diff --git a/studies/202512_GenesysCX/ctm-token-calculator/app/streamlit_app.py b/studies/202512_GenesysCX/ctm-token-calculator/app/streamlit_app.py
index 3153594..283600a 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/app/streamlit_app.py
+++ b/studies/202512_GenesysCX/ctm-token-calculator/app/streamlit_app.py
@@ -29,6 +29,7 @@ import plotly.graph_objects as go
import streamlit as st
import tokencalc.scenarios as tc_scenarios
+from tokencalc import appendix4 as a4
from tokencalc import (
CONTRACTED_NAMED_USERS,
CTM_DEFAULT_FEATURE_SCOPES,
@@ -118,6 +119,7 @@ def _case(scenario: str) -> dict:
st.sidebar.title("NTT DATA — CTM Token Calculator")
page = st.sidebar.radio("Page", [
+ "0. Corrected Business Case",
"1. Inputs", "2. Token Meters", "3. Cost Model", "4. Benefit Model",
"5. Business Case", "6. Sensitivity Analysis", "7. Export",
])
@@ -151,9 +153,320 @@ def _users_warning() -> None:
)
+# ── Corrected-case chart chrome (ports the notebook's TEI styling) ───
+
+_INK, _INK2, _MUTED = "#0b0b0b", "#52514e", "#898781"
+_SURFACE, _GRID, _BASELINE = "#fcfcfb", "#e1e0d9", "#c3c2b7"
+_CUMULATIVE, _CONTEXT = "#52514e", "#c3c2b7"
+_CAP_COLOR = {
+ "Agent Copilot": "#2a78d6", "WFM": "#1baf7a", "Email": "#eda100",
+ "STA": "#008300", "Predictive Routing": "#4a3aa7",
+ "Supervisor Copilot": "#e34948",
+}
+_COST_COLOR = {
+ "CCaaS platform licences (ramp-adjusted)": "#2a78d6",
+ "Base professional services + training": "#1baf7a",
+ "Existing platform (term-contract run-off)": "#eda100",
+ "AI token consumption": "#008300",
+ "AI implementation + KB readiness": "#4a3aa7",
+ "AI steady-state tuning": "#e34948",
+}
+_X = [str(y) for y in a4.YEARS]
+
+
+def _tei_layout(fig, title, subtitle=None, height=460):
+ t = f"{title}"
+ if subtitle:
+ t += f"
{subtitle}"
+ fig.update_layout(
+ title=dict(text=t, font=dict(size=16, color=_INK), x=0.02, xanchor="left"),
+ paper_bgcolor=_SURFACE, plot_bgcolor=_SURFACE,
+ font=dict(family='system-ui, -apple-system, "Segoe UI", sans-serif',
+ size=12, color=_INK2),
+ legend=dict(orientation="h", yanchor="top", y=-0.10, x=0,
+ font=dict(size=11, color=_INK2)),
+ xaxis=dict(type="category", showgrid=False, linecolor=_BASELINE,
+ tickfont=dict(color=_MUTED)),
+ yaxis=dict(gridcolor=_GRID, zerolinecolor=_BASELINE, zerolinewidth=1.5,
+ tickformat="$~s", tickfont=dict(color=_MUTED)),
+ hovermode="x unified", bargap=0.45, height=height,
+ margin=dict(t=70, r=30, b=80, l=70),
+ )
+ return fig
+
+
+def _bar(x, y, name, color):
+ return go.Bar(x=x, y=y, name=name,
+ marker=dict(color=color, line=dict(width=2, color=_SURFACE)),
+ hovertemplate="%{fullData.name}: %{y:$,.0f}")
+
+
+def _cum_line(x, y, name, color=_CUMULATIVE, dash=None):
+ return go.Scatter(x=x, y=y, name=name, mode="lines+markers",
+ line=dict(color=color, width=2, dash=dash),
+ marker=dict(size=8, line=dict(width=2, color=_SURFACE)),
+ hovertemplate="%{fullData.name}: %{y:$,.0f}")
+
+
+# ── Page 0: Corrected Business Case ──────────────────────────────────
+
+if page == "0. Corrected Business Case":
+ st.header("Corrected Business Case — Appendix 4")
+ st.caption(
+ "Genesys's benefits **verbatim** ($15.0M / 3 yr, phased on their own "
+ "deployment schedule) against a cost case corrected for **AI token "
+ "consumption**, **AI implementation effort (V2 LoE)**, **existing-platform "
+ "double-billing**, and the **ramp credit** the deck also missed. "
+ "The sidebar scenario/year controls do not apply to this page. "
+ "Sites and token pricing are shared with the other pages."
+ )
+ _users_warning()
+
+ # ── Controls ─────────────────────────────────────────────────────
+ c1, c2, c3, c4, c5 = st.columns(5)
+ ramp_months = int(c1.number_input(
+ "Ramp (licence-free months)", 0, 24, a4.DEFAULT_RAMP_MONTHS,
+ help="Genesys ramp programme — the $4.3M/yr commit bills from the "
+ "month after the ramp ends."))
+ hours_mode = c2.selectbox("AI impl hours (V2 LoE)", ["low", "mid", "high"],
+ index=1)
+ blended_rate = float(c3.selectbox("Blended rate $/h", [175, 225, 275], index=1))
+ include_kb = c4.toggle("Include KB readiness", value=True,
+ help="500-1,500 h prerequisite project, flagged "
+ "separately from AI implementation.")
+ discount_rate = (a4.TCO_VERBATIM["npv_discount_rate"]
+ if c5.radio("NPV discount", ["13.5% (deck)", "8% (treasury)"],
+ index=0) == "13.5% (deck)" else 0.08)
+
+ with st.expander("Token-model assumptions (🟡 estimated knobs)"):
+ t1, t2, t3 = st.columns(3)
+ copilot_includes_asia = t1.toggle(
+ "Copilot tokens in ASIA", value=False,
+ help="Deck claims $0 Copilot benefit in ASIA — excluded by default "
+ "for apples-to-apples.")
+ na_email_early = t1.toggle(
+ "NA Email implements early (Jan 2027)", value=True,
+ help="NA Gantt exception: Email realizes Apr 2027.")
+ pr_eligibility = t2.slider(
+ "Predictive Routing eligibility", 0.0, 1.0, 1.0, 0.05,
+ help="Share of voice volume on PR-enabled queues. At 100% PR tokens "
+ "(~$1.8M/yr) exceed the $470K/yr claimed benefit.")
+ translate_eligibility = t2.slider(
+ "AI Translate eligibility (SupCopilot proxy)", 0.0, 0.10, 0.01, 0.005)
+ email_tokens_per_msg = t3.number_input(
+ "Email Auto-Respond tokens/msg (🔴 unpublished)", 0.0, 1.0, 0.05, 0.01,
+ help="Working assumption ≈1 AI action per generated response "
+ "(Genesys Cloud Copilot meters 20 AI actions/token).")
+ email_respond_rate = t3.slider(
+ "Email auto-respond rate", 0.0, 0.60, 0.255, 0.005,
+ help="Deck claims 25.5% of email interactions auto-responded.")
+
+ with st.expander("Current-state contracts by region — edit as real data arrives"):
+ st.caption("Seeded as $7.3M × agent share (🟡). Term contracts bill "
+ "through their termination month regardless of Genesys go-live "
+ "— that is the double-billing.")
+ cs_default = a4.current_state_inputs(sites).reset_index()
+ cs_edit = st.data_editor(
+ cs_default[["region", "agents", "annual_cost", "contract_termination"]],
+ key="a4_current_state", hide_index=True,
+ disabled=["region", "agents"],
+ column_config={
+ "annual_cost": st.column_config.NumberColumn(format="$%,.0f"),
+ "contract_termination": st.column_config.DateColumn(),
+ },
+ )
+ current_state = cs_edit.set_index("region")
+ current_state["contract_termination"] = [
+ d if d is not None else a4.DEFAULT_TERMINATION
+ for d in current_state["contract_termination"]
+ ]
+
+ # ── Model (all math in tokencalc.appendix4) ──────────────────────
+ token_ro, email_ro, benefit_ro = a4.build_rollouts(
+ sites, na_email_early, ramp_months)
+ benefits_long = a4.benefits_by_year(benefit_ro, na_email_early)
+ benefit_by_year = benefits_long.groupby("year")["benefit"].sum().to_dict()
+
+ core_scopes, email_scopes = a4.build_scopes(
+ sites, copilot_includes_asia, pr_eligibility, translate_eligibility)
+ a4_meters = {**meters,
+ "Email AI (Auto-Respond)": a4.autorespond_meter(email_tokens_per_msg)}
+ tokens_long = a4.token_costs_by_year(
+ sites, a4_meters, pricing, a4.claim_scenario(email_respond_rate),
+ core_scopes, email_scopes, token_ro, email_ro,
+ use_contracted=st.session_state.use_contracted)
+ token_by_year = tokens_long.groupby("year")["annual_cost"].sum().to_dict()
+
+ impl_detail, impl_y, kb_y, steady_y = a4.build_impl_costs(
+ sites, hours_mode, blended_rate, include_kb,
+ copilot_includes_asia, na_email_early)
+ current_y = a4.current_costs_by_year(current_state)
+ licence_y = a4.licence_costs_by_year(ramp_months)
+ ps_y = a4.ps_costs_by_year()
+
+ corrected_costs = pd.DataFrame({
+ "CCaaS platform licences (ramp-adjusted)": licence_y,
+ "Base professional services + training": ps_y,
+ "Existing platform (term-contract run-off)": current_y,
+ "AI token consumption": token_by_year,
+ "AI implementation + KB readiness": {y: impl_y[y] + kb_y[y]
+ for y in a4.YEARS},
+ "AI steady-state tuning": steady_y,
+ }).T[a4.YEARS]
+ corrected_by_year = {y: float(corrected_costs[y].sum()) for y in a4.YEARS}
+ pitched_by_year = {y: a4.TCO_VERBATIM["ccaas_annual"] + ps_y[y] for y in a4.YEARS}
+
+ inc_c, net_c = a4.case_flows(corrected_by_year, benefit_by_year)
+ inc_p, net_p = a4.case_flows(pitched_by_year, benefit_by_year)
+ kpi_c = a4.case_kpis(inc_c, net_c, discount_rate)
+ kpi_p = a4.case_kpis(inc_p, net_p, discount_rate)
+
+ # ── KPIs ─────────────────────────────────────────────────────────
+ m1, m2, m3, m4, m5 = st.columns(5)
+ m1.metric("3-yr net (corrected)", a4.money(kpi_c["net_3yr"]),
+ delta=a4.money(kpi_c["net_3yr"] - kpi_p["net_3yr"]) + " vs pitch",
+ delta_color="inverse")
+ m2.metric("ROI", f"{kpi_c['roi']:.0%}" if kpi_c["roi"] is not None
+ else "n/a — net saving")
+ m3.metric(f"NPV @ {discount_rate:.1%}", a4.money(kpi_c["npv"]))
+ m4.metric("Payback", kpi_c["payback"])
+ m5.metric("3-yr programme cost", a4.money(sum(corrected_by_year.values())),
+ delta=a4.money(sum(corrected_by_year.values())
+ - sum(pitched_by_year.values())) + " vs pitch",
+ delta_color="inverse")
+
+ smell = sum(impl_y.values()) / a4.SLIDE_TOTALS["total_3yr"]
+ if smell < a4.SMELL_TEST_FLOOR:
+ st.caption(f"⚠️ Smell test: AI implementation = {smell:.1%} of the benefit "
+ f"claim, below the 15% floor (industry band 20-40%). V2 "
+ f"deliberately strips vendor inflation — sweep hours × rate "
+ f"above to test robustness.")
+
+ # ── Figures ──────────────────────────────────────────────────────
+ tab_bc, tab_ben, tab_cost, tab_cmp = st.tabs(
+ ["Business case", "Benefits (verbatim)", "Costs (corrected)",
+ "Pitched vs corrected"])
+
+ with tab_bc:
+ fig = go.Figure()
+ fig.add_trace(_bar(_X, [benefit_by_year[y] for y in a4.YEARS],
+ "Benefits (verbatim Genesys)", "#2a78d6"))
+ fig.add_trace(_bar(_X, [-inc_c[y] for y in a4.YEARS],
+ "Incremental cost vs $7.3M/yr baseline", "#e34948"))
+ cum_net = pd.Series([net_c[y] for y in a4.YEARS]).cumsum()
+ fig.add_trace(_cum_line(_X, cum_net, "Cumulative net"))
+ for i, c in enumerate(cum_net):
+ fig.add_annotation(x=i, y=float(c), text=f"{a4.money(float(c))}",
+ showarrow=False, yshift=14 if c >= 0 else -14,
+ font=dict(size=12, color=_INK))
+ fig.update_layout(barmode="relative")
+ _tei_layout(fig, "Corrected business case — benefits vs incremental cost",
+ "Baseline = keep paying $7.3M/yr · double-billing hits 2026-27, "
+ "cost avoidance and benefits land 2028", height=500)
+ st.plotly_chart(fig, width="stretch", key="a4_fig_case")
+
+ with tab_ben:
+ fig = go.Figure()
+ for cap in a4.CAPABILITIES:
+ vals = [benefits_long.query("capability == @cap and year == @y")
+ ["benefit"].sum() for y in a4.YEARS]
+ fig.add_trace(_bar(_X, vals, cap, _CAP_COLOR[cap]))
+ cum = pd.Series([benefit_by_year[y] for y in a4.YEARS]).cumsum()
+ fig.add_trace(_cum_line(_X, cum, "Cumulative benefits"))
+ for i, y in enumerate(a4.YEARS):
+ fig.add_annotation(x=i, y=benefit_by_year[y],
+ text=f"{a4.money(benefit_by_year[y])}",
+ showarrow=False, yshift=12,
+ font=dict(size=12, color=_INK))
+ fig.update_layout(barmode="stack")
+ _tei_layout(fig, "Benefits over 3 years — verbatim Genesys (Appendix 4)",
+ "Phased by Genesys's own deployment schedule — $0 in 2026 · "
+ "WFM = Workforce Forecast & Scheduling")
+ st.plotly_chart(fig, width="stretch", key="a4_fig_benefits")
+
+ with tab_cost:
+ fig = go.Figure()
+ for line in corrected_costs.index:
+ fig.add_trace(_bar(_X, [corrected_costs.loc[line, y] for y in a4.YEARS],
+ line, _COST_COLOR[line]))
+ cum_c = pd.Series([corrected_by_year[y] for y in a4.YEARS]).cumsum()
+ cum_p = pd.Series([pitched_by_year[y] for y in a4.YEARS]).cumsum()
+ fig.add_trace(_cum_line(_X, cum_c, "Cumulative — corrected"))
+ fig.add_trace(_cum_line(_X, cum_p, "Cumulative — as pitched",
+ color=_CONTEXT, dash="dash"))
+ for i, y in enumerate(a4.YEARS):
+ fig.add_annotation(x=i, y=corrected_by_year[y],
+ text=f"{a4.money(corrected_by_year[y])}",
+ showarrow=False, yshift=12,
+ font=dict(size=12, color=_INK))
+ delta = float(cum_c.iloc[-1] - cum_p.iloc[-1])
+ fig.add_annotation(x=len(a4.YEARS) - 1, y=float(cum_c.iloc[-1]),
+ text=f"3-yr {a4.html_money(float(cum_c.iloc[-1]))} — "
+ f"{a4.html_money(delta)} above the pitch",
+ showarrow=False, yshift=18, xshift=-70,
+ font=dict(size=12, color=_INK2))
+ fig.update_layout(barmode="stack")
+ _tei_layout(fig, "Programme cost over 3 years — with the missed costs",
+ "Existing platforms bill until term-contract end "
+ "(double-billing) · licences ramp-free · tokens + AI "
+ "implementation added", height=500)
+ st.plotly_chart(fig, width="stretch", key="a4_fig_costs")
+
+ with tab_cmp:
+ fig = go.Figure()
+ fig.add_trace(_bar(_X, [pitched_by_year[y] for y in a4.YEARS],
+ "As pitched (deck)", _CONTEXT))
+ fig.add_trace(_bar(_X, [corrected_by_year[y] for y in a4.YEARS],
+ "Corrected", "#2a78d6"))
+ for i, y in enumerate(a4.YEARS):
+ d = corrected_by_year[y] - pitched_by_year[y]
+ fig.add_annotation(x=i, y=corrected_by_year[y], xshift=16,
+ text=f"{'+' if d >= 0 else '−'}{a4.money(abs(d))}",
+ showarrow=False, yshift=12,
+ font=dict(size=12, color=_INK))
+ fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.15)
+ _tei_layout(fig, "Cost case: as pitched vs corrected, by year",
+ "Delta labels = what each year's pitch understates", height=400)
+ st.plotly_chart(fig, width="stretch", key="a4_fig_compare")
+
+ # ── Detail tables ────────────────────────────────────────────────
+ with st.expander("Cost stack detail"):
+ show = corrected_costs.copy()
+ show.columns = [str(c) for c in show.columns]
+ show["3-yr"] = show.sum(axis=1)
+ show.loc["TOTAL — corrected"] = show.sum()
+ st.dataframe(show, width="stretch",
+ column_config={c: st.column_config.NumberColumn(
+ str(c), format="$%,.0f") for c in show.columns})
+ with st.expander("Token consumption detail"):
+ tok = tokens_long.pivot_table(index="cost_line", columns="year",
+ values="annual_cost", aggfunc="sum")
+ tok.columns = [str(c) for c in tok.columns]
+ tok.loc["WFM (no token meter — licence-included)"] = 0.0
+ tok["3-yr"] = tok.sum(axis=1)
+ tok = tok.sort_values("3-yr", ascending=False)
+ tok.loc["TOTAL"] = tok.sum()
+ st.dataframe(tok, width="stretch",
+ column_config={c: st.column_config.NumberColumn(
+ str(c), format="$%,.0f") for c in tok.columns})
+ with st.expander("AI implementation detail (V2 LoE)"):
+ impl_show = impl_detail.copy()
+ impl_show.columns = [str(c) for c in impl_show.columns]
+ st.dataframe(impl_show, width="stretch", hide_index=True,
+ column_config={str(c): st.column_config.NumberColumn(
+ str(c), format="$%,.0f")
+ for c in ["cost", *a4.YEARS]})
+
+ st.caption(
+ "Not modelled: early-termination fees, migration costs beyond PS/impl. "
+ "Non-NAM site volumes are tokencalc placeholders (🟡). Full method, "
+ "assertions and sensitivity grids: "
+ "`notebooks/ctm_business_case_corrected.ipynb`."
+ )
+
# ── Page 1: Inputs ───────────────────────────────────────────────────
-if page == "1. Inputs":
+elif page == "1. Inputs":
st.header("Inputs")
st.caption("Site data outside NAM is **estimated — confirm with CTM data**.")
_users_warning()
@@ -223,7 +536,9 @@ if page == "1. Inputs":
with col2:
up = st.file_uploader("Load scenario JSON", type="json")
if up is not None and st.button("Load"):
- s, t, sc = scenario_state_from_json(up.read().decode())
+ # 4th element is the rollout plan (None for legacy files) —
+ # not used by these pages yet.
+ s, t, sc, _rollout = scenario_state_from_json(up.read().decode())
st.session_state.sites, st.session_state.takeouts = s, t
st.session_state.scopes = sc
st.cache_data.clear()
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb b/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb
index 7fd3fae..1b70081 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb
+++ b/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb
@@ -2,7 +2,7 @@
"cells": [
{
"cell_type": "markdown",
- "id": "aeb959ef",
+ "id": "5f5ffd6a",
"metadata": {},
"source": [
"# CTM × Genesys CCaaS — Corrected Business Case\n",
@@ -31,13 +31,13 @@
{
"cell_type": "code",
"execution_count": 1,
- "id": "83ba2b80",
+ "id": "26632e8d",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.281815Z",
- "iopub.status.busy": "2026-07-07T12:22:42.281637Z",
- "iopub.status.idle": "2026-07-07T12:22:42.521532Z",
- "shell.execute_reply": "2026-07-07T12:22:42.520768Z"
+ "iopub.execute_input": "2026-07-07T15:40:53.934907Z",
+ "iopub.status.busy": "2026-07-07T15:40:53.934727Z",
+ "iopub.status.idle": "2026-07-07T15:40:54.324524Z",
+ "shell.execute_reply": "2026-07-07T15:40:54.323917Z"
}
},
"outputs": [
@@ -65,12 +65,25 @@
"import plotly.graph_objects as go\n",
"\n",
"from tokencalc import *\n",
+ "# Single source of truth for the corrected case — shared with the\n",
+ "# Streamlit \"Corrected Business Case\" view. Only presentation lives here.\n",
+ "from tokencalc.appendix4 import (\n",
+ " YEARS, YEAR_INDEX, REGIONS, CAPABILITIES,\n",
+ " VERBATIM_BENEFITS, SLIDE_TOTALS, TCO_VERBATIM,\n",
+ " IMPL_MONTH, REALIZE_MONTH, BENEFIT_LAG_MONTHS, NA_EMAIL_IMPL_MONTH,\n",
+ " AI_IMPL_HOURS, KB_READINESS_HOURS, STEADY_STATE_HOURS,\n",
+ " DEFAULT_RAMP_MONTHS, SMELL_TEST_FLOOR,\n",
+ " autorespond_meter, benefits_by_year, build_impl_costs, build_rollouts,\n",
+ " build_scopes, case_flows, case_kpis, claim_scenario,\n",
+ " crossfoot_tolerance, current_costs_by_year, current_months_in_year,\n",
+ " current_state_inputs, hours_pick, impl_feature_regions,\n",
+ " licence_costs_by_year, money, html_money, month_label,\n",
+ " ps_costs_by_year, region_agents, region_site_names, token_costs_by_year,\n",
+ " verbatim_dataframe,\n",
+ ")\n",
"\n",
"pd.options.display.float_format = \"{:,.0f}\".format\n",
"\n",
- "YEARS = [2026, 2027, 2028] # model years 1..3, contract start Jan 2026\n",
- "YEAR_INDEX = {2026: 1, 2027: 2, 2028: 3}\n",
- "\n",
"# ── Chart chrome (dataviz reference palette, light surface) ─────────\n",
"INK, INK2, MUTED = \"#0b0b0b\", \"#52514e\", \"#898781\"\n",
"SURFACE, GRID, BASELINE = \"#fcfcfb\", \"#e1e0d9\", \"#c3c2b7\"\n",
@@ -131,17 +144,6 @@
" hovertemplate=\"%{fullData.name}: %{y:$,.0f}\")\n",
"\n",
"\n",
- "def money(v):\n",
- " sign, a = (\"-\" if v < 0 else \"\"), abs(v)\n",
- " return f\"{sign}${a/1e6:,.1f}M\" if a >= 1e6 else f\"{sign}${a/1e3:,.0f}K\"\n",
- "\n",
- "\n",
- "def html_money(v):\n",
- " # Plotly text with two or more bare \"$\" triggers MathJax math mode —\n",
- " # annotations holding several amounts must use the HTML entity instead.\n",
- " return money(v).replace(\"$\", \"$\")\n",
- "\n",
- "\n",
"print(f\"tokencalc loaded — {len(CTM_DEFAULT_SITES)} sites, \"\n",
" f\"{sum(s.named_users for s in CTM_DEFAULT_SITES):,} named users \"\n",
" f\"(contracted: {CONTRACTED_NAMED_USERS:,})\")"
@@ -149,7 +151,7 @@
},
{
"cell_type": "markdown",
- "id": "9139f3c0",
+ "id": "fda63800",
"metadata": {},
"source": [
"## §1 · Current state & contract inputs — data collection\n",
@@ -171,13 +173,13 @@
{
"cell_type": "code",
"execution_count": 2,
- "id": "7d0c26bd",
+ "id": "7e4e12ce",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.523771Z",
- "iopub.status.busy": "2026-07-07T12:22:42.523541Z",
- "iopub.status.idle": "2026-07-07T12:22:42.546359Z",
- "shell.execute_reply": "2026-07-07T12:22:42.545634Z"
+ "iopub.execute_input": "2026-07-07T15:40:54.327578Z",
+ "iopub.status.busy": "2026-07-07T15:40:54.327172Z",
+ "iopub.status.idle": "2026-07-07T15:40:54.363991Z",
+ "shell.execute_reply": "2026-07-07T15:40:54.363033Z"
}
},
"outputs": [
@@ -233,7 +235,7 @@
"
0 | \n",
" 3,348,969 | \n",
" 2027-12-31 | \n",
- " 🟡 agent-share allocation of the verbatim $7.3M | \n",
+ " 🟡 agent-share allocation of the verbatim total | \n",
" \n",
" \n",
" | ANZ | \n",
@@ -241,7 +243,7 @@
" 0 | \n",
" 677,320 | \n",
" 2027-12-31 | \n",
- " 🟡 agent-share allocation of the verbatim $7.3M | \n",
+ " 🟡 agent-share allocation of the verbatim total | \n",
"
\n",
" \n",
" | EMEA | \n",
@@ -249,7 +251,7 @@
" 0 | \n",
" 1,204,124 | \n",
" 2027-12-31 | \n",
- " 🟡 agent-share allocation of the verbatim $7.3M | \n",
+ " 🟡 agent-share allocation of the verbatim total | \n",
"
\n",
" \n",
" | ASIA | \n",
@@ -257,7 +259,7 @@
" 0 | \n",
" 2,069,588 | \n",
" 2027-12-31 | \n",
- " 🟡 agent-share allocation of the verbatim $7.3M | \n",
+ " 🟡 agent-share allocation of the verbatim total | \n",
"
\n",
" \n",
"\n",
@@ -273,10 +275,10 @@
"\n",
" confidence \n",
"region \n",
- "NA 🟡 agent-share allocation of the verbatim $7.3M \n",
- "ANZ 🟡 agent-share allocation of the verbatim $7.3M \n",
- "EMEA 🟡 agent-share allocation of the verbatim $7.3M \n",
- "ASIA 🟡 agent-share allocation of the verbatim $7.3M "
+ "NA 🟡 agent-share allocation of the verbatim total \n",
+ "ANZ 🟡 agent-share allocation of the verbatim total \n",
+ "EMEA 🟡 agent-share allocation of the verbatim total \n",
+ "ASIA 🟡 agent-share allocation of the verbatim total "
]
},
"metadata": {},
@@ -365,71 +367,23 @@
}
],
"source": [
- "# ── Verbatim TCO anchors (Appendix 4, slides 5-6) ────────────────────\n",
- "TCO_VERBATIM = {\n",
- " \"current_annual\": 7_300_000, # current global spend / yr\n",
- " \"current_3yr\": 22_000_000,\n",
- " \"ccaas_annual\": 4_300_000, # licence run-rate / yr\n",
- " \"ccaas_3yr\": 15_400_000, # deck's 3-yr CCaaS investment (no ramp, no AI costs)\n",
- " \"prof_services_y1\": 2_400_000,\n",
- " \"training_y1\": 167_000,\n",
- " \"npv_discount_rate\": 0.135, # deck's benefit-NPV rate\n",
- "}\n",
- "RAMP_MONTHS = 12 # Genesys ramp programme: licence-free months from contract start\n",
+ "# ── Contract inputs (model: tokencalc.appendix4) ─────────────────────\n",
+ "RAMP_MONTHS = DEFAULT_RAMP_MONTHS # Genesys ramp: licence-free months from contract start\n",
"DISCOUNT_RATE = TCO_VERBATIM[\"npv_discount_rate\"] # 0.08 = CTM treasury alternative\n",
"\n",
- "# ── Region ⇄ site mapping (tokencalc sites → deck regions) ───────────\n",
- "SITE_REGION = {\"NAM\": \"NA\", \"AUZ\": \"ANZ\", \"EMEA\": \"EMEA\"} # APAC * → ASIA\n",
- "REGIONS = [\"NA\", \"ANZ\", \"EMEA\", \"ASIA\"]\n",
"sites = list(CTM_DEFAULT_SITES)\n",
"ALL_SITES = [s.site_name for s in sites]\n",
"ASIA_SITES = [n for n in ALL_SITES if n.startswith(\"APAC\")]\n",
- "REGION_SITES = {r: [n for n in ALL_SITES if SITE_REGION.get(n, \"ASIA\") == r] for r in REGIONS}\n",
- "agents_by_region = {\n",
- " r: sum(s.agents for s in sites if s.site_name in REGION_SITES[r]) for r in REGIONS\n",
- "}\n",
- "TOTAL_AGENTS = sum(agents_by_region.values())\n",
+ "REGION_SITES = region_site_names(sites)\n",
+ "agents_by_region = region_agents(sites)\n",
"\n",
"# ── Per-region current-state inputs (EDIT HERE as real data arrives) ─\n",
- "current_state = pd.DataFrame([\n",
- " {\n",
- " \"region\": r,\n",
- " \"agents\": agents_by_region[r],\n",
- " \"share\": agents_by_region[r] / TOTAL_AGENTS,\n",
- " \"annual_cost\": TCO_VERBATIM[\"current_annual\"] * agents_by_region[r] / TOTAL_AGENTS,\n",
- " \"contract_termination\": dt.date(2027, 12, 31),\n",
- " \"confidence\": \"🟡 agent-share allocation of the verbatim $7.3M\",\n",
- " }\n",
- " for r in REGIONS\n",
- "]).set_index(\"region\")\n",
+ "# Seeded as $7.3M × agent share; annual_cost and contract_termination\n",
+ "# are the editable columns.\n",
+ "current_state = current_state_inputs(sites)\n",
"\n",
- "\n",
- "def current_months_in_year(termination: dt.date, cal_year: int) -> int:\n",
- " # Term contract bills through its termination month, then stops.\n",
- " if cal_year < termination.year:\n",
- " return 12\n",
- " if cal_year > termination.year:\n",
- " return 0\n",
- " return termination.month\n",
- "\n",
- "\n",
- "current_by_year = {\n",
- " y: float(sum(row[\"annual_cost\"] * current_months_in_year(row[\"contract_termination\"], y) / 12\n",
- " for _, row in current_state.iterrows()))\n",
- " for y in YEARS\n",
- "}\n",
- "\n",
- "\n",
- "def licence_months_in_year(year_index: int, ramp_months: int) -> int:\n",
- " # Ramp programme: billing starts in calendar month ramp_months + 1.\n",
- " start, end = 12 * (year_index - 1) + 1, 12 * year_index\n",
- " return max(0, end - max(start, ramp_months + 1) + 1)\n",
- "\n",
- "\n",
- "licence_by_year = {\n",
- " y: TCO_VERBATIM[\"ccaas_annual\"] * licence_months_in_year(YEAR_INDEX[y], RAMP_MONTHS) / 12\n",
- " for y in YEARS\n",
- "}\n",
+ "current_by_year = current_costs_by_year(current_state)\n",
+ "licence_by_year = licence_costs_by_year(RAMP_MONTHS)\n",
"\n",
"assert abs(current_state[\"annual_cost\"].sum() - TCO_VERBATIM[\"current_annual\"]) < 1\n",
"print(f\"current 3-yr = {money(3 * TCO_VERBATIM['current_annual'])} \"\n",
@@ -454,7 +408,7 @@
},
{
"cell_type": "markdown",
- "id": "8af2a53b",
+ "id": "0c74e449",
"metadata": {},
"source": [
"## §2 · Verbatim Genesys benefits & deployment schedule\n",
@@ -476,13 +430,13 @@
{
"cell_type": "code",
"execution_count": 3,
- "id": "da68f997",
+ "id": "c7e86e80",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.547907Z",
- "iopub.status.busy": "2026-07-07T12:22:42.547785Z",
- "iopub.status.idle": "2026-07-07T12:22:42.577658Z",
- "shell.execute_reply": "2026-07-07T12:22:42.576992Z"
+ "iopub.execute_input": "2026-07-07T15:40:54.368463Z",
+ "iopub.status.busy": "2026-07-07T15:40:54.368220Z",
+ "iopub.status.idle": "2026-07-07T15:40:54.416525Z",
+ "shell.execute_reply": "2026-07-07T15:40:54.415639Z"
}
},
"outputs": [
@@ -607,52 +561,11 @@
}
],
"source": [
- "# ── VERBATIM Appendix 4 slides 12-15: (annual, 3-yr) per region × capability ──\n",
- "CAPABILITIES = [\"Agent Copilot\", \"WFM\", \"Email\", \"STA\", \"Predictive Routing\", \"Supervisor Copilot\"]\n",
- "VERBATIM_BENEFITS = { # (annual_value, three_yr_value) — verbatim, do not edit\n",
- " (\"NA\", \"Agent Copilot\"): (2_400_000, 3_400_000),\n",
- " (\"NA\", \"Email\"): (1_900_000, 2_500_000),\n",
- " (\"NA\", \"STA\"): (294_000, 506_000),\n",
- " (\"NA\", \"Supervisor Copilot\"): (218_000, 291_000),\n",
- " (\"NA\", \"Predictive Routing\"): (97_000, 167_000),\n",
- " (\"NA\", \"WFM\"): (0, 0), # NA excluded — has similar feature\n",
- " (\"ANZ\", \"Agent Copilot\"): (3_600_000, 3_900_000),\n",
- " (\"ANZ\", \"WFM\"): (1_300_000, 1_400_000),\n",
- " (\"ANZ\", \"Predictive Routing\"): (279_000, 302_000),\n",
- " (\"ANZ\", \"Email\"): (132_000, 143_000),\n",
- " (\"ANZ\", \"STA\"): (97_000, 105_000),\n",
- " (\"ANZ\", \"Supervisor Copilot\"): (25_000, 27_000),\n",
- " (\"ASIA\", \"WFM\"): (1_600_000, 914_000),\n",
- " (\"ASIA\", \"Email\"): (160_000, 93_000),\n",
- " (\"ASIA\", \"STA\"): (124_000, 72_000),\n",
- " (\"ASIA\", \"Predictive Routing\"): (87_000, 51_000),\n",
- " (\"ASIA\", \"Agent Copilot\"): (0, 0),\n",
- " (\"ASIA\", \"Supervisor Copilot\"): (0, 0),\n",
- " (\"EMEA\", \"WFM\"): (824_000, 687_000),\n",
- " (\"EMEA\", \"Email\"): (282_000, 235_000),\n",
- " (\"EMEA\", \"STA\"): (157_000, 131_000),\n",
- " (\"EMEA\", \"Agent Copilot\"): (77_000, 64_000),\n",
- " (\"EMEA\", \"Supervisor Copilot\"): (59_000, 49_000),\n",
- " (\"EMEA\", \"Predictive Routing\"): (7_000, 6_000),\n",
- "}\n",
- "SLIDE_TOTALS = { # deck's own (rounded) summary rows — slides 8-9\n",
- " \"regional_3yr\": {\"NA\": 6_900_000, \"ANZ\": 5_900_000, \"ASIA\": 1_100_000, \"EMEA\": 1_200_000},\n",
- " \"capability_3yr\": {\"Agent Copilot\": 7_400_000, \"WFM\": 3_000_000, \"Email\": 2_900_000,\n",
- " \"STA\": 814_000, \"Predictive Routing\": 526_000,\n",
- " \"Supervisor Copilot\": 367_000},\n",
- " \"total_3yr\": 15_000_000,\n",
- " \"total_annual\": 13_600_000,\n",
- "}\n",
- "\n",
- "verbatim = pd.DataFrame(\n",
- " [{\"region\": r, \"capability\": c, \"annual\": a, \"three_yr\": t}\n",
- " for (r, c), (a, t) in VERBATIM_BENEFITS.items()]\n",
- ")\n",
- "\n",
- "\n",
- "def _tol(v): # deck rounds to $0.1M — its own tables cross-foot ±$50-120K\n",
- " return max(100_000, 0.015 * v)\n",
+ "# ── VERBATIM Appendix 4 slides 12-15 (data: tokencalc.appendix4) ─────\n",
+ "verbatim = verbatim_dataframe()\n",
"\n",
+ "# Deck rounds to $0.1M — its own tables cross-foot ±$50-120K.\n",
+ "_tol = crossfoot_tolerance\n",
"\n",
"for r, expect in SLIDE_TOTALS[\"regional_3yr\"].items():\n",
" got = verbatim.loc[verbatim.region == r, \"three_yr\"].sum()\n",
@@ -673,13 +586,13 @@
{
"cell_type": "code",
"execution_count": 4,
- "id": "67842fd4",
+ "id": "fe3572d2",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.579539Z",
- "iopub.status.busy": "2026-07-07T12:22:42.579402Z",
- "iopub.status.idle": "2026-07-07T12:22:42.590942Z",
- "shell.execute_reply": "2026-07-07T12:22:42.590120Z"
+ "iopub.execute_input": "2026-07-07T15:40:54.418553Z",
+ "iopub.status.busy": "2026-07-07T15:40:54.418349Z",
+ "iopub.status.idle": "2026-07-07T15:40:54.432146Z",
+ "shell.execute_reply": "2026-07-07T15:40:54.431545Z"
}
},
"outputs": [
@@ -810,39 +723,13 @@
],
"source": [
"# ── Genesys/Broadreach deployment schedule (slides 17-21) ────────────\n",
- "IMPL_MONTH = {\"NA\": 18, \"ANZ\": 21, \"EMEA\": 24, \"ASIA\": 27} # months from Jan 2026, inclusive\n",
- "BENEFIT_LAG_MONTHS = 3\n",
- "REALIZE_MONTH = {r: m + BENEFIT_LAG_MONTHS for r, m in IMPL_MONTH.items()}\n",
- "NA_EMAIL_EARLY = True # NA Gantt exception: Email realizes Apr 2027\n",
- "NA_EMAIL_IMPL_MONTH = 13 # ⇒ implemented Jan 2027, realizes month 16\n",
+ "# IMPL_MONTH = {NA: 18, ANZ: 21, EMEA: 24, ASIA: 27}; benefits realize\n",
+ "# +3 months; NA Email implements early (Jan 2027, realizes Apr 2027).\n",
+ "# go_live_month = label − 1 so the labelled month is included (§2 note).\n",
+ "NA_EMAIL_EARLY = True\n",
"\n",
- "_MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\",\n",
- " \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\n",
- "\n",
- "\n",
- "def month_label(m): # m is 1-indexed from Jan 2026\n",
- " return f\"{_MONTHS[(m - 1) % 12]} {2026 + (m - 1) // 12}\"\n",
- "\n",
- "\n",
- "# go_live_month = label − 1 so the labelled month is included (see §2 note).\n",
- "TOKEN_ROLLOUT = RolloutPlan(\n",
- " contract_start=\"2026-01\", build_months=27, ramp_months=RAMP_MONTHS,\n",
- " first_year_platform_discount=0.0, # licences handled verbatim in §1, not by this plan\n",
- " go_live_month={\"NAM\": IMPL_MONTH[\"NA\"] - 1, \"AUZ\": IMPL_MONTH[\"ANZ\"] - 1,\n",
- " \"EMEA\": IMPL_MONTH[\"EMEA\"] - 1,\n",
- " **{n: IMPL_MONTH[\"ASIA\"] - 1 for n in ASIA_SITES}},\n",
- ")\n",
- "EMAIL_TOKEN_ROLLOUT = dataclasses.replace( # Auto-Respond only: NA implements early\n",
- " TOKEN_ROLLOUT,\n",
- " go_live_month={**TOKEN_ROLLOUT.go_live_month,\n",
- " \"NAM\": (NA_EMAIL_IMPL_MONTH - 1) if NA_EMAIL_EARLY else IMPL_MONTH[\"NA\"] - 1},\n",
- ")\n",
- "BENEFIT_ROLLOUT = RolloutPlan( # region-keyed; NA_EMAIL covers the (NA, Email) cell only\n",
- " first_year_platform_discount=0.0,\n",
- " go_live_month={**{r: REALIZE_MONTH[r] - 1 for r in REGIONS},\n",
- " \"NA_EMAIL\": (NA_EMAIL_IMPL_MONTH + BENEFIT_LAG_MONTHS - 1)\n",
- " if NA_EMAIL_EARLY else REALIZE_MONTH[\"NA\"] - 1},\n",
- ")\n",
+ "TOKEN_ROLLOUT, EMAIL_TOKEN_ROLLOUT, BENEFIT_ROLLOUT = build_rollouts(\n",
+ " sites, NA_EMAIL_EARLY, RAMP_MONTHS)\n",
"\n",
"schedule = pd.DataFrame([\n",
" {\n",
@@ -865,13 +752,13 @@
{
"cell_type": "code",
"execution_count": 5,
- "id": "618205b4",
+ "id": "a06e90b9",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.593342Z",
- "iopub.status.busy": "2026-07-07T12:22:42.593199Z",
- "iopub.status.idle": "2026-07-07T12:22:42.650436Z",
- "shell.execute_reply": "2026-07-07T12:22:42.649806Z"
+ "iopub.execute_input": "2026-07-07T15:40:54.433929Z",
+ "iopub.status.busy": "2026-07-07T15:40:54.433731Z",
+ "iopub.status.idle": "2026-07-07T15:40:54.508761Z",
+ "shell.execute_reply": "2026-07-07T15:40:54.508003Z"
}
},
"outputs": [
@@ -989,15 +876,7 @@
],
"source": [
"# ── Phase each verbatim 3-yr value by its region's realization window ─\n",
- "rows = []\n",
- "for (region, cap), (annual, three_yr) in VERBATIM_BENEFITS.items():\n",
- " key = \"NA_EMAIL\" if (region == \"NA\" and cap == \"Email\" and NA_EMAIL_EARLY) else region\n",
- " live = [BENEFIT_ROLLOUT.live_months_in_year(key, YEAR_INDEX[y]) for y in YEARS]\n",
- " total_live = sum(live)\n",
- " for y, m in zip(YEARS, live):\n",
- " rows.append({\"region\": region, \"capability\": cap, \"year\": y,\n",
- " \"benefit\": three_yr * m / total_live if total_live else 0.0})\n",
- "benefits_long = pd.DataFrame(rows)\n",
+ "benefits_long = benefits_by_year(BENEFIT_ROLLOUT, NA_EMAIL_EARLY)\n",
"benefit_total_by_year = benefits_long.groupby(\"year\")[\"benefit\"].sum().to_dict()\n",
"\n",
"# Scaling is at the finest grain, so every verbatim total is reproduced exactly.\n",
@@ -1015,7 +894,7 @@
},
{
"cell_type": "markdown",
- "id": "f47fdc0f",
+ "id": "3b9174ab",
"metadata": {},
"source": [
"## §3 · Feature enablement → token consumption (missing cost #1)\n",
@@ -1040,13 +919,13 @@
{
"cell_type": "code",
"execution_count": 6,
- "id": "99e510d0",
+ "id": "977418e1",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.652747Z",
- "iopub.status.busy": "2026-07-07T12:22:42.652584Z",
- "iopub.status.idle": "2026-07-07T12:22:42.656004Z",
- "shell.execute_reply": "2026-07-07T12:22:42.655407Z"
+ "iopub.execute_input": "2026-07-07T15:40:54.510564Z",
+ "iopub.status.busy": "2026-07-07T15:40:54.510380Z",
+ "iopub.status.idle": "2026-07-07T15:40:54.514601Z",
+ "shell.execute_reply": "2026-07-07T15:40:54.512950Z"
}
},
"outputs": [],
@@ -1063,13 +942,13 @@
{
"cell_type": "code",
"execution_count": 7,
- "id": "3ea04102",
+ "id": "9767177f",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.657915Z",
- "iopub.status.busy": "2026-07-07T12:22:42.657753Z",
- "iopub.status.idle": "2026-07-07T12:22:42.671127Z",
- "shell.execute_reply": "2026-07-07T12:22:42.670305Z"
+ "iopub.execute_input": "2026-07-07T15:40:54.516706Z",
+ "iopub.status.busy": "2026-07-07T15:40:54.516514Z",
+ "iopub.status.idle": "2026-07-07T15:40:54.529884Z",
+ "shell.execute_reply": "2026-07-07T15:40:54.529284Z"
}
},
"outputs": [
@@ -1192,38 +1071,14 @@
}
],
"source": [
- "# ── Claim-level scenario: deck parameters, no consumption ramp ───────\n",
- "GENESYS_CLAIM = Scenario(\n",
- " name=\"genesys-claim\",\n",
- " voice_bot_deflection=0.0, voice_bot_avg_minutes=0.0, agentic_va_deflection=0.0,\n",
- " voice_summarization_eligibility=0.0, voice_knowledge_eligibility=0.0, # unused by this scope set\n",
- " email_auto_respond_rate=EMAIL_AUTO_RESPOND_RATE,\n",
- " email_auto_suggest_acceptance=0.0, # Auto-Suggest is inside Copilot's per-user rate (V2 #1)\n",
- " consumption_cost_realization={1: 1.0, 2: 1.0, 3: 1.0}, # Genesys claims no maturity ramp\n",
- ")\n",
- "\n",
- "AUTORESPOND_METER = dataclasses.replace(\n",
- " DEFAULT_METERS[\"Email AI (Auto-Respond)\"],\n",
- " units_per_token=1.0 / EMAIL_AUTORESPOND_TOKENS_PER_MSG,\n",
- " tokens_per_unit=EMAIL_AUTORESPOND_TOKENS_PER_MSG,\n",
- " confidence=Confidence.ESTIMATED,\n",
- " notes=\"WORKING ASSUMPTION — rate unpublished; ≈1 AI action per generated response \"\n",
- " \"(Genesys Cloud Copilot meters 20 AI actions/token). Sensitivity in §9.\",\n",
- ")\n",
+ "# ── Claim-level scenario, meter override & scopes (tokencalc.appendix4) ─\n",
+ "# No adoption_curve on any scope — a curve would silently override the\n",
+ "# claim scenario's flat consumption realization.\n",
+ "GENESYS_CLAIM = claim_scenario(EMAIL_AUTO_RESPOND_RATE)\n",
+ "AUTORESPOND_METER = autorespond_meter(EMAIL_AUTORESPOND_TOKENS_PER_MSG)\n",
"METERS = {**DEFAULT_METERS, \"Email AI (Auto-Respond)\": AUTORESPOND_METER}\n",
- "\n",
- "COPILOT_SITES = [\"NAM\", \"AUZ\", \"EMEA\"] + (ASIA_SITES if COPILOT_INCLUDES_ASIA else [])\n",
- "# NOTE: no adoption_curve on any scope — a curve would silently override the\n",
- "# claim scenario's flat consumption realization in calculate_consumption_ai_cost.\n",
- "CORE_SCOPES = [\n",
- " FeatureScope(\"Agent Copilot [named]\", COPILOT_SITES, phase=1),\n",
- " FeatureScope(\"Speech & Text Analytics [named]\", ALL_SITES, phase=1),\n",
- " FeatureScope(\"Predictive Routing\", ALL_SITES, phase=1, eligibility_pct=PR_ELIGIBILITY),\n",
- " FeatureScope(\"AI Summary & Insights\", COPILOT_SITES, phase=1), # $0 by Rule 1 — kept visible\n",
- " FeatureScope(\"AI Translate\", ASIA_SITES + [\"EMEA\"], phase=1,\n",
- " eligibility_pct=AI_TRANSLATE_ELIGIBILITY),\n",
- "]\n",
- "EMAIL_SCOPES = [FeatureScope(\"Email AI (Auto-Respond)\", ALL_SITES, phase=1)]\n",
+ "CORE_SCOPES, EMAIL_SCOPES = build_scopes(\n",
+ " sites, COPILOT_INCLUDES_ASIA, PR_ELIGIBILITY, AI_TRANSLATE_ELIGIBILITY)\n",
"\n",
"USED_FEATURES = [sc.feature for sc in CORE_SCOPES + EMAIL_SCOPES]\n",
"display(meters_dataframe({f: METERS[f] for f in USED_FEATURES})\n",
@@ -1233,13 +1088,13 @@
{
"cell_type": "code",
"execution_count": 8,
- "id": "7727fbb0",
+ "id": "3f3102f4",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.672905Z",
- "iopub.status.busy": "2026-07-07T12:22:42.672732Z",
- "iopub.status.idle": "2026-07-07T12:22:42.717246Z",
- "shell.execute_reply": "2026-07-07T12:22:42.716200Z"
+ "iopub.execute_input": "2026-07-07T15:40:54.531888Z",
+ "iopub.status.busy": "2026-07-07T15:40:54.531696Z",
+ "iopub.status.idle": "2026-07-07T15:40:54.584270Z",
+ "shell.execute_reply": "2026-07-07T15:40:54.583627Z"
}
},
"outputs": [
@@ -1385,18 +1240,9 @@
],
"source": [
"# ── Token cost by year — engine call, rollout-gated ──────────────────\n",
- "frames = []\n",
- "for y in YEARS:\n",
- " core = calculate_total_cost(sites, CORE_SCOPES, METERS, DEFAULT_PRICING,\n",
- " GENESYS_CLAIM, YEAR_INDEX[y], include_platform=False,\n",
- " use_contracted=USE_CONTRACTED_RATES, rollout=TOKEN_ROLLOUT)\n",
- " email = calculate_total_cost(sites, EMAIL_SCOPES, METERS, DEFAULT_PRICING,\n",
- " GENESYS_CLAIM, YEAR_INDEX[y], include_platform=False,\n",
- " use_contracted=USE_CONTRACTED_RATES, rollout=EMAIL_TOKEN_ROLLOUT)\n",
- " part = pd.concat([core, email], ignore_index=True)\n",
- " part[\"year\"] = y\n",
- " frames.append(part)\n",
- "tokens_long = pd.concat(frames, ignore_index=True)\n",
+ "tokens_long = token_costs_by_year(\n",
+ " sites, METERS, DEFAULT_PRICING, GENESYS_CLAIM, CORE_SCOPES, EMAIL_SCOPES,\n",
+ " TOKEN_ROLLOUT, EMAIL_TOKEN_ROLLOUT, use_contracted=USE_CONTRACTED_RATES)\n",
"\n",
"tokens_pivot = tokens_long.pivot_table(index=\"cost_line\", columns=\"year\",\n",
" values=\"annual_cost\", aggfunc=\"sum\")\n",
@@ -1417,13 +1263,13 @@
{
"cell_type": "code",
"execution_count": 9,
- "id": "ee088f69",
+ "id": "e62ecfa7",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:42.720863Z",
- "iopub.status.busy": "2026-07-07T12:22:42.720525Z",
- "iopub.status.idle": "2026-07-07T12:22:43.918533Z",
- "shell.execute_reply": "2026-07-07T12:22:43.917721Z"
+ "iopub.execute_input": "2026-07-07T15:40:54.586011Z",
+ "iopub.status.busy": "2026-07-07T15:40:54.585789Z",
+ "iopub.status.idle": "2026-07-07T15:40:55.863721Z",
+ "shell.execute_reply": "2026-07-07T15:40:55.860941Z"
}
},
"outputs": [
@@ -2447,7 +2293,7 @@
},
{
"cell_type": "markdown",
- "id": "7d197edc",
+ "id": "bf0108e5",
"metadata": {},
"source": [
"## §4 · AI implementation effort (missing cost #2)\n",
@@ -2468,52 +2314,144 @@
{
"cell_type": "code",
"execution_count": 10,
- "id": "4a7bd418",
+ "id": "3b395fbd",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:43.923450Z",
- "iopub.status.busy": "2026-07-07T12:22:43.923326Z",
- "iopub.status.idle": "2026-07-07T12:22:43.927251Z",
- "shell.execute_reply": "2026-07-07T12:22:43.926625Z"
+ "iopub.execute_input": "2026-07-07T15:40:55.866389Z",
+ "iopub.status.busy": "2026-07-07T15:40:55.866192Z",
+ "iopub.status.idle": "2026-07-07T15:40:55.875774Z",
+ "shell.execute_reply": "2026-07-07T15:40:55.874992Z"
}
},
- "outputs": [],
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " | \n",
+ " workstream | \n",
+ " low h | \n",
+ " high h | \n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " | 0 | \n",
+ " Agent Copilot | \n",
+ " 1200 | \n",
+ " 1800 | \n",
+ "
\n",
+ " \n",
+ " | 1 | \n",
+ " Email Auto-Respond | \n",
+ " 800 | \n",
+ " 1400 | \n",
+ "
\n",
+ " \n",
+ " | 2 | \n",
+ " STA | \n",
+ " 800 | \n",
+ " 1200 | \n",
+ "
\n",
+ " \n",
+ " | 3 | \n",
+ " Supervisor Copilot | \n",
+ " 200 | \n",
+ " 400 | \n",
+ "
\n",
+ " \n",
+ " | 4 | \n",
+ " Predictive Routing | \n",
+ " 400 | \n",
+ " 700 | \n",
+ "
\n",
+ " \n",
+ " | 5 | \n",
+ " Cross-cutting | \n",
+ " 1000 | \n",
+ " 1800 | \n",
+ "
\n",
+ " \n",
+ " | 6 | \n",
+ " KB readiness (prerequisite) | \n",
+ " 500 | \n",
+ " 1500 | \n",
+ "
\n",
+ " \n",
+ " | 7 | \n",
+ " Steady-state (h/yr, 2027-28) | \n",
+ " 500 | \n",
+ " 900 | \n",
+ "
\n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " workstream low h high h\n",
+ "0 Agent Copilot 1200 1800\n",
+ "1 Email Auto-Respond 800 1400\n",
+ "2 STA 800 1200\n",
+ "3 Supervisor Copilot 200 400\n",
+ "4 Predictive Routing 400 700\n",
+ "5 Cross-cutting 1000 1800\n",
+ "6 KB readiness (prerequisite) 500 1500\n",
+ "7 Steady-state (h/yr, 2027-28) 500 900"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Regions served per workstream: {'Agent Copilot': ['NA', 'ANZ', 'EMEA'], 'Email Auto-Respond': ['NA', 'ANZ', 'EMEA', 'ASIA'], 'STA': ['NA', 'ANZ', 'EMEA', 'ASIA'], 'Supervisor Copilot': ['NA', 'ANZ', 'EMEA'], 'Predictive Routing': ['NA', 'ANZ', 'EMEA', 'ASIA'], 'Cross-cutting': ['NA', 'ANZ', 'EMEA', 'ASIA']}\n"
+ ]
+ }
+ ],
"source": [
"# ── CONFIG — AI implementation (V2 LoE) ──────────────────────────────\n",
- "AI_IMPL_HOURS = { # (low, high) Y1 hours — ctm_ai_labour_estimate_V2.md\n",
- " \"Agent Copilot\": (1_200, 1_800), # voice + digital incl. email Auto-Suggest, all languages\n",
- " \"Email Auto-Respond\": (800, 1_400), # separate flow; needs system-of-record integration\n",
- " \"STA\": (800, 1_200), # topics, programs, tuning × 7 languages\n",
- " \"Supervisor Copilot\": (200, 400),\n",
- " \"Predictive Routing\": (400, 700),\n",
- " \"Cross-cutting\": (1_000, 1_800), # governance, PM, test environment, integration coordination\n",
- "}\n",
- "KB_READINESS_HOURS = (500, 1_500) # prerequisite project — flagged separately\n",
- "STEADY_STATE_HOURS = (500, 900) # absolute h/yr, 2027-2028\n",
+ "# Hours ranges live in tokencalc.appendix4 (AI_IMPL_HOURS / KB_READINESS_HOURS\n",
+ "# / STEADY_STATE_HOURS), sourced from docs/ctm_ai_labour_estimate_V2.md.\n",
"HOURS_MODE = \"mid\" # \"low\" | \"mid\" | \"high\"\n",
"BLENDED_RATE = 225 # $/h — 175 offshore-heavy | 225 typical | 275 onshore\n",
"INCLUDE_KB_READINESS = True\n",
"\n",
- "IMPL_FEATURE_REGIONS = { # which regions each impl workstream serves\n",
- " \"Agent Copilot\": [\"NA\", \"ANZ\", \"EMEA\"] + ([\"ASIA\"] if COPILOT_INCLUDES_ASIA else []),\n",
- " \"Email Auto-Respond\": REGIONS,\n",
- " \"STA\": REGIONS,\n",
- " \"Supervisor Copilot\": [\"NA\", \"ANZ\", \"EMEA\"], # deck claims $0 SupCopilot benefit in ASIA\n",
- " \"Predictive Routing\": REGIONS,\n",
- " \"Cross-cutting\": REGIONS,\n",
- "}"
+ "display(pd.DataFrame(\n",
+ " [{\"workstream\": f, \"low h\": lo, \"high h\": hi}\n",
+ " for f, (lo, hi) in {**AI_IMPL_HOURS,\n",
+ " \"KB readiness (prerequisite)\": KB_READINESS_HOURS,\n",
+ " \"Steady-state (h/yr, 2027-28)\": STEADY_STATE_HOURS}.items()]))\n",
+ "print(\"Regions served per workstream:\", impl_feature_regions(COPILOT_INCLUDES_ASIA))"
]
},
{
"cell_type": "code",
"execution_count": 11,
- "id": "afdcda65",
+ "id": "23756537",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:43.929433Z",
- "iopub.status.busy": "2026-07-07T12:22:43.929332Z",
- "iopub.status.idle": "2026-07-07T12:22:43.944980Z",
- "shell.execute_reply": "2026-07-07T12:22:43.944243Z"
+ "iopub.execute_input": "2026-07-07T15:40:55.877796Z",
+ "iopub.status.busy": "2026-07-07T15:40:55.877548Z",
+ "iopub.status.idle": "2026-07-07T15:40:55.901736Z",
+ "shell.execute_reply": "2026-07-07T15:40:55.900707Z"
}
},
"outputs": [
@@ -2656,53 +2594,11 @@
}
],
"source": [
- "# ── V2 hours-range × rate model (swap point for the future LoE engine) ─\n",
- "def hours_pick(rng, mode):\n",
- " low, high = rng\n",
- " return {\"low\": low, \"mid\": (low + high) / 2, \"high\": high}[mode]\n",
- "\n",
- "\n",
- "def impl_year_fractions(impl_month):\n",
- " # Spend spreads uniformly from contract start (month 0) to the impl month.\n",
- " prev, fracs = 0, []\n",
- " for yi in (1, 2, 3):\n",
- " cur = min(12 * yi, impl_month)\n",
- " fracs.append((cur - prev) / impl_month)\n",
- " prev = cur\n",
- " return fracs\n",
- "\n",
- "\n",
- "def region_impl_month(feature, region):\n",
- " if feature == \"Email Auto-Respond\" and region == \"NA\" and NA_EMAIL_EARLY:\n",
- " return NA_EMAIL_IMPL_MONTH\n",
- " return IMPL_MONTH[region]\n",
- "\n",
- "\n",
- "def build_impl_costs(mode, rate, include_kb):\n",
- " rows = []\n",
- " workstreams = dict(AI_IMPL_HOURS)\n",
- " if include_kb:\n",
- " workstreams[\"KB readiness (prerequisite)\"] = KB_READINESS_HOURS\n",
- " for feature, rng in workstreams.items():\n",
- " regions = IMPL_FEATURE_REGIONS.get(feature, REGIONS)\n",
- " scope_agents = sum(agents_by_region[r] for r in regions)\n",
- " for r in regions:\n",
- " hours = hours_pick(rng, mode) * agents_by_region[r] / scope_agents\n",
- " fracs = impl_year_fractions(region_impl_month(feature, r))\n",
- " rows.append({\"workstream\": feature, \"region\": r, \"hours\": hours,\n",
- " \"cost\": hours * rate,\n",
- " **{y: hours * rate * f for y, f in zip(YEARS, fracs)}})\n",
- " df = pd.DataFrame(rows)\n",
- " is_kb = df[\"workstream\"].str.startswith(\"KB\")\n",
- " impl_y = {y: float(df.loc[~is_kb, y].sum()) for y in YEARS}\n",
- " kb_y = {y: float(df.loc[is_kb, y].sum()) for y in YEARS}\n",
- " steady = hours_pick(STEADY_STATE_HOURS, mode) * rate\n",
- " steady_y = {2026: 0.0, 2027: steady, 2028: steady}\n",
- " return df, impl_y, kb_y, steady_y\n",
- "\n",
- "\n",
+ "# ── V2 hours-range × rate model (tokencalc.appendix4.build_impl_costs) ─\n",
+ "# Swap point for the future activity-level LoE engine.\n",
"impl_detail, impl_by_year, kb_by_year, steady_by_year = build_impl_costs(\n",
- " HOURS_MODE, BLENDED_RATE, INCLUDE_KB_READINESS)\n",
+ " sites, HOURS_MODE, BLENDED_RATE, INCLUDE_KB_READINESS,\n",
+ " COPILOT_INCLUDES_ASIA, NA_EMAIL_EARLY)\n",
"\n",
"impl_summary = impl_detail.groupby(\"workstream\")[[\"hours\", \"cost\", *YEARS]].sum()\n",
"impl_summary.loc[\"Steady-state tuning (2027-28)\"] = [\n",
@@ -2719,13 +2615,13 @@
{
"cell_type": "code",
"execution_count": 12,
- "id": "b0614dec",
+ "id": "1ffaa74a",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:43.946728Z",
- "iopub.status.busy": "2026-07-07T12:22:43.946619Z",
- "iopub.status.idle": "2026-07-07T12:22:43.950400Z",
- "shell.execute_reply": "2026-07-07T12:22:43.949590Z"
+ "iopub.execute_input": "2026-07-07T15:40:55.904200Z",
+ "iopub.status.busy": "2026-07-07T15:40:55.903948Z",
+ "iopub.status.idle": "2026-07-07T15:40:55.909345Z",
+ "shell.execute_reply": "2026-07-07T15:40:55.908669Z"
}
},
"outputs": [
@@ -2745,7 +2641,8 @@
"# ── Smell test (V1 doc rule): AI impl should be ≥15% of the AI benefit claim ──\n",
"_impl_total = sum(impl_by_year.values())\n",
"_ratio = _impl_total / SLIDE_TOTALS[\"total_3yr\"]\n",
- "_verdict = \"PASS\" if _ratio >= 0.15 else \"FLAG — under-modelled by the industry benchmark\"\n",
+ "_verdict = (\"PASS\" if _ratio >= SMELL_TEST_FLOOR\n",
+ " else \"FLAG — under-modelled by the industry benchmark\")\n",
"print(f\"AI implementation {money(_impl_total)} ÷ benefit claim \"\n",
" f\"{money(SLIDE_TOTALS['total_3yr'])} = {_ratio:.1%} → {_verdict}\")\n",
"print(\"Industry benchmark: 20-40% of the Y1 benefit claim goes to implementation.\")\n",
@@ -2756,7 +2653,7 @@
},
{
"cell_type": "markdown",
- "id": "e98e70db",
+ "id": "b958f93d",
"metadata": {},
"source": [
"## §5 · Corrected cost stack\n",
@@ -2769,13 +2666,13 @@
{
"cell_type": "code",
"execution_count": 13,
- "id": "30948da4",
+ "id": "ad880bda",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:43.952985Z",
- "iopub.status.busy": "2026-07-07T12:22:43.952792Z",
- "iopub.status.idle": "2026-07-07T12:22:43.970944Z",
- "shell.execute_reply": "2026-07-07T12:22:43.970160Z"
+ "iopub.execute_input": "2026-07-07T15:40:55.911950Z",
+ "iopub.status.busy": "2026-07-07T15:40:55.911733Z",
+ "iopub.status.idle": "2026-07-07T15:40:55.936700Z",
+ "shell.execute_reply": "2026-07-07T15:40:55.935983Z"
}
},
"outputs": [
@@ -2961,8 +2858,7 @@
}
],
"source": [
- "ps_by_year = {2026: TCO_VERBATIM[\"prof_services_y1\"] + TCO_VERBATIM[\"training_y1\"],\n",
- " 2027: 0.0, 2028: 0.0}\n",
+ "ps_by_year = ps_costs_by_year() # verbatim $2.4M PS + $167K training, year 1\n",
"impl_kb_by_year = {y: impl_by_year[y] + kb_by_year[y] for y in YEARS}\n",
"\n",
"corrected_costs = pd.DataFrame({\n",
@@ -2997,7 +2893,7 @@
},
{
"cell_type": "markdown",
- "id": "1d37d385",
+ "id": "be744ec4",
"metadata": {},
"source": [
"## §6 · Figure 1 — Benefits over 3 years (verbatim Genesys)"
@@ -3006,13 +2902,13 @@
{
"cell_type": "code",
"execution_count": 14,
- "id": "897493af",
+ "id": "2e2b207c",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:43.973216Z",
- "iopub.status.busy": "2026-07-07T12:22:43.973035Z",
- "iopub.status.idle": "2026-07-07T12:22:44.069313Z",
- "shell.execute_reply": "2026-07-07T12:22:44.068537Z"
+ "iopub.execute_input": "2026-07-07T15:40:55.938459Z",
+ "iopub.status.busy": "2026-07-07T15:40:55.938278Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.023062Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.022206Z"
}
},
"outputs": [
@@ -4090,7 +3986,7 @@
},
{
"cell_type": "markdown",
- "id": "d62fd178",
+ "id": "95e3aa5a",
"metadata": {},
"source": [
"## §7 · Figure 2 — Costs over 3 years, corrected\n",
@@ -4103,13 +3999,13 @@
{
"cell_type": "code",
"execution_count": 15,
- "id": "f12111e3",
+ "id": "c2c9418f",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:44.072051Z",
- "iopub.status.busy": "2026-07-07T12:22:44.071804Z",
- "iopub.status.idle": "2026-07-07T12:22:44.104691Z",
- "shell.execute_reply": "2026-07-07T12:22:44.103839Z"
+ "iopub.execute_input": "2026-07-07T15:40:56.024916Z",
+ "iopub.status.busy": "2026-07-07T15:40:56.024780Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.065404Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.064597Z"
}
},
"outputs": [
@@ -5217,13 +5113,13 @@
{
"cell_type": "code",
"execution_count": 16,
- "id": "31448686",
+ "id": "82335f50",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:44.106991Z",
- "iopub.status.busy": "2026-07-07T12:22:44.106665Z",
- "iopub.status.idle": "2026-07-07T12:22:44.136989Z",
- "shell.execute_reply": "2026-07-07T12:22:44.136239Z"
+ "iopub.execute_input": "2026-07-07T15:40:56.068585Z",
+ "iopub.status.busy": "2026-07-07T15:40:56.068410Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.206163Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.205208Z"
}
},
"outputs": [
@@ -6172,7 +6068,7 @@
},
{
"cell_type": "markdown",
- "id": "b178185b",
+ "id": "bde5fbf2",
"metadata": {},
"source": [
"## §8 · Figure 3 — Overall business case / ROI\n",
@@ -6189,13 +6085,13 @@
{
"cell_type": "code",
"execution_count": 17,
- "id": "4fdcfe24",
+ "id": "08433116",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:44.139720Z",
- "iopub.status.busy": "2026-07-07T12:22:44.139505Z",
- "iopub.status.idle": "2026-07-07T12:22:44.145487Z",
- "shell.execute_reply": "2026-07-07T12:22:44.144835Z"
+ "iopub.execute_input": "2026-07-07T15:40:56.209075Z",
+ "iopub.status.busy": "2026-07-07T15:40:56.208829Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.215238Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.214438Z"
}
},
"outputs": [
@@ -6209,42 +6105,18 @@
}
],
"source": [
+ "# Baseline-relative frame (tokencalc.appendix4.case_flows / case_kpis):\n",
+ "# baseline = do nothing = keep paying $7.3M/yr.\n",
"BASELINE_ANNUAL = TCO_VERBATIM[\"current_annual\"]\n",
"\n",
- "\n",
- "def case_flows(total_cost_by_year):\n",
- " inc = {y: total_cost_by_year[y] - BASELINE_ANNUAL for y in YEARS}\n",
- " net = {y: benefit_total_by_year[y] - inc[y] for y in YEARS}\n",
- " return inc, net\n",
+ "inc_corrected, net_corrected = case_flows(corrected_total_by_year, benefit_total_by_year)\n",
+ "inc_pitched, net_pitched = case_flows(pitched_total_by_year, benefit_total_by_year)\n",
+ "kpi_corrected = case_kpis(inc_corrected, net_corrected, DISCOUNT_RATE)\n",
+ "kpi_pitched = case_kpis(inc_pitched, net_pitched, DISCOUNT_RATE)\n",
"\n",
"\n",
- "def case_kpis(inc, net):\n",
- " net_list = [net[y] for y in YEARS]\n",
- " inc_total = sum(inc.values())\n",
- " pb = payback_years(net_list)\n",
- " if pb is None:\n",
- " pb_label = \"beyond 2028\"\n",
- " elif pb == 0:\n",
- " pb_label = \"immediate\"\n",
- " else:\n",
- " m = math.ceil(pb * 12)\n",
- " pb_label = f\"{m} months (~{_MONTHS[(m - 1) % 12]} {2026 + (m - 1) // 12})\"\n",
- " return {\n",
- " \"3-yr benefits\": sum(benefit_total_by_year.values()),\n",
- " \"3-yr incremental cost\": inc_total,\n",
- " \"3-yr net\": sum(net_list),\n",
- " \"ROI (net ÷ incremental cost)\":\n",
- " f\"{sum(net_list) / inc_total:.0%}\" if inc_total > 0 else \"n/a — net cost saving\",\n",
- " f\"NPV @ {DISCOUNT_RATE:.1%} (deck rate)\": npv(net_list, DISCOUNT_RATE),\n",
- " \"NPV @ 8.0% (CTM treasury)\": npv(net_list, 0.08),\n",
- " \"Payback\": pb_label,\n",
- " }\n",
- "\n",
- "\n",
- "inc_corrected, net_corrected = case_flows(corrected_total_by_year)\n",
- "inc_pitched, net_pitched = case_flows(pitched_total_by_year)\n",
- "kpi_corrected = case_kpis(inc_corrected, net_corrected)\n",
- "kpi_pitched = case_kpis(inc_pitched, net_pitched)\n",
+ "def fmt_roi(k):\n",
+ " return f\"{k['roi']:.0%}\" if k[\"roi\"] is not None else \"n/a — net cost saving\"\n",
"print(f\"corrected: net by year { {y: money(v) for y, v in net_corrected.items()} }\")\n",
"print(f\"pitched: net by year { {y: money(v) for y, v in net_pitched.items()} }\")"
]
@@ -6252,13 +6124,13 @@
{
"cell_type": "code",
"execution_count": 18,
- "id": "c43d1e56",
+ "id": "04c43658",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:44.147385Z",
- "iopub.status.busy": "2026-07-07T12:22:44.147219Z",
- "iopub.status.idle": "2026-07-07T12:22:44.177837Z",
- "shell.execute_reply": "2026-07-07T12:22:44.176873Z"
+ "iopub.execute_input": "2026-07-07T15:40:56.217292Z",
+ "iopub.status.busy": "2026-07-07T15:40:56.217094Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.245223Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.244293Z"
}
},
"outputs": [
@@ -7243,10 +7115,10 @@
"fig.add_annotation(\n",
" xref=\"paper\", yref=\"paper\", x=0.01, y=0.98, align=\"left\", showarrow=False,\n",
" font=dict(size=12, color=INK2), bgcolor=SURFACE, bordercolor=GRID, borderwidth=1,\n",
- " text=(f\"3-yr net {html_money(kpi_corrected['3-yr net'])} · \"\n",
- " f\"ROI {kpi_corrected['ROI (net ÷ incremental cost)']}
\"\n",
- " f\"NPV@{DISCOUNT_RATE:.1%} {html_money(kpi_corrected[f'NPV @ {DISCOUNT_RATE:.1%} (deck rate)'])} · \"\n",
- " f\"payback {kpi_corrected['Payback']}\"))\n",
+ " text=(f\"3-yr net {html_money(kpi_corrected['net_3yr'])} · \"\n",
+ " f\"ROI {fmt_roi(kpi_corrected)}
\"\n",
+ " f\"NPV@{DISCOUNT_RATE:.1%} {html_money(kpi_corrected['npv'])} · \"\n",
+ " f\"payback {kpi_corrected['payback']}\"))\n",
"tei_layout(fig, \"Corrected business case — benefits vs incremental cost\",\n",
" \"Baseline = keep paying $7.3M/yr · double-billing hits 2026-27, \"\n",
" \"cost avoidance and benefits land 2028\", height=500)\n",
@@ -7256,13 +7128,13 @@
{
"cell_type": "code",
"execution_count": 19,
- "id": "f504f2dc",
+ "id": "a8c9584e",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:44.180248Z",
- "iopub.status.busy": "2026-07-07T12:22:44.180036Z",
- "iopub.status.idle": "2026-07-07T12:22:44.206953Z",
- "shell.execute_reply": "2026-07-07T12:22:44.204790Z"
+ "iopub.execute_input": "2026-07-07T15:40:56.247631Z",
+ "iopub.status.busy": "2026-07-07T15:40:56.247492Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.256445Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.255875Z"
}
},
"outputs": [
@@ -7354,17 +7226,29 @@
}
],
"source": [
- "kpis = pd.DataFrame({\"As pitched (deck)\": kpi_pitched, \"Corrected\": kpi_corrected})\n",
- "kpis_fmt = kpis.map(lambda v: money(v) if isinstance(v, (int, float)) else v)\n",
- "delta_net = kpi_pitched[\"3-yr net\"] - kpi_corrected[\"3-yr net\"]\n",
+ "def kpi_display(k, net):\n",
+ " return {\n",
+ " \"3-yr benefits\": money(k[\"benefits_3yr\"]),\n",
+ " \"3-yr incremental cost\": money(k[\"incremental_cost_3yr\"]),\n",
+ " \"3-yr net\": money(k[\"net_3yr\"]),\n",
+ " \"ROI (net ÷ incremental cost)\": fmt_roi(k),\n",
+ " f\"NPV @ {k['discount_rate']:.1%} (deck rate)\": money(k[\"npv\"]),\n",
+ " \"NPV @ 8.0% (CTM treasury)\": money(npv([net[y] for y in YEARS], 0.08)),\n",
+ " \"Payback\": k[\"payback\"],\n",
+ " }\n",
+ "\n",
+ "\n",
+ "kpis_fmt = pd.DataFrame({\"As pitched (deck)\": kpi_display(kpi_pitched, net_pitched),\n",
+ " \"Corrected\": kpi_display(kpi_corrected, net_corrected)})\n",
+ "delta_net = kpi_pitched[\"net_3yr\"] - kpi_corrected[\"net_3yr\"]\n",
"print(f\"The missed costs move the 3-yr case by {money(delta_net)} \"\n",
- " f\"({money(kpi_pitched['3-yr net'])} pitched → {money(kpi_corrected['3-yr net'])} corrected).\")\n",
+ " f\"({money(kpi_pitched['net_3yr'])} pitched → {money(kpi_corrected['net_3yr'])} corrected).\")\n",
"display(kpis_fmt)"
]
},
{
"cell_type": "markdown",
- "id": "ab730edc",
+ "id": "e7d54d0d",
"metadata": {},
"source": [
"## §9 · Sensitivity\n",
@@ -7377,13 +7261,13 @@
{
"cell_type": "code",
"execution_count": 20,
- "id": "80c2a060",
+ "id": "6671014d",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:44.210569Z",
- "iopub.status.busy": "2026-07-07T12:22:44.210085Z",
- "iopub.status.idle": "2026-07-07T12:22:44.477936Z",
- "shell.execute_reply": "2026-07-07T12:22:44.476537Z"
+ "iopub.execute_input": "2026-07-07T15:40:56.258411Z",
+ "iopub.status.busy": "2026-07-07T15:40:56.258284Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.336874Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.336009Z"
}
},
"outputs": [
@@ -8418,10 +8302,8 @@
"\n",
"\n",
"def email_token_cost_3yr(tokens_per_msg, respond_rate):\n",
- " meter = dataclasses.replace(AUTORESPOND_METER, tokens_per_unit=tokens_per_msg,\n",
- " units_per_token=1.0 / tokens_per_msg)\n",
- " sc = dataclasses.replace(GENESYS_CLAIM, email_auto_respond_rate=respond_rate)\n",
- " m = {**METERS, \"Email AI (Auto-Respond)\": meter}\n",
+ " m = {**METERS, \"Email AI (Auto-Respond)\": autorespond_meter(tokens_per_msg)}\n",
+ " sc = claim_scenario(respond_rate)\n",
" return sum(\n",
" calculate_total_cost(sites, EMAIL_SCOPES, m, DEFAULT_PRICING, sc, YEAR_INDEX[y],\n",
" include_platform=False, rollout=EMAIL_TOKEN_ROLLOUT)\n",
@@ -8453,13 +8335,13 @@
{
"cell_type": "code",
"execution_count": 21,
- "id": "abd816ea",
+ "id": "faa1e95f",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:44.480803Z",
- "iopub.status.busy": "2026-07-07T12:22:44.480601Z",
- "iopub.status.idle": "2026-07-07T12:22:44.510856Z",
- "shell.execute_reply": "2026-07-07T12:22:44.509842Z"
+ "iopub.execute_input": "2026-07-07T15:40:56.339516Z",
+ "iopub.status.busy": "2026-07-07T15:40:56.339332Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.368102Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.367009Z"
}
},
"outputs": [
@@ -8540,10 +8422,11 @@
"source": [
"# ── 3-yr corrected net across impl hours mode × blended rate ─────────\n",
"def corrected_net_3yr(mode, rate):\n",
- " _, iby, kby, ssby = build_impl_costs(mode, rate, INCLUDE_KB_READINESS)\n",
+ " _, iby, kby, ssby = build_impl_costs(sites, mode, rate, INCLUDE_KB_READINESS,\n",
+ " COPILOT_INCLUDES_ASIA, NA_EMAIL_EARLY)\n",
" total = {y: licence_by_year[y] + ps_by_year[y] + current_by_year[y]\n",
" + token_total_by_year[y] + iby[y] + kby[y] + ssby[y] for y in YEARS}\n",
- " _, net = case_flows(total)\n",
+ " _, net = case_flows(total, benefit_total_by_year)\n",
" return sum(net.values())\n",
"\n",
"\n",
@@ -8561,7 +8444,7 @@
},
{
"cell_type": "markdown",
- "id": "cb6ad27e",
+ "id": "46cd8894",
"metadata": {},
"source": [
"## §10 · Verification & assertions\n",
@@ -8574,13 +8457,13 @@
{
"cell_type": "code",
"execution_count": 22,
- "id": "7e241b27",
+ "id": "6516ea96",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T12:22:44.513962Z",
- "iopub.status.busy": "2026-07-07T12:22:44.513773Z",
- "iopub.status.idle": "2026-07-07T12:22:44.539348Z",
- "shell.execute_reply": "2026-07-07T12:22:44.537406Z"
+ "iopub.execute_input": "2026-07-07T15:40:56.370749Z",
+ "iopub.status.busy": "2026-07-07T15:40:56.370411Z",
+ "iopub.status.idle": "2026-07-07T15:40:56.403374Z",
+ "shell.execute_reply": "2026-07-07T15:40:56.402523Z"
}
},
"outputs": [
@@ -8652,12 +8535,12 @@
"print(\"All assertions passed.\")\n",
"print(f\" benefits 3-yr {money(sum(benefit_total_by_year.values()))} · \"\n",
" f\"tokens 3-yr {money(sum(token_total_by_year.values()))} · \"\n",
- " f\"corrected net 3-yr {money(kpi_corrected['3-yr net'])}\")"
+ " f\"corrected net 3-yr {money(kpi_corrected['net_3yr'])}\")"
]
},
{
"cell_type": "markdown",
- "id": "8c43881a",
+ "id": "8275984f",
"metadata": {},
"source": [
"## §11 · Risks, gaps & next steps\n",
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tests/test_appendix4.py b/studies/202512_GenesysCX/ctm-token-calculator/tests/test_appendix4.py
new file mode 100644
index 0000000..f1ca39f
--- /dev/null
+++ b/studies/202512_GenesysCX/ctm-token-calculator/tests/test_appendix4.py
@@ -0,0 +1,108 @@
+"""Appendix-4 corrected business case — hand-check acceptance numbers."""
+
+from __future__ import annotations
+
+import datetime as dt
+import math
+
+import pytest
+
+from tokencalc import appendix4 as a4
+from tokencalc.defaults import CTM_DEFAULT_SITES, DEFAULT_METERS, DEFAULT_PRICING
+
+SITES = list(CTM_DEFAULT_SITES)
+
+
+def test_verbatim_crossfoots_to_slide_totals():
+ df = a4.verbatim_dataframe()
+ for r, expect in a4.SLIDE_TOTALS["regional_3yr"].items():
+ got = df.loc[df.region == r, "three_yr"].sum()
+ assert abs(got - expect) <= a4.crossfoot_tolerance(expect), r
+ for c, expect in a4.SLIDE_TOTALS["capability_3yr"].items():
+ got = df.loc[df.capability == c, "three_yr"].sum()
+ assert abs(got - expect) <= a4.crossfoot_tolerance(expect), c
+ assert abs(df["three_yr"].sum() - a4.SLIDE_TOTALS["total_3yr"]) <= \
+ a4.crossfoot_tolerance(a4.SLIDE_TOTALS["total_3yr"])
+
+
+def test_benefits_phase_on_the_deck_schedule():
+ _, _, benefit_rollout = a4.build_rollouts(SITES)
+ long = a4.benefits_by_year(benefit_rollout)
+ by_year = long.groupby("year")["benefit"].sum()
+ assert by_year[2026] == 0.0, "2026 must be $0 under Genesys's own schedule"
+ # Scaling at the finest grain reproduces every verbatim 3-yr value exactly.
+ for (region, cap), (_, three_yr) in a4.VERBATIM_BENEFITS.items():
+ got = long.query("region == @region and capability == @cap")["benefit"].sum()
+ assert got == pytest.approx(three_yr)
+
+
+def test_ramp_zeroes_year_one_licences():
+ assert a4.licence_costs_by_year(12) == {2026: 0.0, 2027: 4_300_000.0,
+ 2028: 4_300_000.0}
+ assert a4.licence_costs_by_year(0)[2026] == 4_300_000.0
+ assert a4.licence_costs_by_year(18)[2027] == pytest.approx(4_300_000 * 6 / 12)
+
+
+def test_current_state_run_off():
+ cs = a4.current_state_inputs(SITES)
+ assert cs["annual_cost"].sum() == pytest.approx(7_300_000)
+ by_year = a4.current_costs_by_year(cs)
+ assert by_year == {2026: pytest.approx(7_300_000),
+ 2027: pytest.approx(7_300_000), 2028: 0.0}
+ cs.loc["NA", "contract_termination"] = dt.date(2028, 6, 30)
+ assert a4.current_costs_by_year(cs)[2028] == pytest.approx(
+ cs.loc["NA", "annual_cost"] * 6 / 12)
+
+
+def test_token_hand_checks():
+ token_ro, email_ro, _ = a4.build_rollouts(SITES)
+ core, email = a4.build_scopes(SITES, copilot_includes_asia=False)
+ meters = {**DEFAULT_METERS, "Email AI (Auto-Respond)": a4.autorespond_meter(0.05)}
+ long = a4.token_costs_by_year(SITES, meters, DEFAULT_PRICING,
+ a4.claim_scenario(0.255), core, email,
+ token_ro, email_ro)
+ # STA 2028: NAM/AUZ/EMEA × 12 months + ASIA × 10 months, by hand.
+ sta = long.query("cost_line == 'Speech & Text Analytics [named]'")
+ assert sta.query("year == 2028")["annual_cost"].sum() == pytest.approx(715_800)
+ # Agent Copilot 2028 (ASIA off): 1,490 users × 40 tokens × 12 months.
+ cp = long.query("cost_line == 'Agent Copilot [named]' and year == 2028")
+ assert cp["annual_cost"].sum() == pytest.approx(1_490 * 40 * 12)
+ # Rule 1: Copilot covers AI Summary at Copilot sites.
+ assert (long.query("cost_line == 'AI Summary & Insights'")["annual_cost"] == 0).all()
+ # Nothing is live in 2026.
+ assert long.query("year == 2026")["annual_cost"].sum() == 0
+ # PR NAM steady-month tokens.
+ assert math.ceil(
+ 1_214_358 * DEFAULT_METERS["Predictive Routing"].tokens_per_unit) == 71_433
+
+
+def test_impl_costs_reconcile_with_v2_doc():
+ _, impl_y, kb_y, steady_y = a4.build_impl_costs(SITES, "mid", 225.0,
+ include_kb=True)
+ assert sum(impl_y.values()) == pytest.approx(5_850 * 225) # V2's "$1.3M"
+ assert sum(kb_y.values()) == pytest.approx(1_000 * 225)
+ assert steady_y == {2026: 0.0, 2027: pytest.approx(700 * 225),
+ 2028: pytest.approx(700 * 225)}
+ # Impl spend is fully booked by each region's implementation month.
+ assert a4.impl_year_fractions(18) == pytest.approx([12 / 18, 6 / 18, 0.0])
+ assert a4.impl_year_fractions(27) == pytest.approx([12 / 27, 12 / 27, 3 / 27])
+
+
+def test_case_flows_and_kpis():
+ benefits = {2026: 0.0, 2027: 2_000_000.0, 2028: 12_000_000.0}
+ costs = {2026: 10_000_000.0, 2027: 13_000_000.0, 2028: 8_000_000.0}
+ inc, net = a4.case_flows(costs, benefits)
+ assert inc == {2026: pytest.approx(2_700_000),
+ 2027: pytest.approx(5_700_000),
+ 2028: pytest.approx(700_000)}
+ for y in a4.YEARS:
+ assert net[y] == pytest.approx(benefits[y] - inc[y])
+ kpis = a4.case_kpis(inc, net)
+ assert kpis["benefits_3yr"] == pytest.approx(sum(benefits.values()))
+ assert kpis["net_3yr"] == pytest.approx(sum(net.values()))
+ assert kpis["roi"] == pytest.approx(kpis["net_3yr"] / kpis["incremental_cost_3yr"])
+ assert kpis["discount_rate"] == 0.135
+ # Net cost saving → ROI undefined.
+ inc2 = {y: -1.0 for y in a4.YEARS}
+ net2 = {y: benefits[y] + 1.0 for y in a4.YEARS}
+ assert a4.case_kpis(inc2, net2)["roi"] is None
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py b/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py
new file mode 100644
index 0000000..9ed9853
--- /dev/null
+++ b/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py
@@ -0,0 +1,515 @@
+"""
+Appendix-4 corrected business case — the Genesys/Broadreach benefits
+kept verbatim, with the costs the deck omitted: AI Experience token
+consumption, AI implementation effort (V2 LoE), and double-billing of
+the existing platforms until their term contracts end.
+
+Single source of truth shared by the notebook
+(``notebooks/ctm_business_case_corrected.ipynb``) and the Streamlit
+"Corrected Business Case" view — presentation layers hold no math.
+
+Sources: ``docs/Appendix 4 - CCaaS Platform Benefit Calculations
+(Consolidated).pptx`` (verbatim figures, deployment schedule) and
+``docs/ctm_ai_labour_estimate_V2.md`` (implementation hours).
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import datetime as dt
+import math
+
+import pandas as pd
+
+from .business_case import npv, payback_years
+from .cost_model import calculate_total_cost
+from .defaults import DEFAULT_METERS
+from .inputs import FeatureScope, SiteInput
+from .meters import Confidence, TokenMeter, TokenPricing
+from .rollout import RolloutPlan
+from .scenarios import Scenario
+
+# ── Timeline ─────────────────────────────────────────────────────────
+
+YEARS = [2026, 2027, 2028] # model years 1..3, contract start Jan 2026
+YEAR_INDEX = {2026: 1, 2027: 2, 2028: 3}
+_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
+ "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
+
+
+def month_label(m: int) -> str:
+ """Calendar label for a 1-indexed month from Jan 2026 (m=21 → 'Sep 2027')."""
+ return f"{_MONTHS[(m - 1) % 12]} {2026 + (m - 1) // 12}"
+
+
+# ── Verbatim Appendix 4 figures ──────────────────────────────────────
+
+REGIONS = ["NA", "ANZ", "EMEA", "ASIA"]
+CAPABILITIES = ["Agent Copilot", "WFM", "Email", "STA",
+ "Predictive Routing", "Supervisor Copilot"]
+
+#: (annual_value, three_yr_value) — VERBATIM slides 12-15, do not edit.
+VERBATIM_BENEFITS: dict[tuple[str, str], tuple[float, float]] = {
+ ("NA", "Agent Copilot"): (2_400_000, 3_400_000),
+ ("NA", "Email"): (1_900_000, 2_500_000),
+ ("NA", "STA"): (294_000, 506_000),
+ ("NA", "Supervisor Copilot"): (218_000, 291_000),
+ ("NA", "Predictive Routing"): (97_000, 167_000),
+ ("NA", "WFM"): (0, 0), # NA excluded — has similar feature
+ ("ANZ", "Agent Copilot"): (3_600_000, 3_900_000),
+ ("ANZ", "WFM"): (1_300_000, 1_400_000),
+ ("ANZ", "Predictive Routing"): (279_000, 302_000),
+ ("ANZ", "Email"): (132_000, 143_000),
+ ("ANZ", "STA"): (97_000, 105_000),
+ ("ANZ", "Supervisor Copilot"): (25_000, 27_000),
+ ("ASIA", "WFM"): (1_600_000, 914_000),
+ ("ASIA", "Email"): (160_000, 93_000),
+ ("ASIA", "STA"): (124_000, 72_000),
+ ("ASIA", "Predictive Routing"): (87_000, 51_000),
+ ("ASIA", "Agent Copilot"): (0, 0),
+ ("ASIA", "Supervisor Copilot"): (0, 0),
+ ("EMEA", "WFM"): (824_000, 687_000),
+ ("EMEA", "Email"): (282_000, 235_000),
+ ("EMEA", "STA"): (157_000, 131_000),
+ ("EMEA", "Agent Copilot"): (77_000, 64_000),
+ ("EMEA", "Supervisor Copilot"): (59_000, 49_000),
+ ("EMEA", "Predictive Routing"): (7_000, 6_000),
+}
+
+#: The deck's own (rounded) summary rows — slides 8-9.
+SLIDE_TOTALS: dict = {
+ "regional_3yr": {"NA": 6_900_000, "ANZ": 5_900_000,
+ "ASIA": 1_100_000, "EMEA": 1_200_000},
+ "capability_3yr": {"Agent Copilot": 7_400_000, "WFM": 3_000_000,
+ "Email": 2_900_000, "STA": 814_000,
+ "Predictive Routing": 526_000,
+ "Supervisor Copilot": 367_000},
+ "total_3yr": 15_000_000,
+ "total_annual": 13_600_000,
+}
+
+#: Verbatim TCO anchors — slides 5-6.
+TCO_VERBATIM: dict[str, float] = {
+ "current_annual": 7_300_000, # current global spend / yr
+ "current_3yr": 22_000_000,
+ "ccaas_annual": 4_300_000, # licence run-rate / yr
+ "ccaas_3yr": 15_400_000, # deck's 3-yr CCaaS investment (no ramp, no AI costs)
+ "prof_services_y1": 2_400_000,
+ "training_y1": 167_000,
+ "npv_discount_rate": 0.135, # deck's benefit-NPV rate
+}
+
+#: Genesys/Broadreach deployment schedule (slides 17-21), months from
+#: Jan 2026 inclusive. Benefits realize IMPL + 3 months.
+IMPL_MONTH = {"NA": 18, "ANZ": 21, "EMEA": 24, "ASIA": 27}
+BENEFIT_LAG_MONTHS = 3
+REALIZE_MONTH = {r: m + BENEFIT_LAG_MONTHS for r, m in IMPL_MONTH.items()}
+#: NA Gantt exception: Email implemented Jan 2027, realizes Apr 2027.
+NA_EMAIL_IMPL_MONTH = 13
+
+DEFAULT_RAMP_MONTHS = 12 # Genesys ramp programme
+DEFAULT_TERMINATION = dt.date(2027, 12, 31) # current-platform term contracts
+
+# ── Region ⇄ site mapping ────────────────────────────────────────────
+
+
+def site_region(site_name: str) -> str:
+ """Map a tokencalc site to its Appendix-4 region (APAC * → ASIA)."""
+ return {"NAM": "NA", "AUZ": "ANZ", "EMEA": "EMEA"}.get(site_name, "ASIA")
+
+
+def region_site_names(sites: list[SiteInput]) -> dict[str, list[str]]:
+ return {r: [s.site_name for s in sites if site_region(s.site_name) == r]
+ for r in REGIONS}
+
+
+def region_agents(sites: list[SiteInput]) -> dict[str, int]:
+ return {r: sum(s.agents for s in sites if site_region(s.site_name) == r)
+ for r in REGIONS}
+
+
+# ── Verbatim benefit helpers ─────────────────────────────────────────
+
+
+def verbatim_dataframe() -> pd.DataFrame:
+ """Long DataFrame of the verbatim benefits: region, capability, annual, three_yr."""
+ return pd.DataFrame(
+ [{"region": r, "capability": c, "annual": a, "three_yr": t}
+ for (r, c), (a, t) in VERBATIM_BENEFITS.items()]
+ )
+
+
+def crossfoot_tolerance(value: float) -> float:
+ """The deck rounds to $0.1M and its own tables cross-foot ±$50-120K."""
+ return max(100_000, 0.015 * value)
+
+
+# ── Schedules & rollouts ─────────────────────────────────────────────
+
+
+def build_rollouts(
+ sites: list[SiteInput],
+ na_email_early: bool = True,
+ ramp_months: int = DEFAULT_RAMP_MONTHS,
+) -> tuple[RolloutPlan, RolloutPlan, RolloutPlan]:
+ """(token, email_token, benefit) rollout plans on the deck's schedule.
+
+ ``RolloutPlan.go_live_month = m`` means active from month m+1; the
+ deck's labels are inclusive (NA "realizes Sep 2027" ⇒ September
+ counts), so keys are set to label − 1. The benefit plan is keyed by
+ region (plus ``NA_EMAIL`` for the NA Gantt exception); the token
+ plans are keyed by site.
+ """
+ token = RolloutPlan(
+ contract_start="2026-01", build_months=max(IMPL_MONTH.values()),
+ ramp_months=ramp_months,
+ first_year_platform_discount=0.0, # licences are handled verbatim, not by this plan
+ go_live_month={s.site_name: IMPL_MONTH[site_region(s.site_name)] - 1
+ for s in sites},
+ )
+ email = dataclasses.replace(
+ token,
+ go_live_month={**token.go_live_month,
+ "NAM": (NA_EMAIL_IMPL_MONTH - 1) if na_email_early
+ else IMPL_MONTH["NA"] - 1},
+ )
+ benefit = RolloutPlan(
+ first_year_platform_discount=0.0,
+ go_live_month={**{r: REALIZE_MONTH[r] - 1 for r in REGIONS},
+ "NA_EMAIL": (NA_EMAIL_IMPL_MONTH + BENEFIT_LAG_MONTHS - 1)
+ if na_email_early else REALIZE_MONTH["NA"] - 1},
+ )
+ return token, email, benefit
+
+
+def benefits_by_year(
+ benefit_rollout: RolloutPlan, na_email_early: bool = True
+) -> pd.DataFrame:
+ """Phase each verbatim 3-yr value by its region's realization window.
+
+ Scaling is at the finest grain (region × capability), so every
+ verbatim per-region, per-capability, and grand total is reproduced
+ exactly. Long DataFrame: region, capability, year, benefit.
+ """
+ rows = []
+ for (region, cap), (_annual, three_yr) in VERBATIM_BENEFITS.items():
+ key = ("NA_EMAIL" if (region == "NA" and cap == "Email" and na_email_early)
+ else region)
+ live = [benefit_rollout.live_months_in_year(key, YEAR_INDEX[y]) for y in YEARS]
+ total_live = sum(live)
+ for y, m in zip(YEARS, live):
+ rows.append({"region": region, "capability": cap, "year": y,
+ "benefit": three_yr * m / total_live if total_live else 0.0})
+ return pd.DataFrame(rows)
+
+
+# ── Base cost lines (verbatim + contract mechanics) ──────────────────
+
+
+def current_months_in_year(termination: dt.date, cal_year: int) -> int:
+ """Months a term contract bills in ``cal_year`` (through its termination month)."""
+ if cal_year < termination.year:
+ return 12
+ if cal_year > termination.year:
+ return 0
+ return termination.month
+
+
+def current_state_inputs(
+ sites: list[SiteInput],
+ total_annual: float | None = None,
+ termination: dt.date = DEFAULT_TERMINATION,
+) -> pd.DataFrame:
+ """Per-region current-platform inputs, seeded by agent share of the
+ verbatim global spend. Region-indexed; annual_cost and
+ contract_termination are the editable columns."""
+ total = TCO_VERBATIM["current_annual"] if total_annual is None else total_annual
+ agents = region_agents(sites)
+ total_agents = sum(agents.values())
+ return pd.DataFrame([
+ {"region": r,
+ "agents": agents[r],
+ "share": agents[r] / total_agents,
+ "annual_cost": total * agents[r] / total_agents,
+ "contract_termination": termination,
+ "confidence": "🟡 agent-share allocation of the verbatim total"}
+ for r in REGIONS
+ ]).set_index("region")
+
+
+def current_costs_by_year(current_state: pd.DataFrame) -> dict[int, float]:
+ """Existing-platform run-off per calendar year (the double-billing line)."""
+ return {
+ y: float(sum(
+ row["annual_cost"]
+ * current_months_in_year(row["contract_termination"], y) / 12
+ for _, row in current_state.iterrows()))
+ for y in YEARS
+ }
+
+
+def licence_months_in_year(year_index: int, ramp_months: int) -> int:
+ """Ramp programme: licence billing starts in calendar month ramp_months + 1."""
+ start, end = 12 * (year_index - 1) + 1, 12 * year_index
+ return max(0, end - max(start, ramp_months + 1) + 1)
+
+
+def licence_costs_by_year(
+ ramp_months: int = DEFAULT_RAMP_MONTHS, annual: float | None = None
+) -> dict[int, float]:
+ rate = TCO_VERBATIM["ccaas_annual"] if annual is None else annual
+ return {y: rate * licence_months_in_year(YEAR_INDEX[y], ramp_months) / 12
+ for y in YEARS}
+
+
+def ps_costs_by_year() -> dict[int, float]:
+ """Base professional services + training — verbatim, year 1 only."""
+ return {2026: TCO_VERBATIM["prof_services_y1"] + TCO_VERBATIM["training_y1"],
+ 2027: 0.0, 2028: 0.0}
+
+
+# ── Token consumption (missing cost #1) ──────────────────────────────
+
+
+def claim_scenario(email_auto_respond_rate: float = 0.255) -> Scenario:
+ """Claim-level scenario: deck parameters, no consumption maturity ramp."""
+ return Scenario(
+ name="genesys-claim",
+ voice_bot_deflection=0.0, voice_bot_avg_minutes=0.0,
+ agentic_va_deflection=0.0,
+ voice_summarization_eligibility=0.0,
+ voice_knowledge_eligibility=0.0, # unused by the Appendix-4 scope set
+ email_auto_respond_rate=email_auto_respond_rate,
+ email_auto_suggest_acceptance=0.0, # Auto-Suggest is inside Copilot (V2 #1)
+ consumption_cost_realization={1: 1.0, 2: 1.0, 3: 1.0},
+ )
+
+
+def autorespond_meter(tokens_per_msg: float = 0.05) -> TokenMeter:
+ """Email Auto-Respond working meter — rate unpublished (🔴→🟡).
+
+ Anchor: ≈1 AI action per generated response; Genesys Cloud Copilot
+ meters 20 AI actions per token.
+ """
+ return dataclasses.replace(
+ DEFAULT_METERS["Email AI (Auto-Respond)"],
+ units_per_token=1.0 / tokens_per_msg,
+ tokens_per_unit=tokens_per_msg,
+ confidence=Confidence.ESTIMATED,
+ notes="WORKING ASSUMPTION — rate unpublished; ≈1 AI action per generated "
+ "response (Genesys Cloud Copilot meters 20 AI actions/token).",
+ )
+
+
+def build_scopes(
+ sites: list[SiteInput],
+ copilot_includes_asia: bool = False,
+ pr_eligibility: float = 1.0,
+ ai_translate_eligibility: float = 0.01,
+) -> tuple[list[FeatureScope], list[FeatureScope]]:
+ """(core, email) feature scopes mirroring the six deck capabilities.
+
+ No ``adoption_curve`` on any scope — a curve would silently override
+ the claim scenario's flat consumption realization. Email scopes are
+ separate because NA Email implements early (own rollout plan).
+ """
+ all_names = [s.site_name for s in sites]
+ asia = [n for n in all_names if site_region(n) == "ASIA"]
+ non_asia = [n for n in all_names if site_region(n) != "ASIA"]
+ copilot_sites = non_asia + (asia if copilot_includes_asia else [])
+ core = [
+ FeatureScope("Agent Copilot [named]", copilot_sites, phase=1),
+ FeatureScope("Speech & Text Analytics [named]", all_names, phase=1),
+ FeatureScope("Predictive Routing", all_names, phase=1,
+ eligibility_pct=pr_eligibility),
+ # $0 by Rule 1 (Copilot covers summarization) — kept visible.
+ FeatureScope("AI Summary & Insights", copilot_sites, phase=1),
+ # Supervisor Copilot small-volume proxy.
+ FeatureScope("AI Translate", asia + ["EMEA"], phase=1,
+ eligibility_pct=ai_translate_eligibility),
+ ]
+ email = [FeatureScope("Email AI (Auto-Respond)", all_names, phase=1)]
+ return core, email
+
+
+def token_costs_by_year(
+ sites: list[SiteInput],
+ meters: dict[str, TokenMeter],
+ pricing: dict[str, TokenPricing],
+ scenario: Scenario,
+ core_scopes: list[FeatureScope],
+ email_scopes: list[FeatureScope],
+ token_rollout: RolloutPlan,
+ email_rollout: RolloutPlan,
+ use_contracted: bool = False,
+) -> pd.DataFrame:
+ """Engine-computed token costs, rollout-gated, per calendar year.
+
+ Long DataFrame: cost_line, scope, annual_cost, confidence, year.
+ """
+ frames = []
+ for y in YEARS:
+ for scopes, rollout in ((core_scopes, token_rollout),
+ (email_scopes, email_rollout)):
+ part = calculate_total_cost(
+ sites, scopes, meters, pricing, scenario, YEAR_INDEX[y],
+ include_platform=False, use_contracted=use_contracted,
+ rollout=rollout,
+ )
+ part["year"] = y
+ frames.append(part)
+ return pd.concat(frames, ignore_index=True)
+
+
+# ── AI implementation effort (missing cost #2, V2 LoE) ───────────────
+
+#: (low, high) Y1 hours — docs/ctm_ai_labour_estimate_V2.md.
+AI_IMPL_HOURS: dict[str, tuple[float, float]] = {
+ "Agent Copilot": (1_200, 1_800), # voice + digital incl. email Auto-Suggest
+ "Email Auto-Respond": (800, 1_400), # separate flow; needs SoR integration
+ "STA": (800, 1_200), # topics, programs, tuning × 7 languages
+ "Supervisor Copilot": (200, 400),
+ "Predictive Routing": (400, 700),
+ "Cross-cutting": (1_000, 1_800), # governance, PM, test env, integration
+}
+KB_READINESS_HOURS = (500, 1_500) # prerequisite project — flagged separately
+STEADY_STATE_HOURS = (500, 900) # absolute h/yr, 2027-2028
+DEFAULT_BLENDED_RATE = 225.0
+SMELL_TEST_FLOOR = 0.15 # impl ≥ 15% of benefit claim, or flag
+
+
+def impl_feature_regions(copilot_includes_asia: bool = False) -> dict[str, list[str]]:
+ """Which regions each implementation workstream serves."""
+ return {
+ "Agent Copilot": (["NA", "ANZ", "EMEA"]
+ + (["ASIA"] if copilot_includes_asia else [])),
+ "Email Auto-Respond": list(REGIONS),
+ "STA": list(REGIONS),
+ "Supervisor Copilot": ["NA", "ANZ", "EMEA"], # deck: $0 SupCopilot in ASIA
+ "Predictive Routing": list(REGIONS),
+ "Cross-cutting": list(REGIONS),
+ }
+
+
+def hours_pick(rng: tuple[float, float], mode: str) -> float:
+ low, high = rng
+ return {"low": low, "mid": (low + high) / 2, "high": high}[mode]
+
+
+def impl_year_fractions(impl_month: int) -> list[float]:
+ """Spend spreads uniformly from contract start (month 0) to the impl month."""
+ prev, fracs = 0, []
+ for yi in (1, 2, 3):
+ cur = min(12 * yi, impl_month)
+ fracs.append((cur - prev) / impl_month)
+ prev = cur
+ return fracs
+
+
+def region_impl_month(feature: str, region: str, na_email_early: bool = True) -> int:
+ if feature == "Email Auto-Respond" and region == "NA" and na_email_early:
+ return NA_EMAIL_IMPL_MONTH
+ return IMPL_MONTH[region]
+
+
+def build_impl_costs(
+ sites: list[SiteInput],
+ mode: str = "mid",
+ rate: float = DEFAULT_BLENDED_RATE,
+ include_kb: bool = True,
+ copilot_includes_asia: bool = False,
+ na_email_early: bool = True,
+) -> tuple[pd.DataFrame, dict[int, float], dict[int, float], dict[int, float]]:
+ """V2 hours-range × rate model (swap point for the future LoE engine).
+
+ Returns (detail_df, impl_by_year, kb_by_year, steady_by_year).
+ Hours allocate to each workstream's scoped regions by agent share;
+ steady-state is booked program-level in 2027-2028.
+ """
+ agents = region_agents(sites)
+ feature_regions = impl_feature_regions(copilot_includes_asia)
+ workstreams = dict(AI_IMPL_HOURS)
+ if include_kb:
+ workstreams["KB readiness (prerequisite)"] = KB_READINESS_HOURS
+ rows = []
+ for feature, rng in workstreams.items():
+ regions = feature_regions.get(feature, list(REGIONS))
+ scope_agents = sum(agents[r] for r in regions)
+ for r in regions:
+ hours = hours_pick(rng, mode) * agents[r] / scope_agents
+ fracs = impl_year_fractions(
+ region_impl_month(feature, r, na_email_early))
+ rows.append({"workstream": feature, "region": r, "hours": hours,
+ "cost": hours * rate,
+ **{y: hours * rate * f for y, f in zip(YEARS, fracs)}})
+ df = pd.DataFrame(rows)
+ is_kb = df["workstream"].str.startswith("KB")
+ impl_y = {y: float(df.loc[~is_kb, y].sum()) for y in YEARS}
+ kb_y = {y: float(df.loc[is_kb, y].sum()) for y in YEARS}
+ steady = hours_pick(STEADY_STATE_HOURS, mode) * rate
+ steady_y = {2026: 0.0, 2027: steady, 2028: steady}
+ return df, impl_y, kb_y, steady_y
+
+
+# ── Business case (baseline-relative frame) ──────────────────────────
+
+
+def case_flows(
+ total_cost_by_year: dict[int, float],
+ benefit_total_by_year: dict[int, float],
+ baseline_annual: float | None = None,
+) -> tuple[dict[int, float], dict[int, float]]:
+ """(incremental cost, net) vs the do-nothing baseline.
+
+ One frame captures both the 2026-27 double-billing penalty and the
+ post-termination cost-avoidance credit.
+ """
+ base = TCO_VERBATIM["current_annual"] if baseline_annual is None else baseline_annual
+ inc = {y: total_cost_by_year[y] - base for y in YEARS}
+ net = {y: benefit_total_by_year[y] - inc[y] for y in YEARS}
+ return inc, net
+
+
+def payback_label(net_by_year: dict[int, float]) -> str:
+ pb = payback_years([net_by_year[y] for y in YEARS])
+ if pb is None:
+ return f"beyond {YEARS[-1]}"
+ if pb == 0:
+ return "immediate"
+ m = math.ceil(pb * 12)
+ return f"{m} months (~{month_label(m)})"
+
+
+def case_kpis(
+ inc: dict[int, float],
+ net: dict[int, float],
+ discount_rate: float | None = None,
+) -> dict:
+ """KPIs for one cost frame. Benefits are recoverable as net + inc."""
+ rate = TCO_VERBATIM["npv_discount_rate"] if discount_rate is None else discount_rate
+ net_list = [net[y] for y in YEARS]
+ inc_total = sum(inc.values())
+ net_total = sum(net_list)
+ return {
+ "benefits_3yr": net_total + inc_total,
+ "incremental_cost_3yr": inc_total,
+ "net_3yr": net_total,
+ "roi": (net_total / inc_total) if inc_total > 0 else None,
+ "npv": npv(net_list, rate),
+ "discount_rate": rate,
+ "payback": payback_label(net),
+ }
+
+
+# ── Display helpers ──────────────────────────────────────────────────
+
+
+def money(v: float) -> str:
+ sign, a = ("-" if v < 0 else ""), abs(v)
+ return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
+
+
+def html_money(v: float) -> str:
+ """Plotly text with two or more bare ``$`` triggers MathJax math mode —
+ annotations holding several amounts must use the HTML entity instead."""
+ return money(v).replace("$", "$")