Files
palladium/studies/202602_TEI_Amazon_Connect/notebooks/business_case.ipynb
Robert Helewka a420af230b Migrate Amazon Connect TEI study to the Mercury Notebook Pattern
studies/202602_AmazonConnect -> studies/202602_TEI_Amazon_Connect,
rebuilt as pattern Variant 4 (TEI composite reproduction):

- teicalc/ self-contained engine (stdlib-only): Forrester's tables as
  the never-edited verbatim anchor, NPV/ROI/payback + risk adjustment
  transplanted from core/calculations, ClientDrivers overlay (contacts/
  agents/fixed driver map, growth re-base, identity at composite scale),
  scenario stress with core-identical semantics
- one deliverable notebook (business_case.ipynb): widget-pair sidebar
  drivers, published-vs-overlay KPI columns, cash-flow/waterfall/scenario
  charts, verification gate, backstage JSON data appendix
- gate + tests reproduce the published totals within PDF rounding:
  NPV $78.7M / ROI 342% / payback <6 months (engine $78,713,492 /
  342.48% / 0.7 months); 27 study tests, headless nbconvert green,
  stage simulation leak-free, exports carry the appendix
- old Athena workflow (00_provision..04_export, config.py, seed_data.py)
  deleted; git history preserves it; root test fixture repointed to
  teicalc.anchor
- docs: study README rewritten; root README points new studies at
  template/MercuryNotebook; pattern doc stale ctm-token-calculator paths
  now cite studies/202607_CTM_GenesysCX; Variant 4 cites this study as
  its realized reference

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 14:29:46 -04:00

6947 lines
247 KiB
Plaintext
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"cells": [
{
"cell_type": "markdown",
"id": "cell-0",
"metadata": {},
"source": [
"# Amazon Connect TEI — Business Case\n",
"\n",
"Reproduction of Forrester's *Total Economic Impact™ Of Amazon Connect*\n",
"(February 2026, commissioned by AWS) — and a live personalization of it.\n",
"The published composite organization is the **verbatim anchor** (never\n",
"edited); the verification gate proves this notebook reproduces the\n",
"published **$78.7M NPV · 342% ROI · <6-month payback**; the client drivers\n",
"then rescale the composite to your organization.\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",
"| 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-09T18:15:41.740701Z",
"iopub.status.busy": "2026-07-09T18:15:41.740233Z",
"iopub.status.idle": "2026-07-09T18:15:42.174061Z",
"shell.execute_reply": "2026-07-09T18:15:42.173079Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"teicalc loaded — window 20262028 · published NPV $78.7M · ROI 342%\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, growth_multiplier,\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",
" \"ai_contact_resolution\": \"#2a78d6\", # blue\n",
" \"ai_content_sentiment\": \"#1baf7a\", # aqua\n",
" \"ai_forecasting_supervision\": \"#4a3aa7\", # violet\n",
" \"data_driven_profit_lift\": \"#eda100\", # yellow\n",
" \"legacy_solution_savings\": \"#008300\", # green\n",
"}\n",
"COST_COLOR = {\n",
" \"amazon_connect_usage\": \"#e34948\", # red\n",
" \"implementation_migration\": \"#eda100\", # yellow\n",
" \"ongoing_management\": \"#4a3aa7\", # violet\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\"<b>{title}</b>\"\n",
" if subtitle:\n",
" t += f\"<br><span style='font-size:12px;color:{MUTED}'>{subtitle}</span>\"\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}<extra></extra>\")\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}<extra></extra>\")\n",
"\n",
"\n",
"backstage(f\"teicalc loaded — window {YEARS[0]}{YEARS[-1]} · published \"\n",
" f\"NPV {money(PUBLISHED['npv'])} · ROI {PUBLISHED['roi_pct']}%\")\n"
]
},
{
"cell_type": "code",
"execution_count": 2,
"id": "cell-2",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-09T18:15:42.176402Z",
"iopub.status.busy": "2026-07-09T18:15:42.175865Z",
"iopub.status.idle": "2026-07-09T18:15:42.192559Z",
"shell.execute_reply": "2026-07-09T18:15:42.191936Z"
}
},
"outputs": [
{
"data": {
"application/mercury+json": {
"model_id": "0833fb2c5bd3466aba5ff466dec6ff1b",
"position": "sidebar",
"widget": "MarkdownWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "0833fb2c5bd3466aba5ff466dec6ff1b",
"version_major": 2,
"version_minor": 0
},
"text/plain": [
"MarkdownWidget(value='<div style=\"font-family: ui-sans-serif, system-ui, -apple-system, \\'Segoe UI\\', Roboto, …"
]
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# ── Jump-to-section ToC (Mercury sidebar — widgets only, no output) ──\n",
"# Fragment links don't scroll inside Mercury's app shell (SPA base URL),\n",
"# so each entry scrolls via JS: anchor id first, heading-text fallback.\n",
"_TOC = [\n",
" (1, \"Composite organization\"),\n",
" (2, \"Client inputs\"),\n",
" (3, \"Benefits\"),\n",
" (4, \"Costs\"),\n",
" (5, \"Business case\"),\n",
" (6, \"Scenarios\"),\n",
" (7, \"Verification & assertions\"),\n",
" (8, \"Data appendix\"),\n",
"]\n",
"# NB: no raw \"<\" allowed inside the handler — python-markdown escapes the\n",
"# whole tag if the attribute text looks like malformed HTML.\n",
"_JS = (\"var el=document.getElementById('section-{n}');\"\n",
" \"if(!el){{document.querySelectorAll('h1,h2').forEach(function(h){{\"\n",
" \"if(!el&&h.textContent.trim().indexOf('{n} ')===0){{el=h;}}}});}}\"\n",
" \"if(el)el.scrollIntoView({{behavior:'smooth',block:'start'}});\")\n",
"_items = \"\".join(\n",
" f'<li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline\"'\n",
" f' onclick=\"{_JS.format(n=n)}\">{label}</a></li>'\n",
" for n, label in _TOC)\n",
"_toc = mr.Markdown(\n",
" text=(f'<b>Jump to section</b>'\n",
" f'<ol style=\"padding-left:1.2em;margin:6px 0\">{_items}</ol>'),\n",
" position=\"sidebar\")\n"
]
},
{
"cell_type": "markdown",
"id": "cell-3",
"metadata": {},
"source": [
"<a id=\"section-1\"></a>\n",
"## 1 · The Forrester composite (verbatim anchor 🟢)\n",
"\n",
"Forrester's composite organization: a global B2C company with **$10B\n",
"year-1 revenue growing 30% YoY**, **2,000 contact-center agents** plus 200\n",
"supervisors, **20M annual contacts** (75% calls / 25% chat), and a\n",
"10-minute legacy average handle time.\n",
"\n",
"TEI methodology, carried verbatim into the engine: benefits are\n",
"risk-adjusted **down** (×(1rf)), costs **up** (×(1+rf)); the initial\n",
"investment sits at time 0 undiscounted; year flows discount at end-of-year\n",
"(10%, 3 years). Payback runs on risk-adjusted *undiscounted* flows, per the\n",
"PDF's Cash Flow Analysis tables.\n"
]
},
{
"cell_type": "code",
"execution_count": 3,
"id": "cell-4",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-09T18:15:42.194666Z",
"iopub.status.busy": "2026-07-09T18:15:42.194415Z",
"iopub.status.idle": "2026-07-09T18:15:42.210841Z",
"shell.execute_reply": "2026-07-09T18:15:42.210047Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Value 🟢</th>\n",
" </tr>\n",
" <tr>\n",
" <th>Assumption</th>\n",
" <th></th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>agents fte</th>\n",
" <td>2,000</td>\n",
" </tr>\n",
" <tr>\n",
" <th>supervisors fte</th>\n",
" <td>200</td>\n",
" </tr>\n",
" <tr>\n",
" <th>annual contacts y1</th>\n",
" <td>20,000,000</td>\n",
" </tr>\n",
" <tr>\n",
" <th>growth rate</th>\n",
" <td>30%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>call share</th>\n",
" <td>75%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>aht legacy minutes</th>\n",
" <td>10 min</td>\n",
" </tr>\n",
" <tr>\n",
" <th>agent salary</th>\n",
" <td>$45,760</td>\n",
" </tr>\n",
" <tr>\n",
" <th>supervisor salary</th>\n",
" <td>$55,800</td>\n",
" </tr>\n",
" <tr>\n",
" <th>discount rate</th>\n",
" <td>10%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>analysis years</th>\n",
" <td>3 years</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Value 🟢\n",
"Assumption \n",
"agents fte 2,000\n",
"supervisors fte 200\n",
"annual contacts y1 20,000,000\n",
"growth rate 30%\n",
"call share 75%\n",
"aht legacy minutes 10 min\n",
"agent salary $45,760\n",
"supervisor salary $55,800\n",
"discount rate 10%\n",
"analysis years 3 years"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Published 🟢</th>\n",
" </tr>\n",
" <tr>\n",
" <th>Metric</th>\n",
" <th></th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>Benefits PV (risk-adjusted)</th>\n",
" <td>$101,696,791</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Costs PV (risk-adjusted)</th>\n",
" <td>$22,983,076</td>\n",
" </tr>\n",
" <tr>\n",
" <th>NPV</th>\n",
" <td>$78,713,715</td>\n",
" </tr>\n",
" <tr>\n",
" <th>ROI</th>\n",
" <td>342%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Payback</th>\n",
" <td>&lt;6 months</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Published 🟢\n",
"Metric \n",
"Benefits PV (risk-adjusted) $101,696,791\n",
"Costs PV (risk-adjusted) $22,983,076\n",
"NPV $78,713,715\n",
"ROI 342%\n",
"Payback <6 months"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"engine reproduction Δ vs PDF (Forrester table rounding): benefits -223.45 · costs -0.22 · npv -223.22\n"
]
}
],
"source": [
"# ── Composite assumptions & published financial summary (🟢) ─────────\n",
"composite = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM,\n",
" PUBLISHED[\"discount_rate\"])\n",
"\n",
"_fmt = {\n",
" \"agents_fte\": \"{:,}\", \"supervisors_fte\": \"{:,}\",\n",
" \"annual_contacts_y1\": \"{:,}\", \"growth_rate\": \"{:.0%}\",\n",
" \"call_share\": \"{:.0%}\", \"aht_legacy_minutes\": \"{} min\",\n",
" \"agent_salary\": \"${:,}\", \"supervisor_salary\": \"${:,}\",\n",
" \"discount_rate\": \"{:.0%}\", \"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 🟢\": \"<6 months\"},\n",
"])\n",
"display(published_df.set_index(\"Metric\"))\n",
"\n",
"backstage(f\"engine reproduction Δ vs PDF (Forrester table rounding): \"\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": [
"<a id=\"section-2\"></a>\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. The client\n",
"growth rate re-bases the composite's Y1→Y3 trajectory (which embeds 30%\n",
"YoY).\n",
"\n",
"| Published row | Scales with | Confidence |\n",
"|---|---|---|\n",
"| AI-driven contact resolution efficiency | contacts | 🟡 |\n",
"| AI-powered content & sentiment analysis | contacts | 🟡 |\n",
"| AI-enabled forecasting, scheduling & supervision | agents | 🟡 |\n",
"| Data-driven profit lift | contacts | 🔴 proxy — revenue-driven in the PDF |\n",
"| Legacy solution cost savings | agents | 🟡 |\n",
"| Amazon Connect usage | contacts | 🟡 |\n",
"| Implementation & migration | fixed | 🟡 project-based |\n",
"| Ongoing management | fixed | 🟡 |\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-09T18:15:42.213716Z",
"iopub.status.busy": "2026-07-09T18:15:42.213491Z",
"iopub.status.idle": "2026-07-09T18:15:42.227389Z",
"shell.execute_reply": "2026-07-09T18:15:42.226903Z"
}
},
"outputs": [
{
"data": {
"application/mercury+json": {
"model_id": "ace9c00dc3a84babb5b29ae61e2be62c",
"position": "sidebar",
"widget": "NumberInputWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "ace9c00dc3a84babb5b29ae61e2be62c",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<mercury.number.NumberInputWidget object at 0x7f1550b512b0>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"application/mercury+json": {
"model_id": "01c230d30aed4aa58c5a005c44e65f84",
"position": "sidebar",
"widget": "NumberInputWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "01c230d30aed4aa58c5a005c44e65f84",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<mercury.number.NumberInputWidget object at 0x7f1550b4d1d0>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"application/mercury+json": {
"model_id": "d4ccace17e824e4a85c55ff6ef95afb0",
"position": "sidebar",
"widget": "NumberInputWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "d4ccace17e824e4a85c55ff6ef95afb0",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<mercury.number.NumberInputWidget object at 0x7f1550b4cf50>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"application/mercury+json": {
"model_id": "eb0056195e9b444dbb8ef3c08e744446",
"position": "sidebar",
"widget": "SelectWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "eb0056195e9b444dbb8ef3c08e744446",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<mercury.select.SelectWidget object at 0x7f1550b516a0>"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"data": {
"application/mercury+json": {
"model_id": "b1e82327c29a44a8a4a45e8100b3d857",
"position": "sidebar",
"widget": "SelectWidget"
},
"application/vnd.jupyter.widget-view+json": {
"model_id": "b1e82327c29a44a8a4a45e8100b3d857",
"version_major": 2,
"version_minor": 1
},
"text/plain": [
"<mercury.select.SelectWidget object at 0x7f1550b4d810>"
]
},
"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=\"Contact-center agents (FTE) — composite 2,000\",\n",
" value=2_000, min=50, max=50_000, step=50)\n",
"_contacts_w = mr.NumberInput(label=\"Annual contacts, year 1 — composite 20M\",\n",
" value=20_000_000, min=100_000, max=500_000_000,\n",
" step=1_000_000)\n",
"_growth_w = mr.NumberInput(label=\"Contact growth (%/yr) — composite 30\",\n",
" value=30, min=0, max=100, step=5)\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-09T18:15:42.229494Z",
"iopub.status.busy": "2026-07-09T18:15:42.229335Z",
"iopub.status.idle": "2026-07-09T18:15:42.234902Z",
"shell.execute_reply": "2026-07-09T18:15:42.234410Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Client frame: 2,000 agents · 20M contacts (+30%/yr) · moderate scenario → NPV $78.7M · ROI 342% · payback 0.7 months (~Jan 2026)\n",
"scale factors — agents 1.00× · contacts 1.00× · growth re-base Y3 1.000×\n"
]
}
],
"source": [
"# ── Client overlay state (re-runs on any change to the widgets above) ─\n",
"AGENTS_FTE = int(_agents_w.value)\n",
"CONTACTS_Y1 = int(_contacts_w.value)\n",
"GROWTH_RATE = float(_growth_w.value) / 100 # widget holds a % integer\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, annual_contacts_y1=CONTACTS_Y1,\n",
" growth_rate=GROWTH_RATE, 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",
"print(f\"Client frame: {AGENTS_FTE:,} agents · {CONTACTS_Y1 / 1e6:,.0f}M contacts \"\n",
" f\"(+{GROWTH_RATE:.0%}/yr) · {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\"contacts {CONTACTS_Y1 / ASSUMPTIONS['annual_contacts_y1']:.2f}× · \"\n",
" f\"growth re-base Y3 {growth_multiplier(3, GROWTH_RATE):.3f}×\")\n"
]
},
{
"cell_type": "markdown",
"id": "cell-8",
"metadata": {},
"source": [
"<a id=\"section-3\"></a>\n",
"## 3 · Benefits\n",
"\n",
"Five benefit streams (Forrester refs AtEt), risk-adjusted down 1520%.\n",
"Half the total is AI-driven contact-resolution efficiency; all five phase\n",
"up with the growth trajectory across the window.\n"
]
},
{
"cell_type": "code",
"execution_count": 6,
"id": "cell-9",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-09T18:15:42.236799Z",
"iopub.status.busy": "2026-07-09T18:15:42.236657Z",
"iopub.status.idle": "2026-07-09T18:15:42.247373Z",
"shell.execute_reply": "2026-07-09T18:15:42.246802Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Driver</th>\n",
" <th>Risk adj</th>\n",
" <th>2026</th>\n",
" <th>2027</th>\n",
" <th>2028</th>\n",
" <th>3-yr RA</th>\n",
" <th>PV</th>\n",
" </tr>\n",
" <tr>\n",
" <th>Benefit</th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>AI-driven contact resolution efficiency</th>\n",
" <td>contacts</td>\n",
" <td>-15%</td>\n",
" <td>11,824,384</td>\n",
" <td>20,342,608</td>\n",
" <td>32,128,096</td>\n",
" <td>64,295,088</td>\n",
" <td>51,699,827</td>\n",
" </tr>\n",
" <tr>\n",
" <th>AI-powered content and sentiment analysis savings</th>\n",
" <td>contacts</td>\n",
" <td>-15%</td>\n",
" <td>3,898,627</td>\n",
" <td>4,554,650</td>\n",
" <td>5,347,928</td>\n",
" <td>13,801,205</td>\n",
" <td>11,326,358</td>\n",
" </tr>\n",
" <tr>\n",
" <th>AI-enabled forecasting, agent scheduling, and supervision</th>\n",
" <td>agents</td>\n",
" <td>-15%</td>\n",
" <td>5,653,928</td>\n",
" <td>7,763,696</td>\n",
" <td>10,532,955</td>\n",
" <td>23,950,579</td>\n",
" <td>19,469,777</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Data-driven profit lift with increased conversion</th>\n",
" <td>contacts</td>\n",
" <td>-20%</td>\n",
" <td>960,000</td>\n",
" <td>1,248,000</td>\n",
" <td>1,622,400</td>\n",
" <td>3,830,400</td>\n",
" <td>3,123,065</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Legacy solution cost savings</th>\n",
" <td>agents</td>\n",
" <td>-20%</td>\n",
" <td>4,942,080</td>\n",
" <td>6,424,704</td>\n",
" <td>8,352,115</td>\n",
" <td>19,718,899</td>\n",
" <td>16,077,540</td>\n",
" </tr>\n",
" <tr>\n",
" <th>TOTAL</th>\n",
" <td></td>\n",
" <td></td>\n",
" <td>27,279,019</td>\n",
" <td>40,333,658</td>\n",
" <td>57,983,494</td>\n",
" <td>125,596,172</td>\n",
" <td>101,696,568</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Driver Risk adj \\\n",
"Benefit \n",
"AI-driven contact resolution efficiency contacts -15% \n",
"AI-powered content and sentiment analysis savings contacts -15% \n",
"AI-enabled forecasting, agent scheduling, and s... agents -15% \n",
"Data-driven profit lift with increased conversion contacts -20% \n",
"Legacy solution cost savings agents -20% \n",
"TOTAL \n",
"\n",
" 2026 2027 \\\n",
"Benefit \n",
"AI-driven contact resolution efficiency 11,824,384 20,342,608 \n",
"AI-powered content and sentiment analysis savings 3,898,627 4,554,650 \n",
"AI-enabled forecasting, agent scheduling, and s... 5,653,928 7,763,696 \n",
"Data-driven profit lift with increased conversion 960,000 1,248,000 \n",
"Legacy solution cost savings 4,942,080 6,424,704 \n",
"TOTAL 27,279,019 40,333,658 \n",
"\n",
" 2028 3-yr RA \\\n",
"Benefit \n",
"AI-driven contact resolution efficiency 32,128,096 64,295,088 \n",
"AI-powered content and sentiment analysis savings 5,347,928 13,801,205 \n",
"AI-enabled forecasting, agent scheduling, and s... 10,532,955 23,950,579 \n",
"Data-driven profit lift with increased conversion 1,622,400 3,830,400 \n",
"Legacy solution cost savings 8,352,115 19,718,899 \n",
"TOTAL 57,983,494 125,596,172 \n",
"\n",
" PV \n",
"Benefit \n",
"AI-driven contact resolution efficiency 51,699,827 \n",
"AI-powered content and sentiment analysis savings 11,326,358 \n",
"AI-enabled forecasting, agent scheduling, and s... 19,469,777 \n",
"Data-driven profit lift with increased conversion 3,123,065 \n",
"Legacy solution cost savings 16,077,540 \n",
"TOTAL 101,696,568 "
]
},
"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-09T18:15:42.249508Z",
"iopub.status.busy": "2026-07-09T18:15:42.249351Z",
"iopub.status.idle": "2026-07-09T18:15:43.253962Z",
"shell.execute_reply": "2026-07-09T18:15:43.253381Z"
}
},
"outputs": [
{
"data": {
"application/vnd.plotly.v1+json": {
"config": {
"plotlyServerURL": "https://plot.ly"
},
"data": [
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#2a78d6",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "AI-driven contact resolution efficiency",
"type": "bar",
"x": [
"2026",
"2027",
"2028"
],
"y": [
11824384.0,
20342608.0,
32128096.0
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#1baf7a",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "AI-powered content and sentiment analysis savings",
"type": "bar",
"x": [
"2026",
"2027",
"2028"
],
"y": [
3898627.0,
4554650.2,
5347928.0
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#4a3aa7",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "AI-enabled forecasting, agent scheduling, and supervision",
"type": "bar",
"x": [
"2026",
"2027",
"2028"
],
"y": [
5653928.0,
7763696.0,
10532955.2
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#eda100",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Data-driven profit lift with increased conversion",
"type": "bar",
"x": [
"2026",
"2027",
"2028"
],
"y": [
960000.0,
1248000.0,
1622400.0
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#008300",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Legacy solution cost savings",
"type": "bar",
"x": [
"2026",
"2027",
"2028"
],
"y": [
4942080.0,
6424704.0,
8352115.2
]
}
],
"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": "<b>Benefits by year (risk-adjusted)</b><br><span style='font-size:12px;color:#898781'>stacked by benefit stream — client overlay</span>",
"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": [
"<a id=\"section-4\"></a>\n",
"## 4 · Costs\n",
"\n",
"Three cost lines, risk-adjusted **up** 515%. Consumption-priced Amazon\n",
"Connect usage is ~90% of costs PV — the cost side scales with contact\n",
"volume, not seats. Implementation carries the only time-0 outlay\n",
"($1.09M nominal → $1.20M risk-adjusted, undiscounted in the *Initial*\n",
"column).\n"
]
},
{
"cell_type": "code",
"execution_count": 8,
"id": "cell-12",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-09T18:15:43.257431Z",
"iopub.status.busy": "2026-07-09T18:15:43.257260Z",
"iopub.status.idle": "2026-07-09T18:15:43.268554Z",
"shell.execute_reply": "2026-07-09T18:15:43.267833Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Driver</th>\n",
" <th>Risk adj</th>\n",
" <th>Initial</th>\n",
" <th>2026</th>\n",
" <th>2027</th>\n",
" <th>2028</th>\n",
" <th>PV</th>\n",
" </tr>\n",
" <tr>\n",
" <th>Cost</th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>Amazon Connect usage cost</th>\n",
" <td>contacts</td>\n",
" <td>+5%</td>\n",
" <td>0</td>\n",
" <td>6,779,270</td>\n",
" <td>8,348,722</td>\n",
" <td>10,324,609</td>\n",
" <td>20,819,775</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Implementation and migration cost</th>\n",
" <td>fixed</td>\n",
" <td>+10%</td>\n",
" <td>1,196,250</td>\n",
" <td>207,166</td>\n",
" <td>207,166</td>\n",
" <td>0</td>\n",
" <td>1,555,795</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Ongoing management</th>\n",
" <td>fixed</td>\n",
" <td>+15%</td>\n",
" <td>0</td>\n",
" <td>294,630</td>\n",
" <td>215,280</td>\n",
" <td>215,280</td>\n",
" <td>607,506</td>\n",
" </tr>\n",
" <tr>\n",
" <th>TOTAL</th>\n",
" <td></td>\n",
" <td></td>\n",
" <td>1,196,250</td>\n",
" <td>7,281,067</td>\n",
" <td>8,771,168</td>\n",
" <td>10,539,889</td>\n",
" <td>22,983,076</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Driver Risk adj Initial 2026 \\\n",
"Cost \n",
"Amazon Connect usage cost contacts +5% 0 6,779,270 \n",
"Implementation and migration cost fixed +10% 1,196,250 207,166 \n",
"Ongoing management fixed +15% 0 294,630 \n",
"TOTAL 1,196,250 7,281,067 \n",
"\n",
" 2027 2028 PV \n",
"Cost \n",
"Amazon Connect usage cost 8,348,722 10,324,609 20,819,775 \n",
"Implementation and migration cost 207,166 0 1,555,795 \n",
"Ongoing management 215,280 215,280 607,506 \n",
"TOTAL 8,771,168 10,539,889 22,983,076 "
]
},
"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-09T18:15:43.270331Z",
"iopub.status.busy": "2026-07-09T18:15:43.270194Z",
"iopub.status.idle": "2026-07-09T18:15:43.287522Z",
"shell.execute_reply": "2026-07-09T18:15:43.286918Z"
}
},
"outputs": [
{
"data": {
"application/vnd.plotly.v1+json": {
"config": {
"plotlyServerURL": "https://plot.ly"
},
"data": [
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#e34948",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Amazon Connect usage cost",
"type": "bar",
"x": [
"Initial",
"2026",
"2027",
"2028"
],
"y": [
0.0,
6779270.4,
8348722.2,
10324609.05
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#eda100",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Implementation and migration cost",
"type": "bar",
"x": [
"Initial",
"2026",
"2027",
"2028"
],
"y": [
1196250.0,
207166.30000000002,
207166.30000000002,
0.0
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#4a3aa7",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Ongoing management",
"type": "bar",
"x": [
"Initial",
"2026",
"2027",
"2028"
],
"y": [
0.0,
294630.0,
215279.99999999997,
215279.99999999997
]
}
],
"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": "<b>Costs by year (risk-adjusted)</b><br><span style='font-size:12px;color:#898781'>Initial = undiscounted time-0 outlay (implementation)</span>",
"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 (implementation)\")\n",
"fig.show()\n"
]
},
{
"cell_type": "markdown",
"id": "cell-14",
"metadata": {},
"source": [
"<a id=\"section-5\"></a>\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 the PDF's\n",
"own table rounding — the gate in §7 enforces it. Payback lands under a\n",
"month because the composite's $1.2M initial outlay is small against\n",
"~$20M of year-1 net benefit; Forrester publishes it simply as\n",
"\"<6 months\", and sub-month precision is a full-year-aggregation artifact,\n",
"not a forecast.\n"
]
},
{
"cell_type": "code",
"execution_count": 10,
"id": "cell-15",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-09T18:15:43.291216Z",
"iopub.status.busy": "2026-07-09T18:15:43.291061Z",
"iopub.status.idle": "2026-07-09T18:15:43.298783Z",
"shell.execute_reply": "2026-07-09T18:15:43.298192Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Forrester composite (published 🟢)</th>\n",
" <th>Client overlay (🟡)</th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>Benefits PV</th>\n",
" <td>$101,696,791</td>\n",
" <td>$101,696,568</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Costs PV</th>\n",
" <td>$22,983,076</td>\n",
" <td>$22,983,076</td>\n",
" </tr>\n",
" <tr>\n",
" <th>NPV</th>\n",
" <td>$78,713,715</td>\n",
" <td>$78,713,492</td>\n",
" </tr>\n",
" <tr>\n",
" <th>ROI</th>\n",
" <td>342%</td>\n",
" <td>342%</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Payback</th>\n",
" <td>&lt;6 months</td>\n",
" <td>0.7 months (~Jan 2026)</td>\n",
" </tr>\n",
" <tr>\n",
" <th>Discount rate</th>\n",
" <td>10%</td>\n",
" <td>10%</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Forrester composite (published 🟢) Client overlay (🟡)\n",
"Benefits PV $101,696,791 $101,696,568\n",
"Costs PV $22,983,076 $22,983,076\n",
"NPV $78,713,715 $78,713,492\n",
"ROI 342% 342%\n",
"Payback <6 months 0.7 months (~Jan 2026)\n",
"Discount rate 10% 10%"
]
},
"metadata": {},
"output_type": "display_data"
},
{
"name": "stdout",
"output_type": "stream",
"text": [
"engine composite NPV $78,713,491.78 vs published $78,713,715 (Δ -223.22)\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\": \"<6 months\",\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-09T18:15:43.302934Z",
"iopub.status.busy": "2026-07-09T18:15:43.302786Z",
"iopub.status.idle": "2026-07-09T18:15:43.324633Z",
"shell.execute_reply": "2026-07-09T18:15:43.324079Z"
}
},
"outputs": [
{
"data": {
"application/vnd.plotly.v1+json": {
"config": {
"plotlyServerURL": "https://plot.ly"
},
"data": [
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#1baf7a",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Benefits (risk-adjusted)",
"type": "bar",
"x": [
"Initial",
"2026",
"2027",
"2028"
],
"y": [
0,
27279019.0,
40333658.2,
57983494.400000006
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#e34948",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Costs (risk-adjusted)",
"type": "bar",
"x": [
"Initial",
"2026",
"2027",
"2028"
],
"y": [
-1196250.0,
-7281066.7,
-8771168.5,
-10539889.05
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"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": [
-1196250.0,
18801702.3,
50364192.0,
97807797.35000001
]
}
],
"layout": {
"annotations": [
{
"align": "left",
"bgcolor": "#fcfcfb",
"bordercolor": "#e1e0d9",
"borderwidth": 1,
"font": {
"color": "#0b0b0b",
"size": 12
},
"showarrow": false,
"text": "NPV &#36;78.7M · ROI 342% · payback 0.7 months (~Jan 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": "<b>Cash flow — risk-adjusted</b><br><span style='font-size:12px;color:#898781'>benefits up, costs down; initial outlay undiscounted at time 0</span>",
"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 (p.25) ─────────────\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-09T18:15:43.329401Z",
"iopub.status.busy": "2026-07-09T18:15:43.329247Z",
"iopub.status.idle": "2026-07-09T18:15:43.348656Z",
"shell.execute_reply": "2026-07-09T18:15:43.348131Z"
}
},
"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": [
"&#36;101.7M",
"-&#36;23.0M",
"&#36;78.7M"
],
"textposition": "outside",
"totals": {
"marker": {
"color": "#2a78d6"
}
},
"type": "waterfall",
"x": [
"Benefits PV",
"Costs PV",
"NPV"
],
"y": [
101696567.55071373,
-22983075.775356874,
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": "<b>Present value walk — client overlay</b><br><span style='font-size:12px;color:#898781'>discounted at 10%, 3 years</span>",
"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": [
"<a id=\"section-6\"></a>\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 the\n",
"consumption-priced usage cost, and the clamp caps how much extra padding\n",
"the risk delta can add back. The case direction is still conservative:\n",
"NPV and ROI both fall.\n"
]
},
{
"cell_type": "code",
"execution_count": 13,
"id": "cell-19",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-09T18:15:43.353547Z",
"iopub.status.busy": "2026-07-09T18:15:43.353383Z",
"iopub.status.idle": "2026-07-09T18:15:43.362921Z",
"shell.execute_reply": "2026-07-09T18:15:43.362320Z"
}
},
"outputs": [
{
"data": {
"text/html": [
"<div>\n",
"<style scoped>\n",
" .dataframe tbody tr th:only-of-type {\n",
" vertical-align: middle;\n",
" }\n",
"\n",
" .dataframe tbody tr th {\n",
" vertical-align: top;\n",
" }\n",
"\n",
" .dataframe thead th {\n",
" text-align: right;\n",
" }\n",
"</style>\n",
"<table border=\"1\" class=\"dataframe\">\n",
" <thead>\n",
" <tr style=\"text-align: right;\">\n",
" <th></th>\n",
" <th>Adoption</th>\n",
" <th>Risk Δ</th>\n",
" <th>Benefits PV</th>\n",
" <th>Costs PV</th>\n",
" <th>NPV</th>\n",
" <th>ROI %</th>\n",
" <th>Payback (months)</th>\n",
" </tr>\n",
" <tr>\n",
" <th>Scenario</th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" <th></th>\n",
" </tr>\n",
" </thead>\n",
" <tbody>\n",
" <tr>\n",
" <th>conservative</th>\n",
" <td>80%</td>\n",
" <td>+10%</td>\n",
" <td>71,672,868</td>\n",
" <td>17,437,916</td>\n",
" <td>54,234,951</td>\n",
" <td>311</td>\n",
" <td>1</td>\n",
" </tr>\n",
" <tr>\n",
" <th>moderate</th>\n",
" <td>100%</td>\n",
" <td>+0%</td>\n",
" <td>101,696,568</td>\n",
" <td>22,983,076</td>\n",
" <td>78,713,492</td>\n",
" <td>342</td>\n",
" <td>1</td>\n",
" </tr>\n",
" <tr>\n",
" <th>aggressive</th>\n",
" <td>115%</td>\n",
" <td>-5%</td>\n",
" <td>123,911,705</td>\n",
" <td>27,682,369</td>\n",
" <td>96,229,337</td>\n",
" <td>348</td>\n",
" <td>1</td>\n",
" </tr>\n",
" </tbody>\n",
"</table>\n",
"</div>"
],
"text/plain": [
" Adoption Risk Δ Benefits PV Costs PV NPV ROI % \\\n",
"Scenario \n",
"conservative 80% +10% 71,672,868 17,437,916 54,234,951 311 \n",
"moderate 100% +0% 101,696,568 22,983,076 78,713,492 342 \n",
"aggressive 115% -5% 123,911,705 27,682,369 96,229,337 348 \n",
"\n",
" Payback (months) \n",
"Scenario \n",
"conservative 1 \n",
"moderate 1 \n",
"aggressive 1 "
]
},
"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-09T18:15:43.366882Z",
"iopub.status.busy": "2026-07-09T18:15:43.366731Z",
"iopub.status.idle": "2026-07-09T18:15:43.383646Z",
"shell.execute_reply": "2026-07-09T18:15:43.383094Z"
}
},
"outputs": [
{
"data": {
"application/vnd.plotly.v1+json": {
"config": {
"plotlyServerURL": "https://plot.ly"
},
"data": [
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#1baf7a",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Benefits PV",
"type": "bar",
"x": [
"conservative",
"moderate",
"aggressive"
],
"y": [
71672867.64838466,
101696567.55071373,
123911705.40270472
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#e34948",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "Costs PV",
"type": "bar",
"x": [
"conservative",
"moderate",
"aggressive"
],
"y": [
17437916.33959429,
22983075.775356874,
27682368.613918103
]
},
{
"hovertemplate": "%{fullData.name}: %{y:$,.0f}<extra></extra>",
"marker": {
"color": "#2a78d6",
"line": {
"color": "#fcfcfb",
"width": 2
}
},
"name": "NPV",
"type": "bar",
"x": [
"conservative",
"moderate",
"aggressive"
],
"y": [
54234951.30879037,
78713491.77535686,
96229336.78878662
]
}
],
"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": "<b>Scenario comparison — client overlay</b><br><span style='font-size:12px;color:#898781'>adoption × risk-delta stress on the same drivers</span>",
"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": [
"<a id=\"section-7\"></a>\n",
"## 7 · Verification & assertions\n",
"\n",
"The gate re-derives the case from the engine and asserts: the verbatim\n",
"anchor is intact; the engine reproduces Forrester's published totals\n",
"within table rounding (±$1,000); the overlay is the identity at composite\n",
"scale; and the structural identities hold at **any** widget setting. It\n",
"must pass in a headless `nbconvert --execute` run — that is this study's\n",
"regression check.\n"
]
},
{
"cell_type": "code",
"execution_count": 15,
"id": "cell-22",
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-09T18:15:43.387984Z",
"iopub.status.busy": "2026-07-09T18:15:43.387815Z",
"iopub.status.idle": "2026-07-09T18:15:43.399773Z",
"shell.execute_reply": "2026-07-09T18:15:43.399196Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"All assertions passed.\n",
" reproduction Δ vs PDF: benefits -223.45 · costs -0.22 · npv -223.22\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\"], 13_911_040)\n",
"_approx(COSTS_VERBATIM[1][\"initial\"], 1_087_500)\n",
"assert ASSUMPTIONS[\"agents_fte\"] == 2_000\n",
"assert ASSUMPTIONS[\"annual_contacts_y1\"] == 20_000_000\n",
"assert (COMPOSITE.agents_fte, COMPOSITE.annual_contacts_y1) == (2_000, 20_000_000)\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=1_000) # Δ 223.45 (PDF rounding)\n",
"_approx(_c[\"costs_pv\"], PUBLISHED[\"costs_pv\"], tol=1_000) # Δ 0.22\n",
"_approx(_c[\"npv\"], PUBLISHED[\"npv\"], tol=1_000) # Δ 223.22\n",
"assert round(_c[\"roi_pct\"]) == PUBLISHED[\"roi_pct\"] # 342.48 → 342\n",
"assert _c[\"payback_months\"] < PUBLISHED[\"payback_months_max\"] # \"<6 months\"\n",
"_approx(_c[\"payback_months\"], 0.72, tol=0.01)\n",
"_approx(_c[\"initial_costs\"], 1_196_250)\n",
"_approx(_c[\"benefits_by_year\"][2026], 27_279_019, tol=1)\n",
"_approx(_c[\"costs_by_year\"][2028], 10_539_889.05, 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, _ = overlay_rows(ClientDrivers(agents_fte=1_000))\n",
"_approx(next(r for r in _hb if r[\"field_key\"] == \"ai_forecasting_supervision\")\n",
" [\"year_values\"][\"1\"], 6_651_680 / 2) # agents-driven halves\n",
"_approx(next(r for r in _hb if r[\"field_key\"] == \"ai_contact_resolution\")\n",
" [\"year_values\"][\"1\"], 13_911_040) # contacts-driven unmoved\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\"], 54_234_951.31, 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\"], 101_696_567.55, tol=1) # engine-exact\n",
" _approx(client[\"npv\"], PUBLISHED[\"npv\"], tol=1_000)\n",
" _approx(client[\"payback_months\"], 0.72, 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": [
"<a id=\"section-8\"></a>\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-09T18:15:43.404067Z",
"iopub.status.busy": "2026-07-09T18:15:43.403921Z",
"iopub.status.idle": "2026-07-09T18:15:43.419260Z",
"shell.execute_reply": "2026-07-09T18:15:43.418439Z"
}
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"#### Composite organization (verbatim assumptions 🟢)\n",
"\n",
"| Assumption | Value 🟢 |\n",
"|:-------------------|:-----------|\n",
"| agents fte | 2,000 |\n",
"| supervisors fte | 200 |\n",
"| annual contacts y1 | 20,000,000 |\n",
"| growth rate | 30% |\n",
"| call share | 75% |\n",
"| aht legacy minutes | 10 min |\n",
"| agent salary | $45,760 |\n",
"| supervisor salary | $55,800 |\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",
"| AI-driven contact resolution efficiency | contacts | -15% | 11,824,384 | 20,342,608 | 32,128,096 | 64,295,088 | 51,699,827 |\n",
"| AI-powered content and sentiment analysis savings | contacts | -15% | 3,898,627 | 4,554,650 | 5,347,928 | 13,801,205 | 11,326,358 |\n",
"| AI-enabled forecasting, agent scheduling, and supervision | agents | -15% | 5,653,928 | 7,763,696 | 10,532,955 | 23,950,579 | 19,469,777 |\n",
"| Data-driven profit lift with increased conversion | contacts | -20% | 960,000 | 1,248,000 | 1,622,400 | 3,830,400 | 3,123,065 |\n",
"| Legacy solution cost savings | agents | -20% | 4,942,080 | 6,424,704 | 8,352,115 | 19,718,899 | 16,077,540 |\n",
"| TOTAL | | | 27,279,019 | 40,333,658 | 57,983,494 | 125,596,172 | 101,696,568 |\n",
"\n",
"#### Costs — client overlay (risk-adjusted $)\n",
"\n",
"| Cost | Driver | Risk adj | Initial | 2026 | 2027 | 2028 | PV |\n",
"|:----------------------------------|:---------|:-----------|----------:|----------:|----------:|-----------:|-----------:|\n",
"| Amazon Connect usage cost | contacts | +5% | 0 | 6,779,270 | 8,348,722 | 10,324,609 | 20,819,775 |\n",
"| Implementation and migration cost | fixed | +10% | 1,196,250 | 207,166 | 207,166 | 0 | 1,555,795 |\n",
"| Ongoing management | fixed | +15% | 0 | 294,630 | 215,280 | 215,280 | 607,506 |\n",
"| TOTAL | | | 1,196,250 | 7,281,067 | 8,771,168 | 10,539,889 | 22,983,076 |\n",
"\n",
"#### KPIs — published composite vs client overlay\n",
"\n",
"| | Forrester composite (published 🟢) | Client overlay (🟡) |\n",
"|:--------------|:-------------------------------------|:-----------------------|\n",
"| Benefits PV | $101,696,791 | $101,696,568 |\n",
"| Costs PV | $22,983,076 | $22,983,076 |\n",
"| NPV | $78,713,715 | $78,713,492 |\n",
"| ROI | 342% | 342% |\n",
"| Payback | <6 months | 0.7 months (~Jan 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% | 71,672,868 | 17,437,916 | 54,234,951 | 311 | 1 |\n",
"| moderate | 100% | +0% | 101,696,568 | 22,983,076 | 78,713,492 | 342 | 1 |\n",
"| aggressive | 115% | -5% | 123,911,705 | 27,682,369 | 96,229,337 | 348 | 1 |\n",
"\n",
"#### Model state (JSON)\n",
"\n",
"```json\n",
"{\n",
" \"study\": \"202602_TEI_Amazon_Connect\",\n",
" \"source\": \"Forrester TEI of Amazon Connect (Feb 2026, commissioned by AWS)\",\n",
" \"published\": {\n",
" \"benefits_pv\": 101696791,\n",
" \"costs_pv\": 22983076,\n",
" \"npv\": 78713715,\n",
" \"roi_pct\": 342,\n",
" \"payback_months_max\": 6,\n",
" \"discount_rate\": 0.1,\n",
" \"analysis_years\": 3\n",
" },\n",
" \"reproduction\": {\n",
" \"benefits_pv\": 101696567.55,\n",
" \"costs_pv\": 22983075.78,\n",
" \"npv\": 78713491.78,\n",
" \"roi_pct\": 342.48,\n",
" \"payback_months\": 0.72\n",
" },\n",
" \"client\": {\n",
" \"drivers\": {\n",
" \"agents_fte\": 2000,\n",
" \"annual_contacts_y1\": 20000000,\n",
" \"growth_rate\": 0.3,\n",
" \"discount_rate\": 0.1,\n",
" \"scenario\": \"moderate\"\n",
" },\n",
" \"benefits_by_year\": {\n",
" \"2026\": 27279019,\n",
" \"2027\": 40333658,\n",
" \"2028\": 57983494\n",
" },\n",
" \"costs_by_year\": {\n",
" \"2026\": 7281067,\n",
" \"2027\": 8771168,\n",
" \"2028\": 10539889\n",
" },\n",
" \"net_by_year\": {\n",
" \"2026\": 19997952,\n",
" \"2027\": 31562490,\n",
" \"2028\": 47443605\n",
" },\n",
" \"cumulative_net_by_year\": {\n",
" \"2026\": 18801702,\n",
" \"2027\": 50364192,\n",
" \"2028\": 97807797\n",
" },\n",
" \"initial_costs\": 1196250,\n",
" \"kpis\": {\n",
" \"benefits_pv\": 101696568,\n",
" \"costs_pv\": 22983076,\n",
" \"npv\": 78713492,\n",
" \"roi_pct\": 342.48,\n",
" \"payback_months\": 0.72,\n",
" \"payback_label\": \"0.7 months (~Jan 2026)\"\n",
" },\n",
" \"scenarios\": {\n",
" \"conservative\": {\n",
" \"benefits_pv\": 71672868,\n",
" \"costs_pv\": 17437916,\n",
" \"npv\": 54234951,\n",
" \"roi_pct\": 311.02\n",
" },\n",
" \"moderate\": {\n",
" \"benefits_pv\": 101696568,\n",
" \"costs_pv\": 22983076,\n",
" \"npv\": 78713492,\n",
" \"roi_pct\": 342.48\n",
" },\n",
" \"aggressive\": {\n",
" \"benefits_pv\": 123911705,\n",
" \"costs_pv\": 27682369,\n",
" \"npv\": 96229337,\n",
" \"roi_pct\": 347.62\n",
" }\n",
" }\n",
" },\n",
" \"driver_map\": {\n",
" \"benefits\": {\n",
" \"ai_contact_resolution\": \"contacts\",\n",
" \"ai_content_sentiment\": \"contacts\",\n",
" \"ai_forecasting_supervision\": \"agents\",\n",
" \"data_driven_profit_lift\": \"contacts\",\n",
" \"legacy_solution_savings\": \"agents\"\n",
" },\n",
" \"costs\": {\n",
" \"amazon_connect_usage\": \"contacts\",\n",
" \"implementation_migration\": \"fixed\",\n",
" \"ongoing_management\": \"fixed\"\n",
" }\n",
" }\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\": \"202602_TEI_Amazon_Connect\",\n",
" \"source\": \"Forrester TEI of Amazon Connect (Feb 2026, commissioned by AWS)\",\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",
" },\n",
" \"client\": {\n",
" \"drivers\": {\n",
" \"agents_fte\": AGENTS_FTE,\n",
" \"annual_contacts_y1\": CONTACTS_Y1,\n",
" \"growth_rate\": GROWTH_RATE,\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",
"}, 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": {
"01c230d30aed4aa58c5a005c44e65f84": {
"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 contacts, year 1 — composite 20M",
"layout": "IPY_MODEL_cc4716028c0c4335b1d70f4e29e905f6",
"layout_path": null,
"max": 500000000.0,
"min": 100000.0,
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"step": 1000000.0,
"tabbable": null,
"tooltip": null,
"url_key": "",
"value": 20000000.0
}
},
"0833fb2c5bd3466aba5ff466dec6ff1b": {
"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_ae315fb739f643f7a1fbe0f3678a6002",
"layout_path": null,
"placeholder": "",
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"style": "IPY_MODEL_f9174ef9b9674e9d8c64bd888d70130b",
"tabbable": null,
"tooltip": null,
"value": "<div style=\"font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 14px; font-weight: normal; line-height: 1.65; color: #0f172a; word-break: break-word;\"><p style=\"margin: 0 0 1em;\"><b>Jump to section</b><ol style=\"padding-left:1.2em;margin:6px 0; margin: 0 0 1em; padding-left: 1.4em;\"><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-1');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('1 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Composite organization</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-2');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('2 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Client inputs</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-3');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('3 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Benefits</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-4');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('4 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Costs</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-5');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('5 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Business case</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-6');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('6 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Scenarios</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-7');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('7 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Verification &amp; assertions</a></li><li style=\"margin:2px 0\"><a style=\"cursor:pointer;text-decoration:underline; color: #007bff; text-decoration: underline; text-underline-offset: 0.14em;\" onclick=\"var el=document.getElementById('section-8');if(!el){document.querySelectorAll('h1,h2').forEach(function(h){if(!el&&h.textContent.trim().indexOf('8 ')===0){el=h;}});}if(el)el.scrollIntoView({behavior:'smooth',block:'start'});\">Data appendix</a></li></ol></p></div>"
}
},
"48a9c76ed0fb498984234409f404ac3f": {
"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
}
},
"ace9c00dc3a84babb5b29ae61e2be62c": {
"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": "Contact-center agents (FTE) — composite 2,000",
"layout": "IPY_MODEL_ce0e81f1ea384a79897a9eb663bda565",
"layout_path": null,
"max": 50000.0,
"min": 50.0,
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"step": 50.0,
"tabbable": null,
"tooltip": null,
"url_key": "",
"value": 2000.0
}
},
"ae315fb739f643f7a1fbe0f3678a6002": {
"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
}
},
"b0de84b09c5743b9a4e7a5dbdf1973d9": {
"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
}
},
"b1e82327c29a44a8a4a45e8100b3d857": {
"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_b0de84b09c5743b9a4e7a5dbdf1973d9",
"layout_path": null,
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"tabbable": null,
"tooltip": null,
"url_key": "",
"value": "moderate"
}
},
"cc4716028c0c4335b1d70f4e29e905f6": {
"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
}
},
"ce0e81f1ea384a79897a9eb663bda565": {
"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
}
},
"d4ccace17e824e4a85c55ff6ef95afb0": {
"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": "Contact growth (%/yr) — composite 30",
"layout": "IPY_MODEL_48a9c76ed0fb498984234409f404ac3f",
"layout_path": null,
"max": 100.0,
"min": 0.0,
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"step": 5.0,
"tabbable": null,
"tooltip": null,
"url_key": "",
"value": 30.0
}
},
"d67347bf93954b6abc75a466c3259dd3": {
"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
}
},
"eb0056195e9b444dbb8ef3c08e744446": {
"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_d67347bf93954b6abc75a466c3259dd3",
"layout_path": null,
"position": "sidebar",
"render_slot_id": null,
"source_cell_id": null,
"tabbable": null,
"tooltip": null,
"url_key": "",
"value": "10% (Forrester)"
}
},
"f9174ef9b9674e9d8c64bd888d70130b": {
"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
}
}
},
"version_major": 2,
"version_minor": 0
}
}
},
"nbformat": 4,
"nbformat_minor": 5
}