{
"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 2026–2028 · 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\"{title}\"\n",
" if subtitle:\n",
" t += f\"
{subtitle}\"\n",
" fig.update_layout(\n",
" title=dict(text=t, font=dict(size=16, color=INK), x=0.02, xanchor=\"left\"),\n",
" paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,\n",
" font=dict(family=FONT_STACK, size=12, color=INK2),\n",
" legend=dict(orientation=\"h\", yanchor=\"top\", y=-0.10, x=0,\n",
" font=dict(size=11, color=INK2)),\n",
" xaxis=dict(type=\"category\", showgrid=False, linecolor=BASELINE,\n",
" tickfont=dict(color=MUTED)),\n",
" yaxis=dict(gridcolor=GRID, zerolinecolor=BASELINE, zerolinewidth=1.5,\n",
" tickformat=\"$~s\", tickfont=dict(color=MUTED)),\n",
" hovermode=\"x unified\", bargap=0.45, height=height,\n",
" margin=dict(t=70, r=30, b=80, l=70),\n",
" )\n",
" return fig\n",
"\n",
"\n",
"def bar(x, y, name, color):\n",
" return go.Bar(x=x, y=y, name=name,\n",
" marker=dict(color=color, line=dict(width=2, color=SURFACE)),\n",
" hovertemplate=\"%{fullData.name}: %{y:$,.0f}\")\n",
"\n",
"\n",
"def cum_line(x, y, name, color=CUMULATIVE, dash=None):\n",
" return go.Scatter(x=x, y=y, name=name, mode=\"lines+markers\",\n",
" line=dict(color=color, width=2, dash=dash),\n",
" marker=dict(size=8, line=dict(width=2, color=SURFACE)),\n",
" hovertemplate=\"%{fullData.name}: %{y:$,.0f}\")\n",
"\n",
"\n",
"backstage(f\"teicalc loaded — window {YEARS[0]}–{YEARS[-1]} · published \"\n",
" f\"NPV {money(PUBLISHED['npv'])} · ROI {PUBLISHED['roi_pct']}%\")\n"
]
},
{
"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='
{label}'\n",
" for n, label in _TOC)\n",
"_toc = mr.Markdown(\n",
" text=(f'
Jump to section'\n",
" f'
{_items}
'),\n",
" position=\"sidebar\")\n"
]
},
{
"cell_type": "markdown",
"id": "cell-3",
"metadata": {},
"source": [
"
\n",
"## 1 · The Forrester composite (verbatim anchor 🟢)\n",
"\n",
"Forrester's composite organization: a global 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** (×(1−rf)), costs **up** (×(1+rf)); the initial\n",
"investment sits at time 0 undiscounted; year flows discount at end-of-year\n",
"(10%, 3 years). 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": [
"
\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Value 🟢 | \n",
"
\n",
" \n",
" | Assumption | \n",
" | \n",
"
\n",
" \n",
" \n",
" \n",
" | agents fte | \n",
" 2,000 | \n",
"
\n",
" \n",
" | supervisors fte | \n",
" 200 | \n",
"
\n",
" \n",
" | annual contacts y1 | \n",
" 20,000,000 | \n",
"
\n",
" \n",
" | growth rate | \n",
" 30% | \n",
"
\n",
" \n",
" | call share | \n",
" 75% | \n",
"
\n",
" \n",
" | aht legacy minutes | \n",
" 10 min | \n",
"
\n",
" \n",
" | agent salary | \n",
" $45,760 | \n",
"
\n",
" \n",
" | supervisor salary | \n",
" $55,800 | \n",
"
\n",
" \n",
" | discount rate | \n",
" 10% | \n",
"
\n",
" \n",
" | analysis years | \n",
" 3 years | \n",
"
\n",
" \n",
"
\n",
"
"
],
"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": [
"
\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Published 🟢 | \n",
"
\n",
" \n",
" | Metric | \n",
" | \n",
"
\n",
" \n",
" \n",
" \n",
" | Benefits PV (risk-adjusted) | \n",
" $101,696,791 | \n",
"
\n",
" \n",
" | Costs PV (risk-adjusted) | \n",
" $22,983,076 | \n",
"
\n",
" \n",
" | NPV | \n",
" $78,713,715 | \n",
"
\n",
" \n",
" | ROI | \n",
" 342% | \n",
"
\n",
" \n",
" | Payback | \n",
" <6 months | \n",
"
\n",
" \n",
"
\n",
"
"
],
"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": [
"
\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": [
"
"
]
},
"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": [
""
]
},
"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": [
""
]
},
"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": [
""
]
},
"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": [
""
]
},
"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": [
"\n",
"## 3 · Benefits\n",
"\n",
"Five benefit streams (Forrester refs At–Et), risk-adjusted down 15–20%.\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": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Driver | \n",
" Risk adj | \n",
" 2026 | \n",
" 2027 | \n",
" 2028 | \n",
" 3-yr RA | \n",
" PV | \n",
"
\n",
" \n",
" | Benefit | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
"
\n",
" \n",
" \n",
" \n",
" | AI-driven contact resolution efficiency | \n",
" contacts | \n",
" -15% | \n",
" 11,824,384 | \n",
" 20,342,608 | \n",
" 32,128,096 | \n",
" 64,295,088 | \n",
" 51,699,827 | \n",
"
\n",
" \n",
" | AI-powered content and sentiment analysis savings | \n",
" contacts | \n",
" -15% | \n",
" 3,898,627 | \n",
" 4,554,650 | \n",
" 5,347,928 | \n",
" 13,801,205 | \n",
" 11,326,358 | \n",
"
\n",
" \n",
" | AI-enabled forecasting, agent scheduling, and supervision | \n",
" agents | \n",
" -15% | \n",
" 5,653,928 | \n",
" 7,763,696 | \n",
" 10,532,955 | \n",
" 23,950,579 | \n",
" 19,469,777 | \n",
"
\n",
" \n",
" | Data-driven profit lift with increased conversion | \n",
" contacts | \n",
" -20% | \n",
" 960,000 | \n",
" 1,248,000 | \n",
" 1,622,400 | \n",
" 3,830,400 | \n",
" 3,123,065 | \n",
"
\n",
" \n",
" | Legacy solution cost savings | \n",
" agents | \n",
" -20% | \n",
" 4,942,080 | \n",
" 6,424,704 | \n",
" 8,352,115 | \n",
" 19,718,899 | \n",
" 16,077,540 | \n",
"
\n",
" \n",
" | TOTAL | \n",
" | \n",
" | \n",
" 27,279,019 | \n",
" 40,333,658 | \n",
" 57,983,494 | \n",
" 125,596,172 | \n",
" 101,696,568 | \n",
"
\n",
" \n",
"
\n",
"
"
],
"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}",
"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}",
"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}",
"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}",
"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}",
"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": "Benefits by year (risk-adjusted)
stacked by benefit stream — client overlay",
"x": 0.02,
"xanchor": "left"
},
"xaxis": {
"linecolor": "#c3c2b7",
"showgrid": false,
"tickfont": {
"color": "#898781"
},
"type": "category"
},
"yaxis": {
"gridcolor": "#e1e0d9",
"tickfont": {
"color": "#898781"
},
"tickformat": "$~s",
"zerolinecolor": "#c3c2b7",
"zerolinewidth": 1.5
}
}
}
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"fig = go.Figure()\n",
"_x = [str(y) for y in YEARS]\n",
"for r in client[\"rows\"][\"benefits\"]:\n",
" fig.add_trace(bar(_x, [r[\"ra_by_year\"][y] for y in YEARS],\n",
" r[\"label\"], BENEFIT_COLOR[r[\"field_key\"]]))\n",
"if not _at_default: # composite yearly total as de-emphasized context\n",
" fig.add_trace(cum_line(_x, [composite[\"benefits_by_year\"][y] for y in YEARS],\n",
" \"Forrester composite total\", CONTEXT, dash=\"dot\"))\n",
"fig.update_layout(barmode=\"stack\")\n",
"tei_layout(fig, \"Benefits by year (risk-adjusted)\",\n",
" subtitle=\"stacked by benefit stream — client overlay\", height=480)\n",
"fig.show()\n"
]
},
{
"cell_type": "markdown",
"id": "cell-11",
"metadata": {},
"source": [
"\n",
"## 4 · Costs\n",
"\n",
"Three cost lines, risk-adjusted **up** 5–15%. 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": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Driver | \n",
" Risk adj | \n",
" Initial | \n",
" 2026 | \n",
" 2027 | \n",
" 2028 | \n",
" PV | \n",
"
\n",
" \n",
" | Cost | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
"
\n",
" \n",
" \n",
" \n",
" | Amazon Connect usage cost | \n",
" contacts | \n",
" +5% | \n",
" 0 | \n",
" 6,779,270 | \n",
" 8,348,722 | \n",
" 10,324,609 | \n",
" 20,819,775 | \n",
"
\n",
" \n",
" | Implementation and migration cost | \n",
" fixed | \n",
" +10% | \n",
" 1,196,250 | \n",
" 207,166 | \n",
" 207,166 | \n",
" 0 | \n",
" 1,555,795 | \n",
"
\n",
" \n",
" | Ongoing management | \n",
" fixed | \n",
" +15% | \n",
" 0 | \n",
" 294,630 | \n",
" 215,280 | \n",
" 215,280 | \n",
" 607,506 | \n",
"
\n",
" \n",
" | TOTAL | \n",
" | \n",
" | \n",
" 1,196,250 | \n",
" 7,281,067 | \n",
" 8,771,168 | \n",
" 10,539,889 | \n",
" 22,983,076 | \n",
"
\n",
" \n",
"
\n",
"
"
],
"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}",
"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}",
"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}",
"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": "Costs by year (risk-adjusted)
Initial = undiscounted time-0 outlay (implementation)",
"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": [
"\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": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Forrester composite (published 🟢) | \n",
" Client overlay (🟡) | \n",
"
\n",
" \n",
" \n",
" \n",
" | Benefits PV | \n",
" $101,696,791 | \n",
" $101,696,568 | \n",
"
\n",
" \n",
" | Costs PV | \n",
" $22,983,076 | \n",
" $22,983,076 | \n",
"
\n",
" \n",
" | NPV | \n",
" $78,713,715 | \n",
" $78,713,492 | \n",
"
\n",
" \n",
" | ROI | \n",
" 342% | \n",
" 342% | \n",
"
\n",
" \n",
" | Payback | \n",
" <6 months | \n",
" 0.7 months (~Jan 2026) | \n",
"
\n",
" \n",
" | Discount rate | \n",
" 10% | \n",
" 10% | \n",
"
\n",
" \n",
"
\n",
"
"
],
"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}",
"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}",
"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}",
"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 $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": "Cash flow — risk-adjusted
benefits up, costs down; initial outlay undiscounted at time 0",
"x": 0.02,
"xanchor": "left"
},
"xaxis": {
"linecolor": "#c3c2b7",
"showgrid": false,
"tickfont": {
"color": "#898781"
},
"type": "category"
},
"yaxis": {
"gridcolor": "#e1e0d9",
"tickfont": {
"color": "#898781"
},
"tickformat": "$~s",
"zerolinecolor": "#c3c2b7",
"zerolinewidth": 1.5
}
}
}
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"# ── Cash flow — mirrors the PDF's Cash Flow Chart (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": [
"$101.7M",
"-$23.0M",
"$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": "Present value walk — client overlay
discounted at 10%, 3 years",
"x": 0.02,
"xanchor": "left"
},
"xaxis": {
"linecolor": "#c3c2b7",
"showgrid": false,
"tickfont": {
"color": "#898781"
},
"type": "category"
},
"yaxis": {
"gridcolor": "#e1e0d9",
"tickfont": {
"color": "#898781"
},
"tickformat": "$~s",
"zerolinecolor": "#c3c2b7",
"zerolinewidth": 1.5
}
}
}
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"fig = go.Figure(go.Waterfall(\n",
" x=[\"Benefits PV\", \"Costs PV\", \"NPV\"],\n",
" measure=[\"relative\", \"relative\", \"total\"],\n",
" y=[client[\"benefits_pv\"], -client[\"costs_pv\"], 0],\n",
" text=[html_money(client[\"benefits_pv\"]), html_money(-client[\"costs_pv\"]),\n",
" html_money(client[\"npv\"])],\n",
" textposition=\"outside\",\n",
" connector=dict(line=dict(color=GRID)),\n",
" increasing=dict(marker=dict(color=BEN_TOTAL)),\n",
" decreasing=dict(marker=dict(color=COST_TOTAL)),\n",
" totals=dict(marker=dict(color=NPV_COLOR)),\n",
"))\n",
"fig.update_layout(showlegend=False)\n",
"tei_layout(fig, \"Present value walk — client overlay\",\n",
" subtitle=f\"discounted at {'{:.0%}'.format(DISCOUNT_RATE)}, 3 years\",\n",
" height=420)\n",
"fig.show()\n"
]
},
{
"cell_type": "markdown",
"id": "cell-18",
"metadata": {},
"source": [
"\n",
"## 6 · Scenarios (🟡)\n",
"\n",
"Scenarios stress the overlay on two levers: **adoption** scales every\n",
"nominal value (including the initial outlay), and **risk delta** widens or\n",
"narrows the TEI risk adjustments — added to benefit risk, subtracted from\n",
"cost risk, clamped at zero.\n",
"\n",
"One counterintuitive consequence, worth stating: the **conservative**\n",
"scenario *lowers* costs PV as well as benefits — 80% adoption shrinks 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": [
"\n",
"\n",
"
\n",
" \n",
" \n",
" | \n",
" Adoption | \n",
" Risk Δ | \n",
" Benefits PV | \n",
" Costs PV | \n",
" NPV | \n",
" ROI % | \n",
" Payback (months) | \n",
"
\n",
" \n",
" | Scenario | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
" | \n",
"
\n",
" \n",
" \n",
" \n",
" | conservative | \n",
" 80% | \n",
" +10% | \n",
" 71,672,868 | \n",
" 17,437,916 | \n",
" 54,234,951 | \n",
" 311 | \n",
" 1 | \n",
"
\n",
" \n",
" | moderate | \n",
" 100% | \n",
" +0% | \n",
" 101,696,568 | \n",
" 22,983,076 | \n",
" 78,713,492 | \n",
" 342 | \n",
" 1 | \n",
"
\n",
" \n",
" | aggressive | \n",
" 115% | \n",
" -5% | \n",
" 123,911,705 | \n",
" 27,682,369 | \n",
" 96,229,337 | \n",
" 348 | \n",
" 1 | \n",
"
\n",
" \n",
"
\n",
"
"
],
"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}",
"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}",
"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}",
"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": "Scenario comparison — client overlay
adoption × risk-delta stress on the same drivers",
"x": 0.02,
"xanchor": "left"
},
"xaxis": {
"linecolor": "#c3c2b7",
"showgrid": false,
"tickfont": {
"color": "#898781"
},
"type": "category"
},
"yaxis": {
"gridcolor": "#e1e0d9",
"tickfont": {
"color": "#898781"
},
"tickformat": "$~s",
"zerolinecolor": "#c3c2b7",
"zerolinewidth": 1.5
}
}
}
},
"metadata": {},
"output_type": "display_data"
}
],
"source": [
"fig = go.Figure()\n",
"_scen = list(scen_summaries)\n",
"for _name, _key, _color in [(\"Benefits PV\", \"benefits_pv\", BEN_TOTAL),\n",
" (\"Costs PV\", \"costs_pv\", COST_TOTAL),\n",
" (\"NPV\", \"npv\", NPV_COLOR)]:\n",
" fig.add_trace(bar(_scen, [scen_summaries[s][_key] for s in _scen],\n",
" _name, _color))\n",
"fig.update_layout(barmode=\"group\")\n",
"tei_layout(fig, \"Scenario comparison — client overlay\",\n",
" subtitle=\"adoption × risk-delta stress on the same drivers\",\n",
" height=420)\n",
"fig.show()\n"
]
},
{
"cell_type": "markdown",
"id": "cell-21",
"metadata": {},
"source": [
"\n",
"## 7 · Verification & assertions\n",
"\n",
"The gate re-derives the case from the engine and asserts: the verbatim\n",
"anchor is intact; 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": [
"\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": ""
}
},
"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
}