Mercury Genesys Token Calculator

This commit is contained in:
2026-08-07 14:49:39 -04:00
parent 22a5d907d6
commit 89b835d681
35 changed files with 14105 additions and 2912 deletions

2
.gitignore vendored
View File

@@ -19,6 +19,8 @@ studies/*/exports/*
!studies/*/exports/.gitkeep !studies/*/exports/.gitkeep
assessments/*/exports/* assessments/*/exports/*
!assessments/*/exports/.gitkeep !assessments/*/exports/.gitkeep
calculators/*/exports/*
!calculators/*/exports/.gitkeep
# Client / engagement documents must NEVER be committed — masters stay # Client / engagement documents must NEVER be committed — masters stay
# client-clean (see CLAUDE.md, Confidentiality). Text/image source material # client-clean (see CLAUDE.md, Confidentiality). Text/image source material

View File

@@ -64,6 +64,12 @@ and [docs/Study_Pattern_V1-00.md](docs/Study_Pattern_V1-00.md).
- **Assessment** (`assessments/Instrument_Name`, undated) — reusable workshop - **Assessment** (`assessments/Instrument_Name`, undated) — reusable workshop
instrument; a living master. Reference implementation: instrument; a living master. Reference implementation:
`assessments/CX_Discovery_Workshop/`. `assessments/CX_Discovery_Workshop/`.
- **Calculator** (`calculators/Vendor_Subject_Calculator`, undated) — prices a
vendor's product from that vendor's **published rate card**: verbatim
rate-card anchor + client overlay. Undated because the anchor is immutable
only *until the vendor republishes* — the publication date lives in the
anchor, not the directory name. Reference implementation:
`calculators/Genesys_Token_Calculator/`.
- **Engagement copy** (`YYYYMM_Client_Instrument`, stamped at copy-time) — a master - **Engagement copy** (`YYYYMM_Client_Instrument`, stamped at copy-time) — a master
copied OUT of this repo for a client; confidential; never merges back. copied OUT of this repo for a client; confidential; never merges back.
- **`template/MercuryNotebook/`** — copy-me scaffold for new studies (py-engine model - **`template/MercuryNotebook/`** — copy-me scaffold for new studies (py-engine model
@@ -96,7 +102,10 @@ structural suite. Running a master's pytest from the wrong venv is the classic
**Show first** — produce the diff/output, present it, wait for a human "go": **Show first** — produce the diff/output, present it, wait for a human "go":
content edits to `topic-bank`/`engagement-data` cells or anchor-adjacent wording; content edits to `topic-bank`/`engagement-data` cells or anchor-adjacent wording;
re-pinning a gate or test after a content/engine change (show the pin diff and the re-pinning a gate or test after a content/engine change (show the pin diff and the
KPI moves honestly); moving or renaming a master; `.gitignore` changes; `git commit`. KPI moves honestly); **re-anchoring a Calculator to a newer vendor publication**
(show the rate diff, the source-date bump, and the cost moves — the protocol is in
the Calculator Pattern); moving or renaming a master; `.gitignore` changes;
`git commit`.
**Forbidden without explicit go-ahead:** **Forbidden without explicit go-ahead:**
`git push`; editing `*_VERBATIM` anchors; committing any client document (SOW, quote, `git push`; editing `*_VERBATIM` anchors; committing any client document (SOW, quote,
@@ -230,6 +239,12 @@ quietly leaving it. Live ones worth knowing:
- **`template/MercuryNotebook/` encodes only the py-engine model** (and its - **`template/MercuryNotebook/` encodes only the py-engine model** (and its
`staging.py` lacks the mypy-strict `backstage_md` variant); rework is a recorded `staging.py` lacks the mypy-strict `backstage_md` variant); rework is a recorded
follow-up. Its `exports/*.{html,md}` are tracked — predates the exports rule. follow-up. Its `exports/*.{html,md}` are tracked — predates the exports rule.
- **`make lint` fails repo-wide — 143 pre-existing ruff errors** (94 in the CTM
study, 16 in AI Diagnostic, the rest spread across the TEI twins, the template
and `00_setup.ipynb`); mostly import sorting and E402 in notebook cells. Newer
masters are clean (`ruff check calculators/` passes), so lint per-directory
when you touch one; a repo-wide sweep is its own commit, not a rider on
feature work.
- **`docs/brand.md` references a `brand_dark.md` that does not exist.** - **`docs/brand.md` references a `brand_dark.md` that does not exist.**
- **`core.bootstrap.init(study=…)` imports `studies.<slug>.config`** — vestigial - **`core.bootstrap.init(study=…)` imports `studies.<slug>.config`** — vestigial
(no study ships a `config.py`; `studies/__init__.py` exists to serve it). Don't (no study ships a `config.py`; `studies/__init__.py` exists to serve it). Don't
@@ -258,6 +273,9 @@ this file summarises:**
notebook-first content, engagement-data cell, copy-out checklist notebook-first content, engagement-data cell, copy-out checklist
- [docs/Study_Pattern_V1-00.md](docs/Study_Pattern_V1-00.md) — studies: verbatim - [docs/Study_Pattern_V1-00.md](docs/Study_Pattern_V1-00.md) — studies: verbatim
anchor + overlay, reproduction gate, published-PDF exception anchor + overlay, reproduction gate, published-PDF exception
- [docs/Calculator_Pattern_V1-00.md](docs/Calculator_Pattern_V1-00.md) —
calculators: the rate-card anchor (and why it lives in `.py`), the
re-anchoring protocol, the proportionality rule (price it; don't build a case)
Everything else: Everything else:

View File

@@ -6,12 +6,15 @@ PIP := $(VENV)/bin/pip
.PHONY: setup lab test check-notebooks lint format clean .PHONY: setup lab test check-notebooks lint format clean
## One-time: create venv, install deps + palladium (editable) ## One-time: create venv, install deps + palladium (editable).
## `pyproject.toml` carries the whole runtime toolchain as core deps and
## pytest/ruff in the `dev` extra — `pip install -e ".[dev]"` is enough to
## run, test, and lint. (There is deliberately no requirements.txt: a
## second dependency list is how `make setup` silently rots.)
setup: setup:
python3 -m venv $(VENV) python3 -m venv $(VENV)
$(PIP) install --upgrade pip $(PIP) install --upgrade pip
$(PIP) install -r requirements.txt $(PIP) install -e ".[dev]"
$(PIP) install -e .
@echo "" @echo ""
@echo "✅ Done. Next: make lab → open 00_setup.ipynb" @echo "✅ Done. Next: make lab → open 00_setup.ipynb"

View File

@@ -23,12 +23,14 @@ Every master obeys one **three-layer contract** (the always-on rules live in
|---|---|---|---| |---|---|---|---|
| **Study** | Reproduction of a dated base document (Forrester TEI or similar), personalized as an overlay | `YYYYMM_TEI_Vendor_Product` / `YYYYMM_Client_Engagement` (dated) | `studies/` | | **Study** | Reproduction of a dated base document (Forrester TEI or similar), personalized as an overlay | `YYYYMM_TEI_Vendor_Product` / `YYYYMM_Client_Engagement` (dated) | `studies/` |
| **Assessment** | Reusable workshop instrument (discovery workshop, diagnostic) | `Instrument_Name` (undated, living) | `assessments/` | | **Assessment** | Reusable workshop instrument (discovery workshop, diagnostic) | `Instrument_Name` (undated, living) | `assessments/` |
| **Calculator** | Prices a vendor's product from that vendor's published rate card — verbatim anchor + client overlay | `Vendor_Subject_Calculator` (undated, living) | `calculators/` |
| **Engagement copy** | A master copied out for a client engagement — acquires client data, becomes **confidential** | `YYYYMM_Client_Instrument`, stamped at copy-time | **outside this repo** | | **Engagement copy** | A master copied out for a client engagement — acquires client data, becomes **confidential** | `YYYYMM_Client_Instrument`, stamped at copy-time | **outside this repo** |
Masters in this repo stay **client-clean**: placeholder engagement data, published or Masters in this repo stay **client-clean**: placeholder engagement data, published or
synthetic numbers, nothing a client said. Patterns: synthetic numbers, nothing a client said. Patterns:
[Assessment](docs/Assessment_Pattern_V1-00.md) · [Assessment](docs/Assessment_Pattern_V1-00.md) ·
[Study](docs/Study_Pattern_V1-00.md) · [Study](docs/Study_Pattern_V1-00.md) ·
[Calculator](docs/Calculator_Pattern_V1-00.md) ·
[shared Mercury mechanics](docs/Mercury_Notebook_Pattern_V1-00.md). [shared Mercury mechanics](docs/Mercury_Notebook_Pattern_V1-00.md).
## Repository layout ## Repository layout
@@ -39,6 +41,8 @@ palladium/
├── assessments/ ├── assessments/
│ ├── CX_Discovery_Workshop/ # ★ reference implementation (notebook-first model) │ ├── CX_Discovery_Workshop/ # ★ reference implementation (notebook-first model)
│ └── CX_AI_Diagnostic/ # capability diagnostic (pre-redesign: generated notebook) │ └── CX_AI_Diagnostic/ # capability diagnostic (pre-redesign: generated notebook)
├── calculators/
│ └── Genesys_Token_Calculator/ # ★ Genesys Cloud AI token cost — published rate card, 2026-07-12
├── studies/ ├── studies/
│ ├── 202512_TEI_Genesys_CX_Cloud/ # Forrester TEI reproduction — NPV $10.8M · ROI 266% │ ├── 202512_TEI_Genesys_CX_Cloud/ # Forrester TEI reproduction — NPV $10.8M · ROI 266%
│ ├── 202602_TEI_Amazon_Connect/ # Forrester TEI reproduction — NPV $78.7M · ROI 342% │ ├── 202602_TEI_Amazon_Connect/ # Forrester TEI reproduction — NPV $78.7M · ROI 342%
@@ -109,7 +113,9 @@ For an **Assessment**, start from the reference implementation and its pattern d
`assessments/CX_Discovery_Workshop/` + [Assessment Pattern](docs/Assessment_Pattern_V1-00.md). `assessments/CX_Discovery_Workshop/` + [Assessment Pattern](docs/Assessment_Pattern_V1-00.md).
For a **Study**, copy `template/MercuryNotebook/` and follow the For a **Study**, copy `template/MercuryNotebook/` and follow the
[Study Pattern](docs/Study_Pattern_V1-00.md) (note: the template still encodes the [Study Pattern](docs/Study_Pattern_V1-00.md) (note: the template still encodes the
py-engine model; its notebook-first rework is a recorded follow-up). Either way: py-engine model; its notebook-first rework is a recorded follow-up). For a
**Calculator**, start from `calculators/Genesys_Token_Calculator/` +
[Calculator Pattern](docs/Calculator_Pattern_V1-00.md). Any of them:
underscores in names (never dashes — directories are Python packages), and register underscores in names (never dashes — directories are Python packages), and register
the new notebook in [tests/nbcheck.py](tests/nbcheck.py) — the completeness test the new notebook in [tests/nbcheck.py](tests/nbcheck.py) — the completeness test
fails until you classify it. fails until you classify it.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,172 @@
# Genesys Cloud AI — Token Calculator
A **Calculator** — a reusable vendor-pricing master (see
[`docs/Calculator_Pattern_V1-00.md`](../../docs/Calculator_Pattern_V1-00.md))
— built on the
[Mercury Notebook Deliverable Pattern](../../docs/Mercury_Notebook_Pattern_V1-00.md).
It prices what Genesys Cloud AI **costs to run**, from the vendor's own
published rate card. Unlike a TEI study it computes no benefits and no NPV:
its "numbers" are a run-rate, a per-feature breakdown, and a range.
The Mercury stage is the screen you share with a client while you size their
AI footprint together; the consultant run-book, the verification gate, and the
machine-readable appendix live backstage and export as LLM input for a
downstream business case.
## The anchor: the vendor's published rate card
The meters are Genesys' own, transcribed verbatim into
[`genesyscalc/ratecard.py`](genesyscalc/ratecard.py):
| Source | Published | What it gives |
|---|---|---|
| [Genesys Cloud tokens model](https://help.genesys.cloud/articles/genesys-cloud-tokens-model/) | **2026-07-12** | 20 token meters, the free monthly allowance, the highest-tier rule, the Copilot exclusion |
| [Genesys Enhanced TTS pricing](https://help.genesys.cloud/articles/genesys-enhanced-tts-pricing/) | **2026-05-22** | $/million characters — **not** token-metered |
That file is **immutable**. When Genesys republishes, re-anchoring is a
deliberate act with its own protocol — see *Re-anchoring* below.
### Four published rules the obvious arithmetic gets wrong
Each is enforced in the engine, pinned by a test, and reported in the
notebook's warnings rather than silently applied:
1. **The 15-second per-call round-up.** *"Genesys rounds up each call to the
next 15-second increment."* Applied per call and then summed — averaging
first understates the bill. A 40-second average bills as 45 (+12.5%); a
5-second bot greeting bills as 15 (3× its duration).
2. **The free monthly allowance.** 250 tokens named / 350 concurrent, which
*"renew each month and do not carry over"* — so it is a real deduction, and
an unused remainder is discarded, never banked.
3. **The highest-tier rule.** *"Genesys bases charges on the highest tier
(price) resource…"* — so the deflection shares are a **partition**, not
overlapping rates. An interaction is charged once, at the highest tier it
touched. Summing tiers independently over-bills.
4. **Copilot covers Supervisor summaries.** Enabling Agent Copilot makes AI
Summary & Insights free; billing both double-charges.
## What the client sees (the stage)
The **published rate card** as the vendor prints it, then three KPI cards
(token cost, text-to-speech, total run-rate with a $/interaction figure), the
allowance shown as the deduction it is, a per-feature cost breakdown, the
list-price → negotiated walk, text-to-speech as its own clearly separated
line, an adoption scenario range, and a tornado of what actually moves the
number.
Text-to-speech is **never** folded silently into the token subtotal: the grand
total always shows tokens and TTS as two labelled components.
You drive it from the sidebar — licence model, currency, token price and
concession, the three deflection shares, average bot seconds, and a
per-feature enable. Every change re-renders the numbers (Mercury re-runs the
cells below the widgets).
## Where the content lives: in the notebook
Two tagged cells of
[`notebooks/genesys_token_calculator.ipynb`](notebooks/genesys_token_calculator.ipynb)
carry everything a consultant edits — **in Jupyter, never in a `.py` file**:
- **`topic-bank`** — the **feature catalogue**: each priced capability's
client-facing title, what it does for them, and the sizing question to ask.
The `key` slugs must match published meter keys (a test pins this), and they
are stable identities the widgets, notes and JSON export all key off.
- **`engagement-data`** — the client facts: volumes by channel, headcount,
licence model, and the **negotiated** commercial terms (contracted rate,
concession). **Illustrative placeholders in this master**; filled in the
engagement copy.
The published **rates** are deliberately *not* content — nobody in this repo
authors them, and editing them is forbidden. They are the vendor's record, so
they live in the engine as an anchor. See the Calculator Pattern for the full
boundary.
`genesyscalc/` holds **code only** — the meter schema, the anchor, and the
arithmetic. The test suite reads the tagged cells straight out of the notebook
(no kernel) and pins content shape, so `pytest` guards the catalogue exactly
as shipped.
## Layout
```
notebooks/genesys_token_calculator.ipynb # THE deliverable — content + data + presentation
# tagged: topic-bank · engagement-data ·
# presentation ×8 · gate · data-appendix
genesyscalc/ # the engine — code only, no content, mypy --strict
ratecard.py # THE VERBATIM ANCHOR — published meters, 2026-07-12
meters.py # schema: Confidence, LicenceModel, MeterBasis, Tier, Meter, TokenPrice
usage.py # volumes → units → tokens (round-up, tier partition, Copilot rule)
billing.py # tokens → dollars (ceil, allowance, price, concession)
tts.py # the NON-token line — own source, own rounding, EOS/EOL
model.py # orchestration: CalculatorInputs → CalculatorResult
sensitivity.py # sweeps + tornado data (no plotting)
appendix.py # the machine-readable JSON payload
staging.py # stage/backstage detection (+ backstage_md)
scripts/export_report.py # execute once → exports/*.html + LLM-ready *.md
tests/ # rate-card tripwire, hand-checked arithmetic, content pins, staging
exports/ # generated report sources (never committed)
```
## Run
```bash
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
mercury --working-dir . # serve the stage (share this screen)
jupyter lab # analyst view
pytest # rate card + engine + content + staging pins
mypy # strict
jupyter nbconvert --to notebook --execute --inplace notebooks/genesys_token_calculator.ipynb
python scripts/export_report.py # exports/*.html + *.md for the LLM handoff
```
The `.md` export opens with a generated preamble (what the document is, the
engagement line, **which rate card produced the numbers**) and ends with the
**Model state (JSON)** block — the machine source of truth, carrying
`rate_card.source_date`. A cost figure without its rate-card date is not
auditable.
## Re-anchoring — when Genesys republishes
The rate card is a *living* anchor: immutable until the vendor changes it,
then updated deliberately. Never partially re-anchor, and never bump the date
without the pins.
1. Diff the published table against `genesyscalc/ratecard.py`.
2. Update the meters **and** `RATE_CARD_SOURCE_DATE` in the same edit.
3. Update the pins in `tests/test_rate_card.py` in the same commit.
4. Re-execute the notebook; update the gate's live-state pins if the defaults moved.
5. Report the cost moves honestly.
6. **Show-first** — the user sees the rate diff and the KPI move before it lands.
`tests/test_rate_card.py` is the tripwire: it pins all 20 rows by exact
feature wording *and* exact rate string, so a silent transcription drift or an
un-noticed republication breaks the build rather than quietly moving a
client-facing number.
## Extending
New or reshaped catalogue content is an edit to the notebook's `topic-bank`
cell in JupyterLab, then re-run the notebook (the **gate** recounts the
catalogue and checks every key against the published meters) and re-pin
`tests/test_catalogue.py`. New *logic* goes in `genesyscalc/` with pins in
`tests/`, **before** the notebook section that renders it.
## Non-goals
- **Not a business case.** No benefits, no NPV, no payback, no ROI, no P&L. If
a client needs those, that is a **Study** — see [`studies/`](../../studies/).
A calculator that grows a benefit model has stopped being a calculator.
- **Not contractual pricing.** A planning model at list rates unless a
contracted rate is entered. Genesys quotes; this estimates.
- **Not a platform TCO.** Genesys Cloud CX seat licensing is out of scope —
this prices the **AI** meters and Enhanced TTS only.
**Running this for a client?** Don't fill client data into this master —
copy the directory out of Palladium, stamp it
`YYYYMM_Client_Genesys_Token_Calculator`, provision a fresh venv there, fill
the `engagement-data` cell in the copy, and treat the copy as confidential
(CLAUDE.md § Confidentiality). A test in this master fails if a client name
lands in `engagement-data` here.

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 calculator root); restart the server to apply.
[main]
title = "Genesys Token Calculator"
favicon_emoji = "🪙"
footer = "Genesys Cloud AI token calculator · NTT DATA"
notebooks_button_label = "Calculators"
[welcome]
header = "Genesys Cloud AI — Token Calculator"
message = """
What Genesys Cloud AI costs to run — open **genesys_token_calculator.ipynb**.
Meters are the vendor's **published** rate card as of **2026-07-12**
([tokens model](https://help.genesys.cloud/articles/genesys-cloud-tokens-model/)),
with Enhanced TTS priced separately from its own published page (2026-05-22).
Set volumes and headcount, move the sidebar controls to test deflection and
commercial terms, and read the per-feature breakdown. A planning model — list
rates unless overridden, never a quote. Export with
`python scripts/export_report.py`.
"""
[theme]
# ── Type — Georgia headings, Arial body (web-safe; no network fetch). ──
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"
muted_text_color = "#586671"
# ── Surfaces — white content on a soft neutral canvas ──
background_color = "#f4f5f6"
content_background_color = "#ffffff"
surface_color = "#ffffff"
card_background_color = "#f8f8f8"
border_color = "#d5d9db"
border_radius = "10px"
# ── Accents — Future Blue ──
primary_color = "#0072bc"
accent_color = "#0072bc"
focus_border_color = "#0072bc"
hover_background_color = "#eef5fb"
selected_background_color = "#dcecfa"
# ── State colors — brand Success / Warning / Error (docs/brand.md) ──
success_color = "#00cb5d"
warning_color = "#ffc400"
danger_color = "#e42600"
slider_track_color = "#e2e6e9"
# ── 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 ──
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 ──
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 ──
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,142 @@
"""Genesys Cloud AI token cost calculator — the engine.
Math only. The vendor's published rate card is the immutable anchor in
:mod:`genesyscalc.ratecard`; everything the consultant authors or the client
supplies lives in the notebook's tagged cells. See
``docs/Calculator_Pattern_V1-00.md``.
"""
from __future__ import annotations
from .appendix import result_json
from .billing import (
MONTHS_PER_YEAR,
CostLine,
CostTotals,
allowance_for,
billable_tokens_monthly,
cost_lines,
total_cost,
)
from .meters import (
Confidence,
LicenceModel,
Meter,
MeterBasis,
Tier,
TokenPrice,
)
from .model import (
CalculatorInputs,
CalculatorResult,
calculate,
compare,
cost_per_interaction,
metered_units,
)
from .ratecard import (
ALLOWANCE_CARRIES_OVER,
COPILOT_COVERS_SUMMARY_RULE,
FREE_TOKENS_PER_MONTH,
HIGHEST_TIER_RULE,
LIST_PRICE_BY_CURRENCY,
METERS,
METERS_VERBATIM,
RATE_CARD_SOURCE,
RATE_CARD_SOURCE_DATE,
VOICE_BOT_ROUNDUP_SECONDS,
ZERO_RATED_VERBATIM,
meter,
per_user_meter,
rate_card_rows,
)
from .sensitivity import DRIVERS, sweep, tornado
from .staging import backstage, backstage_md, on_stage
from .tts import (
TTS_PRICE_PER_MILLION_CHARS,
TTS_SOURCE,
TTS_SOURCE_DATE,
TTS_STANDARD_END_OF_SALE,
TTS_STANDARD_END_OF_SUPPORT,
TtsLine,
TtsUsage,
tts_cost,
tts_free_share,
)
from .usage import (
DeflectionMix,
FeatureUsage,
VoiceBotUsage,
Volumes,
apply_copilot_covers_summary,
subscription_tokens,
virtual_agent_tokens,
voice_bot_billable_minutes,
voice_bot_roundup_uplift,
voice_bot_tokens,
)
__version__ = "0.1.0"
__all__ = [
"ALLOWANCE_CARRIES_OVER",
"COPILOT_COVERS_SUMMARY_RULE",
"DRIVERS",
"FREE_TOKENS_PER_MONTH",
"HIGHEST_TIER_RULE",
"LIST_PRICE_BY_CURRENCY",
"METERS",
"METERS_VERBATIM",
"MONTHS_PER_YEAR",
"RATE_CARD_SOURCE",
"RATE_CARD_SOURCE_DATE",
"TTS_PRICE_PER_MILLION_CHARS",
"TTS_SOURCE",
"TTS_SOURCE_DATE",
"TTS_STANDARD_END_OF_SALE",
"TTS_STANDARD_END_OF_SUPPORT",
"VOICE_BOT_ROUNDUP_SECONDS",
"ZERO_RATED_VERBATIM",
"CalculatorInputs",
"CalculatorResult",
"Confidence",
"CostLine",
"CostTotals",
"DeflectionMix",
"FeatureUsage",
"LicenceModel",
"Meter",
"MeterBasis",
"Tier",
"TokenPrice",
"TtsLine",
"TtsUsage",
"VoiceBotUsage",
"Volumes",
"__version__",
"allowance_for",
"apply_copilot_covers_summary",
"backstage",
"backstage_md",
"billable_tokens_monthly",
"calculate",
"compare",
"cost_lines",
"cost_per_interaction",
"meter",
"metered_units",
"on_stage",
"per_user_meter",
"rate_card_rows",
"result_json",
"subscription_tokens",
"sweep",
"tornado",
"total_cost",
"tts_cost",
"tts_free_share",
"virtual_agent_tokens",
"voice_bot_billable_minutes",
"voice_bot_roundup_uplift",
"voice_bot_tokens",
]

View File

@@ -0,0 +1,126 @@
"""The machine-readable model state (Mercury Notebook Pattern §5).
Plotly figures export as JavaScript an LLM cannot read, so the exported
``.md`` ends with one JSON block carrying everything the figures showed.
``rate_card.source_date`` is the most important field here: it tells a
consumer of the export *which* published rate card produced these numbers.
A cost figure without its rate-card date is not auditable.
"""
from __future__ import annotations
from typing import Any
from .model import CalculatorInputs, CalculatorResult, cost_per_interaction
from .ratecard import (
RATE_CARD_SOURCE,
RATE_CARD_SOURCE_DATE,
VOICE_BOT_ROUNDUP_SECONDS,
)
from .tts import TTS_SOURCE, TTS_SOURCE_DATE
def result_json(
result: CalculatorResult,
inputs: CalculatorInputs,
meta: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""The export payload: provenance, inputs, lines, totals, warnings."""
totals = result.totals
payload: dict[str, Any] = {
"rate_card": {
"source": RATE_CARD_SOURCE,
"source_date": RATE_CARD_SOURCE_DATE,
"voice_bot_roundup_seconds": VOICE_BOT_ROUNDUP_SECONDS,
},
"tts_source": {"source": TTS_SOURCE, "source_date": TTS_SOURCE_DATE},
"inputs": {
"users": inputs.users,
"licence_model": inputs.licence.value,
"enabled_features": sorted(inputs.enabled),
"apply_allowance": inputs.apply_allowance,
"volumes": {
k: v for k, v in vars(inputs.volumes).items() if not k.startswith("_")
},
"deflection_mix": {
"bot_only_share": inputs.mix.bot_only_share,
"virtual_agent_share": inputs.mix.virtual_agent_share,
"agentic_va_share": inputs.mix.agentic_va_share,
"agent_handled_share": inputs.mix.agent_handled_share,
},
"price": {
"currency": inputs.price.currency,
"list_rate": inputs.price.list_rate,
"contracted_rate": inputs.price.contracted_rate,
"concession_pct": inputs.price.concession_pct,
"effective_rate": inputs.price.effective_rate(),
},
"voice_bot": (
{
"calls_per_month": inputs.voice_bot.calls_per_month,
"avg_bot_seconds_per_call": (
inputs.voice_bot.avg_bot_seconds_per_call
),
}
if inputs.voice_bot is not None
else None
),
"tts": (
{
"calls_monthly": inputs.tts.calls_monthly,
"chars_per_call": inputs.tts.chars_per_call,
"tier": inputs.tts.tier,
}
if inputs.tts is not None
else None
),
},
"lines": [
{
"key": line.key,
"feature": line.feature,
"units_monthly": line.units_monthly,
"unit_label": line.unit_label,
"tokens_monthly": line.tokens_monthly,
"cost_monthly": line.cost_monthly,
"cost_annual": line.cost_annual,
"confidence": line.confidence.value,
}
for line in result.lines
],
"totals": {
"consumption_tokens_monthly": totals.consumption_tokens_monthly,
"subscription_tokens_monthly": totals.subscription_tokens_monthly,
"gross_tokens_monthly": totals.gross_tokens_monthly,
"allowance_tokens_monthly": totals.allowance_tokens_monthly,
"billable_tokens_monthly": totals.billable_tokens_monthly,
"currency": totals.currency,
"effective_rate": totals.effective_rate,
"token_cost_monthly": totals.token_cost_monthly,
"token_cost_annual": totals.token_cost_annual,
"list_cost_annual": totals.list_cost_annual,
"concession_saving_annual": totals.concession_saving_annual,
"tts_cost_annual": result.tts_cost_annual,
"grand_total_annual": result.grand_total_annual,
"cost_per_interaction": cost_per_interaction(result, inputs.volumes),
},
"tts": (
{
"tier": result.tts.tier,
"rate_per_million_chars": result.tts.rate_per_million_chars,
"total_chars_monthly": result.tts.total_chars_monthly,
"free_share": result.tts.free_share,
"billable_chars_monthly": result.tts.billable_chars_monthly,
"billed_millions_monthly": result.tts.billed_millions_monthly,
"cost_monthly": result.tts.cost_monthly,
"cost_annual": result.tts.cost_annual,
}
if result.tts is not None
else None
),
"warnings": list(result.warnings),
}
if meta:
payload["meta"] = meta
return payload

View File

@@ -0,0 +1,151 @@
"""Tokens → dollars.
The order of operations is the whole content of this module, and it is
pinned by tests because getting it wrong moves the bill:
1. Round consumption up to whole tokens, monthly. Per-user subscription
totals are exact and are NOT rounded — only metered consumption is.
2. Deduct the free monthly allowance (250 named / 350 concurrent).
3. Floor at zero. The allowance renews monthly and "do[es] not carry over
to future months", so an unused remainder is discarded, never banked.
Everything commercial — the contracted rate, the concession — arrives via
:class:`genesyscalc.meters.TokenPrice` from the notebook's engagement-data
cell. This module knows arithmetic, not deals.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from .meters import Confidence, LicenceModel, TokenPrice
from .ratecard import FREE_TOKENS_PER_MONTH, meter
MONTHS_PER_YEAR: int = 12
@dataclass(frozen=True)
class CostLine:
"""One feature's monthly and annual cost, ready to render."""
key: str
feature: str
units_monthly: float
unit_label: str
tokens_monthly: float
cost_monthly: float
cost_annual: float
confidence: Confidence
source_url: str | None
note: str = ""
@dataclass(frozen=True)
class CostTotals:
"""The token bill, with the allowance and the MSRP walk both visible."""
consumption_tokens_monthly: float
subscription_tokens_monthly: float
gross_tokens_monthly: float
allowance_tokens_monthly: int
billable_tokens_monthly: int
effective_rate: float
currency: str
token_cost_monthly: float
token_cost_annual: float
list_cost_annual: float
concession_saving_annual: float
def billable_tokens_monthly(
consumption_tokens: float,
subscription_tokens: float,
licence: LicenceModel,
apply_allowance: bool = True,
) -> int:
"""Whole tokens actually billed in a month.
``ceil`` lands on the consumption subtotal (Genesys bills whole tokens);
subscription totals are already whole and exact. The allowance is then
deducted and the result floored at zero — it does not carry over, so an
unused remainder is simply lost.
"""
if consumption_tokens < 0 or subscription_tokens < 0:
raise ValueError("token counts must not be negative")
gross = math.ceil(consumption_tokens) + subscription_tokens
allowance = FREE_TOKENS_PER_MONTH[licence] if apply_allowance else 0
return max(0, int(gross) - allowance)
def allowance_for(licence: LicenceModel, apply_allowance: bool = True) -> int:
"""The free monthly token allowance actually in play."""
return FREE_TOKENS_PER_MONTH[licence] if apply_allowance else 0
def cost_lines(
tokens_by_key: dict[str, float], units_by_key: dict[str, float], price: TokenPrice
) -> tuple[CostLine, ...]:
"""One :class:`CostLine` per meter, in published order, at the effective rate.
Line costs are unrounded and un-allowanced: the allowance is an
org-level deduction, not a per-feature one, so attributing it to a
single line would misstate that feature's economics. It lands in
:func:`total_cost`.
"""
rate = price.effective_rate()
lines: list[CostLine] = []
for key in sorted(tokens_by_key, key=lambda k: meter(k).feature):
m = meter(key)
tokens = tokens_by_key[key]
monthly = tokens * rate
lines.append(
CostLine(
key=key,
feature=m.feature,
units_monthly=units_by_key.get(key, 0.0),
unit_label=m.unit_label,
tokens_monthly=tokens,
cost_monthly=monthly,
cost_annual=monthly * MONTHS_PER_YEAR,
confidence=m.confidence,
source_url=m.source_url,
note=m.note,
)
)
return tuple(lines)
def total_cost(
consumption_tokens_monthly: float,
subscription_tokens_monthly: float,
licence: LicenceModel,
price: TokenPrice,
apply_allowance: bool = True,
) -> CostTotals:
"""The org-level bill: allowance deducted, MSRP walk carried alongside."""
billable = billable_tokens_monthly(
consumption_tokens_monthly,
subscription_tokens_monthly,
licence,
apply_allowance,
)
rate = price.effective_rate()
monthly = billable * rate
annual = monthly * MONTHS_PER_YEAR
list_annual = billable * price.list_rate * MONTHS_PER_YEAR
return CostTotals(
consumption_tokens_monthly=consumption_tokens_monthly,
subscription_tokens_monthly=subscription_tokens_monthly,
gross_tokens_monthly=(
math.ceil(consumption_tokens_monthly) + subscription_tokens_monthly
),
allowance_tokens_monthly=allowance_for(licence, apply_allowance),
billable_tokens_monthly=billable,
effective_rate=rate,
currency=price.currency,
token_cost_monthly=monthly,
token_cost_annual=annual,
list_cost_annual=list_annual,
concession_saving_annual=list_annual - annual,
)

View File

@@ -0,0 +1,165 @@
"""Schema for the rate card — types only, no rates.
Rates live in :mod:`genesyscalc.ratecard` as an immutable verbatim anchor.
This module defines what a metered feature *is*, so the anchor can be typed
and the arithmetic can be tested without either knowing the other's
business.
The invariants in ``__post_init__`` are the point: a meter flagged
:attr:`Confidence.CONFIRMED` that carries no source URL and date cannot be
constructed. That is what stops an unsourced number wearing a green tick on
a client-facing stage.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Confidence(Enum):
"""How much weight a number carries. Rendered on stage as an icon."""
CONFIRMED = "confirmed" # published by the vendor, with a source URL + date
ESTIMATED = "estimated" # working assumption, stated
UNKNOWN = "unknown" # not sourced; bound it with sensitivity if material
@property
def icon(self) -> str:
return {"confirmed": "🟢", "estimated": "🟡", "unknown": "🔴"}[self.value]
class LicenceModel(Enum):
"""Genesys prices per-user AI features differently by licence model."""
NAMED = "named"
CONCURRENT = "concurrent"
@property
def label(self) -> str:
return self.value.capitalize()
class MeterBasis(Enum):
"""How a feature converts activity into tokens."""
PER_USER_PER_MONTH = "per_user_per_month" # Copilot 40/60, STA 30/45
UNITS_PER_TOKEN = "units_per_token" # "51 sessions per token"
TOKENS_PER_UNIT = "tokens_per_unit" # "0.5 tokens per interaction"
VOICE_MINUTES = "voice_minutes" # 17 min/token, 15s per-call round-up
ZERO_RATED = "zero_rated" # "no charge for token usage"
class Tier(Enum):
"""Ordering for the published highest-tier rule.
"Genesys bases charges on the highest tier (price) resource that Genesys
Cloud uses during the interaction." Higher value = higher tier = wins.
An interaction is charged once, at the highest tier it touched — see
:class:`genesyscalc.usage.DeflectionMix`.
"""
BOT = 1
VIRTUAL_AGENT = 2
AGENTIC_VIRTUAL_AGENT = 3
@dataclass(frozen=True)
class Meter:
"""One line of the vendor's published rate card.
``feature`` and ``published_rate`` hold the article's *exact* wording;
``rate`` holds the parsed number the arithmetic uses. Keeping both is
what makes a transcription slip catchable — ``test_rate_card`` pins the
strings, and a separate test proves the float agrees with the string.
"""
key: str
feature: str # the article's exact feature name
published_rate: str # the article's exact rate wording
basis: MeterBasis
rate: float = 0.0 # tokens per unit (0.0 for per-user/zero-rated)
per_user_named: float | None = None
per_user_concurrent: float | None = None
tier: Tier | None = None
confidence: Confidence = Confidence.CONFIRMED
source_url: str | None = None
source_date: str | None = None
unit_label: str = "units"
note: str = ""
def __post_init__(self) -> None:
if self.rate < 0:
raise ValueError(f"{self.key}: rate must not be negative")
if self.confidence is Confidence.CONFIRMED and not (
self.source_url and self.source_date
):
raise ValueError(
f"{self.key}: a confirmed (🟢) meter must carry a source URL and date"
)
per_user = self.basis is MeterBasis.PER_USER_PER_MONTH
has_pair = (
self.per_user_named is not None and self.per_user_concurrent is not None
)
if per_user != has_pair:
raise ValueError(
f"{self.key}: per-user rates are required for "
f"{MeterBasis.PER_USER_PER_MONTH.value} and forbidden otherwise"
)
if self.basis is MeterBasis.ZERO_RATED and self.rate != 0.0:
raise ValueError(f"{self.key}: a zero-rated meter must have rate 0.0")
def tokens_per_user_month(self, licence: LicenceModel) -> float:
"""Subscription tokens for one user for one month."""
if self.basis is not MeterBasis.PER_USER_PER_MONTH:
raise ValueError(f"{self.key} is not a per-user meter")
rate = (
self.per_user_named
if licence is LicenceModel.NAMED
else self.per_user_concurrent
)
assert rate is not None # guaranteed by __post_init__
return rate
def tokens_for(self, units: float) -> float:
"""Consumption tokens for ``units`` of activity.
Voice-bot minutes are NOT priced here — they carry a per-call
round-up that needs call counts, so
:func:`genesyscalc.usage.voice_bot_tokens` owns that path.
"""
if self.basis in (MeterBasis.ZERO_RATED,):
return 0.0
if self.basis is MeterBasis.PER_USER_PER_MONTH:
raise ValueError(f"{self.key}: use tokens_per_user_month()")
return units * self.rate
@dataclass(frozen=True)
class TokenPrice:
"""What one token costs, list versus negotiated.
``contracted_rate`` (a signed per-token rate) wins outright when set.
Otherwise the concession is a percentage off list — the lever a
negotiation actually moves. Both are client data and arrive from the
notebook's ``engagement-data`` cell, never from the anchor.
"""
currency: str = "USD"
list_rate: float = 1.00
contracted_rate: float | None = None
concession_pct: float = 0.0
def __post_init__(self) -> None:
if self.list_rate <= 0:
raise ValueError("list_rate must be positive")
if not 0.0 <= self.concession_pct < 1.0:
raise ValueError("concession_pct must be in [0, 1)")
if self.contracted_rate is not None and self.contracted_rate < 0:
raise ValueError("contracted_rate must not be negative")
def effective_rate(self) -> float:
"""The rate actually billed per token."""
if self.contracted_rate is not None:
return self.contracted_rate
return self.list_rate * (1.0 - self.concession_pct)

View File

@@ -0,0 +1,270 @@
"""Orchestration — one call in, the whole costed picture out.
The notebook assembles :class:`CalculatorInputs` from its ``engagement-data``
cell and its widgets, calls :func:`calculate`, and renders the result. The
engine never invents a volume and never reads a widget.
:attr:`CalculatorResult.warnings` is deliberate: when a published rule makes
a number smaller — Copilot covering summaries, the allowance absorbing a
month, deflection zeroing TTS — the result says so. A total that shrank for
a reason nobody can see is indistinguishable from a bug.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from typing import Any
from .billing import CostLine, CostTotals, cost_lines, total_cost
from .meters import LicenceModel, MeterBasis, TokenPrice
from .ratecard import meter
from .tts import TtsLine, TtsUsage, tts_cost
from .usage import (
DeflectionMix,
VoiceBotUsage,
Volumes,
apply_copilot_covers_summary,
subscription_tokens,
virtual_agent_tokens,
voice_bot_billable_minutes,
voice_bot_roundup_uplift,
voice_bot_tokens,
)
#: Meter keys whose units come straight off a channel volume. The notebook's
#: feature catalogue decides which are switched on; this maps each to the
#: volume that drives it.
_VOLUME_DRIVEN: dict[str, str] = {
"bots_digital": "digital_sessions_monthly",
"ai_summary_and_insights": "voice_total_monthly",
"ai_scoring": "evaluations_monthly",
"ai_translate": "translations_monthly",
"predictive_routing": "routed_interactions_monthly",
"genesys_cloud_copilot": "ai_actions_monthly",
"apple_messages_for_business": "messaging_monthly",
"facebook_messenger": "messaging_monthly",
"instagram_direct_messaging": "messaging_monthly",
"whatsapp_messaging": "messaging_monthly",
"x_direct_messaging": "messaging_monthly",
"genesys_cloud_social": "social_posts_monthly",
"social_post_responses": "social_responses_monthly",
"predictive_engagement": "all_interactions_monthly",
"knowledge_queries": "all_interactions_monthly",
}
#: The per-user subscription meters, by licence-model face.
_PER_USER_KEYS: frozenset[str] = frozenset(
{
"agent_copilot_named",
"agent_copilot_concurrent",
"speech_and_text_analytics_named",
"speech_and_text_analytics_concurrent",
}
)
#: Deflection-driven meters — priced from the highest-tier partition, not a
#: raw volume.
_TIER_KEYS: frozenset[str] = frozenset({"virtual_agent", "agentic_virtual_agent"})
#: The Copilot family, in either licence face.
_COPILOT_KEYS: frozenset[str] = frozenset(
{"agent_copilot_named", "agent_copilot_concurrent"}
)
def _volume_for(volumes: Volumes, attr: str) -> float:
return float(getattr(volumes, attr))
@dataclass(frozen=True)
class CalculatorInputs:
"""Everything the notebook supplies. Client data plus widget state."""
volumes: Volumes
users: int
licence: LicenceModel = LicenceModel.NAMED
enabled: frozenset[str] = frozenset()
mix: DeflectionMix = field(default_factory=DeflectionMix)
price: TokenPrice = field(default_factory=TokenPrice)
voice_bot: VoiceBotUsage | None = None
tts: TtsUsage | None = None
apply_allowance: bool = True
def __post_init__(self) -> None:
if self.users < 0:
raise ValueError("users must not be negative")
unknown = sorted(k for k in self.enabled if k not in _known_keys())
if unknown:
raise ValueError(
f"enabled contains keys that are not published meters: {unknown}"
)
@property
def copilot_enabled(self) -> bool:
return bool(self.enabled & _COPILOT_KEYS)
def with_(self, **changes: Any) -> CalculatorInputs:
"""A copy with fields replaced — for scenarios and sensitivity."""
return replace(self, **changes)
def _known_keys() -> frozenset[str]:
from .ratecard import METERS
return frozenset(METERS)
@dataclass(frozen=True)
class CalculatorResult:
"""The costed picture, plus why any number shrank."""
lines: tuple[CostLine, ...]
totals: CostTotals
tts: TtsLine | None
grand_total_annual: float
warnings: tuple[str, ...]
@property
def token_cost_annual(self) -> float:
return self.totals.token_cost_annual
@property
def tts_cost_annual(self) -> float:
return self.tts.cost_annual if self.tts is not None else 0.0
def metered_units(inputs: CalculatorInputs) -> dict[str, float]:
"""Monthly metered units per enabled meter key, before the Copilot rule."""
units: dict[str, float] = {}
for key in sorted(inputs.enabled):
if key in _PER_USER_KEYS:
units[key] = float(inputs.users)
elif key in _TIER_KEYS:
share = (
inputs.mix.virtual_agent_share
if key == "virtual_agent"
else inputs.mix.agentic_va_share
)
units[key] = inputs.volumes.voice_total_monthly * share
elif key == "bots_voice":
units[key] = (
voice_bot_billable_minutes(inputs.voice_bot)
if inputs.voice_bot is not None
else 0.0
)
elif key in _VOLUME_DRIVEN:
units[key] = _volume_for(inputs.volumes, _VOLUME_DRIVEN[key])
else: # pragma: no cover — every published key is classified above
raise ValueError(f"{key} has no unit driver")
return units
def calculate(inputs: CalculatorInputs) -> CalculatorResult:
"""Price a scenario end to end."""
raw_units = metered_units(inputs)
usage = apply_copilot_covers_summary(raw_units, inputs.copilot_enabled)
units = usage.units
warnings: list[str] = list(usage.notes)
tokens: dict[str, float] = {}
consumption = 0.0
# Per-user subscription meters — exact, never rounded.
per_user_enabled = frozenset(inputs.enabled & _PER_USER_KEYS)
subs = subscription_tokens(inputs.users, per_user_enabled, inputs.licence)
tokens.update(subs)
subscription = sum(subs.values())
# Voice bots — minutes with the published per-call round-up.
if "bots_voice" in inputs.enabled and inputs.voice_bot is not None:
bot_tokens = voice_bot_tokens(inputs.voice_bot)
tokens["bots_voice"] = bot_tokens
consumption += bot_tokens
uplift = voice_bot_roundup_uplift(inputs.voice_bot)
if uplift > 0:
warnings.append(
f"Voice bots: the published 15-second per-call round-up adds "
f"{uplift:.1%} to billable bot minutes at a "
f"{inputs.voice_bot.avg_bot_seconds_per_call:.0f}s average."
)
# Virtual agents — the highest-tier partition of voice volume.
tier_tokens = virtual_agent_tokens(
inputs.volumes.voice_total_monthly, inputs.mix
)
for key in sorted(_TIER_KEYS & inputs.enabled):
tokens[key] = tier_tokens.get(key, 0.0)
consumption += tokens[key]
# Everything else — units × published rate.
for key in sorted(inputs.enabled):
if key in _PER_USER_KEYS or key in _TIER_KEYS or key == "bots_voice":
continue
m = meter(key)
value = m.tokens_for(units.get(key, 0.0))
tokens[key] = value
consumption += value
if m.basis is MeterBasis.ZERO_RATED and units.get(key, 0.0) > 0:
warnings.append(
f"{m.feature}: {m.published_rate} — shown at $0 because "
"Genesys publishes it as included."
)
totals = total_cost(
consumption, subscription, inputs.licence, inputs.price, inputs.apply_allowance
)
if inputs.apply_allowance and totals.billable_tokens_monthly == 0:
warnings.append(
f"The free monthly allowance ({totals.allowance_tokens_monthly} "
f"tokens, {inputs.licence.label.lower()} org) covers this month's "
"consumption entirely."
)
tts_line = (
tts_cost(inputs.tts, inputs.mix, inputs.price.concession_pct)
if inputs.tts is not None
else None
)
if tts_line is not None:
warnings.extend(tts_line.warnings)
lines = cost_lines(tokens, units, inputs.price)
grand_total = totals.token_cost_annual + (
tts_line.cost_annual if tts_line is not None else 0.0
)
return CalculatorResult(
lines=lines,
totals=totals,
tts=tts_line,
grand_total_annual=grand_total,
warnings=tuple(warnings),
)
def cost_per_interaction(result: CalculatorResult, volumes: Volumes) -> float:
"""Annual grand total ÷ annual interactions — the number clients recall."""
annual = volumes.all_interactions_monthly * 12
if annual == 0:
return 0.0
return result.grand_total_annual / annual
def compare(scenarios: dict[str, CalculatorInputs]) -> list[dict[str, Any]]:
"""One row per scenario. Cost only — a calculator does not build a case."""
rows: list[dict[str, Any]] = []
for name, inputs in scenarios.items():
result = calculate(inputs)
rows.append(
{
"Scenario": name,
"Billable tokens / mo": result.totals.billable_tokens_monthly,
"Token cost / yr": result.totals.token_cost_annual,
"TTS cost / yr": result.tts_cost_annual,
"Total / yr": result.grand_total_annual,
"$ / interaction": cost_per_interaction(result, inputs.volumes),
}
)
return rows

View File

@@ -0,0 +1,358 @@
"""THE VERBATIM ANCHOR — Genesys Cloud tokens model, as published.
Transcribed from
https://help.genesys.cloud/articles/genesys-cloud-tokens-model/
**last updated 2026-07-12**. Feature names and rate strings below are the
article's own wording, character for character.
VERBATIM — DO NOT EDIT. A vendor republication is a deliberate
**re-anchoring**, not a patch (docs/Calculator_Pattern_V1-00.md):
1. diff the published table against this file
2. update the meters AND bump RATE_CARD_SOURCE_DATE in the same edit
3. update the pins in tests/test_rate_card.py in the same commit
4. re-execute the notebook
5. report the cost moves honestly
6. Show-first — the user sees the rate diff before it lands
Never partially re-anchor; never bump the date without the pins.
Negotiated or contracted values NEVER land here. They are the overlay, and
they live in the notebook's ``engagement-data`` cell — this file is the
vendor's record, not the client's deal.
"""
from __future__ import annotations
from .meters import LicenceModel, Meter, MeterBasis, Tier
RATE_CARD_SOURCE: str = "https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
RATE_CARD_SOURCE_DATE: str = "2026-07-12"
# Every meter below is published on the same page on the same date, so the
# source pair is spelled out once here and passed explicitly. Explicit beats
# a splat: this file is diffed against the article by eye.
_URL = RATE_CARD_SOURCE
_DATE = RATE_CARD_SOURCE_DATE
#: The published meter table, verbatim. Twenty rows, in the article's order.
METERS_VERBATIM: tuple[Meter, ...] = (
Meter(
key="bots_voice",
feature="Bots (Voice)",
published_rate="17 minutes per token",
basis=MeterBasis.VOICE_MINUTES,
rate=1.0 / 17.0,
tier=Tier.BOT,
unit_label="bot minutes",
note=(
"Genesys rounds up each call to the next 15-second increment "
"(clarified 2026-07-12)."
),
source_url=_URL,
source_date=_DATE,
),
Meter(
key="bots_digital",
feature="Bots (Digital)",
published_rate="51 sessions per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 51.0,
tier=Tier.BOT,
unit_label="sessions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="virtual_agent",
feature="Virtual Agent",
published_rate="0.5 tokens per Virtual Agent interaction",
basis=MeterBasis.TOKENS_PER_UNIT,
rate=0.5,
tier=Tier.VIRTUAL_AGENT,
unit_label="interactions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="agentic_virtual_agent",
feature="Agentic Virtual Agent",
published_rate="1.2 tokens per interaction",
basis=MeterBasis.TOKENS_PER_UNIT,
rate=1.2,
tier=Tier.AGENTIC_VIRTUAL_AGENT,
unit_label="interactions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="agent_copilot_named",
feature="Agent Copilot [named]",
published_rate="40 tokens per user",
basis=MeterBasis.PER_USER_PER_MONTH,
per_user_named=40.0,
per_user_concurrent=60.0,
unit_label="users",
note="Enabling Copilot makes Supervisor summaries and insights free.",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="agent_copilot_concurrent",
feature="Agent Copilot [concurrent]",
published_rate="60 tokens per user",
basis=MeterBasis.PER_USER_PER_MONTH,
per_user_named=40.0,
per_user_concurrent=60.0,
unit_label="users",
note="The concurrent-licence face of the same meter.",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="ai_scoring",
feature="AI Scoring",
published_rate="20 evaluations per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 20.0,
unit_label="evaluations",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="ai_translate",
feature="AI Translate",
published_rate="2 translations per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 2.0,
unit_label="translations",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="ai_summary_and_insights",
feature="AI Summary and Insights",
published_rate="50 summaries/insights per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 50.0,
unit_label="summaries",
note=(
"Free when Agent Copilot is enabled — see "
"COPILOT_COVERS_SUMMARY_RULE."
),
source_url=_URL,
source_date=_DATE,
),
Meter(
key="apple_messages_for_business",
feature="Apple Messages for Business",
published_rate="400 inbound or outbound messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="facebook_messenger",
feature="Facebook Messenger",
published_rate="400 messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="instagram_direct_messaging",
feature="Instagram Direct Messaging",
published_rate="400 messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="whatsapp_messaging",
feature="WhatsApp Messaging",
published_rate="400 messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="x_direct_messaging",
feature="X Direct Messaging",
published_rate="400 messages per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="genesys_cloud_social",
feature="Genesys Cloud Social",
published_rate="400 social post ingestions per channel per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="post ingestions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="social_post_responses",
feature="Social Post Responses",
published_rate="400 outbound messages per channel per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 400.0,
unit_label="outbound messages",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="predictive_routing",
feature="Predictive Routing",
published_rate="17 routes per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 17.0,
unit_label="routes",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="speech_and_text_analytics_named",
feature="Speech and Text Analytics [named]",
published_rate="30 tokens per user",
basis=MeterBasis.PER_USER_PER_MONTH,
per_user_named=30.0,
per_user_concurrent=45.0,
unit_label="users",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="speech_and_text_analytics_concurrent",
feature="Speech and Text Analytics [concurrent]",
published_rate="45 tokens per user",
basis=MeterBasis.PER_USER_PER_MONTH,
per_user_named=30.0,
per_user_concurrent=45.0,
unit_label="users",
note="The concurrent-licence face of the same meter.",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="genesys_cloud_copilot",
feature="Genesys Cloud Copilot",
published_rate="20 AI actions per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0 / 20.0,
unit_label="AI actions",
note="Genesys Cloud knowledge queries are not charged.",
source_url=_URL,
source_date=_DATE,
),
)
#: Published as included at no token cost. Shown on stage as explicit $0
#: lines — a zero the vendor put in writing is a finding, not an omission.
ZERO_RATED_VERBATIM: tuple[Meter, ...] = (
Meter(
key="predictive_engagement",
feature="Predictive Engagement",
published_rate="No charge for token usage",
basis=MeterBasis.ZERO_RATED,
unit_label="interactions",
source_url=_URL,
source_date=_DATE,
),
Meter(
key="knowledge_queries",
feature="Genesys Cloud knowledge queries",
published_rate="no charge for Genesys Cloud knowledge queries",
basis=MeterBasis.ZERO_RATED,
unit_label="queries",
source_url=_URL,
source_date=_DATE,
),
)
#: "Named organizations receive 250 tokens; concurrent organizations receive
#: 350 tokens" per month.
FREE_TOKENS_PER_MONTH: dict[LicenceModel, int] = {
LicenceModel.NAMED: 250,
LicenceModel.CONCURRENT: 350,
}
#: "These tokens renew each month and do not carry over to future months."
ALLOWANCE_CARRIES_OVER: bool = False
#: "Genesys rounds up each call to the next 15-second increment."
#: Clarified in the 2026-07-12 revision. Applied PER CALL, then summed.
VOICE_BOT_ROUNDUP_SECONDS: int = 15
#: The published highest-tier rule, verbatim. Renders on stage.
HIGHEST_TIER_RULE: str = (
"In cases where an interaction uses multiple AI resources, such as bot "
"flows, virtual agents, and agentic virtual agents, Genesys bases charges "
"on the highest tier (price) resource that Genesys Cloud uses during the "
"interaction."
)
#: The published Copilot exclusion, verbatim. Renders on stage.
COPILOT_COVERS_SUMMARY_RULE: str = (
"if you enable Agent Copilot simultaneously, then Supervisor Copilot "
"summaries and insights do not consume tokens"
)
#: List price per token. The article points to the pricing hub and cites
#: "USD 1.00 to JPY 120 per token by currency".
LIST_PRICE_BY_CURRENCY: dict[str, float] = {"USD": 1.00, "JPY": 120.0}
#: Every meter by key — the consumption table plus the zero-rated lines.
METERS: dict[str, Meter] = {
m.key: m for m in METERS_VERBATIM + ZERO_RATED_VERBATIM
}
def meter(key: str) -> Meter:
"""Look up a published meter, with a useful error when the key is wrong."""
try:
return METERS[key]
except KeyError:
raise KeyError(
f"{key!r} is not a published Genesys meter. Known keys: "
f"{', '.join(sorted(METERS))}"
) from None
def per_user_meter(licence: LicenceModel, family: str) -> Meter:
"""The Copilot / STA meter face matching ``licence``.
``family`` is ``"agent_copilot"`` or ``"speech_and_text_analytics"``.
Both rates live on both faces, so this is presentation sugar — it picks
the row whose published wording matches the licence being modelled.
"""
return meter(f"{family}_{licence.value}")
def rate_card_rows() -> list[dict[str, str]]:
"""The published table as display rows, in publication order."""
return [
{
"Feature": m.feature,
"Published rate": m.published_rate,
"Confidence": m.confidence.icon,
"Source": m.source_date or "",
}
for m in METERS_VERBATIM + ZERO_RATED_VERBATIM
]

View File

@@ -0,0 +1,133 @@
"""One-at-a-time sweeps and tornado data.
Data only — the notebook draws the figures. Drivers are addressed by a
short dotted path so the notebook can name them without reaching into the
dataclasses itself.
"""
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import replace
from typing import Any
from .model import CalculatorInputs, CalculatorResult, calculate
#: Driver name → (container attribute on CalculatorInputs, field on it).
#: ``None`` container means the field sits directly on CalculatorInputs.
DRIVERS: dict[str, tuple[str | None, str]] = {
"token_price": ("price", "list_rate"),
"concession_pct": ("price", "concession_pct"),
"users": (None, "users"),
"voice_volume": ("volumes", "voice_inbound_monthly"),
"bot_share": ("mix", "bot_only_share"),
"virtual_agent_share": ("mix", "virtual_agent_share"),
"agentic_va_share": ("mix", "agentic_va_share"),
"bot_seconds": ("voice_bot", "avg_bot_seconds_per_call"),
"tts_chars_per_call": ("tts", "chars_per_call"),
}
def driver_value(inputs: CalculatorInputs, driver: str) -> float:
"""The current value of ``driver`` in ``inputs``."""
container, attr = _resolve(driver)
target = inputs if container is None else getattr(inputs, container)
if target is None:
raise ValueError(f"{driver} is not active in this scenario")
return float(getattr(target, attr))
def set_driver(inputs: CalculatorInputs, driver: str, value: float) -> CalculatorInputs:
"""A copy of ``inputs`` with ``driver`` set to ``value``."""
container, attr = _resolve(driver)
if container is None:
return inputs.with_(**{attr: _coerce(attr, value)})
current = getattr(inputs, container)
if current is None:
raise ValueError(f"{driver} is not active in this scenario")
return inputs.with_(**{container: replace(current, **{attr: _coerce(attr, value)})})
def _resolve(driver: str) -> tuple[str | None, str]:
try:
return DRIVERS[driver]
except KeyError:
raise KeyError(
f"{driver!r} is not a known driver. Known: {', '.join(sorted(DRIVERS))}"
) from None
def _coerce(attr: str, value: float) -> Any:
"""``users`` and ``chars_per_call`` are integers by contract."""
if attr in ("users", "chars_per_call"):
return int(round(value))
return float(value)
def sweep(
base: CalculatorInputs, driver: str, values: Sequence[float]
) -> list[dict[str, float]]:
"""Grand total across a range of one driver, everything else held."""
rows: list[dict[str, float]] = []
for value in values:
result: CalculatorResult = calculate(set_driver(base, driver, value))
rows.append(
{
driver: float(value),
"token_cost_annual": result.totals.token_cost_annual,
"tts_cost_annual": result.tts_cost_annual,
"grand_total_annual": result.grand_total_annual,
}
)
return rows
def tornado(
base: CalculatorInputs, drivers: Sequence[str], delta: float = 0.25
) -> list[dict[str, Any]]:
"""±``delta`` on each driver in turn, sorted by the swing it causes.
Drivers that are not active in the scenario (no TTS, no voice bot) are
skipped rather than raising — a tornado over an inactive lever is noise.
Shares are clamped to keep the highest-tier partition legal; a driver
whose low and high both clamp to the same value contributes no swing.
"""
if not 0.0 < delta < 1.0:
raise ValueError("delta must be in (0, 1)")
baseline = calculate(base).grand_total_annual
rows: list[dict[str, Any]] = []
for driver in drivers:
try:
current = driver_value(base, driver)
except (ValueError, KeyError):
continue
low_value = current * (1.0 - delta)
high_value = current * (1.0 + delta)
if driver.endswith("_share"):
low_value = max(0.0, min(1.0, low_value))
high_value = max(0.0, min(1.0, high_value))
try:
low = calculate(set_driver(base, driver, low_value)).grand_total_annual
high = calculate(set_driver(base, driver, high_value)).grand_total_annual
except ValueError:
# e.g. raising a share would break the partition — skip the lever
continue
rows.append(
{
"driver": driver,
"baseline_value": current,
"low_value": low_value,
"high_value": high_value,
"low": low,
"high": high,
"baseline": baseline,
"swing": abs(high - low),
}
)
return sorted(rows, key=lambda r: float(r["swing"]), reverse=True)

View File

@@ -0,0 +1,61 @@
"""
Stage vs backstage — is this notebook render stakeholder-facing?
The Mercury app (3.2.x) is a hybrid Jupyter server: the SAME server (and
kernel pool) can serve both the client-facing app view and JupyterLab, so a
server-level signal cannot tell who is looking. The reliable, per-view
signal is the session name: the app runs every session against a shadow
copy named ``<notebook>__mercury__<id>.ipynb``
(``mercury_app/handlers.py``), and the kernel sees that path in
``JPY_SESSION_NAME``. JupyterLab and nbconvert sessions carry the plain
notebook path (or no session name at all).
``MERCURY_CONFIG_DIR`` is kept as a fallback: ``mercury --working-dir …``
exports it into the server process and every kernel inherits it. It is a
server-level signal — on such a server even JupyterLab kernels carry it, so
diagnostics are then hidden in that Lab view too (hidden, never leaked; use
a separate ``jupyter lab`` for analysis, which is the normal workflow). It
is NOT set when mercury is launched without ``--working-dir``, which is why
it cannot be the primary signal.
Diagnostics routed through :func:`backstage` / :func:`backstage_md` 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
from typing import Any
def on_stage() -> bool:
"""True when this kernel renders the client-facing Mercury app view."""
if "__mercury__" in os.getenv("JPY_SESSION_NAME", ""):
return True # the app's shadow-copy session
return os.getenv("MERCURY_CONFIG_DIR") is not None
def backstage(*args: object, **kwargs: Any) -> None:
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
if not on_stage():
print(*args, **kwargs)
def backstage_md(text: str) -> None:
"""Markdown that renders only backstage (JupyterLab, nbconvert).
Emitted as a ``text/markdown`` display, so nbconvert's markdown export
carries it verbatim — a stream ``print`` would be indented as a code
block. Falls back to ``print`` when IPython isn't importable (plain
pytest), where ``display`` itself already degrades to ``print``.
"""
if on_stage():
return
try:
from IPython.display import Markdown, display
except ImportError:
print(text)
return
display(Markdown(text)) # type: ignore[no-untyped-call]

View File

@@ -0,0 +1,147 @@
"""Genesys Enhanced text-to-speech — billed in dollars, NOT in tokens.
A separate published page, a separate meter, a separate subtotal:
https://help.genesys.cloud/articles/genesys-enhanced-tts-pricing/
**last updated 2026-05-22**.
TTS never appears in the tokens-model article and is never converted to
tokens here. It is carried because Genesys AI spend is understated without
it — but it is reported as its own line, and the grand total shows tokens
and TTS as two labelled components rather than one blended figure.
Three published facts the obvious model misses:
* **"The monthly billing unit is one million characters, rounded up."**
200,000 characters bills as $5; 1,000,005 characters bills as $10.
* **Free during virtual agents.** "Genesys Enhanced TTS is included free of
charge when you use a virtual agent or agentic virtual agent during the
interaction" — so deflection cuts this line as well as the token line.
* **Standard voices are ending.** End of sale 2026-03-29, end of support
2026-08-05. Selecting Standard for a future-state model is a finding.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from .meters import Confidence
from .usage import DeflectionMix
TTS_SOURCE: str = "https://help.genesys.cloud/articles/genesys-enhanced-tts-pricing/"
TTS_SOURCE_DATE: str = "2026-05-22"
#: "$20 per one million TTS characters" (advanced: NTTS, neural, wavenet)
#: and "$5 per one million TTS characters" (standard).
TTS_PRICE_PER_MILLION_CHARS: dict[str, float] = {"advanced": 20.0, "standard": 5.0}
#: "The monthly billing unit is one million characters, rounded up."
TTS_BILLING_UNIT_CHARS: int = 1_000_000
#: Standard Enhanced TTS lifecycle — published end-of-sale / end-of-support.
TTS_STANDARD_END_OF_SALE: str = "2026-03-29"
TTS_STANDARD_END_OF_SUPPORT: str = "2026-08-05"
#: Published, so 🟢 — unlike the tokens article, this page states the rates.
TTS_CONFIDENCE: Confidence = Confidence.CONFIRMED
MONTHS_PER_YEAR: int = 12
@dataclass(frozen=True)
class TtsUsage:
"""Synthesised speech volume. Client data, from ``engagement-data``.
"Usage includes all TTS characters, including letters, numbers,
punctuation, and spaces."
"""
calls_monthly: float
chars_per_call: int = 2_000
tier: str = "advanced"
def __post_init__(self) -> None:
if self.calls_monthly < 0:
raise ValueError("calls_monthly must not be negative")
if self.chars_per_call < 0:
raise ValueError("chars_per_call must not be negative")
if self.tier not in TTS_PRICE_PER_MILLION_CHARS:
raise ValueError(
f"unknown TTS tier {self.tier!r}; known: "
f"{', '.join(sorted(TTS_PRICE_PER_MILLION_CHARS))}"
)
@dataclass(frozen=True)
class TtsLine:
"""The TTS bill, reported separately from the token bill."""
tier: str
rate_per_million_chars: float
total_chars_monthly: float
free_share: float
free_chars_monthly: float
billable_chars_monthly: float
billed_millions_monthly: int
cost_monthly: float
cost_annual: float
confidence: Confidence
source_url: str
source_date: str
warnings: tuple[str, ...] = ()
def tts_free_share(mix: DeflectionMix) -> float:
"""Share of TTS characters Genesys does not charge for.
"included free of charge when you use a virtual agent or agentic virtual
agent during the interaction". Reuses the same partition as the token
model, so the two lines cannot disagree about how much was deflected.
"""
return min(1.0, mix.virtual_agent_share + mix.agentic_va_share)
def tts_cost(
usage: TtsUsage, mix: DeflectionMix | None = None, concession_pct: float = 0.0
) -> TtsLine:
"""The monthly and annual TTS bill, with the published per-1M round-up.
The round-up lands on the *billable* character total each month, after
the virtual-agent free share is removed.
"""
if not 0.0 <= concession_pct < 1.0:
raise ValueError("concession_pct must be in [0, 1)")
free_share = tts_free_share(mix) if mix is not None else 0.0
total_chars = usage.calls_monthly * usage.chars_per_call
free_chars = total_chars * free_share
billable_chars = total_chars - free_chars
billed_millions = math.ceil(billable_chars / TTS_BILLING_UNIT_CHARS)
rate = TTS_PRICE_PER_MILLION_CHARS[usage.tier]
monthly = billed_millions * rate * (1.0 - concession_pct)
warnings: list[str] = []
if usage.tier == "standard":
warnings.append(
"Standard Enhanced TTS reached end of sale on "
f"{TTS_STANDARD_END_OF_SALE} and end of support on "
f"{TTS_STANDARD_END_OF_SUPPORT} — price a future state on "
"advanced voices."
)
return TtsLine(
tier=usage.tier,
rate_per_million_chars=rate,
total_chars_monthly=total_chars,
free_share=free_share,
free_chars_monthly=free_chars,
billable_chars_monthly=billable_chars,
billed_millions_monthly=billed_millions,
cost_monthly=monthly,
cost_annual=monthly * MONTHS_PER_YEAR,
confidence=TTS_CONFIDENCE,
source_url=TTS_SOURCE,
source_date=TTS_SOURCE_DATE,
warnings=tuple(warnings),
)

View File

@@ -0,0 +1,262 @@
"""Volumes → metered units → tokens.
Three published rules are modelled here that the obvious arithmetic gets
wrong. Each one is traceable to a sentence in the tokens-model article:
1. **The 15-second per-call round-up.** "Genesys rounds up each call to the
next 15-second increment." Applied per call and *then* summed — averaging
first and rounding once understates the bill, badly, on short bot touches.
2. **The highest-tier rule.** "Genesys bases charges on the highest tier
(price) resource that Genesys Cloud uses during the interaction." So the
AI-resource shares of a volume are a **partition**, not overlapping slices:
an interaction is charged once, at the highest tier it touched.
3. **Copilot covers Supervisor summaries.** "if you enable Agent Copilot
simultaneously, then Supervisor Copilot summaries and insights do not
consume tokens." Billing AI Summary while Copilot is on double-charges.
"""
from __future__ import annotations
import math
from dataclasses import dataclass, field
from .meters import LicenceModel, MeterBasis, Tier
from .ratecard import VOICE_BOT_ROUNDUP_SECONDS, meter
#: Meters covered by the Copilot exclusion when Agent Copilot is enabled.
COPILOT_COVERED_METERS: frozenset[str] = frozenset({"ai_summary_and_insights"})
#: Which per-interaction meter prices each tier of the partition.
TIER_METER: dict[Tier, str] = {
Tier.VIRTUAL_AGENT: "virtual_agent",
Tier.AGENTIC_VIRTUAL_AGENT: "agentic_virtual_agent",
}
@dataclass(frozen=True)
class VoiceBotUsage:
"""Voice-bot activity, in the shape the round-up needs.
The round-up is per *call*, so a call count is required — an aggregate
minute total cannot be billed correctly.
"""
calls_per_month: float
avg_bot_seconds_per_call: float
def __post_init__(self) -> None:
if self.calls_per_month < 0:
raise ValueError("calls_per_month must not be negative")
if self.avg_bot_seconds_per_call < 0:
raise ValueError("avg_bot_seconds_per_call must not be negative")
@dataclass(frozen=True)
class DeflectionMix:
"""Shares of interactions by the **highest tier** each one touched.
This is a partition, not a set of overlapping rates. ``bot_only_share``
is the share whose highest resource was a bot flow; an interaction that
escalated bot → virtual agent counts once, in ``virtual_agent_share``.
The shares therefore sum to at most 1.0 — the remainder went straight to
an agent and consumed no AI-resource token.
Modelling these as independent percentages (and summing the tokens) is
the over-billing bug the published rule exists to prevent.
"""
bot_only_share: float = 0.0
virtual_agent_share: float = 0.0
agentic_va_share: float = 0.0
def __post_init__(self) -> None:
for name, value in (
("bot_only_share", self.bot_only_share),
("virtual_agent_share", self.virtual_agent_share),
("agentic_va_share", self.agentic_va_share),
):
if not 0.0 <= value <= 1.0:
raise ValueError(f"{name} must be in [0, 1], got {value}")
if self.total_deflected_share > 1.0 + 1e-9:
raise ValueError(
"tier shares must sum to at most 1.0 — they partition the "
"volume by highest tier touched, they do not overlap "
f"(got {self.total_deflected_share})"
)
@property
def total_deflected_share(self) -> float:
"""Share of interactions that touched any AI resource."""
return self.bot_only_share + self.virtual_agent_share + self.agentic_va_share
@property
def agent_handled_share(self) -> float:
"""Share that reached a human without touching an AI resource."""
return 1.0 - self.total_deflected_share
@dataclass(frozen=True)
class Volumes:
"""Monthly interaction volumes by channel. Client data — from the
notebook's ``engagement-data`` cell, never invented by the engine."""
voice_inbound_monthly: float = 0.0
voice_outbound_monthly: float = 0.0
digital_sessions_monthly: float = 0.0
email_monthly: float = 0.0
messaging_monthly: float = 0.0
social_posts_monthly: float = 0.0
social_responses_monthly: float = 0.0
translations_monthly: float = 0.0
evaluations_monthly: float = 0.0
ai_actions_monthly: float = 0.0
routed_interactions_monthly: float = 0.0
def __post_init__(self) -> None:
for name, value in vars(self).items():
if value < 0:
raise ValueError(f"{name} must not be negative")
@property
def voice_total_monthly(self) -> float:
return self.voice_inbound_monthly + self.voice_outbound_monthly
@property
def all_interactions_monthly(self) -> float:
return (
self.voice_total_monthly
+ self.digital_sessions_monthly
+ self.email_monthly
+ self.messaging_monthly
)
def voice_bot_billable_minutes(
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
) -> float:
"""Billable bot minutes per month, with the published per-call round-up.
"Genesys rounds up each call to the next 15-second increment." The
round-up lands on every call, so it is applied first and summed after::
per_call = ceil(seconds / 15) * 15 / 60
total = per_call * calls
A 40-second average bills as 45 seconds — 0.75 min/call, not 0.667. The
penalty is worst on short touches: a 5-second bot greeting bills as 15
seconds, three times its duration.
"""
if roundup_seconds <= 0:
raise ValueError("roundup_seconds must be positive")
increments = math.ceil(usage.avg_bot_seconds_per_call / roundup_seconds)
billable_seconds_per_call = increments * roundup_seconds
return billable_seconds_per_call / 60.0 * usage.calls_per_month
def voice_bot_roundup_uplift(
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
) -> float:
"""How much the round-up adds, as a fraction of the raw duration.
The number worth saying out loud on stage: at a 40-second average it is
0.125 (12.5% more bot cost than the raw minutes suggest).
"""
raw_minutes = usage.avg_bot_seconds_per_call / 60.0 * usage.calls_per_month
if raw_minutes == 0:
return 0.0
return voice_bot_billable_minutes(usage, roundup_seconds) / raw_minutes - 1.0
def voice_bot_tokens(
usage: VoiceBotUsage, roundup_seconds: int = VOICE_BOT_ROUNDUP_SECONDS
) -> float:
"""Voice-bot tokens per month: billable minutes ÷ 17."""
minutes = voice_bot_billable_minutes(usage, roundup_seconds)
return minutes * meter("bots_voice").rate
def tier_interactions(volume: float, mix: DeflectionMix) -> dict[Tier, float]:
"""Split ``volume`` across tiers by highest resource touched.
A partition — the returned values sum to at most ``volume``, so no
interaction can be billed twice.
"""
if volume < 0:
raise ValueError("volume must not be negative")
return {
Tier.BOT: volume * mix.bot_only_share,
Tier.VIRTUAL_AGENT: volume * mix.virtual_agent_share,
Tier.AGENTIC_VIRTUAL_AGENT: volume * mix.agentic_va_share,
}
def virtual_agent_tokens(volume: float, mix: DeflectionMix) -> dict[str, float]:
"""Tokens for the VA and Agentic VA tiers of a deflection partition.
The bot tier is deliberately absent: voice bots are metered in minutes
with a per-call round-up, so :func:`voice_bot_tokens` owns that line.
"""
split = tier_interactions(volume, mix)
return {
TIER_METER[tier]: meter(TIER_METER[tier]).tokens_for(interactions)
for tier, interactions in split.items()
if tier in TIER_METER
}
@dataclass(frozen=True)
class FeatureUsage:
"""Metered units per meter key, plus the notes explaining any zeroing."""
units: dict[str, float] = field(default_factory=dict)
notes: tuple[str, ...] = ()
def get(self, key: str) -> float:
return self.units.get(key, 0.0)
def apply_copilot_covers_summary(
units: dict[str, float], copilot_enabled: bool
) -> FeatureUsage:
"""Zero the summary meters when Agent Copilot is enabled.
"if you enable Agent Copilot simultaneously, then Supervisor Copilot
summaries and insights do not consume tokens." The zeroing is reported,
not silent — a number that shrank for a published reason should say so.
"""
if not copilot_enabled:
return FeatureUsage(units=dict(units))
adjusted = dict(units)
notes: list[str] = []
for key in sorted(COPILOT_COVERED_METERS):
covered = adjusted.get(key, 0.0)
if covered > 0:
adjusted[key] = 0.0
notes.append(
f"{meter(key).feature}: {covered:,.0f} units not billed — "
"Agent Copilot is enabled, and Genesys does not charge "
"Supervisor summaries and insights alongside it."
)
return FeatureUsage(units=adjusted, notes=tuple(notes))
def subscription_tokens(
users: int, enabled_per_user_keys: frozenset[str], licence: LicenceModel
) -> dict[str, float]:
"""Per-user subscription tokens per month, by meter key.
Exact — per-user totals are not rounded; only consumption is (see
:mod:`genesyscalc.billing`).
"""
if users < 0:
raise ValueError("users must not be negative")
out: dict[str, float] = {}
for key in sorted(enabled_per_user_keys):
m = meter(key)
if m.basis is not MeterBasis.PER_USER_PER_MONTH:
raise ValueError(f"{key} is not a per-user meter")
out[key] = users * m.tokens_per_user_month(licence)
return out

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,36 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "genesyscalc"
version = "0.1.0"
description = "Genesys Cloud AI token cost calculator — published rate card, 2026-07-12 (Mercury Notebook Pattern)"
requires-python = ">=3.10"
# The notebook is the deliverable (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",
"mercury>=3.2",
"jupyterlab>=4.0",
"ipywidgets>=8.0",
"nbconvert>=7",
"nbformat>=5.9",
"tabulate>=0.9",
]
[project.optional-dependencies]
dev = ["pytest>=7.4", "mypy>=1.8"]
[tool.setuptools.packages.find]
include = ["genesyscalc*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.mypy]
strict = true
packages = ["genesyscalc"]

View File

@@ -0,0 +1,140 @@
"""Export the token calculator as an LLM-readable report source.
Executes the notebook ONCE (widget defaults — or whatever scenario you've
set and saved in the notebook), then converts the executed copy twice:
exports/genesys_token_calculator.html — human-reviewable, full presentation
exports/genesys_token_calculator.md — leanest LLM input: presentation-tagged
cells (setup, widgets, tables, charts)
are stripped, a framing preamble is
prepended, and the data appendix ends
the file with one fenced JSON block —
the machine source of truth.
Engagement identity (client, date, preparer) is read from the notebook's
``engagement-data`` cell and stamped into the preamble, alongside the
rate-card source date — a cost figure without its rate card is not auditable.
Run from the calculator root: python scripts/export_report.py
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
ROOT = Path(__file__).resolve().parent.parent
NOTEBOOK = ROOT / "notebooks" / "genesys_token_calculator.ipynb"
EXPORTS = ROOT / "exports"
# Cells tagged with any of these never reach the .md export — they are stage
# presentation (source and widget-repr noise), not the priced record.
STRIP_TAGS_FROM_MD = '{"presentation"}'
def engagement_data() -> dict[str, Any]:
"""Exec the engagement-data cell (same trick as tests/conftest.py)."""
import nbformat
nb = nbformat.read(NOTEBOOK, as_version=4)
cells = [c for c in nb.cells if "engagement-data" in c.metadata.get("tags", [])]
assert len(cells) == 1, "expected exactly one engagement-data cell"
ns: dict[str, Any] = {}
exec(compile(cells[0].source, f"{NOTEBOOK.name} [engagement-data]", "exec"), ns)
return ns["ENGAGEMENT"] # type: ignore[no-any-return]
def preamble() -> str:
sys.path.insert(0, str(ROOT))
from genesyscalc.ratecard import RATE_CARD_SOURCE, RATE_CARD_SOURCE_DATE
from genesyscalc.tts import TTS_SOURCE, TTS_SOURCE_DATE
eng = engagement_data()
who = " · ".join(
str(eng[k]).strip()
for k in ("client", "prepared_date", "prepared_by")
if str(eng.get(k, "")).strip()
)
line = who or "master copy — illustrative placeholders; no client data."
return "\n".join(
[
"<!-- Export preamble — generated by scripts/export_report.py -->",
"**What this is** — a costed Genesys Cloud AI scenario, produced from",
"the Mercury-served token calculator. It is a **planning model**: list",
"rates unless a contracted rate was entered, and never a quote.",
"",
f"**Engagement** — {line}",
"",
f"**Rate card** — [{RATE_CARD_SOURCE}]({RATE_CARD_SOURCE}), published",
f"**{RATE_CARD_SOURCE_DATE}**. Text-to-speech is priced separately from",
f"[{TTS_SOURCE}]({TTS_SOURCE}) (published {TTS_SOURCE_DATE}) and is",
"billed per character, not in tokens — it is reported as its own line",
"and never folded into the token subtotal.",
"",
"**How to read it** — first the content as annotated source (the feature",
"catalogue, then the engagement data), then the scenario state, the",
"verification gate, and finally the data appendix: the published rate",
"card, the per-feature cost table, the scenario comparison, the applied",
"rules and warnings, and — last — **Model state (JSON)**, one fenced",
"`json` block. Where prose and JSON disagree, the JSON block is the",
"source of truth; its `rate_card.source_date` says which published rate",
"card produced these numbers.",
"",
"---",
"",
"",
]
)
def main() -> None:
if len(sys.argv) > 1 and sys.argv[1] not in NOTEBOOK.name:
sys.exit(f"no notebook matches {sys.argv[1]!r}")
EXPORTS.mkdir(exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
executed = Path(tmp) / NOTEBOOK.name
# 1. Execute once — both formats convert the same scenario state.
# (Never combine --execute with TagRemovePreprocessor in one call:
# the cell could be stripped before it runs.)
subprocess.run(
[
sys.executable, "-m", "nbconvert", "--execute",
"--to", "notebook", "--output", str(executed), str(NOTEBOOK),
],
check=True, cwd=ROOT,
)
# 2. HTML — full presentation, human review artifact.
subprocess.run(
[
sys.executable, "-m", "nbconvert", "--to", "html",
"--output-dir", str(EXPORTS), "--output", NOTEBOOK.stem,
str(executed),
],
check=True, cwd=ROOT,
)
# 3. Markdown — LLM artifact: strip presentation cells.
subprocess.run(
[
sys.executable, "-m", "nbconvert", "--to", "markdown",
"--output-dir", str(EXPORTS), "--output", NOTEBOOK.stem,
"--TagRemovePreprocessor.enabled=True",
f"--TagRemovePreprocessor.remove_cell_tags={STRIP_TAGS_FROM_MD}",
str(executed),
],
check=True, cwd=ROOT,
)
# 4. Prepend the framing preamble to the markdown export.
md = EXPORTS / f"{NOTEBOOK.stem}.md"
md.write_text(preamble() + md.read_text(encoding="utf-8"), encoding="utf-8")
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,70 @@
"""Test plumbing: import path + notebook content served from tagged cells.
Content and client data live in the notebook, never in ``.py`` — the cells
tagged ``topic-bank`` (the feature catalogue) and ``engagement-data`` (the
client's volumes and commercial terms) in
``notebooks/genesys_token_calculator.ipynb``. The fixtures below read those
cells with nbformat and exec them, so pytest pins the exact content the
deliverable ships (no kernel needed — tagged cells are self-contained by
contract).
The published Genesys rate card is deliberately NOT here: it is a vendor
record nobody in this repo authors, so it lives in ``genesyscalc/ratecard.py``
as an immutable anchor and is pinned by ``test_rate_card.py`` (see
docs/Calculator_Pattern_V1-00.md).
The sys.path insert makes genesyscalc importable even without the master
venv active (the normal setup is ``pip install -e ".[dev]"`` into the
master-local ``.venv/``).
"""
from __future__ import annotations
import pathlib
import sys
from typing import Any
import pytest
MASTER_ROOT = pathlib.Path(__file__).resolve().parent.parent
sys.path.insert(0, str(MASTER_ROOT))
NOTEBOOK = MASTER_ROOT / "notebooks" / "genesys_token_calculator.ipynb"
def tagged_cell_ns(tag: str) -> dict[str, Any]:
"""Exec the single cell carrying ``tag`` and return its namespace."""
import nbformat
nb = nbformat.read(NOTEBOOK, as_version=4)
cells = [c for c in nb.cells if tag in c.metadata.get("tags", [])]
assert len(cells) == 1, (
f"expected exactly one cell tagged {tag!r} in {NOTEBOOK.name}, "
f"found {len(cells)}"
)
ns: dict[str, Any] = {}
exec(compile(cells[0].source, f"{NOTEBOOK.name} [{tag}]", "exec"), ns)
return ns
@pytest.fixture(scope="session")
def catalogue_ns() -> dict[str, Any]:
"""The executed namespace of the notebook's topic-bank cell."""
return tagged_cell_ns("topic-bank")
@pytest.fixture(scope="session")
def features(catalogue_ns: dict[str, Any]) -> tuple[Any, ...]:
"""The FEATURES tuple as the deliverable defines it."""
return catalogue_ns["FEATURES"] # type: ignore[no-any-return]
@pytest.fixture(scope="session")
def feature_by_key(features: tuple[Any, ...]) -> dict[str, Any]:
return {f.key: f for f in features}
@pytest.fixture(scope="session")
def engagement() -> dict[str, Any]:
"""The ENGAGEMENT dict as the deliverable's engagement-data cell ships it."""
return tagged_cell_ns("engagement-data")["ENGAGEMENT"] # type: ignore[no-any-return]

View File

@@ -0,0 +1,154 @@
"""Tokens → dollars: the allowance, the rounding, and the commercial levers.
Hand-checked first, then pinned.
"""
from __future__ import annotations
import pytest
from genesyscalc.billing import (
MONTHS_PER_YEAR,
allowance_for,
billable_tokens_monthly,
cost_lines,
total_cost,
)
from genesyscalc.meters import LicenceModel, TokenPrice
NAMED = LicenceModel.NAMED
CONCURRENT = LicenceModel.CONCURRENT
# ── the free monthly allowance ───────────────────────────────────────────
def test_free_allowance_deducted_named() -> None:
"""1,000 consumption tokens, named org: 1,000 250 = 750 billable."""
assert billable_tokens_monthly(1_000, 0, NAMED) == 750
def test_free_allowance_deducted_concurrent() -> None:
"""Same consumption, concurrent org: 1,000 350 = 650."""
assert billable_tokens_monthly(1_000, 0, CONCURRENT) == 650
def test_allowance_floors_at_zero() -> None:
assert billable_tokens_monthly(120, 0, NAMED) == 0
def test_allowance_does_not_carry_over() -> None:
"""Two quiet months do not bank credit for a busy third.
120 → 0 billable. 120 → 0 billable. 400 → 150 billable, NOT 0: the
unused 130 + 130 is discarded, because the allowance "renew[s] each
month and do[es] not carry over to future months".
"""
assert billable_tokens_monthly(120, 0, NAMED) == 0
assert billable_tokens_monthly(120, 0, NAMED) == 0
assert billable_tokens_monthly(400, 0, NAMED) == 150
def test_allowance_can_be_switched_off() -> None:
"""So the gap is measurable on stage rather than merely asserted."""
assert billable_tokens_monthly(1_000, 0, NAMED, apply_allowance=False) == 1_000
assert allowance_for(NAMED, apply_allowance=False) == 0
assert allowance_for(NAMED) == 250
def test_allowance_applies_across_consumption_and_subscription() -> None:
"""It is an org-level deduction, not a per-line one.
44.12 consumption → ceil 45; + 20,000 subscription = 20,045; 250 =
19,795.
"""
assert billable_tokens_monthly(44.117647, 20_000, NAMED) == 19_795
# ── rounding ─────────────────────────────────────────────────────────────
def test_consumption_tokens_rounded_up_monthly() -> None:
"""44.117647 tokens of bot time bills as 45."""
assert billable_tokens_monthly(44.117647, 0, NAMED, apply_allowance=False) == 45
def test_per_user_tokens_are_exact_not_rounded() -> None:
"""500 named users × 40 = 20,000 exactly — no ceil on subscription."""
assert billable_tokens_monthly(0, 20_000, NAMED, apply_allowance=False) == 20_000
def test_negative_tokens_rejected() -> None:
with pytest.raises(ValueError, match="must not be negative"):
billable_tokens_monthly(-1, 0, NAMED)
# ── price, concession, currency ──────────────────────────────────────────
def test_concession_applies_to_msrp() -> None:
"""$1.00 list, 30% concession → $0.70/token; 1,000 tokens → $700/mo."""
price = TokenPrice(list_rate=1.00, concession_pct=0.30)
assert price.effective_rate() == pytest.approx(0.70)
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
assert totals.token_cost_monthly == pytest.approx(700.0)
assert totals.token_cost_annual == pytest.approx(8_400.0)
def test_msrp_walk_is_carried_alongside() -> None:
"""The deck-frame column: list beside effective, and the saving."""
price = TokenPrice(list_rate=1.00, concession_pct=0.30)
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
assert totals.list_cost_annual == pytest.approx(12_000.0)
assert totals.concession_saving_annual == pytest.approx(3_600.0)
assert totals.token_cost_annual <= totals.list_cost_annual
def test_contracted_rate_overrides_concession() -> None:
price = TokenPrice(list_rate=1.00, contracted_rate=0.55, concession_pct=0.30)
assert price.effective_rate() == pytest.approx(0.55)
def test_currency_carried_through() -> None:
price = TokenPrice(currency="JPY", list_rate=120.0)
totals = total_cost(1_000, 0, NAMED, price, apply_allowance=False)
assert totals.currency == "JPY"
assert totals.token_cost_monthly == pytest.approx(120_000.0)
def test_invalid_concession_rejected() -> None:
with pytest.raises(ValueError, match="concession_pct"):
TokenPrice(concession_pct=1.0)
# ── cost lines ───────────────────────────────────────────────────────────
def test_cost_lines_price_each_meter_at_the_effective_rate() -> None:
price = TokenPrice(list_rate=1.00, concession_pct=0.25)
lines = cost_lines(
{"ai_scoring": 100.0, "virtual_agent": 7_500.0},
{"ai_scoring": 2_000.0, "virtual_agent": 15_000.0},
price,
)
by_key = {line.key: line for line in lines}
assert by_key["ai_scoring"].cost_monthly == pytest.approx(75.0)
assert by_key["virtual_agent"].cost_monthly == pytest.approx(5_625.0)
assert by_key["virtual_agent"].cost_annual == pytest.approx(
5_625.0 * MONTHS_PER_YEAR
)
assert by_key["ai_scoring"].units_monthly == pytest.approx(2_000.0)
assert by_key["ai_scoring"].unit_label == "evaluations"
def test_cost_lines_are_ordered_by_feature_name() -> None:
lines = cost_lines(
{"virtual_agent": 1.0, "ai_scoring": 1.0}, {}, TokenPrice()
)
assert [line.feature for line in lines] == ["AI Scoring", "Virtual Agent"]
def test_totals_expose_the_allowance_that_was_applied() -> None:
totals = total_cost(1_000, 20_000, NAMED, TokenPrice())
assert totals.allowance_tokens_monthly == 250
assert totals.gross_tokens_monthly == pytest.approx(21_000.0)
assert totals.billable_tokens_monthly == 20_750

View File

@@ -0,0 +1,94 @@
"""The feature catalogue — content pins, read from the notebook by tag.
The catalogue is the master's authored content: it lives in the notebook's
``topic-bank`` cell, and these tests exec that cell rather than importing a
module. The published RATES are not here — they are the vendor's record, in
``genesyscalc/ratecard.py``, pinned by ``test_rate_card.py``.
"""
from __future__ import annotations
from typing import Any
from genesyscalc.ratecard import METERS
EXPECTED_KEYS = (
"agent_copilot_named",
"speech_and_text_analytics_named",
"bots_voice",
"virtual_agent",
"agentic_virtual_agent",
"ai_summary_and_insights",
"ai_scoring",
"bots_digital",
"ai_translate",
"predictive_routing",
"whatsapp_messaging",
"genesys_cloud_copilot",
"predictive_engagement",
)
def test_catalogue_size(features: tuple[Any, ...]) -> None:
assert len(features) == 13
def test_catalogue_keys_and_order(features: tuple[Any, ...]) -> None:
"""Keys are stable identities — widgets, notes and the export key off them."""
assert tuple(f.key for f in features) == EXPECTED_KEYS
def test_keys_are_unique(features: tuple[Any, ...]) -> None:
assert len({f.key for f in features}) == len(features)
def test_every_catalogue_key_is_a_published_meter(features: tuple[Any, ...]) -> None:
"""The cross-layer tie: a rename here silently unprices a feature."""
for f in features:
assert f.key in METERS, f"{f.key} is not a published Genesys meter"
def test_every_feature_carries_its_content(features: tuple[Any, ...]) -> None:
for f in features:
assert f.title.strip(), f"{f.key} has no title"
assert f.description.strip(), f"{f.key} has no description"
assert f.ask.strip(), f"{f.key} has no sizing question"
assert f.ask.strip().endswith(("?", ")")), (
f"{f.key}: `ask` should read as a question to put to the client"
)
def test_titles_are_distinct(features: tuple[Any, ...]) -> None:
"""Titles become widget labels; duplicates collide in the sidebar."""
titles = [f.title for f in features]
assert len(set(titles)) == len(titles)
def test_default_scenario_is_a_coherent_starting_point(
features: tuple[Any, ...],
) -> None:
"""Headless nbconvert takes every widget at its seed, so the defaults
must form a scenario the gate can pass — and a plausible one to show."""
default_on = {f.key for f in features if f.default_on}
assert len(default_on) == 8
assert "agent_copilot_named" in default_on
assert "bots_voice" in default_on
# Copilot on by default means the summary line must be visible but free —
# that pairing is the published exclusion the calculator demonstrates.
assert "ai_summary_and_insights" in default_on
def test_catalogue_uses_a_schema_not_loose_dicts(features: tuple[Any, ...]) -> None:
"""Content is structured, so the notebook and tests agree on its shape."""
first = features[0]
assert type(first).__name__ == "Feature"
assert {"key", "title", "description", "ask", "default_on"} <= set(
type(first).__dataclass_fields__
)
def test_catalogue_cell_is_self_contained(catalogue_ns: dict[str, Any]) -> None:
"""It execs with no setup cell — the conftest fixture proves it, but pin
the contract explicitly so a stray dependency is caught here."""
assert "FEATURES" in catalogue_ns
assert "Feature" in catalogue_ns

View File

@@ -0,0 +1,115 @@
"""Engagement data — SHAPE pins only.
The master ships placeholders; an engagement copy fills them in. So these
tests pin the *shape* of the cell and never its emptiness — asserting that
``client`` is blank would fail the moment the copy is used for real, which
is precisely backwards.
"""
from __future__ import annotations
from typing import Any
import nbformat
from tests.conftest import NOTEBOOK
IDENTITY_KEYS = {"client", "prepared_date", "prepared_by"}
VOLUME_KEYS = {
"voice_inbound_monthly",
"voice_outbound_monthly",
"digital_sessions_monthly",
"email_monthly",
"messaging_monthly",
"social_posts_monthly",
"social_responses_monthly",
"translations_monthly",
"evaluations_monthly",
"ai_actions_monthly",
"routed_interactions_monthly",
}
COMMERCIAL_KEYS = {
"agent_users",
"licence_model",
"currency",
"contracted_rate_per_token",
"concession_pct",
}
TTS_KEYS = {"tts_chars_per_call", "tts_voice_tier"}
def test_engagement_shape(engagement: dict[str, Any]) -> None:
assert set(engagement) == (
IDENTITY_KEYS | VOLUME_KEYS | COMMERCIAL_KEYS | TTS_KEYS
)
def test_identity_fields_are_strings(engagement: dict[str, Any]) -> None:
"""Blank in the master, filled in the copy — either way, strings."""
for key in IDENTITY_KEYS:
assert isinstance(engagement[key], str)
def test_volumes_are_non_negative_numbers(engagement: dict[str, Any]) -> None:
for key in VOLUME_KEYS:
value = engagement[key]
assert isinstance(value, (int, float)) and not isinstance(value, bool)
assert value >= 0, f"{key} must not be negative"
def test_commercial_terms_are_well_formed(engagement: dict[str, Any]) -> None:
assert isinstance(engagement["agent_users"], int)
assert engagement["agent_users"] >= 0
assert engagement["licence_model"] in ("named", "concurrent")
assert isinstance(engagement["currency"], str) and engagement["currency"]
assert 0.0 <= float(engagement["concession_pct"]) < 1.0
contracted = engagement["contracted_rate_per_token"]
assert contracted is None or float(contracted) >= 0
def test_currency_is_one_the_rate_card_publishes(engagement: dict[str, Any]) -> None:
from genesyscalc.ratecard import LIST_PRICE_BY_CURRENCY
assert engagement["currency"] in LIST_PRICE_BY_CURRENCY
def test_tts_settings(engagement: dict[str, Any]) -> None:
from genesyscalc.tts import TTS_PRICE_PER_MILLION_CHARS
assert isinstance(engagement["tts_chars_per_call"], int)
assert engagement["tts_chars_per_call"] >= 0
assert engagement["tts_voice_tier"] in TTS_PRICE_PER_MILLION_CHARS
def test_master_carries_no_client_identity(engagement: dict[str, Any]) -> None:
"""The one emptiness check that IS correct: masters stay client-clean.
This guards the master in THIS repo. An engagement copy lives outside
Palladium, so it never runs this suite — filling the cell there does not
break anything (CLAUDE.md § Confidentiality).
"""
assert engagement["client"] == "", (
"a client name in the master means the copy-out step was skipped — "
"copy the directory out of Palladium before entering client data"
)
def test_engagement_cell_sits_above_the_widget_cell() -> None:
"""Client data must not re-run on a sidebar change (Mercury re-runs only
cells below a changed widget)."""
nb = nbformat.read(NOTEBOOK, as_version=4)
eng = [
i
for i, c in enumerate(nb.cells)
if "engagement-data" in c.metadata.get("tags", [])
]
widgets = [
i
for i, c in enumerate(nb.cells)
if c.cell_type == "code" and "mr.Select(" in c.source
]
assert len(eng) == 1 and widgets
assert eng[0] < min(widgets)

View File

@@ -0,0 +1,263 @@
"""End-to-end pricing, hand-checked.
The reference scenario below is the one the notebook ships as its default.
Every figure was computed by hand from the published rates before it was
pinned here.
"""
from __future__ import annotations
import json
import pytest
from genesyscalc.appendix import result_json
from genesyscalc.meters import LicenceModel, TokenPrice
from genesyscalc.model import (
CalculatorInputs,
calculate,
compare,
cost_per_interaction,
metered_units,
)
from genesyscalc.ratecard import RATE_CARD_SOURCE_DATE
from genesyscalc.tts import TtsUsage
from genesyscalc.usage import DeflectionMix, VoiceBotUsage, Volumes
REFERENCE_ENABLED = frozenset(
{
"agent_copilot_named",
"speech_and_text_analytics_named",
"bots_voice",
"virtual_agent",
"agentic_virtual_agent",
"ai_summary_and_insights",
"ai_scoring",
"whatsapp_messaging",
"predictive_engagement",
}
)
@pytest.fixture
def volumes() -> Volumes:
return Volumes(
voice_inbound_monthly=100_000,
voice_outbound_monthly=20_000,
digital_sessions_monthly=8_000,
email_monthly=12_000,
messaging_monthly=6_000,
evaluations_monthly=2_000,
)
@pytest.fixture
def reference(volumes: Volumes) -> CalculatorInputs:
return CalculatorInputs(
volumes=volumes,
users=500,
licence=LicenceModel.NAMED,
enabled=REFERENCE_ENABLED,
mix=DeflectionMix(
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
),
price=TokenPrice(),
voice_bot=VoiceBotUsage(
calls_per_month=30_000, avg_bot_seconds_per_call=40
),
tts=TtsUsage(calls_monthly=100_000, chars_per_call=2_000),
)
# ── the hand-checked reference scenario ──────────────────────────────────
def test_subscription_lines(reference: CalculatorInputs) -> None:
"""500 users: Copilot 500×40 = 20,000; STA 500×30 = 15,000 tokens/mo."""
by_key = {line.key: line for line in calculate(reference).lines}
assert by_key["agent_copilot_named"].tokens_monthly == pytest.approx(20_000.0)
assert by_key["speech_and_text_analytics_named"].tokens_monthly == pytest.approx(
15_000.0
)
assert by_key["agent_copilot_named"].cost_annual == pytest.approx(240_000.0)
def test_voice_bot_line(reference: CalculatorInputs) -> None:
"""30,000 calls × ceil(40/15)×15s = 45s = 0.75 min → 22,500 min.
22,500 / 17 = 1,323.53 tokens/mo → $15,882.35/yr at $1.00."""
by_key = {line.key: line for line in calculate(reference).lines}
assert by_key["bots_voice"].units_monthly == pytest.approx(22_500.0)
assert by_key["bots_voice"].tokens_monthly == pytest.approx(1_323.529412, rel=1e-6)
assert by_key["bots_voice"].cost_annual == pytest.approx(15_882.35, abs=0.01)
def test_virtual_agent_lines_use_the_partition(reference: CalculatorInputs) -> None:
"""120,000 voice: VA 15% = 18,000 × 0.5 = 9,000 tokens;
AVA 5% = 6,000 × 1.2 = 7,200 tokens."""
by_key = {line.key: line for line in calculate(reference).lines}
assert by_key["virtual_agent"].units_monthly == pytest.approx(18_000.0)
assert by_key["virtual_agent"].tokens_monthly == pytest.approx(9_000.0)
assert by_key["agentic_virtual_agent"].units_monthly == pytest.approx(6_000.0)
assert by_key["agentic_virtual_agent"].tokens_monthly == pytest.approx(7_200.0)
def test_summary_is_zeroed_because_copilot_is_on(reference: CalculatorInputs) -> None:
result = calculate(reference)
by_key = {line.key: line for line in result.lines}
assert by_key["ai_summary_and_insights"].units_monthly == 0.0
assert by_key["ai_summary_and_insights"].cost_annual == 0.0
assert any("Agent Copilot is enabled" in w for w in result.warnings)
def test_zero_rated_line_is_shown_not_hidden(reference: CalculatorInputs) -> None:
"""A published $0 is a finding — it renders as a line, at zero."""
by_key = {line.key: line for line in calculate(reference).lines}
assert "predictive_engagement" in by_key
assert by_key["predictive_engagement"].units_monthly > 0
assert by_key["predictive_engagement"].cost_annual == 0.0
def test_reference_totals(reference: CalculatorInputs) -> None:
"""consumption 17,638.53 → ceil 17,639; + 35,000 subscription = 52,639;
250 allowance = 52,389 billable → $52,389/mo → $628,668/yr.
TTS 200M chars, 20% VA-free → 160M → $3,200/mo → $38,400/yr.
Grand total $667,068/yr."""
totals = calculate(reference).totals
assert totals.consumption_tokens_monthly == pytest.approx(17_638.529412, rel=1e-6)
assert totals.subscription_tokens_monthly == pytest.approx(35_000.0)
assert totals.gross_tokens_monthly == pytest.approx(52_639.0)
assert totals.allowance_tokens_monthly == 250
assert totals.billable_tokens_monthly == 52_389
assert totals.token_cost_annual == pytest.approx(628_668.0)
def test_reference_grand_total_splits_tokens_from_tts(
reference: CalculatorInputs,
) -> None:
result = calculate(reference)
assert result.tts_cost_annual == pytest.approx(38_400.0)
assert result.grand_total_annual == pytest.approx(667_068.0)
assert result.grand_total_annual == pytest.approx(
result.token_cost_annual + result.tts_cost_annual
)
def test_cost_per_interaction(reference: CalculatorInputs) -> None:
"""$667,068 / (146,000 × 12) = $0.3807 per interaction."""
result = calculate(reference)
assert cost_per_interaction(result, reference.volumes) == pytest.approx(
0.380747, rel=1e-5
)
def test_roundup_warning_is_reported(reference: CalculatorInputs) -> None:
assert any("15-second per-call round-up" in w for w in calculate(reference).warnings)
# ── structural ties that must hold at any setting ────────────────────────
def test_lines_sum_to_the_gross_token_count(reference: CalculatorInputs) -> None:
result = calculate(reference)
assert sum(line.tokens_monthly for line in result.lines) == pytest.approx(
result.totals.consumption_tokens_monthly
+ result.totals.subscription_tokens_monthly
)
def test_effective_cost_never_exceeds_list(reference: CalculatorInputs) -> None:
discounted = reference.with_(price=TokenPrice(concession_pct=0.4))
result = calculate(discounted)
assert result.totals.token_cost_annual <= result.totals.list_cost_annual
assert result.totals.concession_saving_annual >= 0
def test_allowance_only_ever_reduces_the_bill(reference: CalculatorInputs) -> None:
with_allowance = calculate(reference).totals.billable_tokens_monthly
without = calculate(
reference.with_(apply_allowance=False)
).totals.billable_tokens_monthly
assert with_allowance == without - 250
def test_allowance_absorbing_a_month_is_reported() -> None:
quiet = CalculatorInputs(
volumes=Volumes(voice_inbound_monthly=100),
users=0,
enabled=frozenset({"ai_scoring"}),
)
result = calculate(quiet)
assert result.totals.billable_tokens_monthly == 0
assert any("free monthly allowance" in w for w in result.warnings)
def test_unknown_feature_key_rejected(volumes: Volumes) -> None:
with pytest.raises(ValueError, match="not published meters"):
CalculatorInputs(volumes=volumes, users=1, enabled=frozenset({"telepathy"}))
def test_metered_units_covers_every_enabled_key(reference: CalculatorInputs) -> None:
assert set(metered_units(reference)) == set(reference.enabled)
def test_empty_scenario_costs_nothing() -> None:
result = calculate(CalculatorInputs(volumes=Volumes(), users=0))
assert result.grand_total_annual == 0.0
assert result.lines == ()
# ── licence model, scenarios ─────────────────────────────────────────────
def test_concurrent_licence_costs_more_per_user(reference: CalculatorInputs) -> None:
"""Copilot 40→60 and STA 30→45, so 35,000 → 52,500 subscription tokens."""
concurrent = reference.with_(
licence=LicenceModel.CONCURRENT,
enabled=frozenset(
{"agent_copilot_named", "speech_and_text_analytics_named"}
),
)
named = reference.with_(
enabled=frozenset({"agent_copilot_named", "speech_and_text_analytics_named"})
)
assert calculate(named).totals.subscription_tokens_monthly == pytest.approx(35_000.0)
assert calculate(concurrent).totals.subscription_tokens_monthly == pytest.approx(
52_500.0
)
def test_compare_is_monotone_in_deflection(reference: CalculatorInputs) -> None:
"""More virtual-agent deflection means more consumption tokens."""
rows = compare(
{
"Conservative": reference.with_(
mix=DeflectionMix(bot_only_share=0.20, virtual_agent_share=0.05)
),
"Base": reference,
"Aggressive": reference.with_(
mix=DeflectionMix(
bot_only_share=0.35,
virtual_agent_share=0.25,
agentic_va_share=0.10,
)
),
}
)
assert [r["Scenario"] for r in rows] == ["Conservative", "Base", "Aggressive"]
totals = [r["Token cost / yr"] for r in rows]
assert totals[0] < totals[1] < totals[2]
# ── the export payload ───────────────────────────────────────────────────
def test_result_json_round_trips_and_carries_provenance(
reference: CalculatorInputs,
) -> None:
payload = result_json(calculate(reference), reference, meta={"client": "Example"})
assert json.loads(json.dumps(payload)) == payload
assert payload["rate_card"]["source_date"] == RATE_CARD_SOURCE_DATE
assert payload["rate_card"]["voice_bot_roundup_seconds"] == 15
assert payload["tts_source"]["source_date"] == "2026-05-22"
assert payload["totals"]["grand_total_annual"] == pytest.approx(667_068.0)
assert payload["meta"]["client"] == "Example"
assert len(payload["lines"]) == len(REFERENCE_ENABLED)

View File

@@ -0,0 +1,174 @@
"""The vendor-change tripwire.
Every published row is pinned by its exact feature wording AND its exact
rate string, so a transcription slip or a vendor republication breaks the
build rather than quietly moving a client-facing number. When Genesys
republishes, these pins are updated in the SAME commit as the anchor — see
the re-anchoring protocol in docs/Calculator_Pattern_V1-00.md.
"""
from __future__ import annotations
import pytest
from genesyscalc.meters import Confidence, LicenceModel, MeterBasis
from genesyscalc.ratecard import (
ALLOWANCE_CARRIES_OVER,
COPILOT_COVERS_SUMMARY_RULE,
FREE_TOKENS_PER_MONTH,
HIGHEST_TIER_RULE,
LIST_PRICE_BY_CURRENCY,
METERS,
METERS_VERBATIM,
RATE_CARD_SOURCE,
RATE_CARD_SOURCE_DATE,
VOICE_BOT_ROUNDUP_SECONDS,
ZERO_RATED_VERBATIM,
meter,
rate_card_rows,
)
# The published table, as it reads on the page. Transcribed 2026-07-12.
PUBLISHED = {
"Bots (Voice)": "17 minutes per token",
"Bots (Digital)": "51 sessions per token",
"Virtual Agent": "0.5 tokens per Virtual Agent interaction",
"Agentic Virtual Agent": "1.2 tokens per interaction",
"Agent Copilot [named]": "40 tokens per user",
"Agent Copilot [concurrent]": "60 tokens per user",
"AI Scoring": "20 evaluations per token",
"AI Translate": "2 translations per token",
"AI Summary and Insights": "50 summaries/insights per token",
"Apple Messages for Business": "400 inbound or outbound messages per token",
"Facebook Messenger": "400 messages per token",
"Instagram Direct Messaging": "400 messages per token",
"WhatsApp Messaging": "400 messages per token",
"X Direct Messaging": "400 messages per token",
"Genesys Cloud Social": "400 social post ingestions per channel per token",
"Social Post Responses": "400 outbound messages per channel per token",
"Predictive Routing": "17 routes per token",
"Speech and Text Analytics [named]": "30 tokens per user",
"Speech and Text Analytics [concurrent]": "45 tokens per user",
"Genesys Cloud Copilot": "20 AI actions per token",
}
def test_published_meter_table_verbatim() -> None:
assert {m.feature: m.published_rate for m in METERS_VERBATIM} == PUBLISHED
def test_meter_count() -> None:
assert len(METERS_VERBATIM) == 20
assert len(ZERO_RATED_VERBATIM) == 2
def test_source_pinned() -> None:
"""Bumping the date without updating the pins is the failure mode."""
assert RATE_CARD_SOURCE_DATE == "2026-07-12"
assert RATE_CARD_SOURCE == (
"https://help.genesys.cloud/articles/genesys-cloud-tokens-model/"
)
def test_every_confirmed_meter_carries_source_url_and_date() -> None:
for m in METERS.values():
if m.confidence is Confidence.CONFIRMED:
assert m.source_url, f"{m.key} is 🟢 without a source URL"
assert m.source_date, f"{m.key} is 🟢 without a source date"
def test_unsourced_confirmed_meter_cannot_be_constructed() -> None:
from genesyscalc.meters import Meter
with pytest.raises(ValueError, match="source URL and date"):
Meter(
key="made_up",
feature="Made Up",
published_rate="1 per token",
basis=MeterBasis.UNITS_PER_TOKEN,
rate=1.0,
)
@pytest.mark.parametrize(
("key", "expected"),
[
("bots_voice", 1 / 17),
("bots_digital", 1 / 51),
("virtual_agent", 0.5),
("agentic_virtual_agent", 1.2),
("ai_scoring", 1 / 20),
("ai_translate", 1 / 2),
("ai_summary_and_insights", 1 / 50),
("whatsapp_messaging", 1 / 400),
("genesys_cloud_social", 1 / 400),
("predictive_routing", 1 / 17),
("genesys_cloud_copilot", 1 / 20),
],
)
def test_numeric_rates_match_their_published_strings(key: str, expected: float) -> None:
"""Catches a float drifting away from the wording beside it."""
assert meter(key).rate == pytest.approx(expected)
def test_per_user_rates() -> None:
copilot = meter("agent_copilot_named")
assert copilot.tokens_per_user_month(LicenceModel.NAMED) == 40.0
assert copilot.tokens_per_user_month(LicenceModel.CONCURRENT) == 60.0
sta = meter("speech_and_text_analytics_named")
assert sta.tokens_per_user_month(LicenceModel.NAMED) == 30.0
assert sta.tokens_per_user_month(LicenceModel.CONCURRENT) == 45.0
def test_free_allowance() -> None:
assert FREE_TOKENS_PER_MONTH[LicenceModel.NAMED] == 250
assert FREE_TOKENS_PER_MONTH[LicenceModel.CONCURRENT] == 350
assert ALLOWANCE_CARRIES_OVER is False
def test_voice_roundup_granularity() -> None:
assert VOICE_BOT_ROUNDUP_SECONDS == 15
def test_zero_rated_features() -> None:
assert {m.key for m in ZERO_RATED_VERBATIM} == {
"predictive_engagement",
"knowledge_queries",
}
assert all(m.rate == 0.0 for m in ZERO_RATED_VERBATIM)
assert all(m.tokens_for(1_000_000) == 0.0 for m in ZERO_RATED_VERBATIM)
def test_highest_tier_rule_text_verbatim() -> None:
"""It renders on stage, so the wording is part of the deliverable."""
assert HIGHEST_TIER_RULE == (
"In cases where an interaction uses multiple AI resources, such as bot "
"flows, virtual agents, and agentic virtual agents, Genesys bases "
"charges on the highest tier (price) resource that Genesys Cloud uses "
"during the interaction."
)
def test_copilot_covers_summary_rule_text_verbatim() -> None:
assert COPILOT_COVERS_SUMMARY_RULE == (
"if you enable Agent Copilot simultaneously, then Supervisor Copilot "
"summaries and insights do not consume tokens"
)
def test_list_price() -> None:
assert LIST_PRICE_BY_CURRENCY["USD"] == 1.00
assert LIST_PRICE_BY_CURRENCY["JPY"] == 120.0
def test_meter_lookup_error_is_useful() -> None:
with pytest.raises(KeyError, match="not a published Genesys meter"):
meter("nope")
def test_rate_card_rows_cover_every_published_line() -> None:
rows = rate_card_rows()
assert len(rows) == len(METERS_VERBATIM) + len(ZERO_RATED_VERBATIM)
assert rows[0]["Feature"] == "Bots (Voice)"
assert all(r["Confidence"] == "🟢" for r in rows)
assert all(r["Source"] == RATE_CARD_SOURCE_DATE for r in rows)

View File

@@ -0,0 +1,110 @@
"""Sweeps and tornado data."""
from __future__ import annotations
import pytest
from genesyscalc.meters import TokenPrice
from genesyscalc.model import CalculatorInputs, calculate
from genesyscalc.sensitivity import (
DRIVERS,
driver_value,
set_driver,
sweep,
tornado,
)
from genesyscalc.tts import TtsUsage
from genesyscalc.usage import DeflectionMix, VoiceBotUsage, Volumes
@pytest.fixture
def base() -> CalculatorInputs:
return CalculatorInputs(
volumes=Volumes(voice_inbound_monthly=100_000, voice_outbound_monthly=20_000),
users=500,
enabled=frozenset(
{"agent_copilot_named", "bots_voice", "virtual_agent"}
),
mix=DeflectionMix(bot_only_share=0.30, virtual_agent_share=0.15),
price=TokenPrice(),
voice_bot=VoiceBotUsage(calls_per_month=30_000, avg_bot_seconds_per_call=40),
tts=TtsUsage(calls_monthly=100_000),
)
def test_driver_round_trip(base: CalculatorInputs) -> None:
assert driver_value(base, "users") == 500
assert driver_value(set_driver(base, "users", 750), "users") == 750
def test_nested_driver_round_trip(base: CalculatorInputs) -> None:
assert driver_value(base, "concession_pct") == 0.0
moved = set_driver(base, "concession_pct", 0.25)
assert moved.price.concession_pct == pytest.approx(0.25)
assert base.price.concession_pct == 0.0 # inputs are frozen; no mutation
def test_integer_drivers_stay_integers(base: CalculatorInputs) -> None:
assert isinstance(set_driver(base, "users", 512.6).users, int)
assert set_driver(base, "users", 512.6).users == 513
def test_unknown_driver_is_named(base: CalculatorInputs) -> None:
with pytest.raises(KeyError, match="not a known driver"):
driver_value(base, "vibes")
def test_sweep_is_monotone_in_token_price(base: CalculatorInputs) -> None:
rows = sweep(base, "token_price", [0.5, 1.0, 1.5, 2.0])
costs = [r["token_cost_annual"] for r in rows]
assert costs == sorted(costs)
assert len(rows) == 4
def test_sweep_carries_the_driver_value(base: CalculatorInputs) -> None:
rows = sweep(base, "concession_pct", [0.0, 0.25])
assert rows[0]["concession_pct"] == 0.0
assert rows[1]["grand_total_annual"] < rows[0]["grand_total_annual"]
def test_tornado_is_sorted_by_swing(base: CalculatorInputs) -> None:
rows = tornado(base, ["token_price", "users", "bot_seconds", "concession_pct"])
swings = [r["swing"] for r in rows]
assert swings == sorted(swings, reverse=True)
assert all(r["swing"] >= 0 for r in rows)
def test_tornado_brackets_the_baseline(base: CalculatorInputs) -> None:
baseline = calculate(base).grand_total_annual
for row in tornado(base, ["token_price", "users"]):
assert row["low"] <= baseline <= row["high"]
assert row["baseline"] == pytest.approx(baseline)
def test_tornado_skips_inactive_levers() -> None:
"""No TTS and no voice bot in the scenario — those levers are noise."""
plain = CalculatorInputs(
volumes=Volumes(voice_inbound_monthly=1_000),
users=10,
enabled=frozenset({"agent_copilot_named"}),
)
drivers = [r["driver"] for r in tornado(plain, list(DRIVERS))]
assert "bot_seconds" not in drivers
assert "tts_chars_per_call" not in drivers
assert "users" in drivers
def test_tornado_skips_a_lever_that_would_break_the_partition() -> None:
"""Raising a share past the partition is illegal, not a data point."""
saturated = CalculatorInputs(
volumes=Volumes(voice_inbound_monthly=1_000),
users=10,
enabled=frozenset({"virtual_agent"}),
mix=DeflectionMix(bot_only_share=0.5, virtual_agent_share=0.5),
)
drivers = [r["driver"] for r in tornado(saturated, ["virtual_agent_share"])]
assert drivers == []
def test_invalid_delta_rejected(base: CalculatorInputs) -> None:
with pytest.raises(ValueError, match="delta"):
tornado(base, ["users"], delta=1.5)

View File

@@ -0,0 +1,61 @@
"""Stage/backstage detection.
Two signals mark the Mercury app view (see staging.py): the app's shadow
session name (``__mercury__`` in ``JPY_SESSION_NAME`` — per-view, primary)
and the ``MERCURY_CONFIG_DIR`` env var (server-level fallback, only set by
``mercury --working-dir``). Either one means "a client may be looking".
"""
import pytest
from genesyscalc import staging
@pytest.fixture(autouse=True)
def clean_stage_env(monkeypatch):
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
monkeypatch.delenv("JPY_SESSION_NAME", raising=False)
def test_backstage_by_default(capsys):
assert not staging.on_stage()
staging.backstage("visible")
assert capsys.readouterr().out == "visible\n"
def test_plain_session_name_is_backstage(monkeypatch, capsys):
# A JupyterLab or nbconvert session: the real notebook path, no marker.
monkeypatch.setenv("JPY_SESSION_NAME", "notebooks/genesys_token_calculator.ipynb")
assert not staging.on_stage()
staging.backstage("visible")
assert capsys.readouterr().out == "visible\n"
def test_mercury_shadow_session_is_stage(monkeypatch, capsys):
# The Mercury app runs against a shadow copy: <stem>__mercury__<id>.ipynb
monkeypatch.setenv(
"JPY_SESSION_NAME", "notebooks/genesys_token_calculator__mercury__545e5520.ipynb"
)
assert staging.on_stage()
staging.backstage("hidden")
assert capsys.readouterr().out == ""
def test_config_dir_fallback_is_stage(monkeypatch, capsys):
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
assert staging.on_stage()
staging.backstage("hidden")
assert capsys.readouterr().out == ""
def test_backstage_md_renders_only_off_stage(monkeypatch, capsys):
# Off stage it must emit SOMETHING (rich markdown under a kernel;
# IPython's display degrades to print under plain pytest) …
staging.backstage_md("**visible**")
assert capsys.readouterr().out != ""
# … and on stage — either signal — nothing at all.
monkeypatch.setenv(
"JPY_SESSION_NAME", "notebooks/genesys_token_calculator__mercury__ab12cd34.ipynb"
)
staging.backstage_md("**hidden**")
assert capsys.readouterr().out == ""

View File

@@ -0,0 +1,114 @@
"""Enhanced TTS — the non-token line, its round-up, and its free share."""
from __future__ import annotations
import pytest
from genesyscalc.meters import Confidence
from genesyscalc.tts import (
TTS_PRICE_PER_MILLION_CHARS,
TTS_SOURCE,
TTS_SOURCE_DATE,
TTS_STANDARD_END_OF_SALE,
TTS_STANDARD_END_OF_SUPPORT,
TtsUsage,
tts_cost,
tts_free_share,
)
from genesyscalc.usage import DeflectionMix
def test_published_rates() -> None:
assert TTS_PRICE_PER_MILLION_CHARS == {"advanced": 20.0, "standard": 5.0}
def test_source_pinned_and_distinct_from_the_tokens_article() -> None:
"""TTS has its own page and its own date — it is not a token meter."""
from genesyscalc.ratecard import RATE_CARD_SOURCE, RATE_CARD_SOURCE_DATE
assert TTS_SOURCE_DATE == "2026-05-22"
assert TTS_SOURCE.endswith("genesys-enhanced-tts-pricing/")
assert TTS_SOURCE != RATE_CARD_SOURCE
assert TTS_SOURCE_DATE != RATE_CARD_SOURCE_DATE
def test_tts_is_published_so_confirmed() -> None:
line = tts_cost(TtsUsage(calls_monthly=100_000))
assert line.confidence is Confidence.CONFIRMED
def test_hand_checked_advanced_cost() -> None:
"""100,000 calls × 2,000 chars = 200M chars → 200 units × $20 = $4,000/mo."""
line = tts_cost(TtsUsage(calls_monthly=100_000, chars_per_call=2_000))
assert line.total_chars_monthly == pytest.approx(200_000_000.0)
assert line.billed_millions_monthly == 200
assert line.cost_monthly == pytest.approx(4_000.0)
assert line.cost_annual == pytest.approx(48_000.0)
def test_hand_checked_standard_cost() -> None:
"""Same volume on standard voices: 200 × $5 = $1,000/mo."""
line = tts_cost(
TtsUsage(calls_monthly=100_000, chars_per_call=2_000, tier="standard")
)
assert line.cost_monthly == pytest.approx(1_000.0)
def test_per_million_roundup_published_examples() -> None:
"""The article's own examples: 200,000 chars = $5; 1,000,005 chars = $10.
Both on standard voices, where the unit rate is $5.
"""
small = tts_cost(TtsUsage(calls_monthly=1, chars_per_call=200_000, tier="standard"))
assert small.billed_millions_monthly == 1
assert small.cost_monthly == pytest.approx(5.0)
just_over = tts_cost(
TtsUsage(calls_monthly=1, chars_per_call=1_000_005, tier="standard")
)
assert just_over.billed_millions_monthly == 2
assert just_over.cost_monthly == pytest.approx(10.0)
def test_free_share_tracks_the_virtual_agent_tiers() -> None:
"""Free during VA and agentic VA — bot-only deflection does not qualify."""
mix = DeflectionMix(
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
)
assert tts_free_share(mix) == pytest.approx(0.20)
def test_free_share_reduces_the_bill() -> None:
"""200M chars, 20% free → 160M billable → 160 × $20 = $3,200/mo."""
mix = DeflectionMix(virtual_agent_share=0.15, agentic_va_share=0.05)
line = tts_cost(TtsUsage(calls_monthly=100_000, chars_per_call=2_000), mix)
assert line.free_share == pytest.approx(0.20)
assert line.free_chars_monthly == pytest.approx(40_000_000.0)
assert line.billable_chars_monthly == pytest.approx(160_000_000.0)
assert line.cost_monthly == pytest.approx(3_200.0)
def test_standard_tier_warns_about_end_of_life() -> None:
line = tts_cost(TtsUsage(calls_monthly=1_000, tier="standard"))
assert len(line.warnings) == 1
assert TTS_STANDARD_END_OF_SALE in line.warnings[0]
assert TTS_STANDARD_END_OF_SUPPORT in line.warnings[0]
def test_advanced_tier_does_not_warn() -> None:
assert tts_cost(TtsUsage(calls_monthly=1_000)).warnings == ()
def test_concession_applies_to_tts_too() -> None:
line = tts_cost(TtsUsage(calls_monthly=100_000), concession_pct=0.25)
assert line.cost_monthly == pytest.approx(3_000.0)
def test_zero_volume_costs_nothing() -> None:
line = tts_cost(TtsUsage(calls_monthly=0))
assert line.billed_millions_monthly == 0
assert line.cost_monthly == 0.0
def test_unknown_tier_rejected() -> None:
with pytest.raises(ValueError, match="unknown TTS tier"):
TtsUsage(calls_monthly=1, tier="neural-ultra")

View File

@@ -0,0 +1,206 @@
"""Volumes → tokens, with the three published rules that trip the naive math.
Every number below was computed by hand first, then pinned.
"""
from __future__ import annotations
import pytest
from genesyscalc.meters import LicenceModel, Tier
from genesyscalc.usage import (
DeflectionMix,
VoiceBotUsage,
Volumes,
apply_copilot_covers_summary,
subscription_tokens,
tier_interactions,
virtual_agent_tokens,
voice_bot_billable_minutes,
voice_bot_roundup_uplift,
voice_bot_tokens,
)
# ── 1 · the 15-second per-call round-up ──────────────────────────────────
def test_voice_bot_15_second_roundup() -> None:
"""1,000 calls at a 40s average.
ceil(40/15) = 3 increments = 45s = 0.75 min/call → 750.0 min/month.
The naive 40/60 × 1,000 = 666.67 min understates by 12.5%.
"""
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=40)
assert voice_bot_billable_minutes(usage) == pytest.approx(750.0)
assert voice_bot_tokens(usage) == pytest.approx(750.0 / 17.0)
assert voice_bot_tokens(usage) == pytest.approx(44.117647, rel=1e-6)
def test_roundup_uplift_is_reportable() -> None:
"""The stage-facing number: 12.5% more than the raw duration implies."""
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=40)
assert voice_bot_roundup_uplift(usage) == pytest.approx(0.125)
def test_roundup_is_exact_on_increment_boundaries() -> None:
"""45s is exactly three increments — no inflation, uplift 0."""
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=45)
assert voice_bot_billable_minutes(usage) == pytest.approx(750.0)
assert voice_bot_roundup_uplift(usage) == pytest.approx(0.0)
def test_roundup_penalises_short_calls_hardest() -> None:
"""A 5-second bot greeting bills as 15 seconds — 3× its duration.
1,000 calls × 5s = 83.33 raw minutes, billed as 250.0.
"""
usage = VoiceBotUsage(calls_per_month=1_000, avg_bot_seconds_per_call=5)
assert voice_bot_billable_minutes(usage) == pytest.approx(250.0)
assert voice_bot_roundup_uplift(usage) == pytest.approx(2.0)
def test_zero_bot_volume_is_free_and_does_not_divide_by_zero() -> None:
usage = VoiceBotUsage(calls_per_month=0, avg_bot_seconds_per_call=0)
assert voice_bot_billable_minutes(usage) == 0.0
assert voice_bot_roundup_uplift(usage) == 0.0
assert voice_bot_tokens(usage) == 0.0
def test_negative_bot_usage_rejected() -> None:
with pytest.raises(ValueError, match="calls_per_month"):
VoiceBotUsage(calls_per_month=-1, avg_bot_seconds_per_call=30)
# ── 2 · the highest-tier rule as a partition ─────────────────────────────
def test_tier_shares_must_partition() -> None:
"""Shares summing past 1.0 would bill some interaction twice."""
with pytest.raises(ValueError, match="sum to at most 1.0"):
DeflectionMix(
bot_only_share=0.5, virtual_agent_share=0.4, agentic_va_share=0.3
)
def test_tier_share_bounds() -> None:
with pytest.raises(ValueError, match="must be in"):
DeflectionMix(virtual_agent_share=1.4)
def test_agent_handled_share_is_the_remainder() -> None:
mix = DeflectionMix(
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
)
assert mix.total_deflected_share == pytest.approx(0.50)
assert mix.agent_handled_share == pytest.approx(0.50)
def test_highest_tier_charges_each_interaction_once() -> None:
"""100,000 interactions: 30% bot, 15% VA, 5% agentic VA.
VA = 100,000 × 0.15 × 0.5 tokens = 7,500
AVA = 100,000 × 0.05 × 1.2 tokens = 6,000
------
13,500
Applying every tier to the whole volume — the additive reading — would
give 50,000 + 120,000 = far more, billing the same interaction at
several tiers at once.
"""
mix = DeflectionMix(
bot_only_share=0.30, virtual_agent_share=0.15, agentic_va_share=0.05
)
tokens = virtual_agent_tokens(100_000, mix)
assert tokens["virtual_agent"] == pytest.approx(7_500.0)
assert tokens["agentic_virtual_agent"] == pytest.approx(6_000.0)
assert sum(tokens.values()) == pytest.approx(13_500.0)
def test_tier_interactions_never_exceed_the_volume() -> None:
mix = DeflectionMix(
bot_only_share=0.40, virtual_agent_share=0.35, agentic_va_share=0.25
)
split = tier_interactions(100_000, mix)
assert sum(split.values()) == pytest.approx(100_000.0)
assert split[Tier.BOT] == pytest.approx(40_000.0)
def test_bot_tier_is_not_priced_per_interaction() -> None:
"""Voice bots bill in minutes with a round-up, so they are absent here."""
mix = DeflectionMix(bot_only_share=1.0)
assert virtual_agent_tokens(100_000, mix) == {
"virtual_agent": 0.0,
"agentic_virtual_agent": 0.0,
}
# ── 3 · Copilot covers Supervisor summaries ──────────────────────────────
def test_summary_billed_when_copilot_off() -> None:
usage = apply_copilot_covers_summary(
{"ai_summary_and_insights": 120_000.0}, copilot_enabled=False
)
assert usage.get("ai_summary_and_insights") == pytest.approx(120_000.0)
assert usage.notes == ()
def test_copilot_covers_summary_zeroes_the_line() -> None:
"""The published exclusion, enforced — and reported, not silent."""
usage = apply_copilot_covers_summary(
{"ai_summary_and_insights": 120_000.0}, copilot_enabled=True
)
assert usage.get("ai_summary_and_insights") == 0.0
assert len(usage.notes) == 1
assert "Agent Copilot is enabled" in usage.notes[0]
def test_copilot_exclusion_leaves_other_meters_alone() -> None:
usage = apply_copilot_covers_summary(
{"ai_summary_and_insights": 100.0, "ai_scoring": 500.0}, copilot_enabled=True
)
assert usage.get("ai_scoring") == pytest.approx(500.0)
def test_copilot_exclusion_is_quiet_when_there_is_nothing_to_zero() -> None:
usage = apply_copilot_covers_summary(
{"ai_summary_and_insights": 0.0}, copilot_enabled=True
)
assert usage.notes == ()
# ── subscription tokens ──────────────────────────────────────────────────
def test_per_user_tokens_are_exact_and_licence_sensitive() -> None:
"""500 users × 40 = 20,000 named; × 60 = 30,000 concurrent."""
keys = frozenset({"agent_copilot_named"})
assert subscription_tokens(500, keys, LicenceModel.NAMED) == {
"agent_copilot_named": 20_000.0
}
assert subscription_tokens(500, keys, LicenceModel.CONCURRENT) == {
"agent_copilot_named": 30_000.0
}
def test_subscription_rejects_a_consumption_meter() -> None:
with pytest.raises(ValueError, match="not a per-user meter"):
subscription_tokens(10, frozenset({"ai_scoring"}), LicenceModel.NAMED)
# ── volumes ──────────────────────────────────────────────────────────────
def test_volume_totals() -> None:
v = Volumes(
voice_inbound_monthly=100_000,
voice_outbound_monthly=20_000,
digital_sessions_monthly=8_000,
email_monthly=12_000,
)
assert v.voice_total_monthly == pytest.approx(120_000.0)
assert v.all_interactions_monthly == pytest.approx(140_000.0)
def test_negative_volume_rejected() -> None:
with pytest.raises(ValueError, match="voice_inbound_monthly"):
Volumes(voice_inbound_monthly=-1)

View File

@@ -0,0 +1,264 @@
# Calculator Pattern v1.0.0
Standardizes **Calculators** — Palladium masters that price a vendor's product
from that vendor's own **published rate card**: a verbatim rate-card anchor,
a client overlay in tagged cells, and a re-anchoring protocol for when the
vendor republishes.
## 🐾 Red Panda Approval™
This pattern follows Red Panda Approval standards (see `CLAUDE.md` for the
rubric).
**Audience note:** written to be loaded whole as context by an LLM agent
building or modifying a Calculator. Rules are imperative (MUST/SHOULD/NEVER),
each with a one-line *why*. It **specializes**
[`Mercury_Notebook_Pattern_V1-00.md`](Mercury_Notebook_Pattern_V1-00.md) —
read that first; the shared mechanics (reactivity, widget-pairs, gate,
staging, appendix, packaging) are **not** restated here. `CLAUDE.md` is the
always-on contract and takes precedence where documents disagree.
Reference implementation:
[`calculators/Genesys_Token_Calculator/`](../calculators/Genesys_Token_Calculator/).
---
## What a Calculator is — and why it's a third kind
| | Study | Assessment | **Calculator** |
|---|---|---|---|
| Anchored to | a **dated publication** | — (it *is* the instrument) | a **living rate card** |
| Naming | `YYYYMM_…` dated | `Instrument_Name` undated | `Vendor_Subject_Calculator` undated |
| Anchor lifetime | immutable **forever** | n/a | immutable **until the vendor republishes** |
| Its "numbers" | NPV / ROI / payback | status & progress | **run-rate, $/unit, per-feature cost** |
| Lives in | `studies/` | `assessments/` | `calculators/` |
The distinguishing property is the **anchor's lifetime**. A Study's anchor is
frozen forever — the December 2025 Forrester PDF will never change, so a dated
directory name is honest. A Calculator's anchor is frozen *until the vendor
republishes*, at which point updating it is a deliberate, test-breaking,
dated act. A `YYYYMM_` prefix would start lying the moment the vendor updated
their page, so **Calculators are undated**; the publication date lives inside
the anchor (`RATE_CARD_SOURCE_DATE`), pinned by a test and shown on stage.
> **Note.** Mercury Pattern *Variant 3 — Exploratory calculator* describes the
> pre-master form of this: a what-if surface inside a study. This pattern
> promotes it to a first-class master type. A Variant-3 notebook that outlives
> its study should graduate into `calculators/`.
---
## Required Structure & Contracts
Everything the Mercury pattern requires, **plus** the four below.
### 1 · The rate card is a verbatim anchor, and it lives in `.py`
`CLAUDE.md` says content never lives in `.py`. A published vendor rate card is
**not content**, and the boundary is who authors and edits the thing:
> **In `.py` (the engine):** the vendor's published rate card — verbatim, with
> a source URL and a source date. A third-party record nobody in this repo
> authors. **In tagged notebook cells:** everything the consultant authors or
> the client supplies — volumes, headcount, licence model, the **negotiated**
> rate, feature enablement, adoption assumptions, scenario definitions, and
> the narrative.
*Why:* the content rule exists because *"the notebook is the document the
consultant edits"*. Nobody edits the vendor's rate card — editing it is
**forbidden**, and changing it requires a republication plus the re-anchoring
protocol below. Putting an immutable anchor on the surface designated as
editable invites exactly the casual edit the anchor rule prevents, and makes
the most-likely-wrong numbers checkable only via a notebook parse. Precedent:
`studies/202602_TEI_Amazon_Connect/teicalc/anchor.py` holds Forrester's
published tables the same way.
**Corollary that has teeth:** the *contracted overlay* is **client data** and
belongs in the `engagement-data` cell, never in `.py`. A Calculator's engine
MUST ship **no client volumes at all** — no `defaults.py` full of real sites.
(`studies/202607_CTM_GenesysCX/tokencalc/defaults.py` does exactly that; it is
the anti-precedent, not the model.)
The anchor module MUST carry:
```python
RATE_CARD_SOURCE: str # the URL
RATE_CARD_SOURCE_DATE: str # the publication's own "last updated" date
METERS_VERBATIM: tuple[Meter, ...] # feature wording AND rate string, exact
```
Each meter keeps the vendor's **exact wording** (`feature`) and **exact rate
string** (`published_rate`) beside the parsed number. *Why:* the string is
what makes a transcription slip catchable — a test pins the strings, and a
second test proves the float still agrees with the string beside it.
### 2 · The `topic-bank` cell holds the catalogue, not the rates
Since the rates are in the engine, the required `topic-bank` cell holds what a
consultant genuinely authors: the **feature catalogue** — per capability, a
client-facing title, a one-line description, and the **sizing question to
ask**. Each `key` MUST match a published meter key, pinned by a test. *Why:*
this keeps the tag taxonomy honest rather than tag-stuffed, and the cross-layer
pin catches a rename that would otherwise silently unprice a feature.
### 3 · Costs the vendor bills outside the primary meter are a separate line
A vendor usually bills something adjacent on a different basis (here: Enhanced
TTS, per character rather than per token). Such a line MUST have its **own
source URL and date**, its own confidence flag, and its own subtotal — and the
grand total MUST show the components labelled, never one blended figure.
*Why:* the headline is understated without it and wrong with it silently
folded in; a reader must be able to see which meter produced which dollar.
### 4 · Every published rule that changes the arithmetic is modelled and reported
Vendors bury billing rules in prose. Each one that moves the number MUST be
enforced in the engine, pinned with hand-checked arithmetic, and **surfaced in
the result** (a `warnings` tuple), not applied silently. *Why:* a total that
shrank for a published reason nobody can see is indistinguishable from a bug.
In the reference implementation these are the per-call round-up, the free
monthly allowance, the highest-tier rule, and the Copilot exclusion.
---
## The Re-anchoring Protocol
The section unique to this kind. A Calculator tracks a *living* rate card, so
updating the anchor is expected — but never casual. Re-anchoring is
**Show-first** (`CLAUDE.md` § Risk tier), unlike editing a Study anchor, which
is forbidden outright.
When the vendor republishes:
1. **Diff** the published table against the anchor module.
2. **Update the meters AND bump `RATE_CARD_SOURCE_DATE` in the same edit.**
3. **Update the test pins in the same commit.**
4. **Re-execute** the notebook; re-pin the gate's live-state numbers if the
defaults moved.
5. **Report the cost moves honestly** — say what got more expensive.
6. **Show the user the rate diff** and the KPI move before it lands.
> Never partially re-anchor. Never bump the date without the pins. A date that
> claims a rate card the numbers don't match is worse than a stale one, because
> it is stale *and* it lies about it.
---
## Standard Choice Values
Beyond the Mercury pattern's confidence legend (🟢 confirmed / 🟡 estimated /
🔴 unknown), Calculators standardize:
```python
class Confidence(Enum): # 🟢 requires source_url AND source_date — enforced
CONFIRMED = "confirmed"
ESTIMATED = "estimated"
UNKNOWN = "unknown"
class LicenceModel(Enum): # vendors price per-seat features by licence model
NAMED = "named"
CONCURRENT = "concurrent"
```
A 🟢 meter that carries no source URL and date MUST fail to construct. *Why:*
it is the one invariant that stops an unsourced number wearing a green tick on
a client-facing stage.
---
## Recommended Practices
- **The list → negotiated walk.** Carry `list_cost` beside the effective cost
and show both. The client sees MSRP → their deal explicitly (the Mercury
pattern's deck-frame column, applied to pricing).
- **Published zeroes are findings.** A feature the vendor publishes as free
renders as an explicit `$0` line with the quoted wording — not an omission.
- **Adoption as a range.** Adoption is the largest uncertainty in any
consumption estimate; ship conservative / base / aggressive rather than a
single point.
- **A `$/unit` figure.** Cost per interaction is the number a client remembers
and the one that survives a change of volume assumptions.
---
## Anti-Patterns
-**Don't build a business case in a Calculator.** NPV, payback, ROI, a
benefit model, a P&L — any of these means the work has become a **Study**.
Move it, don't grow it here.
-**Don't edit `RATE_CARD_VERBATIM` to reflect a negotiated rate.** That is
the overlay, and it belongs in `engagement-data`. The anchor is what the
vendor published, not what the client pays.
-**Don't bump the source date without updating the pins** (see the
protocol).
-**Don't ship a rate without a source URL and a confidence flag** — the
schema should make this impossible.
-**Don't put engagement volumes in the engine.** Masters stay client-clean;
an engine with real client sites in it cannot be shared.
- ❌ **Don't fold a differently-metered cost into the headline meter's
subtotal** — separate line, separate source, labelled components.
-**Don't model a per-unit round-up by averaging first.** Round per billing
event, then sum; averaging first understates short events, sometimes by
multiples.
---
## Testing
The Mercury pattern's layers 04, plus **layer 1a — the rate-card tripwire**,
which is the highest-value test in a Calculator:
```python
def test_published_meter_table_verbatim():
"""Exact feature wording AND exact rate string, every row.
A transcription slip or an un-noticed republication breaks the build
rather than quietly moving a client-facing number.
"""
assert {m.feature: m.published_rate for m in METERS_VERBATIM} == PUBLISHED
def test_source_pinned():
assert RATE_CARD_SOURCE_DATE == "2026-07-12"
def test_numeric_rates_match_their_published_strings():
"""Catches a float drifting away from the wording beside it."""
def test_every_confirmed_meter_carries_source_url_and_date(): ...
```
Then **hand-check the arithmetic before pinning it** — compute each published
rule's effect by hand, write the working into the test's docstring, and pin
the result:
```python
def test_voice_bot_15_second_roundup():
"""1,000 calls at a 40s average.
ceil(40/15) = 3 increments = 45s = 0.75 min/call → 750.0 min/month.
The naive 40/60 × 1,000 = 666.67 understates by 12.5%.
"""
assert voice_bot_billable_minutes(VoiceBotUsage(1_000, 40)) == pytest.approx(750.0)
```
The in-notebook **gate** additionally asserts *structural ties that hold at any
widget setting* — the allowance identity, effective ≤ list, components summing
to the grand total, and each published exclusion — so a stakeholder moving a
slider cannot produce an incoherent number.
---
## Adding a Calculator
1. Copy `calculators/Genesys_Token_Calculator/` and rename it
`Vendor_Subject_Calculator` (underscores — directories are Python packages).
2. Rename the engine package; keep `staging.py` byte-for-byte.
3. Replace the anchor module with the new vendor's published table —
**verbatim**, with its own source URL and date — and rewrite the rate-card
tripwire against it.
4. Rewrite the `topic-bank` catalogue and reset `engagement-data` to
placeholders.
5. Register the notebook in [`tests/nbcheck.py`](../tests/nbcheck.py)
(`NOTEBOOK_FIRST`) — the completeness test fails until you do.
6. Run the full verification: `pytest`, `mypy`, headless `nbconvert --execute`,
`python scripts/export_report.py`, a stage simulation, and an actual
`mercury --working-dir .` render.

View File

@@ -17,6 +17,14 @@ dependencies = [
"plotly>=5.18", "plotly>=5.18",
"numpy>=1.26", "numpy>=1.26",
"nbformat>=5.9", "nbformat>=5.9",
"openpyxl>=3.1",
"mercury>=3.2",
"jupyterlab>=4.0",
"ipywidgets>=8.0",
"nbconvert>=7",
"tabulate>=0.9",
"pydantic>=2.5",
"PyYAML>=6.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -46,4 +54,5 @@ ignore = ["E501"] # line length handled by formatter
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
"studies/*/notebooks/*.ipynb" = ["E402"] "studies/*/notebooks/*.ipynb" = ["E402"]
"assessments/*/notebooks/*.ipynb" = ["E402"] "assessments/*/notebooks/*.ipynb" = ["E402"]
"calculators/*/notebooks/*.ipynb" = ["E402"]
"tests/*" = ["F401"] "tests/*" = ["F401"]

View File

@@ -1,9 +0,0 @@
requests>=2.31
python-dotenv>=1.0
jupyter>=1.0
pandas>=2.0
plotly>=5.18
numpy>=1.26
nbformat>=5.9
pytest>=7.4
ruff>=0.1

View File

@@ -28,6 +28,7 @@ REPO = Path(__file__).resolve().parent.parent
# full check set, including the tagged-cell taxonomy. # full check set, including the tagged-cell taxonomy.
NOTEBOOK_FIRST = { NOTEBOOK_FIRST = {
"assessments/CX_Discovery_Workshop/notebooks/cx_discovery.ipynb", "assessments/CX_Discovery_Workshop/notebooks/cx_discovery.ipynb",
"calculators/Genesys_Token_Calculator/notebooks/genesys_token_calculator.ipynb",
} }
# Structural tier only, each with its recorded reason (see CLAUDE.md, # Structural tier only, each with its recorded reason (see CLAUDE.md,
@@ -63,7 +64,7 @@ UNIQUE_TAGS = ("topic-bank", "engagement-data", "gate", "data-appendix")
def discover() -> list[str]: def discover() -> list[str]:
"""Every notebook on disk under the master roots (repo-relative).""" """Every notebook on disk under the master roots (repo-relative)."""
found: list[str] = [] found: list[str] = []
for base in ("studies", "assessments", "template"): for base in ("studies", "assessments", "calculators", "template"):
root = REPO / base root = REPO / base
if not root.is_dir(): if not root.is_dir():
continue continue