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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
workstreamlow hhigh h
0Agent Copilot12001800
1Email Auto-Respond8001400
2STA8001200
3Supervisor Copilot200400
4Predictive Routing400700
5Cross-cutting10001800
6KB readiness (prerequisite)5001500
7Steady-state (h/yr, 2027-28)500900
\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("$", "$")