docs: add migration notebook and clarify export usage

This commit is contained in:
2026-07-07 21:29:29 -04:00
parent 99da955377
commit 0c3e927f23
7 changed files with 8981 additions and 1536 deletions

View File

@@ -33,8 +33,9 @@ mercury --working-dir notebooks/
# Or work on them directly in JupyterLab # Or work on them directly in JupyterLab
jupyter lab notebooks/ jupyter lab notebooks/
# Export the corrected business case as LLM-readable report sources # Export the business-case notebooks as LLM-readable report sources
# (exports/*.html for review, exports/*.md for feeding an LLM) # (exports/*.html for review, exports/*.md for feeding an LLM;
# optional filter: python scripts/export_report.py migration)
python scripts/export_report.py python scripts/export_report.py
# Tests # Tests
@@ -46,17 +47,18 @@ pytest
**The notebooks are the deliverables.** All math lives in the pure-Python **The notebooks are the deliverables.** All math lives in the pure-Python
`tokencalc/` library; the notebooks are thin presentation layers over it. `tokencalc/` library; the notebooks are thin presentation layers over it.
[Mercury](https://runmercury.com) serves them as interactive web apps — the [Mercury](https://runmercury.com) serves them as interactive web apps — the
`mercury` input widgets in `ctm_business_case_corrected.ipynb` let you tune `mercury` input widgets in the business-case notebooks let you tune
contract values, termination dates, token assumptions, and implementation contract values, termination dates, token assumptions, and implementation
pricing live for a client, and headless runs (nbconvert, the section-10 regression pricing live for a client, and headless runs (nbconvert, each notebook's
gate) simply use the widget defaults. `scripts/export_report.py` executes the regression-gate section) simply use the widget defaults. `scripts/export_report.py`
notebook and writes HTML + markdown to `exports/`; the notebook's section-12 executes the notebooks and writes HTML + markdown to `exports/`; each notebook's
machine-readable appendix carries every number behind the figures so an LLM machine-readable appendix section carries every number behind the figures so an
can draft the client report from the export. LLM can draft the client report from the export.
| Notebook | Purpose | | Notebook | Purpose |
|---|---| |---|---|
| `notebooks/ctm_business_case_corrected.ipynb` | Client-facing corrected business case (Mercury-interactive) | | `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 | | `notebooks/ctm_token_calculator.ipynb` | Full token-cost / scenario workbench |
| Module | Purpose | | Module | Purpose |

View File

@@ -18,9 +18,10 @@ notebooks_button_label = "Analyses"
header = "CTM × Genesys CCaaS" header = "CTM × Genesys CCaaS"
message = """ message = """
Interactive business-case notebooks. **Corrected Business Case** keeps Interactive business-case notebooks. **Corrected Business Case** keeps
Genesys's claimed benefits verbatim and adds the costs the pitch omitted Genesys's claimed benefits verbatim and adds the costs the pitch omitted;
tune the 🟡 inputs live for the client, then export the personalized report **Migration + WFM** strips out every AI capability and prices the platform
source with `python scripts/export_report.py`. move alone. Tune the 🟡 inputs live for the client, then export the
personalized report source with `python scripts/export_report.py`.
""" """
[theme] [theme]

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,17 @@
"""Export the corrected business case notebook as LLM-readable report sources. """Export the deliverable notebooks as LLM-readable report sources.
Executes the notebook fresh (widget defaults — or whatever defaults you edit in), Executes each notebook fresh (widget defaults — or whatever defaults you edit in),
then writes both formats to exports/: then writes both formats to exports/:
exports/ctm_business_case_corrected.html — human-reviewable, tables render exports/<notebook>.html — human-reviewable, tables render
exports/ctm_business_case_corrected.md — leanest LLM input exports/<notebook>.md — leanest LLM input
Plotly figures export as JavaScript an LLM cannot read; the notebook's section-12 Plotly figures export as JavaScript an LLM cannot read; each notebook's
machine-readable appendix carries every number behind them. machine-readable appendix section carries every number behind them.
Run from the project root: python scripts/export_report.py 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 from __future__ import annotations
@@ -18,18 +20,26 @@ import sys
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
NOTEBOOK = ROOT / "notebooks" / "ctm_business_case_corrected.ipynb" NOTEBOOKS = [
ROOT / "notebooks" / "ctm_business_case_corrected.ipynb",
ROOT / "notebooks" / "ctm_migration_wfm.ipynb",
]
EXPORTS = ROOT / "exports" EXPORTS = ROOT / "exports"
def main() -> None: 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) EXPORTS.mkdir(exist_ok=True)
for fmt in ("html", "markdown"): for nb in picked:
subprocess.run( for fmt in ("html", "markdown"):
[sys.executable, "-m", "nbconvert", "--execute", subprocess.run(
"--to", fmt, "--output-dir", str(EXPORTS), str(NOTEBOOK)], [sys.executable, "-m", "nbconvert", "--execute",
check=True, cwd=ROOT, "--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
) check=True, cwd=ROOT,
)
for p in sorted(EXPORTS.iterdir()): for p in sorted(EXPORTS.iterdir()):
if p.suffix in (".html", ".md"): if p.suffix in (".html", ".md"):
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)") print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")

View File

@@ -0,0 +1,66 @@
"""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)
def test_breakeven_extrapolates_past_window():
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}
ben = mw.wfm_benefits_by_year(_default_benefit_rollout())
ben_y = {y: float(ben.loc[ben.year == y, "benefit"].sum()) for y in a4.YEARS}
inc, net = a4.case_flows(total, ben_y)
assert sum(net.values()) == pytest.approx(-1_553_000, abs=1_000)
label = mw.runrate_breakeven_label(net, mw.runrate_saving_annual())
assert label == "40 months (~Apr 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,83 @@
"""
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,
) -> float:
"""Steady-state annual saving once term contracts end and the ramp
is over: (baseline licence run-rate) + scoped WFM annual values.
"""
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) + 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)"