Files
palladium/studies/202607_CTM_GenesysCX/tokencalc/appendix4.py

581 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 behind the deliverable notebook
(``notebooks/ctm_business_case_corrected.ipynb``, served with
Mercury) — the presentation layer holds 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
}
#: Actual contracted values where they differ from the deck — 🟢
#: contractual. Layered over TCO_VERBATIM, which stays the untouched
#: record of what Genesys pitched (the anchor CTM can follow);
#: presentation reads through :func:`tco`.
TCO_CONTRACTED: dict[str, float] = {
"ccaas_annual": 3_200_000, # signed licence run-rate (deck pitched $4.3M/yr)
}
#: NTT professional services — SOW billing milestones (🟢 contractual).
#: The deck's verbatim anchor books $2.4M PS in year 1; the signed SOW
#: bills $2,025,446.48 in four milestones split 50/50 across 2026-27.
PS_MILESTONES: list[dict] = [
{"name": "SOW Effective Date", "date": dt.date(2026, 3, 15),
"share": 0.30, "amount": 607_633.94},
{"name": "Start of client UAT (first region, NA)", "date": dt.date(2026, 10, 16),
"share": 0.20, "amount": 405_089.30},
{"name": "Start of client UAT (last region, APAC)", "date": dt.date(2027, 10, 1),
"share": 0.30, "amount": 607_633.94},
{"name": "Completion of last go-live migration", "date": dt.date(2027, 11, 1),
"share": 0.20, "amount": 405_089.30},
]
PS_CONTRACTED_TOTAL = sum(m["amount"] for m in PS_MILESTONES)
#: NTT managed services — commences billing at MCX go-live (🟢 contractual).
#: Not in the deck's TCO at all; an ongoing run-rate cost thereafter.
MANAGED_SERVICES_ANNUAL = 410_918.40
MCX_GO_LIVE = dt.date(2026, 10, 16)
def tco(key: str) -> float:
"""Contracted value where one exists, else the deck's verbatim anchor."""
return TCO_CONTRACTED.get(key, TCO_VERBATIM[key])
#: Deployment schedule per the current PM delivery timeline (Jul 2026),
#: months from Jan 2026 inclusive. UAT start is the implementation anchor:
#: NA Oct 2026 (m10), EMEA Feb 2027 (m14), ANZ Jun 2027 (m18),
#: APAC Oct 2027 (m22). Benefits realize IMPL + 3 months.
IMPL_MONTH = {"NA": 10, "EMEA": 14, "ANZ": 18, "ASIA": 22}
BENEFIT_LAG_MONTHS = 3
REALIZE_MONTH = {r: m + BENEFIT_LAG_MONTHS for r, m in IMPL_MONTH.items()}
#: NA-Email-early exception retired under the compressed timeline
#: (NA implements Oct 2026; Email cannot realize before NA itself).
NA_EMAIL_IMPL_MONTH = IMPL_MONTH["NA"]
DEFAULT_RAMP_MONTHS = 6 # Genesys ramp programme (🟢 order form)
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(contracted: bool = False) -> dict[int, float]:
"""Base professional services + training.
Verbatim: the deck's $2.4M PS lump plus training, all in year 1.
Contracted: PS phased on the SOW billing milestones (50/50 across
2026-27, $2.03M total); training stays the verbatim year-1 line —
the SOW milestones don't itemize it separately.
"""
training = TCO_VERBATIM["training_y1"]
if contracted:
ps = {y: 0.0 for y in YEARS}
for m in PS_MILESTONES:
ps[m["date"].year] += m["amount"]
return {y: ps[y] + (training if y == 2026 else 0.0) for y in YEARS}
return {2026: TCO_VERBATIM["prof_services_y1"] + training,
2027: 0.0, 2028: 0.0}
def ps_milestones_dataframe() -> pd.DataFrame:
"""The SOW billing milestones as a display table."""
return pd.DataFrame(PS_MILESTONES)
def managed_services_by_year(
annual: float = MANAGED_SERVICES_ANNUAL, start: dt.date = MCX_GO_LIVE
) -> dict[int, float]:
"""Managed services bill from the month after go-live (Oct 16 → Nov),
then run at the full annual rate — an ongoing cost with no end date
inside the model window."""
def _months(y: int) -> int:
if y < start.year:
return 0
return 12 if y > start.year else 12 - start.month
return {y: annual * _months(y) / 12 for y in YEARS}
# ── 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,
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 (PM, governance, testing, integration)": (1_000, 1_800),
}
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 (PM, governance, testing, integration)": 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;")