docs: introduce Mercury Notebook Deliverable Pattern

This commit is contained in:
2026-07-08 13:43:12 -04:00
parent a991879061
commit c3260ae7b8
55 changed files with 12036 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
exports/
__pycache__/
*.pyc
.ipynb_checkpoints/

View File

@@ -0,0 +1,102 @@
# CTM Token Calculator
> 📐 **Reference implementation** of the
> [Mercury Notebook Deliverable Pattern](../../../docs/Mercury_Notebook_Pattern_V1-00.md).
**Genesys AI Token Cost & Business Case Calculator** — interactive,
defensible modeling of Genesys Cloud **CX 3** platform + AI feature costs
against realistic benefit scenarios, replacing single-point vendor ROI
outputs with sensitivity-aware **Floor / Realistic / Stretch** analysis.
> ⚠️ **Planning tool.** Uses published Genesys list rates unless overridden —
> explicitly not a replacement for contractual pricing. No Genesys API
> integration; this is a forward-looking model, not a production-consumption
> dashboard.
## CTM context
- 9 sites (NAM, EMEA, AUZ, 6× APAC), **2,088 contracted named users**
- NAM volumes from CTM discovery; **all other site data is estimated —
confirm with CTM** (flagged throughout the UI)
- Cost takeouts include the NICE IEX (NAM) retirement placeholder ($1.3M/yr,
estimated)
- Every meter carries a confidence flag: 🟢 confirmed (published rate) ·
🟡 estimated · 🔴 unknown (working default, rate not yet sourced)
## Install & run
```bash
cd ctm-token-calculator
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]" # everything needed to serve, run, and export the notebooks
# Serve the notebooks as interactive web apps (Mercury)
mercury --working-dir notebooks/
# Or work on them directly in JupyterLab
jupyter lab notebooks/
# Export the business-case notebooks as LLM-readable report sources
# (exports/*.html for review, exports/*.md for feeding an LLM;
# optional filter: python scripts/export_report.py migration)
python scripts/export_report.py
# Tests
pytest
```
## Architecture
**The notebooks are the deliverables.** All math lives in the pure-Python
`tokencalc/` library; the notebooks are thin presentation layers over it.
[Mercury](https://runmercury.com) serves them as interactive web apps — the
`mercury` input widgets in the business-case notebooks let you tune
contract values, termination dates, token assumptions, and implementation
pricing live for a client, and headless runs (nbconvert, each notebook's
regression-gate section) simply use the widget defaults. `scripts/export_report.py`
executes the notebooks and writes HTML + markdown to `exports/`; each notebook's
machine-readable appendix section carries every number behind the figures so an
LLM can draft the client report from the export.
| Notebook | Purpose |
|---|---|
| `notebooks/ctm_business_case_corrected.ipynb` | Client-facing corrected business case (Mercury-interactive) |
| `notebooks/ctm_migration_wfm.ipynb` | Migration + WFM only, all AI removed — the no-AI floor of the case (Mercury-interactive) |
| `notebooks/ctm_token_calculator.ipynb` | Full token-cost / scenario workbench |
| Module | Purpose |
|---|---|
| `meters.py` | Token meter + pricing dataclasses, confidence enum |
| `defaults.py` | Genesys meter catalogue, CTM sites/takeouts/phasing, CX 3 rate ($111.28/user/mo) |
| `inputs.py` | Validated input dataclasses (sites, feature scopes, takeouts) |
| `scenarios.py` | Floor/Realistic/Stretch + benefit params (Genesys claim vs pressure-tested) |
| `cost_model.py` | Platform, per-user AI, consumption AI cost engines |
| `benefit_model.py` | AHT/ACW/email/deflection/STA benefit engines |
| `business_case.py` | 3-year P&L, NPV @ 8%, payback, ROI |
| `exports.py` | Multi-sheet Excel, CSV, JSON scenario save/load |
### Correctness rules encoded in the model
1. **Agent Copilot covers Supervisor AI Summary** — AI Summary & Insights is
never billed at sites where Copilot is enabled (Copilot's 40 tokens/user/mo
includes summarization). Implemented and tested.
2. **Billing-style rounding** — monthly consumption token totals are rounded
up (`ceil`) per site before pricing; per-user totals are exact.
3. **Regional pricing** — every site resolves its token rate through its
pricing region (US/EU/AU/APAC); nothing is hardcoded to US.
4. **Adoption ramp** — consumption features ramp (default Y1 = 70%); per-user
licences are paid in full from their phase year. Phasing is per-site,
per-feature, per-phase (1/2/3/off).
### Verified reference numbers
- STA: 2,088 users × 30 tokens × 12 × $1 = **$751,680** ✓ (test)
- Agent Copilot: 2,088 × 40 × 12 × $1 = **$1,002,240** ✓ (test)
- NPV hand-check: 100/yr × 3 @ 8% = 257.710 ✓ (test)
## Auditability
Every number traces to an input and a meter: cost rows carry the feature,
scope (sites), and confidence; benefit rows carry the driver line and scope;
the Excel export includes input, meter, cost-detail, benefit-detail, business
case, and three-scenario comparison sheets.

View File

@@ -0,0 +1,81 @@
# Mercury app-shell theme — NTT DATA brand (light), modern surfaces.
# See docs/brand.md for the source palette.
#
# Loaded from the directory where you launch `mercury` (this project root);
# restart the server to apply changes. Only keys in mercury/config.py
# CSS_VARIABLE_MAP emit a CSS variable — anything else in DEFAULT_THEME is
# either derived or component-baked (e.g. success/warning/danger, slider
# track, widget bg) and silently no-ops here. Omitted keys are derived
# from the ones below.
[main]
title = "CTM × Genesys — Business Case"
favicon_emoji = "📊"
footer = "CTM × Genesys CCaaS study"
notebooks_button_label = "Analyses"
[welcome]
header = "CTM × Genesys CCaaS"
message = """
Interactive business-case notebooks. **Corrected Business Case** keeps
Genesys's claimed benefits verbatim and adds the costs the pitch omitted;
**Migration + WFM** strips out every AI capability and prices the platform
move alone. Tune the 🟡 inputs live for the client, then export the
personalized report source with `python scripts/export_report.py`.
"""
[theme]
# ── Type — Georgia headings, Arial body. Both web-safe system fonts,
# so no font_url / network fetch. Georgia ships only normal+bold, so
# heading weight is 700 (the default 800 would render as faux-bold). ──
font_family = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
heading_font_family = "Georgia, 'Times New Roman', Times, serif"
font_size = "15px"
font_weight = "normal"
heading_font_weight = "700"
# ── Text — NTT ink scale ──
text_color = "#2e404d" # body
muted_text_color = "#586671" # captions / secondary
# ── Surfaces — white content floating on a soft neutral canvas (depth).
# For a strictly-white page instead, set background_color = "#ffffff". ──
background_color = "#f4f5f6" # outer page
content_background_color = "#ffffff" # notebook column
surface_color = "#ffffff"
card_background_color = "#f8f8f8" # brand card
border_color = "#d5d9db" # brand border
border_radius = "10px" # modern rounding
# ── Accents — Future Blue. primary_color also drives the Run button + focus. ──
primary_color = "#0072bc"
accent_color = "#0072bc"
focus_border_color = "#0072bc"
hover_background_color = "#eef5fb" # light blue tint
selected_background_color = "#dcecfa"
# ── Sidebar — clean white, hairline divider ──
sidebar_background_color = "#ffffff"
sidebar_text_color = "#2e404d"
sidebar_title_color = "#151d2c"
sidebar_shadow = "1px 0 0 #d5d9db"
# ── Top bar — deep NTT navy (brand heading-primary) ──
topbar_background_color = "#151d2c"
topbar_text_color = "#ffffff"
topbar_border_color = "rgba(255,255,255,0.08)"
# ── Footer ──
footer_background_color = "#ffffff"
footer_text_color = "#586671"
footer_border_color = "#d5d9db"
# ── Run button — subtle brand-blue gradient (else derives from primary) ──
run_button_background = "linear-gradient(180deg, #0087dc 0%, #0072bc 100%)"
run_button_background_hover = "linear-gradient(180deg, #1a93e6 0%, #0079c8 100%)"
run_button_text_color = "#ffffff"
# ── Depth — soft, navy-tinted shadows (modern) ──
shadow_sm = "0 1px 2px rgba(21,29,44,0.05)"
shadow_md = "0 6px 18px rgba(21,29,44,0.08)"
shadow_lg = "0 16px 40px rgba(21,29,44,0.10)"

View File

@@ -0,0 +1,198 @@
The Genesys ROI documents claim 5 AI feature benefit categories:
Agent Copilot (voice + digital handle time + ACW)
Email AI (Auto-Respond + Auto-Suggest)
Speech & Text Analytics
Supervisor Copilot (AI Translate, AI Summary, Admin)
Predictive Routing
None of these are turn-key. Each requires configuration, tuning, and enablement effort. The original case has zero implementation cost.
Framework — four LoE dimensions per feature
Every AI feature carries four kinds of effort:
Dimension What it is Scales with
Fixed setup One-time base configuration — instance creation, settings, rules, permissions, security Roughly constant per feature
Variable configuration Per-scope effort — per queue, per language, per intent, per wrap-up code, per KB article Multipliers × unit count
Iterative tuning Test → measure → adjust cycles. Non-negotiable for AI features. Typical: 3-6 cycles before production stability Complexity of the feature
Enablement & change Agent/supervisor training, adoption support, communications, super-user network Headcount + geography
Plus a steady-state annual line that everyone forgets:
Steady-state What it is
Annual optimization Retraining, KB refresh, drift correction, model tuning as customer behavior shifts
Per-feature LoE — Genesys-claimed feature set
Below is my working LoE structure for the calculator. Hours are for a typical medium-complexity implementation. CTM-specific amplifiers follow in the next section.
1. Agent Copilot
Per the Genesys documentation you shared, the setup dimensions are: create the Copilot instance, configure settings, configure NLU (intents), configure rules, configure queues, configure per-language variants, wrap-up code configuration, AI Studio for custom summaries, testing, permissions, KB integration for answer highlighting.
Activity Unit Hours per unit Notes
Base Copilot instance setup Fixed 80-120 Per language variant (one instance per language)
Settings, rules, permissions config Fixed 40-60
NLU / intent modeling Per 10 intents 30-50 Includes utterance generation, training, validation
Wrap-up code mapping Per 20 wrap-ups 8-12 Includes utterance training per code
Queue configuration Per queue 1-3 Critical CTM scaling factor
Custom summary templates (AI Studio) Per template 20-40 If custom summaries wanted
Knowledge base article preparation Per 100 articles 20-40 Only if KB used for answer highlighting — separate from KB creation
Testing / tuning cycles Per cycle 80-120 Plan for 4-6 cycles Y1
Agent training Per 100 agents 8-12 Blended live/self-paced
Supervisor / admin enablement Per site 16-24
Typical medium implementation (10 queues, 1 language, 100 intents, 100 wrap-ups, 500 agents, 4 tuning cycles, 500 KB articles): ~1,500 hours.
2. Email AI (Auto-Suggest + Auto-Respond)
Activity Unit Hours per unit
Base Email AI setup Fixed 60-100
Intent library for email Per 10 intents 40-60 (higher than voice — more text nuance)
Response template library (Auto-Suggest) Per 20 templates 30-50
Auto-Respond flow design Per flow 60-100 (business rules, escalation logic, guardrails)
Integration to systems of record for response Per integration 80-200
Testing / tuning cycles Per cycle 100-160
Agent training on suggested/edit vs. auto Per 100 agents 6-10
Typical medium implementation: ~1,200-1,800 hours.
Critical note: Auto-Respond at any meaningful rate requires integration to case/order/account data — doesn't work without the ESB. Auto-Suggest is more forgiving. This shapes phasing.
3. Speech & Text Analytics
Activity Unit Hours per unit
STA topic/program setup Fixed 80-120
Program per language Per language 60-100
Topic library — compliance Per 20 topics 20-30
Topic library — CX / operational Per 20 topics 20-30
Category / phrase library tuning Per cycle 60-100 (plan for 3-5 cycles)
Dashboard / report configuration Per dashboard 20-30
Supervisor enablement Per site 8-16
Typical medium implementation: ~600-1,000 hours.
4. Supervisor Copilot
Activity Unit Hours per unit
Supervisor Copilot instance & settings Fixed 40-60
AI Translate configuration per language pair Per pair 8-16
AI Summary insight configuration Fixed 40-60
Alerting rules & thresholds Per rule set 20-40
Supervisor training Per 10 supervisors 8-16
Typical medium implementation: ~300-500 hours.
5. Predictive Routing
Activity Unit Hours per unit
PR model configuration Fixed 60-100
Data source setup and validation Fixed 40-80
Per-queue optimization Per queue 2-4
Baseline measurement & A/B Per cycle 80-120 (plan 2-3 cycles)
Model retraining automation Fixed 20-40
Typical medium implementation: ~500-800 hours.
Cross-cutting activities (allocate across features)
These are the ones that get missed and blow budgets:
Activity Unit Hours
KB curation & prep (source-of-truth for Copilot, Email AI, and STA) Per 100 articles 40-80
KB governance setup (versioning, ownership, refresh cadence) Fixed 100-200
AI governance framework (drift detection, model versioning, escalation paths) Fixed 120-200
Data pipeline / integration to systems of record Per SoR 200-500
Testing environment setup Fixed 80-160
Program management overhead Per month program duration 40-80
Regulatory / compliance review for AI features Per feature 20-60
CTM-specific amplifiers
Now the ugly part. Every parameter above gets multiplied at CTM scale:
Parameter Typical medium CTM
Tails 10-50 1,000+ (6-10× amplifier on queue-configuration line items)
Languages 1-3 7+ (English, French, Spanish, German, Mandarin, Cantonese, Japanese)
Sites 1-3 9 (change management overhead compounds)
Agent count 100-500 ~1,900 (training scales linearly)
Regions 1 4 (NAM, EMEA, AUZ, APAC) — program management overhead compounds
Systems-of-record integration 1-2 pre-built 0 today, ESB Nov 2026+
KB maturity Unknown Unknown — flag as major risk
Amplifier math for Agent Copilot at CTM scale
Using the LoE table above at CTM parameters, mid-range hours:
Activity CTM units Hours
Base Copilot instance × 7 languages 7 700
Settings/rules/permissions 1 50
NLU/intent modeling — 300 intents (large enterprise) 30 1,200
Wrap-up codes — 500 codes 25 250
Queue configuration — 1,000 queues at 2 hrs each 1,000 2,000
Custom summary templates — 15 templates 15 450
KB article preparation — 5,000 articles 50 1,500
Testing/tuning — 6 cycles 6 600
Agent training — 1,900 agents 19 190
Supervisor enablement — 9 sites 9 180
Agent Copilot subtotal ~7,100 hours
Full CTM AI implementation LoE
Feature Estimated hours
Agent Copilot 6,500 - 8,500
Email AI (Auto-Suggest + Auto-Respond) 3,000 - 4,500
Speech & Text Analytics 1,500 - 2,500
Supervisor Copilot 600 - 900
Predictive Routing 1,200 - 1,800
Feature subtotal 12,800 - 18,200
Cross-cutting (KB, governance, PM, integration) 4,000 - 7,000
Total Y1 implementation LoE 16,800 - 25,200 hours
Translating to dollars
I don't know your PS rate, but for context using industry-standard blended rates:
Blended rate Y1 implementation cost range
$175/hr (offshore-heavy blend) $2.9M - $4.4M
$225/hr (typical NTT DATA blended) $3.8 million - $5.7 million
$275/hr (onshore-heavy specialist) $4.6M - $6.9M
Plus annual steady-state at 15-20% of implementation = $430K - $1.4M/yr recurring for ongoing optimization, tuning, KB refresh, model retraining.
What this does to the case
Adding implementation costs to the model:
Component Y1 Y2 Y3 3-Year
Platform license $2.79M $2.79M $2.79M $8.37M
AI token costs (Realistic) $2.0M $3.5M $5.0M $10.5M
AI implementation LoE (new) $3.8 million-5.7 million $0.6M-1.1M $0.6M-1.1M $5.0M-7.9M
Legacy platform takeouts ($2.0M) ($2.0M) ($2.0M) ($6.0M)
Realistic AI benefits ($1.5M) ($4.5M) ($7.5M) ($13.5M)
NET +$5.1M to +$7.0M +$0.4M to +$0.9M -$1.1M to -$1.6M +$4.4M to +$6.3M
In the current model, program is net-negative $4-6M over 3 years even in Realistic scenario. Y1 is the ugly year because implementation cost front-loads. Y3 is when benefits catch up — barely.
And that's using Genesys's own claimed benefits, unadjusted. If we apply the realistic haircuts we discussed earlier (Y1 benefit realization at 30-50%), the picture gets worse.
Calculator amendment
Add to the spec:
New dataclass in inputs.py
@dataclass
class ImplementationEffort:
feature: str
fixed_setup_hours: float
per_queue_hours: float
per_language_hours: float
per_100_intents_hours: float
per_20_wrapups_hours: float
per_100_kb_articles_hours: float
tuning_cycles: int
hours_per_tuning_cycle: float
per_100_agents_training_hours: float
per_site_enablement_hours: float
@dataclass
class ImplementationCosts:
ps_blended_rate: float # $/hr — user-configurable
steady_state_pct_annual: float # default 0.175 (17.5%)
ramp_phasing: dict[str, float] # feature -> % of hours in Y1 vs Y2
New module tokencalc/implementation.py
def calculate_feature_loe(effort: ImplementationEffort,
sites: list[SiteInput],
feature_scope: FeatureScope,
complexity_params: ComplexityParams
) -> pd.DataFrame:
"""Returns hours breakdown by activity for one feature."""
def calculate_implementation_cost(loe_by_feature: dict,
ps_rate: float,
phasing: dict
) -> pd.DataFrame:
"""Distributes hours across Y1/Y2/Y3 with steady-state tail."""
New Streamlit page: "Implementation Effort"
LoE inputs per feature (adjustable, defaults from table above)
Complexity multipliers (queue count, language count, KB size — pulled from Inputs page)
PS rate slider
Steady-state % slider
Output table: hours × rate by feature, by year
Warning: "Implementation cost was NOT in the original Genesys business case."
Smell-test rule to add
If total AI implementation cost < 15% of AI benefit claim, flag as under-modeled. Industry benchmark: 20-40% of Y1 benefit claim goes to implementation.

View File

@@ -0,0 +1,64 @@
Correction #1 — Email Auto-Suggest is part of Copilot
You're correct, and I got this wrong. Per Genesys documentation, Agent Copilot handles both voice and digital channels (email, chat, messaging) — including Auto-Suggest for email. Email Auto-Suggest is not a separate product; it's a Copilot capability on the email channel.
Auto-Respond is the separate line — that's Email Bot / Digital Bot flow territory, distinct from Copilot's suggest-and-agent-approves model.
That collapses my LoE table. Corrected structure:
Feature (corrected) What it actually is
Agent Copilot Voice + digital channels (including email Auto-Suggest, chat suggest, messaging suggest) — one implementation
Email Auto-Respond Automated response without agent — separate flow, needs system-of-record integration
STA Standalone product
Supervisor Copilot Standalone product
Predictive Routing Standalone product
So I was double-counting Copilot email work. Email AI as I described it was inflated.
Correction #2 — The hours were wrong. Let me show my work.
You called it correctly. 16,800-25,200 hours is 8-12 person-years of labour for five features on a mature platform. That's absurd on its face. Genesys Cloud is not custom software; these features are configuration, not development. Let me diagnose where I inflated:
Where I over-estimated
Queue configuration at 2 hours × 1,000 queues = 2,000 hours for Copilot alone. Nonsense. Copilot doesn't require per-queue configuration in the way I implied. Enable Copilot on the account, configure once, apply to queues via policy. Real work: ~50-100 hours to define which queue types get Copilot, plus per-queue-type tuning, not per-individual-queue.
NLU / intent modeling at 40 hours per 10 intents = 1,200 hours for 300 intents. Overstated. Copilot leverages Genesys's pre-built LLM understanding — you're not training an NLU from scratch. You're configuring wrap-up code mappings and validating auto-summary quality. Real work: ~200-400 hours for a large enterprise intent library, not 1,200.
KB article prep at 40 hours per 100 articles = 1,500 hours for 5,000 articles. Double-counted. KB curation is either (a) already done in your KB platform and Copilot indexes it, or (b) a separate KB maturity project — not an AI implementation line item. Real work: ~200-400 hours for connector setup and indexing configuration, plus a separate flag that says "KB curation is a prerequisite project, not part of AI implementation."
Testing/tuning at 100 hours × 6 cycles. Overstated. Test cycles for a hosted AI feature aren't full regression cycles — they're validation of prompt output quality and adjustment. Real: ~40-60 hours per cycle, 3-4 cycles typical.
Training at 10 hours per 100 agents. Wildly high. This is a Copilot UI change — 30 minutes of orientation, some job aids, super-user support. Real: ~1-2 hours per 100 agents for train-the-trainer + material creation.
Cross-cutting at 4,000-7,000 hours. Inflated by carrying forward the double-counted items above.
Corrected LoE — Genesys AI features at CTM scale
Working from realistic effort, not vendor-services-inflation:
Feature Realistic hours (CTM scale)
Agent Copilot (voice + digital, all languages) 1,200 - 1,800
Email Auto-Respond (separate from Copilot; needs integration) 800 - 1,400
STA (topics, programs, tuning for 7 languages) 800 - 1,200
Supervisor Copilot 200 - 400
Predictive Routing 400 - 700
Feature subtotal 3,400 - 5,500
Cross-cutting (governance, PM, testing environment, integration coordination) 1,000 - 1,800
KB readiness project (separate line — prerequisite) 500 - 1,500 (flagged separately)
Total Y1 AI implementation 4,400 - 7,300 hours
Annual steady-state (Y2, Y3) 500 - 900 hours
That's 2-3.5 person-years of Y1 effort across 5 features. Still substantial — this is a real, multi-workstream program at CTM scale — but not the fantasy 12 person-years I had before.
Corrected cost impact
Blended rate Y1 implementation Annual steady-state
$175/hr $770K - $1.28M $88K - $158K
$225/hr $990K - $1.64M $113K - $203K
$275/hr $1.21M - $2.01M $138K - $248K
Updated combined case
Using $225/hr blended rate and Realistic scenario:
Component Y1 Y2 Y3 3-Year
Genesys CX 3 platform license $2.79M $2.79M $2.79M $8.37M
Base platform implementation $1.5M — — $1.5M
AI token costs $2.0M $3.5M $5.0M $10.5M
AI implementation (corrected) $1.3M $0.16M $0.16M $1.6M
Total future-state cost $7.6M $6.45M $7.95M $22.0M
Current-state takeout ($7.3M) ($7.3M) ($7.3M) ($21.9M)
AI benefits (realistic) ($1.5M) ($4.5M) ($7.5M) ($13.5M)
Program net -$1.2M +$5.35M +$6.85M +$11.0M

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "ctm-token-calculator"
version = "0.1.0"
description = "Genesys AI Token Cost & Business Case Calculator (CTM)"
requires-python = ">=3.10"
# The notebooks are the deliverables (served with Mercury, exported via
# nbconvert, tables via tabulate) — the whole toolchain is a required
# runtime dependency, not an extra. `pip install -e .` must be enough.
dependencies = [
"pandas>=2.0",
"plotly>=5.18",
"openpyxl>=3.1",
"mercury>=3.2",
"jupyterlab>=4.0",
"ipywidgets>=8.0",
"nbconvert>=7",
"tabulate>=0.9",
]
[project.optional-dependencies]
dev = ["pytest>=7.4", "mypy>=1.8"]
[tool.setuptools.packages.find]
include = ["tokencalc*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.mypy]
strict = true
packages = ["tokencalc"]

View File

@@ -0,0 +1,49 @@
"""Export the deliverable notebooks as LLM-readable report sources.
Executes each notebook fresh (widget defaults — or whatever defaults you edit in),
then writes both formats to exports/:
exports/<notebook>.html — human-reviewable, tables render
exports/<notebook>.md — leanest LLM input
Plotly figures export as JavaScript an LLM cannot read; each notebook's
machine-readable appendix section carries every number behind them.
Run from the project root: python scripts/export_report.py [name-filter]
An optional argument exports only notebooks whose filename contains it,
e.g. python scripts/export_report.py migration
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
NOTEBOOKS = [
ROOT / "notebooks" / "ctm_business_case_corrected.ipynb",
ROOT / "notebooks" / "ctm_migration_wfm.ipynb",
]
EXPORTS = ROOT / "exports"
def main() -> None:
picked = [nb for nb in NOTEBOOKS
if len(sys.argv) < 2 or sys.argv[1] in nb.name]
if not picked:
sys.exit(f"no notebook matches {sys.argv[1]!r}")
EXPORTS.mkdir(exist_ok=True)
for nb in picked:
for fmt in ("html", "markdown"):
subprocess.run(
[sys.executable, "-m", "nbconvert", "--execute",
"--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
check=True, cwd=ROOT,
)
for p in sorted(EXPORTS.iterdir()):
if p.suffix in (".html", ".md"):
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,137 @@
"""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)
# The order form's ramp is 6 months — licences bill from July 2026.
assert a4.DEFAULT_RAMP_MONTHS == 6
assert a4.licence_costs_by_year() == {2026: 2_150_000.0, 2027: 4_300_000.0,
2028: 4_300_000.0}
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
def test_contracted_overlays_verbatim():
assert a4.tco("ccaas_annual") == 3_200_000 # signed contract
assert a4.TCO_VERBATIM["ccaas_annual"] == 4_300_000 # deck record intact
assert a4.tco("current_annual") == a4.TCO_VERBATIM["current_annual"]
assert a4.licence_costs_by_year(12, a4.tco("ccaas_annual"))[2027] == 3_200_000
def test_sow_milestones_and_managed_services():
assert a4.PS_CONTRACTED_TOTAL == pytest.approx(2_025_446.48)
for m in a4.PS_MILESTONES: # amounts match the shares
assert m["amount"] == pytest.approx(m["share"] * a4.PS_CONTRACTED_TOTAL,
abs=0.01)
ps = a4.ps_costs_by_year(contracted=True) # 50/50 across 2026-27
assert ps[2026] == pytest.approx(607_633.94 + 405_089.30 + 167_000)
assert ps[2027] == pytest.approx(607_633.94 + 405_089.30)
assert ps[2028] == 0.0
# The deck's verbatim year-1 lump stays intact for the as-pitched frame.
assert a4.ps_costs_by_year() == {2026: 2_567_000, 2027: 0.0, 2028: 0.0}
# Managed services bill from the month after MCX go-live (Sep 30 → Oct).
ms = a4.managed_services_by_year()
assert ms[2026] == pytest.approx(410_918.40 * 3 / 12)
assert ms[2027] == pytest.approx(410_918.40)
assert ms[2028] == pytest.approx(410_918.40)

View File

@@ -0,0 +1,237 @@
"""Benefit engine."""
from __future__ import annotations
import pytest
from tokencalc.benefit_model import (
calculate_acw_summarization_benefit,
calculate_email_ai_benefit,
calculate_total_benefit,
calculate_va_deflection_benefit,
)
from tokencalc.defaults import CTM_DEFAULT_FEATURE_SCOPES, CTM_DEFAULT_SITES
from tokencalc.inputs import WORKING_SECONDS_PER_YEAR, FeatureScope, SiteInput
from tokencalc.scenarios import BENEFIT_PARAMS
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
def _small_site() -> SiteInput:
return SiteInput(
"Small", "US", agents=10, supervisors=1,
voice_volume_monthly=10_000, email_volume_monthly=1_000,
chat_volume_monthly=0, sms_volume_monthly=0,
voice_aht_seconds=300, email_aht_seconds=600,
chat_aht_seconds=480, voice_acw_seconds=60,
fully_loaded_agent_cost_annual=74_880, # → $0.01/second exactly
fully_loaded_supervisor_cost_annual=95_000,
)
def test_acw_benefit_hand_check():
"""10,000 calls × 12 × 70% eligible × 60s ACW × 40% reduction ×
50% Y1 realization × $0.01/s = $10,080."""
site = _small_site()
assert site.agent_cost_per_second == pytest.approx(0.01)
df = calculate_acw_summarization_benefit(
[site], FeatureScope("Agent Copilot", ["Small"]), "realistic", year=1,
)
expected = 10_000 * 12 * 0.70 * 60 * 0.40 * 0.50 * 0.01
assert df["annual_value"].sum() == pytest.approx(expected)
def test_email_benefit_split():
site = _small_site()
df = calculate_email_ai_benefit(
[site], FeatureScope("Email AI (Auto-Respond)", ["Small"]),
"realistic", year=1,
)
# Auto-Suggest is not a separate line — it lives inside Agent Copilot.
lines = set(df["benefit_line"])
assert lines == {"Email Auto-Respond (displaced handling)"}
# auto-respond: 1,000×12 × 20% × 600s × 50% × $0.01 = $7,200
respond = df[df["benefit_line"].str.contains("Respond")]["annual_value"].sum()
assert respond == pytest.approx(7_200)
def test_scenarios_produce_distinct_benefits():
totals = {
name: calculate_total_benefit(
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, name, year=2
)["annual_value"].sum()
for name in ("floor", "realistic", "stretch")
}
assert totals["floor"] < totals["realistic"] < totals["stretch"]
def test_claim_exceeds_realistic():
realistic = calculate_total_benefit(
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=1,
params="realistic",
)["annual_value"].sum()
claim = calculate_total_benefit(
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=1,
params="claim",
)["annual_value"].sum()
assert claim > realistic
def test_benefits_ramp_by_year():
by_year = [
calculate_total_benefit(
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, "realistic", year=y
)["annual_value"].sum()
for y in (1, 2, 3)
]
assert by_year[0] < by_year[1] < by_year[2]
def test_zero_volume_site_is_safe():
site = SiteInput(
"Empty", "US", agents=0, supervisors=0,
voice_volume_monthly=0, email_volume_monthly=0,
chat_volume_monthly=0, sms_volume_monthly=0,
voice_aht_seconds=300, email_aht_seconds=600,
chat_aht_seconds=480, voice_acw_seconds=0,
fully_loaded_agent_cost_annual=0,
fully_loaded_supervisor_cost_annual=0,
)
df = calculate_total_benefit(
[site], [FeatureScope("Agent Copilot", ["Empty"])], "realistic", year=1,
)
assert df["annual_value"].sum() == 0
def test_working_seconds_constant():
assert WORKING_SECONDS_PER_YEAR == 2_080 * 3_600
# ── Virtual Agent deflection tests ───────────────────────────────────────────
def test_va_bot_deflection_hand_check():
"""Voice Bot: 10,000 calls/mo × 12 × 35% bot_rate × 300s AHT
× 50% Y1 realization × realization_factor × $0.01/s.
realistic realization_factor = 0.70 × 0.80 × (1 0.05) = 0.532
"""
site = _small_site()
df = calculate_va_deflection_benefit(
[site],
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
"realistic",
year=1,
params="realistic",
)
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
real_factor = completion * labour * (1.0 - callback)
expected = (
10_000 * 12 # annual calls
* 0.35 # bot deflection rate
* 300 # AHT seconds
* 0.50 # Y1 scenario realization
* real_factor # completion × labour × (1 callback)
* 0.01 # labour rate per second
)
assert df["annual_value"].sum() == pytest.approx(expected)
def test_va_agentic_deflection_uses_residual():
"""Agentic VA must operate on the residual (1 bot_rate) call pool,
not the full volume.
With bot_rate=0.35 and va_rate=0.15:
residual = 10,000 × (1 0.35) = 6,500 calls/mo
va_deflected = 6,500 × 0.15 = 975 calls/mo
"""
site = _small_site()
df = calculate_va_deflection_benefit(
[site],
FeatureScope("Agentic Virtual Agent", ["Small"], deflection_target=0.15),
"realistic",
year=1,
params="realistic",
)
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
real_factor = completion * labour * (1.0 - callback)
# realistic scenario: voice_bot_deflection = 0.35
bot_rate = 0.35
va_rate = 0.15
expected = (
10_000 * 12 # annual calls
* (1.0 - bot_rate) * va_rate # residual × va_rate (layered)
* 300 # AHT seconds
* 0.50 # Y1 scenario realization
* real_factor
* 0.01
)
assert df["annual_value"].sum() == pytest.approx(expected)
def test_va_no_double_count():
"""Combined bot + VA benefit must be less than the naive additive sum.
Naive (wrong): volume × (bot_rate + va_rate) × AHT × ...
Correct (layered): volume × (bot_rate + (1bot_rate)×va_rate) × AHT × ...
With bot=35%, va=15%:
naive total deflection = 50%
layered total deflection = 35% + 65%×15% = 44.75%
"""
site = _small_site()
bot_scope = FeatureScope("Voice Bot", ["Small"], deflection_target=0.35)
va_scope = FeatureScope("Agentic Virtual Agent", ["Small"], deflection_target=0.15)
bot_df = calculate_va_deflection_benefit([site], bot_scope, "realistic", year=1)
va_df = calculate_va_deflection_benefit([site], va_scope, "realistic", year=1)
combined = bot_df["annual_value"].sum() + va_df["annual_value"].sum()
# Naive additive (the old broken model): both on full volume
completion = BENEFIT_PARAMS["va_completion_rate"]["realistic"]
labour = BENEFIT_PARAMS["va_labour_realization"]["realistic"]
callback = BENEFIT_PARAMS["va_callback_discount"]["realistic"]
real_factor = completion * labour * (1.0 - callback)
naive = (
10_000 * 12 * (0.35 + 0.15) * 300 * 0.50 * real_factor * 0.01
)
assert combined < naive, (
f"Combined layered benefit ({combined:.2f}) should be less than "
f"naive additive ({naive:.2f}) — double-count not fixed"
)
# Also verify the exact layered total
layered_deflection = 0.35 + (1.0 - 0.35) * 0.15 # = 0.4475
expected_combined = (
10_000 * 12 * layered_deflection * 300 * 0.50 * real_factor * 0.01
)
assert combined == pytest.approx(expected_combined)
def test_va_claim_params_reproduce_no_haircut():
"""params='claim' must apply zero haircuts (all factors = 1.0),
reproducing the original Genesys ROI-doc assumption."""
site = _small_site()
df_claim = calculate_va_deflection_benefit(
[site],
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
"realistic",
year=1,
params="claim",
)
df_realistic = calculate_va_deflection_benefit(
[site],
FeatureScope("Voice Bot", ["Small"], deflection_target=0.35),
"realistic",
year=1,
params="realistic",
)
# claim should be strictly higher (no haircuts applied)
assert df_claim["annual_value"].sum() > df_realistic["annual_value"].sum()
# claim realization_factor = 1.0 × 1.0 × (1 0.0) = 1.0
expected_claim = 10_000 * 12 * 0.35 * 300 * 0.50 * 1.0 * 0.01
assert df_claim["annual_value"].sum() == pytest.approx(expected_claim)

View File

@@ -0,0 +1,117 @@
"""Business case maths + exports."""
from __future__ import annotations
import pytest
from tokencalc.business_case import build_business_case, npv, payback_years
from tokencalc.defaults import (
CTM_DEFAULT_FEATURE_SCOPES,
CTM_DEFAULT_SITES,
CTM_DEFAULT_TAKEOUTS,
DEFAULT_METERS,
DEFAULT_PRICING,
)
from tokencalc.exports import (
export_excel,
scenario_state_from_json,
scenario_state_to_json,
)
def test_npv_hand_check():
"""100/yr for 3 years @ 8%: 92.593 + 85.734 + 79.383 = 257.710."""
assert npv([100, 100, 100], 0.08) == pytest.approx(257.710, abs=0.001)
def test_payback_interpolation():
# -100 in Y1, +200 in Y2 → breakeven halfway through Y2 = 1.5 years
assert payback_years([-100, 200, 0]) == pytest.approx(1.5)
assert payback_years([-100, -100, -100]) is None
assert payback_years([50, 50, 50]) == pytest.approx(0.0)
def _case(scenario="realistic", **kw):
return build_business_case(
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES, DEFAULT_METERS,
DEFAULT_PRICING, CTM_DEFAULT_TAKEOUTS, scenario, **kw,
)
def test_business_case_shape():
case = _case()
assert set(case) == {
"cost_by_year", "benefit_by_year", "takeouts_by_year",
"net_by_year", "cumulative_net", "npv",
"payback_period_years", "roi_3yr",
}
for key in ("cost_by_year", "benefit_by_year", "net_by_year"):
assert {"Y1", "Y2", "Y3"} <= set(case[key].columns)
def test_net_consistency():
"""NET row must equal benefits + takeouts costs, per year."""
case = _case()
nb = case["net_by_year"].set_index("line")
for y in ("Y1", "Y2", "Y3"):
assert nb.loc["NET", y] == pytest.approx(
nb.loc["TOTAL BENEFITS", y]
+ nb.loc["TOTAL TAKEOUTS", y]
- nb.loc["TOTAL COSTS", y]
)
# cumulative is a running sum of NET
assert nb.loc["Cumulative net", "Y3"] == pytest.approx(
sum(nb.loc["NET", y] for y in ("Y1", "Y2", "Y3"))
)
def test_npv_matches_net_rows():
case = _case()
nb = case["net_by_year"].set_index("line")
net = [nb.loc["NET", y] for y in ("Y1", "Y2", "Y3")]
assert case["npv"] == pytest.approx(npv(net, 0.08))
def test_three_scenarios_distinct():
npvs = {s: _case(s)["npv"] for s in ("floor", "realistic", "stretch")}
assert len({round(v) for v in npvs.values()}) == 3
assert npvs["floor"] < npvs["realistic"] < npvs["stretch"]
def test_implementation_amortization():
base = _case()
with_impl = _case(implementation_cost=900_000)
nb, nb2 = (
c["net_by_year"].set_index("line") for c in (base, with_impl)
)
for y in ("Y1", "Y2", "Y3"):
assert nb2.loc["TOTAL COSTS", y] == pytest.approx(
nb.loc["TOTAL COSTS", y] + 300_000
)
def test_excel_export_readable(tmp_path):
case = _case()
path = export_excel(
{
"Business Case": case["net_by_year"],
"Costs": case["cost_by_year"],
"Benefits": case["benefit_by_year"],
},
tmp_path / "ctm.xlsx",
)
import openpyxl
wb = openpyxl.load_workbook(path)
assert set(wb.sheetnames) == {"Business Case", "Costs", "Benefits"}
def test_scenario_json_roundtrip(tmp_path):
p = tmp_path / "state.json"
scenario_state_to_json(
CTM_DEFAULT_SITES, CTM_DEFAULT_TAKEOUTS, CTM_DEFAULT_FEATURE_SCOPES, p
)
sites, takeouts, scopes, _rollout = scenario_state_from_json(p)
assert [s.site_name for s in sites] == [s.site_name for s in CTM_DEFAULT_SITES]
assert takeouts[0].annual_cost == CTM_DEFAULT_TAKEOUTS[0].annual_cost
assert scopes[0].adoption_curve == CTM_DEFAULT_FEATURE_SCOPES[0].adoption_curve

View File

@@ -0,0 +1,188 @@
"""Cost engine — including the spec's acceptance numbers."""
from __future__ import annotations
import pytest
from tokencalc.cost_model import (
calculate_consumption_ai_cost,
calculate_per_user_ai_cost,
calculate_platform_license_cost,
calculate_total_cost,
)
from tokencalc.defaults import (
CONTRACTED_NAMED_USERS,
CTM_DEFAULT_FEATURE_SCOPES,
CTM_DEFAULT_SITES,
DEFAULT_METERS,
DEFAULT_PRICING,
)
from tokencalc.inputs import FeatureScope, SiteInput
from tokencalc.scenarios import get_scenario
ALL_SITES = [s.site_name for s in CTM_DEFAULT_SITES]
def _scope(feature, sites=None, **kw):
return FeatureScope(feature, sites or ALL_SITES, **kw)
def test_default_sites_match_contracted_users():
assert sum(s.named_users for s in CTM_DEFAULT_SITES) == CONTRACTED_NAMED_USERS
def test_sta_acceptance_number():
"""2,088 users × 30 tokens × 12 months × $1 = $751,680."""
df = calculate_per_user_ai_cost(
CTM_DEFAULT_SITES, _scope("Speech & Text Analytics [named]"),
DEFAULT_METERS["Speech & Text Analytics [named]"], DEFAULT_PRICING,
)
assert df["annual_cost"].sum() == pytest.approx(751_680)
def test_agent_copilot_acceptance_number():
"""2,088 users × 40 tokens × 12 months × $1 = $1,002,240."""
df = calculate_per_user_ai_cost(
CTM_DEFAULT_SITES, _scope("Agent Copilot [named]"),
DEFAULT_METERS["Agent Copilot [named]"], DEFAULT_PRICING,
)
assert df["annual_cost"].sum() == pytest.approx(1_002_240)
def test_ai_translate_not_active_before_phase():
"""AI Translate (consumption meter) produces zero cost before its phase."""
scenario = get_scenario("realistic")
apac_sites = [s.site_name for s in CTM_DEFAULT_SITES if s.region_pricing == "APAC"]
df = calculate_consumption_ai_cost(
CTM_DEFAULT_SITES,
_scope("AI Translate", apac_sites, phase=3),
DEFAULT_METERS["AI Translate"], scenario, DEFAULT_PRICING, year=2,
)
assert df["annual_cost"].sum() == 0
def test_copilot_covers_supervisor_summary():
"""Rule 1: AI Summary cost is zero at Copilot sites."""
scenario = get_scenario("realistic")
total = calculate_total_cost(
CTM_DEFAULT_SITES,
[
_scope("Agent Copilot [named]"),
_scope("AI Summary & Insights"),
],
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
include_platform=False,
)
summary_row = total[total["cost_line"] == "AI Summary & Insights"].iloc[0]
assert summary_row["annual_cost"] == 0
# Without Copilot the same line costs real money.
total2 = calculate_total_cost(
CTM_DEFAULT_SITES,
[_scope("AI Summary & Insights")],
DEFAULT_METERS, DEFAULT_PRICING, scenario, year=1,
include_platform=False,
)
assert total2[total2["cost_line"] == "AI Summary & Insights"].iloc[0][
"annual_cost"
] > 0
def test_consumption_tokens_rounded_up_monthly():
"""Rule 2: ceil on monthly site token totals."""
site = SiteInput(
"Tiny", "US", agents=5, supervisors=0,
voice_volume_monthly=100, email_volume_monthly=0,
chat_volume_monthly=0, sms_volume_monthly=0,
voice_aht_seconds=300, email_aht_seconds=600,
chat_aht_seconds=480, voice_acw_seconds=60,
fully_loaded_agent_cost_annual=65_000,
fully_loaded_supervisor_cost_annual=95_000,
)
# realistic: 100 calls × 35% × 1.5 min = 52.5 min × (1/17) = 3.088
# tokens × 70% Y1 ramp applied to units → 36.75 min → 2.16 tokens → ceil 3
df = calculate_consumption_ai_cost(
[site], FeatureScope("Voice Bot", ["Tiny"]),
DEFAULT_METERS["Voice Bot"], "realistic", DEFAULT_PRICING, year=1,
)
assert df.iloc[0]["tokens_monthly"] == 3
assert df.iloc[0]["annual_cost"] == pytest.approx(3 * 12 * 1.0)
def test_predictive_routing_consumption():
"""1,700 calls/mo ÷ 17 per token = 100 tokens/mo → $1,200/yr (year 2, no ramp)."""
site = SiteInput(
"Tiny", "US", agents=5, supervisors=0,
voice_volume_monthly=1_700, email_volume_monthly=0,
chat_volume_monthly=0, sms_volume_monthly=0,
voice_aht_seconds=300, email_aht_seconds=600,
chat_aht_seconds=480, voice_acw_seconds=60,
fully_loaded_agent_cost_annual=65_000,
fully_loaded_supervisor_cost_annual=95_000,
)
df = calculate_consumption_ai_cost(
[site], FeatureScope("Predictive Routing", ["Tiny"]),
DEFAULT_METERS["Predictive Routing"], "realistic", DEFAULT_PRICING, year=2,
)
assert df.iloc[0]["tokens_monthly"] == 100
assert df.iloc[0]["annual_cost"] == pytest.approx(1_200)
def test_predictive_routing_eligibility_and_total_cost():
"""eligibility_pct halves the routed volume; total_cost handles the scope."""
site = SiteInput(
"Tiny", "US", agents=5, supervisors=0,
voice_volume_monthly=1_700, email_volume_monthly=0,
chat_volume_monthly=0, sms_volume_monthly=0,
voice_aht_seconds=300, email_aht_seconds=600,
chat_aht_seconds=480, voice_acw_seconds=60,
fully_loaded_agent_cost_annual=65_000,
fully_loaded_supervisor_cost_annual=95_000,
)
scope = FeatureScope("Predictive Routing", ["Tiny"], eligibility_pct=0.5)
df = calculate_consumption_ai_cost(
[site], scope, DEFAULT_METERS["Predictive Routing"], "realistic",
DEFAULT_PRICING, year=2,
)
assert df.iloc[0]["tokens_monthly"] == 50
total = calculate_total_cost(
[site], [scope], DEFAULT_METERS, DEFAULT_PRICING, "realistic", 2,
include_platform=False,
)
pr_row = total[total["cost_line"] == "Predictive Routing"].iloc[0]
assert pr_row["annual_cost"] == pytest.approx(50 * 12 * 1.0)
def test_regional_pricing_not_hardcoded():
pricing = dict(DEFAULT_PRICING)
from tokencalc.meters import TokenPricing
pricing["APAC"] = TokenPricing(region="APAC", list_rate_per_token=2.0)
apac_site = next(s for s in CTM_DEFAULT_SITES if s.region_pricing == "APAC")
df = calculate_per_user_ai_cost(
[apac_site], _scope("Speech & Text Analytics [named]", [apac_site.site_name]),
DEFAULT_METERS["Speech & Text Analytics [named]"], pricing,
)
expected = apac_site.named_users * 30 * 12 * 2.0
assert df["annual_cost"].sum() == pytest.approx(expected)
def test_year1_consumption_ramp_default_70pct():
sc = get_scenario("realistic")
assert sc.cost_realization(1) == pytest.approx(0.70)
assert sc.cost_realization(2) == 1.0
def test_platform_license_cost():
df = calculate_platform_license_cost(CTM_DEFAULT_SITES)
expected = CONTRACTED_NAMED_USERS * 111.28 * 12
assert df["annual_cost"].sum() == pytest.approx(expected)
def test_total_cost_default_scopes_runs_all_years():
for year in (1, 2, 3):
df = calculate_total_cost(
CTM_DEFAULT_SITES, CTM_DEFAULT_FEATURE_SCOPES,
DEFAULT_METERS, DEFAULT_PRICING, "realistic", year,
)
assert (df["annual_cost"] >= 0).all()
assert {"cost_line", "scope", "annual_cost", "confidence"} <= set(df.columns)

View File

@@ -0,0 +1,92 @@
"""Meter catalogue integrity."""
from __future__ import annotations
import pytest
from tokencalc.defaults import DEFAULT_METERS, DEFAULT_PRICING
from tokencalc.meters import Confidence, MeterType, TokenMeter, TokenPricing
def test_all_spec_meters_present():
expected = {
# Voice / Bot
"Voice Bot", "Digital Bot",
# Virtual Agent
"Virtual Agent (legacy)", "Agentic Virtual Agent",
# Agent Copilot (named + concurrent)
"Agent Copilot [named]", "Agent Copilot [concurrent]",
# AI Quality / Analytics
"AI Scoring", "AI Summary & Insights",
# Speech & Text Analytics (named + concurrent)
"Speech & Text Analytics [named]", "Speech & Text Analytics [concurrent]",
# Routing
"Predictive Routing",
# Messaging
"Direct Messaging", "Social Listening", "Social Responses",
# Language
"AI Translate",
# Genesys Cloud Copilot
"Genesys Cloud Copilot",
# Email AI (rate TBD; Auto-Suggest is inside Agent Copilot)
"Email AI (Auto-Respond)",
}
assert expected == set(DEFAULT_METERS)
def test_confirmed_rates():
m = DEFAULT_METERS
assert m["Voice Bot"].units_per_token == 17
assert m["Voice Bot"].tokens_per_unit == pytest.approx(0.0588, abs=1e-3)
assert m["Digital Bot"].units_per_token == 51
assert m["Agentic Virtual Agent"].tokens_per_unit == 1.2
assert m["AI Summary & Insights"].tokens_per_unit == 0.02
assert m["Direct Messaging"].units_per_token == 400
# Named variants
assert m["Speech & Text Analytics [named]"].tokens_per_unit == 30
assert m["Speech & Text Analytics [concurrent]"].tokens_per_unit == 45
assert m["Agent Copilot [named]"].tokens_per_unit == 40
assert m["Agent Copilot [concurrent]"].tokens_per_unit == 60
# AI Translate is now a confirmed consumption meter
assert m["AI Translate"].tokens_per_unit == 0.5
assert m["AI Translate"].units_per_token == 2
assert m["AI Translate"].confidence is Confidence.CONFIRMED
# New meters
assert m["AI Scoring"].units_per_token == 20
assert m["Predictive Routing"].units_per_token == 17
assert m["Genesys Cloud Copilot"].units_per_token == 20
def test_unknown_meters_flagged():
unknown = {f for f, m in DEFAULT_METERS.items() if m.confidence is Confidence.UNKNOWN}
assert unknown == {"Email AI (Auto-Respond)"}
assert Confidence.UNKNOWN.icon == "🔴"
assert Confidence.CONFIRMED.icon == "🟢"
def test_inverse_consistency_validated():
with pytest.raises(ValueError, match="not inverses"):
TokenMeter(
feature="Bad", meter_type=MeterType.PER_MINUTE,
units_per_token=10, tokens_per_unit=0.5,
confidence=Confidence.ESTIMATED, notes="",
)
def test_every_confirmed_meter_has_source_url():
for m in DEFAULT_METERS.values():
if m.confidence is Confidence.CONFIRMED:
assert m.source_url, f"{m.feature} missing source URL"
def test_pricing_effective_rate():
p = TokenPricing(region="US", list_rate_per_token=1.0,
contracted_rate_per_token=0.85)
assert p.effective_rate(use_contracted=False) == 1.0
assert p.effective_rate(use_contracted=True) == 0.85
# no contracted rate → falls back to list
assert DEFAULT_PRICING["US"].effective_rate(use_contracted=True) == 1.0
def test_all_regions_priced():
assert set(DEFAULT_PRICING) == {"US", "EU", "AU", "APAC"}

View File

@@ -0,0 +1,92 @@
"""Migration + WFM (no-AI) scenario — hand-check acceptance numbers."""
from __future__ import annotations
import pytest
from tokencalc import appendix4 as a4
from tokencalc import migration_wfm as mw
from tokencalc.defaults import CTM_DEFAULT_SITES
SITES = list(CTM_DEFAULT_SITES)
def _default_benefit_rollout():
_, _, benefit_rollout = a4.build_rollouts(SITES)
return benefit_rollout
def test_wfm_scope_and_verbatim_total():
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
assert set(ben["capability"]) == {"WFM"}
assert set(ben["region"]) == set(mw.DEFAULT_WFM_REGIONS)
assert "EMEA" not in set(ben["region"]), "EMEA WFM is out of scope"
# NA $0 (migration) + ANZ $1.4M + ASIA $914K — verbatim, exact.
assert ben["benefit"].sum() == pytest.approx(2_314_000)
def test_wfm_phasing_on_deck_schedule():
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
by_year = ben.groupby("year")["benefit"].sum()
assert by_year[2026] == 0.0
# ANZ realizes Dec 2027 (1 of 13 live months lands in 2027).
assert by_year[2027] == pytest.approx(1_400_000 / 13)
assert by_year[2028] == pytest.approx(2_314_000 - 1_400_000 / 13)
def test_runrate_saving_annual():
# (7.3M 4.3M) licence + 1.3M ANZ + 1.6M ASIA + 0 NA = 5.9M.
assert mw.wfm_annual_runrate() == pytest.approx(2_900_000)
assert mw.runrate_saving_annual() == pytest.approx(5_900_000)
assert mw.runrate_saving_annual(regions=["NA"]) == pytest.approx(3_000_000)
assert mw.runrate_saving_annual(licence_annual=4_800_000,
regions=[]) == pytest.approx(2_500_000)
# Contracted frame: managed services stay in the run-rate forever.
assert mw.runrate_saving_annual(
a4.tco("ccaas_annual"), managed_annual=a4.MANAGED_SERVICES_ANNUAL
) == pytest.approx(6_589_081.60)
def _default_wfm_benefits_by_year():
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
return {y: float(ben.loc[ben.year == y, "benefit"].sum()) for y in a4.YEARS}
def test_breakeven_extrapolates_past_window():
# Deck frame: deck licence rate (6-month ramp), verbatim PS lump,
# no managed services.
cs = a4.current_state_inputs(SITES)
cur = a4.current_costs_by_year(cs)
lic = a4.licence_costs_by_year()
ps = a4.ps_costs_by_year()
total = {y: cur[y] + lic[y] + ps[y] for y in a4.YEARS}
inc, net = a4.case_flows(total, _default_wfm_benefits_by_year())
assert sum(net.values()) == pytest.approx(-3_703_000, abs=1_000)
label = mw.runrate_breakeven_label(net, mw.runrate_saving_annual())
assert label == "44 months (~Aug 2029, extrapolated)"
def test_contracted_frame_with_sow_and_managed_services():
# Contracted frame: signed licence rate, SOW PS milestones, managed
# services from MCX go-live — the case the notebook leads with.
cs = a4.current_state_inputs(SITES)
cur = a4.current_costs_by_year(cs)
lic = a4.licence_costs_by_year(annual=a4.tco("ccaas_annual"))
ps = a4.ps_costs_by_year(contracted=True)
man = a4.managed_services_by_year()
total = {y: cur[y] + lic[y] + ps[y] + man[y] for y in a4.YEARS}
inc, net = a4.case_flows(total, _default_wfm_benefits_by_year())
assert sum(net.values()) == pytest.approx(-1_503_013, abs=1_000)
runrate = mw.runrate_saving_annual(
a4.tco("ccaas_annual"), managed_annual=a4.MANAGED_SERVICES_ANNUAL)
label = mw.runrate_breakeven_label(net, runrate)
assert label == "39 months (~Mar 2029, extrapolated)"
def test_breakeven_defers_in_window_and_guards_zero_runrate():
positive = {2026: 1_000_000.0, 2027: 0.0, 2028: 0.0}
assert mw.runrate_breakeven_label(positive, 5_900_000) == \
a4.payback_label(positive)
negative = {2026: -1_000_000.0, 2027: 0.0, 2028: 0.0}
assert mw.runrate_breakeven_label(negative, 0.0) == \
"never at current run-rate"

View File

@@ -0,0 +1,15 @@
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
from tokencalc import staging
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
assert not staging.on_stage()
staging.backstage("visible")
assert capsys.readouterr().out == "visible\n"
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
assert staging.on_stage()
staging.backstage("hidden")
assert capsys.readouterr().out == ""

View File

@@ -0,0 +1,77 @@
"""
tokencalc — Genesys AI token cost & business case calculator core.
Pure-Python, UI-agnostic. The notebooks (served interactively with
Mercury) are thin presentation layers over these functions.
"""
from .benefit_model import calculate_total_benefit
from .business_case import build_business_case, npv, payback_years
from .cost_model import (
calculate_consumption_ai_cost,
calculate_per_user_ai_cost,
calculate_platform_license_cost,
calculate_total_cost,
)
from .defaults import (
CONTRACTED_NAMED_USERS,
CTM_DEFAULT_FEATURE_SCOPES,
CTM_DEFAULT_ROLLOUT,
CTM_DEFAULT_SITES,
CTM_DEFAULT_TAKEOUTS,
DEFAULT_METERS,
DEFAULT_PRICING,
PLATFORM_RATE_PER_USER_MONTHLY,
)
from .rollout import NO_ROLLOUT, RolloutPlan
from .exports import (
export_csv,
export_excel,
meters_dataframe,
scenario_state_from_json,
scenario_state_to_json,
sites_dataframe,
)
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
from .scenarios import BENEFIT_PARAMS, SCENARIOS, Scenario, get_scenario
__version__ = "0.1.0"
__all__ = [
"BENEFIT_PARAMS",
"CONTRACTED_NAMED_USERS",
"CTM_DEFAULT_FEATURE_SCOPES",
"CTM_DEFAULT_ROLLOUT",
"CTM_DEFAULT_SITES",
"CTM_DEFAULT_TAKEOUTS",
"Confidence",
"CostTakeout",
"DEFAULT_METERS",
"DEFAULT_PRICING",
"FeatureScope",
"MeterType",
"NO_ROLLOUT",
"PLATFORM_RATE_PER_USER_MONTHLY",
"RolloutPlan",
"SCENARIOS",
"Scenario",
"SiteInput",
"TokenMeter",
"TokenPricing",
"build_business_case",
"calculate_consumption_ai_cost",
"calculate_per_user_ai_cost",
"calculate_platform_license_cost",
"calculate_total_benefit",
"calculate_total_cost",
"export_csv",
"export_excel",
"get_scenario",
"meters_dataframe",
"npv",
"payback_years",
"scenario_state_from_json",
"scenario_state_to_json",
"sites_dataframe",
]

View File

@@ -0,0 +1,577 @@
"""
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)", "date": dt.date(2026, 9, 30),
"share": 0.20, "amount": 405_089.30},
{"name": "Start of client UAT (last region)", "date": dt.date(2027, 6, 30),
"share": 0.30, "amount": 607_633.94},
{"name": "Completion of last go-live migration", "date": dt.date(2027, 9, 30),
"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, 9, 30)
def tco(key: str) -> float:
"""Contracted value where one exists, else the deck's verbatim anchor."""
return TCO_CONTRACTED.get(key, TCO_VERBATIM[key])
#: 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 = 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 (Sep 30 → Oct),
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;")

View File

@@ -0,0 +1,429 @@
"""
Benefit calculation engine.
All benefits convert saved handle-time seconds into dollars via each
site's fully-loaded labour rate per working second. Reduction
percentages come from :data:`tokencalc.scenarios.BENEFIT_PARAMS` —
``realistic`` (pressure-tested) by default; pass ``params="claim"``
to reproduce the Genesys ROI-doc figures for side-by-side comparison.
Every figure scales by the scenario's year realization ramp.
"""
from __future__ import annotations
import pandas as pd
from .inputs import FeatureScope, SiteInput
from .meters import Confidence
from .rollout import NO_ROLLOUT, RolloutPlan
from .scenarios import BENEFIT_PARAMS, Scenario, get_scenario
MONTHS_PER_YEAR = 12
def _param(name: str, params: str) -> float:
return BENEFIT_PARAMS[name][params]
def _scope_for(feature_scopes: list[FeatureScope] | FeatureScope,
feature: str) -> FeatureScope | None:
if isinstance(feature_scopes, FeatureScope):
return feature_scopes if feature_scopes.feature == feature else None
return next((s for s in feature_scopes if s.feature == feature), None)
def _df(rows: list[dict]) -> pd.DataFrame:
return pd.DataFrame(
rows, columns=["benefit_line", "scope", "annual_value", "confidence"]
)
def calculate_voice_handle_time_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""AHT reduction from knowledge surfacing (Agent Copilot).
Benefit = volume × eligibility × AHT × reduction% × labour rate.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
reduction = _param("voice_aht_knowledge_reduction", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
eligibility = (
feature_scope.eligibility_pct
if feature_scope.eligibility_pct is not None
else sc.voice_knowledge_eligibility
)
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* eligibility * s.voice_aht_seconds * reduction * realization
)
rows.append(
{
"benefit_line": "Voice AHT (knowledge surfacing)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_acw_summarization_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""ACW eliminated by auto-summarization (Copilot / AI Summary)."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
reduction = _param("voice_acw_reduction", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
eligibility = (
feature_scope.eligibility_pct
if feature_scope.eligibility_pct is not None
else sc.voice_summarization_eligibility
)
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* eligibility * s.voice_acw_seconds * reduction * realization
)
rows.append(
{
"benefit_line": "Voice ACW (summarization)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_email_ai_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Email Auto-Respond — full displacement at the respond rate.
(Email Auto-Suggest is not a separate benefit line: it is included
in Agent Copilot, whose per-user meter carries the drafting help.)"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
respond_rate = (
feature_scope.deflection_target
if feature_scope.deflection_target is not None
else sc.email_auto_respond_rate
)
annual_emails = s.email_volume_monthly * MONTHS_PER_YEAR
respond_seconds = (
annual_emails * respond_rate * s.email_aht_seconds * realization
)
rate = s.agent_cost_per_second
rows.append(
{
"benefit_line": "Email Auto-Respond (displaced handling)",
"scope": s.site_name,
"annual_value": respond_seconds * rate
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.UNKNOWN.value, # meter rate unsourced
}
)
return _df(rows)
def calculate_sta_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""STA reduces AHT *indirectly* via coaching — small reduction with
a realistic ramp (default 1.5% vs the 4% claim)."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
reduction = _param("sta_aht_reduction", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* s.voice_aht_seconds * reduction * realization
)
rows.append(
{
"benefit_line": "STA coaching (AHT)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_va_deflection_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Agent labour avoided on calls deflected to Voice Bot or Agentic VA.
**Layered (sequential) deflection model** — Voice Bot runs first on
the full call pool; Agentic VA handles a share of the *residual*
(calls the bot did not deflect). The two mechanisms are substitutes
operating on the same call base, not independent additive benefits.
Effective total deflection:
bot_rate + (1 bot_rate) × va_rate
e.g. 35% + 65% × 15% = 44.75% (not 50%)
**Three realization haircuts** are applied to convert raw deflected
volume into realizable labour savings:
1. ``completion_rate`` — share of "deflected" calls that don't
escalate to an agent mid-session (bot/VA fully handles the call).
2. ``labour_realization`` — staffing flexibility factor; deflected
volume doesn't reduce headcount 1:1 due to minimums, shrinkage,
and occupancy ceilings.
3. ``callback_discount`` — fraction of deflected calls that re-enter
as repeat contacts (poorly-handled deflections drive callbacks).
Combined realistic factor: 0.70 × 0.80 × (1 0.05) ≈ 0.53
The ``params="claim"`` path sets all three factors to their
``claim`` values (1.0 / 1.0 / 0.0) to reproduce the original
Genesys ROI-doc figures for side-by-side comparison.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
realization = sc.realization(year)
# Realization haircuts — read from BENEFIT_PARAMS so claim/realistic
# paths are consistent with all other benefit lines.
completion_rate = _param("va_completion_rate", params)
labour_real = _param("va_labour_realization", params)
callback_disc = _param("va_callback_discount", params)
realization_factor = completion_rate * labour_real * (1.0 - callback_disc)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
if feature_scope.feature == "Voice Bot":
# Bot operates on the full call pool.
bot_rate = (
feature_scope.deflection_target
if feature_scope.deflection_target is not None
else sc.voice_bot_deflection
)
deflected_calls = s.voice_volume_monthly * MONTHS_PER_YEAR * bot_rate
else: # Agentic Virtual Agent
# VA operates on the residual after the bot has deflected its share.
# If Voice Bot is not in scope (VA-only deployment), bot_rate = 0
# and the VA works on the full pool — still correct.
bot_rate = sc.voice_bot_deflection
va_rate = (
feature_scope.deflection_target
if feature_scope.deflection_target is not None
else sc.agentic_va_deflection
)
residual_calls = (
s.voice_volume_monthly * MONTHS_PER_YEAR * (1.0 - bot_rate)
)
deflected_calls = residual_calls * va_rate
seconds_saved = deflected_calls * s.voice_aht_seconds * realization
rows.append(
{
"benefit_line": f"{feature_scope.feature} deflection (labour avoided)",
"scope": s.site_name,
"annual_value": (
seconds_saved
* s.agent_cost_per_second
* realization_factor
* ro.fraction_live(s.site_name, year)
),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_supervisor_copilot_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Supervisor time reclaimed (summaries, QA triage). ESTIMATED."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
saving = _param("supervisor_copilot_time_saving", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
rows.append(
{
"benefit_line": "Supervisor time (AI summaries/insights)",
"scope": s.site_name,
"annual_value": s.supervisors
* s.fully_loaded_supervisor_cost_annual
* saving * realization
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
def calculate_predictive_routing_benefit(
sites: list[SiteInput],
feature_scope: FeatureScope,
scenario: str | Scenario,
year: int,
params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Predictive routing AHT effect. ESTIMATED; off unless scoped."""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
ro = rollout or NO_ROLLOUT
reduction = _param("predictive_routing_aht_reduction", params)
realization = sc.realization(year)
rows = []
for s in sites:
if not feature_scope.active(s.site_name, year):
continue
seconds_saved = (
s.voice_volume_monthly * MONTHS_PER_YEAR
* s.voice_aht_seconds * reduction * realization
)
rows.append(
{
"benefit_line": "Predictive routing (AHT)",
"scope": s.site_name,
"annual_value": seconds_saved * s.agent_cost_per_second
* ro.fraction_live(s.site_name, year),
"confidence": Confidence.ESTIMATED.value,
}
)
return _df(rows)
#: Which calculator handles which feature scope.
#: Agent Copilot and STA exist in named/concurrent variants — both map
#: to the same benefit calculators.
#: Voice Bot and Agentic VA both route to calculate_va_deflection_benefit,
#: which implements the layered sequential model — VA operates on the
#: residual after the bot has deflected its share.
_BENEFIT_DISPATCH = {
"Agent Copilot [named]": (
calculate_voice_handle_time_benefit,
calculate_acw_summarization_benefit,
),
"Agent Copilot [concurrent]": (
calculate_voice_handle_time_benefit,
calculate_acw_summarization_benefit,
),
"AI Summary & Insights": (), # benefit carried by Copilot where present
"Speech & Text Analytics [named]": (calculate_sta_benefit,),
"Speech & Text Analytics [concurrent]": (calculate_sta_benefit,),
"Voice Bot": (calculate_va_deflection_benefit,),
"Agentic Virtual Agent": (calculate_va_deflection_benefit,),
"Predictive Routing": (calculate_predictive_routing_benefit,),
}
_COPILOT_FEATURES = {"Agent Copilot [named]", "Agent Copilot [concurrent]"}
def calculate_total_benefit(
sites: list[SiteInput],
feature_scopes: list[FeatureScope],
scenario: str | Scenario,
year: int,
params: str = "realistic",
include_supervisor_benefit: bool = True,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""All benefit lines for one scenario-year, aggregated per line.
Returns DataFrame: benefit_line, scope, annual_value, confidence.
Voice Bot and Agentic VA deflection benefits use the layered
sequential model: the bot deflects from the full call pool; the VA
deflects from the residual. The two features are NOT additive on
the same base — see :func:`calculate_va_deflection_benefit`.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
frames: list[pd.DataFrame] = []
# Find whichever Copilot variant is in scope (named or concurrent).
copilot_scope = next(
(s for s in feature_scopes if s.feature in _COPILOT_FEATURES), None
)
for scope in feature_scopes:
for fn in _BENEFIT_DISPATCH.get(scope.feature, ()): # type: ignore[arg-type]
frames.append(fn(sites, scope, sc, year, params=params, rollout=rollout))
if include_supervisor_benefit and copilot_scope is not None:
frames.append(
calculate_supervisor_copilot_benefit(
sites, copilot_scope, sc, year, params=params, rollout=rollout
)
)
frames = [f for f in frames if not f.empty]
if not frames:
return _df([])
detail = pd.concat(frames, ignore_index=True)
grouped = (
detail.groupby("benefit_line", sort=False)
.agg(
scope=("scope", lambda v: ", ".join(sorted(set(v)))),
annual_value=("annual_value", "sum"),
confidence=("confidence", "first"),
)
.reset_index()
)
return grouped[["benefit_line", "scope", "annual_value", "confidence"]]

View File

@@ -0,0 +1,188 @@
"""
Business case — combines costs, benefits, and cost takeouts into a
3-year net view with NPV, payback, and ROI.
Convention: all cashflows are year-end and discounted at
``discount_rate`` (default 8%); there is no undiscounted year-0 column
— implementation is amortized straight-line across the analysis years
(spec §5.6 "Implementation amort." line).
"""
from __future__ import annotations
import pandas as pd
from .benefit_model import calculate_total_benefit
from .cost_model import calculate_total_cost
from .defaults import (
DEFAULT_DISCOUNT_RATE,
DEFAULT_IMPLEMENTATION_COST,
PLATFORM_RATE_PER_USER_MONTHLY,
)
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, TokenMeter, TokenPricing
from .rollout import RolloutPlan
from .scenarios import Scenario, get_scenario
def npv(cashflows_by_year: list[float], discount_rate: float) -> float:
"""Year-end-discounted NPV of year-1..N cashflows."""
return sum(
cf / (1 + discount_rate) ** year
for year, cf in enumerate(cashflows_by_year, start=1)
)
def payback_years(cashflows_by_year: list[float]) -> float | None:
"""First (fractional) year cumulative net turns >= 0; None if never.
Cashflows are assumed evenly spread within each year.
"""
cumulative = 0.0
for year, cf in enumerate(cashflows_by_year, start=1):
if cumulative + cf >= 0 and cf != 0:
if cumulative >= 0:
return float(year - 1)
return (year - 1) + (-cumulative / cf)
cumulative += cf
return None
def build_business_case(
sites: list[SiteInput],
feature_scopes: list[FeatureScope],
meters: dict[str, TokenMeter],
pricing: dict[str, TokenPricing],
takeouts: list[CostTakeout],
scenario: str | Scenario,
years: int = 3,
discount_rate: float = DEFAULT_DISCOUNT_RATE,
platform_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
implementation_cost: float = DEFAULT_IMPLEMENTATION_COST,
use_contracted: bool = False,
benefit_params: str = "realistic",
rollout: RolloutPlan | None = None,
) -> dict:
"""Returns the dict described in spec §4.3 (DataFrames + headline
metrics). Every number traces to a cost line, benefit line, or
takeout row in the per-year detail frames.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
year_cols = [f"Y{y}" for y in range(1, years + 1)]
cost_frames, benefit_frames = {}, {}
for y in range(1, years + 1):
cost_frames[y] = calculate_total_cost(
sites, feature_scopes, meters, pricing, sc, y,
platform_rate=platform_rate, use_contracted=use_contracted,
rollout=rollout,
)
benefit_frames[y] = calculate_total_benefit(
sites, feature_scopes, sc, y, params=benefit_params,
rollout=rollout,
)
# ── cost_by_year: one row per cost line, one column per year ────
cost_lines = list(cost_frames[1]["cost_line"])
cost_by_year = pd.DataFrame({"line": cost_lines})
for y in range(1, years + 1):
cost_by_year[f"Y{y}"] = list(cost_frames[y]["annual_cost"])
cost_by_year["confidence"] = list(cost_frames[1]["confidence"])
if implementation_cost:
amort = implementation_cost / years
cost_by_year = pd.concat(
[
cost_by_year,
pd.DataFrame(
[
{
"line": "Implementation (amortized)",
**{c: amort for c in year_cols},
"confidence": Confidence.ESTIMATED.value,
}
]
),
],
ignore_index=True,
)
# ── benefit_by_year ──────────────────────────────────────────────
benefit_lines: list[str] = []
for y in range(1, years + 1):
for line in benefit_frames[y]["benefit_line"]:
if line not in benefit_lines:
benefit_lines.append(line)
benefit_by_year = pd.DataFrame({"line": benefit_lines})
for y in range(1, years + 1):
lookup = dict(
zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["annual_value"])
)
benefit_by_year[f"Y{y}"] = [lookup.get(line, 0.0) for line in benefit_lines]
conf_lookup: dict[str, str] = {}
for y in range(1, years + 1):
conf_lookup.update(
dict(zip(benefit_frames[y]["benefit_line"], benefit_frames[y]["confidence"]))
)
benefit_by_year["confidence"] = [
conf_lookup.get(line, Confidence.ESTIMATED.value) for line in benefit_lines
]
# ── takeouts_by_year ─────────────────────────────────────────────
takeouts_by_year = pd.DataFrame(
[
{
"line": t.name,
**{f"Y{y}": t.value_in_year(y) for y in range(1, years + 1)},
"confidence": t.confidence.value,
}
for t in takeouts
]
)
# ── net + cumulative ─────────────────────────────────────────────
total_costs = [float(cost_by_year[c].sum()) for c in year_cols]
total_benefits = [float(benefit_by_year[c].sum()) for c in year_cols]
total_takeouts = [
float(takeouts_by_year[c].sum()) if not takeouts_by_year.empty else 0.0
for c in year_cols
]
net = [
b + t - c for b, t, c in zip(total_benefits, total_takeouts, total_costs)
]
cumulative = pd.Series(net).cumsum().tolist()
net_by_year = pd.DataFrame(
{
"line": [
"TOTAL COSTS", "TOTAL TAKEOUTS", "TOTAL BENEFITS",
"NET", "Cumulative net",
],
**{
f"Y{y}": [
total_costs[y - 1], total_takeouts[y - 1],
total_benefits[y - 1], net[y - 1], cumulative[y - 1],
]
for y in range(1, years + 1)
},
}
)
cumulative_net = pd.DataFrame(
{"year": list(range(1, years + 1)), "cumulative_net": cumulative}
)
total_cost_sum = sum(total_costs)
total_value_sum = sum(total_benefits) + sum(total_takeouts)
return {
"cost_by_year": cost_by_year,
"benefit_by_year": benefit_by_year,
"takeouts_by_year": takeouts_by_year,
"net_by_year": net_by_year,
"cumulative_net": cumulative_net,
"npv": npv(net, discount_rate),
"payback_period_years": payback_years(net),
"roi_3yr": (
(total_value_sum - total_cost_sum) / total_cost_sum
if total_cost_sum
else None
),
}

View File

@@ -0,0 +1,317 @@
"""
Cost calculation engine.
Correctness rules implemented here (see spec §4.1):
1. **Agent Copilot covers Supervisor AI Summary.** Where Agent Copilot
is enabled at a site, AI Summary & Insights consumption at that site
is forced to zero — Copilot's per-user token rate already includes
interaction summarization. Source: Genesys Cloud AI Experience
token metering,
https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
2. **Token rounding.** Genesys rounds consumption up at billing —
``math.ceil`` is applied to each site's MONTHLY consumption token
total before the rate. Per-user totals (users × tokens/user/month)
are exact and not rounded.
3. **Regional pricing.** Every site resolves its rate through its
``region_pricing`` key — never a hardcoded US rate.
4. **Adoption ramp.** Consumption features ramp (default Y1 = 70%);
per-user licences are paid in full from their phase year.
"""
from __future__ import annotations
import math
import pandas as pd
from .defaults import PLATFORM_RATE_PER_USER_MONTHLY
from .inputs import FeatureScope, SiteInput
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
from .rollout import NO_ROLLOUT, RolloutPlan
from .scenarios import Scenario, get_scenario
MONTHS_PER_YEAR = 12
def _rate(site: SiteInput, pricing: dict[str, TokenPricing],
use_contracted: bool = False) -> float:
"""Resolve the per-token rate for a site's pricing region."""
region = pricing.get(site.region_pricing)
if region is None:
raise KeyError(
f"No TokenPricing for region {site.region_pricing!r} "
f"(site {site.site_name})"
)
return region.effective_rate(use_contracted)
def calculate_platform_license_cost(
sites: list[SiteInput],
per_user_monthly_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
year: int = 1,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Genesys Cloud CX 3 named-user platform licences.
The commit bills in full from contract start regardless of site
go-lives; the vendor ramp credit reduces YEAR 1 only (typical
6-month ramp → 50% Y1 discount).
Returns DataFrame: site, agents, supervisors, named_users, annual_cost.
"""
ro = rollout or NO_ROLLOUT
factor = ro.platform_factor(year)
rows = [
{
"site": s.site_name,
"agents": s.agents,
"supervisors": s.supervisors,
"named_users": s.named_users,
"annual_cost": s.named_users
* per_user_monthly_rate
* MONTHS_PER_YEAR
* factor,
}
for s in sites
]
return pd.DataFrame(rows)
def calculate_per_user_ai_cost(
sites: list[SiteInput],
feature_scope: FeatureScope,
meter: TokenMeter,
pricing: dict[str, TokenPricing],
year: int = 1,
use_contracted: bool = False,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Per-user-per-month AI features (STA, Agent Copilot, AI Translate).
No adoption ramp and no rounding (users × tokens/user/month is
exact) — but token usage only starts at site go-live, so the year
bills for the months the site is live (``rollout``).
Returns DataFrame: site, users_in_scope, tokens_monthly, annual_cost.
"""
if meter.meter_type is not MeterType.PER_USER_PER_MONTH:
raise ValueError(f"{meter.feature} is not a per-user meter")
ro = rollout or NO_ROLLOUT
rows = []
for s in sites:
in_scope = feature_scope.active(s.site_name, year)
users = s.named_users if in_scope else 0
live_months = ro.live_months_in_year(s.site_name, year)
tokens_monthly = users * meter.tokens_per_unit
rows.append(
{
"site": s.site_name,
"users_in_scope": users,
"tokens_monthly": tokens_monthly,
"annual_cost": tokens_monthly
* live_months
* _rate(s, pricing, use_contracted),
}
)
return pd.DataFrame(rows)
def _monthly_units(site: SiteInput, feature: str, scope: FeatureScope,
scenario: Scenario) -> float:
"""Monthly metered units for a consumption feature at one site.
Explicit ``scope.deflection_target`` / ``scope.eligibility_pct``
override the scenario defaults.
"""
if feature == "Voice Bot":
deflection = (
scope.deflection_target
if scope.deflection_target is not None
else scenario.voice_bot_deflection
)
return (
site.voice_volume_monthly * deflection * scenario.voice_bot_avg_minutes
) # minutes
if feature == "Agentic Virtual Agent":
# Layered model: VA operates on the residual volume after the voice bot
# has already deflected its share. Cost base = residual × va_rate.
# This is consistent with the benefit model and avoids double-counting
# the same call pool across both deflection mechanisms.
bot_deflection = scenario.voice_bot_deflection
va_deflection = (
scope.deflection_target
if scope.deflection_target is not None
else scenario.agentic_va_deflection
)
residual = site.voice_volume_monthly * (1.0 - bot_deflection)
return residual * va_deflection # interactions
if feature == "Virtual Agent (legacy)":
deflection = scope.deflection_target or 0.0
return site.voice_volume_monthly * deflection
if feature == "AI Summary & Insights":
eligibility = (
scope.eligibility_pct
if scope.eligibility_pct is not None
else scenario.voice_summarization_eligibility
)
return site.voice_volume_monthly * eligibility # summaries
if feature == "Email AI (Auto-Respond)":
rate = (
scope.deflection_target
if scope.deflection_target is not None
else scenario.email_auto_respond_rate
)
return site.email_volume_monthly * rate # messages
if feature in ("Direct Messaging", "Social Listening", "Social Responses"):
eligibility = scope.eligibility_pct if scope.eligibility_pct is not None else 1.0
return (site.chat_volume_monthly + site.sms_volume_monthly) * eligibility
if feature == "AI Translate":
# Each voice interaction generates one translation; eligibility_pct
# can be used to scope to a subset of interactions (e.g. non-English only).
eligibility = scope.eligibility_pct if scope.eligibility_pct is not None else 1.0
return site.voice_volume_monthly * eligibility # translations
if feature == "Predictive Routing":
# Every predictively-routed voice interaction consumes the PR meter;
# eligibility_pct scopes to the share of volume on PR-enabled queues.
eligibility = scope.eligibility_pct if scope.eligibility_pct is not None else 1.0
return site.voice_volume_monthly * eligibility # routed interactions
raise KeyError(f"No consumption-volume mapping for feature {feature!r}")
def calculate_consumption_ai_cost(
sites: list[SiteInput],
feature_scope: FeatureScope,
meter: TokenMeter,
scenario: str | Scenario,
pricing: dict[str, TokenPricing],
year: int = 1,
use_contracted: bool = False,
excluded_sites: set[str] | None = None,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""Consumption-metered AI features (Voice Bots, Agentic VA,
Supervisor AI Summary, Email Auto-Respond, messaging meters).
Applies eligibility/deflection from the scenario (or explicit scope
overrides), the adoption ramp, billing-style ``ceil`` rounding on
each site's monthly token total, and — with a ``rollout`` — bills
only the months the site is live (usage starts at go-live).
``excluded_sites`` supports the Copilot-covers-Summary rule.
Returns DataFrame: site, eligible_volume, tokens_monthly, annual_cost.
"""
if meter.meter_type is MeterType.PER_USER_PER_MONTH:
raise ValueError(f"{meter.feature} is a per-user meter, not consumption")
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
excluded = excluded_sites or set()
ro = rollout or NO_ROLLOUT
# Ramp: an explicit adoption curve wins; otherwise the scenario's
# default consumption realization (Y1 = 70%). This models usage
# maturity; rollout live-months model calendar availability — they
# compound (live 6 months × 70% maturity).
ramp = (
feature_scope.adoption(year)
if feature_scope.adoption_curve
else sc.cost_realization(year)
)
rows = []
for s in sites:
active = (
feature_scope.active(s.site_name, year)
and s.site_name not in excluded
)
units = _monthly_units(s, meter.feature, feature_scope, sc) if active else 0.0
units *= ramp
live_months = ro.live_months_in_year(s.site_name, year)
# Rule 2: round each site's monthly token total UP (billing).
tokens_monthly = math.ceil(units * meter.tokens_per_unit) if units > 0 else 0
rows.append(
{
"site": s.site_name,
"eligible_volume": units,
"tokens_monthly": tokens_monthly,
"annual_cost": tokens_monthly
* live_months
* _rate(s, pricing, use_contracted),
}
)
return pd.DataFrame(rows)
def calculate_total_cost(
sites: list[SiteInput],
feature_scopes: list[FeatureScope],
meters: dict[str, TokenMeter],
pricing: dict[str, TokenPricing],
scenario: str | Scenario,
year: int,
platform_rate: float = PLATFORM_RATE_PER_USER_MONTHLY,
use_contracted: bool = False,
include_platform: bool = True,
rollout: RolloutPlan | None = None,
) -> pd.DataFrame:
"""All cost lines for one scenario-year.
Returns DataFrame: cost_line, scope, annual_cost, confidence.
"""
sc = get_scenario(scenario) if isinstance(scenario, str) else scenario
rows: list[dict] = []
if include_platform:
platform = calculate_platform_license_cost(
sites, platform_rate, year=year, rollout=rollout
)
ramped = rollout is not None and rollout.platform_factor(year) < 1.0
rows.append(
{
"cost_line": "Genesys CX 3 platform licences"
+ (" (ramp credit applied)" if ramped else ""),
"scope": "all sites",
"annual_cost": float(platform["annual_cost"].sum()),
"confidence": Confidence.CONFIRMED.value,
}
)
# Rule 1: Agent Copilot covers Supervisor AI Summary. Sites where
# Copilot is active this year are excluded from AI Summary billing —
# Copilot's per-user token rate already includes interaction summarization.
# https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
_COPILOT_FEATURES = {"Agent Copilot [named]", "Agent Copilot [concurrent]"}
copilot_sites: set[str] = set()
for scope in feature_scopes:
if scope.feature in _COPILOT_FEATURES:
copilot_sites |= {
s.site_name for s in sites if scope.active(s.site_name, year)
}
for scope in feature_scopes:
meter = meters.get(scope.feature)
if meter is None:
raise KeyError(f"No meter defined for feature {scope.feature!r}")
if meter.meter_type is MeterType.PER_USER_PER_MONTH:
df = calculate_per_user_ai_cost(
sites, scope, meter, pricing, year=year,
use_contracted=use_contracted, rollout=rollout,
)
in_scope = df[df["users_in_scope"] > 0]["site"].tolist()
else:
excluded = (
copilot_sites if scope.feature == "AI Summary & Insights" else None
)
df = calculate_consumption_ai_cost(
sites, scope, meter, sc, pricing, year=year,
use_contracted=use_contracted, excluded_sites=excluded,
rollout=rollout,
)
in_scope = df[df["annual_cost"] > 0]["site"].tolist()
rows.append(
{
"cost_line": scope.feature,
"scope": ", ".join(in_scope) if in_scope else "",
"annual_cost": float(df["annual_cost"].sum()),
"confidence": meter.confidence.value,
}
)
return pd.DataFrame(rows)

View File

@@ -0,0 +1,423 @@
"""
CTM default inputs and the Genesys meter catalogue.
⚠️ Site volumes/AHTs/costs outside NAM are PLACEHOLDERS flagged
ESTIMATED — confirm with CTM data before client use. NAM volumes are
from the CTM discovery pack. Named users across all sites total the
contracted licence count (2,088).
"""
from __future__ import annotations
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, MeterType, TokenMeter, TokenPricing
from .rollout import RolloutPlan
# ── Platform ─────────────────────────────────────────────────────────
#: Genesys Cloud CX 3 named-user list rate, USD/user/month.
#: Source: Genesys Cloud public pricing (CX 3 tier), planning figure.
PLATFORM_RATE_PER_USER_MONTHLY = 111.28
#: CTM contracted named-user count — UI warns when site totals diverge.
CONTRACTED_NAMED_USERS = 2_088
#: Business-case discount rate (CTM treasury planning assumption).
DEFAULT_DISCOUNT_RATE = 0.08
#: One-off implementation estimate, amortized straight-line over the
#: analysis horizon in the P&L. ESTIMATED — confirm with delivery team.
DEFAULT_IMPLEMENTATION_COST = 0.0
_GENESYS_TOKEN_METERS = (
"https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
)
# ── Token meters ─────────────────────────────────────────────────────
# Rates per the published Genesys AI Experience token tables unless
# flagged otherwise. UNKNOWN meters carry working defaults (clearly
# labelled) so the model still produces a range.
DEFAULT_METERS: dict[str, TokenMeter] = {
m.feature: m
for m in [
# ── Voice / Bot ───────────────────────────────────────────────
TokenMeter(
feature="Voice Bot",
meter_type=MeterType.PER_MINUTE,
units_per_token=17.0,
tokens_per_unit=1 / 17, # 0.0588
confidence=Confidence.CONFIRMED,
notes="IVR self-service voice bot minutes; 17 min per token.",
source_url=_GENESYS_TOKEN_METERS,
),
TokenMeter(
feature="Digital Bot",
meter_type=MeterType.PER_INTERACTION,
units_per_token=51.0,
tokens_per_unit=1 / 51, # 0.0196
confidence=Confidence.CONFIRMED,
notes="Digital (non-voice) bot sessions; 51 sessions per token.",
source_url=_GENESYS_TOKEN_METERS,
),
# ── Virtual Agent ─────────────────────────────────────────────
TokenMeter(
feature="Virtual Agent (legacy)",
meter_type=MeterType.PER_INTERACTION,
units_per_token=2.0,
tokens_per_unit=0.5,
confidence=Confidence.CONFIRMED,
notes="Legacy (non-agentic) virtual agent; 0.5 tokens per interaction.",
source_url=_GENESYS_TOKEN_METERS,
),
TokenMeter(
feature="Agentic Virtual Agent",
meter_type=MeterType.PER_INTERACTION,
units_per_token=0.833,
tokens_per_unit=1.2,
confidence=Confidence.CONFIRMED,
notes="Agentic VA; 1.2 tokens per interaction.",
source_url=_GENESYS_TOKEN_METERS,
),
# ── Agent Copilot (named vs concurrent) ───────────────────────
TokenMeter(
feature="Agent Copilot [named]",
meter_type=MeterType.PER_USER_PER_MONTH,
units_per_token=0.0,
tokens_per_unit=40.0,
confidence=Confidence.CONFIRMED,
notes=(
"40 tokens per named user per month. Includes interaction "
"summarization (covers AI Summary & Insights)."
),
source_url=_GENESYS_TOKEN_METERS,
),
TokenMeter(
feature="Agent Copilot [concurrent]",
meter_type=MeterType.PER_USER_PER_MONTH,
units_per_token=0.0,
tokens_per_unit=60.0,
confidence=Confidence.CONFIRMED,
notes=(
"60 tokens per concurrent user per month. Includes interaction "
"summarization (covers AI Summary & Insights)."
),
source_url=_GENESYS_TOKEN_METERS,
),
# ── AI Quality / Analytics ────────────────────────────────────
TokenMeter(
feature="AI Scoring",
meter_type=MeterType.PER_INTERACTION,
units_per_token=20.0,
tokens_per_unit=0.05,
confidence=Confidence.CONFIRMED,
notes="AI-scored quality evaluations; 20 evaluations per token.",
source_url=_GENESYS_TOKEN_METERS,
),
TokenMeter(
feature="AI Summary & Insights",
meter_type=MeterType.PER_SUMMARY,
units_per_token=50.0,
tokens_per_unit=0.02,
confidence=Confidence.CONFIRMED,
notes=(
"Supervisor standalone summarization; 50 summaries per token. "
"NOT metered where Agent Copilot is assigned — see cost model."
),
source_url=_GENESYS_TOKEN_METERS,
),
# ── Speech & Text Analytics (named vs concurrent) ─────────────
TokenMeter(
feature="Speech & Text Analytics [named]",
meter_type=MeterType.PER_USER_PER_MONTH,
units_per_token=0.0,
tokens_per_unit=30.0,
confidence=Confidence.CONFIRMED,
notes="STA named licence; 30 tokens per named user per month.",
source_url=_GENESYS_TOKEN_METERS,
),
TokenMeter(
feature="Speech & Text Analytics [concurrent]",
meter_type=MeterType.PER_USER_PER_MONTH,
units_per_token=0.0,
tokens_per_unit=45.0,
confidence=Confidence.CONFIRMED,
notes="STA concurrent licence; 45 tokens per concurrent user per month.",
source_url=_GENESYS_TOKEN_METERS,
),
# ── Routing / Engagement ──────────────────────────────────────
TokenMeter(
feature="Predictive Routing",
meter_type=MeterType.PER_INTERACTION,
units_per_token=17.0,
tokens_per_unit=1 / 17, # 0.0588
confidence=Confidence.CONFIRMED,
notes="Predictive routing; 17 routes per token.",
source_url=_GENESYS_TOKEN_METERS,
),
# ── Messaging ─────────────────────────────────────────────────
TokenMeter(
feature="Direct Messaging",
meter_type=MeterType.PER_MESSAGE,
units_per_token=400.0,
tokens_per_unit=0.0025,
confidence=Confidence.CONFIRMED,
notes=(
"Apple Messages for Business, Facebook Messenger, Instagram DM, "
"WhatsApp, and X (Twitter) DM; 400 inbound or outbound messages "
"per token. Additional carrier charges apply for WhatsApp and X."
),
source_url=_GENESYS_TOKEN_METERS,
),
TokenMeter(
feature="Social Listening",
meter_type=MeterType.PER_MESSAGE,
units_per_token=400.0,
tokens_per_unit=0.0025,
confidence=Confidence.CONFIRMED,
notes="Genesys Cloud Social; 400 social post ingestions per channel per token.",
source_url=_GENESYS_TOKEN_METERS,
),
TokenMeter(
feature="Social Responses",
meter_type=MeterType.PER_MESSAGE,
units_per_token=400.0,
tokens_per_unit=0.0025,
confidence=Confidence.CONFIRMED,
notes="Social Post Responses; 400 outbound messages per channel per token.",
source_url=_GENESYS_TOKEN_METERS,
),
# ── Language / Translation ────────────────────────────────────
TokenMeter(
feature="AI Translate",
meter_type=MeterType.PER_INTERACTION,
units_per_token=2.0,
tokens_per_unit=0.5,
confidence=Confidence.CONFIRMED,
notes="AI translation; 2 translations per token.",
source_url=_GENESYS_TOKEN_METERS,
),
# ── Genesys Cloud Copilot ─────────────────────────────────────
TokenMeter(
feature="Genesys Cloud Copilot",
meter_type=MeterType.PER_INTERACTION,
units_per_token=20.0,
tokens_per_unit=0.05,
confidence=Confidence.CONFIRMED,
notes=(
"20 AI actions per token; Genesys Cloud knowledge queries "
"are not charged."
),
source_url=_GENESYS_TOKEN_METERS,
),
# ── Email AI (rate not yet published) ─────────────────────────
# Email Auto-Suggest is included in Agent Copilot's per-user
# meter (no standalone SKU) — only Auto-Respond is listed here.
TokenMeter(
feature="Email AI (Auto-Respond)",
meter_type=MeterType.PER_MESSAGE,
units_per_token=0.0,
tokens_per_unit=0.0,
confidence=Confidence.UNKNOWN,
notes="Feature not yet available; rate TBD.",
),
]
}
#: Features metered per named user per month.
PER_USER_FEATURES = [
f for f, m in DEFAULT_METERS.items()
if m.meter_type is MeterType.PER_USER_PER_MONTH
]
# ── Token pricing ────────────────────────────────────────────────────
# $1/token US list confirmed; other regions default to the same list
# rate until regional figures are sourced (override in UI).
DEFAULT_PRICING: dict[str, TokenPricing] = {
"US": TokenPricing(region="US", list_rate_per_token=1.0),
"EU": TokenPricing(region="EU", list_rate_per_token=1.0), # TBD — assumed US list
"AU": TokenPricing(region="AU", list_rate_per_token=1.0), # TBD — assumed US list
"APAC": TokenPricing(region="APAC", list_rate_per_token=1.0), # TBD
}
# ── CTM sites ────────────────────────────────────────────────────────
# NAM figures from CTM discovery. ALL OTHER SITES + every AHT/ACW and
# labour-cost figure are ESTIMATED placeholders — confirm with CTM.
# Named users sum to CONTRACTED_NAMED_USERS (2,088).
_COMMON = {
"voice_aht_seconds": 300, # placeholder — flag as estimate
"email_aht_seconds": 600,
"chat_aht_seconds": 480,
"voice_acw_seconds": 60,
}
CTM_DEFAULT_SITES: list[SiteInput] = [
SiteInput(
"NAM", "US", agents=890, supervisors=60, # split TBD
voice_volume_monthly=1_214_358,
email_volume_monthly=275_800,
chat_volume_monthly=110,
sms_volume_monthly=1_040,
fully_loaded_agent_cost_annual=65_000, # placeholder
fully_loaded_supervisor_cost_annual=95_000,
languages=["English", "French", "Spanish"],
**_COMMON,
),
SiteInput(
"EMEA", "EU", agents=320, supervisors=25,
voice_volume_monthly=420_000,
email_volume_monthly=95_000,
chat_volume_monthly=40,
sms_volume_monthly=400,
fully_loaded_agent_cost_annual=60_000,
fully_loaded_supervisor_cost_annual=88_000,
languages=["English", "French", "German", "Italian", "Spanish"],
**_COMMON,
),
SiteInput(
"AUZ", "AU", agents=180, supervisors=15,
voice_volume_monthly=250_000,
email_volume_monthly=56_000,
chat_volume_monthly=25,
sms_volume_monthly=250,
fully_loaded_agent_cost_annual=70_000,
fully_loaded_supervisor_cost_annual=100_000,
languages=["English"],
**_COMMON,
),
SiteInput(
"APAC HK", "APAC", agents=120, supervisors=10,
voice_volume_monthly=160_000,
email_volume_monthly=38_000,
chat_volume_monthly=15,
sms_volume_monthly=150,
fully_loaded_agent_cost_annual=55_000,
fully_loaded_supervisor_cost_annual=80_000,
languages=["English", "Cantonese", "Mandarin"],
**_COMMON,
),
SiteInput(
"APAC SG", "APAC", agents=110, supervisors=10,
voice_volume_monthly=150_000,
email_volume_monthly=34_000,
chat_volume_monthly=15,
sms_volume_monthly=120,
fully_loaded_agent_cost_annual=55_000,
fully_loaded_supervisor_cost_annual=80_000,
languages=["English", "Mandarin", "Malay"],
**_COMMON,
),
SiteInput(
"APAC SH", "APAC", agents=130, supervisors=10,
voice_volume_monthly=175_000,
email_volume_monthly=40_000,
chat_volume_monthly=15,
sms_volume_monthly=130,
fully_loaded_agent_cost_annual=35_000,
fully_loaded_supervisor_cost_annual=55_000,
languages=["Mandarin"],
**_COMMON,
),
SiteInput(
"APAC GZ", "APAC", agents=90, supervisors=8,
voice_volume_monthly=120_000,
email_volume_monthly=28_000,
chat_volume_monthly=10,
sms_volume_monthly=100,
fully_loaded_agent_cost_annual=35_000,
fully_loaded_supervisor_cost_annual=55_000,
languages=["Mandarin", "Cantonese"],
**_COMMON,
),
SiteInput(
"APAC JP", "APAC", agents=60, supervisors=6,
voice_volume_monthly=80_000,
email_volume_monthly=19_000,
chat_volume_monthly=8,
sms_volume_monthly=80,
fully_loaded_agent_cost_annual=60_000,
fully_loaded_supervisor_cost_annual=85_000,
languages=["Japanese"],
**_COMMON,
),
SiteInput(
"APAC TW", "APAC", agents=40, supervisors=4,
voice_volume_monthly=54_000,
email_volume_monthly=12_000,
chat_volume_monthly=5,
sms_volume_monthly=50,
fully_loaded_agent_cost_annual=40_000,
fully_loaded_supervisor_cost_annual=60_000,
languages=["Mandarin"],
**_COMMON,
),
]
ALL_SITE_NAMES = [s.site_name for s in CTM_DEFAULT_SITES]
# ── Cost takeouts ────────────────────────────────────────────────────
CTM_DEFAULT_TAKEOUTS: list[CostTakeout] = [
CostTakeout(
"NICE IEX (NAM)",
annual_cost=1_300_000,
start_year=1,
start_month=7, # can only switch off after NAM go-live (month 6)
confidence=Confidence.ESTIMATED,
notes="Mid-band estimate; needs CTM contract confirmation.",
),
CostTakeout(
"Legacy CC platform",
annual_cost=0,
start_year=2,
confidence=Confidence.UNKNOWN,
notes="Placeholder — populate once retirement scope is confirmed.",
),
]
# ── Default rollout & ramp ───────────────────────────────────────────
# 12-month build. Genesys bills the licence commit from contract start;
# the 6-month ramp gives a 50% first-year credit on the platform commit.
# AI token usage (and benefits) start only when each region goes live.
CTM_DEFAULT_ROLLOUT = RolloutPlan(
contract_start=None, # set when known — "Date Genesys starts billing"
build_months=12,
ramp_months=6,
first_year_platform_discount=0.50,
go_live_month={
"NAM": 6,
"EMEA": 9,
"AUZ": 12,
"APAC HK": 12,
"APAC SG": 12,
"APAC SH": 12,
"APAC GZ": 12,
"APAC JP": 12,
"APAC TW": 12,
},
)
# ── Default feature scoping / phasing ────────────────────────────────
# Phase = model year the feature switches on. Consumption features ramp
# via adoption_curve; per-user licences are paid in full from the phase
# year.
_RAMP = {1: 0.70, 2: 1.0, 3: 1.0}
CTM_DEFAULT_FEATURE_SCOPES: list[FeatureScope] = [
FeatureScope("Voice Bot", ALL_SITE_NAMES, phase=1, adoption_curve=_RAMP),
FeatureScope("Agentic Virtual Agent", ["NAM", "EMEA"], phase=2,
adoption_curve={2: 0.70, 3: 1.0}),
# CTM has named licences — use the [named] variant for both STA and Copilot.
FeatureScope("Speech & Text Analytics [named]", ALL_SITE_NAMES, phase=1),
FeatureScope("Agent Copilot [named]", ALL_SITE_NAMES, phase=1),
FeatureScope("AI Summary & Insights", ALL_SITE_NAMES, phase=1,
adoption_curve=_RAMP),
FeatureScope("Direct Messaging", ALL_SITE_NAMES, phase=1, adoption_curve=_RAMP),
FeatureScope("AI Translate",
["APAC HK", "APAC SG", "APAC SH", "APAC GZ", "APAC JP", "APAC TW"],
phase=3),
]

View File

@@ -0,0 +1,131 @@
"""
Excel / CSV / JSON export.
Excel uses openpyxl via pandas — multi-sheet workbooks readable in
Excel 2019+. JSON round-trips the full input state (sites, takeouts,
feature scopes) so a scenario can be saved and reloaded.
"""
from __future__ import annotations
import dataclasses
import json
from pathlib import Path
import pandas as pd
from .inputs import CostTakeout, FeatureScope, SiteInput
from .meters import Confidence, TokenMeter
from .rollout import RolloutPlan
def meters_dataframe(meters: dict[str, TokenMeter]) -> pd.DataFrame:
"""Meter catalogue as a display/export-ready DataFrame."""
return pd.DataFrame(
[
{
"feature": m.feature,
"meter_type": m.meter_type.value,
"units_per_token": m.units_per_token or None,
"tokens_per_unit": m.tokens_per_unit,
"confidence": f"{m.confidence.icon} {m.confidence.value}",
"notes": m.notes,
"source": m.source_url or "",
}
for m in meters.values()
]
)
def sites_dataframe(sites: list[SiteInput]) -> pd.DataFrame:
rows = []
for s in sites:
d = dataclasses.asdict(s)
d["languages"] = ", ".join(d["languages"])
rows.append(d)
return pd.DataFrame(rows)
def export_excel(
sheets: dict[str, pd.DataFrame],
path: str | Path,
) -> Path:
"""Write a multi-sheet Excel workbook. Sheet names are truncated to
Excel's 31-character limit."""
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
with pd.ExcelWriter(path, engine="openpyxl") as writer:
for name, df in sheets.items():
df.to_excel(writer, sheet_name=name[:31], index=False)
return path
def export_csv(df: pd.DataFrame, path: str | Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
df.to_csv(path, index=False)
return path
# ── JSON scenario save / load ────────────────────────────────────────
def scenario_state_to_json(
sites: list[SiteInput],
takeouts: list[CostTakeout],
feature_scopes: list[FeatureScope],
path: str | Path | None = None,
rollout: RolloutPlan | None = None,
) -> str:
"""Serialize the full input state; optionally write to ``path``."""
state = {
"sites": [dataclasses.asdict(s) for s in sites],
"takeouts": [
{**dataclasses.asdict(t), "confidence": t.confidence.value}
for t in takeouts
],
"feature_scopes": [
{
**dataclasses.asdict(f),
"adoption_curve": {str(k): v for k, v in f.adoption_curve.items()},
}
for f in feature_scopes
],
}
if rollout is not None:
state["rollout"] = dataclasses.asdict(rollout)
text = json.dumps(state, indent=2)
if path is not None:
Path(path).write_text(text)
return text
def scenario_state_from_json(
source: str | Path,
) -> tuple[list[SiteInput], list[CostTakeout], list[FeatureScope], RolloutPlan | None]:
"""Inverse of :func:`scenario_state_to_json`. ``source`` is a JSON
string or a file path. The fourth element is None for legacy files
saved without a rollout plan."""
raw = (
Path(source).read_text()
if isinstance(source, Path) or (isinstance(source, str) and source.strip().endswith(".json"))
else str(source)
)
state = json.loads(raw)
sites = [SiteInput(**s) for s in state["sites"]]
takeouts = [
CostTakeout(**{**t, "confidence": Confidence(t["confidence"])})
for t in state["takeouts"]
]
scopes = [
FeatureScope(
**{
**f,
"adoption_curve": {int(k): v for k, v in f["adoption_curve"].items()},
}
)
for f in state["feature_scopes"]
]
rollout = (
RolloutPlan(**state["rollout"]) if "rollout" in state else None
)
return sites, takeouts, scopes, rollout

View File

@@ -0,0 +1,155 @@
"""
Input bundles — validated dataclasses, no untyped dicts.
All volumes are MONTHLY; all AHT/ACW figures are SECONDS; all labour
costs are ANNUAL fully-loaded USD.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from .meters import Confidence
#: Sanity bounds for handle times (seconds).
AHT_MIN_SECONDS = 10
AHT_MAX_SECONDS = 3600
#: Working hours per FTE-year used to derive per-second labour rates.
WORKING_HOURS_PER_YEAR = 2_080
WORKING_SECONDS_PER_YEAR = WORKING_HOURS_PER_YEAR * 3600
@dataclass
class SiteInput:
site_name: str # "NAM", "EMEA", "AUZ", "APAC HK", …
region_pricing: str # "US", "AU", "EU", "APAC"
agents: int # excluding supervisors
supervisors: int
voice_volume_monthly: int
email_volume_monthly: int
chat_volume_monthly: int
sms_volume_monthly: int
voice_aht_seconds: int
email_aht_seconds: int
chat_aht_seconds: int
voice_acw_seconds: int
fully_loaded_agent_cost_annual: float
fully_loaded_supervisor_cost_annual: float
licence_type: str = "named" # "named" | "concurrent"
languages: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
if self.licence_type not in ("named", "concurrent"):
raise ValueError(
f"{self.site_name}: licence_type must be 'named' or 'concurrent', "
f"got {self.licence_type!r}"
)
if self.agents < 0 or self.supervisors < 0:
raise ValueError(f"{self.site_name}: agent/supervisor counts must be >= 0")
for name in (
"voice_volume_monthly",
"email_volume_monthly",
"chat_volume_monthly",
"sms_volume_monthly",
):
if getattr(self, name) < 0:
raise ValueError(f"{self.site_name}: {name} must be >= 0")
for name in ("voice_aht_seconds", "email_aht_seconds", "chat_aht_seconds"):
v = getattr(self, name)
if v and not AHT_MIN_SECONDS <= v <= AHT_MAX_SECONDS:
raise ValueError(
f"{self.site_name}: {name}={v}s outside sensible bounds "
f"({AHT_MIN_SECONDS}-{AHT_MAX_SECONDS}s)"
)
if self.voice_acw_seconds < 0:
raise ValueError(f"{self.site_name}: voice_acw_seconds must be >= 0")
@property
def named_users(self) -> int:
return self.agents + self.supervisors
@property
def agent_cost_per_second(self) -> float:
"""Fully-loaded agent labour rate per working second (DBZ-safe)."""
return self.fully_loaded_agent_cost_annual / WORKING_SECONDS_PER_YEAR
@property
def supervisor_cost_per_second(self) -> float:
return self.fully_loaded_supervisor_cost_annual / WORKING_SECONDS_PER_YEAR
@dataclass
class FeatureScope:
"""Which feature is enabled at which sites, in which phase.
``phase`` is the model year (1-3) the feature switches on;
``adoption_curve`` maps model year -> adoption fraction (0.0-1.0)
applied to consumption-metered features (per-user licenses are paid
in full from the phase year onward).
"""
feature: str
enabled_sites: list[str]
phase: int = 1
adoption_curve: dict[int, float] = field(default_factory=dict)
deflection_target: float | None = None
eligibility_pct: float | None = None
def __post_init__(self) -> None:
if self.phase < 1:
raise ValueError(f"{self.feature}: phase must be >= 1")
for year, pct in self.adoption_curve.items():
if not 0.0 <= pct <= 1.0:
raise ValueError(
f"{self.feature}: adoption_curve[{year}]={pct} outside 0-1"
)
for name in ("deflection_target", "eligibility_pct"):
v = getattr(self, name)
if v is not None and not 0.0 <= v <= 1.0:
raise ValueError(f"{self.feature}: {name}={v} outside 0-1")
def active(self, site_name: str, year: int) -> bool:
return site_name in self.enabled_sites and year >= self.phase
def adoption(self, year: int) -> float:
"""Adoption fraction for ``year`` (1.0 when no curve given)."""
if not self.adoption_curve:
return 1.0
if year in self.adoption_curve:
return self.adoption_curve[year]
# Past the last defined year → hold the last value.
last = max(self.adoption_curve)
return self.adoption_curve[last] if year > last else 0.0
@dataclass
class CostTakeout:
"""A retired platform/licence whose cost the programme reclaims.
``start_month`` (1-12, within ``start_year``) prorates the first
active year — e.g. NICE IEX can only be switched off once NAM is
live, so start_year=1, start_month=7 reclaims 6/12 of Y1.
"""
name: str # "NICE IEX (NAM)", "Legacy CC platform", …
annual_cost: float
start_year: int = 1
confidence: Confidence = Confidence.ESTIMATED
notes: str = ""
start_month: int = 1
def __post_init__(self) -> None:
if self.annual_cost < 0:
raise ValueError(f"{self.name}: annual_cost must be >= 0")
if self.start_year < 1:
raise ValueError(f"{self.name}: start_year must be >= 1")
if not 1 <= self.start_month <= 12:
raise ValueError(f"{self.name}: start_month must be 1-12")
def value_in_year(self, year: int) -> float:
if year < self.start_year:
return 0.0
if year == self.start_year:
return self.annual_cost * (12 - (self.start_month - 1)) / 12
return self.annual_cost

View File

@@ -0,0 +1,87 @@
"""
Genesys AI Experience token meters and pricing.
Every meter carries a :class:`Confidence` flag so the UI can distinguish
published Genesys rates from estimates and unknowns. Rates here are
*planning inputs* — this tool explicitly does not replace contractual
pricing (see README, Non-Goals).
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class MeterType(Enum):
PER_USER_PER_MONTH = "per_user_per_month"
PER_INTERACTION = "per_interaction"
PER_MINUTE = "per_minute"
PER_MESSAGE = "per_message"
PER_SUMMARY = "per_summary"
class Confidence(Enum):
CONFIRMED = "confirmed" # published Genesys rate
ESTIMATED = "estimated" # reasonable industry assumption
UNKNOWN = "unknown" # rate not yet sourced
@property
def icon(self) -> str:
return {"confirmed": "🟢", "estimated": "🟡", "unknown": "🔴"}[self.value]
@dataclass
class TokenMeter:
"""One Genesys AI feature's token meter.
``units_per_token`` and ``tokens_per_unit`` are inverses; both are
stored because the UI shows whichever reads more naturally (e.g.
"17 minutes per token" vs "0.0588 tokens per minute"). For
PER_USER_PER_MONTH meters ``units_per_token`` is 0.0 (n/a) and
``tokens_per_unit`` is the flat tokens/user/month figure.
"""
feature: str
meter_type: MeterType
units_per_token: float
tokens_per_unit: float
confidence: Confidence
notes: str
source_url: str | None = None
def __post_init__(self) -> None:
if self.tokens_per_unit < 0:
raise ValueError(f"{self.feature}: tokens_per_unit must be >= 0")
if (
self.meter_type is not MeterType.PER_USER_PER_MONTH
and self.units_per_token > 0
and self.tokens_per_unit > 0
):
product = self.units_per_token * self.tokens_per_unit
if not 0.95 <= product <= 1.05:
raise ValueError(
f"{self.feature}: units_per_token ({self.units_per_token}) and "
f"tokens_per_unit ({self.tokens_per_unit}) are not inverses"
)
@dataclass
class TokenPricing:
"""Per-region token pricing. Default is US list at $1/token."""
region: str # "US", "AU", "EU", "APAC"
list_rate_per_token: float = 1.0
contracted_rate_per_token: float | None = None
prepay_commit_tokens: int | None = None
overage_rate_per_token: float | None = None
def __post_init__(self) -> None:
if self.list_rate_per_token < 0:
raise ValueError(f"{self.region}: list rate must be >= 0")
def effective_rate(self, use_contracted: bool = False) -> float:
"""Contracted rate when requested and known, else list rate."""
if use_contracted and self.contracted_rate_per_token is not None:
return self.contracted_rate_per_token
return self.list_rate_per_token

View File

@@ -0,0 +1,88 @@
"""
Migration + WFM scenario — the no-AI business case.
Current state → Genesys Cloud CX migration, NA users migrated onto
Genesys WFM, WFM implemented for APAC (ANZ + ASIA). Migration and WFM
are included in the base implementation price (the verbatim PS +
training), so the only cost lines are the existing-platform run-off,
the licence ramp, and base PS — no token consumption, no AI
implementation labour. The only benefits kept are the deck's verbatim
WFM lines for the regions in WFM scope.
Single source of truth behind ``notebooks/ctm_migration_wfm.ipynb``
(served with Mercury) — the presentation layer holds no math. All
primitives come from :mod:`tokencalc.appendix4`; this module only
scopes and extrapolates.
"""
from __future__ import annotations
import math
import pandas as pd
from . import appendix4 as a4
#: WFM scope in this scenario. NA is a migration off its existing
#: WFM-like tool (verbatim benefit $0 — "has similar feature"); ANZ and
#: ASIA are new implementations ("APAC"); EMEA is out of scope, so the
#: deck's EMEA WFM benefit line is dropped.
DEFAULT_WFM_REGIONS = ["NA", "ANZ", "ASIA"]
def wfm_benefits_by_year(
benefit_rollout, regions: list[str] | None = None
) -> pd.DataFrame:
"""Verbatim WFM benefits for the scoped regions, phased on the
deck's deployment schedule (realize = impl + 3 months, inclusive).
Long DataFrame: region, capability, year, benefit — the WFM slice
of :func:`tokencalc.appendix4.benefits_by_year`.
"""
scope = DEFAULT_WFM_REGIONS if regions is None else regions
df = a4.benefits_by_year(benefit_rollout)
return df[(df["capability"] == "WFM")
& (df["region"].isin(scope))].reset_index(drop=True)
def wfm_annual_runrate(regions: list[str] | None = None) -> float:
"""Sum of the verbatim WFM *annual* values for the scoped regions."""
scope = DEFAULT_WFM_REGIONS if regions is None else regions
return float(sum(annual for (r, c), (annual, _t) in
a4.VERBATIM_BENEFITS.items()
if c == "WFM" and r in scope))
def runrate_saving_annual(
licence_annual: float | None = None,
regions: list[str] | None = None,
baseline_annual: float | None = None,
managed_annual: float = 0.0,
) -> float:
"""Steady-state annual saving once term contracts end and the ramp
is over: (baseline licence run-rate managed services) + scoped
WFM annual values.
Defaults are the deck frame (deck licence rate, no managed
services); the contracted frame passes ``a4.MANAGED_SERVICES_ANNUAL``.
"""
lic = a4.TCO_VERBATIM["ccaas_annual"] if licence_annual is None else licence_annual
base = a4.TCO_VERBATIM["current_annual"] if baseline_annual is None else baseline_annual
return (base - lic - managed_annual) + wfm_annual_runrate(regions)
def runrate_breakeven_label(
net_by_year: dict[int, float], runrate_annual: float
) -> str:
"""Payback label, extrapolated past the model window at a run-rate.
Inside 2026-28 this defers to :func:`tokencalc.appendix4.payback_label`;
a deficit at end-2028 fills at ``runrate_annual`` per year.
"""
deficit = -sum(net_by_year[y] for y in a4.YEARS)
if deficit <= 0:
return a4.payback_label(net_by_year)
if runrate_annual <= 0:
return "never at current run-rate"
m = 12 * len(a4.YEARS) + math.ceil(12 * deficit / runrate_annual)
return f"{m} months (~{a4.month_label(m)}, extrapolated)"

View File

@@ -0,0 +1,81 @@
"""
Implementation rollout & ramp model.
Captures the gap between **when Genesys starts billing** (contract
start) and **when each region actually goes live**:
- The platform licence commit bills in full from contract start; the
vendor's *ramp period* compensates with a first-year credit
(typical: 6-month ramp → 50% Y1 discount on the platform commit).
- AI token usage (per-user and consumption meters) starts only when a
site goes live, and bills for the months the site is live in each
model year.
- Benefits likewise accrue only from go-live (the scenario realization
curve then models adoption maturity *within* the live period).
A site with ``go_live_month = m`` is live for ``12*year m`` months of
the first ``year`` years (clamped to 0..12 per year). So NAM at month 6
is live 6 months of Y1; EMEA at month 9 → 3 months; AUZ/APAC at month
12 → 0 months in Y1 and fully live from Y2.
"""
from __future__ import annotations
from dataclasses import dataclass, field
MONTHS_PER_YEAR = 12
@dataclass
class RolloutPlan:
#: ISO date Genesys starts billing the licence commit (informational,
#: surfaced in UI/exports; the model works in months-from-start).
contract_start: str | None = None
#: Total build duration, months (informational).
build_months: int = 12
#: Vendor ramp period, months. Documentation for the Y1 credit below.
ramp_months: int = 6
#: First-year credit on the platform licence commit. Typical
#: 6-month ramp = 50% discount in year 1; years 2+ bill in full.
first_year_platform_discount: float = 0.5
#: site_name -> go-live month (months after contract start).
#: Sites absent from the map are treated as live from day 0.
go_live_month: dict[str, int] = field(default_factory=dict)
def __post_init__(self) -> None:
if not 0.0 <= self.first_year_platform_discount <= 1.0:
raise ValueError("first_year_platform_discount must be within 0-1")
if self.ramp_months < 0 or self.build_months < 0:
raise ValueError("ramp_months/build_months must be >= 0")
for site, m in self.go_live_month.items():
if m < 0:
raise ValueError(f"{site}: go_live_month must be >= 0")
# ── Availability ────────────────────────────────────────────────
def live_months_in_year(self, site_name: str, year: int) -> int:
"""Months ``site_name`` is live during model year ``year`` (1-based)."""
go_live = self.go_live_month.get(site_name, 0)
live_by_year_end = max(0, MONTHS_PER_YEAR * year - go_live)
live_by_prev_year_end = max(0, MONTHS_PER_YEAR * (year - 1) - go_live)
return min(MONTHS_PER_YEAR, live_by_year_end - live_by_prev_year_end)
def fraction_live(self, site_name: str, year: int) -> float:
return self.live_months_in_year(site_name, year) / MONTHS_PER_YEAR
# ── Billing ─────────────────────────────────────────────────────
def platform_factor(self, year: int) -> float:
"""Fraction of the full platform commit billed in ``year``."""
return 1.0 - self.first_year_platform_discount if year == 1 else 1.0
#: Behaviour identical to the pre-rollout model: everything live from
#: day 0, no ramp credit.
NO_ROLLOUT = RolloutPlan(
build_months=0, ramp_months=0, first_year_platform_discount=0.0
)

View File

@@ -0,0 +1,144 @@
"""
Scenario definitions — Floor / Realistic / Stretch.
Every scenario parameter the cost and benefit engines read lives here;
no magic numbers in the calculation modules. Ships with the spec
defaults; callers may construct custom :class:`Scenario` objects.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass
class Scenario:
name: str
# ── Cost-side drivers ───────────────────────────────────────────
voice_bot_deflection: float # share of voice volume deflected to bot
voice_bot_avg_minutes: float # bot minutes per deflected call
# Agentic VA deflection is INCREMENTAL — applied to the residual volume
# after the voice bot has already handled its share (layered model).
# Effective total deflection = bot_rate + (1 bot_rate) × va_rate.
agentic_va_deflection: float # share of RESIDUAL voice volume to agentic VA
voice_summarization_eligibility: float
voice_knowledge_eligibility: float
email_auto_respond_rate: float # share of email auto-responded
# ── Virtual Agent benefit realization factors ───────────────────
# Applied to both Voice Bot and Agentic VA deflection benefits.
# completion_rate — share of "deflected" calls that don't escalate to an agent
# mid-session (bot/VA fully handles the interaction).
# labour_realization — staffing flexibility: deflected volume doesn't reduce
# headcount 1:1 due to minimums, shrinkage, occupancy ceilings.
# callback_discount — fraction of deflected calls that re-enter as repeat contacts
# (poorly-handled deflections drive callbacks).
# Combined realistic factor: 0.70 × 0.80 × (1 0.05) ≈ 0.53
va_completion_rate: float = 0.70
va_labour_realization: float = 0.80
va_callback_discount: float = 0.05
# year -> fraction of full benefit realized
benefit_realization: dict[int, float] = field(default_factory=dict)
# year -> fraction of steady-state consumption cost incurred.
# Per-user licenses are paid in full from day 1; consumption meters
# ramp with usage (default Y1 = 70%).
consumption_cost_realization: dict[int, float] = field(
default_factory=lambda: {1: 0.70, 2: 1.0, 3: 1.0}
)
def realization(self, year: int) -> float:
if year in self.benefit_realization:
return self.benefit_realization[year]
last = max(self.benefit_realization, default=0)
return self.benefit_realization.get(last, 1.0) if year > last else 0.0
def cost_realization(self, year: int) -> float:
if year in self.consumption_cost_realization:
return self.consumption_cost_realization[year]
last = max(self.consumption_cost_realization, default=0)
return (
self.consumption_cost_realization.get(last, 1.0) if year > last else 0.0
)
#: Benefit reduction parameters. ``claim`` = Genesys ROI-doc figure;
#: ``realistic`` = pressure-tested midpoint of the spec's Y1 range.
#: The benefit engine uses ``realistic`` by default; ``claim`` powers
#: the side-by-side comparison view.
BENEFIT_PARAMS: dict[str, dict[str, float]] = {
"voice_aht_knowledge_reduction": {"claim": 0.094, "realistic": 0.055}, # 4-7% Y1
"voice_acw_reduction": {"claim": 1.00, "realistic": 0.40}, # 30-50% Y1
"digital_aht_reduction": {"claim": 0.18, "realistic": 0.085}, # 5-12% Y1
"digital_acw_reduction": {"claim": 1.00, "realistic": 0.40}, # 30-50% Y1
"sta_aht_reduction": {"claim": 0.04, "realistic": 0.015}, # 1-2% Y1
# ESTIMATED lines (no Genesys claim published):
"supervisor_copilot_time_saving": {"claim": 0.10, "realistic": 0.05},
"predictive_routing_aht_reduction": {"claim": 0.04, "realistic": 0.02},
# Virtual Agent realization factors.
# ``claim`` = 100% realization (original model assumption — no haircuts).
# ``realistic`` = production-calibrated midpoints per the spec analysis.
"va_completion_rate": {"claim": 1.00, "realistic": 0.70}, # 60-75% voice bot; 50-70% agentic VA Y1
"va_labour_realization": {"claim": 1.00, "realistic": 0.80}, # 70-85% staffing flexibility
"va_callback_discount": {"claim": 0.00, "realistic": 0.05}, # 5-10% deflected re-enter as repeat contacts
}
SCENARIOS: dict[str, Scenario] = {
"floor": Scenario(
name="floor",
voice_bot_deflection=0.20,
voice_bot_avg_minutes=1.0,
agentic_va_deflection=0.05,
voice_summarization_eligibility=0.50,
voice_knowledge_eligibility=0.40,
email_auto_respond_rate=0.10,
# VA realization: conservative — low completion, limited staffing flex
# Combined: 0.60 × 0.70 × (1 0.05) ≈ 0.40
va_completion_rate=0.60,
va_labour_realization=0.70,
va_callback_discount=0.05,
benefit_realization={1: 0.30, 2: 0.60, 3: 0.80},
),
"realistic": Scenario(
name="realistic",
voice_bot_deflection=0.35,
voice_bot_avg_minutes=1.5,
agentic_va_deflection=0.15,
voice_summarization_eligibility=0.70,
voice_knowledge_eligibility=0.60,
email_auto_respond_rate=0.20,
# VA realization: production midpoints per spec analysis
# Combined: 0.70 × 0.80 × (1 0.05) ≈ 0.53
va_completion_rate=0.70,
va_labour_realization=0.80,
va_callback_discount=0.05,
benefit_realization={1: 0.50, 2: 0.80, 3: 0.95},
),
"stretch": Scenario(
name="stretch",
voice_bot_deflection=0.50,
voice_bot_avg_minutes=2.0,
agentic_va_deflection=0.25,
voice_summarization_eligibility=0.90,
voice_knowledge_eligibility=0.80,
email_auto_respond_rate=0.50,
# VA realization: optimistic — high completion, good staffing flexibility
# Combined: 0.75 × 0.85 × (1 0.03) ≈ 0.62
va_completion_rate=0.75,
va_labour_realization=0.85,
va_callback_discount=0.03,
benefit_realization={1: 0.75, 2: 0.95, 3: 1.00},
),
}
def get_scenario(name: str) -> Scenario:
try:
return SCENARIOS[name.lower()]
except KeyError as e:
raise KeyError(
f"Unknown scenario {name!r}. Valid: {sorted(SCENARIOS)}"
) from e

View File

@@ -0,0 +1,29 @@
"""
Stage vs backstage — is this notebook render stakeholder-facing?
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
into the server process so the widget library can locate ``config.toml``
(see ``mercury/config.py``); every kernel that server spawns inherits it.
JupyterLab and nbconvert kernels don't have it. That makes the variable a
reliable signal for "the audience is looking" (the stage) versus an
analyst session or a headless export run (backstage).
Diagnostics routed through :func:`backstage` stay visible in JupyterLab
and land in the nbconvert exports (where the machine-readable appendix
must appear for LLM consumption) but never render in the Mercury app.
"""
from __future__ import annotations
import os
def on_stage() -> bool:
"""True when running under the Mercury app (stakeholder-facing)."""
return os.getenv("MERCURY_CONFIG_DIR") is not None
def backstage(*args, **kwargs) -> None:
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage():
print(*args, **kwargs)