diff --git a/.env.example b/.env.example index 24ddc8a..19a499a 100644 --- a/.env.example +++ b/.env.example @@ -2,24 +2,3 @@ # which prompts for these and writes .env for you. ATHENA_BASE_URL=https://athena.ouranos.helu.ca ATHENA_API_KEY=your-api-key-here - -# Optional โ€” pre-set the active study + tool so notebooks/CLI pick them up -# without editing config.py. 00_provision.ipynb writes these for you. -# A TEI tool attaches to exactly ONE of proposal / engagement. -# PALLADIUM_REPORT_PUBLIC_ID= -# PALLADIUM_TOOL_PUBLIC_ID= -# PALLADIUM_PROPOSAL_ID= -# PALLADIUM_ENGAGEMENT_ID= - -# --------------------------------------------------------------------------- -# Locale / display formatting (Streamlit app) -# --------------------------------------------------------------------------- -# Currency symbol prefix (default: $) -# PALLADIUM_CURRENCY_SYMBOL=$ -# -# Thousands separator (default: , for Americas/UK; use . for continental Europe) -# PALLADIUM_THOUSANDS_SEP=, -# -# Decimal separator (default: . for Americas/UK; use , for continental Europe) -# PALLADIUM_DECIMAL_SEP=. - diff --git a/00_setup.ipynb b/00_setup.ipynb index b6faf8f..697778e 100644 --- a/00_setup.ipynb +++ b/00_setup.ipynb @@ -4,21 +4,7 @@ "cell_type": "markdown", "id": "021ac129", "metadata": {}, - "source": [ - "# ๐Ÿ›ก๏ธ Palladium โ€” Setup & Connection\n", - "\n", - "**Start here.** This notebook gets you from a fresh clone to a working Athena connection.\n", - "\n", - "| Where things live | |\n", - "|---|---|\n", - "| `00_setup.ipynb` | โ† you are here: credentials + connection check |\n", - "| `studies//notebooks/` | the actual TEI work, numbered `00_provision` โ†’ `04_export` |\n", - "| `core/` | shared logic (API client, financial math) โ€” you rarely edit this |\n", - "| `app/` | Streamlit data-entry UI: `make app` or `streamlit run app/main.py` |\n", - "| `.env` | your Athena URL + API key (gitignored; created below) |\n", - "\n", - "Run cells top to bottom. Re-run any time โ€” every step is idempotent." - ] + "source": "# ๐Ÿ›ก๏ธ Palladium โ€” Setup & Connection\n\n**Start here.** This notebook gets you from a fresh clone to a working Athena connection.\n\n| Where things live | |\n|---|---|\n| `00_setup.ipynb` | โ† you are here: credentials + connection check |\n| `studies//` | self-contained pattern studies (own venv, engine, Mercury notebook) |\n| `core/` | shared logic (API client, financial math) โ€” you rarely edit this |\n| `.env` | your Athena URL + API key (gitignored; created below) |\n\nRun cells top to bottom. Re-run any time โ€” every step is idempotent." }, { "cell_type": "code", @@ -37,32 +23,13 @@ "output_type": "execute_result" } ], - "source": [ - "# Bootstrap โ€” finds the repo root, loads .env, builds the API client.\n", - "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, save_credentials\n", - "\n", - "pal = init(connect=False)\n", - "pal" - ] + "source": "# Bootstrap โ€” finds the repo root, loads .env, builds the API client.\nimport sys, pathlib # path shim: works on a fresh kernel\nfor _p in [pathlib.Path.cwd(), *pathlib.Path.cwd().parents]:\n if (_p / \"pyproject.toml\").exists():\n sys.path.insert(0, str(_p)); break\n\nfrom core.bootstrap import init, save_credentials\n\npal = init(connect=False)\npal" }, { "cell_type": "markdown", "id": "7ca43976", "metadata": {}, - "source": [ - "## 1 ยท Credentials\n", - "\n", - "Stored in `/.env` (gitignored). The cell below only prompts if no key is\n", - "configured yet โ€” paste the key at the prompt and it's saved for every future\n", - "session, notebook, the CLI, and the Streamlit app.\n", - "\n", - "Current target: **https://athena.ouranos.helu.ca** (Ouranos sandbox โ€” safe to experiment, no production data)." - ] + "source": "## 1 ยท Credentials\n\nStored in `/.env` (gitignored). The cell below only prompts if no key is\nconfigured yet โ€” paste the key at the prompt and it's saved for every future\nsession, notebook, the CLI, and the Streamlit app.\n\nCurrent target: **https://athena.ouranos.helu.ca** (Ouranos sandbox โ€” safe to experiment, no production data)." }, { "cell_type": "code", @@ -79,26 +46,13 @@ ] } ], - "source": [ - "import os\n", - "from getpass import getpass\n", - "\n", - "if not os.getenv(\"ATHENA_API_KEY\"):\n", - " key = getpass(\"Athena API key (input hidden): \")\n", - " path = save_credentials(api_key=key)\n", - " print(f\"Saved โ†’ {path}\")\n", - "else:\n", - " print(f\"โœ… Credentials already configured for {os.getenv('ATHENA_BASE_URL')}\")\n", - " print(\" (To rotate the key: save_credentials(api_key='new-key'))\")" - ] + "source": "import os\nfrom getpass import getpass\n\nif not os.getenv(\"ATHENA_API_KEY\"):\n key = getpass(\"Athena API key (input hidden): \")\n path = save_credentials(api_key=key)\n print(f\"Saved โ†’ {path}\")\nelse:\n print(f\"โœ… Credentials already configured for {os.getenv('ATHENA_BASE_URL')}\")\n print(\" (To rotate the key: save_credentials(api_key='new-key'))\")" }, { "cell_type": "markdown", "id": "aa7464fd", "metadata": {}, - "source": [ - "## 2 ยท Test the connection" - ] + "source": "## 2 ยท Test the connection" }, { "cell_type": "code", @@ -128,19 +82,13 @@ "output_type": "execute_result" } ], - "source": [ - "pal = init() # builds the client and pings /api/v1/tei/reports/\n", - "client = pal.client\n", - "pal.connection" - ] + "source": "pal = init() # builds the client and pings /api/v1/tei/reports/\nclient = pal.client\npal.connection" }, { "cell_type": "markdown", "id": "6877d6ae", "metadata": {}, - "source": [ - "## 3 ยท What's in this Athena instance?" - ] + "source": "## 3 ยท What's in this Athena instance?" }, { "cell_type": "code", @@ -209,19 +157,7 @@ "output_type": "display_data" } ], - "source": [ - "import pandas as pd\n", - "\n", - "reports = client.list_reports()\n", - "if reports:\n", - " display(pd.DataFrame(reports)[\n", - " [c for c in (\"id\", \"name\", \"vendor\", \"version\", \"status\",\n", - " \"analysis_period_years\", \"discount_rate\",\n", - " \"field_count\", \"instance_count\") if c in reports[0]]\n", - " ])\n", - "else:\n", - " print(\"No TEI report templates yet โ€” studies/202602_AmazonConnect/notebooks/00_provision.ipynb creates one.\")" - ] + "source": "import pandas as pd\n\nreports = client.list_reports()\nif reports:\n display(pd.DataFrame(reports)[\n [c for c in (\"id\", \"name\", \"vendor\", \"version\", \"status\",\n \"analysis_period_years\", \"discount_rate\",\n \"field_count\", \"instance_count\") if c in reports[0]]\n ])\nelse:\n print(\"No TEI report templates yet.\")" }, { "cell_type": "code", @@ -237,31 +173,13 @@ ] } ], - "source": [ - "tools = client.list_tools()\n", - "if tools:\n", - " display(pd.DataFrame(tools)[\n", - " [c for c in (\"id\", \"name\", \"status\", \"current_version\") if c in tools[0]]\n", - " ])\n", - "else:\n", - " print(\"No TEI tool instances yet.\")" - ] + "source": "tools = client.list_tools()\nif tools:\n display(pd.DataFrame(tools)[\n [c for c in (\"id\", \"name\", \"status\", \"current_version\") if c in tools[0]]\n ])\nelse:\n print(\"No TEI tool instances yet.\")" }, { "cell_type": "markdown", "id": "33114d67", "metadata": {}, - "source": [ - "## Next steps\n", - "\n", - "1. **Provision the Amazon Connect study** โ†’ open\n", - " [`studies/202602_AmazonConnect/notebooks/00_provision.ipynb`](studies/202602_AmazonConnect/notebooks/00_provision.ipynb).\n", - " It creates the report template + fields in the sandbox, creates a tool,\n", - " seeds the Forrester values, calculates, and verifies the published totals\n", - " (NPV \\$78.7M ยท ROI 342% ยท payback <6 months).\n", - "2. **Work the study** โ†’ notebooks `01_benefits` โ†’ `04_export` in the same folder.\n", - "3. **Interactive data entry** โ†’ `make app` (or `streamlit run app/main.py`)." - ] + "source": "## Next steps\n\n1. **Open a study** โ€” each `studies//` is self-contained (own venv +\n engine + verification gate): see its README, e.g.\n [`studies/202602_TEI_Amazon_Connect/`](studies/202602_TEI_Amazon_Connect/README.md)\n (NPV \\$78.7M ยท ROI 342%) or\n [`studies/202512_TEI_Genesys_CX_Cloud/`](studies/202512_TEI_Genesys_CX_Cloud/README.md)\n (NPV \\$10.8M ยท ROI 266%).\n2. **Serve a deliverable** โ†’ `mercury --working-dir notebooks/` from the study root.\n3. **Start a new study** โ†’ copy `template/MercuryNotebook/` per the\n [pattern](docs/Mercury_Notebook_Pattern_V1-00.md)." }, { "cell_type": "code", @@ -269,7 +187,7 @@ "id": "d20d824f-e464-4ff7-8191-10c2495842a0", "metadata": {}, "outputs": [], - "source": [] + "source": "" }, { "cell_type": "code", @@ -277,7 +195,7 @@ "id": "630ee935-7c7b-47e5-9c13-6285316823e2", "metadata": {}, "outputs": [], - "source": [] + "source": "" }, { "cell_type": "code", @@ -285,7 +203,7 @@ "id": "7eba3877-8e51-443f-9953-9d0a48425f9f", "metadata": {}, "outputs": [], - "source": [] + "source": "" } ], "metadata": { diff --git a/Makefile b/Makefile index 9a8e57d..a2cdd8d 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ VENV := .venv PY := $(VENV)/bin/python PIP := $(VENV)/bin/pip -.PHONY: setup lab app test lint format clean +.PHONY: setup lab test lint format clean ## One-time: create venv, install deps + palladium (editable) setup: @@ -19,10 +19,6 @@ setup: lab: $(VENV)/bin/jupyter lab -## Launch the Streamlit data-entry app -app: - $(VENV)/bin/streamlit run app/main.py - ## Run the test suite (no Athena connection needed โ€” HTTP is mocked) test: $(PY) -m pytest tests/ -v diff --git a/README.md b/README.md index cc2fbc4..84cb9a5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **TEI (Total Economic Impact) Calculator** โ€” The strategic artifact that protects the business case. -Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic Impact analyses. It connects to [Athena](https://athena.nttdata.com) for data persistence, performs financial calculations (NPV, ROI, payback period), and exports structured data for the report generation pipeline. +Palladium is a Jupyter-notebook toolkit for building Total Economic Impact analyses. Each study is a self-contained Mercury-served notebook deliverable (math in a study package, verification gate, LLM-readable exports); a small shared `core/` talks to [Athena](https://athena.nttdata.com) for client/opportunity context and server-side TEI tooling. > *In Greek mythology, the Palladium was a sacred artifact of Athena that protected Troy. Whoever possessed it held strategic advantage. In our ecosystem, Palladium protects the deal โ€” transforming discovery inputs into a financial case no CFO can ignore.* @@ -12,23 +12,17 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ Palladium โ”‚ โ”‚ โ”‚ -โ”‚ studies/202512_GenesysCX/ โ† legacy study (this path) โ”‚ -โ”‚ studies/YYYYMM_/ โ”‚ -โ”‚ โ”œโ”€ notebooks/ โ”€โ” โ”‚ -โ”‚ โ”œโ”€ seed_data.py โ”‚ โ”‚ -โ”‚ โ””โ”€ config.py โ”‚ โ”‚ -โ”‚ โ–ผ โ”‚ -โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ -โ”‚ โ”‚ core/ โ”‚ โ†โ”€ โ”‚ app/ โ”‚ โ”‚ -โ”‚ โ”‚ shared logic โ”‚ โ”‚ Streamlit โ”‚ โ”‚ -โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ -โ”‚ โ”‚ โ”‚ โ”‚ -โ”‚ โ–ผ โ–ผ โ”‚ -โ”‚ tei_client โ†’ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ Athena API โ”‚ -โ”‚ calculations โ”‚ -โ”‚ export โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ export.json โ”‚ -โ”‚ notebook_helpers โ”‚ -โ”‚ cli โ”‚ +โ”‚ studies/YYYYMM_TEI_Vendor_Product/ โ† self-contained study โ”‚ +โ”‚ studies/YYYYMM_Client_EngagementName/ (own venv + engine) โ”‚ +โ”‚ โ”œโ”€ / โ† ALL math, verbatim anchors โ”‚ +โ”‚ โ”œโ”€ notebooks/ โ† THE deliverable (Mercury-served) โ”‚ +โ”‚ โ”œโ”€ tests/ โ† pinned acceptance numbers โ”‚ +โ”‚ โ””โ”€ exports/ โ† .html/.md + JSON appendix (for LLMs, โ”‚ +โ”‚ and the Athena repository roadmap) โ”‚ +โ”‚ โ”‚ +โ”‚ core/ โ† shared Athena toolkit (studies do NOT import it) โ”‚ +โ”‚ tei_client โ†’ โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–บ Athena API โ”‚ +โ”‚ calculations ยท export ยท cli ยท bootstrap โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ ``` @@ -36,26 +30,25 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic | Component | Purpose | |-----------|---------| +| **`studies/`** | One self-contained folder per engagement โ€” own venv, engine package, Mercury notebook, tests, exports | +| **`template/`** | Copy-me study scaffold โ€” start here for new studies | | **`core/tei_client`** | Python API client for Athena's TEI endpoints | | **`core/calculations`** | Financial logic โ€” NPV, ROI, payback, risk adjustment, scenarios | | **`core/export`** | Builds the structured JSON envelope consumed by the report pipeline | -| **`core/notebook_helpers`** | Pandas tables, Plotly charts, IPython display widgets | | **`core/cli`** | `python -m palladium` command-line interface | -| **`app/`** | Streamlit data-entry UI with version management โ€” *study-agnostic* | -| **`studies/`** | One folder per TEI engagement (notebooks, seed data, config, source PDF) | -| **`template/`** | Copy-me study templates โ€” start here for new studies | -> **New studies follow the [Mercury Notebook Deliverable Pattern](docs/Mercury_Notebook_Pattern_V1-00.md)**: +> **All studies follow the [Mercury Notebook Deliverable Pattern](docs/Mercury_Notebook_Pattern_V1-00.md)**: > the notebook *is* the artifact โ€” self-contained study package, Mercury-served, > gate-verified, LLM-exportable. Start from [`template/MercuryNotebook/`](template/MercuryNotebook/). -> The Streamlit `app/` path is retired by that pattern; existing TEI studies migrate to it. +> The Streamlit `app/` and `core/notebook_helpers` were retired when the last +> legacy study migrated (git history keeps them). --- ## Quick Start โ€” Jupyter Lab first Palladium is a **Jupyter Lab-first** environment. Everything starts from a -notebook; the Streamlit app and CLI are companions, not prerequisites. +notebook; the CLI is a companion, not a prerequisite. ```bash git clone https://github.com/nttdata/palladium.git @@ -73,14 +66,14 @@ Then open **`00_setup.ipynb`** at the repo root. It will: Current target instance: **https://athena.ouranos.helu.ca** (Ouranos sandbox โ€” no production data, safe to experiment). -From any notebook, setup is one import: +From any root-level notebook, the Athena connection is one import (pattern +studies are self-contained and never import `core`): ```python from core.bootstrap import init -pal = init(study="202512_GenesysCX") # loads .env, connects, imports study +pal = init() # loads .env, builds client, tests it pal.client.list_reports() -pal.seed_data.BENEFITS ``` ### Configuration @@ -92,11 +85,6 @@ writes it for you; to do it by hand: # .env ATHENA_BASE_URL=https://athena.ouranos.helu.ca ATHENA_API_KEY=your-api-key-here -# written by the provisioning notebook: -PALLADIUM_REPORT_PUBLIC_ID=... -PALLADIUM_TOOL_PUBLIC_ID=... -PALLADIUM_PROPOSAL_ID=... # or PALLADIUM_ENGAGEMENT_ID โ€” a TEI tool - # attaches to exactly one of the two ``` ### Verify Connection @@ -129,19 +117,11 @@ Its notebook reproduces the published totals within the PDF's rounding โ€” **NPV $78.7M โ€ข ROI 342% โ€ข Payback <6 months** โ€” and the verification gate asserts it on every headless run. See the study's README for details. -The remaining legacy study, `studies/202512_GenesysCX/`, still uses the -shared `core/` workflow (`make lab`, provision โ†’ push โ†’ calculate); it -migrates to the pattern next, after which `core/`'s notebook helpers and -`app/` retire. - -### Streamlit application (study-agnostic) - -Interactive UI for data entry and version management. Works for any TEI -study because field definitions come from Athena at runtime: - -```bash -streamlit run app/main.py -``` +`studies/202512_TEI_Genesys_CX_Cloud/` follows the same shape (**NPV $10.8M +โ€ข ROI 266%**), with one signature input: the Genesys AI Experience token +line the published study models at $0, priced live from the client's quote. +`studies/202607_CTM_GenesysCX/` is the full multi-notebook reference +implementation. ### CLI @@ -171,9 +151,10 @@ python -m palladium export -o export.json pytest tests/ -v ``` -50 tests cover the API client (mocked HTTP), the financial math, and the -export envelope shape. The Amazon Connect seed data is asserted against -the published Forrester totals. +The root suite covers the API client (mocked HTTP), the financial math, and +the export envelope shape; the Amazon Connect verbatim anchor is asserted +against the published Forrester totals. Each study additionally carries its +own pinned suite (`cd studies/ && pytest`). --- @@ -244,7 +225,7 @@ Three scenarios model uncertainty in adoption and realization ``` palladium/ โ”œโ”€โ”€ 00_setup.ipynb # โ† START HERE: credentials + connection -โ”œโ”€โ”€ Makefile # make setup / lab / app / test +โ”œโ”€โ”€ Makefile # make setup / lab / test โ”œโ”€โ”€ core/ # Shared, study-agnostic Python package โ”‚ โ”œโ”€โ”€ bootstrap.py # one-import notebook setup (init, save_credentials) โ”‚ โ”œโ”€โ”€ tei_client/ # Athena API client @@ -257,25 +238,19 @@ palladium/ โ”‚ โ”‚ โ””โ”€โ”€ scenarios.py โ”‚ โ”œโ”€โ”€ export/ โ”‚ โ”‚ โ””โ”€โ”€ report_data.py # JSON envelope for the report pipeline -โ”‚ โ”œโ”€โ”€ notebook_helpers/ -โ”‚ โ”‚ โ”œโ”€โ”€ tables.py # Pandas dataframe builders -โ”‚ โ”‚ โ”œโ”€โ”€ charts.py # Plotly figures -โ”‚ โ”‚ โ””โ”€โ”€ display.py # IPython KPI cards, alerts โ”‚ โ””โ”€โ”€ cli/ โ”‚ โ””โ”€โ”€ main.py # `python -m palladium ...` โ”œโ”€โ”€ palladium/ # CLI shim (just exposes `python -m palladium`) โ”‚ โ””โ”€โ”€ __main__.py -โ”œโ”€โ”€ app/ # Streamlit UI โ€” works with any TEI study -โ”‚ โ”œโ”€โ”€ main.py # entry point -โ”‚ โ”œโ”€โ”€ views/ # benefits, costs, summary, versions (NOT `pages/` โ€” avoids Streamlit auto-multipage) -โ”‚ โ””โ”€โ”€ components/ # tables, charts โ”œโ”€โ”€ template/ โ”‚ โ””โ”€โ”€ MercuryNotebook/ # copy-me pattern scaffold (runnable) -โ”œโ”€โ”€ studies/ # One folder per engagement -โ”‚ โ”œโ”€โ”€ 202512_GenesysCX/ # CX Cloud TEI โ€” legacy shared-core layout -โ”‚ โ”‚ โ”œโ”€โ”€ README.md # NPV $10.8M ยท ROI 266% + AI-token line -โ”‚ โ”‚ โ”œโ”€โ”€ config.py / seed_data.py # study-scoped PALLADIUM_GENESYSCX_* keys -โ”‚ โ”‚ โ””โ”€โ”€ notebooks/ # 00_provision, 01_business_case +โ”œโ”€โ”€ studies/ # One self-contained folder per engagement +โ”‚ โ”œโ”€โ”€ 202512_TEI_Genesys_CX_Cloud/ # CX Cloud TEI โ€” pattern Variant 4 +โ”‚ โ”‚ โ”œโ”€โ”€ README.md # NPV $10.8M ยท ROI 266% + the $0 AI-token line +โ”‚ โ”‚ โ”œโ”€โ”€ teicalc/ # self-contained engine (anchor/model/overlay) +โ”‚ โ”‚ โ”œโ”€โ”€ notebooks/business_case.ipynb +โ”‚ โ”‚ โ”œโ”€โ”€ tests/ ยท scripts/ ยท config.toml ยท pyproject.toml +โ”‚ โ”‚ โ””โ”€โ”€ docs/ # Forrester PDF + Genesys token-metering notes โ”‚ โ”œโ”€โ”€ 202602_TEI_Amazon_Connect/ # Amazon Connect TEI โ€” pattern Variant 4 โ”‚ โ”‚ โ”œโ”€โ”€ README.md # NPV $78.7M ยท ROI 342%, reproduced + gated โ”‚ โ”‚ โ”œโ”€โ”€ teicalc/ # self-contained engine (anchor/model/overlay) @@ -285,7 +260,7 @@ palladium/ โ”‚ โ”‚ โ””โ”€โ”€ docs/ โ”‚ โ”‚ โ””โ”€โ”€ 202602_TEI Report Amazon Connect.pdf โ”‚ โ””โ”€โ”€ 202607_CTM_GenesysCX/ # CTM ร— Genesys study โ€” pattern reference impl -โ”œโ”€โ”€ tests/ # 50 tests for core/ +โ”œโ”€โ”€ tests/ # root tests for core/ โ”‚ โ”œโ”€โ”€ test_client.py โ”‚ โ”œโ”€โ”€ test_calculations.py โ”‚ โ””โ”€โ”€ test_export.py @@ -384,7 +359,8 @@ The export envelope (`core.export.build_report_data`) includes: ## Version Management -Palladium manages version history through both the API and the Streamlit UI: +Athena keeps version history for TEI tools, driven through the API +(`core.tei_client`: `save_version` / `list_versions` / `get_version`): 1. **Save Version** โ€” Snapshots current values + summary with a descriptive note 2. **View History** โ€” All versions with headline metrics (NPV, ROI) @@ -435,7 +411,6 @@ ruff format . | `requests` | โ‰ฅ2.31 | HTTP client for Athena API | | `python-dotenv` | โ‰ฅ1.0 | Environment configuration | | `jupyter` | โ‰ฅ1.0 | Notebook environment | -| `streamlit` | โ‰ฅ1.30 | Data entry application | | `pandas` | โ‰ฅ2.0 | Data manipulation | | `plotly` | โ‰ฅ5.18 | Interactive visualizations | | `numpy` | โ‰ฅ1.26 | Financial calculations | diff --git a/app/__init__.py b/app/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/components/__init__.py b/app/components/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/components/charts.py b/app/components/charts.py deleted file mode 100644 index 87dbbd9..0000000 --- a/app/components/charts.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Streamlit-friendly chart wrappers (delegate to core.notebook_helpers.charts). - -Every wrapper takes a ``key`` โ€” the same figure type renders on multiple -tabs (Summary, Benefits, Costs) within one script run, so Streamlit needs -explicit element IDs to avoid StreamlitDuplicateElementId errors. -""" - -from __future__ import annotations - -import streamlit as st - -from core.notebook_helpers import charts as core_charts - - -def cashflow(yearly_breakdown, *, initial_cost: float = 0.0, key: str = "cashflow") -> None: - fig = core_charts.cashflow_chart(yearly_breakdown, initial_cost=initial_cost) - st.plotly_chart(fig, width="stretch", key=key) - - -def benefits_bar(items, *, key: str = "benefits_bar") -> None: - fig = core_charts.benefits_bar(items) - st.plotly_chart(fig, width="stretch", key=key) - - -def cost_pie(items, *, key: str = "cost_pie") -> None: - fig = core_charts.cost_breakdown_pie(items) - st.plotly_chart(fig, width="stretch", key=key) - - -def benefits_vs_costs_by_year(benefit_items, cost_items, *, key: str = "by_year") -> None: - fig = core_charts.benefits_vs_costs_by_year(benefit_items, cost_items) - st.plotly_chart(fig, width="stretch", key=key) - - -def scenario_bars(scenarios, *, key: str = "scenario_bars") -> None: - fig = core_charts.scenario_comparison(scenarios) - st.plotly_chart(fig, width="stretch", key=key) - - -def waterfall(values, *, key: str = "waterfall") -> None: - fig = core_charts.waterfall(values) - st.plotly_chart(fig, width="stretch", key=key) diff --git a/app/components/tables.py b/app/components/tables.py deleted file mode 100644 index 009fb1f..0000000 --- a/app/components/tables.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Streamlit data-editor wrappers for benefit/cost rows.""" - -from __future__ import annotations - -import pandas as pd -import streamlit as st - -from app.locale import currency_fmt, fmt_currency, fmt_pct, pct_fmt, _STANDARD_LOCALE - - -def _years_for_table(fields: list[dict], analysis_years: int) -> list[int]: - """Years 1..N -- taken from analysis_period_years on the report.""" - return list(range(1, max(int(analysis_years or 3), 1) + 1)) - - -def value_editor( - table: str, - fields: list[dict], - values: list[dict], - *, - analysis_years: int, - key: str, -) -> pd.DataFrame: - """ - Render an ``st.data_editor`` for benefit or cost values. - - The editor shows one row per field (filtered to ``table``), with year - columns, an ``initial`` column for costs, a risk_adjustment column, and - a notes column. Returns the edited DataFrame; the caller is responsible - for converting it back to value-row dicts and PUTting to Athena. - - Currency columns use the locale configured via PALLADIUM_CURRENCY_SYMBOL / - PALLADIUM_THOUSANDS_SEP / PALLADIUM_DECIMAL_SEP in .env. - The risk_adj column is stored as a 0-1 fraction and displayed as a - percentage (e.g. 0.20 -> "20.00%"). - """ - fields = [ - f - for f in fields - if f.get("table") == table - # Companion "_initial" fields are edited via the Initial column - # on their parent cost row, not as standalone rows. - and not str(f.get("field_key", "")).endswith("_initial") - ] - fields.sort(key=lambda f: int(f.get("sort_order") or 0)) - - by_key = {v.get("field_key"): v for v in values} - years = _years_for_table(fields, analysis_years) - - rows: list[dict] = [] - for f in fields: - v = by_key.get(f["field_key"], {}) or {} - yv = v.get("year_values") or {} - risk_raw = float(v.get("risk_adjustment") or 0.0) - row = { - "field_key": f["field_key"], - "label": f.get("label", f["field_key"]), - "category": f.get("category", "") or "", - } - if table == "costs": - if _STANDARD_LOCALE: - row["Initial"] = float(v.get("initial") or 0.0) - else: - row["Initial"] = fmt_currency(float(v.get("initial") or 0.0)) - for y in years: - raw = float(yv.get(str(y)) or 0.0) - if _STANDARD_LOCALE: - row[f"Year {y}"] = raw - else: - row[f"Year {y}"] = fmt_currency(raw) - # Risk adj: store as fraction for standard locales (NumberColumn handles - # display), or pre-format as "20.00%" string for non-standard locales. - if _STANDARD_LOCALE: - row["risk_adj"] = risk_raw - else: - row["risk_adj"] = fmt_pct(risk_raw) - row["notes"] = v.get("notes", "") or "" - rows.append(row) - - df = pd.DataFrame(rows) - - _cur_fmt = currency_fmt() - _pct_fmt_str = pct_fmt() - - column_config: dict = { - "field_key": st.column_config.TextColumn("Key", disabled=True, width="small"), - "label": st.column_config.TextColumn("Field", disabled=True), - "category": st.column_config.TextColumn("Category", disabled=True, width="small"), - "notes": st.column_config.TextColumn("Notes", width="medium"), - } - - if _STANDARD_LOCALE: - column_config["risk_adj"] = st.column_config.NumberColumn( - "Risk Adj.", - min_value=0.0, - max_value=1.0, - step=0.05, - format=_pct_fmt_str, - help="Enter as a decimal fraction (e.g. 0.20 = 20%)", - ) - if table == "costs": - column_config["Initial"] = st.column_config.NumberColumn( - "Initial", format=_cur_fmt - ) - for y in years: - column_config[f"Year {y}"] = st.column_config.NumberColumn( - f"Year {y}", format=_cur_fmt - ) - else: - # Non-standard locale: display as pre-formatted strings (read-only display; - # user edits the raw number and we re-format on save). - column_config["risk_adj"] = st.column_config.TextColumn( - "Risk Adj.", help="Displayed as percentage; stored as 0-1 fraction" - ) - if table == "costs": - column_config["Initial"] = st.column_config.TextColumn("Initial") - for y in years: - column_config[f"Year {y}"] = st.column_config.TextColumn(f"Year {y}") - - edited = st.data_editor( - df, - column_config=column_config, - width="stretch", - num_rows="fixed", - hide_index=True, - key=key, - ) - return edited - - -def df_to_values(df: pd.DataFrame, table: str, analysis_years: int) -> list[dict]: - """Convert an edited DataFrame back to wire-format value rows.""" - out: list[dict] = [] - years = list(range(1, max(int(analysis_years or 3), 1) + 1)) - for _, row in df.iterrows(): - item: dict = {"field_key": row["field_key"], "table": table} - yv = {} - for y in years: - col = f"Year {y}" - if col in df.columns: - yv[str(y)] = float(row[col] or 0) - if yv: - item["year_values"] = yv - if table == "costs" and "Initial" in df.columns: - item["initial"] = float(row["Initial"] or 0) - ra = row.get("risk_adj") - if ra is not None and not pd.isna(ra): - item["risk_adjustment"] = float(ra) - notes = row.get("notes") - if isinstance(notes, str) and notes.strip(): - item["notes"] = notes.strip() - out.append(item) - return out diff --git a/app/locale.py b/app/locale.py deleted file mode 100644 index 99f1764..0000000 --- a/app/locale.py +++ /dev/null @@ -1,87 +0,0 @@ -""" -Locale / formatting settings for the Palladium Streamlit app. - -All settings are read from environment variables (via .env) so the same -codebase can be deployed for different regions without code changes. - -Environment variables ---------------------- -PALLADIUM_CURRENCY_SYMBOL Default: "$" - Prefix shown before monetary values (e.g. "$", "โ‚ฌ", "ยฃ", "CAD "). - -PALLADIUM_THOUSANDS_SEP Default: "," - Thousands separator used in number display (e.g. "," for Americas, - "." for continental Europe, " " for some locales). - -PALLADIUM_DECIMAL_SEP Default: "." - Decimal separator (e.g. "." for Americas/UK, "," for continental Europe). - -Note: Streamlit's NumberColumn ``format`` uses printf-style strings. -The ``%,`` flag (thousands separator) is supported in Streamlit โ‰ฅ 1.31. -For non-standard separators (e.g. European "." thousands / "," decimal) -the values are pre-formatted as strings and displayed in TextColumns. -""" - -from __future__ import annotations - -import os - - -def _env(key: str, default: str) -> str: - return os.environ.get(key, default).strip() - - -# --------------------------------------------------------------------------- -# Resolved settings (read once at import time; restart app to pick up changes) -# --------------------------------------------------------------------------- -CURRENCY_SYMBOL: str = _env("PALLADIUM_CURRENCY_SYMBOL", "$") -THOUSANDS_SEP: str = _env("PALLADIUM_THOUSANDS_SEP", ",") -DECIMAL_SEP: str = _env("PALLADIUM_DECIMAL_SEP", ".") - -# True when the locale uses standard printf-compatible separators -# (i.e. "," thousands + "." decimal โ€” the C/POSIX default). -# When False, we pre-format values as strings instead of relying on printf. -_STANDARD_LOCALE: bool = THOUSANDS_SEP == "," and DECIMAL_SEP == "." - - -def currency_fmt() -> str: - """Return a Streamlit NumberColumn ``format`` string for currency. - - For standard locales returns e.g. ``"$%,.0f"`` (thousands-separated, - no decimal places). For non-standard locales returns ``"%s"`` and - callers should use :func:`fmt_currency` to pre-format the value. - """ - if _STANDARD_LOCALE: - return f"{CURRENCY_SYMBOL}%,.0f" - return "%s" - - -def pct_fmt() -> str: - """Return a Streamlit NumberColumn ``format`` string for percentages. - - Stores the value as a fraction (0โ€“1) and displays as e.g. ``"20.00%"``. - Streamlit's ``%%`` in format strings renders a literal ``%``. - """ - if _STANDARD_LOCALE: - return "%.2f%%" - return "%s" - - -def fmt_currency(value: float) -> str: - """Format *value* as a currency string using the configured locale.""" - if _STANDARD_LOCALE: - return f"{CURRENCY_SYMBOL}{value:,.0f}" - # Non-standard: build manually - integer_part = f"{int(abs(value)):,}".replace(",", THOUSANDS_SEP) - sign = "-" if value < 0 else "" - return f"{sign}{CURRENCY_SYMBOL}{integer_part}" - - -def fmt_pct(value: float) -> str: - """Format *value* (0โ€“1 fraction) as a percentage string.""" - pct = value * 100 - if _STANDARD_LOCALE: - return f"{pct:.2f}%" - integer_part = f"{int(pct)}" - decimal_part = f"{abs(pct) % 1:.2f}"[1:] # ".xx" - return f"{integer_part}{DECIMAL_SEP}{decimal_part[1:]}%" diff --git a/app/main.py b/app/main.py deleted file mode 100644 index 51ef145..0000000 --- a/app/main.py +++ /dev/null @@ -1,223 +0,0 @@ -""" -Palladium Streamlit app โ€” TEI data entry, calculation, versioning, export. - -Run from the project root:: - - streamlit run app/main.py - -The app picks a TEI tool by ``public_id`` (or creates one from a Report -template) and exposes Benefits, Costs, Summary, and Versions pages. It is -study-agnostic โ€” the field set is loaded dynamically from Athena based on -the linked Report template. -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -# Allow `streamlit run app/main.py` from project root without `pip install -e .` -_ROOT = Path(__file__).resolve().parent.parent -if str(_ROOT) not in sys.path: - sys.path.insert(0, str(_ROOT)) - -import streamlit as st - -from core.tei_client import AthenaAPIError, TEIClient -from app.utils import icon, inject_icons - -st.set_page_config( - page_title="Palladium โ€” TEI Calculator", - page_icon="๐Ÿ›ก๏ธ", - layout="wide", -) - - - -@st.cache_resource(show_spinner=False) -def get_client() -> TEIClient: - return TEIClient() - - -def _safe_call(fn, *args, **kwargs): - """Run an API call, surfacing errors as Streamlit messages.""" - try: - return fn(*args, **kwargs) - except AthenaAPIError as e: - st.error(f"Athena API error {e.status_code}: {e.detail}") - except ValueError as e: - st.error(str(e)) - return None - - -# CRM lookups, cached briefly so the cascading selects stay snappy. -@st.cache_data(ttl=120, show_spinner=False) -def _crm_clients(_client: TEIClient) -> list[dict]: - try: - return _client.list_clients() - except AthenaAPIError: - return [] - - -@st.cache_data(ttl=120, show_spinner=False) -def _crm_proposals(_client: TEIClient, client_id: int) -> list[dict]: - try: - return _client.proposals_for_client(client_id) - except AthenaAPIError: - return [] - - -@st.cache_data(ttl=120, show_spinner=False) -def _crm_engagements(_client: TEIClient, client_name: str) -> list[dict]: - try: - return _client.engagements_for_client(client_name) - except AthenaAPIError: - return [] - - -def sidebar_tool_picker(client: TEIClient) -> dict | None: - """Sidebar: pick an existing TEI tool or create one from a report template.""" - st.sidebar.markdown( - f"{icon('shield-fill')} **Palladium**", unsafe_allow_html=True - ) - st.sidebar.caption("TEI Calculator") - - tools = _safe_call(client.list_tools) or [] - if tools: - labels = { - f"{t.get('name', '(unnamed)')} โ€” {t.get('id', '')[:8]}โ€ฆ": t for t in tools - } - choice = st.sidebar.selectbox("TEI Tool", list(labels.keys())) - tool = labels[choice] - else: - st.sidebar.info("No TEI tools yet. Create one below.") - tool = None - - with st.sidebar.expander("Create new tool"): - reports = _safe_call(client.list_reports) or [] - if not reports: - st.write("No report templates available.") - else: - report_labels = {f"{r['name']} ({r['vendor']} {r['version']})": r for r in reports} - r_choice = st.selectbox("Report template", list(report_labels.keys())) - - # A TEI tool must attach to a Proposal OR an Engagement. - # Cascade: client โ†’ proposal/engagement, pulled from the CRM. - clients = _crm_clients(client) - if not clients: - st.warning("No CRM clients found โ€” create one in Athena first.") - return tool - client_labels = {c["name"]: c for c in clients} - c_choice = st.selectbox("Client", list(client_labels.keys())) - crm_client = client_labels[c_choice] - - attach_kind = st.radio( - "Attach to", ["Proposal", "Engagement"], horizontal=True - ) - proposal_id: int | None = None - engagement_id: int | None = None - if attach_kind == "Proposal": - proposals = _crm_proposals(client, crm_client["id"]) - if proposals: - p_labels = { - f"{p.get('name')} ({p.get('status')})": p for p in proposals - } - p_choice = st.selectbox("Proposal", list(p_labels.keys())) - proposal_id = p_labels[p_choice]["id"] - else: - st.info( - f"{crm_client['name']} has no proposals. Create one in " - "Athena (or via 00_provision.ipynb) first." - ) - else: - engagements = _crm_engagements(client, crm_client["name"]) - if engagements: - e_labels = { - f"{e.get('name')} ({e.get('status')})": e for e in engagements - } - e_choice = st.selectbox("Engagement", list(e_labels.keys())) - engagement_id = e_labels[e_choice]["id"] - else: - st.info(f"{crm_client['name']} has no engagements.") - - default_name = f"{crm_client['name']} โ€” {report_labels[r_choice]['name']}" - new_name = st.text_input("Tool name", default_name) - if st.button( - "Create", disabled=proposal_id is None and engagement_id is None - ): - report = report_labels[r_choice] - created = _safe_call( - client.create_tool, - report_public_id=report["id"], - proposal=proposal_id, - engagement=engagement_id, - name=new_name or None, - ) - if created: - st.success(f"Created tool {created.get('id')}") - st.cache_data.clear() - st.rerun() - - if tool: - st.sidebar.divider() - _opp = tool.get("opportunity") or {} - _client_name = (_opp.get("client") or {}).get("name") - if _client_name: - st.sidebar.markdown(f"**Client**: {_client_name}") - st.sidebar.markdown(f"**Public ID**: `{tool.get('id')}`") - st.sidebar.markdown(f"**Status**: {tool.get('status', '?')}") - st.sidebar.markdown(f"**Version**: {tool.get('current_version', 0)}") - if st.sidebar.button("Recalculate"): - _safe_call(client.calculate, tool["id"]) - st.toast("Recalculated.", icon=None) - st.cache_data.clear() - return tool - - -def main() -> None: - inject_icons() - - st.markdown( - f"

{icon('shield-fill')} Palladium โ€” TEI Calculator

", - unsafe_allow_html=True, - ) - try: - client = get_client() - except ValueError as e: - st.error(str(e)) - st.info("Set ATHENA_BASE_URL and ATHENA_API_KEY in your `.env` file.") - st.stop() - return - - tool = sidebar_tool_picker(client) - - if tool is None: - st.info("Pick or create a TEI tool from the sidebar to begin.") - return - - # Tab navigation โ€” matches `app/views/*` modules but kept as tabs so all - # views share the chosen tool/state without re-querying. - # - # NOTE: the directory is `app/views/`, NOT `app/pages/`. Streamlit treats a - # `pages/` directory next to the entrypoint as auto-discovered multipage - # scripts, which would render blank since these modules only define - # `render()` and have no top-level output. - tabs = st.tabs(["Summary", "Benefits", "Costs", "Versions"]) - - from app.views import benefits as benefits_page - from app.views import costs as costs_page - from app.views import summary as summary_page - from app.views import versions as versions_page - - with tabs[0]: - summary_page.render(client, tool) - with tabs[1]: - benefits_page.render(client, tool) - with tabs[2]: - costs_page.render(client, tool) - with tabs[3]: - versions_page.render(client, tool) - - -if __name__ == "__main__": - main() diff --git a/app/utils.py b/app/utils.py deleted file mode 100644 index 739d36d..0000000 --- a/app/utils.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Shared UI utilities for the Palladium Streamlit app. - -Kept in a separate module so that ``app.main`` and ``app.views.*`` can both -import from here without creating a circular dependency. -""" - -from __future__ import annotations - -import streamlit as st - -# --------------------------------------------------------------------------- -# Bootstrap Icons โ€” injected once at the top of every page render. -# Using the CDN stylesheet so no npm/build step is needed. -# --------------------------------------------------------------------------- -_BI_CSS = """ - - -""" - - -def inject_icons() -> None: - """Inject Bootstrap Icons CSS (idempotent โ€” Streamlit deduplicates identical HTML).""" - st.markdown(_BI_CSS, unsafe_allow_html=True) - - -def icon(name: str, *, cls: str = "") -> str: - """Return an inline Bootstrap Icon ```` tag. - - Usage:: - - st.markdown(icon("bar-chart") + " Financial Summary", unsafe_allow_html=True) - - See the full icon catalogue at https://icons.getbootstrap.com/ - """ - extra = f" {cls}" if cls else "" - return f'' diff --git a/app/views/__init__.py b/app/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/app/views/_helpers.py b/app/views/_helpers.py deleted file mode 100644 index 619de63..0000000 --- a/app/views/_helpers.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Common helpers shared by the page modules.""" - -from __future__ import annotations - -import streamlit as st - -from core.tei_client import AthenaAPIError, TEIClient - - -def report_meta(client: TEIClient, tool: dict) -> dict: - """Fetch the linked report (handles both nested-object and id-only forms).""" - report_obj = tool.get("report") - if isinstance(report_obj, dict): - return report_obj - if isinstance(report_obj, str): - try: - return client.get_report(report_obj) - except AthenaAPIError as e: - st.error(f"Failed to load report template: {e}") - return {} - - -def safe(fn, *args, **kwargs): - try: - return fn(*args, **kwargs) - except AthenaAPIError as e: - st.error(f"Athena API error {e.status_code}: {e.detail}") - return None diff --git a/app/views/benefits.py b/app/views/benefits.py deleted file mode 100644 index 232654d..0000000 --- a/app/views/benefits.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Benefits data-entry tab.""" - -from __future__ import annotations - -import streamlit as st - -from app.components import charts -from app.components.tables import df_to_values, value_editor -from app.utils import icon - -from app.views._helpers import report_meta, safe -from core.tei_client import TEIClient - - -def render(client: TEIClient, tool: dict) -> None: - st.markdown( - f"

{icon('graph-up-arrow')} Benefits

", - unsafe_allow_html=True, - ) - public_id = tool["id"] - report = report_meta(client, tool) - analysis_years = int(report.get("analysis_period_years") or 3) - - fields = safe(client.list_fields, report.get("id"), "benefits") or [] - values = [v for v in safe(client.get_values, public_id) or [] if v.get("table") == "benefits"] - - if not fields: - st.info("This report template has no benefit fields defined.") - return - - edited = value_editor( - "benefits", - fields, - values, - analysis_years=analysis_years, - key=f"benefits_editor_{public_id}", - ) - - col1, col2 = st.columns([1, 4]) - with col1: - if st.button("Save benefits", width="stretch"): - - payload = df_to_values(edited, "benefits", analysis_years) - result = safe(client.update_values, public_id, payload) - if result is not None: - st.success(f"Saved {len(payload)} benefit values.") - st.cache_data.clear() - with col2: - st.caption( - "Values are saved as nominal annual amounts. Risk adjustments are " - "applied at calculate time. Use the Recalculate button in the " - "sidebar after saving to refresh the summary." - ) - - if values: - st.divider() - charts.benefits_bar(values, key=f"benefits_tab_bar_{public_id}") diff --git a/app/views/costs.py b/app/views/costs.py deleted file mode 100644 index 40b20b8..0000000 --- a/app/views/costs.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Costs data-entry tab.""" - -from __future__ import annotations - -import streamlit as st - -from app.components import charts -from app.components.tables import df_to_values, value_editor -from app.utils import icon - -from app.views._helpers import report_meta, safe -from core.tei_client import TEIClient - - -def render(client: TEIClient, tool: dict) -> None: - st.markdown( - f"

{icon('receipt')} Costs

", - unsafe_allow_html=True, - ) - public_id = tool["id"] - report = report_meta(client, tool) - analysis_years = int(report.get("analysis_period_years") or 3) - - fields = safe(client.list_fields, report.get("id"), "costs") or [] - values = [v for v in safe(client.get_values, public_id) or [] if v.get("table") == "costs"] - - if not fields: - st.info("This report template has no cost fields defined.") - return - - edited = value_editor( - "costs", - fields, - values, - analysis_years=analysis_years, - key=f"costs_editor_{public_id}", - ) - - col1, col2 = st.columns([1, 4]) - with col1: - if st.button("Save costs", width="stretch"): - - payload = df_to_values(edited, "costs", analysis_years) - result = safe(client.update_values, public_id, payload) - if result is not None: - st.success(f"Saved {len(payload)} cost values.") - st.cache_data.clear() - with col2: - st.caption( - "The Initial column is undiscounted year-0 spend. Year columns " - "are end-of-year cashflows. Costs are risk-adjusted upward " - "(higher risk โ†’ higher cost)." - ) - - if values: - st.divider() - col_pie, col_year = st.columns(2) - with col_pie: - charts.cost_pie(values, key=f"costs_tab_pie_{public_id}") - with col_year: - benefit_values = [ - v - for v in safe(client.get_values, public_id) or [] - if v.get("table") == "benefits" - ] - charts.benefits_vs_costs_by_year( - benefit_values, values, key=f"costs_tab_by_year_{public_id}" - ) diff --git a/app/views/summary.py b/app/views/summary.py deleted file mode 100644 index 875e20d..0000000 --- a/app/views/summary.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Financial summary dashboard tab.""" - -from __future__ import annotations - -import streamlit as st - -from app.components import charts -from app.locale import CURRENCY_SYMBOL, currency_fmt, fmt_currency -from app.utils import icon -from app.views._helpers import report_meta, safe - -from core.export import build_report_data -from core.tei_client import AthenaAPIError, TEIClient - - -def render(client: TEIClient, tool: dict) -> None: - st.markdown( - f"

{icon('bar-chart-line')} Financial Summary

", - unsafe_allow_html=True, - ) - public_id = tool["id"] - report = report_meta(client, tool) - - try: - summary = client.get_summary(public_id) - except AthenaAPIError as e: - if e.status_code == 404: - st.info( - "No summary yet โ€” click **Recalculate** in the sidebar after " - "filling in benefits and costs." - ) - return - st.error(f"Athena API error: {e.detail}") - return - - npv = float(summary.get("net_present_value") or summary.get("npv") or 0) - roi = float( - summary.get("roi_percentage") - or summary.get("roi") - or summary.get("roi_pct") - or 0 - ) - payback = summary.get("payback_period_months", summary.get("payback_months")) - bpv = float(summary.get("total_benefits_pv") or 0) - cpv = float(summary.get("total_costs_pv") or 0) - - cols = st.columns(5) - cols[0].metric("NPV", f"{CURRENCY_SYMBOL}{npv/1_000_000:,.1f}M") - cols[1].metric("ROI", f"{roi:,.0f}%") - cols[2].metric( - "Payback", - f"{float(payback):.1f} months" if payback is not None else "N/A", - ) - cols[3].metric("Benefits PV", f"{CURRENCY_SYMBOL}{bpv/1_000_000:,.1f}M") - cols[4].metric("Costs PV", f"{CURRENCY_SYMBOL}{cpv/1_000_000:,.1f}M") - - st.divider() - - # โ”€โ”€ Financial visualizations โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - # Built from the live value rows so Year-0 "Initial" amounts stay - # separate (Athena's per-year summary folds them into Year 1). - values = safe(client.get_values, public_id) or [] - benefit_rows = [v for v in values if v.get("table") == "benefits"] - cost_rows = [v for v in values if v.get("table") == "costs"] - - if benefit_rows or cost_rows: - col_pie, col_bar = st.columns(2) - with col_pie: - charts.cost_pie(cost_rows, key=f"summary_pie_{public_id}") - with col_bar: - charts.benefits_bar(benefit_rows, key=f"summary_bar_{public_id}") - charts.benefits_vs_costs_by_year( - benefit_rows, cost_rows, key=f"summary_by_year_{public_id}" - ) - - # Cash flow + cumulative net โ€” the Forrester-style exhibit. - def _yearly_breakdown_from_values(): - initial = sum(float(c.get("initial") or 0) for c in cost_rows) - years: set[int] = set() - for v in [*benefit_rows, *cost_rows]: - years.update(int(y) for y in (v.get("year_values") or {})) - rows, cumulative = [], -initial - for y in sorted(years): - b = sum( - float((v.get("year_values") or {}).get(str(y), 0) or 0) - * (1 - float(v.get("risk_adjustment") or 0)) - for v in benefit_rows - ) - c = sum( - float((v.get("year_values") or {}).get(str(y), 0) or 0) - for v in cost_rows - ) - cumulative += b - c - rows.append( - {"year": y, "benefits": b, "costs": c, "net": b - c, - "cumulative_net": cumulative} - ) - return rows, initial - - yb, initial = ([], 0.0) - if benefit_rows or cost_rows: - yb, initial = _yearly_breakdown_from_values() - if not yb: - # Fallback: documented per-year summary keys (initial folded in Y1). - n = 1 - while f"benefits_year_{n}" in summary or f"costs_year_{n}" in summary: - b = float(summary.get(f"benefits_year_{n}") or 0) - c = float(summary.get(f"costs_year_{n}") or 0) - yb.append({"year": n, "benefits": b, "costs": c, "net": b - c}) - n += 1 - initial = float(summary.get("initial_costs") or 0) - if yb: - charts.cashflow(yb, initial_cost=initial, key=f"summary_cashflow_{public_id}") - with st.expander("Cash flow table"): - _cur = currency_fmt() - st.dataframe( - yb, - column_config={ - "year": st.column_config.NumberColumn("Year", format="%d"), - "benefits": st.column_config.NumberColumn("Benefits", format=_cur), - "costs": st.column_config.NumberColumn("Costs", format=_cur), - "net": st.column_config.NumberColumn("Net", format=_cur), - }, - width="stretch", - hide_index=True, - ) - else: - st.caption("No yearly breakdown in this summary.") - - # Waterfall โ€” Benefits PV down to NPV. - if bpv or cpv: - charts.waterfall([ - ("Benefits PV", bpv), - ("Costs PV", -cpv), - ("NPV", npv), - ], key=f"summary_waterfall_{public_id}") - - # Scenario comparison โ€” computed locally from current values - with st.expander("Scenario analysis (conservative / moderate / aggressive)"): - envelope = safe( - build_report_data, - client, - public_id, - include_scenarios=True, - study_slug=report.get("name", ""), - ) - if envelope and envelope.get("scenarios"): - charts.scenario_bars( - envelope["scenarios"], key=f"summary_scenarios_{public_id}" - ) - rows = [ - { - "Scenario": k, - "Benefits PV": float(v.get("total_benefits_pv") or 0), - "Costs PV": float(v.get("total_costs_pv") or 0), - "NPV": float(v.get("npv") or 0), - "ROI %": float(v.get("roi_pct") or 0), - "Payback (months)": ( - round(float(v.get("payback_months") or 0), 1) - if v.get("payback_months") is not None - else None - ), - } - for k, v in envelope["scenarios"].items() - ] - _cur = currency_fmt() - st.dataframe( - rows, - column_config={ - "Scenario": st.column_config.TextColumn("Scenario"), - "Benefits PV": st.column_config.NumberColumn("Benefits PV", format=_cur), - "Costs PV": st.column_config.NumberColumn("Costs PV", format=_cur), - "NPV": st.column_config.NumberColumn("NPV", format=_cur), - "ROI %": st.column_config.NumberColumn("ROI %", format="%.1f%%"), - "Payback (months)": st.column_config.NumberColumn( - "Payback (months)", format="%.1f" - ), - }, - width="stretch", - hide_index=True, - ) - - # Export button - st.divider() - if st.button("Build export envelope (JSON)"): - envelope = safe( - build_report_data, - client, - public_id, - include_scenarios=True, - study_slug=report.get("name", ""), - ) - if envelope: - import json - - data = json.dumps(envelope, indent=2, default=str) - st.download_button( - "Download export.json", - data=data, - file_name=f"{public_id}_export.json", - mime="application/json", - ) diff --git a/app/views/versions.py b/app/views/versions.py deleted file mode 100644 index cfaa923..0000000 --- a/app/views/versions.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Version history tab โ€” list, diff, save, restore.""" - -from __future__ import annotations - -import streamlit as st - -from app.utils import icon - -from app.views._helpers import safe -from core.tei_client import TEIClient - - -def _flatten_values(values: list[dict]) -> dict[str, dict]: - """Index a values list by field_key for easy diffing.""" - return {v.get("field_key", ""): v for v in values} - - -def _diff_rows(a: dict[str, dict], b: dict[str, dict]) -> list[dict]: - """Return one row per field with side-by-side year values.""" - keys = sorted(set(a.keys()) | set(b.keys())) - rows: list[dict] = [] - - def _years_of(v: dict) -> dict: - """Accept both friendly (year_values) and wire (nested years) shapes.""" - if isinstance(v.get("year_values"), dict): - return {str(k): val for k, val in v["year_values"].items()} - if isinstance(v.get("years"), dict): - return { - str(k): (cell or {}).get("value") - for k, cell in v["years"].items() - } - if v.get("value") is not None: - return {"1": v["value"]} - return {} - - for k in keys: - av = a.get(k, {}) or {} - bv = b.get(k, {}) or {} - ay = _years_of(av) - by = _years_of(bv) - years = sorted(set(ay.keys()) | set(by.keys()), key=lambda x: int(x)) - for y in years: - a_val = float(ay.get(y) or 0) - b_val = float(by.get(y) or 0) - if abs(a_val - b_val) < 1e-9: - continue - rows.append( - { - "field_key": k, - "year": y, - "left": a_val, - "right": b_val, - "delta": b_val - a_val, - } - ) - return rows - - -def render(client: TEIClient, tool: dict) -> None: - st.markdown( - f"

{icon('clock-history')} Versions

", - unsafe_allow_html=True, - ) - public_id = tool["id"] - - versions = safe(client.list_versions, public_id) or [] - versions = sorted( - versions, key=lambda v: int(v.get("version_number") or 0), reverse=True - ) - - # Save new version - with st.expander("Save current state as a new version", expanded=not versions): - note = st.text_area( - "Version note", - placeholder=( - "What changed? E.g. 'CFO confirmed 1.8M contacts/month; " - "raised legacy license cost from $160 to $180/agent.'" - ), - ) - if st.button("Save version", disabled=not note.strip()): - result = safe(client.save_version, public_id, note.strip()) - if result: - st.success( - f"Saved version {result.get('version_number', '?')}." - ) - st.rerun() - - if not versions: - st.info("No versions saved yet.") - return - - # Listing - st.subheader("History") - rows = [] - for v in versions: - snap = v.get("summary_snapshot") or v.get("summary") or {} - rows.append( - { - "Version": v.get("version_number"), - "Date": v.get("created_at") or v.get("date"), - "NPV": float(snap.get("net_present_value") or snap.get("npv") or 0), - "ROI %": float( - snap.get("roi_percentage") - or snap.get("roi") - or snap.get("roi_pct") - or 0 - ), - "Note": v.get("note", ""), - } - ) - st.dataframe(rows, width="stretch", hide_index=True) - - - # Compare two versions - st.subheader("Compare") - if len(versions) < 2: - st.caption("Save two or more versions to compare.") - return - labels = {f"v{v['version_number']} โ€” {v.get('note', '')[:40]}": v for v in versions} - keys = list(labels.keys()) - c1, c2 = st.columns(2) - with c1: - left_label = st.selectbox("Left (older)", keys, index=min(1, len(keys) - 1)) - with c2: - right_label = st.selectbox("Right (newer)", keys, index=0) - - if left_label == right_label: - st.caption("Pick two different versions to see a diff.") - return - - left = safe(client.get_version, public_id, labels[left_label]["version_number"]) - right = safe(client.get_version, public_id, labels[right_label]["version_number"]) - if not (left and right): - return - - a_values = left.get("values_snapshot") or left.get("values") or [] - b_values = right.get("values_snapshot") or right.get("values") or [] - diff = _diff_rows(_flatten_values(a_values), _flatten_values(b_values)) - if not diff: - st.success("No value differences between these versions.") - else: - st.dataframe(diff, width="stretch", hide_index=True) - diff --git a/core/bootstrap.py b/core/bootstrap.py index 7f362b3..dc6965a 100644 --- a/core/bootstrap.py +++ b/core/bootstrap.py @@ -7,10 +7,11 @@ From *any* notebook in the repo (root, ``studies//notebooks/``, โ€ฆ):: pal = init() # loads .env, builds client, tests it pal.client.list_reports() -or, for a study notebook:: - - pal = init(study="202602_AmazonConnect") - pal.config.STUDY_SLUG, pal.seed_data.BENEFITS +(Pattern studies under ``studies/`` are self-contained โ€” they carry their +own engine and venv and never import ``core``. The legacy ``study=`` +parameter loaded a study's ``config.py``/``seed_data.py``; those modules +were retired with the study migrations, so ``init()`` is now purely the +Athena connection bootstrap.) If ``core`` itself can't be imported (fresh kernel, notebook cwd deep in the tree), put this two-liner first โ€” it is the only path juggling left anywhere:: diff --git a/core/export/report_data.py b/core/export/report_data.py index a27a836..1e67070 100644 --- a/core/export/report_data.py +++ b/core/export/report_data.py @@ -138,7 +138,7 @@ def build_report_data( include_scenarios: if True, locally compute conservative / moderate / aggressive summaries and attach them under ``scenarios``. study_slug: optional human-friendly study identifier (e.g. - ``"202602_AmazonConnect"``) โ€” written into ``metadata``. + ``"202602_TEI_Amazon_Connect"``) โ€” written into ``metadata``. Returns: A dict with keys:: diff --git a/core/notebook_helpers/__init__.py b/core/notebook_helpers/__init__.py deleted file mode 100644 index 0a4a282..0000000 --- a/core/notebook_helpers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Notebook helpers โ€” pandas tables, plotly charts, IPython display.""" - -from core.notebook_helpers import charts, display, tables - -__all__ = ["charts", "display", "tables"] diff --git a/core/notebook_helpers/charts.py b/core/notebook_helpers/charts.py deleted file mode 100644 index 52b3593..0000000 --- a/core/notebook_helpers/charts.py +++ /dev/null @@ -1,315 +0,0 @@ -""" -Plotly charts for TEI analyses. - -Each function returns a ``plotly.graph_objects.Figure`` so callers can -``.show()`` (notebook), pass to ``st.plotly_chart`` (Streamlit), or write to -HTML / image. No styling is hard-coded beyond a neutral default palette. -""" - -from __future__ import annotations - -from collections.abc import Iterable - -import plotly.graph_objects as go - -PALETTE = { - "benefits": "#2E7D32", # green - "costs": "#C62828", # red - "net_positive": "#1565C0", # blue - "net_negative": "#C62828", - "cumulative": "#616161", # grey -} - -#: Visual theme โ€” override per study/client with :func:`apply_theme`. -#: Hex colours; fonts are CSS font-family strings. -THEME = { - "heading_font": "Helvetica Neue, Arial, sans-serif", - "body_font": "Helvetica, Arial, sans-serif", - "font_color": "#1F2937", - # Circle-chart slice colours aโ€“j, used in order. - "pie_colors": [ - "#1565C0", # a - "#2E7D32", # b - "#C62828", # c - "#F9A825", # d - "#6A1B9A", # e - "#00838F", # f - "#EF6C00", # g - "#5D4037", # h - "#37474F", # i - "#AD1457", # j - ], - "bar_green": "#2E7D32", - "bar_red": "#C62828", -} - - -def apply_theme(**overrides) -> dict: - """ - Override theme values for all charts in this session. - - Accepts any THEME key. ``pie_colors`` may be a list (used in order) or - a dict keyed ``"a"``โ€“``"j"`` (sorted alphabetically). Returns the - active theme. Example:: - - from core.notebook_helpers import charts - charts.apply_theme( - heading_font="Georgia, serif", - font_color="#102A43", - pie_colors={"a": "#1565C0", "b": "#2E7D32"}, - bar_green="#1B5E20", - bar_red="#B71C1C", - ) - """ - for key, value in overrides.items(): - if key not in THEME: - raise KeyError( - f"Unknown theme key {key!r}. Valid keys: {sorted(THEME)}" - ) - if key == "pie_colors" and isinstance(value, dict): - value = [value[k] for k in sorted(value)] - THEME[key] = value - return THEME - - -def _themed(fig: go.Figure) -> go.Figure: - """Apply theme fonts/colours to a figure's layout.""" - fig.update_layout( - font={"family": THEME["body_font"], "color": THEME["font_color"]}, - title_font={ - "family": THEME["heading_font"], - "color": THEME["font_color"], - }, - legend_font={"family": THEME["body_font"], "color": THEME["font_color"]}, - ) - return fig - - -def cashflow_chart( - yearly_breakdown: list[dict], - *, - title: str = "Cash Flow Analysis (Risk-Adjusted)", - initial_cost: float = 0.0, -) -> go.Figure: - """ - Stacked bars of benefits & costs by year + cumulative net line. - - Mirrors the chart on page 25 of the Forrester Amazon Connect TEI study. - """ - if not yearly_breakdown: - return go.Figure(layout={"title": title}) - - years = ["Initial"] + [f"Year {row['year']}" for row in yearly_breakdown] - benefits = [0.0] + [float(row.get("benefits", 0)) for row in yearly_breakdown] - costs = [-float(initial_cost)] + [ - -float(row.get("costs", 0)) for row in yearly_breakdown - ] - # cumulative_net assumes initial cost has already been deducted - cumulative = [-float(initial_cost)] + [ - float(row.get("cumulative_net", 0)) for row in yearly_breakdown - ] - - fig = go.Figure() - fig.add_bar( - name="Total benefits", - x=years, - y=benefits, - marker_color=THEME["bar_green"], - ) - fig.add_bar( - name="Total costs", - x=years, - y=costs, - marker_color=THEME["bar_red"], - ) - fig.add_scatter( - name="Cumulative net benefits", - x=years, - y=cumulative, - mode="lines+markers", - line={"color": PALETTE["cumulative"], "width": 3}, - ) - fig.update_layout( - title=title, - barmode="relative", - yaxis_tickformat="$,.0f", - legend={"orientation": "h", "y": -0.15}, - margin={"l": 40, "r": 20, "t": 60, "b": 40}, - ) - return _themed(fig) - - -def benefits_bar(items: list[dict], *, title: str = "Benefits (Three-Year)") -> go.Figure: - """Horizontal bars of risk-adjusted three-year totals per benefit.""" - labels: list[str] = [] - totals: list[float] = [] - for it in items: - rf = float(it.get("risk_adjustment") or 0.0) - yv = it.get("year_values") or {} - ra_total = sum(float(v or 0) * (1.0 - rf) for v in yv.values()) - labels.append(it.get("label", "") or it.get("field_key", "")) - totals.append(ra_total) - - fig = go.Figure( - go.Bar( - x=totals, - y=labels, - orientation="h", - marker_color=THEME["bar_green"], - text=[f"${t/1_000_000:,.1f}M" for t in totals], - textposition="auto", - ) - ) - fig.update_layout( - title=title, - xaxis_tickformat="$,.0f", - yaxis={"autorange": "reversed"}, - margin={"l": 40, "r": 20, "t": 60, "b": 40}, - ) - return _themed(fig) - - -def cost_breakdown_pie( - items: list[dict], *, title: str = "Cost Breakdown (Three-Year, Risk-Adjusted)" -) -> go.Figure: - """Pie chart of risk-adjusted costs by category/label.""" - labels: list[str] = [] - values: list[float] = [] - for it in items: - rf = float(it.get("risk_adjustment") or 0.0) - yv = it.get("year_values") or {} - initial = float(it.get("initial") or 0.0) - ra_total = ( - initial * (1.0 + rf) - + sum(float(v or 0) * (1.0 + rf) for v in yv.values()) - ) - labels.append(it.get("label", "") or it.get("field_key", "")) - values.append(ra_total) - - fig = go.Figure(go.Pie(labels=labels, values=values, hole=0.35, - marker={"colors": THEME["pie_colors"]})) - fig.update_layout(title=title, margin={"l": 40, "r": 20, "t": 60, "b": 40}) - return _themed(fig) - - -def benefits_vs_costs_by_year( - benefit_items: list[dict], - cost_items: list[dict], - *, - title: str = "Benefits vs Costs by Year (Risk-Adjusted)", -) -> go.Figure: - """ - Grouped bars of risk-adjusted benefits and costs per year, with an - Initial (Year 0) column for one-time costs. - - Accepts the friendly value rows from ``TEIClient.get_values``: - benefit values are nominal (field-level risk adjustment applied here); - cost values are stored already risk-adjusted (Palladium convention), - with ``initial`` carrying the Year-0 amount. - """ - years: set[int] = set() - for it in [*benefit_items, *cost_items]: - years.update(int(y) for y in (it.get("year_values") or {})) - year_list = sorted(years) or [1, 2, 3] - - benefits_by_year: dict[int, float] = dict.fromkeys(year_list, 0.0) - costs_by_year: dict[int, float] = dict.fromkeys(year_list, 0.0) - initial_total = 0.0 - - for it in benefit_items: - rf = float(it.get("risk_adjustment") or 0.0) - for y, v in (it.get("year_values") or {}).items(): - benefits_by_year[int(y)] += float(v or 0) * (1.0 - rf) - for it in cost_items: - initial_total += float(it.get("initial") or 0.0) - for y, v in (it.get("year_values") or {}).items(): - costs_by_year[int(y)] += float(v or 0) - - x = ["Initial"] + [f"Year {y}" for y in year_list] - benefits = [0.0] + [benefits_by_year[y] for y in year_list] - costs = [initial_total] + [costs_by_year[y] for y in year_list] - - fig = go.Figure() - fig.add_bar(name="Benefits", x=x, y=benefits, marker_color=THEME["bar_green"], - text=[f"${v/1_000_000:,.1f}M" if v else "" for v in benefits], - textposition="outside") - fig.add_bar(name="Costs", x=x, y=costs, marker_color=THEME["bar_red"], - text=[f"${v/1_000_000:,.1f}M" if v else "" for v in costs], - textposition="outside") - fig.update_layout( - title=title, - barmode="group", - yaxis_tickformat="$,.0f", - legend={"orientation": "h", "y": -0.15}, - margin={"l": 40, "r": 20, "t": 60, "b": 40}, - ) - return _themed(fig) - - -def scenario_comparison(scenarios: dict) -> go.Figure: - """Grouped bars comparing NPV and Costs PV across scenarios.""" - keys: list[str] = list(scenarios.keys()) - if not keys: - return go.Figure() - benefits = [float(scenarios[k].get("total_benefits_pv") or 0) for k in keys] - costs = [float(scenarios[k].get("total_costs_pv") or 0) for k in keys] - npvs = [float(scenarios[k].get("npv") or 0) for k in keys] - - fig = go.Figure() - fig.add_bar(name="Benefits PV", x=keys, y=benefits, marker_color=THEME["bar_green"]) - fig.add_bar(name="Costs PV", x=keys, y=costs, marker_color=THEME["bar_red"]) - fig.add_bar(name="NPV", x=keys, y=npvs, marker_color=PALETTE["net_positive"]) - fig.update_layout( - title="Scenario Comparison", - barmode="group", - yaxis_tickformat="$,.0f", - legend={"orientation": "h", "y": -0.15}, - ) - return _themed(fig) - - -def cumulative_benefits_chart( - yearly_breakdown: list[dict], - *, - title: str = "Cumulative Net Benefits", -) -> go.Figure: - """Single-line cumulative net benefits trajectory.""" - if not yearly_breakdown: - return go.Figure(layout={"title": title}) - years = [f"Year {row['year']}" for row in yearly_breakdown] - cumulative = [float(row.get("cumulative_net", 0)) for row in yearly_breakdown] - fig = go.Figure( - go.Scatter( - x=years, - y=cumulative, - mode="lines+markers", - fill="tozeroy", - line={"color": PALETTE["net_positive"], "width": 3}, - ) - ) - fig.update_layout(title=title, yaxis_tickformat="$,.0f") - return _themed(fig) - - -def waterfall(values: Iterable[tuple[str, float]], *, title: str = "TEI Waterfall") -> go.Figure: - """ - Generic waterfall (pass tuples of (label, value)). - - Used by 03_business_case to show: Benefits PV โ†’ Costs PV โ†’ NPV. - """ - labels, amounts = zip(*values, strict=True) if values else ([], []) - measures = ["relative"] * (len(labels) - 1) + ["total"] if labels else [] - fig = go.Figure( - go.Waterfall( - x=list(labels), - y=list(amounts), - measure=measures, - text=[f"${v/1_000_000:,.1f}M" for v in amounts], - textposition="outside", - increasing={"marker": {"color": THEME["bar_green"]}}, - decreasing={"marker": {"color": THEME["bar_red"]}}, - totals={"marker": {"color": PALETTE["net_positive"]}}, - ) - ) - fig.update_layout(title=title, yaxis_tickformat="$,.0f") - return _themed(fig) diff --git a/core/notebook_helpers/display.py b/core/notebook_helpers/display.py deleted file mode 100644 index f593ba0..0000000 --- a/core/notebook_helpers/display.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -IPython display helpers โ€” KPI cards, formatted summary blocks, alerts. - -Functions are notebook-safe: they fall back to plain ``print`` when running -outside Jupyter / when IPython is not available. -""" - -from __future__ import annotations - -from typing import Any - -try: # pragma: no cover โ€“ IPython is a soft dep - from IPython.display import HTML, display - - _IPY = True -except Exception: # pragma: no cover - _IPY = False - - -def _money(value: Any, default: str = "โ€”") -> str: - try: - v = float(value) - except (TypeError, ValueError): - return default - if abs(v) >= 1_000_000_000: - return f"${v/1_000_000_000:,.1f}B" - if abs(v) >= 1_000_000: - return f"${v/1_000_000:,.1f}M" - if abs(v) >= 1_000: - return f"${v/1_000:,.1f}K" - return f"${v:,.0f}" - - -def _pct(value: Any, default: str = "โ€”") -> str: - try: - v = float(value) - except (TypeError, ValueError): - return default - return f"{v:,.0f}%" - - -def _months(value: Any, default: str = "N/A") -> str: - if value is None: - return default - try: - v = float(value) - except (TypeError, ValueError): - return default - if v < 6: - return f"<6 months ({v:.1f})" - return f"{v:.1f} months" - - -def kpi_cards(summary: dict, *, title: str | None = None) -> Any: - """ - Render a row of KPI cards (NPV, ROI, Payback, Benefits PV). - - In notebooks, returns/displays inline HTML. Outside IPython, prints a - plain text version. - """ - npv = _money(summary.get("npv")) - roi = _pct(summary.get("roi") or summary.get("roi_pct")) - payback = _months(summary.get("payback_months")) - benefits_pv = _money(summary.get("total_benefits_pv")) - costs_pv = _money(summary.get("total_costs_pv")) - - if not _IPY: # pragma: no cover - print(title or "TEI Summary") - print(f" NPV: {npv} ROI: {roi} Payback: {payback}") - print(f" Benefits PV: {benefits_pv} Costs PV: {costs_pv}") - return None - - title_html = ( - f'
' - f"{title}
" - if title - else "" - ) - card_style = ( - "flex:1;min-width:140px;padding:14px 18px;margin:4px;border-radius:8px;" - "background:#f7f9fc;border:1px solid #e3e8ee;" - ) - label_style = "font-size:0.78em;color:#6b7480;text-transform:uppercase;letter-spacing:0.04em;" - value_style = "font-size:1.6em;font-weight:600;color:#1a2540;margin-top:4px;" - - cards = [ - ("NPV", npv), - ("ROI", roi), - ("Payback", payback), - ("Benefits PV", benefits_pv), - ("Costs PV", costs_pv), - ] - cards_html = "".join( - f'
' - f'
{label}
' - f'
{value}
' - f"
" - for label, value in cards - ) - html = ( - f'
{title_html}' - f'
{cards_html}
' - f"
" - ) - return display(HTML(html)) - - -def summary_panel(summary: dict, *, title: str = "TEI Financial Summary") -> None: - """Plain-text bordered summary block (mirrors the PDF Cash Flow Analysis).""" - width = 60 - print("โ•" * width) - print(f" {title}") - print("โ•" * width) - print(f" Benefits PV : {_money(summary.get('total_benefits_pv')):>20}") - print(f" Costs PV : {_money(summary.get('total_costs_pv')):>20}") - print("โ”€" * width) - print(f" NPV : {_money(summary.get('npv')):>20}") - roi_val = summary.get("roi") or summary.get("roi_pct") - print(f" ROI : {_pct(roi_val):>20}") - print(f" Payback : {_months(summary.get('payback_months')):>20}") - print("โ•" * width) - - -def alert(text: str, kind: str = "info") -> Any: - """Coloured alert box for notebooks ('info', 'success', 'warning', 'error').""" - colors = { - "info": ("#0277bd", "#e1f5fe"), - "success": ("#2e7d32", "#e8f5e9"), - "warning": ("#ef6c00", "#fff3e0"), - "error": ("#c62828", "#ffebee"), - } - fg, bg = colors.get(kind, colors["info"]) - if not _IPY: # pragma: no cover - print(f"[{kind.upper()}] {text}") - return None - html = ( - f'
' - f"{text}
" - ) - return display(HTML(html)) diff --git a/core/notebook_helpers/tables.py b/core/notebook_helpers/tables.py deleted file mode 100644 index ab3473e..0000000 --- a/core/notebook_helpers/tables.py +++ /dev/null @@ -1,127 +0,0 @@ -""" -Pandas dataframe builders for benefit / cost / summary tables. - -Each builder accepts the friendly value-row dicts returned by -``core.tei_client.TEIClient.get_values`` and returns a -nicely-formatted DataFrame for display in notebooks. -""" - -from __future__ import annotations - -from collections.abc import Iterable -from typing import Any - -import pandas as pd - -from core.calculations import risk_adjust_benefit, risk_adjust_cost - - -def _years_in_data(items: Iterable[dict]) -> list[int]: - years: set[int] = set() - for it in items: - for k in (it.get("year_values") or {}): - try: - years.add(int(k)) - except (TypeError, ValueError): - continue - return sorted(years) - - -def benefits_table(items: list[dict]) -> pd.DataFrame: - """Tidy benefits dataframe with one row per benefit, year columns, totals.""" - if not items: - return pd.DataFrame( - columns=["field_key", "label", "category", "risk_adjustment"] - ) - years = _years_in_data(items) - rows: list[dict[str, Any]] = [] - for it in items: - rf = float(it.get("risk_adjustment") or 0.0) - yv = it.get("year_values") or {} - row = { - "field_key": it.get("field_key", ""), - "label": it.get("label", "") or it.get("field_key", ""), - "category": it.get("category", ""), - "risk_adjustment": rf, - } - nominal_total = 0.0 - ra_total = 0.0 - for y in years: - v = float(yv.get(str(y)) or 0.0) - ra = risk_adjust_benefit(v, rf) - row[f"Year {y}"] = v - row[f"Year {y} (RA)"] = ra - nominal_total += v - ra_total += ra - row["Total"] = nominal_total - row["Total (RA)"] = ra_total - rows.append(row) - return pd.DataFrame(rows) - - -def costs_table(items: list[dict]) -> pd.DataFrame: - """Tidy costs dataframe โ€” adds an Initial column when present.""" - if not items: - return pd.DataFrame( - columns=["field_key", "label", "category", "risk_adjustment", "Initial"] - ) - years = _years_in_data(items) - rows: list[dict[str, Any]] = [] - for it in items: - rf = float(it.get("risk_adjustment") or 0.0) - yv = it.get("year_values") or {} - initial = float(it.get("initial") or 0.0) - row = { - "field_key": it.get("field_key", ""), - "label": it.get("label", "") or it.get("field_key", ""), - "category": it.get("category", ""), - "risk_adjustment": rf, - "Initial": initial, - "Initial (RA)": risk_adjust_cost(initial, rf), - } - nominal_total = initial - ra_total = risk_adjust_cost(initial, rf) - for y in years: - v = float(yv.get(str(y)) or 0.0) - ra = risk_adjust_cost(v, rf) - row[f"Year {y}"] = v - row[f"Year {y} (RA)"] = ra - nominal_total += v - ra_total += ra - row["Total"] = nominal_total - row["Total (RA)"] = ra_total - rows.append(row) - return pd.DataFrame(rows) - - -def summary_table(summary: dict) -> pd.DataFrame: - """Single-row summary dataframe of headline KPIs.""" - pb = summary.get("payback_months") - pb_str = f"{float(pb):.1f} months" if pb not in (None, "") else "N/A" - data = { - "NPV": [float(summary.get("npv") or 0)], - "ROI %": [float(summary.get("roi") or summary.get("roi_pct") or 0)], - "Payback": [pb_str], - "Benefits PV": [float(summary.get("total_benefits_pv") or 0)], - "Costs PV": [float(summary.get("total_costs_pv") or 0)], - "Discount rate": [float(summary.get("discount_rate") or 0)], - "Analysis years": [int(summary.get("analysis_years") or 0)], - } - return pd.DataFrame(data) - - -def cashflow_table(summary: dict) -> pd.DataFrame: - """Per-year cashflow dataframe from a summary's ``yearly_breakdown``.""" - yb = summary.get("yearly_breakdown") or [] - if not yb: - return pd.DataFrame(columns=["Year", "Benefits", "Costs", "Net", "Cumulative"]) - df = pd.DataFrame(yb) - rename = { - "year": "Year", - "benefits": "Benefits", - "costs": "Costs", - "net": "Net", - "cumulative_net": "Cumulative", - } - df = df.rename(columns=rename) - return df diff --git a/pyproject.toml b/pyproject.toml index 2383f0b..614d41c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,6 @@ dependencies = [ [project.optional-dependencies] notebooks = ["jupyter>=1.0", "ipython>=8.0"] -app = ["streamlit>=1.30"] dev = ["pytest>=7.4", "ruff>=0.1"] [project.scripts] @@ -28,7 +27,7 @@ palladium = "core.cli.main:main" [tool.setuptools.packages.find] include = ["core*", "palladium*"] -exclude = ["tests*", "studies*", "app*", "docs*"] +exclude = ["tests*", "studies*", "docs*"] [tool.pytest.ini_options] testpaths = ["tests"] @@ -46,4 +45,3 @@ ignore = ["E501"] # line length handled by formatter [tool.ruff.lint.per-file-ignores] "studies/*/notebooks/*.ipynb" = ["E402"] "tests/*" = ["F401"] -"app/main.py" = ["E402"] # sys.path bootstrap before app imports diff --git a/requirements.txt b/requirements.txt index 5daa3c0..d50ff81 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,6 @@ requests>=2.31 python-dotenv>=1.0 jupyter>=1.0 -streamlit>=1.30 pandas>=2.0 plotly>=5.18 numpy>=1.26 diff --git a/studies/202512_GenesysCX/.DS_Store b/studies/202512_GenesysCX/.DS_Store deleted file mode 100644 index b4e6492..0000000 Binary files a/studies/202512_GenesysCX/.DS_Store and /dev/null differ diff --git a/studies/202512_GenesysCX/README.md b/studies/202512_GenesysCX/README.md deleted file mode 100644 index 585dbd9..0000000 --- a/studies/202512_GenesysCX/README.md +++ /dev/null @@ -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`. diff --git a/studies/202512_GenesysCX/__init__.py b/studies/202512_GenesysCX/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/studies/202512_GenesysCX/config.py b/studies/202512_GenesysCX/config.py deleted file mode 100644 index 1dc9792..0000000 --- a/studies/202512_GenesysCX/config.py +++ /dev/null @@ -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") diff --git a/studies/202512_GenesysCX/notebooks/00_provision.ipynb b/studies/202512_GenesysCX/notebooks/00_provision.ipynb deleted file mode 100644 index 3156fac..0000000 --- a/studies/202512_GenesysCX/notebooks/00_provision.ipynb +++ /dev/null @@ -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": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
idnameverticalclient_typeemployee_countcontact_center_agent_countsupervisor_count
02Global Guardian InsuranceNoneFor-Profit120002500None
13EudaimonixNoneFor-Profit1500300None
24Aetherium ForgeNoneFor-Profit50042None
\n", - "
" - ], - "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": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
Global Guardian Insurance
id2
nameGlobal Guardian Insurance
abbreviated_nameGGI
verticalNone
client_typeFor-Profit
employee_count12000
revenue4500000000.0
contact_center_agent_count2500
service_desk_agent_count300
supervisor_countNone
location_count120
\n", - "
" - ], - "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": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
idnamestatusopportunity
01Secure Cloud Infrastructure ModernizationDraftSecure Cloud Infrastructure Modernization
\n", - "
" - ], - "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": [ - "
\n", - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
metricpublished (Forrester)expected (Athena methodology)athena actualvs expected
0total_benefits_pv14,840,63814,840,64014,840,637-0.00%
1total_costs_pv4,057,1703,938,1703,938,170+0.00%
2net_present_value10,783,46810,902,47010,902,466-0.00%
3roi_percentage266277277+0.01%
\n", - "
" - ], - "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 -} diff --git a/studies/202512_GenesysCX/notebooks/01_benefits.ipynb b/studies/202512_GenesysCX/notebooks/01_benefits.ipynb deleted file mode 100644 index 39349cd..0000000 --- a/studies/202512_GenesysCX/notebooks/01_benefits.ipynb +++ /dev/null @@ -1,1408 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "g1-md-intro", - "metadata": {}, - "source": [ - "# 01 \u2014 Benefits Analysis\n", - "\n", - "**Study:** Forrester *Total Economic Impact\u2122 Of CX Cloud \u2014 Cost Savings\n", - "And Business Benefits Enabled By Genesys And Salesforce* (December 2025)\n", - "\n", - "Quantify the four benefit categories Forrester identified for the\n", - "composite organization (\\$2.5B revenue, 600 CX agents / 400 concurrent\n", - "licenses, 80,000 weekly interactions @ 12 min), push them into Athena,\n", - "and verify the totals match the published study\n", - "(Benefits PV \u2248 **\\$14.84M**)." - ] - }, - { - "cell_type": "markdown", - "id": "g1-md-setup", - "metadata": {}, - "source": [ - "## Setup\n", - "\n", - "We add the project root to `sys.path` so the notebook can import `core` and\n", - "the study's local modules without `pip install -e .`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "01-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": 2, - "id": "g1-code-imports", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
Study: 202512_GenesysCX \u2022 discount rate 10% \u2022 3-year horizon
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from core.calculations import npv, risk_adjust_benefit\n", - "from core.notebook_helpers import charts, display, tables\n", - "display.alert(\n", - " f'Study: {config.STUDY_SLUG} \u2022 discount rate {config.DISCOUNT_RATE:.0%} '\n", - " f'\u2022 {config.ANALYSIS_YEARS}-year horizon',\n", - " 'info',\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "g1-md-benefits-table", - "metadata": {}, - "source": [ - "## Benefits \u2014 what the published study quantifies\n", - "\n", - "Forrester quantifies four benefit categories for the composite. The MTTR,\n", - "self-service deflection and revenue-lift assumptions are documented in the\n", - "`notes` field of every seed row (full PDF cell references \u2014 A1\u2013A4, B1\u2013B8,\n", - "C1\u2013C6, D1\u2013D3 \u2014 preserved for the audit trail):\n", - "\n", - "| Ref | Benefit | Y1 | Y2 | Y3 | Risk Adj |\n", - "|---|---|---|---|---|---|\n", - "| At | Retirement of legacy systems with CX Cloud adoption | \\$680K | \\$930K | \\$930K | 5% |\n", - "| Bt | Self-service savings (40 FTEs reallocated) | \\$2.33M | \\$2.33M | \\$2.33M | 15% |\n", - "| Ct | CX agent efficiency (MTTR 12\u219210 min, 60k weekly interactions) | \\$2.91M | \\$2.91M | \\$2.91M | 10% |\n", - "| Dt | Incremental sales from agent assist (1.5% lift \u00d7 8% margin \u00d7 \\$500M) | \\$0.6M | \\$0.6M | \\$0.6M | 5% |\n", - "\n", - "All four are seeded in `seed_data.BENEFITS` with full source notes." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "g1-code-benefits-table", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 field_keylabelcategoryrisk_adjustmentYear 1Year 1 (RA)Year 2Year 2 (RA)Year 3Year 3 (RA)TotalTotal (RA)
0legacy_retirementRetirement of legacy systems with CX Cloud adoptionCost Savings0.050000$680,000$646,000$930,000$883,500$930,000$883,500$2,540,000$2,413,000
1self_service_savingsCost savings from reallocated workers and avoided seasonal hires with increased customer self-serviceProductivity0.150000$2,329,600$1,980,160$2,329,600$1,980,160$2,329,600$1,980,160$6,988,800$5,940,480
2agent_efficiencyCX agent efficiency gainsProductivity0.100000$2,912,000$2,620,800$2,912,000$2,620,800$2,912,000$2,620,800$8,736,000$7,862,400
3agent_assist_salesIncremental sales from agent assist capabilitiesRevenue0.050000$600,000$570,000$600,000$570,000$600,000$570,000$1,800,000$1,710,000
\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = tables.benefits_table(seed.BENEFITS)\n", - "df.style.format({col: '${:,.0f}' for col in df.columns if col not in ('field_key','label','category','risk_adjustment')})" - ] - }, - { - "cell_type": "markdown", - "id": "g1-md-source-notes", - "metadata": {}, - "source": [ - "### Where each benefit comes from\n", - "\n", - "The seed-data notes capture the per-line Forrester math so the model\n", - "stays auditable. Surface them next to the headline numbers:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "g1-code-source-notes", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 RefBenefitRisk Adj3-yr NominalSource / formula
0AtRetirement of legacy systems with CX Cloud adoption5%$2,540,000PDF A1\u2013A4. Telephony $250k Y1 ramping to $500k (legacy sunset completes mid-Y1) + WFM/recording/transcription apps $100k + reduced dev effort $230k (2,400 hrs @ $94) + reduced platform mgmt $100k (1,500 hrs @ $65). Risk adj 5%.
1BtCost savings from reallocated workers and avoided seasonal hires with increased customer self-service15%$6,988,800PDF B1\u2013B8. Self-service completion 15%\u219225% on 80k weekly interactions \u2192 8,000 deflected/week \u2192 40 FTEs @ $58,240 fully burdened. Risk adj 15%. (PDF B7 formula cites B2 where the 12-min interaction length is meant; 40 FTEs is correct.)
2CtCX agent efficiency gains10%$8,736,000PDF C1\u2013C6. MTTR 12\u219210 min on 60k agent-handled interactions per week \u2192 104,000 hrs/yr @ $28 fully burdened. Risk adj 10%.
3DtIncremental sales from agent assist capabilities5%$1,800,000PDF D1\u2013D3. $500M revenue impacted (20% of $2.5B) \u00d7 1.5% lift \u00d7 8% gross margin. Risk adj 5%.
\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import pandas as pd\n", - "\n", - "notes_df = pd.DataFrame([\n", - " {\n", - " 'Ref': ref,\n", - " 'Benefit': b['label'],\n", - " 'Risk Adj': f\"{b['risk_adjustment']:.0%}\",\n", - " '3-yr Nominal': sum(b['year_values'].values()),\n", - " 'Source / formula': b['notes'],\n", - " }\n", - " for ref, b in zip(['At', 'Bt', 'Ct', 'Dt'], seed.BENEFITS)\n", - "])\n", - "notes_df.style.format({'3-yr Nominal': '${:,.0f}'}).set_properties(**{'text-align': 'left'})" - ] - }, - { - "cell_type": "markdown", - "id": "g1-md-validation", - "metadata": {}, - "source": [ - "## Local validation against the PDF\n", - "\n", - "Re-derive the per-benefit risk-adjusted PV (`value \u00d7 (1 \u2212 risk_adj)` per\n", - "year, NPV at 10%) and confirm we land on Forrester's **\\$14,840,638**\n", - "total within rounding." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "g1-code-validation", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 BenefitY1 (RA)Y2 (RA)Y3 (RA)PV
0Retirement of legacy systems with CX Cloud adoption$646,000$883,500$883,500$1,981,225
1Cost savings from reallocated workers and avoided seasonal hires with increased customer self-service$1,980,160$1,980,160$1,980,160$4,924,365
2CX agent efficiency gains$2,620,800$2,620,800$2,620,800$6,517,542
3Incremental sales from agent assist capabilities$570,000$570,000$570,000$1,417,506
4TOTAL$5,816,960$6,054,460$6,054,460$14,840,637
\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "rows = []\n", - "for b in seed.BENEFITS:\n", - " rf = b['risk_adjustment']\n", - " yr = [b['year_values'][str(y)] for y in (1, 2, 3)]\n", - " yr_ra = [risk_adjust_benefit(v, rf) for v in yr]\n", - " pv = npv(yr_ra, config.DISCOUNT_RATE)\n", - " rows.append({\n", - " 'Benefit': b['label'],\n", - " 'Y1 (RA)': yr_ra[0],\n", - " 'Y2 (RA)': yr_ra[1],\n", - " 'Y3 (RA)': yr_ra[2],\n", - " 'PV': pv,\n", - " })\n", - "df_check = pd.DataFrame(rows)\n", - "df_check.loc[len(df_check)] = ['TOTAL', df_check['Y1 (RA)'].sum(), df_check['Y2 (RA)'].sum(), df_check['Y3 (RA)'].sum(), df_check['PV'].sum()]\n", - "df_check.style.format({c: '${:,.0f}' for c in df_check.columns if c != 'Benefit'})" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "g1-code-pv-check", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
Computed Benefits PV: $14,840,637
Forrester target: $14,840,638
\u0394 = $-1 (rounding)
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "expected_pv = seed.PUBLISHED['total_benefits_pv'] # 14,840,638\n", - "computed_pv = df_check.iloc[-1]['PV']\n", - "delta = computed_pv - expected_pv\n", - "kind = 'success' if abs(delta) < 100 else 'warning'\n", - "display.alert(\n", - " f'Computed Benefits PV: ${computed_pv:,.0f}
'\n", - " f'Forrester target: ${expected_pv:,.0f}
'\n", - " f'\u0394 = ${delta:,.0f} (rounding)',\n", - " kind,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "g1-md-visualize", - "metadata": {}, - "source": [ - "## Visualize\n", - "\n", - "Horizontal bar chart of risk-adjusted three-year totals \u2014 mirrors the\n", - "*Benefits (Three-Year)* exhibit on PDF p.6 of the published study." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "g1-code-bar", - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.plotly.v1+json": { - "config": { - "plotlyServerURL": "https://plot.ly" - }, - "data": [ - { - "marker": { - "color": "#2E7D32" - }, - "orientation": "h", - "text": [ - "$2.4M", - "$5.9M", - "$7.9M", - "$1.7M" - ], - "textposition": "auto", - "type": "bar", - "x": [ - 2413000, - 5940480, - 7862400, - 1710000 - ], - "y": [ - "Retirement of legacy systems with CX Cloud adoption", - "Cost savings from reallocated workers and avoided seasonal hires with increased customer self-service", - "CX agent efficiency gains", - "Incremental sales from agent assist capabilities" - ] - } - ], - "layout": { - "font": { - "color": "#1F2937", - "family": "Helvetica, Arial, sans-serif" - }, - "legend": { - "font": { - "color": "#1F2937", - "family": "Helvetica, Arial, sans-serif" - } - }, - "margin": { - "b": 40, - "l": 40, - "r": 20, - "t": 60 - }, - "template": { - "data": { - "bar": [ - { - "error_x": { - "color": "#2a3f5f" - }, - "error_y": { - "color": "#2a3f5f" - }, - "marker": { - "line": { - "color": "#E5ECF6", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "bar" - } - ], - "barpolar": [ - { - "marker": { - "line": { - "color": "#E5ECF6", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "barpolar" - } - ], - "carpet": [ - { - "aaxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "white", - "linecolor": "white", - "minorgridcolor": "white", - "startlinecolor": "#2a3f5f" - }, - "baxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "white", - "linecolor": "white", - "minorgridcolor": "white", - "startlinecolor": "#2a3f5f" - }, - "type": "carpet" - } - ], - "choropleth": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "choropleth" - } - ], - "contour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "contour" - } - ], - "contourcarpet": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "contourcarpet" - } - ], - "heatmap": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmap" - } - ], - "histogram": [ - { - "marker": { - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "histogram" - } - ], - "histogram2d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2d" - } - ], - "histogram2dcontour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2dcontour" - } - ], - "mesh3d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "mesh3d" - } - ], - "parcoords": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "parcoords" - } - ], - "pie": [ - { - "automargin": true, - "type": "pie" - } - ], - "scatter": [ - { - "fillpattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - }, - "type": "scatter" - } - ], - "scatter3d": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatter3d" - } - ], - "scattercarpet": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattercarpet" - } - ], - "scattergeo": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergeo" - } - ], - "scattergl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergl" - } - ], - "scattermap": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermap" - } - ], - "scattermapbox": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermapbox" - } - ], - "scatterpolar": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolar" - } - ], - "scatterpolargl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolargl" - } - ], - "scatterternary": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterternary" - } - ], - "surface": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "surface" - } - ], - "table": [ - { - "cells": { - "fill": { - "color": "#EBF0F8" - }, - "line": { - "color": "white" - } - }, - "header": { - "fill": { - "color": "#C8D4E3" - }, - "line": { - "color": "white" - } - }, - "type": "table" - } - ] - }, - "layout": { - "annotationdefaults": { - "arrowcolor": "#2a3f5f", - "arrowhead": 0, - "arrowwidth": 1 - }, - "autotypenumbers": "strict", - "coloraxis": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "colorscale": { - "diverging": [ - [ - 0, - "#8e0152" - ], - [ - 0.1, - "#c51b7d" - ], - [ - 0.2, - "#de77ae" - ], - [ - 0.3, - "#f1b6da" - ], - [ - 0.4, - "#fde0ef" - ], - [ - 0.5, - "#f7f7f7" - ], - [ - 0.6, - "#e6f5d0" - ], - [ - 0.7, - "#b8e186" - ], - [ - 0.8, - "#7fbc41" - ], - [ - 0.9, - "#4d9221" - ], - [ - 1, - "#276419" - ] - ], - "sequential": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "sequentialminus": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ] - }, - "colorway": [ - "#636efa", - "#EF553B", - "#00cc96", - "#ab63fa", - "#FFA15A", - "#19d3f3", - "#FF6692", - "#B6E880", - "#FF97FF", - "#FECB52" - ], - "font": { - "color": "#2a3f5f" - }, - "geo": { - "bgcolor": "white", - "lakecolor": "white", - "landcolor": "#E5ECF6", - "showlakes": true, - "showland": true, - "subunitcolor": "white" - }, - "hoverlabel": { - "align": "left" - }, - "hovermode": "closest", - "mapbox": { - "style": "light" - }, - "paper_bgcolor": "white", - "plot_bgcolor": "#E5ECF6", - "polar": { - "angularaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "bgcolor": "#E5ECF6", - "radialaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - } - }, - "scene": { - "xaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - }, - "yaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - }, - "zaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - } - }, - "shapedefaults": { - "line": { - "color": "#2a3f5f" - } - }, - "ternary": { - "aaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "baxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "bgcolor": "#E5ECF6", - "caxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - } - }, - "title": { - "x": 0.05 - }, - "xaxis": { - "automargin": true, - "gridcolor": "white", - "linecolor": "white", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "white", - "zerolinewidth": 2 - }, - "yaxis": { - "automargin": true, - "gridcolor": "white", - "linecolor": "white", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "white", - "zerolinewidth": 2 - } - } - }, - "title": { - "font": { - "color": "#1F2937", - "family": "Helvetica Neue, Arial, sans-serif" - }, - "text": "Benefits (Three-Year)" - }, - "xaxis": { - "tickformat": "$,.0f" - }, - "yaxis": { - "autorange": "reversed" - } - } - }, - "image/png": "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gAACCCCAAAIIIIAAAggggAACCCQSAQKRieRCUU0EEEAAAQQQQAABBBBAAAEEEEAAAQQSswCByMR89ag7AggggAACCCCAAAIIIIAAAggggAACiUSAQGQiuVBUEwEEEEAAAQQQQAABBBBAAAEEEEAAgcQsQCAyMV896o4AAggggAACCCCAAAIIIIAAAggggEAiESAQmUguFNVEAAEEEEAAAQQQQAABBBBAAAEEEEAgMQsQiEzMV4+6I4AAAggggAACCCCAAAIIIIAAAgggkEgEohJJPakmAggggAACCCAQp8DKrevl0LETcW5j5eUJJE+eTFLvTS7HT529vIwS4dHJkiWXAtkLSYrkKRJh7akyAggggAACCCCQMAUIRCbM60KtEEAAAQQQQCCeAhO/nyirdq6I597shkD8BPJnLShDW48mEBk/LvZCAAEEEEAAAQTiJUAgMl5M7IQAAggggAACCVVg/7H9csB8kRAIp0DuzHnDmR15IYAAAggggAACCBgBxojkNkAAAQQQQAABBBBAAAEEEEAAAQQQQAABzwUIRHpOTAEIIIAAAggggAACCCCAAAIIIIAAAgggQCCSewABBBBAAAEEEEAAAQQQQAABBBBAAAEEPBcgEOk5MQUggAACCCCAAAIIIIAAAggggAACCCCAAIFI7gEEEEAAAQQQQAABBBBAAAEEEEAAAQQQ8FyAQKTnxBSAAAIIIIAAAggggAACCCCAAAIIIIAAAgQiuQcQQAABBBBAAAEEEEAAAQQQQAABBBBAwHMBApGeE1MAAggggAACCCCAAAIIIIAAAggggAACCBCI5B5AAAEEEEAAAQQQQAABBBBAAAEEEEAAAc8FCER6TkwBCCCAAAIIIIAAAggggAACCCCAAAIIIEAgknsAAQQQQAABBBBAAAEEEEAAAQQQQAABBDwXIBDpOTEFIIAAAggggAACCCCAAAIIIIAAAggggACBSO4BBBBAAAEEEEAAAQQQQAABBBBAAAEEEPBcgECk58QUgAACCCCAAAIIIIAAAggggAACCCCAAAIEIrkHEEAAAQQQQAABBBBAAAEEEEAAAQQQQMBzAQKRnhNTAAIIIIAAAggggAACCCCAAAIIIIAAAggQiOQeQAABBBBAAAEEEEAAAQQQQAABBBBAAAHPBQhEek5MAQgggAACCCCAAAIIIIAAAggggAACCCBA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" - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "charts.benefits_bar(seed.BENEFITS).show()" - ] - }, - { - "cell_type": "markdown", - "id": "g1-md-where-tokens", - "metadata": {}, - "source": [ - "### Heads-up: three of these benefits depend on AI consumption\n", - "\n", - "Benefits **Bt** (self-service uplift), **Ct** (AI coaching / agent\n", - "efficiency) and **Dt** (agent-assist sales) are all delivered through\n", - "Genesys AI capabilities that are **billed via AI Experience tokens**.\n", - "The published study modelled \\$0 of token consumption \u2014 costs notebook\n", - "(`02_costs.ipynb`) adds the line back, and `03_business_case.ipynb`\n", - "exposes a sensitivity sweep so you can see what the AI cost does to NPV." - ] - }, - { - "cell_type": "markdown", - "id": "g1-md-push", - "metadata": {}, - "source": [ - "## Push to Athena (optional)\n", - "\n", - "When `config.TOOL_PUBLIC_ID` is set (filled in by `00_provision.ipynb`),\n", - "persist the seed values to the live TEI tool. Otherwise this cell is a\n", - "no-op so the notebook still runs offline." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "g1-code-push", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
No PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID set \u2014 skipped Athena push. Run 00_provision.ipynb to provision the tool.
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "if config.TOOL_PUBLIC_ID:\n", - " from core.tei_client import TEIClient\n", - "\n", - " client = TEIClient()\n", - " client.update_values(config.TOOL_PUBLIC_ID, seed.BENEFITS)\n", - " display.alert(f'Pushed {len(seed.BENEFITS)} benefit rows to '\n", - " f'tool {config.TOOL_PUBLIC_ID}.', 'success')\n", - "else:\n", - " display.alert(\n", - " 'No PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID set \u2014 skipped Athena push. '\n", - " 'Run 00_provision.ipynb to provision the tool.',\n", - " 'info',\n", - " )" - ] - }, - { - "cell_type": "markdown", - "id": "g1-md-next", - "metadata": {}, - "source": [ - "---\n", - "\n", - "Continue with [`02_costs.ipynb`](02_costs.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 -} diff --git a/studies/202512_GenesysCX/notebooks/02_costs.ipynb b/studies/202512_GenesysCX/notebooks/02_costs.ipynb deleted file mode 100644 index 8f2ab10..0000000 --- a/studies/202512_GenesysCX/notebooks/02_costs.ipynb +++ /dev/null @@ -1,1508 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "g2-md-intro", - "metadata": {}, - "source": [ - "# 02 \u2014 Costs Analysis\n", - "\n", - "**Study:** Forrester *TEI\u2122 Of CX Cloud \u2014 Genesys + Salesforce* (December 2025)\n", - "\n", - "Three published cost categories on a 3-year horizon at a 10% discount\n", - "rate, **plus a fourth Palladium-added line** for Genesys AI Experience\n", - "token consumption \u2014 which the published study omits despite three of its\n", - "four benefits depending on AI capabilities Genesys bills via tokens.\n", - "\n", - "Published target Costs PV = **\\$4,057,170** (the AI-tokens line is\n", - "seeded at \\$0 here; `03_business_case.ipynb` runs the sensitivity)." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "02-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": 2, - "id": "g2-code-imports", - "metadata": {}, - "outputs": [], - "source": [ - "from core.calculations import npv, risk_adjust_cost\n", - "from core.notebook_helpers import charts, display, tables" - ] - }, - { - "cell_type": "markdown", - "id": "g2-md-cost-table", - "metadata": {}, - "source": [ - "## Costs \u2014 what the deal looks like\n", - "\n", - "| Ref | Cost | Initial | Y1 | Y2 | Y3 | Risk Adj |\n", - "|---|---|---|---|---|---|---|\n", - "| Et | CX Cloud licenses (400 concurrent) | \u2014 | \\$840K | \\$840K | \\$840K | \u21915% |\n", - "| Ft | Implementation & deployment (10-week project) | \\$1.19M | \u2014 | \u2014 | \u2014 | \u219110% |\n", - "| Gt | Ongoing management (5 staff @ 30%) | \u2014 | \\$203K | \\$203K | \\$203K | \u219110% |\n", - "| **+** | **Genesys AI Experience tokens** *(Palladium add)* | \u2014 | per quote | per quote | per quote | 0% |\n", - "\n", - "Note **costs are risk-adjusted *upward*** (higher risk \u2192 higher modelled\n", - "cost) \u2014 Forrester convention, opposite of how benefits get adjusted.\n", - "\n", - "**PDF caveat:** the published Total Costs table (p.14) prints the\n", - "implementation initial as \\$1,304,600, but the detail rows, the cash flow\n", - "analysis, and the literal arithmetic (\\$1,190,000 \u00d7 1.10) all give\n", - "\\$1,309,000. The seed uses the correct figure." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "g2-code-table", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 field_keylabelcategoryrisk_adjustmentInitialInitial (RA)Year 1Year 1 (RA)Year 2Year 2 (RA)Year 3Year 3 (RA)TotalTotal (RA)
0cx_cloud_licensesCX Cloud solution costs (licenses)Subscription0.050000$0$0$840,000$882,000$840,000$882,000$840,000$882,000$2,520,000$2,646,000
1implementationImplementation and deployment costImplementation0.100000$1,190,000$1,309,000$0$0$0$0$0$0$1,190,000$1,309,000
2ongoing_managementOngoing management costsOperations0.100000$0$0$202,800$223,080$202,800$223,080$202,800$223,080$608,400$669,240
3genesys_ai_tokensGenesys AI Experience token consumptionSubscription0.000000$0$0$0$0$0$0$0$0$0$0
\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = tables.costs_table(seed.COSTS)\n", - "df.style.format({c: '${:,.0f}' for c in df.columns if c not in ('field_key','label','category','risk_adjustment')})" - ] - }, - { - "cell_type": "markdown", - "id": "g2-md-source-notes", - "metadata": {}, - "source": [ - "### Where each cost comes from" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "g2-code-source-notes", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 RefCostRisk AdjInitial3-yr Annual (Nominal)Source / formula
0EtCX Cloud solution costs (licenses)+5%$0$2,520,000PDF E1\u2013E3. Genesys Cloud CX 2 $170/user/mo + Salesforce Voice $25/user/mo + connector $25/user/mo, 400 concurrent users, 20% contractual discount \u2192 $650k + $95k + $95k. Risk adj +5%. Seat licenses ONLY \u2014 AI consumption is a separate line (genesys_ai_tokens).
1FtImplementation and deployment cost+10%$1,190,000$0PDF F1\u2013F5. 10-week implementation: 20 FTEs @ $80/hr fully burdened ($640k) + $550k professional services. Risk adj +10% \u2192 $1,309,000 (the p.14 Total Costs table's $1,304,600 is a typo in the study).
2GtOngoing management costs+10%$0$608,400PDF G1\u2013G3. 5 people @ 30% time (12 hrs/wk) @ $65/hr. Risk adj +10%.
3+ AIGenesys AI Experience token consumption+0%$0$0NOT in the published study \u2014 Forrester modeled $0 AI consumption even though benefits B (self-service uplift), C (AI coaching/assist), and D (agent assist upsell) all depend on AI capabilities that Genesys bills via AI Experience tokens. Seeded 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.
\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "import pandas as pd\n", - "\n", - "notes_df = pd.DataFrame([\n", - " {\n", - " 'Ref': ref,\n", - " 'Cost': c['label'],\n", - " 'Risk Adj': f\"+{c['risk_adjustment']:.0%}\",\n", - " 'Initial': c.get('initial', 0),\n", - " '3-yr Annual (Nominal)': sum(c['year_values'].values()),\n", - " 'Source / formula': c['notes'],\n", - " }\n", - " for ref, c in zip(['Et', 'Ft', 'Gt', '+ AI'], seed.COSTS)\n", - "])\n", - "notes_df.style.format({'Initial': '${:,.0f}', '3-yr Annual (Nominal)': '${:,.0f}'}).set_properties(**{'text-align': 'left'})" - ] - }, - { - "cell_type": "markdown", - "id": "g2-md-validation", - "metadata": {}, - "source": [ - "## Local validation against the PDF\n", - "\n", - "Reproduce the **\\$4,057,170** Costs PV from the PDF Cash Flow Analysis.\n", - "Forrester applies risk *upward* to costs (`value \u00d7 (1 + risk_adj)`),\n", - "leaves Year-0 *Initial* amounts undiscounted, and discounts Years 1\u20133\n", - "at 10%. The `genesys_ai_tokens` line is seeded at \\$0, so it doesn't\n", - "move the published total." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "g2-code-validation", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 CostInitial (RA)Y1 (RA)Y2 (RA)Y3 (RA)PV
0CX Cloud solution costs (licenses)$0$882,000$882,000$882,000$2,193,403
1Implementation and deployment cost$1,309,000$0$0$0$1,309,000
2Ongoing management costs$0$223,080$223,080$223,080$554,767
3Genesys AI Experience token consumption$0$0$0$0$0
4TOTAL$1,309,000$1,105,080$1,105,080$1,105,080$4,057,170
\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "rows = []\n", - "for c in seed.COSTS:\n", - " rf = c['risk_adjustment']\n", - " init_ra = risk_adjust_cost(c.get('initial') or 0, rf)\n", - " yr = [c['year_values'][str(y)] for y in (1, 2, 3)]\n", - " yr_ra = [risk_adjust_cost(v, rf) for v in yr]\n", - " pv = npv(yr_ra, config.DISCOUNT_RATE, initial=init_ra)\n", - " rows.append({\n", - " 'Cost': c['label'],\n", - " 'Initial (RA)': init_ra,\n", - " 'Y1 (RA)': yr_ra[0],\n", - " 'Y2 (RA)': yr_ra[1],\n", - " 'Y3 (RA)': yr_ra[2],\n", - " 'PV': pv,\n", - " })\n", - "df_check = pd.DataFrame(rows)\n", - "totals = df_check.drop(columns='Cost').sum()\n", - "df_check.loc[len(df_check)] = ['TOTAL'] + totals.tolist()\n", - "df_check.style.format({c: '${:,.0f}' for c in df_check.columns if c != 'Cost'})" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "g2-code-pv-check", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
Computed Costs PV: $4,057,170
Forrester target: $4,057,170
\u0394 = $0
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "expected_pv = seed.PUBLISHED['total_costs_pv'] # 4,057,170\n", - "computed_pv = df_check.iloc[-1]['PV']\n", - "delta = computed_pv - expected_pv\n", - "kind = 'success' if abs(delta) < 100 else 'warning'\n", - "display.alert(\n", - " f'Computed Costs PV: ${computed_pv:,.0f}
'\n", - " f'Forrester target: ${expected_pv:,.0f}
'\n", - " f'\u0394 = ${delta:,.0f}',\n", - " kind,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "id": "g2-md-athena-delta", - "metadata": {}, - "source": [ - "### Athena vs Forrester \u2014 the Year-0 discounting delta\n", - "\n", - "Forrester leaves the \\$1.309M implementation initial undiscounted in\n", - "Year 0. **Athena treats non-annual values as Year-1 cashflows**, so it\n", - "discounts the same amount (\\$1,309,000 / 1.10 = \\$1,190,000), making\n", - "Athena's Costs PV ~\\$119k *lower* than Forrester's. That's methodology,\n", - "not data error \u2014 `seed_data.ATHENA_EXPECTED` records the expected\n", - "server-side total, and `00_provision.ipynb` verifies the pipeline\n", - "matches it tightly before reconciling against the published number." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "g2-code-athena-delta", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
 metricForrester (published)Athena (expected)
0total_benefits_pv14,840,63814,840,640
1total_costs_pv4,057,1703,938,170
2net_present_value10,783,46810,902,470
3roi_percentage266277
\n" - ], - "text/plain": [ - "" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "delta_df = pd.DataFrame([\n", - " {'metric': k,\n", - " 'Forrester (published)': seed.PUBLISHED[k],\n", - " 'Athena (expected)': seed.ATHENA_EXPECTED[k]}\n", - " for k in ('total_benefits_pv', 'total_costs_pv', 'net_present_value', 'roi_percentage')\n", - "])\n", - "delta_df.style.format({\n", - " 'Forrester (published)': '{:,.0f}',\n", - " 'Athena (expected)': '{:,.0f}',\n", - "})" - ] - }, - { - "cell_type": "markdown", - "id": "g2-md-mix", - "metadata": {}, - "source": [ - "## Cost mix\n", - "\n", - "At seeded values, **CX Cloud licenses dominate** (~62% of 3-year cost),\n", - "with implementation a one-time investment. The AI-tokens slice is zero\n", - "until you enter the quote in `03_business_case.ipynb` \u2014 and that's\n", - "exactly the point: in real deals it can rival the license line." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "g2-code-pie", - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.plotly.v1+json": { - "config": { - "plotlyServerURL": "https://plot.ly" - }, - "data": [ - { - "hole": 0.35, - "labels": [ - "CX Cloud solution costs (licenses)", - "Implementation and deployment cost", - "Ongoing management costs", - "Genesys AI Experience token consumption" - ], - "marker": { - "colors": [ - "#1565C0", - "#2E7D32", - "#C62828", - "#F9A825", - "#6A1B9A", - "#00838F", - "#EF6C00", - "#5D4037", - "#37474F", - "#AD1457" - ] - }, - "type": "pie", - "values": [ - 2646000, - 1309000, - 669240.0000000001, - 0 - ] - } - ], - "layout": { - "font": { - "color": "#1F2937", - "family": "Helvetica, Arial, sans-serif" - }, - "legend": { - "font": { - "color": "#1F2937", - "family": "Helvetica, Arial, sans-serif" - } - }, - "margin": { - "b": 40, - "l": 40, - "r": 20, - "t": 60 - }, - "template": { - "data": { - "bar": [ - { - "error_x": { - "color": "#2a3f5f" - }, - "error_y": { - "color": "#2a3f5f" - }, - "marker": { - "line": { - "color": "#E5ECF6", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "bar" - } - ], - "barpolar": [ - { - "marker": { - "line": { - "color": "#E5ECF6", - "width": 0.5 - }, - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "barpolar" - } - ], - "carpet": [ - { - "aaxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "white", - "linecolor": "white", - "minorgridcolor": "white", - "startlinecolor": "#2a3f5f" - }, - "baxis": { - "endlinecolor": "#2a3f5f", - "gridcolor": "white", - "linecolor": "white", - "minorgridcolor": "white", - "startlinecolor": "#2a3f5f" - }, - "type": "carpet" - } - ], - "choropleth": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "choropleth" - } - ], - "contour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "contour" - } - ], - "contourcarpet": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "contourcarpet" - } - ], - "heatmap": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "heatmap" - } - ], - "histogram": [ - { - "marker": { - "pattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - } - }, - "type": "histogram" - } - ], - "histogram2d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2d" - } - ], - "histogram2dcontour": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "histogram2dcontour" - } - ], - "mesh3d": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "type": "mesh3d" - } - ], - "parcoords": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "parcoords" - } - ], - "pie": [ - { - "automargin": true, - "type": "pie" - } - ], - "scatter": [ - { - "fillpattern": { - "fillmode": "overlay", - "size": 10, - "solidity": 0.2 - }, - "type": "scatter" - } - ], - "scatter3d": [ - { - "line": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatter3d" - } - ], - "scattercarpet": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattercarpet" - } - ], - "scattergeo": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergeo" - } - ], - "scattergl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattergl" - } - ], - "scattermap": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermap" - } - ], - "scattermapbox": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scattermapbox" - } - ], - "scatterpolar": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolar" - } - ], - "scatterpolargl": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterpolargl" - } - ], - "scatterternary": [ - { - "marker": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "type": "scatterternary" - } - ], - "surface": [ - { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - }, - "colorscale": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "type": "surface" - } - ], - "table": [ - { - "cells": { - "fill": { - "color": "#EBF0F8" - }, - "line": { - "color": "white" - } - }, - "header": { - "fill": { - "color": "#C8D4E3" - }, - "line": { - "color": "white" - } - }, - "type": "table" - } - ] - }, - "layout": { - "annotationdefaults": { - "arrowcolor": "#2a3f5f", - "arrowhead": 0, - "arrowwidth": 1 - }, - "autotypenumbers": "strict", - "coloraxis": { - "colorbar": { - "outlinewidth": 0, - "ticks": "" - } - }, - "colorscale": { - "diverging": [ - [ - 0, - "#8e0152" - ], - [ - 0.1, - "#c51b7d" - ], - [ - 0.2, - "#de77ae" - ], - [ - 0.3, - "#f1b6da" - ], - [ - 0.4, - "#fde0ef" - ], - [ - 0.5, - "#f7f7f7" - ], - [ - 0.6, - "#e6f5d0" - ], - [ - 0.7, - "#b8e186" - ], - [ - 0.8, - "#7fbc41" - ], - [ - 0.9, - "#4d9221" - ], - [ - 1, - "#276419" - ] - ], - "sequential": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ], - "sequentialminus": [ - [ - 0, - "#0d0887" - ], - [ - 0.1111111111111111, - "#46039f" - ], - [ - 0.2222222222222222, - "#7201a8" - ], - [ - 0.3333333333333333, - "#9c179e" - ], - [ - 0.4444444444444444, - "#bd3786" - ], - [ - 0.5555555555555556, - "#d8576b" - ], - [ - 0.6666666666666666, - "#ed7953" - ], - [ - 0.7777777777777778, - "#fb9f3a" - ], - [ - 0.8888888888888888, - "#fdca26" - ], - [ - 1, - "#f0f921" - ] - ] - }, - "colorway": [ - "#636efa", - "#EF553B", - "#00cc96", - "#ab63fa", - "#FFA15A", - "#19d3f3", - "#FF6692", - "#B6E880", - "#FF97FF", - "#FECB52" - ], - "font": { - "color": "#2a3f5f" - }, - "geo": { - "bgcolor": "white", - "lakecolor": "white", - "landcolor": "#E5ECF6", - "showlakes": true, - "showland": true, - "subunitcolor": "white" - }, - "hoverlabel": { - "align": "left" - }, - "hovermode": "closest", - "mapbox": { - "style": "light" - }, - "paper_bgcolor": "white", - "plot_bgcolor": "#E5ECF6", - "polar": { - "angularaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "bgcolor": "#E5ECF6", - "radialaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - } - }, - "scene": { - "xaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - }, - "yaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - }, - "zaxis": { - "backgroundcolor": "#E5ECF6", - "gridcolor": "white", - "gridwidth": 2, - "linecolor": "white", - "showbackground": true, - "ticks": "", - "zerolinecolor": "white" - } - }, - "shapedefaults": { - "line": { - "color": "#2a3f5f" - } - }, - "ternary": { - "aaxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "baxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - }, - "bgcolor": "#E5ECF6", - "caxis": { - "gridcolor": "white", - "linecolor": "white", - "ticks": "" - } - }, - "title": { - "x": 0.05 - }, - "xaxis": { - "automargin": true, - "gridcolor": "white", - "linecolor": "white", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "white", - "zerolinewidth": 2 - }, - "yaxis": { - "automargin": true, - "gridcolor": "white", - "linecolor": "white", - "ticks": "", - "title": { - "standoff": 15 - }, - "zerolinecolor": "white", - "zerolinewidth": 2 - } - } - }, - "title": { - "font": { - "color": "#1F2937", - "family": "Helvetica Neue, Arial, sans-serif" - }, - "text": "Cost Breakdown (Three-Year, Risk-Adjusted)" - } - } - }, - "image/png": "iVBORw0KGgoAAAANSUhEUgAABSIAAAF5CAYAAACLLlQAAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAFIqADAAQAAAABAAABeQAAAABiH+3MAABAAElEQVR4AeydBXxUxxbGP5zi7u7F3d3di7u7u3uBIsUdikvRQnF31+IaJLgHkkACb86Eu28TdpON2zfvt7k2cuY/N/volzPnhPmuClhIgARIgARIgARIgARIgARIgARIgARIgARIgARIwB8JhPXHvtk1CZAACZAACZAACZAACZAACZAACZAACZAACZAACWgCFCL5IpAACZAACZAACZAACZAACZAACZAACZAACZAACfg7AQqR/o6YA5AACZAACZAACZAACZAACZAACZAACZAACZAACVCI5DtAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiTg7wQoRPo7Yg5AAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAIZLvAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQgL8ToBDp74g5AAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAIVIvgMkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAL+ToBCpL8j5gAkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIkQAIUIvkOkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJ+DsBCpH+jpgDkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJUIjkO0ACJEACJEACJEACJEACJEACJEACJEACJEACJODvBChE+jtiDkACJEACJEACJEACJEACJEACJEACJEACJEACJEAhku8ACZAACZAACZAACZAACZAACZAACZAACZAACZCAvxOgEOnviDkACZAACZAACZAACZAACZAACZAACZAACZAACZAAhUi+AyRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAiRAAv5OILy/jxBMB3BycsLV67fw1cUFaVKlRIL4cYPETL5//+7OjjBhwri7DsgLscWn4/umbUDO8fyl/xA5UiT8mjGdl8MKi6A8L+Pd8WzNbKnjJQg/qGDYYd6VNbu9y9y79c1t8Hju0748tvN47XGcoHJt2Hn2/CVEjhwZWTNn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PVQJnZeOynvvxq07iBQpEiQpjXgMWiviwScx1RKr+GsiRloqErNOYqxJUhARFizF7LPUzr/uybLL9svYysNMhEyPRQQGySottkZWWYGlGPabZ7f22M7StcTWs1NJgOLHj2vy/rRUT2x69vylZihxOAOz3L57X8W9bIheXduiW8fWAWqKV2sjxthSx7dGezWGT96RHXsOQDwLly2Y5lvzrLaXrcsSX1S8j+OphEPefV+tduzDB169/4uXrdHxJTerP2wYIqewF6FPvieM0ASWhvfN94q0/ai2yXvW/zv1HSBb6cUr0qOAPPL3qVi74R8c3rVBx8y1ZB/vkQAJkAAJkAAJkAAJkAAJkAAJkIA5AQqR5jR4TgJmBGT7+sXLV3Fs32ZTVmmzxzz1JgGJ+1mpVlPtWVy7RiVvtg6Z1SUj/KjxU3XoB0mmkziRW0KpoD5bEScLlqqmPCkbone3dkHdXNpHAiRAAiRAAiRAAiRAAiRAAiQQRAhY35saRAykGSQQWAQGqXig7z98wJZtuwLLhBA1bgQVBmDW1HGgCPn/ZRWxW+LPSvKc4CJCivXi1SphHdqpOJMsJEACJEACJEACJEACJEACJEACJGArAXpE2kqK9UIlgeOnzqn4k7/o+IOhEgAn7a8Ejp88q5PoSHzL4FQk5quEmZAs3CwkQAIkQAIkQAIkQAIkQAIkQAIkYCsBCpG2kmI9EiABEiABEiABEiABEiABEiABEiABEiABEiABHxPg1mwfo2NDEiABEiABEiABEiABEiABEiABEiABEiABEiABWwlQiLSVFOuRAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAn4mACFSB+jY0MSIAESIAESIAESIAESIAESIAESIAESIAESIAFbCVCItJUU65EACZAACZAACZAACZAACZAACZAACZAACZAACfiYAIVIH6NjQxIgARIgARIgARIgARIgARIgARIgARIgARIgAVsJUIi0lRTrkQAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJ+JgAhUgfo2NDEiABEiABEiABEiABEiABEiABEiABEiABEiABWwlQiLSVFOuRAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAn4mACFSB+jY0MSIAESIAESIAESIAESIAESIAESIAESIAESIAFbCVCItJUU65EACZAACZAACZAACZAACZAACZAACZAACZAACfiYAIVIH6NjQxIgARIgARIgARIgARIgARIgARIgARIgARIgAVsJUIi0lRTrkQAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJ+JgAhUgfo2NDEiABEiABEiABEiABEiABEiABEiABEiABEiABWwlQiLSVFOuRAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAn4mACFSB+jY0MSIAESIAESIAESIAESIAESIAESIAESIAESIAFbCVCItJUU65EACZAACZAACZAACZAACZAACZAACZAACZAACfiYAIVIH6NjQxIgARIgARIgARIgARIgARIgARIgARIgARIgAVsJUIi0lRTrkQAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJ+JgAhUgfo2NDEiABEiABEiABEiABEiABEiABEiABEiABEiABWwlQiLSVFOuRAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAmQAAn4mACFSB+jY0MSIAESIAESIAESIAESIAESIAESIAESIAESIAFbCVCItJUU65EACZAACZAACZAACZAACZAACZAACZAACZAACfiYAIVIH6NjQxIgARIgARIgARIgARIgARIgARIgARIgARIgAVsJUIi0lRTrkQAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJkAAJ+JgAhUgfo2NDEiABEiABEiABEiABEiABEiABEiABEiABEiABWwmEt7Ui65EACZAACZAACZAACfgvgQ+OH/D8w3O8+PASrxxe4eXHV3j76S3eq/sfHN+rz0d8cv4Ex6+OcPziBKevTnD55gLXb676kzVxXey7VAjhw4ZB+HBhEOHHJ2qkcIj2S3jE0J9wiB45PGJHC48EMSIiYcxISBAzgj4mihURsaNG8N9JsncSIAESIAESIAESIIFQS4BCZKhdek6cBEiABEiABEggMAi8+/wO918+wIPXdvr48PVDPHn7BPbvnsLB2cFXJn3/Dri4ftcffDXvyt2F+YOfzqP/Eg6p4v+CVPEiI6Uc40dGxiRR8av6xIjCfzr+BIw3SIAESIAESIAESIAEbCbAf03ajIoVSYAESIAESIAESMB7BB4rgfG/x1dx89lN9bmFW+ojXo5BuXx0dMWVhw7649HOJLEjIVPSqMisPjlSRkPu1DGQPG5kj9V4TQIkQAIkQAIkQAIk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AAEEEDBLgESkWbK0iwACCCCAAAIIIIAAAggggAACCCCAAAIuARKRLgpeIIAAAggggAACCCCAAAIIIIAAAggggIBZAiQizZKlXQQQQAABBBBAAAEEEEAAAQQQQAABBBBwCZCIdFHwAgEEEEAAAQQQQAABBBBAAAEEEEAAAQTMEiARaZYs7SKAAAIIIIAAAggggAACCCCAAAIIIICAS4BEpIuCFwgggAACCCCAAAIIIIAAAggggAACCCBglgCJSLNkaRcBBBBAAAEEEEAAAQQQQAABBBBAAAEEXAIkIl0UvEAAAQQQQAABBBBAAAEEEEAAAQQQQAABswRIRJolS7sIIIAAAggggAACCCCAAAIIIIAAAggg4BIgEemi4AUCCCCAAAIIIIAAAggggAACCCCAAAIImCVAItIsWdpFAAEEEEAAAQQQQAABBBBAAAEEEEAAAZcAiUgXBS8QQAABBBBAAAEEEEAAAQQQQAABBBBAwCwBEpFmydIuAggggAACCCCAAAIIIIAAAggggAACCLgESES6KHiBAAIIIIAAAggggAACCCCAAAIIIIAAAmYJkIg0S5Z2EUAAAQQQQAABBBBAAAEEEEAAAQQQQMAlQCLSRcELBBBAAAEEEEAAAQQQQAABBBBAAAEEEDBLgESkWbK0iwACCCCAAAIIIIAAAggggAACCCCAAAIugf8Hoz/pr2nFsJsAAAAASUVORK5CYII=" - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "charts.cost_breakdown_pie(seed.COSTS).show()" - ] - }, - { - "cell_type": "markdown", - "id": "g2-md-ai-callout", - "metadata": {}, - "source": [ - "## Why the AI-tokens line matters\n", - "\n", - "Genesys bills AI consumption in *AI Experience tokens* \u2014 pricing is\n", - "tiered, capability-dependent, and deal-specific. The Forrester study\n", - "modelled **\\$0** of AI consumption even though benefits **Bt**, **Ct**\n", - "and **Dt** all rely on AI features that draw tokens:\n", - "\n", - "- **Bt** Self-service uplift (15\u219225%) \u2014 voice/digital virtual agents\n", - "- **Ct** Agent efficiency (MTTR -2 min) \u2014 real-time AI coaching/assist\n", - "- **Dt** Agent-assist incremental sales \u2014 next-best-action suggestions\n", - "\n", - "For sizing context, the study's own drivers imply ~1.04M self-service\n", - "interactions/yr (B5 \u00d7 52) and ~3.12M agent-assisted interactions/yr\n", - "(C1 \u00d7 52) would draw tokens. Athena stores a single annual cost value\n", - "per cost line, and so does the seed \u2014 enter the negotiated annual\n", - "figure from the Genesys quote in `03_business_case.ipynb` and document\n", - "the quote details (volume, unit price, tier) in the field notes." - ] - }, - { - "cell_type": "markdown", - "id": "g2-md-push", - "metadata": {}, - "source": [ - "## Push to Athena (optional)" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "g2-code-push", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
No PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID set \u2014 skipped Athena push.
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "if config.TOOL_PUBLIC_ID:\n", - " from core.tei_client import TEIClient\n", - "\n", - " client = TEIClient()\n", - " # Push pre-multiplied by (1 + risk_adj) per Palladium convention\n", - " payload = []\n", - " for c in seed.COSTS:\n", - " f = 1 + c['risk_adjustment']\n", - " payload.append({\n", - " 'field_key': c['field_key'],\n", - " 'year_values': {y: round(v * f, 2) for y, v in c['year_values'].items()},\n", - " 'initial': round((c.get('initial') or 0) * f, 2),\n", - " 'notes': c['notes'],\n", - " })\n", - " client.update_values(config.TOOL_PUBLIC_ID, payload)\n", - " display.alert(f'Pushed {len(payload)} cost rows to '\n", - " f'tool {config.TOOL_PUBLIC_ID}.', 'success')\n", - "else:\n", - " display.alert('No PALLADIUM_GENESYSCX_TOOL_PUBLIC_ID set \u2014 skipped Athena push.', 'info')" - ] - }, - { - "cell_type": "markdown", - "id": "g2-md-next", - "metadata": {}, - "source": [ - "Continue with [`03_business_case.ipynb`](03_business_case.ipynb) \u2192" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e1507f5a-3a42-4c9e-bbce-a86ff1a1dbb7", - "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 -} diff --git a/studies/202512_GenesysCX/notebooks/03_business_case.ipynb b/studies/202512_GenesysCX/notebooks/03_business_case.ipynb deleted file mode 100644 index 2b94810..0000000 --- a/studies/202512_GenesysCX/notebooks/03_business_case.ipynb +++ /dev/null @@ -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 PUSH = True and ensure '\n", - " 'TOOL_PUBLIC_ID 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 -} diff --git a/studies/202512_GenesysCX/notebooks/04_export.ipynb b/studies/202512_GenesysCX/notebooks/04_export.ipynb deleted file mode 100644 index e77b04c..0000000 --- a/studies/202512_GenesysCX/notebooks/04_export.ipynb +++ /dev/null @@ -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 {source}.', '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 {out_path.relative_to(ROOT)} ({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 -} diff --git a/studies/202512_TEI_Genesys_CX_Cloud/README.md b/studies/202512_TEI_Genesys_CX_Cloud/README.md new file mode 100644 index 0000000..44b61d4 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/README.md @@ -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/`. diff --git a/studies/202512_TEI_Genesys_CX_Cloud/config.toml b/studies/202512_TEI_Genesys_CX_Cloud/config.toml new file mode 100644 index 0000000..6c68822 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/config.toml @@ -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)" diff --git a/studies/202512_GenesysCX/docs/Genesys-Token-Metering.md b/studies/202512_TEI_Genesys_CX_Cloud/docs/Genesys-Token-Metering.md similarity index 100% rename from studies/202512_GenesysCX/docs/Genesys-Token-Metering.md rename to studies/202512_TEI_Genesys_CX_Cloud/docs/Genesys-Token-Metering.md diff --git a/studies/202512_GenesysCX/docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf b/studies/202512_TEI_Genesys_CX_Cloud/docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf similarity index 100% rename from studies/202512_GenesysCX/docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf rename to studies/202512_TEI_Genesys_CX_Cloud/docs/The-Total-Economic-Impact-Of-CX-Cloud.pdf diff --git a/studies/202512_GenesysCX/exports/.gitkeep b/studies/202512_TEI_Genesys_CX_Cloud/exports/.gitkeep similarity index 100% rename from studies/202512_GenesysCX/exports/.gitkeep rename to studies/202512_TEI_Genesys_CX_Cloud/exports/.gitkeep diff --git a/studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb b/studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb new file mode 100644 index 0000000..ade7152 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/notebooks/business_case.ipynb @@ -0,0 +1,7133 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# Genesys CX Cloud TEI โ€” Business Case\n", + "\n", + "Reproduction of Forrester's *The Total Economic Impactโ„ข Of CX Cloud โ€” Cost\n", + "Savings And Business Benefits Enabled By Genesys And Salesforce* (December\n", + "2025, commissioned by Genesys and Salesforce) โ€” and a live personalization\n", + "of it. The published composite organization is the **verbatim anchor**\n", + "(never edited); the verification gate proves this notebook reproduces the\n", + "published **$10.8M NPV ยท 266% ROI**; the client drivers then rescale the\n", + "composite to your organization.\n", + "\n", + "One line deserves special attention: the published study models **$0 of\n", + "Genesys AI Experience token consumption**, even though three of its four\n", + "benefits depend on AI capabilities that Genesys bills via tokens. That line\n", + "is anchored at $0 (so the reproduction matches the PDF) and exposed as a\n", + "direct ๐Ÿ”ด input โ€” price it from the Genesys quote and watch the case move.\n", + "\n", + "**This notebook is the deliverable** โ€” served interactively with Mercury,\n", + "exported via nbconvert as the report source (Mercury Notebook Pattern,\n", + "Variant 4).\n", + "\n", + "| Layer | What it is | Confidence |\n", + "|---|---|---|\n", + "| Verbatim anchor | Forrester's composite tables, unedited | ๐ŸŸข published |\n", + "| Client overlay | first-order linear rescale by your drivers | ๐ŸŸก estimated |\n", + "| AI-token line | negotiated annual quote figure, direct input | ๐Ÿ”ด unknown until quoted |\n", + "| Scenario | adoption ร— risk stress | ๐ŸŸก estimated |\n", + "\n", + "Confidence legend: ๐ŸŸข confirmed/published ยท ๐ŸŸก estimated (stated assumption) ยท ๐Ÿ”ด unknown (flagged)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:18.686288Z", + "iopub.status.busy": "2026-07-09T19:15:18.686041Z", + "iopub.status.idle": "2026-07-09T19:15:19.083373Z", + "shell.execute_reply": "2026-07-09T19:15:19.082724Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "teicalc loaded โ€” window 2026โ€“2028 ยท published NPV $10.8M ยท ROI 266% ยท AI-token line anchored at $0\n" + ] + } + ], + "source": [ + "# โ”€โ”€ Setup โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "import sys, pathlib\n", + "_ROOT = pathlib.Path.cwd()\n", + "if not (_ROOT / \"teicalc\").exists(): # notebook lives in notebooks/\n", + " _ROOT = _ROOT.parent\n", + "sys.path.insert(0, str(_ROOT))\n", + "\n", + "import pandas as pd\n", + "import plotly.graph_objects as go\n", + "\n", + "import mercury as mr\n", + "\n", + "# Single source of truth โ€” all math lives in the study package; only\n", + "# presentation (and Mercury input widgets) lives here.\n", + "from teicalc import (\n", + " ASSUMPTIONS, BENEFITS_VERBATIM, COSTS_VERBATIM, PUBLISHED,\n", + " YEARS, X_LABELS,\n", + " BENEFIT_DRIVERS, COST_DRIVERS, COMPOSITE, ClientDrivers,\n", + " SCENARIOS, apply_scenario, compute_summary,\n", + " money, html_money, overlay_rows,\n", + ")\n", + "from teicalc.staging import backstage\n", + "\n", + "pd.options.display.float_format = \"{:,.0f}\".format\n", + "\n", + "# โ”€โ”€ Chart chrome (dataviz reference palette, light surface) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "INK, INK2, MUTED = \"#0b0b0b\", \"#52514e\", \"#898781\"\n", + "SURFACE, GRID, BASELINE = \"#fcfcfb\", \"#e1e0d9\", \"#c3c2b7\"\n", + "CUMULATIVE, CONTEXT = \"#52514e\", \"#c3c2b7\" # neutral line; de-emphasized context series\n", + "FONT_STACK = 'system-ui, -apple-system, \"Segoe UI\", sans-serif'\n", + "\n", + "# Fixed row colors โ€” color follows the entity across every figure.\n", + "BENEFIT_COLOR = {\n", + " \"legacy_retirement\": \"#2a78d6\", # blue\n", + " \"self_service_savings\": \"#1baf7a\", # aqua\n", + " \"agent_efficiency\": \"#4a3aa7\", # violet\n", + " \"agent_assist_sales\": \"#eda100\", # yellow\n", + "}\n", + "COST_COLOR = {\n", + " \"cx_cloud_licenses\": \"#2a78d6\", # blue\n", + " \"implementation\": \"#eda100\", # yellow\n", + " \"ongoing_management\": \"#4a3aa7\", # violet\n", + " \"genesys_ai_tokens\": \"#e34948\", # red โ€” the flagged $0 line\n", + "}\n", + "BEN_TOTAL, COST_TOTAL, NPV_COLOR = \"#1baf7a\", \"#e34948\", \"#2a78d6\"\n", + "\n", + "\n", + "def tei_layout(fig, title, subtitle=None, height=460):\n", + " t = f\"{title}\"\n", + " if subtitle:\n", + " t += f\"
{subtitle}\"\n", + " fig.update_layout(\n", + " title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor=\"left\"),\n", + " paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,\n", + " font=dict(family=FONT_STACK, size=12, color=INK2),\n", + " legend=dict(orientation=\"h\", yanchor=\"top\", y=-0.10, x=0,\n", + " font=dict(size=11, color=INK2)),\n", + " xaxis=dict(type=\"category\", showgrid=False, linecolor=BASELINE,\n", + " tickfont=dict(color=MUTED)),\n", + " yaxis=dict(gridcolor=GRID, zerolinecolor=BASELINE, zerolinewidth=1.5,\n", + " tickformat=\"$~s\", tickfont=dict(color=MUTED)),\n", + " hovermode=\"x unified\", bargap=0.45, height=height,\n", + " margin=dict(t=70, r=30, b=80, l=70),\n", + " )\n", + " return fig\n", + "\n", + "\n", + "def bar(x, y, name, color):\n", + " return go.Bar(x=x, y=y, name=name,\n", + " marker=dict(color=color, line=dict(width=2, color=SURFACE)),\n", + " hovertemplate=\"%{fullData.name}: %{y:$,.0f}\")\n", + "\n", + "\n", + "def cum_line(x, y, name, color=CUMULATIVE, dash=None):\n", + " return go.Scatter(x=x, y=y, name=name, mode=\"lines+markers\",\n", + " line=dict(color=color, width=2, dash=dash),\n", + " marker=dict(size=8, line=dict(width=2, color=SURFACE)),\n", + " hovertemplate=\"%{fullData.name}: %{y:$,.0f}\")\n", + "\n", + "\n", + "backstage(f\"teicalc loaded โ€” window {YEARS[0]}โ€“{YEARS[-1]} ยท published \"\n", + " f\"NPV {money(PUBLISHED['npv'])} ยท ROI {PUBLISHED['roi_pct']}% ยท \"\n", + " f\"AI-token line anchored at $0\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:19.085680Z", + "iopub.status.busy": "2026-07-09T19:15:19.085417Z", + "iopub.status.idle": "2026-07-09T19:15:19.097643Z", + "shell.execute_reply": "2026-07-09T19:15:19.096879Z" + } + }, + "outputs": [ + { + "data": { + "application/mercury+json": { + "model_id": "c7bf126b79ee49b2bb7140ec4fb10b96", + "position": "sidebar", + "widget": "MarkdownWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "c7bf126b79ee49b2bb7140ec4fb10b96", + "version_major": 2, + "version_minor": 0 + }, + "text/plain": [ + "MarkdownWidget(value='
{label}'\n", + " for n, label in _TOC)\n", + "_toc = mr.Markdown(\n", + " text=(f'Jump to section'\n", + " f'
    {_items}
'),\n", + " position=\"sidebar\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-3", + "metadata": {}, + "source": [ + "\n", + "## 1 ยท The Forrester composite (verbatim anchor ๐ŸŸข)\n", + "\n", + "Forrester's composite organization: a **global supply company with $2.5B\n", + "revenue and 10,000 employees**, running **600 CX agents** (400 concurrent\n", + "licenses) who handle **80,000 weekly interactions** averaging 12 minutes.\n", + "\n", + "TEI methodology, carried verbatim into the engine: benefits are\n", + "risk-adjusted **down** (ร—(1โˆ’rf)), costs **up** (ร—(1+rf)); the initial\n", + "investment sits at time 0 undiscounted; year flows discount at end-of-year\n", + "(10%, 3 years). Two source footnotes preserved in the anchor: the p.14\n", + "Total Costs table's $1,304,600 implementation figure is a typo (the detail\n", + "table and cash-flow analysis give $1,309,000), and the study models **$0\n", + "AI-token consumption** (ยง4).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:19.100446Z", + "iopub.status.busy": "2026-07-09T19:15:19.100167Z", + "iopub.status.idle": "2026-07-09T19:15:19.119649Z", + "shell.execute_reply": "2026-07-09T19:15:19.118932Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Value ๐ŸŸข
Assumption
annual revenue$2,500,000,000
employees10,000
agents fte600
concurrent licenses400
weekly interactions80,000
interaction minutes12 min
self service rate before15%
self service rate after25%
mttr saved minutes2 min
agent hourly rate$28/hr
agent annual salary$58,240
revenue impacted$500,000,000
revenue lift1.5%
gross margin8%
discount rate10%
analysis years3 years
\n", + "
" + ], + "text/plain": [ + " Value ๐ŸŸข\n", + "Assumption \n", + "annual revenue $2,500,000,000\n", + "employees 10,000\n", + "agents fte 600\n", + "concurrent licenses 400\n", + "weekly interactions 80,000\n", + "interaction minutes 12 min\n", + "self service rate before 15%\n", + "self service rate after 25%\n", + "mttr saved minutes 2 min\n", + "agent hourly rate $28/hr\n", + "agent annual salary $58,240\n", + "revenue impacted $500,000,000\n", + "revenue lift 1.5%\n", + "gross margin 8%\n", + "discount rate 10%\n", + "analysis years 3 years" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Published ๐ŸŸข
Metric
Benefits PV (risk-adjusted)$14,840,638
Costs PV (risk-adjusted)$4,057,170
NPV$10,783,468
ROI266%
Paybacknot headlined (engine: 3.3 months)
\n", + "
" + ], + "text/plain": [ + " Published ๐ŸŸข\n", + "Metric \n", + "Benefits PV (risk-adjusted) $14,840,638\n", + "Costs PV (risk-adjusted) $4,057,170\n", + "NPV $10,783,468\n", + "ROI 266%\n", + "Payback not headlined (engine: 3.3 months)" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "engine reproduction ฮ” vs PDF: benefits -1.19 ยท costs +0.40 ยท npv -1.58\n" + ] + } + ], + "source": [ + "# โ”€โ”€ Composite assumptions & published financial summary (๐ŸŸข) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "composite = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM,\n", + " PUBLISHED[\"discount_rate\"])\n", + "\n", + "_fmt = {\n", + " \"annual_revenue\": \"${:,}\", \"employees\": \"{:,}\", \"agents_fte\": \"{:,}\",\n", + " \"concurrent_licenses\": \"{:,}\", \"weekly_interactions\": \"{:,}\",\n", + " \"interaction_minutes\": \"{} min\", \"self_service_rate_before\": \"{:.0%}\",\n", + " \"self_service_rate_after\": \"{:.0%}\", \"mttr_saved_minutes\": \"{} min\",\n", + " \"agent_hourly_rate\": \"${}/hr\", \"agent_annual_salary\": \"${:,}\",\n", + " \"revenue_impacted\": \"${:,}\", \"revenue_lift\": \"{:.1%}\",\n", + " \"gross_margin\": \"{:.0%}\", \"discount_rate\": \"{:.0%}\",\n", + " \"analysis_years\": \"{} years\",\n", + "}\n", + "assumptions_df = pd.DataFrame(\n", + " [{\"Assumption\": k.replace(\"_\", \" \"), \"Value ๐ŸŸข\": _fmt[k].format(v)}\n", + " for k, v in ASSUMPTIONS.items()])\n", + "display(assumptions_df.set_index(\"Assumption\"))\n", + "\n", + "published_df = pd.DataFrame([\n", + " {\"Metric\": \"Benefits PV (risk-adjusted)\", \"Published ๐ŸŸข\": f\"${PUBLISHED['benefits_pv']:,}\"},\n", + " {\"Metric\": \"Costs PV (risk-adjusted)\", \"Published ๐ŸŸข\": f\"${PUBLISHED['costs_pv']:,}\"},\n", + " {\"Metric\": \"NPV\", \"Published ๐ŸŸข\": f\"${PUBLISHED['npv']:,}\"},\n", + " {\"Metric\": \"ROI\", \"Published ๐ŸŸข\": f\"{PUBLISHED['roi_pct']}%\"},\n", + " {\"Metric\": \"Payback\", \"Published ๐ŸŸข\": \"not headlined (engine: 3.3 months)\"},\n", + "])\n", + "display(published_df.set_index(\"Metric\"))\n", + "\n", + "backstage(f\"engine reproduction ฮ” vs PDF: \"\n", + " f\"benefits {composite['benefits_pv'] - PUBLISHED['benefits_pv']:+,.2f} ยท \"\n", + " f\"costs {composite['costs_pv'] - PUBLISHED['costs_pv']:+,.2f} ยท \"\n", + " f\"npv {composite['npv'] - PUBLISHED['npv']:+,.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-5", + "metadata": {}, + "source": [ + "\n", + "## 2 ยท Client inputs (overlay ๐ŸŸก)\n", + "\n", + "The overlay is a **first-order linear rescale** of Forrester's composite โ€”\n", + "it answers *\"what does the composite look like at your size?\"*, not *\"what\n", + "is your TEI?\"*. Each published row scales with the driver that dominates\n", + "its derivation in the PDF; project-based costs stay fixed. This composite's\n", + "trajectory is flat (Y2 = Y3), so there is no growth re-base.\n", + "\n", + "| Published row | Scales with | Confidence |\n", + "|---|---|---|\n", + "| Retirement of legacy systems | agents | ๐ŸŸก seat-scoped platform costs |\n", + "| Self-service savings | interactions | ๐ŸŸก |\n", + "| CX agent efficiency gains | interactions | ๐ŸŸก |\n", + "| Incremental sales from agent assist | revenue | ๐ŸŸก |\n", + "| CX Cloud licenses | agents | ๐ŸŸก |\n", + "| Implementation & deployment | fixed | ๐ŸŸก project-based |\n", + "| Ongoing management | fixed | ๐ŸŸก |\n", + "| **Genesys AI Experience tokens** | **direct $/yr input** | ๐Ÿ”ด $0 until quoted |\n", + "\n", + "*Change any input in the sidebar โ€” every table, figure and KPI below\n", + "recomputes. The assertions in ยง7 hold at any setting.*\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:19.121610Z", + "iopub.status.busy": "2026-07-09T19:15:19.121442Z", + "iopub.status.idle": "2026-07-09T19:15:19.139328Z", + "shell.execute_reply": "2026-07-09T19:15:19.138672Z" + } + }, + "outputs": [ + { + "data": { + "application/mercury+json": { + "model_id": "b2751bf42368472ebf73f08317e9cde9", + "position": "sidebar", + "widget": "NumberInputWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "b2751bf42368472ebf73f08317e9cde9", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/mercury+json": { + "model_id": "2247afa5bfb04ad8905333e3f64caf67", + "position": "sidebar", + "widget": "NumberInputWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "2247afa5bfb04ad8905333e3f64caf67", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/mercury+json": { + "model_id": "55da27f443ca4021895933ad5d96f23b", + "position": "sidebar", + "widget": "NumberInputWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "55da27f443ca4021895933ad5d96f23b", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/mercury+json": { + "model_id": "9173bebf5a34432e8e4819f906db4add", + "position": "sidebar", + "widget": "NumberInputWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "9173bebf5a34432e8e4819f906db4add", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/mercury+json": { + "model_id": "76c10bd18e9c466daec54721bc92b435", + "position": "sidebar", + "widget": "SelectWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "76c10bd18e9c466daec54721bc92b435", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/mercury+json": { + "model_id": "ce038c3719d240a1a635401ac4a6208e", + "position": "sidebar", + "widget": "SelectWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "ce038c3719d240a1a635401ac4a6208e", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# โ”€โ”€ Client drivers (Mercury sidebar โ€” widgets only, NO other output) โ”€\n", + "# NB: Mercury re-executes only cells BELOW a changed widget's cell, so\n", + "# this cell constructs widgets ONLY โ€” .value is read downstream.\n", + "_agents_w = mr.NumberInput(label=\"CX agents (FTE) โ€” composite 600\",\n", + " value=600, min=25, max=20_000, step=25)\n", + "_interactions_w = mr.NumberInput(label=\"Weekly interactions โ€” composite 80,000\",\n", + " value=80_000, min=1_000, max=2_000_000,\n", + " step=5_000)\n", + "_revenue_w = mr.NumberInput(label=\"Annual revenue ($) โ€” composite 2.5B\",\n", + " value=2_500_000_000, min=100_000_000,\n", + " max=100_000_000_000, step=100_000_000)\n", + "_tokens_w = mr.NumberInput(label=\"Genesys AI tokens ($/yr) โ€” study modeled $0\",\n", + " value=0, min=0, max=5_000_000, step=25_000)\n", + "_discount_w = mr.Select(label=\"Discount rate\", value=\"10% (Forrester)\",\n", + " choices=[\"8%\", \"10% (Forrester)\", \"12%\"])\n", + "_scenario_w = mr.Select(label=\"Scenario\", value=\"moderate\",\n", + " choices=[\"conservative\", \"moderate\", \"aggressive\"])\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:19.141205Z", + "iopub.status.busy": "2026-07-09T19:15:19.141026Z", + "iopub.status.idle": "2026-07-09T19:15:19.147273Z", + "shell.execute_reply": "2026-07-09T19:15:19.146674Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Client frame: 600 agents ยท 80,000 weekly interactions ยท moderate scenario โ†’ NPV $10.8M ยท ROI 266% ยท payback 3.3 months (~Apr 2026)\n", + "scale factors โ€” agents 1.00ร— ยท interactions 1.00ร— ยท revenue 1.00ร—\n" + ] + } + ], + "source": [ + "# โ”€โ”€ Client overlay state (re-runs on any change to the widgets above) โ”€\n", + "AGENTS_FTE = int(_agents_w.value)\n", + "WEEKLY_INTERACTIONS = int(_interactions_w.value)\n", + "ANNUAL_REVENUE = float(_revenue_w.value)\n", + "AI_TOKENS_ANNUAL = float(_tokens_w.value)\n", + "DISCOUNT_RATE = {\"8%\": 0.08, \"10% (Forrester)\": 0.10,\n", + " \"12%\": 0.12}[str(_discount_w.value)]\n", + "SCENARIO = str(_scenario_w.value)\n", + "\n", + "DRIVERS = ClientDrivers(agents_fte=AGENTS_FTE,\n", + " weekly_interactions=WEEKLY_INTERACTIONS,\n", + " annual_revenue=ANNUAL_REVENUE,\n", + " ai_tokens_annual=AI_TOKENS_ANNUAL,\n", + " discount_rate=DISCOUNT_RATE)\n", + "overlay_benefits, overlay_costs = overlay_rows(DRIVERS)\n", + "client_benefits = apply_scenario(overlay_benefits, SCENARIO)\n", + "client_costs = apply_scenario(overlay_costs, SCENARIO)\n", + "client = compute_summary(client_benefits, client_costs, DISCOUNT_RATE)\n", + "\n", + "_at_default = (DRIVERS == COMPOSITE and SCENARIO == \"moderate\")\n", + "\n", + "_tok = f\" ยท AI tokens {money(AI_TOKENS_ANNUAL)}/yr\" if AI_TOKENS_ANNUAL else \"\"\n", + "print(f\"Client frame: {AGENTS_FTE:,} agents ยท {WEEKLY_INTERACTIONS:,} weekly \"\n", + " f\"interactions{_tok} ยท {SCENARIO} scenario โ†’ NPV {money(client['npv'])} ยท \"\n", + " f\"ROI {client['roi_pct']:.0f}% ยท payback {client['payback_label']}\")\n", + "backstage(f\"scale factors โ€” agents {AGENTS_FTE / ASSUMPTIONS['agents_fte']:.2f}ร— ยท \"\n", + " f\"interactions {WEEKLY_INTERACTIONS / ASSUMPTIONS['weekly_interactions']:.2f}ร— ยท \"\n", + " f\"revenue {ANNUAL_REVENUE / ASSUMPTIONS['annual_revenue']:.2f}ร—\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "\n", + "## 3 ยท Benefits\n", + "\n", + "Four benefit streams (Forrester refs Aโ€“D), risk-adjusted down 5โ€“15%.\n", + "Agent efficiency and self-service deflection carry ~three-quarters of the\n", + "total; legacy retirement ramps as the old telephony sunsets mid-year-1;\n", + "the profile is otherwise flat.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:19.149355Z", + "iopub.status.busy": "2026-07-09T19:15:19.149198Z", + "iopub.status.idle": "2026-07-09T19:15:19.161479Z", + "shell.execute_reply": "2026-07-09T19:15:19.160698Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
DriverRisk adj2026202720283-yr RAPV
Benefit
Retirement of legacy systems with CX Cloud adoptionagents-5%646,000883,500883,5002,413,0001,981,225
Cost savings from reallocated workers and avoided seasonal hires with increased customer self-serviceinteractions-15%1,980,1601,980,1601,980,1605,940,4804,924,365
CX agent efficiency gainsinteractions-10%2,620,8002,620,8002,620,8007,862,4006,517,542
Incremental sales from agent assist capabilitiesrevenue-5%570,000570,000570,0001,710,0001,417,506
TOTAL5,816,9606,054,4606,054,46017,925,88014,840,637
\n", + "
" + ], + "text/plain": [ + " Driver Risk adj \\\n", + "Benefit \n", + "Retirement of legacy systems with CX Cloud adop... agents -5% \n", + "Cost savings from reallocated workers and avoid... interactions -15% \n", + "CX agent efficiency gains interactions -10% \n", + "Incremental sales from agent assist capabilities revenue -5% \n", + "TOTAL \n", + "\n", + " 2026 2027 \\\n", + "Benefit \n", + "Retirement of legacy systems with CX Cloud adop... 646,000 883,500 \n", + "Cost savings from reallocated workers and avoid... 1,980,160 1,980,160 \n", + "CX agent efficiency gains 2,620,800 2,620,800 \n", + "Incremental sales from agent assist capabilities 570,000 570,000 \n", + "TOTAL 5,816,960 6,054,460 \n", + "\n", + " 2028 3-yr RA \\\n", + "Benefit \n", + "Retirement of legacy systems with CX Cloud adop... 883,500 2,413,000 \n", + "Cost savings from reallocated workers and avoid... 1,980,160 5,940,480 \n", + "CX agent efficiency gains 2,620,800 7,862,400 \n", + "Incremental sales from agent assist capabilities 570,000 1,710,000 \n", + "TOTAL 6,054,460 17,925,880 \n", + "\n", + " PV \n", + "Benefit \n", + "Retirement of legacy systems with CX Cloud adop... 1,981,225 \n", + "Cost savings from reallocated workers and avoid... 4,924,365 \n", + "CX agent efficiency gains 6,517,542 \n", + "Incremental sales from agent assist capabilities 1,417,506 \n", + "TOTAL 14,840,637 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# โ”€โ”€ Benefits table โ€” client overlay, risk-adjusted โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "_ben_rows = client[\"rows\"][\"benefits\"]\n", + "benefits_df = pd.DataFrame([{\n", + " \"Benefit\": r[\"label\"],\n", + " \"Driver\": BENEFIT_DRIVERS[r[\"field_key\"]],\n", + " \"Risk adj\": f\"-{r['risk_adjustment']:.0%}\",\n", + " **{str(y): r[\"ra_by_year\"][y] for y in YEARS},\n", + " \"3-yr RA\": r[\"three_yr_ra\"],\n", + " \"PV\": r[\"pv\"],\n", + "} for r in _ben_rows]).set_index(\"Benefit\")\n", + "benefits_df.loc[\"TOTAL\"] = [\"\", \"\"] + [client[\"benefits_by_year\"][y] for y in YEARS] \\\n", + " + [sum(r[\"three_yr_ra\"] for r in _ben_rows), client[\"benefits_pv\"]]\n", + "if not _at_default: # published composite beside the overlay for reference\n", + " benefits_df[\"Composite PV ๐ŸŸข\"] = \\\n", + " [r[\"pv\"] for r in composite[\"rows\"][\"benefits\"]] + [composite[\"benefits_pv\"]]\n", + "display(benefits_df)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-10", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:19.163518Z", + "iopub.status.busy": "2026-07-09T19:15:19.163257Z", + "iopub.status.idle": "2026-07-09T19:15:20.430977Z", + "shell.execute_reply": "2026-07-09T19:15:20.429934Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#2a78d6", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Retirement of legacy systems with CX Cloud adoption", + "type": "bar", + "x": [ + "2026", + "2027", + "2028" + ], + "y": [ + 646000.0, + 883500.0, + 883500.0 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#1baf7a", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Cost savings from reallocated workers and avoided seasonal hires with increased customer self-service", + "type": "bar", + "x": [ + "2026", + "2027", + "2028" + ], + "y": [ + 1980160.0, + 1980160.0, + 1980160.0 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#4a3aa7", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "CX agent efficiency gains", + "type": "bar", + "x": [ + "2026", + "2027", + "2028" + ], + "y": [ + 2620800.0, + 2620800.0, + 2620800.0 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#eda100", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Incremental sales from agent assist capabilities", + "type": "bar", + "x": [ + "2026", + "2027", + "2028" + ], + "y": [ + 570000.0, + 570000.0, + 570000.0 + ] + } + ], + "layout": { + "bargap": 0.45, + "barmode": "stack", + "font": { + "color": "#52514e", + "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", + "size": 12 + }, + "height": 480, + "hovermode": "x unified", + "legend": { + "font": { + "color": "#52514e", + "size": 11 + }, + "orientation": "h", + "x": 0, + "y": -0.1, + "yanchor": "top" + }, + "margin": { + "b": 80, + "l": 70, + "r": 30, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 16 + }, + "text": "Benefits by year (risk-adjusted)
stacked by benefit stream โ€” client overlay", + "x": 0.02, + "xanchor": "left" + }, + "xaxis": { + "linecolor": "#c3c2b7", + "showgrid": false, + "tickfont": { + "color": "#898781" + }, + "type": "category" + }, + "yaxis": { + "gridcolor": "#e1e0d9", + "tickfont": { + "color": "#898781" + }, + "tickformat": "$~s", + "zerolinecolor": "#c3c2b7", + "zerolinewidth": 1.5 + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig = go.Figure()\n", + "_x = [str(y) for y in YEARS]\n", + "for r in client[\"rows\"][\"benefits\"]:\n", + " fig.add_trace(bar(_x, [r[\"ra_by_year\"][y] for y in YEARS],\n", + " r[\"label\"], BENEFIT_COLOR[r[\"field_key\"]]))\n", + "if not _at_default: # composite yearly total as de-emphasized context\n", + " fig.add_trace(cum_line(_x, [composite[\"benefits_by_year\"][y] for y in YEARS],\n", + " \"Forrester composite total\", CONTEXT, dash=\"dot\"))\n", + "fig.update_layout(barmode=\"stack\")\n", + "tei_layout(fig, \"Benefits by year (risk-adjusted)\",\n", + " subtitle=\"stacked by benefit stream โ€” client overlay\", height=480)\n", + "fig.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-11", + "metadata": {}, + "source": [ + "\n", + "## 4 ยท Costs โ€” and the $0 AI line (๐Ÿ”ด)\n", + "\n", + "Three published cost lines, risk-adjusted **up** 5โ€“10%: seat licenses,\n", + "a 10-week implementation (the only time-0 outlay: $1.19M nominal โ†’ $1.31M\n", + "risk-adjusted), and a small ops team.\n", + "\n", + "The fourth line is the one the study *doesn't* price: **Genesys AI\n", + "Experience token consumption**. Forrester models it at $0, yet the\n", + "self-service uplift (B), agent efficiency (C), and agent assist (D)\n", + "benefits all run on AI capabilities that Genesys bills via tokens. The\n", + "anchor keeps the $0 verbatim so the reproduction matches the PDF; the\n", + "sidebar's token input prices it for a real client from the Genesys quote.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "cell-12", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.435367Z", + "iopub.status.busy": "2026-07-09T19:15:20.435032Z", + "iopub.status.idle": "2026-07-09T19:15:20.449985Z", + "shell.execute_reply": "2026-07-09T19:15:20.449281Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
DriverRisk adjInitial202620272028PV
Cost
CX Cloud solution costs (licenses)agents+5%0882,000882,000882,0002,193,403
Implementation and deployment costfixed+10%1,309,0000001,309,000
Ongoing management costsfixed+10%0223,080223,080223,080554,767
Genesys AI Experience token consumptionai_tokens+0%00000
TOTAL1,309,0001,105,0801,105,0801,105,0804,057,170
\n", + "
" + ], + "text/plain": [ + " Driver Risk adj Initial \\\n", + "Cost \n", + "CX Cloud solution costs (licenses) agents +5% 0 \n", + "Implementation and deployment cost fixed +10% 1,309,000 \n", + "Ongoing management costs fixed +10% 0 \n", + "Genesys AI Experience token consumption ai_tokens +0% 0 \n", + "TOTAL 1,309,000 \n", + "\n", + " 2026 2027 2028 \\\n", + "Cost \n", + "CX Cloud solution costs (licenses) 882,000 882,000 882,000 \n", + "Implementation and deployment cost 0 0 0 \n", + "Ongoing management costs 223,080 223,080 223,080 \n", + "Genesys AI Experience token consumption 0 0 0 \n", + "TOTAL 1,105,080 1,105,080 1,105,080 \n", + "\n", + " PV \n", + "Cost \n", + "CX Cloud solution costs (licenses) 2,193,403 \n", + "Implementation and deployment cost 1,309,000 \n", + "Ongoing management costs 554,767 \n", + "Genesys AI Experience token consumption 0 \n", + "TOTAL 4,057,170 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# โ”€โ”€ Costs table โ€” client overlay, risk-adjusted โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "_cost_rows = client[\"rows\"][\"costs\"]\n", + "costs_df = pd.DataFrame([{\n", + " \"Cost\": r[\"label\"],\n", + " \"Driver\": COST_DRIVERS[r[\"field_key\"]],\n", + " \"Risk adj\": f\"+{r['risk_adjustment']:.0%}\",\n", + " \"Initial\": r[\"initial_ra\"],\n", + " **{str(y): r[\"ra_by_year\"][y] for y in YEARS},\n", + " \"PV\": r[\"pv\"],\n", + "} for r in _cost_rows]).set_index(\"Cost\")\n", + "costs_df.loc[\"TOTAL\"] = [\"\", \"\", client[\"initial_costs\"]] \\\n", + " + [client[\"costs_by_year\"][y] for y in YEARS] + [client[\"costs_pv\"]]\n", + "if not _at_default:\n", + " costs_df[\"Composite PV ๐ŸŸข\"] = \\\n", + " [r[\"pv\"] for r in composite[\"rows\"][\"costs\"]] + [composite[\"costs_pv\"]]\n", + "display(costs_df)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cell-13", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.458332Z", + "iopub.status.busy": "2026-07-09T19:15:20.458028Z", + "iopub.status.idle": "2026-07-09T19:15:20.482508Z", + "shell.execute_reply": "2026-07-09T19:15:20.481680Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#2a78d6", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "CX Cloud solution costs (licenses)", + "type": "bar", + "x": [ + "Initial", + "2026", + "2027", + "2028" + ], + "y": [ + 0.0, + 882000.0, + 882000.0, + 882000.0 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#eda100", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Implementation and deployment cost", + "type": "bar", + "x": [ + "Initial", + "2026", + "2027", + "2028" + ], + "y": [ + 1309000.0, + 0.0, + 0.0, + 0.0 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#4a3aa7", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Ongoing management costs", + "type": "bar", + "x": [ + "Initial", + "2026", + "2027", + "2028" + ], + "y": [ + 0.0, + 223080.00000000003, + 223080.00000000003, + 223080.00000000003 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#e34948", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Genesys AI Experience token consumption", + "type": "bar", + "x": [ + "Initial", + "2026", + "2027", + "2028" + ], + "y": [ + 0.0, + 0.0, + 0.0, + 0.0 + ] + } + ], + "layout": { + "bargap": 0.45, + "barmode": "stack", + "font": { + "color": "#52514e", + "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", + "size": 12 + }, + "height": 460, + "hovermode": "x unified", + "legend": { + "font": { + "color": "#52514e", + "size": 11 + }, + "orientation": "h", + "x": 0, + "y": -0.1, + "yanchor": "top" + }, + "margin": { + "b": 80, + "l": 70, + "r": 30, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 16 + }, + "text": "Costs by year (risk-adjusted)
Initial = undiscounted time-0 outlay; the red AI-token segment is $0 until priced", + "x": 0.02, + "xanchor": "left" + }, + "xaxis": { + "linecolor": "#c3c2b7", + "showgrid": false, + "tickfont": { + "color": "#898781" + }, + "type": "category" + }, + "yaxis": { + "gridcolor": "#e1e0d9", + "tickfont": { + "color": "#898781" + }, + "tickformat": "$~s", + "zerolinecolor": "#c3c2b7", + "zerolinewidth": 1.5 + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig = go.Figure()\n", + "for r in client[\"rows\"][\"costs\"]:\n", + " fig.add_trace(bar(X_LABELS,\n", + " [r[\"initial_ra\"]] + [r[\"ra_by_year\"][y] for y in YEARS],\n", + " r[\"label\"], COST_COLOR[r[\"field_key\"]]))\n", + "fig.update_layout(barmode=\"stack\")\n", + "tei_layout(fig, \"Costs by year (risk-adjusted)\",\n", + " subtitle=\"Initial = undiscounted time-0 outlay; the red AI-token \"\n", + " \"segment is $0 until priced\")\n", + "fig.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-14", + "metadata": {}, + "source": [ + "\n", + "## 5 ยท Business case\n", + "\n", + "The left column is Forrester's published Financial Summary, verbatim. The\n", + "right column is the client overlay at the sidebar's drivers. At the\n", + "defaults the engine reproduces the published totals to within $2 โ€” the\n", + "gate in ยง7 enforces it. Forrester does not headline a payback for this\n", + "study; the engine computes **3.3 months** from the cash-flow table\n", + "($1.31M initial vs ~$4.7M year-1 net).\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "cell-15", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.484624Z", + "iopub.status.busy": "2026-07-09T19:15:20.484408Z", + "iopub.status.idle": "2026-07-09T19:15:20.493816Z", + "shell.execute_reply": "2026-07-09T19:15:20.493019Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Forrester composite (published ๐ŸŸข)Client overlay (๐ŸŸก)
Benefits PV$14,840,638$14,840,637
Costs PV$4,057,170$4,057,170
NPV$10,783,468$10,783,466
ROI266%266%
Paybacknot headlined3.3 months (~Apr 2026)
Discount rate10%10%
\n", + "
" + ], + "text/plain": [ + " Forrester composite (published ๐ŸŸข) Client overlay (๐ŸŸก)\n", + "Benefits PV $14,840,638 $14,840,637\n", + "Costs PV $4,057,170 $4,057,170\n", + "NPV $10,783,468 $10,783,466\n", + "ROI 266% 266%\n", + "Payback not headlined 3.3 months (~Apr 2026)\n", + "Discount rate 10% 10%" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "engine composite NPV $10,783,466.42 vs published $10,783,468 (ฮ” -1.58)\n" + ] + } + ], + "source": [ + "# โ”€โ”€ KPIs โ€” published composite beside the client overlay โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "kpis_fmt = pd.DataFrame({\n", + " \"Forrester composite (published ๐ŸŸข)\": {\n", + " \"Benefits PV\": f\"${PUBLISHED['benefits_pv']:,}\",\n", + " \"Costs PV\": f\"${PUBLISHED['costs_pv']:,}\",\n", + " \"NPV\": f\"${PUBLISHED['npv']:,}\",\n", + " \"ROI\": f\"{PUBLISHED['roi_pct']}%\",\n", + " \"Payback\": \"not headlined\",\n", + " \"Discount rate\": f\"{PUBLISHED['discount_rate']:.0%}\",\n", + " },\n", + " \"Client overlay (๐ŸŸก)\": {\n", + " \"Benefits PV\": f\"${client['benefits_pv']:,.0f}\",\n", + " \"Costs PV\": f\"${client['costs_pv']:,.0f}\",\n", + " \"NPV\": f\"${client['npv']:,.0f}\",\n", + " \"ROI\": f\"{client['roi_pct']:.0f}%\",\n", + " \"Payback\": client[\"payback_label\"],\n", + " \"Discount rate\": f\"{DISCOUNT_RATE:.0%}\",\n", + " },\n", + "})\n", + "display(kpis_fmt)\n", + "backstage(f\"engine composite NPV ${composite['npv']:,.2f} vs published \"\n", + " f\"${PUBLISHED['npv']:,} (ฮ” {composite['npv'] - PUBLISHED['npv']:+,.2f})\")\n" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "cell-16", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.496223Z", + "iopub.status.busy": "2026-07-09T19:15:20.495912Z", + "iopub.status.idle": "2026-07-09T19:15:20.525205Z", + "shell.execute_reply": "2026-07-09T19:15:20.524534Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#1baf7a", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Benefits (risk-adjusted)", + "type": "bar", + "x": [ + "Initial", + "2026", + "2027", + "2028" + ], + "y": [ + 0, + 5816960.0, + 6054460.0, + 6054460.0 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#e34948", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Costs (risk-adjusted)", + "type": "bar", + "x": [ + "Initial", + "2026", + "2027", + "2028" + ], + "y": [ + -1309000.0, + -1105080.0, + -1105080.0, + -1105080.0 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "line": { + "color": "#52514e", + "width": 2 + }, + "marker": { + "line": { + "color": "#fcfcfb", + "width": 2 + }, + "size": 8 + }, + "mode": "lines+markers", + "name": "Cumulative net", + "type": "scatter", + "x": [ + "Initial", + "2026", + "2027", + "2028" + ], + "y": [ + -1309000.0, + 3402880.0, + 8352260.0, + 13301640.0 + ] + } + ], + "layout": { + "annotations": [ + { + "align": "left", + "bgcolor": "#fcfcfb", + "bordercolor": "#e1e0d9", + "borderwidth": 1, + "font": { + "color": "#0b0b0b", + "size": 12 + }, + "showarrow": false, + "text": "NPV $10.8M ยท ROI 266% ยท payback 3.3 months (~Apr 2026)", + "x": 0.02, + "xref": "paper", + "y": 0.98, + "yref": "paper" + } + ], + "bargap": 0.45, + "barmode": "relative", + "font": { + "color": "#52514e", + "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", + "size": 12 + }, + "height": 460, + "hovermode": "x unified", + "legend": { + "font": { + "color": "#52514e", + "size": 11 + }, + "orientation": "h", + "x": 0, + "y": -0.1, + "yanchor": "top" + }, + "margin": { + "b": 80, + "l": 70, + "r": 30, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 16 + }, + "text": "Cash flow โ€” risk-adjusted
benefits up, costs down; initial outlay undiscounted at time 0", + "x": 0.02, + "xanchor": "left" + }, + "xaxis": { + "linecolor": "#c3c2b7", + "showgrid": false, + "tickfont": { + "color": "#898781" + }, + "type": "category" + }, + "yaxis": { + "gridcolor": "#e1e0d9", + "tickfont": { + "color": "#898781" + }, + "tickformat": "$~s", + "zerolinecolor": "#c3c2b7", + "zerolinewidth": 1.5 + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# โ”€โ”€ Cash flow โ€” mirrors the PDF's Cash Flow Chart โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "fig = go.Figure()\n", + "fig.add_trace(bar(X_LABELS,\n", + " [0] + [client[\"benefits_by_year\"][y] for y in YEARS],\n", + " \"Benefits (risk-adjusted)\", BEN_TOTAL))\n", + "fig.add_trace(bar(X_LABELS,\n", + " [-client[\"initial_costs\"]] + [-client[\"costs_by_year\"][y] for y in YEARS],\n", + " \"Costs (risk-adjusted)\", COST_TOTAL))\n", + "fig.add_trace(cum_line(X_LABELS,\n", + " [-client[\"initial_costs\"]] + [client[\"cumulative_net_by_year\"][y] for y in YEARS],\n", + " \"Cumulative net\"))\n", + "fig.update_layout(barmode=\"relative\")\n", + "fig.add_annotation(\n", + " x=0.02, y=0.98, xref=\"paper\", yref=\"paper\", showarrow=False, align=\"left\",\n", + " text=(f\"NPV {html_money(client['npv'])} ยท ROI {client['roi_pct']:.0f}% ยท \"\n", + " f\"payback {client['payback_label']}\"),\n", + " font=dict(size=12, color=INK), bgcolor=SURFACE,\n", + " bordercolor=GRID, borderwidth=1)\n", + "tei_layout(fig, \"Cash flow โ€” risk-adjusted\",\n", + " subtitle=\"benefits up, costs down; initial outlay undiscounted at time 0\")\n", + "fig.show()\n" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "cell-17", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.527870Z", + "iopub.status.busy": "2026-07-09T19:15:20.527655Z", + "iopub.status.idle": "2026-07-09T19:15:20.551901Z", + "shell.execute_reply": "2026-07-09T19:15:20.551086Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "connector": { + "line": { + "color": "#e1e0d9" + } + }, + "decreasing": { + "marker": { + "color": "#e34948" + } + }, + "increasing": { + "marker": { + "color": "#1baf7a" + } + }, + "measure": [ + "relative", + "relative", + "total" + ], + "text": [ + "$14.8M", + "-$4.1M", + "$10.8M" + ], + "textposition": "outside", + "totals": { + "marker": { + "color": "#2a78d6" + } + }, + "type": "waterfall", + "x": [ + "Benefits PV", + "Costs PV", + "NPV" + ], + "y": [ + 14840636.814425241, + -4057170.398196844, + 0 + ] + } + ], + "layout": { + "bargap": 0.45, + "font": { + "color": "#52514e", + "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", + "size": 12 + }, + "height": 420, + "hovermode": "x unified", + "legend": { + "font": { + "color": "#52514e", + "size": 11 + }, + "orientation": "h", + "x": 0, + "y": -0.1, + "yanchor": "top" + }, + "margin": { + "b": 80, + "l": 70, + "r": 30, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "showlegend": false, + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 16 + }, + "text": "Present value walk โ€” client overlay
discounted at 10%, 3 years", + "x": 0.02, + "xanchor": "left" + }, + "xaxis": { + "linecolor": "#c3c2b7", + "showgrid": false, + "tickfont": { + "color": "#898781" + }, + "type": "category" + }, + "yaxis": { + "gridcolor": "#e1e0d9", + "tickfont": { + "color": "#898781" + }, + "tickformat": "$~s", + "zerolinecolor": "#c3c2b7", + "zerolinewidth": 1.5 + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig = go.Figure(go.Waterfall(\n", + " x=[\"Benefits PV\", \"Costs PV\", \"NPV\"],\n", + " measure=[\"relative\", \"relative\", \"total\"],\n", + " y=[client[\"benefits_pv\"], -client[\"costs_pv\"], 0],\n", + " text=[html_money(client[\"benefits_pv\"]), html_money(-client[\"costs_pv\"]),\n", + " html_money(client[\"npv\"])],\n", + " textposition=\"outside\",\n", + " connector=dict(line=dict(color=GRID)),\n", + " increasing=dict(marker=dict(color=BEN_TOTAL)),\n", + " decreasing=dict(marker=dict(color=COST_TOTAL)),\n", + " totals=dict(marker=dict(color=NPV_COLOR)),\n", + "))\n", + "fig.update_layout(showlegend=False)\n", + "tei_layout(fig, \"Present value walk โ€” client overlay\",\n", + " subtitle=f\"discounted at {'{:.0%}'.format(DISCOUNT_RATE)}, 3 years\",\n", + " height=420)\n", + "fig.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-18", + "metadata": {}, + "source": [ + "\n", + "## 6 ยท Scenarios (๐ŸŸก)\n", + "\n", + "Scenarios stress the overlay on two levers: **adoption** scales every\n", + "nominal value (including the initial outlay), and **risk delta** widens or\n", + "narrows the TEI risk adjustments โ€” added to benefit risk, subtracted from\n", + "cost risk, clamped at zero.\n", + "\n", + "One counterintuitive consequence, worth stating: the **conservative**\n", + "scenario *lowers* costs PV as well as benefits โ€” 80% adoption shrinks\n", + "every cost line and the clamp zeroes all this study's cost paddings (5โ€“10%\n", + "risk factors, all under the 10% delta). The case direction is still\n", + "conservative: NPV and ROI both fall.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "cell-19", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.554518Z", + "iopub.status.busy": "2026-07-09T19:15:20.554277Z", + "iopub.status.idle": "2026-07-09T19:15:20.564822Z", + "shell.execute_reply": "2026-07-09T19:15:20.564005Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
AdoptionRisk ฮ”Benefits PVCosts PVNPVROI %Payback (months)
Scenario
conservative80%+10%10,543,4943,026,6317,516,8632483
moderate100%+0%14,840,6374,057,17010,783,4662663
aggressive115%-5%18,021,9624,883,28513,138,6772693
\n", + "
" + ], + "text/plain": [ + " Adoption Risk ฮ” Benefits PV Costs PV NPV ROI % \\\n", + "Scenario \n", + "conservative 80% +10% 10,543,494 3,026,631 7,516,863 248 \n", + "moderate 100% +0% 14,840,637 4,057,170 10,783,466 266 \n", + "aggressive 115% -5% 18,021,962 4,883,285 13,138,677 269 \n", + "\n", + " Payback (months) \n", + "Scenario \n", + "conservative 3 \n", + "moderate 3 \n", + "aggressive 3 " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# โ”€โ”€ Scenario sweep over the client overlay โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "scen_summaries = {\n", + " s: compute_summary(apply_scenario(overlay_benefits, s),\n", + " apply_scenario(overlay_costs, s), DISCOUNT_RATE)\n", + " for s in SCENARIOS\n", + "}\n", + "scen_df = pd.DataFrame([{\n", + " \"Scenario\": s,\n", + " \"Adoption\": f\"{SCENARIOS[s]['adoption']:.0%}\",\n", + " \"Risk ฮ”\": f\"{SCENARIOS[s]['risk_delta']:+.0%}\",\n", + " \"Benefits PV\": r[\"benefits_pv\"],\n", + " \"Costs PV\": r[\"costs_pv\"],\n", + " \"NPV\": r[\"npv\"],\n", + " \"ROI %\": round(r[\"roi_pct\"], 1),\n", + " \"Payback (months)\": round(r[\"payback_months\"], 2)\n", + " if r[\"payback_months\"] is not None else float(\"nan\"),\n", + "} for s, r in scen_summaries.items()]).set_index(\"Scenario\")\n", + "display(scen_df)\n" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "cell-20", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.567700Z", + "iopub.status.busy": "2026-07-09T19:15:20.567440Z", + "iopub.status.idle": "2026-07-09T19:15:20.587490Z", + "shell.execute_reply": "2026-07-09T19:15:20.586666Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#1baf7a", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Benefits PV", + "type": "bar", + "x": [ + "conservative", + "moderate", + "aggressive" + ], + "y": [ + 10543493.914350111, + 14840636.814425241, + 18021962.2539444 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#e34948", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Costs PV", + "type": "bar", + "x": [ + "conservative", + "moderate", + "aggressive" + ], + "y": [ + 3026631.4049586775, + 4057170.398196844, + 4883285.090157775 + ] + }, + { + "hovertemplate": "%{fullData.name}: %{y:$,.0f}", + "marker": { + "color": "#2a78d6", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "NPV", + "type": "bar", + "x": [ + "conservative", + "moderate", + "aggressive" + ], + "y": [ + 7516862.509391434, + 10783466.416228397, + 13138677.163786625 + ] + } + ], + "layout": { + "bargap": 0.45, + "barmode": "group", + "font": { + "color": "#52514e", + "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", + "size": 12 + }, + "height": 420, + "hovermode": "x unified", + "legend": { + "font": { + "color": "#52514e", + "size": 11 + }, + "orientation": "h", + "x": 0, + "y": -0.1, + "yanchor": "top" + }, + "margin": { + "b": 80, + "l": 70, + "r": 30, + "t": 70 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 16 + }, + "text": "Scenario comparison โ€” client overlay
adoption ร— risk-delta stress on the same drivers", + "x": 0.02, + "xanchor": "left" + }, + "xaxis": { + "linecolor": "#c3c2b7", + "showgrid": false, + "tickfont": { + "color": "#898781" + }, + "type": "category" + }, + "yaxis": { + "gridcolor": "#e1e0d9", + "tickfont": { + "color": "#898781" + }, + "tickformat": "$~s", + "zerolinecolor": "#c3c2b7", + "zerolinewidth": 1.5 + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig = go.Figure()\n", + "_scen = list(scen_summaries)\n", + "for _name, _key, _color in [(\"Benefits PV\", \"benefits_pv\", BEN_TOTAL),\n", + " (\"Costs PV\", \"costs_pv\", COST_TOTAL),\n", + " (\"NPV\", \"npv\", NPV_COLOR)]:\n", + " fig.add_trace(bar(_scen, [scen_summaries[s][_key] for s in _scen],\n", + " _name, _color))\n", + "fig.update_layout(barmode=\"group\")\n", + "tei_layout(fig, \"Scenario comparison โ€” client overlay\",\n", + " subtitle=\"adoption ร— risk-delta stress on the same drivers\",\n", + " height=420)\n", + "fig.show()\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-21", + "metadata": {}, + "source": [ + "\n", + "## 7 ยท Verification & assertions\n", + "\n", + "The gate re-derives the case from the engine and asserts: the verbatim\n", + "anchor is intact (AI-token line at $0 included); the engine reproduces\n", + "Forrester's published totals within $5; the overlay is the identity at\n", + "composite scale; and the structural identities hold at **any** widget\n", + "setting. It must pass in a headless `nbconvert --execute` run โ€” that is\n", + "this study's regression check.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "cell-22", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.590288Z", + "iopub.status.busy": "2026-07-09T19:15:20.590029Z", + "iopub.status.idle": "2026-07-09T19:15:20.607459Z", + "shell.execute_reply": "2026-07-09T19:15:20.606722Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "All assertions passed.\n", + " reproduction ฮ” vs PDF: benefits -1.19 ยท costs +0.40 ยท npv -1.58\n" + ] + } + ], + "source": [ + "def _approx(got, want, tol=0.5):\n", + " assert abs(got - want) <= tol, f\"got {got:,.2f}, want {want:,.2f}\"\n", + "\n", + "\n", + "# โ”€โ”€ Anchor integrity โ€” the verbatim record is intact (unconditional) โ”€\n", + "_approx(BENEFITS_VERBATIM[0][\"year_values\"][\"1\"], 680_000)\n", + "_approx(COSTS_VERBATIM[1][\"initial\"], 1_190_000)\n", + "_tok = next(r for r in COSTS_VERBATIM if r[\"field_key\"] == \"genesys_ai_tokens\")\n", + "assert all(v == 0 for v in _tok[\"year_values\"].values()) # the $0 AI line, verbatim\n", + "assert ASSUMPTIONS[\"agents_fte\"] == 600\n", + "assert ASSUMPTIONS[\"weekly_interactions\"] == 80_000\n", + "assert (COMPOSITE.agents_fte, COMPOSITE.ai_tokens_annual) == (600, 0.0)\n", + "\n", + "# โ”€โ”€ Published reproduction โ€” engine defaults, explicit args โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "_c = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)\n", + "_approx(_c[\"benefits_pv\"], PUBLISHED[\"benefits_pv\"], tol=5) # ฮ” โˆ’1.19 (PDF rounding)\n", + "_approx(_c[\"costs_pv\"], PUBLISHED[\"costs_pv\"], tol=5) # ฮ” +0.40\n", + "_approx(_c[\"npv\"], PUBLISHED[\"npv\"], tol=5) # ฮ” โˆ’1.58\n", + "assert round(_c[\"roi_pct\"]) == PUBLISHED[\"roi_pct\"] # 265.79 โ†’ 266\n", + "_approx(_c[\"payback_months\"], 3.33, tol=0.01) # not headlined in the PDF\n", + "_approx(_c[\"initial_costs\"], 1_309_000)\n", + "_approx(_c[\"benefits_by_year\"][2026], 5_816_960, tol=1)\n", + "_approx(_c[\"costs_by_year\"][2028], 1_105_080, tol=1)\n", + "\n", + "# โ”€โ”€ Overlay identity + scaling behaviour (explicit args) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "_ob, _oc = overlay_rows(COMPOSITE)\n", + "_id = compute_summary(_ob, _oc, 0.10)\n", + "_approx(_id[\"benefits_pv\"], _c[\"benefits_pv\"], tol=0.01) # identity at composite\n", + "_hb, _hc = overlay_rows(ClientDrivers(agents_fte=300))\n", + "_approx(next(r for r in _hb if r[\"field_key\"] == \"legacy_retirement\")\n", + " [\"year_values\"][\"1\"], 340_000) # agents-driven halves\n", + "_approx(next(r for r in _hb if r[\"field_key\"] == \"self_service_savings\")\n", + " [\"year_values\"][\"1\"], 2_329_600) # interactions-driven unmoved\n", + "_tb, _tc = overlay_rows(ClientDrivers(ai_tokens_annual=250_000))\n", + "_t = compute_summary(_tb, _tc, 0.10)\n", + "_approx(_t[\"costs_pv\"] - _c[\"costs_pv\"], 621_713, tol=1) # tokens priced โ†’ PV moves\n", + "\n", + "# โ”€โ”€ Scenario pin (explicit args) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "_s = compute_summary(apply_scenario(BENEFITS_VERBATIM, \"conservative\"),\n", + " apply_scenario(COSTS_VERBATIM, \"conservative\"), 0.10)\n", + "_approx(_s[\"npv\"], 7_516_862.51, tol=1)\n", + "\n", + "# โ”€โ”€ Structural ties โ€” hold at ANY widget state (unconditional) โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "_approx(client[\"npv\"], client[\"benefits_pv\"] - client[\"costs_pv\"], tol=0.01)\n", + "_approx(client[\"roi_pct\"], client[\"npv\"] / client[\"costs_pv\"] * 100, tol=0.01)\n", + "for _y in YEARS:\n", + " _approx(client[\"net_by_year\"][_y],\n", + " client[\"benefits_by_year\"][_y] - client[\"costs_by_year\"][_y], tol=0.01)\n", + "_approx(client[\"cumulative_net_by_year\"][YEARS[-1]],\n", + " sum(client[\"net_by_year\"].values()) - client[\"initial_costs\"], tol=0.01)\n", + "_approx(sum(r[\"pv\"] for r in client[\"rows\"][\"benefits\"]), client[\"benefits_pv\"], tol=0.01)\n", + "_approx(sum(r[\"pv\"] for r in client[\"rows\"][\"costs\"]), client[\"costs_pv\"], tol=0.01)\n", + "\n", + "# โ”€โ”€ Live state โ€” only when the sidebar sits at the composite defaults โ”€\n", + "if _at_default:\n", + " _approx(client[\"benefits_pv\"], 14_840_636.81, tol=1) # engine-exact\n", + " _approx(client[\"npv\"], PUBLISHED[\"npv\"], tol=5)\n", + " _approx(client[\"payback_months\"], 3.33, tol=0.01)\n", + "\n", + "backstage(\"All assertions passed.\")\n", + "backstage(f\" reproduction ฮ” vs PDF: benefits \"\n", + " f\"{_c['benefits_pv'] - PUBLISHED['benefits_pv']:+,.2f} ยท \"\n", + " f\"costs {_c['costs_pv'] - PUBLISHED['costs_pv']:+,.2f} ยท \"\n", + " f\"npv {_c['npv'] - PUBLISHED['npv']:+,.2f}\")\n" + ] + }, + { + "cell_type": "markdown", + "id": "cell-23", + "metadata": {}, + "source": [ + "\n", + "## 8 ยท Data appendix โ€” for the machines\n", + "\n", + "Everything below renders **backstage only** (JupyterLab / nbconvert\n", + "exports): markdown tables plus one JSON block of model state. Carried in\n", + "the exports, this is the payload a downstream LLM โ€” or, on the roadmap,\n", + "Athena as the study repository โ€” consumes directly. On the Mercury stage\n", + "it stays hidden.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "cell-24", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-09T19:15:20.610247Z", + "iopub.status.busy": "2026-07-09T19:15:20.609930Z", + "iopub.status.idle": "2026-07-09T19:15:20.632690Z", + "shell.execute_reply": "2026-07-09T19:15:20.631995Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "#### Composite organization (verbatim assumptions ๐ŸŸข)\n", + "\n", + "| Assumption | Value ๐ŸŸข |\n", + "|:-------------------------|:---------------|\n", + "| annual revenue | $2,500,000,000 |\n", + "| employees | 10,000 |\n", + "| agents fte | 600 |\n", + "| concurrent licenses | 400 |\n", + "| weekly interactions | 80,000 |\n", + "| interaction minutes | 12 min |\n", + "| self service rate before | 15% |\n", + "| self service rate after | 25% |\n", + "| mttr saved minutes | 2 min |\n", + "| agent hourly rate | $28/hr |\n", + "| agent annual salary | $58,240 |\n", + "| revenue impacted | $500,000,000 |\n", + "| revenue lift | 1.5% |\n", + "| gross margin | 8% |\n", + "| discount rate | 10% |\n", + "| analysis years | 3 years |\n", + "\n", + "#### Benefits โ€” client overlay (risk-adjusted $)\n", + "\n", + "| Benefit | Driver | Risk adj | 2026 | 2027 | 2028 | 3-yr RA | PV |\n", + "|:------------------------------------------------------------------------------------------------------|:-------------|:-----------|----------:|----------:|----------:|-----------:|-----------:|\n", + "| Retirement of legacy systems with CX Cloud adoption | agents | -5% | 646,000 | 883,500 | 883,500 | 2,413,000 | 1,981,225 |\n", + "| Cost savings from reallocated workers and avoided seasonal hires with increased customer self-service | interactions | -15% | 1,980,160 | 1,980,160 | 1,980,160 | 5,940,480 | 4,924,365 |\n", + "| CX agent efficiency gains | interactions | -10% | 2,620,800 | 2,620,800 | 2,620,800 | 7,862,400 | 6,517,542 |\n", + "| Incremental sales from agent assist capabilities | revenue | -5% | 570,000 | 570,000 | 570,000 | 1,710,000 | 1,417,506 |\n", + "| TOTAL | | | 5,816,960 | 6,054,460 | 6,054,460 | 17,925,880 | 14,840,637 |\n", + "\n", + "#### Costs โ€” client overlay (risk-adjusted $)\n", + "\n", + "| Cost | Driver | Risk adj | Initial | 2026 | 2027 | 2028 | PV |\n", + "|:----------------------------------------|:----------|:-----------|----------:|----------:|----------:|----------:|----------:|\n", + "| CX Cloud solution costs (licenses) | agents | +5% | 0 | 882,000 | 882,000 | 882,000 | 2,193,403 |\n", + "| Implementation and deployment cost | fixed | +10% | 1,309,000 | 0 | 0 | 0 | 1,309,000 |\n", + "| Ongoing management costs | fixed | +10% | 0 | 223,080 | 223,080 | 223,080 | 554,767 |\n", + "| Genesys AI Experience token consumption | ai_tokens | +0% | 0 | 0 | 0 | 0 | 0 |\n", + "| TOTAL | | | 1,309,000 | 1,105,080 | 1,105,080 | 1,105,080 | 4,057,170 |\n", + "\n", + "#### KPIs โ€” published composite vs client overlay\n", + "\n", + "| | Forrester composite (published ๐ŸŸข) | Client overlay (๐ŸŸก) |\n", + "|:--------------|:-------------------------------------|:-----------------------|\n", + "| Benefits PV | $14,840,638 | $14,840,637 |\n", + "| Costs PV | $4,057,170 | $4,057,170 |\n", + "| NPV | $10,783,468 | $10,783,466 |\n", + "| ROI | 266% | 266% |\n", + "| Payback | not headlined | 3.3 months (~Apr 2026) |\n", + "| Discount rate | 10% | 10% |\n", + "\n", + "#### Scenarios (client overlay)\n", + "\n", + "| Scenario | Adoption | Risk ฮ” | Benefits PV | Costs PV | NPV | ROI % | Payback (months) |\n", + "|:-------------|:-----------|:---------|--------------:|-----------:|-----------:|--------:|-------------------:|\n", + "| conservative | 80% | +10% | 10,543,494 | 3,026,631 | 7,516,863 | 248 | 3 |\n", + "| moderate | 100% | +0% | 14,840,637 | 4,057,170 | 10,783,466 | 266 | 3 |\n", + "| aggressive | 115% | -5% | 18,021,962 | 4,883,285 | 13,138,677 | 269 | 3 |\n", + "\n", + "#### Model state (JSON)\n", + "\n", + "```json\n", + "{\n", + " \"study\": \"202512_TEI_Genesys_CX_Cloud\",\n", + " \"source\": \"Forrester TEI of CX Cloud, by Genesys and Salesforce (Dec 2025, commissioned by Genesys and Salesforce)\",\n", + " \"published\": {\n", + " \"benefits_pv\": 14840638,\n", + " \"costs_pv\": 4057170,\n", + " \"npv\": 10783468,\n", + " \"roi_pct\": 266,\n", + " \"discount_rate\": 0.1,\n", + " \"analysis_years\": 3\n", + " },\n", + " \"reproduction\": {\n", + " \"benefits_pv\": 14840636.81,\n", + " \"costs_pv\": 4057170.4,\n", + " \"npv\": 10783466.42,\n", + " \"roi_pct\": 265.79,\n", + " \"payback_months\": 3.33,\n", + " \"payback_note\": \"Forrester does not headline a payback for this study\"\n", + " },\n", + " \"client\": {\n", + " \"drivers\": {\n", + " \"agents_fte\": 600,\n", + " \"weekly_interactions\": 80000,\n", + " \"annual_revenue\": 2500000000.0,\n", + " \"ai_tokens_annual\": 0.0,\n", + " \"discount_rate\": 0.1,\n", + " \"scenario\": \"moderate\"\n", + " },\n", + " \"benefits_by_year\": {\n", + " \"2026\": 5816960,\n", + " \"2027\": 6054460,\n", + " \"2028\": 6054460\n", + " },\n", + " \"costs_by_year\": {\n", + " \"2026\": 1105080,\n", + " \"2027\": 1105080,\n", + " \"2028\": 1105080\n", + " },\n", + " \"net_by_year\": {\n", + " \"2026\": 4711880,\n", + " \"2027\": 4949380,\n", + " \"2028\": 4949380\n", + " },\n", + " \"cumulative_net_by_year\": {\n", + " \"2026\": 3402880,\n", + " \"2027\": 8352260,\n", + " \"2028\": 13301640\n", + " },\n", + " \"initial_costs\": 1309000,\n", + " \"kpis\": {\n", + " \"benefits_pv\": 14840637,\n", + " \"costs_pv\": 4057170,\n", + " \"npv\": 10783466,\n", + " \"roi_pct\": 265.79,\n", + " \"payback_months\": 3.33,\n", + " \"payback_label\": \"3.3 months (~Apr 2026)\"\n", + " },\n", + " \"scenarios\": {\n", + " \"conservative\": {\n", + " \"benefits_pv\": 10543494,\n", + " \"costs_pv\": 3026631,\n", + " \"npv\": 7516863,\n", + " \"roi_pct\": 248.36\n", + " },\n", + " \"moderate\": {\n", + " \"benefits_pv\": 14840637,\n", + " \"costs_pv\": 4057170,\n", + " \"npv\": 10783466,\n", + " \"roi_pct\": 265.79\n", + " },\n", + " \"aggressive\": {\n", + " \"benefits_pv\": 18021962,\n", + " \"costs_pv\": 4883285,\n", + " \"npv\": 13138677,\n", + " \"roi_pct\": 269.05\n", + " }\n", + " }\n", + " },\n", + " \"driver_map\": {\n", + " \"benefits\": {\n", + " \"legacy_retirement\": \"agents\",\n", + " \"self_service_savings\": \"interactions\",\n", + " \"agent_efficiency\": \"interactions\",\n", + " \"agent_assist_sales\": \"revenue\"\n", + " },\n", + " \"costs\": {\n", + " \"cx_cloud_licenses\": \"agents\",\n", + " \"implementation\": \"fixed\",\n", + " \"ongoing_management\": \"fixed\",\n", + " \"genesys_ai_tokens\": \"ai_tokens\"\n", + " }\n", + " },\n", + " \"ai_token_note\": \"Published study models $0 Genesys AI Experience token consumption; benefits B/C/D depend on token-billed AI capabilities. Price via drivers.ai_tokens_annual.\"\n", + "}\n", + "```\n" + ] + } + ], + "source": [ + "# โ”€โ”€ Data appendix โ€” LLM-readable dump of every model output โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€\n", + "# Renders backstage only (JupyterLab / nbconvert exports) โ€” hidden on\n", + "# the Mercury stage, where the narrative and figures carry the story.\n", + "import json as _json\n", + "\n", + "\n", + "def _section(title, df, **kw):\n", + " backstage(f\"\\n#### {title}\\n\")\n", + " backstage(df.to_markdown(floatfmt=\",.0f\", **kw))\n", + "\n", + "\n", + "_section(\"Composite organization (verbatim assumptions ๐ŸŸข)\",\n", + " assumptions_df, index=False)\n", + "_section(\"Benefits โ€” client overlay (risk-adjusted $)\", benefits_df)\n", + "_section(\"Costs โ€” client overlay (risk-adjusted $)\", costs_df)\n", + "_section(\"KPIs โ€” published composite vs client overlay\", kpis_fmt)\n", + "_section(\"Scenarios (client overlay)\", scen_df)\n", + "\n", + "backstage(\"\\n#### Model state (JSON)\\n\")\n", + "backstage(\"```json\")\n", + "backstage(_json.dumps({\n", + " \"study\": \"202512_TEI_Genesys_CX_Cloud\",\n", + " \"source\": (\"Forrester TEI of CX Cloud, by Genesys and Salesforce \"\n", + " \"(Dec 2025, commissioned by Genesys and Salesforce)\"),\n", + " \"published\": PUBLISHED,\n", + " \"reproduction\": {\n", + " \"benefits_pv\": round(composite[\"benefits_pv\"], 2),\n", + " \"costs_pv\": round(composite[\"costs_pv\"], 2),\n", + " \"npv\": round(composite[\"npv\"], 2),\n", + " \"roi_pct\": round(composite[\"roi_pct\"], 2),\n", + " \"payback_months\": round(composite[\"payback_months\"], 2),\n", + " \"payback_note\": \"Forrester does not headline a payback for this study\",\n", + " },\n", + " \"client\": {\n", + " \"drivers\": {\n", + " \"agents_fte\": AGENTS_FTE,\n", + " \"weekly_interactions\": WEEKLY_INTERACTIONS,\n", + " \"annual_revenue\": ANNUAL_REVENUE,\n", + " \"ai_tokens_annual\": AI_TOKENS_ANNUAL,\n", + " \"discount_rate\": DISCOUNT_RATE,\n", + " \"scenario\": SCENARIO,\n", + " },\n", + " \"benefits_by_year\": {str(y): round(client[\"benefits_by_year\"][y]) for y in YEARS},\n", + " \"costs_by_year\": {str(y): round(client[\"costs_by_year\"][y]) for y in YEARS},\n", + " \"net_by_year\": {str(y): round(client[\"net_by_year\"][y]) for y in YEARS},\n", + " \"cumulative_net_by_year\": {str(y): round(client[\"cumulative_net_by_year\"][y]) for y in YEARS},\n", + " \"initial_costs\": round(client[\"initial_costs\"]),\n", + " \"kpis\": {\n", + " \"benefits_pv\": round(client[\"benefits_pv\"]),\n", + " \"costs_pv\": round(client[\"costs_pv\"]),\n", + " \"npv\": round(client[\"npv\"]),\n", + " \"roi_pct\": round(client[\"roi_pct\"], 2),\n", + " \"payback_months\": round(client[\"payback_months\"], 2)\n", + " if client[\"payback_months\"] is not None else None,\n", + " \"payback_label\": client[\"payback_label\"],\n", + " },\n", + " \"scenarios\": {\n", + " s: {\"benefits_pv\": round(r[\"benefits_pv\"]),\n", + " \"costs_pv\": round(r[\"costs_pv\"]),\n", + " \"npv\": round(r[\"npv\"]),\n", + " \"roi_pct\": round(r[\"roi_pct\"], 2)}\n", + " for s, r in scen_summaries.items()\n", + " },\n", + " },\n", + " \"driver_map\": {\"benefits\": BENEFIT_DRIVERS, \"costs\": COST_DRIVERS},\n", + " \"ai_token_note\": (\"Published study models $0 Genesys AI Experience token \"\n", + " \"consumption; benefits B/C/D depend on token-billed AI \"\n", + " \"capabilities. Price via drivers.ai_tokens_annual.\"),\n", + "}, indent=2))\n", + "backstage(\"```\")\n" + ] + } + ], + "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.13.7" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": { + "14c2d1e5a0644752a2731aca143939b2": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "2247afa5bfb04ad8905333e3f64caf67": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.number.NumberInputWidget", + "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "disabled": false, + "hidden": false, + "label": "Weekly interactions โ€” composite 80,000", + "layout": "IPY_MODEL_e2772f6f5be849508a98ba9fd5de0dd1", + "layout_path": null, + "max": 2000000.0, + "min": 1000.0, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "step": 5000.0, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": 80000.0 + } + }, + "2b2aa32e5ae340dfab0c7f0de2791359": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "55da27f443ca4021895933ad5d96f23b": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.number.NumberInputWidget", + "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "disabled": false, + "hidden": false, + "label": "Annual revenue ($) โ€” composite 2.5B", + "layout": "IPY_MODEL_14c2d1e5a0644752a2731aca143939b2", + "layout_path": null, + "max": 100000000000.0, + "min": 100000000.0, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "step": 100000000.0, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": 2500000000.0 + } + }, + "6367ab66c77b4dcba88690962f8f466b": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "76c10bd18e9c466daec54721bc92b435": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.select.SelectWidget", + "_css": "\n .mljar-select-container {\n position: relative;\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n overflow: visible;\n }\n\n .mljar-select-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n overflow: visible;\n }\n\n .mljar-select-container.is-open {\n z-index: 20;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: auto;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n position: fixed;\n z-index: 10000;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n document.body.appendChild(dropdown);\n\n const updateDropdownPosition = () => {\n if (!isOpen) {\n return;\n }\n const rect = control.getBoundingClientRect();\n dropdown.style.top = `${rect.bottom + 6}px`;\n dropdown.style.left = `${rect.left}px`;\n dropdown.style.width = `${rect.width}px`;\n };\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n if (isOpen) {\n updateDropdownPosition();\n }\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n const closeDropdown = () => {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n if (event.target === caret && isOpen) {\n closeDropdown();\n input.blur();\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target) && !dropdown.contains(event.target)) {\n closeDropdown();\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n window.addEventListener(\"resize\", updateDropdownPosition);\n document.addEventListener(\"scroll\", updateDropdownPosition, true);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n closeDropdown();\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n dropdown.remove();\n document.removeEventListener(\"click\", handleDocumentClick);\n window.removeEventListener(\"resize\", updateDropdownPosition);\n document.removeEventListener(\"scroll\", updateDropdownPosition, true);\n };\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "choices": [ + "8%", + "10% (Forrester)", + "12%" + ], + "disabled": false, + "hidden": false, + "label": "Discount rate", + "layout": "IPY_MODEL_fdb25a02906540a49bcd2fb7791aba21", + "layout_path": null, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": "10% (Forrester)" + } + }, + "9173bebf5a34432e8e4819f906db4add": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.number.NumberInputWidget", + "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "disabled": false, + "hidden": false, + "label": "Genesys AI tokens ($/yr) โ€” study modeled $0", + "layout": "IPY_MODEL_2b2aa32e5ae340dfab0c7f0de2791359", + "layout_path": null, + "max": 5000000.0, + "min": 0.0, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "step": 25000.0, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": 0.0 + } + }, + "979e73c2734f41dca452ea6800cad0b8": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "2.0.0", + "model_name": "HTMLStyleModel", + "state": { + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "2.0.0", + "_model_name": "HTMLStyleModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "StyleView", + "background": null, + "description_width": "", + "font_size": null, + "text_color": null + } + }, + "ac40d1aeb52245df84aad900dc195c77": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "b01d658cd1294c5382cf6c64284d4781": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "b2751bf42368472ebf73f08317e9cde9": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.number.NumberInputWidget", + "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "disabled": false, + "hidden": false, + "label": "CX agents (FTE) โ€” composite 600", + "layout": "IPY_MODEL_6367ab66c77b4dcba88690962f8f466b", + "layout_path": null, + "max": 20000.0, + "min": 25.0, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "step": 25.0, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": 600.0 + } + }, + "c7bf126b79ee49b2bb7140ec4fb10b96": { + "model_module": "@jupyter-widgets/controls", + "model_module_version": "2.0.0", + "model_name": "HTMLModel", + "state": { + "_dom_classes": [], + "_model_module": "@jupyter-widgets/controls", + "_model_module_version": "2.0.0", + "_model_name": "HTMLModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/controls", + "_view_module_version": "2.0.0", + "_view_name": "HTMLView", + "cell_id": "", + "description": "", + "description_allow_html": false, + "layout": "IPY_MODEL_ac40d1aeb52245df84aad900dc195c77", + "layout_path": null, + "placeholder": "โ€‹", + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "style": "IPY_MODEL_979e73c2734f41dca452ea6800cad0b8", + "tabbable": null, + "tooltip": null, + "value": "" + } + }, + "ce038c3719d240a1a635401ac4a6208e": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.select.SelectWidget", + "_css": "\n .mljar-select-container {\n position: relative;\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n padding-left: 4px;\n padding-right: 4px;\n overflow: visible;\n }\n\n .mljar-select-label {\n padding-top: 6px;\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n overflow: visible;\n }\n\n .mljar-select-container.is-open {\n z-index: 20;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: auto;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n position: fixed;\n z-index: 10000;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n document.body.appendChild(dropdown);\n\n const updateDropdownPosition = () => {\n if (!isOpen) {\n return;\n }\n const rect = control.getBoundingClientRect();\n dropdown.style.top = `${rect.bottom + 6}px`;\n dropdown.style.left = `${rect.left}px`;\n dropdown.style.width = `${rect.width}px`;\n };\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n if (isOpen) {\n updateDropdownPosition();\n }\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n const closeDropdown = () => {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n if (event.target === caret && isOpen) {\n closeDropdown();\n input.blur();\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target) && !dropdown.contains(event.target)) {\n closeDropdown();\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n window.addEventListener(\"resize\", updateDropdownPosition);\n document.addEventListener(\"scroll\", updateDropdownPosition, true);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n closeDropdown();\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n dropdown.remove();\n document.removeEventListener(\"click\", handleDocumentClick);\n window.removeEventListener(\"resize\", updateDropdownPosition);\n document.removeEventListener(\"scroll\", updateDropdownPosition, true);\n };\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "choices": [ + "conservative", + "moderate", + "aggressive" + ], + "disabled": false, + "hidden": false, + "label": "Scenario", + "layout": "IPY_MODEL_b01d658cd1294c5382cf6c64284d4781", + "layout_path": null, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": "moderate" + } + }, + "e2772f6f5be849508a98ba9fd5de0dd1": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "fdb25a02906540a49bcd2fb7791aba21": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + } + }, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/studies/202512_TEI_Genesys_CX_Cloud/pyproject.toml b/studies/202512_TEI_Genesys_CX_Cloud/pyproject.toml new file mode 100644 index 0000000..a5009a5 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/pyproject.toml @@ -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"] diff --git a/studies/202512_TEI_Genesys_CX_Cloud/scripts/export_report.py b/studies/202512_TEI_Genesys_CX_Cloud/scripts/export_report.py new file mode 100644 index 0000000..2cf4e3d --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/scripts/export_report.py @@ -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/.html โ€” human-reviewable, tables render + exports/.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() diff --git a/studies/202512_TEI_Genesys_CX_Cloud/teicalc/__init__.py b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/__init__.py new file mode 100644 index 0000000..4d9531d --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/__init__.py @@ -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", +] diff --git a/studies/202512_GenesysCX/seed_data.py b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/anchor.py similarity index 54% rename from studies/202512_GenesysCX/seed_data.py rename to studies/202512_TEI_Genesys_CX_Cloud/teicalc/anchor.py index 47170a1..a9ac0f7 100644 --- a/studies/202512_GenesysCX/seed_data.py +++ b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/anchor.py @@ -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 diff --git a/studies/202512_TEI_Genesys_CX_Cloud/teicalc/model.py b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/model.py new file mode 100644 index 0000000..34ca59b --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/model.py @@ -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("$", "$") diff --git a/studies/202512_TEI_Genesys_CX_Cloud/teicalc/overlay.py b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/overlay.py new file mode 100644 index 0000000..d7911f7 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/overlay.py @@ -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)) diff --git a/studies/202512_TEI_Genesys_CX_Cloud/teicalc/scenarios.py b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/scenarios.py new file mode 100644 index 0000000..48c4d36 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/scenarios.py @@ -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 diff --git a/studies/202512_TEI_Genesys_CX_Cloud/teicalc/staging.py b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/staging.py new file mode 100644 index 0000000..2ec7bec --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/teicalc/staging.py @@ -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) diff --git a/studies/202512_TEI_Genesys_CX_Cloud/tests/conftest.py b/studies/202512_TEI_Genesys_CX_Cloud/tests/conftest.py new file mode 100644 index 0000000..c743302 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/tests/conftest.py @@ -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)) diff --git a/studies/202512_TEI_Genesys_CX_Cloud/tests/test_anchor.py b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_anchor.py new file mode 100644 index 0000000..c5c80a0 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_anchor.py @@ -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 diff --git a/studies/202512_TEI_Genesys_CX_Cloud/tests/test_model.py b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_model.py new file mode 100644 index 0000000..5604d01 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_model.py @@ -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) diff --git a/studies/202512_TEI_Genesys_CX_Cloud/tests/test_overlay.py b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_overlay.py new file mode 100644 index 0000000..8bd9336 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_overlay.py @@ -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 diff --git a/studies/202512_TEI_Genesys_CX_Cloud/tests/test_scenarios.py b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_scenarios.py new file mode 100644 index 0000000..b587311 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_scenarios.py @@ -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 diff --git a/studies/202512_TEI_Genesys_CX_Cloud/tests/test_staging.py b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_staging.py new file mode 100644 index 0000000..1254fe8 --- /dev/null +++ b/studies/202512_TEI_Genesys_CX_Cloud/tests/test_staging.py @@ -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 == ""