Migrate Amazon Connect TEI study to the Mercury Notebook Pattern
studies/202602_AmazonConnect -> studies/202602_TEI_Amazon_Connect, rebuilt as pattern Variant 4 (TEI composite reproduction): - teicalc/ self-contained engine (stdlib-only): Forrester's tables as the never-edited verbatim anchor, NPV/ROI/payback + risk adjustment transplanted from core/calculations, ClientDrivers overlay (contacts/ agents/fixed driver map, growth re-base, identity at composite scale), scenario stress with core-identical semantics - one deliverable notebook (business_case.ipynb): widget-pair sidebar drivers, published-vs-overlay KPI columns, cash-flow/waterfall/scenario charts, verification gate, backstage JSON data appendix - gate + tests reproduce the published totals within PDF rounding: NPV $78.7M / ROI 342% / payback <6 months (engine $78,713,492 / 342.48% / 0.7 months); 27 study tests, headless nbconvert green, stage simulation leak-free, exports carry the appendix - old Athena workflow (00_provision..04_export, config.py, seed_data.py) deleted; git history preserves it; root test fixture repointed to teicalc.anchor - docs: study README rewritten; root README points new studies at template/MercuryNotebook; pattern doc stale ctm-token-calculator paths now cite studies/202607_CTM_GenesysCX; Variant 4 cites this study as its realized reference Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
97
studies/202602_TEI_Amazon_Connect/README.md
Normal file
97
studies/202602_TEI_Amazon_Connect/README.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 202602 — Amazon Connect TEI
|
||||
|
||||
Self-contained reproduction of the Forrester *Total Economic Impact™ Of
|
||||
Amazon Connect* study (February 2026, commissioned by AWS), built on the
|
||||
[Mercury Notebook Deliverable Pattern](../../docs/Mercury_Notebook_Pattern_V1-00.md)
|
||||
as **Variant 4 — TEI composite reproduction**: Forrester's composite
|
||||
organization is the never-edited verbatim anchor, the in-notebook gate
|
||||
proves the engine reproduces the published totals, and 🟡 client drivers
|
||||
rescale the composite live.
|
||||
|
||||
## Source
|
||||
|
||||
The full Forrester study is at
|
||||
[`docs/202602_TEI Report Amazon Connect.pdf`](docs/202602_TEI%20Report%20Amazon%20Connect.pdf).
|
||||
|
||||
Published composite totals (3-yr risk-adjusted PV @ 10%), reproduced by
|
||||
`teicalc` to within the PDF's own table rounding (benefits PV lands $223
|
||||
low; costs PV $0.22 low):
|
||||
|
||||
| Metric | Published | Engine |
|
||||
|---|---|---|
|
||||
| Benefits PV | **$101,696,791** | $101,696,568 |
|
||||
| Costs PV | **$22,983,076** | $22,983,076 |
|
||||
| NPV | **$78,713,715** | $78,713,492 |
|
||||
| ROI | **342%** | 342.48% |
|
||||
| Payback | **<6 months** | 0.7 months |
|
||||
|
||||
## Composite organization (the verbatim anchor 🟢)
|
||||
|
||||
* Global B2C, ~$10B revenue (Y1), 30% YoY growth
|
||||
* 2,000 contact-center agents, 200 supervisors
|
||||
* 20M annual contacts (75% calls, 25% chat)
|
||||
* 10-min average handle time
|
||||
|
||||
## Client overlay (🟡)
|
||||
|
||||
A first-order linear rescale — "the composite at your size", not "your
|
||||
TEI". Each published row scales with the driver that dominates its PDF
|
||||
derivation:
|
||||
|
||||
| Row | Driver | Confidence |
|
||||
|---|---|---|
|
||||
| AI contact resolution · content/sentiment | contacts | 🟡 |
|
||||
| Forecasting/supervision · legacy savings | agents | 🟡 |
|
||||
| Data-driven profit lift | contacts | 🔴 proxy (revenue-driven in the PDF) |
|
||||
| Amazon Connect usage | contacts | 🟡 |
|
||||
| Implementation · ongoing management | fixed | 🟡 project-based |
|
||||
|
||||
The client growth rate re-bases the composite's Y1→Y3 trajectory (which
|
||||
embeds 30% YoY). At composite scale the overlay is the identity — the
|
||||
gate asserts it.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
202602_TEI_Amazon_Connect/
|
||||
├── teicalc/ ← ALL math (stdlib-only) — notebooks hold none
|
||||
│ ├── anchor.py ← Forrester's tables, VERBATIM, never edited
|
||||
│ ├── model.py ← NPV/ROI/payback, risk adjustment, compute_summary
|
||||
│ ├── overlay.py ← ClientDrivers + driver map + overlay_rows
|
||||
│ ├── scenarios.py ← conservative / moderate / aggressive
|
||||
│ └── staging.py ← on_stage()/backstage() (Mercury vs nbconvert)
|
||||
├── notebooks/business_case.ipynb ← THE deliverable
|
||||
├── scripts/export_report.py
|
||||
├── tests/ ← hand-checked pinned acceptance numbers
|
||||
├── config.toml ← Mercury theme (NTT DATA brand)
|
||||
├── pyproject.toml ← full toolchain as core deps — no requirements.txt
|
||||
├── docs/ ← the Forrester PDF
|
||||
└── exports/ ← generated .html/.md; gitignored
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
python -m venv .venv && source .venv/bin/activate
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| Tests | `pytest` |
|
||||
| Serve (the stage) | `mercury --working-dir notebooks/` (run from this project root so `config.toml` loads) |
|
||||
| Analyst view (backstage) | `jupyter lab` |
|
||||
| Headless check | `jupyter nbconvert --to notebook --execute --inplace notebooks/business_case.ipynb` |
|
||||
| Export for LLMs | `python scripts/export_report.py` |
|
||||
|
||||
The data appendix (markdown tables + JSON model state) rides inside
|
||||
`exports/business_case.md` — it replaces the retired `exports/export.json`
|
||||
pipeline and is the payload for the Athena study-repository roadmap.
|
||||
|
||||
## History
|
||||
|
||||
This study previously ran on the shared `core/` package with an
|
||||
Athena-workflow notebook chain (provision → push → calculate → export).
|
||||
That workflow was retired when the study migrated to the pattern
|
||||
(git history preserves it); the engine reproduces the same published
|
||||
totals locally, pinned in `tests/`.
|
||||
81
studies/202602_TEI_Amazon_Connect/config.toml
Normal file
81
studies/202602_TEI_Amazon_Connect/config.toml
Normal file
@@ -0,0 +1,81 @@
|
||||
# Mercury app-shell theme — NTT DATA brand (light), modern surfaces.
|
||||
# See docs/brand.md for the source palette.
|
||||
#
|
||||
# Loaded from the directory where you launch `mercury` (this project root);
|
||||
# restart the server to apply changes. Only keys in mercury/config.py
|
||||
# CSS_VARIABLE_MAP emit a CSS variable — anything else in DEFAULT_THEME is
|
||||
# either derived or component-baked (e.g. success/warning/danger, slider
|
||||
# track, widget bg) and silently no-ops here. Omitted keys are derived
|
||||
# from the ones below.
|
||||
|
||||
[main]
|
||||
title = "Amazon Connect TEI — Business Case"
|
||||
favicon_emoji = "📊"
|
||||
footer = "Amazon Connect TEI study (Forrester, Feb 2026)"
|
||||
notebooks_button_label = "Analyses"
|
||||
|
||||
[welcome]
|
||||
header = "Amazon Connect TEI"
|
||||
message = """
|
||||
Interactive reproduction of Forrester's *Total Economic Impact™ Of Amazon
|
||||
Connect* composite ($78.7M NPV · 342% ROI). The published study is the
|
||||
verbatim anchor; tune the 🟡 client drivers live to rescale the composite
|
||||
to your organization, then export the personalized report source with
|
||||
`python scripts/export_report.py`.
|
||||
"""
|
||||
|
||||
[theme]
|
||||
# ── Type — Georgia headings, Arial body. Both web-safe system fonts,
|
||||
# so no font_url / network fetch. Georgia ships only normal+bold, so
|
||||
# heading weight is 700 (the default 800 would render as faux-bold). ──
|
||||
font_family = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
|
||||
heading_font_family = "Georgia, 'Times New Roman', Times, serif"
|
||||
font_size = "15px"
|
||||
font_weight = "normal"
|
||||
heading_font_weight = "700"
|
||||
|
||||
# ── Text — NTT ink scale ──
|
||||
text_color = "#2e404d" # body
|
||||
muted_text_color = "#586671" # captions / secondary
|
||||
|
||||
# ── Surfaces — white content floating on a soft neutral canvas (depth).
|
||||
# For a strictly-white page instead, set background_color = "#ffffff". ──
|
||||
background_color = "#f4f5f6" # outer page
|
||||
content_background_color = "#ffffff" # notebook column
|
||||
surface_color = "#ffffff"
|
||||
card_background_color = "#f8f8f8" # brand card
|
||||
border_color = "#d5d9db" # brand border
|
||||
border_radius = "10px" # modern rounding
|
||||
|
||||
# ── Accents — Future Blue. primary_color also drives the Run button + focus. ──
|
||||
primary_color = "#0072bc"
|
||||
accent_color = "#0072bc"
|
||||
focus_border_color = "#0072bc"
|
||||
hover_background_color = "#eef5fb" # light blue tint
|
||||
selected_background_color = "#dcecfa"
|
||||
|
||||
# ── Sidebar — clean white, hairline divider ──
|
||||
sidebar_background_color = "#ffffff"
|
||||
sidebar_text_color = "#2e404d"
|
||||
sidebar_title_color = "#151d2c"
|
||||
sidebar_shadow = "1px 0 0 #d5d9db"
|
||||
|
||||
# ── Top bar — deep NTT navy (brand heading-primary) ──
|
||||
topbar_background_color = "#151d2c"
|
||||
topbar_text_color = "#ffffff"
|
||||
topbar_border_color = "rgba(255,255,255,0.08)"
|
||||
|
||||
# ── Footer ──
|
||||
footer_background_color = "#ffffff"
|
||||
footer_text_color = "#586671"
|
||||
footer_border_color = "#d5d9db"
|
||||
|
||||
# ── Run button — subtle brand-blue gradient (else derives from primary) ──
|
||||
run_button_background = "linear-gradient(180deg, #0087dc 0%, #0072bc 100%)"
|
||||
run_button_background_hover = "linear-gradient(180deg, #1a93e6 0%, #0079c8 100%)"
|
||||
run_button_text_color = "#ffffff"
|
||||
|
||||
# ── Depth — soft, navy-tinted shadows (modern) ──
|
||||
shadow_sm = "0 1px 2px rgba(21,29,44,0.05)"
|
||||
shadow_md = "0 6px 18px rgba(21,29,44,0.08)"
|
||||
shadow_lg = "0 16px 40px rgba(21,29,44,0.10)"
|
||||
Binary file not shown.
0
studies/202602_TEI_Amazon_Connect/exports/.gitkeep
Normal file
0
studies/202602_TEI_Amazon_Connect/exports/.gitkeep
Normal file
6946
studies/202602_TEI_Amazon_Connect/notebooks/business_case.ipynb
Normal file
6946
studies/202602_TEI_Amazon_Connect/notebooks/business_case.ipynb
Normal file
File diff suppressed because one or more lines are too long
36
studies/202602_TEI_Amazon_Connect/pyproject.toml
Normal file
36
studies/202602_TEI_Amazon_Connect/pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "teicalc"
|
||||
version = "0.1.0"
|
||||
description = "Amazon Connect TEI (Forrester, Feb 2026) — composite reproduction + client overlay"
|
||||
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",
|
||||
"openpyxl>=3.1",
|
||||
"mercury>=3.2",
|
||||
"jupyterlab>=4.0",
|
||||
"ipywidgets>=8.0",
|
||||
"nbconvert>=7",
|
||||
"tabulate>=0.9",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = ["pytest>=7.4", "mypy>=1.8"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
include = ["teicalc*"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
packages = ["teicalc"]
|
||||
47
studies/202602_TEI_Amazon_Connect/scripts/export_report.py
Normal file
47
studies/202602_TEI_Amazon_Connect/scripts/export_report.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Export the deliverable notebooks as LLM-readable report sources.
|
||||
|
||||
Executes each notebook fresh (widget defaults — or whatever defaults you edit in),
|
||||
then writes both formats to exports/:
|
||||
|
||||
exports/<notebook>.html — human-reviewable, tables render
|
||||
exports/<notebook>.md — leanest LLM input
|
||||
|
||||
Plotly figures export as JavaScript an LLM cannot read; each notebook's
|
||||
machine-readable appendix section carries every number behind them.
|
||||
|
||||
Run from the project root: python scripts/export_report.py [name-filter]
|
||||
An optional argument exports only notebooks whose filename contains it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
NOTEBOOKS = [
|
||||
ROOT / "notebooks" / "business_case.ipynb",
|
||||
]
|
||||
EXPORTS = ROOT / "exports"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
picked = [nb for nb in NOTEBOOKS
|
||||
if len(sys.argv) < 2 or sys.argv[1] in nb.name]
|
||||
if not picked:
|
||||
sys.exit(f"no notebook matches {sys.argv[1]!r}")
|
||||
EXPORTS.mkdir(exist_ok=True)
|
||||
for nb in picked:
|
||||
for fmt in ("html", "markdown"):
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "nbconvert", "--execute",
|
||||
"--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
|
||||
check=True, cwd=ROOT,
|
||||
)
|
||||
for p in sorted(EXPORTS.iterdir()):
|
||||
if p.suffix in (".html", ".md"):
|
||||
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
56
studies/202602_TEI_Amazon_Connect/teicalc/__init__.py
Normal file
56
studies/202602_TEI_Amazon_Connect/teicalc/__init__.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
teicalc — self-contained engine for the Amazon Connect TEI study
|
||||
(Forrester, February 2026). Mercury Notebook Pattern, Variant 4:
|
||||
verbatim composite anchor → published-totals gate → client overlay.
|
||||
"""
|
||||
|
||||
from .anchor import ASSUMPTIONS, BENEFITS_VERBATIM, COSTS_VERBATIM, PUBLISHED
|
||||
from .model import (
|
||||
X_LABELS,
|
||||
YEAR_INDEX,
|
||||
YEARS,
|
||||
benefits_by_year,
|
||||
by_calendar,
|
||||
compute_summary,
|
||||
costs_by_year,
|
||||
discount_factor,
|
||||
html_money,
|
||||
initial_costs,
|
||||
money,
|
||||
month_label,
|
||||
npv,
|
||||
payback_label,
|
||||
payback_months,
|
||||
payback_years,
|
||||
present_value,
|
||||
risk_adjust_benefit,
|
||||
risk_adjust_cost,
|
||||
risk_adjusted_rows,
|
||||
roi_pct,
|
||||
)
|
||||
from .overlay import (
|
||||
BENEFIT_DRIVERS,
|
||||
COMPOSITE,
|
||||
COST_DRIVERS,
|
||||
ClientDrivers,
|
||||
growth_multiplier,
|
||||
overlay_rows,
|
||||
scale_factor,
|
||||
)
|
||||
from .scenarios import SCENARIOS, apply_scenario
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"ASSUMPTIONS", "BENEFITS_VERBATIM", "COSTS_VERBATIM", "PUBLISHED",
|
||||
"YEARS", "YEAR_INDEX", "X_LABELS",
|
||||
"by_calendar", "month_label",
|
||||
"discount_factor", "present_value", "npv", "roi_pct",
|
||||
"payback_years", "payback_months", "payback_label",
|
||||
"risk_adjust_benefit", "risk_adjust_cost", "risk_adjusted_rows",
|
||||
"benefits_by_year", "costs_by_year", "initial_costs",
|
||||
"compute_summary", "money", "html_money",
|
||||
"ClientDrivers", "COMPOSITE", "BENEFIT_DRIVERS", "COST_DRIVERS",
|
||||
"scale_factor", "growth_multiplier", "overlay_rows",
|
||||
"SCENARIOS", "apply_scenario",
|
||||
]
|
||||
167
studies/202602_TEI_Amazon_Connect/teicalc/anchor.py
Normal file
167
studies/202602_TEI_Amazon_Connect/teicalc/anchor.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
The verbatim anchor — Forrester *Total Economic Impact™ Of Amazon Connect*
|
||||
(February 2026, commissioned by AWS).
|
||||
|
||||
VERBATIM, do not edit. These are Forrester's published composite-organization
|
||||
tables and financial summary, transplanted unchanged from the study PDF
|
||||
(``docs/202602_TEI Report Amazon Connect.pdf``). Client personalization
|
||||
lives in :mod:`teicalc.overlay`; scenario stress lives in
|
||||
:mod:`teicalc.scenarios` — both deep-copy, neither mutates this record.
|
||||
|
||||
Rows keep Forrester's own year-index keys (``"1"``/``"2"``/``"3"``);
|
||||
:mod:`teicalc.model` maps them to calendar years (2026–2028). Values are
|
||||
*nominal* (pre-risk-adjustment); the risk factor is stored per row and
|
||||
applied by the model (benefits ×(1−rf), costs ×(1+rf), per the TEI
|
||||
methodology).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: 3-year nominal benefit cashflows — 🟢 published.
|
||||
BENEFITS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "ai_contact_resolution",
|
||||
"table": "benefits",
|
||||
"label": "AI-driven contact resolution efficiency",
|
||||
"category": "Productivity",
|
||||
"year_values": {"1": 13_911_040, "2": 23_932_480, "3": 37_797_760},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF Section At/Atr. Composite: 20M annual contacts, 30% YoY "
|
||||
"growth, 75% calls, 10-min AHT with legacy. Connect drops AHT "
|
||||
"12% Y1 and shifts traffic to chat/self-service. 80% "
|
||||
"productivity recapture. Risk adj 15% (legacy performance, "
|
||||
"implementation depth, integration scope, growth)."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "ai_content_sentiment",
|
||||
"table": "benefits",
|
||||
"label": "AI-powered content and sentiment analysis savings",
|
||||
"category": "Productivity",
|
||||
"year_values": {"1": 4_586_620, "2": 5_358_412, "3": 6_291_680},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF Section Bt/Btr. Auto post-contact summaries reclaim ~60s "
|
||||
"per call; QA scaled from 1–3% to 100%; supervisors freed from "
|
||||
"manual review. Risk adj 15%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "ai_forecasting_supervision",
|
||||
"table": "benefits",
|
||||
"label": "AI-enabled forecasting, agent scheduling, and supervision",
|
||||
"category": "Productivity",
|
||||
"year_values": {"1": 6_651_680, "2": 9_133_760, "3": 12_391_712},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF Section Ct/Ctr. ML-WFM yields 5% agent FTE optimization "
|
||||
"and supervisors managing 20% more agents (10→12). 80% "
|
||||
"productivity recapture. Risk adj 15%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "data_driven_profit_lift",
|
||||
"table": "benefits",
|
||||
"label": "Data-driven profit lift with increased conversion",
|
||||
"category": "Revenue",
|
||||
"year_values": {"1": 1_200_000, "2": 1_560_000, "3": 2_028_000},
|
||||
"risk_adjustment": 0.20,
|
||||
"notes": (
|
||||
"PDF Section Dt/Dtr. Composite revenue $10B Y1 (+30% YoY); "
|
||||
"5% from outbound contact-center marketing; conversion lifts "
|
||||
"from 10% to 12% (+20% relative); 12% operating margin. "
|
||||
"Risk adj 20%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "legacy_solution_savings",
|
||||
"table": "benefits",
|
||||
"label": "Legacy solution cost savings",
|
||||
"category": "Cost Savings",
|
||||
"year_values": {"1": 6_177_600, "2": 8_030_880, "3": 10_440_144},
|
||||
"risk_adjustment": 0.20,
|
||||
"notes": (
|
||||
"PDF Section Et/Etr. Avg legacy license $180/agent-month × "
|
||||
"(agents+supervisors) × 12, plus 30% overhead for infra & "
|
||||
"third-party tools. Risk adj 20%."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
#: Costs include an ``initial`` (year-0, undiscounted) component for
|
||||
#: implementation. Cost risk adjustments are applied *upward*. 🟢 published.
|
||||
COSTS_VERBATIM: list[dict] = [
|
||||
{
|
||||
"field_key": "amazon_connect_usage",
|
||||
"table": "costs",
|
||||
"label": "Amazon Connect usage cost",
|
||||
"category": "Subscription",
|
||||
"initial": 0,
|
||||
"year_values": {"1": 6_456_448, "2": 7_951_164, "3": 9_832_961},
|
||||
"risk_adjustment": 0.05,
|
||||
"notes": (
|
||||
"PDF Section Ft/Ftr. Telephony $0.0106/min + Unlimited AI "
|
||||
"$0.0380/min on minutes that reach an agent, plus chat at "
|
||||
"$0.0100/message (10 messages/chat). Risk adj 5%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "implementation_migration",
|
||||
"table": "costs",
|
||||
"label": "Implementation and migration cost",
|
||||
"category": "Implementation",
|
||||
"initial": 1_087_500,
|
||||
"year_values": {"1": 188_333, "2": 188_333, "3": 0},
|
||||
"risk_adjustment": 0.10,
|
||||
"notes": (
|
||||
"PDF Section Gt/Gtr. 6-month initial migration: 5 internal "
|
||||
"FTE @ $115k + $800k pro-services. Y1/Y2 M&A integrations: 2 "
|
||||
"months × 2 FTE + $150k pro-services. Risk adj 10%."
|
||||
),
|
||||
},
|
||||
{
|
||||
"field_key": "ongoing_management",
|
||||
"table": "costs",
|
||||
"label": "Ongoing management",
|
||||
"category": "Operations",
|
||||
"initial": 0,
|
||||
"year_values": {"1": 256_200, "2": 187_200, "3": 187_200},
|
||||
"risk_adjustment": 0.15,
|
||||
"notes": (
|
||||
"PDF Section Ht/Htr. Y1: 5 IT/PM @ 30% × $115k + 5 business "
|
||||
"users @ 30% × $55,800. Y2/Y3: 3 IT/PM @ 30% + 5 business "
|
||||
"users @ 30%. Risk adj 15%."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
#: Composite-organization drivers — 🟢 published (PDF "Composite Organization").
|
||||
ASSUMPTIONS: dict = {
|
||||
"agents_fte": 2_000,
|
||||
"supervisors_fte": 200,
|
||||
"annual_contacts_y1": 20_000_000,
|
||||
"growth_rate": 0.30,
|
||||
"call_share": 0.75,
|
||||
"aht_legacy_minutes": 10,
|
||||
"agent_salary": 45_760,
|
||||
"supervisor_salary": 55_800,
|
||||
"discount_rate": 0.10,
|
||||
"analysis_years": 3,
|
||||
}
|
||||
|
||||
|
||||
#: The PDF's Financial Summary — the gate's reproduction target. 🟢 published.
|
||||
#: The engine reproduces these to within Forrester's own table rounding
|
||||
#: (benefits PV lands $223 low; costs PV $0.22 low).
|
||||
PUBLISHED: dict = {
|
||||
"benefits_pv": 101_696_791,
|
||||
"costs_pv": 22_983_076,
|
||||
"npv": 78_713_715,
|
||||
"roi_pct": 342,
|
||||
"payback_months_max": 6, # published as "<6 months"
|
||||
"discount_rate": 0.10,
|
||||
"analysis_years": 3,
|
||||
}
|
||||
267
studies/202602_TEI_Amazon_Connect/teicalc/model.py
Normal file
267
studies/202602_TEI_Amazon_Connect/teicalc/model.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
Finance engine — the single source of truth for every number in the notebook.
|
||||
|
||||
Transplanted from the retired shared ``core/calculations`` and
|
||||
``core/export/report_data.py`` so the study is self-contained (Mercury
|
||||
Notebook Pattern, Required §2/§7). Conventions match the Forrester TEI
|
||||
methodology:
|
||||
|
||||
* The *Initial* investment is **not** discounted — it occurs at time zero.
|
||||
* Year-N cash flows are discounted at the end of the year:
|
||||
``PV = CF_n / (1 + r) ** n``.
|
||||
* Benefits are risk-adjusted *down* (``×(1−rf)``), costs *up* (``×(1+rf)``).
|
||||
* Payback runs on risk-adjusted **undiscounted** flows (the PDF's
|
||||
"<6 months" uses the Cash Flow Analysis table's nominal RA rows).
|
||||
|
||||
Everything this module returns for display is keyed by **calendar year**
|
||||
(Forrester Year 1/2/3 → 2026/2027/2028); ``initial`` stays a Year-0 scalar
|
||||
and never appears inside a ``*_by_year`` dict.
|
||||
|
||||
This module is stdlib-only on purpose — the repo-root test suite imports it
|
||||
without the study's venv.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Iterable, Sequence
|
||||
from copy import deepcopy
|
||||
|
||||
# ── Timeline ─────────────────────────────────────────────────────────
|
||||
|
||||
YEARS: list[int] = [2026, 2027, 2028] # Forrester Year 1/2/3; window opens Jan 2026
|
||||
YEAR_INDEX: dict[int, int] = {y: i for i, y in enumerate(YEARS, start=1)}
|
||||
X_LABELS: list[str] = ["Initial"] + [str(y) for y in YEARS]
|
||||
|
||||
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||
|
||||
|
||||
def month_label(m: int) -> str:
|
||||
"""Calendar label for a 1-indexed month from Jan of YEARS[0]."""
|
||||
return f"{_MONTHS[(m - 1) % 12]} {YEARS[0] + (m - 1) // 12}"
|
||||
|
||||
|
||||
def by_calendar(year_values: dict[str, float]) -> dict[int, float]:
|
||||
"""Map Forrester's ``{"1": v, …}`` year-index keys to calendar years."""
|
||||
return {YEARS[int(k) - 1]: float(v or 0) for k, v in year_values.items()}
|
||||
|
||||
|
||||
# ── Discounting primitives ───────────────────────────────────────────
|
||||
|
||||
|
||||
def discount_factor(year_index: int, discount_rate: float) -> float:
|
||||
"""``1 / (1 + r) ** n``. Year 0 → 1.0 (no discount)."""
|
||||
if year_index < 0:
|
||||
raise ValueError("year_index must be >= 0")
|
||||
return 1.0 / ((1.0 + discount_rate) ** year_index)
|
||||
|
||||
|
||||
def present_value(amount: float, year_index: int, discount_rate: float) -> float:
|
||||
"""Discount ``amount`` from end-of-year ``year_index`` to present."""
|
||||
return amount * discount_factor(year_index, discount_rate)
|
||||
|
||||
|
||||
def npv(cashflows: Iterable[float], discount_rate: float,
|
||||
initial: float = 0.0) -> float:
|
||||
"""``initial + Σ CF_n / (1 + r)^n`` — initial undiscounted (TEI)."""
|
||||
return initial + sum(
|
||||
present_value(float(cf), n, discount_rate)
|
||||
for n, cf in enumerate(cashflows, start=1)
|
||||
)
|
||||
|
||||
|
||||
def roi_pct(benefits_pv: float, costs_pv: float) -> float:
|
||||
"""``(Benefits − Costs) / Costs`` as a percentage; 0 when costs ≤ 0."""
|
||||
if costs_pv <= 0:
|
||||
return 0.0
|
||||
return (benefits_pv - costs_pv) / costs_pv * 100.0
|
||||
|
||||
|
||||
# ── Payback ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def payback_years(initial_cost: float,
|
||||
yearly_net: Sequence[float]) -> float | None:
|
||||
"""
|
||||
Years until cumulative net benefits cover the initial cost, with linear
|
||||
interpolation inside the crossing year. ``None`` if never reached.
|
||||
"""
|
||||
remaining = float(initial_cost)
|
||||
if remaining <= 0:
|
||||
return 0.0
|
||||
for i, cf in enumerate(yearly_net):
|
||||
cf = float(cf)
|
||||
if cf <= 0:
|
||||
remaining += -cf # a net-loss year widens the gap
|
||||
continue
|
||||
if cf >= remaining:
|
||||
return i + remaining / cf
|
||||
remaining -= cf
|
||||
return None
|
||||
|
||||
|
||||
def payback_months(initial_cost: float,
|
||||
yearly_net: Sequence[float]) -> float | None:
|
||||
"""Same as :func:`payback_years`, in months."""
|
||||
yrs = payback_years(initial_cost, yearly_net)
|
||||
return yrs * 12.0 if yrs is not None else None
|
||||
|
||||
|
||||
def payback_label(months: float | None) -> str:
|
||||
"""Human label: ``"0.7 months (~Jan 2026)"`` / ``"immediate"`` / ``"beyond 2028"``."""
|
||||
if months is None:
|
||||
return f"beyond {YEARS[-1]}"
|
||||
if months <= 0:
|
||||
return "immediate"
|
||||
return f"{months:.1f} months (~{month_label(max(1, math.ceil(months)))})"
|
||||
|
||||
|
||||
# ── Risk adjustment (TEI: benefits down, costs up) ───────────────────
|
||||
|
||||
|
||||
def risk_adjust_benefit(amount: float, risk_factor: float) -> float:
|
||||
"""``amount × (1 − rf)``, rf clamped to [0, 1]."""
|
||||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||||
return amount * (1.0 - rf)
|
||||
|
||||
|
||||
def risk_adjust_cost(amount: float, risk_factor: float) -> float:
|
||||
"""``amount × (1 + rf)``, rf clamped to [0, 1]."""
|
||||
rf = max(0.0, min(1.0, float(risk_factor)))
|
||||
return amount * (1.0 + rf)
|
||||
|
||||
|
||||
def risk_adjusted_rows(rows: list[dict], table: str) -> list[dict]:
|
||||
"""Deep-copied rows with the per-row risk factor applied to every value."""
|
||||
adjust = risk_adjust_benefit if table == "benefits" else risk_adjust_cost
|
||||
out: list[dict] = []
|
||||
for raw in rows:
|
||||
row = deepcopy(raw)
|
||||
rf = float(row.get("risk_adjustment") or 0.0)
|
||||
row["year_values"] = {
|
||||
k: adjust(float(v or 0), rf) for k, v in row["year_values"].items()
|
||||
}
|
||||
if row.get("initial"):
|
||||
# Only costs carry an initial; TEI adjusts it upward like the years.
|
||||
row["initial"] = risk_adjust_cost(float(row["initial"]), rf) \
|
||||
if table == "costs" else float(row["initial"])
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
|
||||
# ── Aggregation (calendar-keyed) ─────────────────────────────────────
|
||||
|
||||
|
||||
def _totals_by_year(ra_rows: list[dict]) -> dict[int, float]:
|
||||
totals = {y: 0.0 for y in YEARS}
|
||||
for row in ra_rows:
|
||||
for y, v in by_calendar(row["year_values"]).items():
|
||||
totals[y] += v
|
||||
return totals
|
||||
|
||||
|
||||
def benefits_by_year(rows: list[dict]) -> dict[int, float]:
|
||||
"""Risk-adjusted benefit totals per calendar year."""
|
||||
return _totals_by_year(risk_adjusted_rows(rows, "benefits"))
|
||||
|
||||
|
||||
def costs_by_year(rows: list[dict]) -> dict[int, float]:
|
||||
"""Risk-adjusted cost totals per calendar year (excludes ``initial``)."""
|
||||
return _totals_by_year(risk_adjusted_rows(rows, "costs"))
|
||||
|
||||
|
||||
def initial_costs(rows: list[dict]) -> float:
|
||||
"""Risk-adjusted Year-0 outlay (undiscounted)."""
|
||||
return sum(
|
||||
float(row.get("initial") or 0)
|
||||
for row in risk_adjusted_rows(rows, "costs")
|
||||
)
|
||||
|
||||
|
||||
# ── Composite summary ────────────────────────────────────────────────
|
||||
|
||||
|
||||
def compute_summary(benefits: list[dict], costs: list[dict],
|
||||
discount_rate: float = 0.10) -> dict:
|
||||
"""
|
||||
The full business-case readout for one set of value rows.
|
||||
|
||||
Returns KPIs (``benefits_pv``/``costs_pv``/``npv``/``roi_pct``/
|
||||
``payback_months``/``payback_label``/``initial_costs``/nominal totals),
|
||||
calendar-keyed schedules (``benefits_by_year``/``costs_by_year``/
|
||||
``net_by_year``/``cumulative_net_by_year`` — cumulative subtracts the
|
||||
initial outlay), and a per-row breakdown under ``rows``.
|
||||
"""
|
||||
ben_ra = risk_adjusted_rows(benefits, "benefits")
|
||||
cost_ra = risk_adjusted_rows(costs, "costs")
|
||||
|
||||
ben_by = _totals_by_year(ben_ra)
|
||||
cost_by = _totals_by_year(cost_ra)
|
||||
initial = sum(float(r.get("initial") or 0) for r in cost_ra)
|
||||
|
||||
benefits_pv = npv([ben_by[y] for y in YEARS], discount_rate)
|
||||
costs_pv = npv([cost_by[y] for y in YEARS], discount_rate, initial=initial)
|
||||
|
||||
net_by = {y: ben_by[y] - cost_by[y] for y in YEARS}
|
||||
cum, cum_by = -initial, {}
|
||||
for y in YEARS:
|
||||
cum += net_by[y]
|
||||
cum_by[y] = cum
|
||||
|
||||
pb_months = payback_months(initial, [net_by[y] for y in YEARS])
|
||||
|
||||
def _row_breakdown(ra_rows: list[dict], table: str) -> list[dict]:
|
||||
out = []
|
||||
for row in ra_rows:
|
||||
ra_by = by_calendar(row["year_values"])
|
||||
init_ra = float(row.get("initial") or 0)
|
||||
entry = {
|
||||
"field_key": row["field_key"],
|
||||
"label": row["label"],
|
||||
"category": row["category"],
|
||||
"risk_adjustment": row["risk_adjustment"],
|
||||
"ra_by_year": ra_by,
|
||||
"three_yr_ra": sum(ra_by.values()),
|
||||
"pv": npv([ra_by[y] for y in YEARS], discount_rate,
|
||||
initial=init_ra if table == "costs" else 0.0),
|
||||
}
|
||||
if table == "costs":
|
||||
entry["initial_ra"] = init_ra
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
return {
|
||||
"discount_rate": discount_rate,
|
||||
"benefits_pv": benefits_pv,
|
||||
"costs_pv": costs_pv,
|
||||
"npv": benefits_pv - costs_pv,
|
||||
"roi_pct": roi_pct(benefits_pv, costs_pv),
|
||||
"payback_months": pb_months,
|
||||
"payback_label": payback_label(pb_months),
|
||||
"initial_costs": initial,
|
||||
"benefits_nominal": sum(ben_by.values()),
|
||||
"costs_nominal": sum(cost_by.values()) + initial,
|
||||
"benefits_by_year": ben_by,
|
||||
"costs_by_year": cost_by,
|
||||
"net_by_year": net_by,
|
||||
"cumulative_net_by_year": cum_by,
|
||||
"rows": {
|
||||
"benefits": _row_breakdown(ben_ra, "benefits"),
|
||||
"costs": _row_breakdown(cost_ra, "costs"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Display helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def money(v: float) -> str:
|
||||
sign, a = ("-" if v < 0 else ""), abs(v)
|
||||
return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
|
||||
|
||||
|
||||
def html_money(v: float) -> str:
|
||||
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
|
||||
annotations holding several amounts must use the HTML entity instead."""
|
||||
return money(v).replace("$", "$")
|
||||
107
studies/202602_TEI_Amazon_Connect/teicalc/overlay.py
Normal file
107
studies/202602_TEI_Amazon_Connect/teicalc/overlay.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Client overlay — Variant 4's personalization layer.
|
||||
|
||||
The verbatim anchor is Forrester's *composite organization* (2,000 agents,
|
||||
20M contacts, 30% growth). This module rescales that composite to a client's
|
||||
size: a 🟡 **first-order linear rescale**, answering "what does the composite
|
||||
look like at your scale?", not "what is your TEI?".
|
||||
|
||||
Each verbatim row is tied to the driver that dominates its derivation in the
|
||||
PDF (see ``BENEFIT_DRIVERS``/``COST_DRIVERS``); rows scale linearly with
|
||||
their driver, project-based costs stay fixed. The client's growth rate
|
||||
re-bases the composite's Y1→Y3 trajectory (which embeds 30% YoY).
|
||||
|
||||
``overlay_rows(COMPOSITE)`` is the identity — it reproduces the verbatim
|
||||
numbers exactly, so headless widget defaults form the published-study
|
||||
reproduction the gate expects. The anchor is never mutated: every function
|
||||
deep-copies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .anchor import ASSUMPTIONS, BENEFITS_VERBATIM, COSTS_VERBATIM
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClientDrivers:
|
||||
"""Client inputs; defaults are the Forrester composite (identity overlay)."""
|
||||
|
||||
agents_fte: int = ASSUMPTIONS["agents_fte"] # 2,000 (+200 supervisors at 10:1)
|
||||
annual_contacts_y1: int = ASSUMPTIONS["annual_contacts_y1"] # 20M
|
||||
growth_rate: float = ASSUMPTIONS["growth_rate"] # 0.30 YoY
|
||||
discount_rate: float = ASSUMPTIONS["discount_rate"] # 0.10
|
||||
|
||||
|
||||
COMPOSITE = ClientDrivers()
|
||||
|
||||
|
||||
#: 🟡 Which driver each verbatim row scales with, per its PDF derivation.
|
||||
BENEFIT_DRIVERS: dict[str, str] = {
|
||||
"ai_contact_resolution": "contacts", # AHT × volume → contact-driven
|
||||
"ai_content_sentiment": "contacts", # per-call summaries/QA → contact-driven
|
||||
"ai_forecasting_supervision": "agents", # FTE optimization + supervisor span
|
||||
"data_driven_profit_lift": "contacts", # 🔴 proxy — revenue-driven in the PDF;
|
||||
# outbound volume is the nearest linear driver
|
||||
"legacy_solution_savings": "agents", # $/agent-month licences (supervisors follow 10:1)
|
||||
}
|
||||
COST_DRIVERS: dict[str, str] = {
|
||||
"amazon_connect_usage": "contacts", # per-minute/per-message consumption
|
||||
"implementation_migration": "fixed", # project-based — does not scale
|
||||
"ongoing_management": "fixed", # small fixed team
|
||||
}
|
||||
|
||||
|
||||
def scale_factor(driver: str, d: ClientDrivers) -> float:
|
||||
"""Linear size ratio vs the composite for one driver kind."""
|
||||
if driver == "contacts":
|
||||
return d.annual_contacts_y1 / ASSUMPTIONS["annual_contacts_y1"]
|
||||
if driver == "agents":
|
||||
return d.agents_fte / ASSUMPTIONS["agents_fte"]
|
||||
if driver == "fixed":
|
||||
return 1.0
|
||||
raise KeyError(f"Unknown driver: {driver!r}")
|
||||
|
||||
|
||||
def growth_multiplier(year_index: int, growth_rate: float) -> float:
|
||||
"""
|
||||
Re-base the composite's Y1→Y3 trajectory on the client's growth.
|
||||
|
||||
The verbatim year values already embed the composite's 30% YoY growth;
|
||||
dividing it out and compounding the client's rate preserves the
|
||||
composite's *shape* while adopting the client's slope. Year 1 → 1.0.
|
||||
"""
|
||||
composite_g = ASSUMPTIONS["growth_rate"]
|
||||
return ((1.0 + growth_rate) / (1.0 + composite_g)) ** (year_index - 1)
|
||||
|
||||
|
||||
def overlay_rows(d: ClientDrivers = COMPOSITE) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Deep-copied (benefits, costs) rows rescaled to the client's drivers.
|
||||
|
||||
Non-fixed rows: ``year_values[n] ×= scale_factor × growth_multiplier(n)``.
|
||||
Fixed rows keep their year values and ``initial`` unchanged (no growth
|
||||
re-base either — they are project/team costs, not volume costs).
|
||||
Risk factors, labels, and notes are untouched.
|
||||
"""
|
||||
|
||||
def _apply(rows: list[dict], drivers: dict[str, str]) -> list[dict]:
|
||||
out = []
|
||||
for raw in rows:
|
||||
row = deepcopy(raw)
|
||||
driver = drivers[row["field_key"]]
|
||||
if driver != "fixed":
|
||||
s = scale_factor(driver, d)
|
||||
row["year_values"] = {
|
||||
k: float(v) * s * growth_multiplier(int(k), d.growth_rate)
|
||||
for k, v in row["year_values"].items()
|
||||
}
|
||||
if row.get("initial"):
|
||||
row["initial"] = float(row["initial"]) * s
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
return (_apply(BENEFITS_VERBATIM, BENEFIT_DRIVERS),
|
||||
_apply(COSTS_VERBATIM, COST_DRIVERS))
|
||||
67
studies/202602_TEI_Amazon_Connect/teicalc/scenarios.py
Normal file
67
studies/202602_TEI_Amazon_Connect/teicalc/scenarios.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Scenario stress — transplanted from the retired shared ``core/calculations/scenarios.py``
|
||||
with identical semantics.
|
||||
|
||||
Forrester TEI risk-adjusts benefits *down* and costs *up*; scenarios stress
|
||||
both levers:
|
||||
|
||||
* ``adoption`` scales nominal values (``year_values`` and ``initial``).
|
||||
* ``risk_delta`` is *added* to a benefit's risk factor and *subtracted*
|
||||
from a cost's (conservative = more uncertainty on benefits, less padding
|
||||
on costs), then clamped to [0, 1].
|
||||
|
||||
``"moderate"`` is the identity — the headless default reproduces the
|
||||
published study. Note the counterintuitive corollary: the conservative
|
||||
scenario *lowers* costs PV, because 80% adoption shrinks consumption-priced
|
||||
usage and the clamp caps cost padding.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
SCENARIOS: dict[str, dict[str, float]] = {
|
||||
"conservative": {"adoption": 0.80, "risk_delta": 0.10},
|
||||
"moderate": {"adoption": 1.00, "risk_delta": 0.00},
|
||||
"aggressive": {"adoption": 1.15, "risk_delta": -0.05},
|
||||
}
|
||||
|
||||
|
||||
def apply_scenario(
|
||||
items: list[dict],
|
||||
scenario: str = "moderate",
|
||||
*,
|
||||
multipliers: dict[str, dict[str, float]] | None = None,
|
||||
table: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Deep-copied value rows with the scenario applied; inputs are not mutated.
|
||||
|
||||
Each row needs ``year_values`` (year-string → float), optionally
|
||||
``initial`` and ``risk_adjustment``, and a ``table`` of ``"benefits"``
|
||||
or ``"costs"`` (or pass ``table=`` to force one) — the table decides the
|
||||
sign of ``risk_delta``.
|
||||
"""
|
||||
cfg = (multipliers or SCENARIOS).get(scenario)
|
||||
if cfg is None:
|
||||
raise KeyError(f"Unknown scenario: {scenario!r}")
|
||||
adoption = float(cfg.get("adoption", 1.0))
|
||||
risk_delta = float(cfg.get("risk_delta", 0.0))
|
||||
|
||||
out: list[dict] = []
|
||||
for raw in items:
|
||||
item = deepcopy(raw)
|
||||
item_table = item.get("table") or table or "benefits"
|
||||
item["table"] = item_table
|
||||
|
||||
item["year_values"] = {
|
||||
k: float(v) * adoption for k, v in item["year_values"].items()
|
||||
}
|
||||
if item.get("initial") is not None:
|
||||
item["initial"] = float(item["initial"]) * adoption
|
||||
|
||||
ra = float(item.get("risk_adjustment") or 0.0)
|
||||
new_ra = ra + risk_delta if item_table == "benefits" else ra - risk_delta
|
||||
item["risk_adjustment"] = max(0.0, min(1.0, new_ra))
|
||||
out.append(item)
|
||||
return out
|
||||
29
studies/202602_TEI_Amazon_Connect/teicalc/staging.py
Normal file
29
studies/202602_TEI_Amazon_Connect/teicalc/staging.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Stage vs backstage — is this notebook render stakeholder-facing?
|
||||
|
||||
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
|
||||
into the server process so the widget library can locate ``config.toml``
|
||||
(see ``mercury/config.py``); every kernel that server spawns inherits it.
|
||||
JupyterLab and nbconvert kernels don't have it. That makes the variable a
|
||||
reliable signal for "the audience is looking" (the stage) versus an
|
||||
analyst session or a headless export run (backstage).
|
||||
|
||||
Diagnostics routed through :func:`backstage` stay visible in JupyterLab
|
||||
and land in the nbconvert exports (where the machine-readable appendix
|
||||
must appear for LLM consumption) but never render in the Mercury app.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def on_stage() -> bool:
|
||||
"""True when running under the Mercury app (stakeholder-facing)."""
|
||||
return os.getenv("MERCURY_CONFIG_DIR") is not None
|
||||
|
||||
|
||||
def backstage(*args, **kwargs) -> None:
|
||||
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
|
||||
if not on_stage():
|
||||
print(*args, **kwargs)
|
||||
7
studies/202602_TEI_Amazon_Connect/tests/conftest.py
Normal file
7
studies/202602_TEI_Amazon_Connect/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Make teicalc importable even without the study venv active (the normal
|
||||
setup is ``pip install -e ".[dev]"`` into the study-local ``.venv/``)."""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||
95
studies/202602_TEI_Amazon_Connect/tests/test_anchor.py
Normal file
95
studies/202602_TEI_Amazon_Connect/tests/test_anchor.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""The verbatim anchor is Forrester's published record — pinned value by
|
||||
value, and proven immutable under every engine code path."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
from teicalc import (
|
||||
ASSUMPTIONS,
|
||||
BENEFITS_VERBATIM,
|
||||
COMPOSITE,
|
||||
COSTS_VERBATIM,
|
||||
PUBLISHED,
|
||||
ClientDrivers,
|
||||
apply_scenario,
|
||||
compute_summary,
|
||||
overlay_rows,
|
||||
)
|
||||
|
||||
|
||||
def _row(rows, key):
|
||||
return next(r for r in rows if r["field_key"] == key)
|
||||
|
||||
|
||||
def test_benefit_rows_verbatim():
|
||||
assert [r["field_key"] for r in BENEFITS_VERBATIM] == [
|
||||
"ai_contact_resolution",
|
||||
"ai_content_sentiment",
|
||||
"ai_forecasting_supervision",
|
||||
"data_driven_profit_lift",
|
||||
"legacy_solution_savings",
|
||||
]
|
||||
expected = {
|
||||
"ai_contact_resolution": ({"1": 13_911_040, "2": 23_932_480, "3": 37_797_760}, 0.15),
|
||||
"ai_content_sentiment": ({"1": 4_586_620, "2": 5_358_412, "3": 6_291_680}, 0.15),
|
||||
"ai_forecasting_supervision": ({"1": 6_651_680, "2": 9_133_760, "3": 12_391_712}, 0.15),
|
||||
"data_driven_profit_lift": ({"1": 1_200_000, "2": 1_560_000, "3": 2_028_000}, 0.20),
|
||||
"legacy_solution_savings": ({"1": 6_177_600, "2": 8_030_880, "3": 10_440_144}, 0.20),
|
||||
}
|
||||
for key, (years, rf) in expected.items():
|
||||
row = _row(BENEFITS_VERBATIM, key)
|
||||
assert row["year_values"] == years
|
||||
assert row["risk_adjustment"] == rf
|
||||
assert row["table"] == "benefits"
|
||||
|
||||
|
||||
def test_cost_rows_verbatim():
|
||||
expected = {
|
||||
"amazon_connect_usage": ({"1": 6_456_448, "2": 7_951_164, "3": 9_832_961}, 0.05, 0),
|
||||
"implementation_migration": ({"1": 188_333, "2": 188_333, "3": 0}, 0.10, 1_087_500),
|
||||
"ongoing_management": ({"1": 256_200, "2": 187_200, "3": 187_200}, 0.15, 0),
|
||||
}
|
||||
for key, (years, rf, initial) in expected.items():
|
||||
row = _row(COSTS_VERBATIM, key)
|
||||
assert row["year_values"] == years
|
||||
assert row["risk_adjustment"] == rf
|
||||
assert row["initial"] == initial
|
||||
assert row["table"] == "costs"
|
||||
|
||||
|
||||
def test_assumptions_and_published():
|
||||
assert ASSUMPTIONS["agents_fte"] == 2_000
|
||||
assert ASSUMPTIONS["supervisors_fte"] == 200
|
||||
assert ASSUMPTIONS["annual_contacts_y1"] == 20_000_000
|
||||
assert ASSUMPTIONS["growth_rate"] == 0.30
|
||||
assert ASSUMPTIONS["discount_rate"] == 0.10
|
||||
assert ASSUMPTIONS["analysis_years"] == 3
|
||||
|
||||
assert PUBLISHED["benefits_pv"] == 101_696_791
|
||||
assert PUBLISHED["costs_pv"] == 22_983_076
|
||||
assert PUBLISHED["npv"] == 78_713_715
|
||||
assert PUBLISHED["roi_pct"] == 342
|
||||
assert PUBLISHED["payback_months_max"] == 6
|
||||
|
||||
# The composite drivers ARE the anchor assumptions.
|
||||
assert COMPOSITE.agents_fte == ASSUMPTIONS["agents_fte"]
|
||||
assert COMPOSITE.annual_contacts_y1 == ASSUMPTIONS["annual_contacts_y1"]
|
||||
assert COMPOSITE.growth_rate == ASSUMPTIONS["growth_rate"]
|
||||
assert COMPOSITE.discount_rate == ASSUMPTIONS["discount_rate"]
|
||||
|
||||
|
||||
def test_anchor_is_never_mutated():
|
||||
"""Exercise every engine code path, then prove the record unchanged."""
|
||||
ben_snap = deepcopy(BENEFITS_VERBATIM)
|
||||
cost_snap = deepcopy(COSTS_VERBATIM)
|
||||
|
||||
overlay_rows()
|
||||
overlay_rows(ClientDrivers(agents_fte=137, annual_contacts_y1=1_000_000,
|
||||
growth_rate=0.0))
|
||||
for scenario in ("conservative", "moderate", "aggressive"):
|
||||
apply_scenario(BENEFITS_VERBATIM, scenario)
|
||||
apply_scenario(COSTS_VERBATIM, scenario)
|
||||
compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.08)
|
||||
|
||||
assert BENEFITS_VERBATIM == ben_snap
|
||||
assert COSTS_VERBATIM == cost_snap
|
||||
123
studies/202602_TEI_Amazon_Connect/tests/test_model.py
Normal file
123
studies/202602_TEI_Amazon_Connect/tests/test_model.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""Engine pins — every number hand-checked before pinning.
|
||||
|
||||
RA_benefit = v×(1−rf), RA_cost = v×(1+rf), PV = Σ RA_n/(1.1)^n, initial
|
||||
undiscounted. The composite reproduction lands within Forrester's own table
|
||||
rounding of the published Financial Summary (benefits PV $223 low, costs PV
|
||||
$0.22 low) — pinned both engine-exact (±$1) and against PUBLISHED (±$1,000,
|
||||
the convention the retired workflow notebooks used).
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFITS_VERBATIM,
|
||||
COSTS_VERBATIM,
|
||||
PUBLISHED,
|
||||
X_LABELS,
|
||||
YEAR_INDEX,
|
||||
YEARS,
|
||||
by_calendar,
|
||||
compute_summary,
|
||||
discount_factor,
|
||||
money,
|
||||
npv,
|
||||
payback_label,
|
||||
payback_months,
|
||||
payback_years,
|
||||
roi_pct,
|
||||
)
|
||||
|
||||
# Hand-checked risk-adjusted PVs per row (see module docstring).
|
||||
ROW_PVS = {
|
||||
"ai_contact_resolution": 51_699_826.78,
|
||||
"ai_content_sentiment": 11_326_357.54,
|
||||
"ai_forecasting_supervision": 19_469_777.37,
|
||||
"data_driven_profit_lift": 3_123_065.36,
|
||||
"legacy_solution_savings": 16_077_540.50,
|
||||
"amazon_connect_usage": 20_819_775.10,
|
||||
"implementation_migration": 1_555_794.82,
|
||||
"ongoing_management": 607_505.86,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def composite():
|
||||
return compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
|
||||
|
||||
def test_calendar_mapping():
|
||||
assert YEARS == [2026, 2027, 2028]
|
||||
assert YEAR_INDEX == {2026: 1, 2027: 2, 2028: 3}
|
||||
assert X_LABELS == ["Initial", "2026", "2027", "2028"]
|
||||
assert by_calendar({"1": 10, "2": 20, "3": 30}) == {2026: 10, 2027: 20, 2028: 30}
|
||||
|
||||
|
||||
def test_primitives():
|
||||
assert discount_factor(0, 0.10) == 1.0
|
||||
assert discount_factor(1, 0.10) == pytest.approx(1 / 1.1)
|
||||
assert npv([110], 0.10) == pytest.approx(100)
|
||||
assert npv([110], 0.10, initial=-50) == pytest.approx(50)
|
||||
assert roi_pct(101_696_791, 22_983_076) == pytest.approx(342.48, abs=0.1)
|
||||
assert roi_pct(100, 0) == 0.0
|
||||
assert money(78_713_492) == "$78.7M"
|
||||
assert money(-250_000) == "-$250K"
|
||||
|
||||
|
||||
def test_payback_edges():
|
||||
assert payback_years(0, [100]) == 0.0
|
||||
assert payback_years(500, []) is None
|
||||
assert payback_years(500, [-100, 200]) is None # gap widens, never covered
|
||||
assert payback_years(300, [-100, 400]) == pytest.approx(2.0)
|
||||
assert payback_months(100, [1_200]) == pytest.approx(1.0)
|
||||
assert payback_label(None) == "beyond 2028"
|
||||
assert payback_label(0.0) == "immediate"
|
||||
assert payback_label(0.7178) == "0.7 months (~Jan 2026)"
|
||||
assert payback_label(14.2) == "14.2 months (~Mar 2027)"
|
||||
|
||||
|
||||
def test_per_row_pvs(composite):
|
||||
rows = composite["rows"]["benefits"] + composite["rows"]["costs"]
|
||||
assert len(rows) == 8
|
||||
for row in rows:
|
||||
assert row["pv"] == pytest.approx(ROW_PVS[row["field_key"]], abs=1)
|
||||
|
||||
|
||||
def test_composite_totals_engine_exact(composite):
|
||||
assert composite["benefits_pv"] == pytest.approx(101_696_567.55, abs=1)
|
||||
assert composite["costs_pv"] == pytest.approx(22_983_075.78, abs=1)
|
||||
assert composite["npv"] == pytest.approx(78_713_491.78, abs=1)
|
||||
assert composite["roi_pct"] == pytest.approx(342.4846, abs=0.01)
|
||||
assert composite["payback_months"] == pytest.approx(0.7178, abs=0.001)
|
||||
assert composite["initial_costs"] == pytest.approx(1_196_250, abs=0.01)
|
||||
|
||||
|
||||
def test_composite_reproduces_published(composite):
|
||||
assert composite["benefits_pv"] == pytest.approx(PUBLISHED["benefits_pv"], abs=1_000)
|
||||
assert composite["costs_pv"] == pytest.approx(PUBLISHED["costs_pv"], abs=1_000)
|
||||
assert composite["npv"] == pytest.approx(PUBLISHED["npv"], abs=1_000)
|
||||
assert round(composite["roi_pct"]) == PUBLISHED["roi_pct"]
|
||||
assert composite["payback_months"] < PUBLISHED["payback_months_max"]
|
||||
assert composite["payback_label"] == "0.7 months (~Jan 2026)"
|
||||
|
||||
|
||||
def test_yearly_schedules(composite):
|
||||
assert composite["benefits_by_year"][2026] == pytest.approx(27_279_019.00, abs=0.01)
|
||||
assert composite["benefits_by_year"][2027] == pytest.approx(40_333_658.20, abs=0.01)
|
||||
assert composite["benefits_by_year"][2028] == pytest.approx(57_983_494.40, abs=0.01)
|
||||
assert composite["costs_by_year"][2026] == pytest.approx(7_281_066.70, abs=0.01)
|
||||
assert composite["costs_by_year"][2027] == pytest.approx(8_771_168.50, abs=0.01)
|
||||
assert composite["costs_by_year"][2028] == pytest.approx(10_539_889.05, abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2028] == pytest.approx(97_807_797.35, abs=0.01)
|
||||
|
||||
|
||||
def test_cross_foots(composite):
|
||||
assert composite["npv"] == pytest.approx(
|
||||
composite["benefits_pv"] - composite["costs_pv"], abs=0.01)
|
||||
for y in YEARS:
|
||||
assert composite["net_by_year"][y] == pytest.approx(
|
||||
composite["benefits_by_year"][y] - composite["costs_by_year"][y], abs=0.01)
|
||||
assert composite["cumulative_net_by_year"][2028] == pytest.approx(
|
||||
sum(composite["net_by_year"].values()) - composite["initial_costs"], abs=0.01)
|
||||
for table, total in (("benefits", "benefits_pv"), ("costs", "costs_pv")):
|
||||
assert sum(r["pv"] for r in composite["rows"][table]) == pytest.approx(
|
||||
composite[total], abs=0.01)
|
||||
96
studies/202602_TEI_Amazon_Connect/tests/test_overlay.py
Normal file
96
studies/202602_TEI_Amazon_Connect/tests/test_overlay.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Client-overlay pins — identity at the composite, linear per-driver
|
||||
scaling, growth re-basing, and copy semantics."""
|
||||
|
||||
import dataclasses
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFIT_DRIVERS,
|
||||
BENEFITS_VERBATIM,
|
||||
COMPOSITE,
|
||||
COST_DRIVERS,
|
||||
COSTS_VERBATIM,
|
||||
ClientDrivers,
|
||||
compute_summary,
|
||||
growth_multiplier,
|
||||
overlay_rows,
|
||||
scale_factor,
|
||||
)
|
||||
|
||||
|
||||
def _row(rows, key):
|
||||
return next(r for r in rows if r["field_key"] == key)
|
||||
|
||||
|
||||
def test_identity_at_composite():
|
||||
"""overlay_rows(COMPOSITE) reproduces the verbatim study to the cent."""
|
||||
ob, oc = overlay_rows(COMPOSITE)
|
||||
got = compute_summary(ob, oc, 0.10)
|
||||
want = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
assert got["benefits_pv"] == pytest.approx(want["benefits_pv"], abs=0.01)
|
||||
assert got["costs_pv"] == pytest.approx(want["costs_pv"], abs=0.01)
|
||||
assert got["npv"] == pytest.approx(want["npv"], abs=0.01)
|
||||
|
||||
|
||||
def test_driver_map_covers_every_row():
|
||||
assert set(BENEFIT_DRIVERS) == {r["field_key"] for r in BENEFITS_VERBATIM}
|
||||
assert set(COST_DRIVERS) == {r["field_key"] for r in COSTS_VERBATIM}
|
||||
|
||||
|
||||
def test_scale_factor():
|
||||
d = ClientDrivers(agents_fte=1_000, annual_contacts_y1=40_000_000)
|
||||
assert scale_factor("agents", d) == pytest.approx(0.5)
|
||||
assert scale_factor("contacts", d) == pytest.approx(2.0)
|
||||
assert scale_factor("fixed", d) == 1.0
|
||||
with pytest.raises(KeyError):
|
||||
scale_factor("revenue", d)
|
||||
|
||||
|
||||
def test_half_agents_halves_agent_rows_only():
|
||||
ob, oc = overlay_rows(ClientDrivers(agents_fte=1_000))
|
||||
assert _row(ob, "ai_forecasting_supervision")["year_values"]["1"] == \
|
||||
pytest.approx(6_651_680 / 2)
|
||||
assert _row(ob, "legacy_solution_savings")["year_values"]["1"] == \
|
||||
pytest.approx(6_177_600 / 2)
|
||||
# Contact-driven and fixed rows unmoved.
|
||||
assert _row(ob, "ai_contact_resolution")["year_values"]["1"] == \
|
||||
pytest.approx(13_911_040)
|
||||
assert _row(oc, "amazon_connect_usage")["year_values"]["1"] == \
|
||||
pytest.approx(6_456_448)
|
||||
assert _row(oc, "implementation_migration")["initial"] == 1_087_500
|
||||
|
||||
|
||||
def test_double_contacts_doubles_usage_only():
|
||||
_, oc = overlay_rows(ClientDrivers(annual_contacts_y1=40_000_000))
|
||||
assert _row(oc, "amazon_connect_usage")["year_values"]["1"] == \
|
||||
pytest.approx(6_456_448 * 2)
|
||||
assert _row(oc, "implementation_migration")["year_values"]["1"] == \
|
||||
pytest.approx(188_333)
|
||||
assert _row(oc, "ongoing_management")["year_values"]["1"] == \
|
||||
pytest.approx(256_200)
|
||||
|
||||
|
||||
def test_growth_rebase():
|
||||
assert growth_multiplier(1, 0.0) == 1.0 # Y1 always 1.0
|
||||
assert growth_multiplier(2, 0.0) == pytest.approx(1 / 1.3)
|
||||
assert growth_multiplier(3, 0.0) == pytest.approx((1 / 1.3) ** 2)
|
||||
assert growth_multiplier(3, 0.30) == 1.0 # composite growth = identity
|
||||
|
||||
ob, oc = overlay_rows(ClientDrivers(growth_rate=0.0))
|
||||
row = _row(ob, "ai_contact_resolution")
|
||||
assert row["year_values"]["1"] == pytest.approx(13_911_040)
|
||||
assert row["year_values"]["2"] == pytest.approx(23_932_480 / 1.3)
|
||||
assert row["year_values"]["3"] == pytest.approx(37_797_760 / 1.3**2)
|
||||
# Fixed rows ignore the growth re-base too.
|
||||
assert _row(oc, "ongoing_management")["year_values"]["2"] == pytest.approx(187_200)
|
||||
|
||||
|
||||
def test_drivers_frozen_and_rows_are_copies():
|
||||
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||
COMPOSITE.agents_fte = 1 # type: ignore[misc]
|
||||
ob, oc = overlay_rows(COMPOSITE)
|
||||
ob[0]["year_values"]["1"] = -1
|
||||
oc[0]["year_values"]["1"] = -1
|
||||
assert BENEFITS_VERBATIM[0]["year_values"]["1"] == 13_911_040
|
||||
assert COSTS_VERBATIM[0]["year_values"]["1"] == 6_456_448
|
||||
75
studies/202602_TEI_Amazon_Connect/tests/test_scenarios.py
Normal file
75
studies/202602_TEI_Amazon_Connect/tests/test_scenarios.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Scenario pins — hand-checked composite results per scenario, clamp
|
||||
behaviour, and copy semantics."""
|
||||
|
||||
import pytest
|
||||
|
||||
from teicalc import (
|
||||
BENEFITS_VERBATIM,
|
||||
COSTS_VERBATIM,
|
||||
SCENARIOS,
|
||||
apply_scenario,
|
||||
compute_summary,
|
||||
)
|
||||
|
||||
|
||||
def _summary(scenario):
|
||||
return compute_summary(
|
||||
apply_scenario(BENEFITS_VERBATIM, scenario),
|
||||
apply_scenario(COSTS_VERBATIM, scenario),
|
||||
0.10,
|
||||
)
|
||||
|
||||
|
||||
def test_scenario_definitions():
|
||||
assert SCENARIOS == {
|
||||
"conservative": {"adoption": 0.80, "risk_delta": 0.10},
|
||||
"moderate": {"adoption": 1.00, "risk_delta": 0.00},
|
||||
"aggressive": {"adoption": 1.15, "risk_delta": -0.05},
|
||||
}
|
||||
|
||||
|
||||
def test_moderate_is_identity():
|
||||
got = _summary("moderate")
|
||||
want = compute_summary(BENEFITS_VERBATIM, COSTS_VERBATIM, 0.10)
|
||||
assert got["benefits_pv"] == pytest.approx(want["benefits_pv"], abs=0.01)
|
||||
assert got["costs_pv"] == pytest.approx(want["costs_pv"], abs=0.01)
|
||||
|
||||
|
||||
def test_conservative_pins():
|
||||
s = _summary("conservative")
|
||||
assert s["benefits_pv"] == pytest.approx(71_672_867.65, abs=1)
|
||||
assert s["costs_pv"] == pytest.approx(17_437_916.34, abs=1)
|
||||
assert s["npv"] == pytest.approx(54_234_951.31, abs=1)
|
||||
assert s["roi_pct"] == pytest.approx(311.02, abs=0.01)
|
||||
assert s["payback_months"] == pytest.approx(0.763, abs=0.001)
|
||||
|
||||
|
||||
def test_aggressive_pins():
|
||||
s = _summary("aggressive")
|
||||
assert s["benefits_pv"] == pytest.approx(123_911_705.40, abs=1)
|
||||
assert s["costs_pv"] == pytest.approx(27_682_368.61, abs=1)
|
||||
assert s["npv"] == pytest.approx(96_229_336.79, abs=1)
|
||||
assert s["roi_pct"] == pytest.approx(347.62, abs=0.01)
|
||||
assert s["payback_months"] == pytest.approx(0.705, abs=0.001)
|
||||
|
||||
|
||||
def test_risk_delta_clamps_at_zero():
|
||||
"""Conservative subtracts 0.10 from cost risk; usage (0.05) clamps to 0."""
|
||||
rows = apply_scenario(COSTS_VERBATIM, "conservative")
|
||||
usage = next(r for r in rows if r["field_key"] == "amazon_connect_usage")
|
||||
assert usage["risk_adjustment"] == 0.0
|
||||
impl = next(r for r in rows if r["field_key"] == "implementation_migration")
|
||||
assert impl["risk_adjustment"] == pytest.approx(0.0) # 0.10 − 0.10
|
||||
assert impl["initial"] == pytest.approx(1_087_500 * 0.80) # adoption scales initial
|
||||
|
||||
|
||||
def test_unknown_scenario_raises():
|
||||
with pytest.raises(KeyError):
|
||||
apply_scenario(BENEFITS_VERBATIM, "wildly_optimistic")
|
||||
|
||||
|
||||
def test_inputs_not_mutated():
|
||||
apply_scenario(BENEFITS_VERBATIM, "aggressive")
|
||||
apply_scenario(COSTS_VERBATIM, "conservative")
|
||||
assert BENEFITS_VERBATIM[0]["year_values"]["1"] == 13_911_040
|
||||
assert COSTS_VERBATIM[1]["initial"] == 1_087_500
|
||||
15
studies/202602_TEI_Amazon_Connect/tests/test_staging.py
Normal file
15
studies/202602_TEI_Amazon_Connect/tests/test_staging.py
Normal file
@@ -0,0 +1,15 @@
|
||||
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
|
||||
|
||||
from teicalc import staging
|
||||
|
||||
|
||||
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
|
||||
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
|
||||
assert not staging.on_stage()
|
||||
staging.backstage("visible")
|
||||
assert capsys.readouterr().out == "visible\n"
|
||||
|
||||
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
|
||||
assert staging.on_stage()
|
||||
staging.backstage("hidden")
|
||||
assert capsys.readouterr().out == ""
|
||||
Reference in New Issue
Block a user