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:
BIN
studies/202512_GenesysCX/.DS_Store
vendored
BIN
studies/202512_GenesysCX/.DS_Store
vendored
Binary file not shown.
@@ -1,51 +0,0 @@
|
||||
# Genesys CX Cloud TEI — December 2025
|
||||
|
||||
Source: Forrester, *The Total Economic Impact™ Of CX Cloud — Cost Savings And
|
||||
Business Benefits Enabled By Genesys And Salesforce* (commissioned by Genesys
|
||||
and Salesforce, December 2025). PDF in `docs/`.
|
||||
|
||||
## Headline (published, 3-yr risk-adjusted PV @ 10%)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Benefits PV | $14,840,638 |
|
||||
| Costs PV | $4,057,170 |
|
||||
| **NPV** | **$10,783,468** |
|
||||
| **ROI** | **266%** |
|
||||
| Payback | ~4 months (computed; not headlined in the study) |
|
||||
|
||||
Composite: global supply company, $2.5B revenue, 10,000 employees, 600 CX
|
||||
agents (400 concurrent licenses), 80,000 weekly interactions @ 12 min.
|
||||
|
||||
## Structure
|
||||
|
||||
4 benefits (legacy retirement ↓5%, self-service savings ↓15%, agent
|
||||
efficiency ↓10%, agent-assist sales ↓5%) and 3 published costs (licenses ↑5%,
|
||||
implementation ↑10% — initial-only, ongoing management ↑10%), **plus one
|
||||
Palladium addition**: `genesys_ai_tokens`, an AI Experience token consumption
|
||||
line the published study omits (it models $0 AI cost while three of four
|
||||
benefits depend on AI). Stored exactly as Athena stores it — a single annual
|
||||
cost value, entered from the Genesys quote in `01_business_case.ipynb` (which
|
||||
includes a sensitivity sweep), with quote details kept in the field notes.
|
||||
Seeded at $0 to reproduce the published totals.
|
||||
|
||||
## Study quirks (documented, handled)
|
||||
|
||||
- p.14 prints implementation initial as $1,304,600; 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.
|
||||
- The initial cost is ~32% of cost PV, so Athena's discount-initial-as-Year-1
|
||||
behaviour shifts ROI to ~277%. Verification matches `ATHENA_EXPECTED`
|
||||
tightly, then reconciles to `PUBLISHED` with this explained delta.
|
||||
|
||||
## Notebooks
|
||||
|
||||
| Notebook | Purpose |
|
||||
|---|---|
|
||||
| `00_provision.ipynb` | Create template + fields + tool in Athena (client/proposal selection), seed, calculate, verify |
|
||||
| `01_business_case.ipynb` | Working business case + Genesys AI token quantity × price sensitivity |
|
||||
|
||||
Env keys are study-scoped: `PALLADIUM_GENESYSCX_REPORT_PUBLIC_ID`,
|
||||
`PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID`, `PALLADIUM_GENESYSCX_PROPOSAL_ID` /
|
||||
`PALLADIUM_GENESYSCX_ENGAGEMENT_ID`.
|
||||
@@ -1,38 +0,0 @@
|
||||
"""
|
||||
Study configuration for the Genesys CX Cloud TEI (Forrester, December 2025).
|
||||
|
||||
Env keys are *study-scoped* (PALLADIUM_GENESYSCX_*) so this study can coexist
|
||||
with the Amazon Connect tool IDs in the same .env. 00_provision.ipynb writes
|
||||
them for you.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
#: Human-friendly study identifier — used in export metadata + filenames.
|
||||
STUDY_SLUG = "202512_GenesysCX"
|
||||
|
||||
|
||||
def _int_env(name: str) -> int | None:
|
||||
raw = os.getenv(name, "").strip()
|
||||
return int(raw) if raw else None
|
||||
|
||||
|
||||
#: TEI Report template public_id (12-char short UUID).
|
||||
REPORT_PUBLIC_ID: str = os.getenv("PALLADIUM_GENESYSCX_REPORT_PUBLIC_ID", "")
|
||||
|
||||
#: TEI Tool instance public_id.
|
||||
TOOL_PUBLIC_ID: str = os.getenv("PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID", "")
|
||||
|
||||
#: Default discount rate used for local validation of the study numbers.
|
||||
DISCOUNT_RATE = 0.10
|
||||
|
||||
#: Analysis horizon (years).
|
||||
ANALYSIS_YEARS = 3
|
||||
|
||||
#: Athena Proposal PK (a TEI tool attaches to a Proposal OR an Engagement).
|
||||
PROPOSAL_ID: int | None = _int_env("PALLADIUM_GENESYSCX_PROPOSAL_ID")
|
||||
|
||||
#: Athena Engagement PK (alternative attachment point).
|
||||
ENGAGEMENT_ID: int | None = _int_env("PALLADIUM_GENESYSCX_ENGAGEMENT_ID")
|
||||
@@ -1,934 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "41520e77",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 00 · Provision — Genesys CX Cloud TEI in Athena\n",
|
||||
"\n",
|
||||
"Source study: Forrester, *The Total Economic Impact™ Of CX Cloud* (Genesys +\n",
|
||||
"Salesforce, December 2025). Published headline: **NPV \\$10.78M · ROI 266%**.\n",
|
||||
"\n",
|
||||
"This notebook creates everything the study needs in the Athena sandbox:\n",
|
||||
"\n",
|
||||
"1. **Report template** *CX Cloud (Genesys + Salesforce) 2025* + **field definitions** — 4 benefits, 3 published costs, **plus the `genesys_ai_tokens` consumption line the published study omits**\n",
|
||||
"2. **Client selection** from the CRM (profile pulled, no re-entry)\n",
|
||||
"3. **Attachment** to a Proposal or Engagement\n",
|
||||
"4. **Seed values** + server-side **calculation**\n",
|
||||
"5. **Two-tier verification**: exact match vs Athena-methodology expectations, then reconciliation to the published totals (explained Year-0 discounting delta)\n",
|
||||
"6. Persists study-scoped IDs (`PALLADIUM_GENESYSCX_*`) to `.env`\n",
|
||||
"\n",
|
||||
"Safe to re-run — every step finds existing objects before creating new ones."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 1,
|
||||
"id": "1b6f1117",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"✅ Athena connected — https://athena.ouranos.helu.ca (2 report templates visible)\n",
|
||||
"📁 Study: 202512_GenesysCX\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"import sys, pathlib # path shim: works on a fresh kernel\n",
|
||||
"for _p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:\n",
|
||||
" if (_p / \"pyproject.toml\").exists():\n",
|
||||
" sys.path.insert(0, str(_p)); break\n",
|
||||
"\n",
|
||||
"import pandas as pd\n",
|
||||
"from core.bootstrap import init, update_env\n",
|
||||
"\n",
|
||||
"pal = init(study=\"202512_GenesysCX\")\n",
|
||||
"client, seed, config = pal.client, pal.seed_data, pal.config\n",
|
||||
"assert pal.connection.get(\"status\") == \"ok\", \"Fix the connection first → 00_setup.ipynb\""
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "c1f8b6bd",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 1 · Report template (find or create)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 2,
|
||||
"id": "cc81e408",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Found existing report template UCb2hSJprSBx (status: active)\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"REPORT_NAME, VENDOR = \"CX Cloud (Genesys + Salesforce) 2025\", \"Genesys\"\n",
|
||||
"\n",
|
||||
"report = next(\n",
|
||||
" (r for r in client.list_reports()\n",
|
||||
" if r.get(\"name\") == REPORT_NAME and r.get(\"vendor\") == VENDOR),\n",
|
||||
" None,\n",
|
||||
")\n",
|
||||
"if report is None:\n",
|
||||
" report = client.create_report(\n",
|
||||
" name=REPORT_NAME,\n",
|
||||
" vendor=VENDOR,\n",
|
||||
" version=\"1.0\",\n",
|
||||
" description=(\n",
|
||||
" \"Forrester TEI of CX Cloud (Genesys + Salesforce), Dec 2025. \"\n",
|
||||
" \"Includes Palladium's genesys_ai_tokens consumption line, \"\n",
|
||||
" \"which the published study omits.\"\n",
|
||||
" ),\n",
|
||||
" analysis_period_years=seed.ASSUMPTIONS[\"analysis_years\"],\n",
|
||||
" discount_rate=seed.ASSUMPTIONS[\"discount_rate\"],\n",
|
||||
" status=\"draft\",\n",
|
||||
" )\n",
|
||||
" print(f\"Created report template {report['id']}\")\n",
|
||||
"else:\n",
|
||||
" print(f\"Found existing report template {report['id']} (status: {report.get('status')})\")\n",
|
||||
"\n",
|
||||
"REPORT_ID = report[\"id\"]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "e31bbd8b",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 2 · Field definitions\n",
|
||||
"\n",
|
||||
"Same Palladium conventions as the Amazon Connect study: benefit risk\n",
|
||||
"adjustments live on the field; cost values get pushed pre-multiplied by\n",
|
||||
"`(1 + risk_adj)`; Year-0 amounts use companion `*_initial` fields.\n",
|
||||
"The `genesys_ai_tokens` line is seeded \\$0 (reproduces the published study) —\n",
|
||||
"the annual cost gets entered per deal, from the Genesys quote, in\n",
|
||||
"`03_business_case.ipynb`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 3,
|
||||
"id": "55e69828",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"0 fields created, 12 already existed.\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"def field_defs():\n",
|
||||
" defs, sort = [], 0\n",
|
||||
" for b in seed.BENEFITS:\n",
|
||||
" sort += 1\n",
|
||||
" defs.append({\n",
|
||||
" \"table\": \"benefits\",\n",
|
||||
" \"field_key\": b[\"field_key\"],\n",
|
||||
" \"label\": b[\"label\"],\n",
|
||||
" \"description\": b[\"notes\"][:200],\n",
|
||||
" \"field_type\": \"currency\",\n",
|
||||
" \"category\": b[\"category\"],\n",
|
||||
" \"is_annual\": True,\n",
|
||||
" \"risk_adjustment\": str(b[\"risk_adjustment\"]),\n",
|
||||
" \"sort_order\": sort,\n",
|
||||
" \"is_required\": True,\n",
|
||||
" \"source_notes\": b[\"notes\"],\n",
|
||||
" })\n",
|
||||
" for c in seed.COSTS:\n",
|
||||
" sort += 1\n",
|
||||
" defs.append({\n",
|
||||
" \"table\": \"costs\",\n",
|
||||
" \"field_key\": c[\"field_key\"],\n",
|
||||
" \"label\": c[\"label\"],\n",
|
||||
" \"description\": c[\"notes\"][:200],\n",
|
||||
" \"field_type\": \"currency\",\n",
|
||||
" \"category\": c[\"category\"],\n",
|
||||
" \"is_annual\": True,\n",
|
||||
" \"risk_adjustment\": \"0\", # cost risk adj applied client-side\n",
|
||||
" \"sort_order\": sort,\n",
|
||||
" \"is_required\": False,\n",
|
||||
" \"source_notes\": c[\"notes\"],\n",
|
||||
" })\n",
|
||||
" sort += 1\n",
|
||||
" defs.append({\n",
|
||||
" \"table\": \"costs\",\n",
|
||||
" \"field_key\": f\"{c['field_key']}_initial\",\n",
|
||||
" \"label\": f\"{c['label']} — initial (Year 0)\",\n",
|
||||
" \"description\": \"One-time Year-0 amount (companion field).\",\n",
|
||||
" \"field_type\": \"currency\",\n",
|
||||
" \"category\": c[\"category\"],\n",
|
||||
" \"is_annual\": False,\n",
|
||||
" \"risk_adjustment\": \"0\",\n",
|
||||
" \"sort_order\": sort,\n",
|
||||
" \"is_required\": False,\n",
|
||||
" \"source_notes\": \"Year-0 lump sum; Athena treats non-annual values as Year 1.\",\n",
|
||||
" })\n",
|
||||
" return defs\n",
|
||||
"\n",
|
||||
"existing = {f[\"field_key\"] for f in client.list_fields(REPORT_ID)}\n",
|
||||
"created = 0\n",
|
||||
"for d in field_defs():\n",
|
||||
" if d[\"field_key\"] not in existing:\n",
|
||||
" client.create_field(REPORT_ID, d)\n",
|
||||
" created += 1\n",
|
||||
"print(f\"{created} fields created, {len(existing)} already existed.\")\n",
|
||||
"\n",
|
||||
"if report.get(\"status\") == \"draft\":\n",
|
||||
" client.update_report(REPORT_ID, status=\"active\")\n",
|
||||
" print(\"Report template activated.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "96b360d3",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 3 · Select the client"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 4,
|
||||
"id": "5a0a701f",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>id</th>\n",
|
||||
" <th>name</th>\n",
|
||||
" <th>vertical</th>\n",
|
||||
" <th>client_type</th>\n",
|
||||
" <th>employee_count</th>\n",
|
||||
" <th>contact_center_agent_count</th>\n",
|
||||
" <th>supervisor_count</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>2</td>\n",
|
||||
" <td>Global Guardian Insurance</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>For-Profit</td>\n",
|
||||
" <td>12000</td>\n",
|
||||
" <td>2500</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>1</th>\n",
|
||||
" <td>3</td>\n",
|
||||
" <td>Eudaimonix</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>For-Profit</td>\n",
|
||||
" <td>1500</td>\n",
|
||||
" <td>300</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>2</th>\n",
|
||||
" <td>4</td>\n",
|
||||
" <td>Aetherium Forge</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" <td>For-Profit</td>\n",
|
||||
" <td>500</td>\n",
|
||||
" <td>42</td>\n",
|
||||
" <td>None</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" id name vertical client_type employee_count \\\n",
|
||||
"0 2 Global Guardian Insurance None For-Profit 12000 \n",
|
||||
"1 3 Eudaimonix None For-Profit 1500 \n",
|
||||
"2 4 Aetherium Forge None For-Profit 500 \n",
|
||||
"\n",
|
||||
" contact_center_agent_count supervisor_count \n",
|
||||
"0 2500 None \n",
|
||||
"1 300 None \n",
|
||||
"2 42 None "
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"CLIENT_SEARCH = \"\" # e.g. \"Acme\" — empty lists everyone\n",
|
||||
"\n",
|
||||
"clients = client.list_clients(search=CLIENT_SEARCH or None)\n",
|
||||
"if clients:\n",
|
||||
" display(pd.DataFrame(clients)[\n",
|
||||
" [c for c in (\"id\", \"name\", \"vertical\", \"client_type\", \"employee_count\",\n",
|
||||
" \"contact_center_agent_count\", \"supervisor_count\")\n",
|
||||
" if c in clients[0]]\n",
|
||||
" ])\n",
|
||||
"else:\n",
|
||||
" print(\"No clients found — create one in the Athena UI (Orbit → Clients) and re-run.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 5,
|
||||
"id": "1e375b54",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>Global Guardian Insurance</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>id</th>\n",
|
||||
" <td>2</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>name</th>\n",
|
||||
" <td>Global Guardian Insurance</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>abbreviated_name</th>\n",
|
||||
" <td>GGI</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>vertical</th>\n",
|
||||
" <td>None</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>client_type</th>\n",
|
||||
" <td>For-Profit</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>employee_count</th>\n",
|
||||
" <td>12000</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>revenue</th>\n",
|
||||
" <td>4500000000.0</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>contact_center_agent_count</th>\n",
|
||||
" <td>2500</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>service_desk_agent_count</th>\n",
|
||||
" <td>300</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>supervisor_count</th>\n",
|
||||
" <td>None</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>location_count</th>\n",
|
||||
" <td>120</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" Global Guardian Insurance\n",
|
||||
"id 2\n",
|
||||
"name Global Guardian Insurance\n",
|
||||
"abbreviated_name GGI\n",
|
||||
"vertical None\n",
|
||||
"client_type For-Profit\n",
|
||||
"employee_count 12000\n",
|
||||
"revenue 4500000000.0\n",
|
||||
"contact_center_agent_count 2500\n",
|
||||
"service_desk_agent_count 300\n",
|
||||
"supervisor_count None\n",
|
||||
"location_count 120"
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"CRM agent count: 2500 (composite: 600) — indicative scale 4.17×\n",
|
||||
"CRM revenue: $4,500,000,000 (composite: $2,500,000,000)\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"CLIENT_ID = 2 # ← set from the `id` column above, or leave for auto-pick\n",
|
||||
"\n",
|
||||
"if CLIENT_ID is None and len(clients) == 1:\n",
|
||||
" CLIENT_ID = clients[0][\"id\"]\n",
|
||||
" print(f\"Auto-selected the only client: {clients[0]['name']} (id={CLIENT_ID})\")\n",
|
||||
"assert CLIENT_ID is not None, \"Set CLIENT_ID from the table above and re-run this cell.\"\n",
|
||||
"\n",
|
||||
"profile = client.client_profile(CLIENT_ID)\n",
|
||||
"CLIENT_NAME = profile[\"name\"]\n",
|
||||
"display(pd.DataFrame([profile]).T.rename(columns={0: CLIENT_NAME}))\n",
|
||||
"\n",
|
||||
"# Client data → study scaling levers (no re-entry)\n",
|
||||
"CLIENT_ASSUMPTIONS = dict(seed.ASSUMPTIONS)\n",
|
||||
"if profile.get(\"contact_center_agent_count\"):\n",
|
||||
" CLIENT_ASSUMPTIONS[\"agents_fte\"] = profile[\"contact_center_agent_count\"]\n",
|
||||
" scale = CLIENT_ASSUMPTIONS[\"agents_fte\"] / seed.ASSUMPTIONS[\"agents_fte\"]\n",
|
||||
" print(f\"CRM agent count: {CLIENT_ASSUMPTIONS['agents_fte']} \"\n",
|
||||
" f\"(composite: {seed.ASSUMPTIONS['agents_fte']}) — \"\n",
|
||||
" f\"indicative scale {scale:.2f}×\")\n",
|
||||
"if profile.get(\"revenue\"):\n",
|
||||
" CLIENT_ASSUMPTIONS[\"annual_revenue\"] = float(profile[\"revenue\"])\n",
|
||||
" print(f\"CRM revenue: ${CLIENT_ASSUMPTIONS['annual_revenue']:,.0f} \"\n",
|
||||
" f\"(composite: ${seed.ASSUMPTIONS['annual_revenue']:,.0f})\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2ff83486",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 4 · Pick the attachment — Proposal or Engagement"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 6,
|
||||
"id": "584e01dd",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Proposals for Global Guardian Insurance:\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>id</th>\n",
|
||||
" <th>name</th>\n",
|
||||
" <th>status</th>\n",
|
||||
" <th>opportunity</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>1</td>\n",
|
||||
" <td>Secure Cloud Infrastructure Modernization</td>\n",
|
||||
" <td>Draft</td>\n",
|
||||
" <td>Secure Cloud Infrastructure Modernization</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" id name status \\\n",
|
||||
"0 1 Secure Cloud Infrastructure Modernization Draft \n",
|
||||
"\n",
|
||||
" opportunity \n",
|
||||
"0 Secure Cloud Infrastructure Modernization "
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"proposals = client.proposals_for_client(CLIENT_ID)\n",
|
||||
"engagements = client.engagements_for_client(CLIENT_NAME)\n",
|
||||
"\n",
|
||||
"if proposals:\n",
|
||||
" print(f\"Proposals for {CLIENT_NAME}:\")\n",
|
||||
" display(pd.DataFrame([\n",
|
||||
" {\"id\": p[\"id\"], \"name\": p.get(\"name\"), \"status\": p.get(\"status\"),\n",
|
||||
" \"opportunity\": (p.get(\"opportunity\") or {}).get(\"name\")}\n",
|
||||
" for p in proposals\n",
|
||||
" ]))\n",
|
||||
"if engagements:\n",
|
||||
" print(f\"Engagements for {CLIENT_NAME}:\")\n",
|
||||
" display(pd.DataFrame([\n",
|
||||
" {\"id\": e[\"id\"], \"name\": e.get(\"name\"), \"status\": e.get(\"status\")}\n",
|
||||
" for e in engagements\n",
|
||||
" ]))\n",
|
||||
"if not proposals and not engagements:\n",
|
||||
" print(f\"{CLIENT_NAME} has no proposals or engagements yet — \"\n",
|
||||
" \"the next cell can create a sandbox opportunity + proposal.\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 7,
|
||||
"id": "e04b1676",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Attaching via: {'proposal': 1}\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"# Set exactly ONE (ids from above). Leave both None to auto-pick — a single\n",
|
||||
"# existing option wins; otherwise a sandbox opportunity + proposal is created.\n",
|
||||
"PROPOSAL_ID = config.PROPOSAL_ID # or e.g. 42\n",
|
||||
"ENGAGEMENT_ID = config.ENGAGEMENT_ID # or e.g. 7\n",
|
||||
"\n",
|
||||
"if PROPOSAL_ID is None and ENGAGEMENT_ID is None:\n",
|
||||
" if len(proposals) == 1 and not engagements:\n",
|
||||
" PROPOSAL_ID = proposals[0][\"id\"]\n",
|
||||
" print(f\"Auto-selected proposal {PROPOSAL_ID}: {proposals[0].get('name')}\")\n",
|
||||
" elif len(engagements) == 1 and not proposals:\n",
|
||||
" ENGAGEMENT_ID = engagements[0][\"id\"]\n",
|
||||
" print(f\"Auto-selected engagement {ENGAGEMENT_ID}: {engagements[0].get('name')}\")\n",
|
||||
" elif not proposals and not engagements:\n",
|
||||
" opp = client.create_opportunity(\n",
|
||||
" name=f\"{CLIENT_NAME} — CX Cloud Modernization (sandbox)\",\n",
|
||||
" client_id=CLIENT_ID,\n",
|
||||
" description=\"Created by Palladium 00_provision for the Genesys CX Cloud TEI.\",\n",
|
||||
" )\n",
|
||||
" prop = client.create_proposal(\n",
|
||||
" name=f\"{CLIENT_NAME} — Genesys CX Cloud TEI (sandbox)\",\n",
|
||||
" opportunity_id=opp[\"id\"],\n",
|
||||
" status=\"Draft\",\n",
|
||||
" )\n",
|
||||
" PROPOSAL_ID = prop[\"id\"]\n",
|
||||
" print(f\"Created opportunity {opp['id']} and proposal {PROPOSAL_ID} for {CLIENT_NAME}.\")\n",
|
||||
" else:\n",
|
||||
" raise SystemExit(\"Multiple options — set PROPOSAL_ID or ENGAGEMENT_ID above and re-run.\")\n",
|
||||
"\n",
|
||||
"assert (PROPOSAL_ID is None) != (ENGAGEMENT_ID is None), \\\n",
|
||||
" \"Set exactly one of PROPOSAL_ID / ENGAGEMENT_ID.\"\n",
|
||||
"attach = {\"proposal\": PROPOSAL_ID} if PROPOSAL_ID else {\"engagement\": ENGAGEMENT_ID}\n",
|
||||
"print(f\"Attaching via: {attach}\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "2b4fcb45",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 5 · Tool instance & seed the published values"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 8,
|
||||
"id": "0655d1fc",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Found existing tool 3rzDgVdsjhVv (status: draft)\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"from core.tei_client import AthenaAPIError\n",
|
||||
"\n",
|
||||
"def _report_id_of(t):\n",
|
||||
" r = t.get(\"report\")\n",
|
||||
" return r.get(\"id\") if isinstance(r, dict) else r\n",
|
||||
"\n",
|
||||
"def _matches_attachment(t):\n",
|
||||
" if PROPOSAL_ID is not None:\n",
|
||||
" opp = t.get(\"opportunity\") or {}\n",
|
||||
" return t.get(\"proposal\") == PROPOSAL_ID or opp.get(\"proposal_id\") == PROPOSAL_ID\n",
|
||||
" eng = t.get(\"engagement\")\n",
|
||||
" eng_id = eng.get(\"id\") if isinstance(eng, dict) else eng\n",
|
||||
" return eng_id == ENGAGEMENT_ID\n",
|
||||
"\n",
|
||||
"candidates = [t for t in client.list_tools() if _report_id_of(t) == REPORT_ID]\n",
|
||||
"tool = next((t for t in candidates if _matches_attachment(t)),\n",
|
||||
" candidates[0] if len(candidates) == 1 else None)\n",
|
||||
"\n",
|
||||
"if tool is None:\n",
|
||||
" try:\n",
|
||||
" tool = client.create_tool(\n",
|
||||
" report_public_id=REPORT_ID,\n",
|
||||
" name=f\"{CLIENT_NAME} — Genesys CX Cloud TEI\",\n",
|
||||
" **attach,\n",
|
||||
" )\n",
|
||||
" print(f\"Created tool {tool['id']} attached to {attach}\")\n",
|
||||
" except AthenaAPIError as e:\n",
|
||||
" if e.status_code == 409: # DUPLICATE_INSTANCE\n",
|
||||
" raise SystemExit(\n",
|
||||
" \"An active tool already exists for this report + attachment. \"\n",
|
||||
" \"Find it with client.list_tools() or pick a different proposal/engagement.\"\n",
|
||||
" ) from e\n",
|
||||
" raise\n",
|
||||
"else:\n",
|
||||
" print(f\"Found existing tool {tool['id']} (status: {tool.get('status')})\")\n",
|
||||
"\n",
|
||||
"TOOL_ID = tool[\"id\"]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 9,
|
||||
"id": "86443d76",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Pushed values for 8 fields (genesys_ai_tokens seeded at $0 — published-study baseline).\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"payload = []\n",
|
||||
"for b in seed.BENEFITS: # nominal; Athena risk-adjusts via the field definition\n",
|
||||
" payload.append({\n",
|
||||
" \"field_key\": b[\"field_key\"],\n",
|
||||
" \"year_values\": b[\"year_values\"],\n",
|
||||
" \"notes\": b[\"notes\"],\n",
|
||||
" })\n",
|
||||
"for c in seed.COSTS: # risk-adjusted UP client-side (Forrester methodology)\n",
|
||||
" factor = 1 + c[\"risk_adjustment\"]\n",
|
||||
" payload.append({\n",
|
||||
" \"field_key\": c[\"field_key\"],\n",
|
||||
" \"year_values\": {y: round(v * factor, 2) for y, v in c[\"year_values\"].items()},\n",
|
||||
" \"initial\": round(c[\"initial\"] * factor, 2),\n",
|
||||
" \"notes\": c[\"notes\"],\n",
|
||||
" })\n",
|
||||
"\n",
|
||||
"client.update_values(TOOL_ID, payload)\n",
|
||||
"print(f\"Pushed values for {len(payload)} fields \"\n",
|
||||
" f\"(genesys_ai_tokens seeded at $0 — published-study baseline).\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "509b52be",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 6 · Calculate & verify\n",
|
||||
"\n",
|
||||
"**Tier 1 — pipeline correctness:** Athena must match `seed.ATHENA_EXPECTED`\n",
|
||||
"(the published model re-discounted under Athena's Year-0-as-Year-1 rule)\n",
|
||||
"within 0.5%.\n",
|
||||
"\n",
|
||||
"**Tier 2 — reconciliation:** show Athena vs the published totals. The\n",
|
||||
"implementation initial (\\$1.309M, ~32% of cost PV) is discounted by Athena\n",
|
||||
"but not by Forrester, so costs PV reads ~\\$119k lower and ROI ~11pp higher\n",
|
||||
"than published. That delta is methodology, not data error."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 10,
|
||||
"id": "0728b42e",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"════════════════════════════════════════════════════════\n",
|
||||
" TEI Financial Summary\n",
|
||||
"════════════════════════════════════════════════════════\n",
|
||||
" Total Benefits (PV): $ 14,840,637\n",
|
||||
" Total Costs (PV): $ 3,938,170\n",
|
||||
"────────────────────────────────────────────────────────\n",
|
||||
" Net Present Value: $ 10,902,466\n",
|
||||
" ROI: 277%\n",
|
||||
" Payback: 4.0 months\n",
|
||||
"════════════════════════════════════════════════════════\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"summary = client.calculate(TOOL_ID)\n",
|
||||
"client.print_summary(TOOL_ID)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 11,
|
||||
"id": "aba8fc21",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"data": {
|
||||
"text/html": [
|
||||
"<div>\n",
|
||||
"<style scoped>\n",
|
||||
" .dataframe tbody tr th:only-of-type {\n",
|
||||
" vertical-align: middle;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe tbody tr th {\n",
|
||||
" vertical-align: top;\n",
|
||||
" }\n",
|
||||
"\n",
|
||||
" .dataframe thead th {\n",
|
||||
" text-align: right;\n",
|
||||
" }\n",
|
||||
"</style>\n",
|
||||
"<table border=\"1\" class=\"dataframe\">\n",
|
||||
" <thead>\n",
|
||||
" <tr style=\"text-align: right;\">\n",
|
||||
" <th></th>\n",
|
||||
" <th>metric</th>\n",
|
||||
" <th>published (Forrester)</th>\n",
|
||||
" <th>expected (Athena methodology)</th>\n",
|
||||
" <th>athena actual</th>\n",
|
||||
" <th>vs expected</th>\n",
|
||||
" </tr>\n",
|
||||
" </thead>\n",
|
||||
" <tbody>\n",
|
||||
" <tr>\n",
|
||||
" <th>0</th>\n",
|
||||
" <td>total_benefits_pv</td>\n",
|
||||
" <td>14,840,638</td>\n",
|
||||
" <td>14,840,640</td>\n",
|
||||
" <td>14,840,637</td>\n",
|
||||
" <td>-0.00%</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>1</th>\n",
|
||||
" <td>total_costs_pv</td>\n",
|
||||
" <td>4,057,170</td>\n",
|
||||
" <td>3,938,170</td>\n",
|
||||
" <td>3,938,170</td>\n",
|
||||
" <td>+0.00%</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>2</th>\n",
|
||||
" <td>net_present_value</td>\n",
|
||||
" <td>10,783,468</td>\n",
|
||||
" <td>10,902,470</td>\n",
|
||||
" <td>10,902,466</td>\n",
|
||||
" <td>-0.00%</td>\n",
|
||||
" </tr>\n",
|
||||
" <tr>\n",
|
||||
" <th>3</th>\n",
|
||||
" <td>roi_percentage</td>\n",
|
||||
" <td>266</td>\n",
|
||||
" <td>277</td>\n",
|
||||
" <td>277</td>\n",
|
||||
" <td>+0.01%</td>\n",
|
||||
" </tr>\n",
|
||||
" </tbody>\n",
|
||||
"</table>\n",
|
||||
"</div>"
|
||||
],
|
||||
"text/plain": [
|
||||
" metric published (Forrester) expected (Athena methodology) \\\n",
|
||||
"0 total_benefits_pv 14,840,638 14,840,640 \n",
|
||||
"1 total_costs_pv 4,057,170 3,938,170 \n",
|
||||
"2 net_present_value 10,783,468 10,902,470 \n",
|
||||
"3 roi_percentage 266 277 \n",
|
||||
"\n",
|
||||
" athena actual vs expected \n",
|
||||
"0 14,840,637 -0.00% \n",
|
||||
"1 3,938,170 +0.00% \n",
|
||||
"2 10,902,466 -0.00% \n",
|
||||
"3 277 +0.01% "
|
||||
]
|
||||
},
|
||||
"metadata": {},
|
||||
"output_type": "display_data"
|
||||
},
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Payback: 4 months (expected ≈ 4)\n",
|
||||
"✅ Tier 1 passed — pipeline reproduces the study under Athena's discounting.\n",
|
||||
"ℹ️ Tier 2: published ROI 266% vs Athena ~277% — explained Year-0 delta (see above).\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"rows, ok = [], True\n",
|
||||
"for key in (\"total_benefits_pv\", \"total_costs_pv\", \"net_present_value\", \"roi_percentage\"):\n",
|
||||
" actual = float(summary.get(key) or 0)\n",
|
||||
" expected = seed.ATHENA_EXPECTED[key]\n",
|
||||
" published = seed.PUBLISHED[key]\n",
|
||||
" diff = (actual - expected) / expected\n",
|
||||
" rows.append({\n",
|
||||
" \"metric\": key,\n",
|
||||
" \"published (Forrester)\": f\"{published:,.0f}\",\n",
|
||||
" \"expected (Athena methodology)\": f\"{expected:,.0f}\",\n",
|
||||
" \"athena actual\": f\"{actual:,.0f}\",\n",
|
||||
" \"vs expected\": f\"{diff:+.2%}\",\n",
|
||||
" })\n",
|
||||
" ok &= abs(diff) <= 0.005\n",
|
||||
"\n",
|
||||
"display(pd.DataFrame(rows))\n",
|
||||
"print(f\"Payback: {summary.get('payback_period_months')} months (expected ≈ 4)\")\n",
|
||||
"assert ok, \"Athena diverged >0.5% from its own expected methodology — investigate.\"\n",
|
||||
"print(\"✅ Tier 1 passed — pipeline reproduces the study under Athena's discounting.\")\n",
|
||||
"print(\"ℹ️ Tier 2: published ROI 266% vs Athena ~277% — explained Year-0 delta (see above).\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "181c7b55",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## 7 · Save a baseline version & persist IDs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": 12,
|
||||
"id": "d8102590",
|
||||
"metadata": {},
|
||||
"outputs": [
|
||||
{
|
||||
"name": "stdout",
|
||||
"output_type": "stream",
|
||||
"text": [
|
||||
"Saved to /Users/robert/git/palladium/.env:\n",
|
||||
" PALLADIUM_GENESYSCX_REPORT_PUBLIC_ID=UCb2hSJprSBx\n",
|
||||
" PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID=3rzDgVdsjhVv\n",
|
||||
" PALLADIUM_GENESYSCX_PROPOSAL_ID=1\n",
|
||||
"\n",
|
||||
"Next → 01_benefits.ipynb (walk through the four Forrester benefits).\n"
|
||||
]
|
||||
}
|
||||
],
|
||||
"source": [
|
||||
"if not client.list_versions(TOOL_ID):\n",
|
||||
" client.save_version(TOOL_ID, note=(\n",
|
||||
" \"Baseline — published Forrester CX Cloud TEI figures (Dec 2025). \"\n",
|
||||
" \"genesys_ai_tokens at $0 per the published study; set the annual \"\n",
|
||||
" \"cost from the Genesys quote in 03_business_case before client use.\"\n",
|
||||
" ))\n",
|
||||
" print(\"Saved version 1 (baseline).\")\n",
|
||||
"\n",
|
||||
"ids = {\n",
|
||||
" \"PALLADIUM_GENESYSCX_REPORT_PUBLIC_ID\": REPORT_ID,\n",
|
||||
" \"PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID\": TOOL_ID,\n",
|
||||
"}\n",
|
||||
"if PROPOSAL_ID is not None:\n",
|
||||
" ids[\"PALLADIUM_GENESYSCX_PROPOSAL_ID\"] = str(PROPOSAL_ID)\n",
|
||||
"if ENGAGEMENT_ID is not None:\n",
|
||||
" ids[\"PALLADIUM_GENESYSCX_ENGAGEMENT_ID\"] = str(ENGAGEMENT_ID)\n",
|
||||
"\n",
|
||||
"env_path = update_env(**ids)\n",
|
||||
"print(f\"Saved to {env_path}:\")\n",
|
||||
"for k, v in ids.items():\n",
|
||||
" print(f\" {k}={v}\")\n",
|
||||
"print(\"\\nNext → 01_benefits.ipynb (walk through the four Forrester benefits).\")"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "4fc81c99-f073-486a-9f65-f207e96e59cd",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "13acdc34-71f6-4220-8675-4e1527cb8e39",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,382 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-intro",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 03 \u2014 Business Case\n",
|
||||
"\n",
|
||||
"Combine the benefits and costs into the consolidated TEI summary,\n",
|
||||
"render the cash-flow exhibit, run scenario analysis, **and price the\n",
|
||||
"Genesys AI Experience tokens line that the published study omits**.\n",
|
||||
"This notebook should reproduce the headline numbers from the PDF\n",
|
||||
"Financial Summary:\n",
|
||||
"\n",
|
||||
"* **NPV \\$10.78M \u2022 ROI 266% \u2022 Payback \u2248 4 months**\n",
|
||||
"\n",
|
||||
"It then exposes a sensitivity sweep for the AI-tokens annual cost so\n",
|
||||
"you can see exactly what an honest deal looks like before sending it\n",
|
||||
"to a client."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "03-bootstrap",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys, pathlib # path shim: works on a fresh kernel\n",
|
||||
"for _p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:\n",
|
||||
" if (_p / \"pyproject.toml\").exists():\n",
|
||||
" sys.path.insert(0, str(_p)); break\n",
|
||||
"\n",
|
||||
"from core.bootstrap import init\n",
|
||||
"\n",
|
||||
"pal = init(study=\"202512_GenesysCX\")\n",
|
||||
"client, seed, config = pal.client, pal.seed_data, pal.config\n",
|
||||
"\n",
|
||||
"STUDY = pal.root / 'studies' / '202512_GenesysCX'\n",
|
||||
"ROOT = pal.root\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-imports",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from core.export.report_data import _compute_summary\n",
|
||||
"from core.notebook_helpers import charts, display, tables"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-summary",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Local summary (no Athena round-trip)\n",
|
||||
"\n",
|
||||
"Compute the moderate-case TEI summary directly from `seed_data` so the\n",
|
||||
"notebook produces results even before the Athena tool is provisioned.\n",
|
||||
"Headline numbers should match the published study."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-summary",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"summary = _compute_summary(\n",
|
||||
" seed.BENEFITS,\n",
|
||||
" seed.COSTS,\n",
|
||||
" config.DISCOUNT_RATE,\n",
|
||||
" config.ANALYSIS_YEARS,\n",
|
||||
")\n",
|
||||
"# `_compute_summary` returns roi_pct; expose it as `roi` for kpi_cards.\n",
|
||||
"summary['roi'] = summary.get('roi_pct')\n",
|
||||
"display.kpi_cards(summary, title='Forrester composite \u2014 moderate case')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-cashflow-table",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"df_cash = tables.cashflow_table(summary)\n",
|
||||
"df_cash.style.format({c: '${:,.0f}' for c in df_cash.columns if c != 'Year'})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-cashflow",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Cash flow chart\n",
|
||||
"\n",
|
||||
"Mirrors the Forrester *Cash Flow Chart* exhibit: stacked benefits/costs\n",
|
||||
"by year + cumulative-net line. Payback hits inside Year 1."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-cashflow-chart",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"charts.cashflow_chart(\n",
|
||||
" summary['yearly_breakdown'],\n",
|
||||
" initial_cost=summary.get('initial_costs', 0),\n",
|
||||
").show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-waterfall",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Waterfall: Benefits PV \u2192 Costs PV \u2192 NPV"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-waterfall",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"charts.waterfall([\n",
|
||||
" ('Benefits PV', summary['total_benefits_pv']),\n",
|
||||
" ('Costs PV', -summary['total_costs_pv']),\n",
|
||||
" ('NPV', summary['npv']),\n",
|
||||
"]).show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-scenarios",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Scenario analysis\n",
|
||||
"\n",
|
||||
"Apply the default Palladium multipliers (see `core.calculations.SCENARIOS`):\n",
|
||||
"\n",
|
||||
"* **Conservative** \u2014 lower adoption, higher risk on benefits / lower on costs\n",
|
||||
"* **Moderate** \u2014 base case (= the published Forrester study)\n",
|
||||
"* **Aggressive** \u2014 full adoption, lower risk on benefits / higher on costs"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-scenarios",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"from core.calculations import apply_scenario\n",
|
||||
"import pandas as pd\n",
|
||||
"\n",
|
||||
"scenario_summaries = {}\n",
|
||||
"for name in ('conservative', 'moderate', 'aggressive'):\n",
|
||||
" sb = apply_scenario(seed.BENEFITS, name, table='benefits')\n",
|
||||
" sc = apply_scenario(seed.COSTS, name, table='costs')\n",
|
||||
" scenario_summaries[name] = _compute_summary(sb, sc, config.DISCOUNT_RATE, config.ANALYSIS_YEARS)\n",
|
||||
"\n",
|
||||
"scen_df = pd.DataFrame([\n",
|
||||
" {\n",
|
||||
" 'Scenario': k,\n",
|
||||
" 'Benefits PV': v['total_benefits_pv'],\n",
|
||||
" 'Costs PV': v['total_costs_pv'],\n",
|
||||
" 'NPV': v['npv'],\n",
|
||||
" 'ROI %': v['roi_pct'],\n",
|
||||
" 'Payback (mo)': round(v['payback_months'], 1) if v['payback_months'] is not None else None,\n",
|
||||
" }\n",
|
||||
" for k, v in scenario_summaries.items()\n",
|
||||
"])\n",
|
||||
"scen_df.style.format({\n",
|
||||
" 'Benefits PV': '${:,.0f}', 'Costs PV': '${:,.0f}', 'NPV': '${:,.0f}', 'ROI %': '{:,.0f}%'\n",
|
||||
"})"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-scenario-chart",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"charts.scenario_comparison(scenario_summaries).show()"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-tokens-intro",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Genesys AI Experience tokens \u2014 annual cost\n",
|
||||
"\n",
|
||||
"Token pricing is tiered, capability-dependent, and deal-specific \u2014\n",
|
||||
"Athena stores a single annual cost value per line, and so does the\n",
|
||||
"seed. Enter the negotiated annual cost from the Genesys quote here.\n",
|
||||
"Quote details (volume, unit price, tier) go into the field notes for\n",
|
||||
"the audit trail.\n",
|
||||
"\n",
|
||||
"For sizing context, the study's own drivers imply roughly **1,040,000**\n",
|
||||
"self-service interactions/yr and **3,120,000** agent-assisted\n",
|
||||
"interactions/yr would draw tokens \u2014 bring the actual figure from the\n",
|
||||
"quote, not a derivation."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-token-input",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"# \u2500\u2500 Deal inputs \u2500\u2500\n",
|
||||
"AI_TOKEN_ANNUAL_COST = 0.0 # $/yr from the Genesys quote \u2014 0 reproduces the published study\n",
|
||||
"AI_TOKEN_QUOTE_NOTE = \"\" # e.g. \"Quote #1234: 4.2M tokens/yr @ $0.05, tier 2 commit\"\n",
|
||||
"\n",
|
||||
"print(f'AI token line: ${AI_TOKEN_ANNUAL_COST:,.0f}/yr')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-sensitivity",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Sensitivity \u2014 what the AI line does to NPV and ROI\n",
|
||||
"\n",
|
||||
"An annual cost `\u0394` raises Costs PV by `\u0394 \u00d7 2.4869` (the 3-year, 10%\n",
|
||||
"annuity factor) and lowers NPV by the same amount. The sweep below\n",
|
||||
"shows where the deal stops being attractive \u2014 and quantifies how much\n",
|
||||
"of the published 266% ROI was *contingent on Forrester modelling \\$0\n",
|
||||
"of token spend*."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-sensitivity",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"ANNUITY = sum(1 / 1.10**n for n in (1, 2, 3)) # 2.4869\n",
|
||||
"\n",
|
||||
"base_benefits_pv = float(summary['total_benefits_pv'])\n",
|
||||
"base_costs_pv = float(summary['total_costs_pv'])\n",
|
||||
"\n",
|
||||
"sweep = [0, 100_000, 250_000, 500_000, 750_000, 1_000_000, 1_500_000, 2_000_000]\n",
|
||||
"if AI_TOKEN_ANNUAL_COST and AI_TOKEN_ANNUAL_COST not in sweep:\n",
|
||||
" sweep = sorted(sweep + [AI_TOKEN_ANNUAL_COST])\n",
|
||||
"\n",
|
||||
"rows = []\n",
|
||||
"for ai_annual in sweep:\n",
|
||||
" costs_pv = base_costs_pv + ai_annual * ANNUITY\n",
|
||||
" npv_v = base_benefits_pv - costs_pv\n",
|
||||
" roi_pct = (npv_v / costs_pv * 100) if costs_pv else 0\n",
|
||||
" rows.append({\n",
|
||||
" 'AI cost/yr': f\"${ai_annual:,.0f}\" + (' \u2190 your input' if ai_annual == AI_TOKEN_ANNUAL_COST and ai_annual else ''),\n",
|
||||
" 'Costs PV': f'${costs_pv:,.0f}',\n",
|
||||
" 'NPV': f'${npv_v:,.0f}',\n",
|
||||
" 'ROI': f'{roi_pct:,.0f}%',\n",
|
||||
" })\n",
|
||||
"\n",
|
||||
"pd.DataFrame(rows)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-tokens-push",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"### Push the AI-tokens cost to Athena\n",
|
||||
"\n",
|
||||
"When `AI_TOKEN_ANNUAL_COST` is set and `TOOL_PUBLIC_ID` exists, write\n",
|
||||
"the annual cost into the `genesys_ai_tokens` field, with the quote\n",
|
||||
"details preserved in the field notes."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-tokens-push",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"PUSH = False # \u2190 set True once AI_TOKEN_ANNUAL_COST is final\n",
|
||||
"\n",
|
||||
"if PUSH and config.TOOL_PUBLIC_ID:\n",
|
||||
" from core.tei_client import TEIClient\n",
|
||||
"\n",
|
||||
" note = (\n",
|
||||
" f'AI Experience tokens: ${AI_TOKEN_ANNUAL_COST:,.0f}/yr. '\n",
|
||||
" + (f'{AI_TOKEN_QUOTE_NOTE} ' if AI_TOKEN_QUOTE_NOTE else '')\n",
|
||||
" + 'Line absent from the published Forrester study.'\n",
|
||||
" )\n",
|
||||
" client = TEIClient()\n",
|
||||
" client.update_values(config.TOOL_PUBLIC_ID, [{\n",
|
||||
" 'field_key': 'genesys_ai_tokens',\n",
|
||||
" 'year_values': {'1': round(AI_TOKEN_ANNUAL_COST, 2),\n",
|
||||
" '2': round(AI_TOKEN_ANNUAL_COST, 2),\n",
|
||||
" '3': round(AI_TOKEN_ANNUAL_COST, 2)},\n",
|
||||
" 'notes': note,\n",
|
||||
" }])\n",
|
||||
" client.calculate(config.TOOL_PUBLIC_ID)\n",
|
||||
" client.print_summary(config.TOOL_PUBLIC_ID)\n",
|
||||
" client.save_version(config.TOOL_PUBLIC_ID, note=f'AI token cost set: {note}')\n",
|
||||
" display.alert('Pushed, recalculated, and versioned.', 'success')\n",
|
||||
"else:\n",
|
||||
" display.alert('Dry run \u2014 set <code>PUSH = True</code> and ensure '\n",
|
||||
" '<code>TOOL_PUBLIC_ID</code> is configured to write to Athena.', 'info')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-crosscheck",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Cross-check vs Athena (optional)\n",
|
||||
"\n",
|
||||
"When `TOOL_PUBLIC_ID` is set, ask Athena to recalculate the summary on\n",
|
||||
"the server side and confirm it matches our local computation (modulo\n",
|
||||
"the documented Year-0 discounting delta \u2014 see `02_costs.ipynb`)."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g3-code-crosscheck",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if config.TOOL_PUBLIC_ID:\n",
|
||||
" from core.tei_client import TEIClient\n",
|
||||
"\n",
|
||||
" client = TEIClient()\n",
|
||||
" client.calculate(config.TOOL_PUBLIC_ID)\n",
|
||||
" server_summary = client.get_summary(config.TOOL_PUBLIC_ID)\n",
|
||||
" display.kpi_cards(server_summary, title='Athena server-side summary')\n",
|
||||
"else:\n",
|
||||
" display.alert('Set TOOL_PUBLIC_ID to compare Athena vs local.', 'info')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g3-md-next",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Continue with [`04_export.ipynb`](04_export.ipynb) \u2192"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g4-md-intro",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"# 04 \u2014 Export for the report pipeline\n",
|
||||
"\n",
|
||||
"Build the structured JSON envelope consumed by the html2docx report\n",
|
||||
"generation pipeline (Peitho). Output goes to `exports/export.json`."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "04-bootstrap",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import sys, pathlib # path shim: works on a fresh kernel\n",
|
||||
"for _p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:\n",
|
||||
" if (_p / \"pyproject.toml\").exists():\n",
|
||||
" sys.path.insert(0, str(_p)); break\n",
|
||||
"\n",
|
||||
"from core.bootstrap import init\n",
|
||||
"\n",
|
||||
"pal = init(study=\"202512_GenesysCX\")\n",
|
||||
"client, seed, config = pal.client, pal.seed_data, pal.config\n",
|
||||
"\n",
|
||||
"STUDY = pal.root / 'studies' / '202512_GenesysCX'\n",
|
||||
"ROOT = pal.root\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g4-code-imports",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"import json\n",
|
||||
"from datetime import datetime, timezone\n",
|
||||
"from core import __version__\n",
|
||||
"from core.calculations import apply_scenario\n",
|
||||
"from core.export.report_data import _compute_summary\n",
|
||||
"from core.notebook_helpers import display"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g4-md-build",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Build the envelope\n",
|
||||
"\n",
|
||||
"Two paths:\n",
|
||||
"\n",
|
||||
"* **Live** \u2014 `core.export.build_report_data(client, public_id)` pulls\n",
|
||||
" authoritative values + summary from Athena and stamps it.\n",
|
||||
"* **Local** \u2014 when no `TOOL_PUBLIC_ID` is configured, build the envelope\n",
|
||||
" directly from `seed_data` so this notebook is always runnable."
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g4-code-build",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"if config.TOOL_PUBLIC_ID:\n",
|
||||
" from core.export import build_report_data\n",
|
||||
" from core.tei_client import TEIClient\n",
|
||||
"\n",
|
||||
" client = TEIClient()\n",
|
||||
" envelope = build_report_data(\n",
|
||||
" client,\n",
|
||||
" config.TOOL_PUBLIC_ID,\n",
|
||||
" include_scenarios=True,\n",
|
||||
" study_slug=config.STUDY_SLUG,\n",
|
||||
" )\n",
|
||||
" source = 'live (Athena)'\n",
|
||||
"else:\n",
|
||||
" summary = _compute_summary(\n",
|
||||
" seed.BENEFITS, seed.COSTS, config.DISCOUNT_RATE, config.ANALYSIS_YEARS\n",
|
||||
" )\n",
|
||||
" summary['roi'] = summary.get('roi_pct')\n",
|
||||
" scenarios = {}\n",
|
||||
" for name in ('conservative', 'moderate', 'aggressive'):\n",
|
||||
" sb = apply_scenario(seed.BENEFITS, name, table='benefits')\n",
|
||||
" sc = apply_scenario(seed.COSTS, name, table='costs')\n",
|
||||
" scenarios[name] = _compute_summary(sb, sc, config.DISCOUNT_RATE, config.ANALYSIS_YEARS)\n",
|
||||
" envelope = {\n",
|
||||
" 'metadata': {\n",
|
||||
" 'study_slug': config.STUDY_SLUG,\n",
|
||||
" 'tool_public_id': '',\n",
|
||||
" 'tool_name': 'CX Cloud (Genesys + Salesforce) TEI (local seed)',\n",
|
||||
" 'report_name': 'Total Economic Impact\u2122 Of CX Cloud \u2014 Genesys + Salesforce',\n",
|
||||
" 'report_vendor': 'Genesys',\n",
|
||||
" 'report_version': '1.0',\n",
|
||||
" 'generated_at': datetime.now(timezone.utc).isoformat(),\n",
|
||||
" 'generator': f'palladium core {__version__} (offline)',\n",
|
||||
" },\n",
|
||||
" 'report': {\n",
|
||||
" 'name': 'Total Economic Impact\u2122 Of CX Cloud \u2014 Genesys + Salesforce',\n",
|
||||
" 'vendor': 'Genesys',\n",
|
||||
" 'version': '1.0',\n",
|
||||
" 'discount_rate': config.DISCOUNT_RATE,\n",
|
||||
" 'analysis_period_years': config.ANALYSIS_YEARS,\n",
|
||||
" },\n",
|
||||
" 'values': {'benefits': seed.BENEFITS, 'costs': seed.COSTS},\n",
|
||||
" 'summary': summary,\n",
|
||||
" 'scenarios': scenarios,\n",
|
||||
" 'assumptions': seed.ASSUMPTIONS,\n",
|
||||
" }\n",
|
||||
" source = 'offline seed data'\n",
|
||||
"\n",
|
||||
"display.alert(f'Envelope built from <b>{source}</b>.', 'info')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g4-code-write",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"out_path = STUDY / 'exports' / 'export.json'\n",
|
||||
"out_path.parent.mkdir(parents=True, exist_ok=True)\n",
|
||||
"out_path.write_text(json.dumps(envelope, indent=2, default=str))\n",
|
||||
"size_kb = out_path.stat().st_size / 1024\n",
|
||||
"display.alert(f'Wrote <code>{out_path.relative_to(ROOT)}</code> ({size_kb:.1f} KB).', 'success')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g4-md-shape",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"## Envelope shape\n",
|
||||
"\n",
|
||||
"Top-level keys consumed by the report pipeline:"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
"id": "g4-code-shape",
|
||||
"metadata": {},
|
||||
"outputs": [],
|
||||
"source": [
|
||||
"for key in envelope:\n",
|
||||
" sub = envelope[key]\n",
|
||||
" if isinstance(sub, dict):\n",
|
||||
" print(f' {key}: dict with keys {list(sub.keys())}')\n",
|
||||
" elif isinstance(sub, list):\n",
|
||||
" print(f' {key}: list[{len(sub)}]')\n",
|
||||
" else:\n",
|
||||
" print(f' {key}: {type(sub).__name__}')"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "g4-md-done",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"Done. Hand off `exports/export.json` to **Peitho** / **html2docx** to produce the final Word report.\n",
|
||||
"\n",
|
||||
"**CLI alternative:** `python -m palladium export $PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID -o studies/202512_GenesysCX/exports/export.json`"
|
||||
]
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"kernelspec": {
|
||||
"display_name": "Python 3 (ipykernel)",
|
||||
"language": "python",
|
||||
"name": "python3"
|
||||
},
|
||||
"language_info": {
|
||||
"codemirror_mode": {
|
||||
"name": "ipython",
|
||||
"version": 3
|
||||
},
|
||||
"file_extension": ".py",
|
||||
"mimetype": "text/x-python",
|
||||
"name": "python",
|
||||
"nbconvert_exporter": "python",
|
||||
"pygments_lexer": "ipython3",
|
||||
"version": "3.12.7"
|
||||
}
|
||||
},
|
||||
"nbformat": 4,
|
||||
"nbformat_minor": 5
|
||||
}
|
||||
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)"
|
||||
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",
|
||||
]
|
||||
@@ -1,45 +1,38 @@
|
||||
"""
|
||||
Seed dataset for the Genesys CX Cloud TEI (Forrester, Dec 2025).
|
||||
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).
|
||||
|
||||
"The Total Economic Impact™ Of CX Cloud — Cost Savings And Business
|
||||
Benefits Enabled By Genesys And Salesforce" (commissioned by Genesys and
|
||||
Salesforce). Composite: global supply company, $2.5B revenue, 10,000
|
||||
employees, 600 CX agents (400 concurrent licenses), 80,000 weekly
|
||||
interactions averaging 12 minutes.
|
||||
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.
|
||||
|
||||
Each row uses the friendly value shape accepted by
|
||||
``core.tei_client.TEIClient.update_values``. Benefit values are *nominal*
|
||||
(pre-risk-adjustment); Athena applies the field-level risk adjustment.
|
||||
Cost values are nominal too — push them pre-multiplied by
|
||||
``(1 + risk_adjustment)`` per the Palladium convention (Athena never
|
||||
risk-adjusts costs).
|
||||
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).
|
||||
|
||||
Published headline (3-yr risk-adjusted, 10% discount)::
|
||||
Two study-specific footnotes, preserved from the source review:
|
||||
|
||||
Benefits PV $14,840,638
|
||||
Costs PV $ 4,057,170
|
||||
NPV $10,783,468
|
||||
ROI 266%
|
||||
Payback ~4 months (computed; the study does not headline it)
|
||||
|
||||
Athena discounts Year-0 "Initial" amounts as Year-1 cashflows (Forrester
|
||||
leaves Year 0 undiscounted). With this study's large initial cost
|
||||
($1,309,000 risk-adjusted) that difference is material, so this module
|
||||
also exports ``ATHENA_EXPECTED`` — the totals Athena *should* produce
|
||||
under its own discounting. Verification: match ATHENA_EXPECTED tightly
|
||||
(pipeline correctness), then reconcile to PUBLISHED with the explained
|
||||
Year-0 delta.
|
||||
|
||||
NOTE on the published PDF: the 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.
|
||||
* 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. Risk adjustment stored separately.
|
||||
BENEFITS: list[dict] = [
|
||||
#: 3-year nominal benefit cashflows — 🟢 published.
|
||||
BENEFITS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "legacy_retirement",
|
||||
"table": "benefits",
|
||||
@@ -98,9 +91,10 @@ BENEFITS: list[dict] = [
|
||||
]
|
||||
|
||||
|
||||
#: Costs are nominal; push × (1 + risk_adjustment). "initial" is the
|
||||
#: Year-0 component (companion non-annual field in Athena).
|
||||
COSTS: list[dict] = [
|
||||
#: 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",
|
||||
@@ -158,16 +152,17 @@ COSTS: list[dict] = [
|
||||
"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. Seeded at $0 to reproduce the published "
|
||||
"Experience tokens. Anchored at $0 to reproduce the published "
|
||||
"totals. For client cases, enter the negotiated annual token "
|
||||
"cost from the Genesys quote and document the quote details "
|
||||
"(token volume, unit price, tier) in these notes."
|
||||
"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 — for scaling to a specific client.
|
||||
#: Composite-organization drivers — 🟢 published (PDF "Composite Organization").
|
||||
ASSUMPTIONS: dict = {
|
||||
"annual_revenue": 2_500_000_000,
|
||||
"employees": 10_000,
|
||||
@@ -188,44 +183,15 @@ ASSUMPTIONS: dict = {
|
||||
}
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Genesys AI Experience tokens
|
||||
#
|
||||
# Genesys bills AI consumption in "AI Experience tokens" — pricing is
|
||||
# tiered, capability-dependent, and deal-specific. Athena stores a
|
||||
# single annual cost value per line, and so do we: enter the negotiated
|
||||
# annual figure from the Genesys quote into ``genesys_ai_tokens`` and
|
||||
# document the quote details (volume, unit price, tier) in the field
|
||||
# notes. For sizing context, the study's own drivers imply ~1,040,000
|
||||
# self-service interactions/yr (B5 × 52) and ~3,120,000 agent-assisted
|
||||
# interactions/yr (C1 × 52) would draw tokens.
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
# Verification targets
|
||||
# ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#: Published Forrester totals (3-yr risk-adjusted PV @ 10%).
|
||||
#: 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 = {
|
||||
"total_benefits_pv": 14_840_638,
|
||||
"total_costs_pv": 4_057_170,
|
||||
"net_present_value": 10_783_468,
|
||||
"roi_percentage": 266,
|
||||
"benefits_pv": 14_840_638,
|
||||
"costs_pv": 4_057_170,
|
||||
"npv": 10_783_468,
|
||||
"roi_pct": 266,
|
||||
"discount_rate": 0.10,
|
||||
"analysis_years": 3,
|
||||
}
|
||||
|
||||
#: What Athena should produce given its own discounting (Year-0 initial
|
||||
#: treated as a Year-1 cashflow: implementation PV = 1,309,000 / 1.10 =
|
||||
#: 1,190,000 instead of 1,309,000). Match these tightly; the difference
|
||||
#: vs PUBLISHED is methodology, not error.
|
||||
ATHENA_EXPECTED: dict = {
|
||||
"total_benefits_pv": 14_840_640,
|
||||
"total_costs_pv": 3_938_170,
|
||||
"net_present_value": 10_902_470,
|
||||
"roi_percentage": 276.8,
|
||||
}
|
||||
|
||||
|
||||
def all_values() -> list[dict]:
|
||||
"""Return BENEFITS + COSTS — single-call payload for update_values."""
|
||||
return BENEFITS + COSTS
|
||||
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