feat: add corrected business case page with TEI-styled charts

This commit is contained in:
2026-07-07 11:48:52 -04:00
parent 483bbe61dc
commit 0fa2091a25
4 changed files with 1268 additions and 447 deletions

View File

@@ -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"<b>{title}</b>"
if subtitle:
t += f"<br><span style='font-size:12px;color:{_MUTED}'>{subtitle}</span>"
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}<extra></extra>")
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}<extra></extra>")
# ── 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"<b>{a4.money(float(c))}</b>",
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"<b>{a4.money(benefit_by_year[y])}</b>",
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"<b>{a4.money(corrected_by_year[y])}</b>",
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 <b>{a4.html_money(float(cum_c.iloc[-1]))}</b> — "
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"<b>{'+' if d >= 0 else ''}{a4.money(abs(d))}</b>",
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()

View File

@@ -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

View File

@@ -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("$", "&#36;")