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:
2026-07-09 14:29:46 -04:00
parent c3260ae7b8
commit a420af230b
33 changed files with 8235 additions and 6923 deletions

View 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",
]

View 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 (20262028). Values are
*nominal* (pre-risk-adjustment); the risk factor is stored per row and
applied by the model (benefits ×(1rf), 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 13% 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,
}

View 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* (``×(1rf)``), 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("$", "&#36;")

View 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))

View 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

View File

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