docs: introduce Mercury Notebook Deliverable Pattern

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

View File

@@ -0,0 +1,47 @@
# Mercury Notebook Template
Copy-me starting point for a Palladium study, per
[`docs/Mercury_Notebook_Pattern_V1-00.md`](../../docs/Mercury_Notebook_Pattern_V1-00.md).
The toy model is complete on purpose — every pattern mechanism (verbatim
anchor + contracted overlay, ramp mechanics, baseline-relative frame,
widget-pair reactivity, verification gate, backstage appendix, exports)
is present and runnable, so you replace math, not plumbing.
## Start a study
```bash
cp -r template/MercuryNotebook studies/YYYYMM_Client_EngagementName
cd studies/YYYYMM_Client_EngagementName
# 1. Rename the package (underscores only — dashes break Python imports):
# studylib/ → <yourstudy>lib/, then fix pyproject.toml + imports.
# 2. Replace studylib/model.py's toy domain with your math;
# re-pin tests/test_model.py with hand-checked numbers.
# 3. Rework notebooks/business_case.ipynb section by section —
# keep the widget-pair cells, gate cell, and appendix cell structure.
pip install -e ".[dev]"
```
## Run
| Task | Command |
|---|---|
| Tests | `pytest` |
| Serve (the stage) | `mercury --working-dir notebooks/` (run from 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` |
## What's here
```
studylib/model.py # ALL math — notebooks hold none
studylib/staging.py # on_stage()/backstage() — Mercury vs JupyterLab/nbconvert
notebooks/business_case.ipynb
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
```
Reference implementation (a full multi-notebook study):
`studies/202512_GenesysCX/ctm-token-calculator/`.

View File

@@ -0,0 +1,60 @@
# Mercury app-shell theme — NTT DATA brand (light).
# See docs/brand.md (repo root) for the source palette, and the CTM study's
# config.toml for a fully-tuned example.
#
# 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; omitted keys are derived.
[main]
title = "Study Title — Business Case"
favicon_emoji = "📊"
footer = "Study footer line"
notebooks_button_label = "Analyses"
[welcome]
header = "Study Title"
message = """
Interactive business-case notebooks. Tune the 🟡 inputs live for the
client, then export the personalized report source with
`python scripts/export_report.py`.
"""
[theme]
# ── Type — Georgia headings, Arial body (web-safe; no font 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 also drives Run button + focus ──
primary_color = "#0072bc"
accent_color = "#0072bc"
focus_border_color = "#0072bc"
hover_background_color = "#eef5fb"
selected_background_color = "#dcecfa"
# ── Sidebar / top bar / footer ──
sidebar_background_color = "#ffffff"
sidebar_text_color = "#2e404d"
sidebar_title_color = "#151d2c"
sidebar_shadow = "1px 0 0 #d5d9db"
topbar_background_color = "#151d2c"
topbar_text_color = "#ffffff"
topbar_border_color = "rgba(255,255,255,0.08)"
footer_background_color = "#ffffff"
footer_text_color = "#586671"
footer_border_color = "#d5d9db"

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,509 @@
# [Study Title] — Business Case
**Thesis:** one paragraph stating what this notebook demonstrates and the frame it uses
(here: a platform migration priced against *doing nothing*, with the vendor's pitched
numbers kept verbatim as the anchor and the signed contract layered over them).
This notebook **is** the deliverable: serve it interactively with
`mercury --working-dir notebooks/`, tune the 🟡 inputs live for the client, then export
an LLM-readable report source with `python scripts/export_report.py`. All math lives in
`studylib/` — the notebook renders it.
Confidence legend: 🟢 confirmed (published/contractual) · 🟡 estimated (working
assumption) · 🔴 unknown.
```python
# ── Setup ──────────────────────────────────────────────────────────
import sys, pathlib
_ROOT = pathlib.Path.cwd()
if not (_ROOT / "studylib").exists(): # notebook lives in notebooks/
_ROOT = _ROOT.parent
sys.path.insert(0, str(_ROOT))
import pandas as pd
import plotly.graph_objects as go
import mercury as mr
# Single source of truth — all math lives in the library; only
# presentation (and Mercury input widgets) lives here.
from studylib.model import (
YEARS, ANCHOR_VERBATIM, DEFAULT_RAMP_MONTHS,
anchor, benefits_by_year, case_flows, case_kpis, current_costs_by_year,
money, html_money, payback_label, platform_costs_by_year, services_by_year,
)
from studylib.staging import backstage
pd.options.display.float_format = "{:,.0f}".format
# ── Chart chrome (dataviz reference palette, light surface) ─────────
INK, INK2, MUTED = "#0b0b0b", "#52514e", "#898781"
SURFACE, GRID = "#fcfcfb", "#e1e0d9"
CUMULATIVE, CONTEXT = "#52514e", "#c3c2b7"
FONT_STACK = 'system-ui, -apple-system, "Segoe UI", sans-serif'
def tei_layout(fig, title, subtitle=None, height=420):
"""House chart chrome — recessive grid, ink text, title top-left."""
t = f"<b>{title}</b>"
if subtitle:
t += f"<br><span style='font-size:12px;color:{INK2}'>{subtitle}</span>"
fig.update_layout(
title=dict(text=t, font=dict(size=16, color=INK), x=0, xanchor="left"),
font=dict(family=FONT_STACK, size=12, color=INK),
paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,
margin=dict(l=60, r=30, t=80, b=45), height=height,
legend=dict(orientation="h", yanchor="bottom", y=1.0, x=0,
bgcolor="rgba(0,0,0,0)"),
xaxis=dict(showgrid=False, zeroline=False),
yaxis=dict(gridcolor=GRID, zeroline=False, tickformat="$,.0f"),
hovermode="x unified",
)
return fig
def bar(x, y, name, color):
return go.Bar(x=x, y=y, name=name, marker_color=color,
marker_line=dict(color=SURFACE, width=2),
hovertemplate="%{y:$,.0f}<extra>" + name + "</extra>")
def cum_line(x, y, name, color=CUMULATIVE, dash=None):
return go.Scatter(x=x, y=y, name=name, mode="lines+markers",
line=dict(color=color, width=2, dash=dash),
marker=dict(size=8),
hovertemplate="%{y:$,.0f}<extra>" + name + "</extra>")
X = [str(y) for y in YEARS]
backstage(f"studylib loaded — window {YEARS[0]}{YEARS[-1]}")
```
studylib loaded — window 20262028
<a id="section-1"></a>
## 1 · Inputs
Contract inputs collected as live widgets. Sidebar widgets are scenario knobs; use
`position="inline"` for data-collection tables that belong in the page flow.
> **Reactivity contract:** Mercury re-executes only the cells *below* a changed
> widget's cell — never the defining cell itself. So the next cell constructs
> widgets ONLY (no other output), and `.value` is read one cell further down.
```python
# ── Inputs (Mercury sidebar — widgets only, NO other output) ────────
# NB: Mercury re-executes only cells BELOW a changed widget's cell, so
# this cell constructs widgets ONLY — .value is read downstream.
_platform_w = mr.NumberInput(label="Platform run-rate ($/yr) — contracted",
value=round(anchor("platform_annual")),
min=0, max=10_000_000, step=25_000)
_ramp_w = mr.NumberInput(label="Ramp — billing-free months",
value=DEFAULT_RAMP_MONTHS, min=0, max=24, step=3)
_npv_w = mr.Select(label="NPV discount rate", value="10% (vendor)",
choices=["10% (vendor)", "8% (treasury)"])
```
<mercury.number.NumberInputWidget object at 0x726bea6f2cf0>
<mercury.number.NumberInputWidget object at 0x726bea609950>
<mercury.select.SelectWidget object at 0x726c48215160>
```python
# ── Model state (re-runs on any change to the widgets above) ────────
PLATFORM_ANNUAL = float(_platform_w.value)
RAMP_MONTHS = int(_ramp_w.value)
DISCOUNT_RATE = 0.10 if _npv_w.value.startswith("10") else 0.08
platform_by_year = platform_costs_by_year(RAMP_MONTHS, PLATFORM_ANNUAL)
services_by = services_by_year()
current_by_year = current_costs_by_year()
ben_by_year = benefits_by_year()
BASELINE_ANNUAL = anchor("baseline_annual")
backstage(f"platform by year: { {y: money(v) for y, v in platform_by_year.items()} }")
print(f"platform run-rate: contracted {money(PLATFORM_ANNUAL)}/yr "
f"(vendor pitched {money(ANCHOR_VERBATIM['platform_annual'])}/yr) · "
f"ramp {RAMP_MONTHS} months")
```
platform by year: {2026: '$250K', 2027: '$500K', 2028: '$500K'}
platform run-rate: contracted $500K/yr (vendor pitched $600K/yr) · ramp 6 months
<a id="section-2"></a>
## 2 · Business case vs doing nothing
**Frame:** baseline-relative. Incremental cost = programme cost baseline
(the do-nothing run-rate); net = benefits incremental cost. Double-billing
while the old platform runs off and the post-termination cost-avoidance credit
both fall out of this one frame.
The KPI table keeps a **vendor-frame column** (pitched rate, verbatim anchors)
beside the **contracted column**, so the client can walk from their own numbers
to the corrected reality.
```python
study_costs = pd.DataFrame({
"Platform (contracted, ramp-adjusted)": platform_by_year,
"Services (year 1)": services_by,
"Existing platform (term-contract run-off)": current_by_year,
}).T[YEARS]
study_costs["3-yr"] = study_costs.sum(axis=1)
total_by_year = {y: float(study_costs[y].sum()) for y in YEARS}
inc, net_by = case_flows(total_by_year, ben_by_year)
kpi = case_kpis(inc, net_by, DISCOUNT_RATE)
# Vendor-anchored comparison: same frame at the pitched (verbatim) rate.
_plat_vendor = platform_costs_by_year(RAMP_MONTHS, ANCHOR_VERBATIM["platform_annual"])
total_vendor = {y: current_by_year[y] + _plat_vendor[y] + services_by[y] for y in YEARS}
inc_v, net_v = case_flows(total_vendor, ben_by_year)
kpi_v = case_kpis(inc_v, net_v, DISCOUNT_RATE)
def kpi_col(k):
return {
"3-yr benefits": money(k["benefits_3yr"]),
"3-yr incremental cost": money(k["incremental_cost_3yr"]),
"3-yr net": money(k["net_3yr"]),
"ROI": f"{k['roi']:.0%}" if k["roi"] is not None else "n/a — net saving",
f"NPV @ {k['discount_rate']:.0%}": money(k["npv"]),
"Payback": k["payback"],
}
kpis_fmt = pd.DataFrame({
f"Vendor frame ({money(ANCHOR_VERBATIM['platform_annual'])}/yr)": kpi_col(kpi_v),
f"Contracted ({money(PLATFORM_ANNUAL)}/yr)": kpi_col(kpi),
})
backstage(f"net by year: { {y: money(v) for y, v in net_by.items()} }")
display(study_costs)
display(kpis_fmt)
```
net by year: {2026: '-$500K', 2027: '-$200K', 2028: '$1.1M'}
<div>
<style scoped>
.dataframe tbody tr th:only-of-type {
vertical-align: middle;
}
.dataframe tbody tr th {
vertical-align: top;
}
.dataframe thead th {
text-align: right;
}
</style>
<table border="1" class="dataframe">
<thead>
<tr style="text-align: right;">
<th></th>
<th>2026</th>
<th>2027</th>
<th>2028</th>
<th>3-yr</th>
</tr>
</thead>
<tbody>
<tr>
<th>Platform (contracted, ramp-adjusted)</th>
<td>250,000</td>
<td>500,000</td>
<td>500,000</td>
<td>1,250,000</td>
</tr>
<tr>
<th>Services (year 1)</th>
<td>250,000</td>
<td>0</td>
<td>0</td>
<td>250,000</td>
</tr>
<tr>
<th>Existing platform (term-contract run-off)</th>
<td>1,000,000</td>
<td>1,000,000</td>
<td>0</td>
<td>2,000,000</td>
</tr>
</tbody>
</table>
</div>
<div>
<style scoped>
.dataframe tbody tr th:only-of-type {
vertical-align: middle;
}
.dataframe tbody tr th {
vertical-align: top;
}
.dataframe thead th {
text-align: right;
}
</style>
<table border="1" class="dataframe">
<thead>
<tr style="text-align: right;">
<th></th>
<th>Vendor frame ($600K/yr)</th>
<th>Contracted ($500K/yr)</th>
</tr>
</thead>
<tbody>
<tr>
<th>3-yr benefits</th>
<td>$900K</td>
<td>$900K</td>
</tr>
<tr>
<th>3-yr incremental cost</th>
<td>$750K</td>
<td>$500K</td>
</tr>
<tr>
<th>3-yr net</th>
<td>$150K</td>
<td>$400K</td>
</tr>
<tr>
<th>ROI</th>
<td>20%</td>
<td>80%</td>
</tr>
<tr>
<th>NPV @ 10%</th>
<td>$3K</td>
<td>$207K</td>
</tr>
<tr>
<th>Payback</th>
<td>35 months (~Nov 2028)</td>
<td>32 months (~Aug 2028)</td>
</tr>
</tbody>
</table>
</div>
```python
fig = go.Figure()
fig.add_trace(bar(X, [ben_by_year[y] for y in YEARS], "Benefits", "#1baf7a"))
fig.add_trace(bar(X, [-inc[y] for y in YEARS],
f"Incremental cost vs {money(BASELINE_ANNUAL)}/yr baseline",
"#e34948"))
cum_net = pd.Series([net_by[y] for y in YEARS]).cumsum()
fig.add_trace(cum_line(X, cum_net, "Cumulative net"))
fig.update_layout(barmode="relative")
# Several amounts in one annotation → html_money, or MathJax eats the text.
fig.add_annotation(
xref="paper", yref="paper", x=0.01, y=0.98, align="left", showarrow=False,
font=dict(size=12, color=INK2), bgcolor=SURFACE, bordercolor=GRID, borderwidth=1,
text=(f"3-yr net <b>{html_money(kpi['net_3yr'])}</b> · "
f"NPV@{DISCOUNT_RATE:.0%} <b>{html_money(kpi['npv'])}</b> · "
f"payback <b>{kpi['payback']}</b>"))
tei_layout(fig, "Business case vs doing nothing",
"Baseline-relative: net = benefits (programme cost baseline)")
fig.show()
```
<a id="section-3"></a>
## 3 · Verification & assertions
Engine pins use **explicit default arguments**, so the gate tests `studylib`, not the
current widget state; live-state checks only run when the inputs sit at their defaults.
This cell must pass under headless `nbconvert --execute` — it is the study's smoke test.
Output renders backstage only.
```python
def _approx(got, want, tol=0.5):
assert abs(got - want) <= tol, f"got {got:,.2f}, want {want:,.2f}"
# Engine pins — explicit defaults, independent of widget state
_approx(anchor("platform_annual"), 500_000) # signed overlay
_approx(ANCHOR_VERBATIM["platform_annual"], 600_000) # vendor record intact
_p = platform_costs_by_year() # verbatim rate, 6-mo ramp
_approx(_p[2026], 300_000)
_approx(_p[2027], 600_000)
_b = benefits_by_year()
_approx(sum(_b.values()), anchor("benefit_3yr"))
_approx(_b[2026], 0) # nothing lands in year 1
# Default-flow pins (contracted frame at engine defaults)
_pc = platform_costs_by_year(annual=anchor("platform_annual"))
_tot = {y: current_costs_by_year()[y] + _pc[y] + services_by_year()[y] for y in YEARS}
_, _net = case_flows(_tot, _b)
_approx(sum(_net.values()), 400_000)
assert payback_label(_net) == "32 months (~Aug 2028)"
# Live state — only checked when the widgets sit at their defaults
_at_default = (PLATFORM_ANNUAL == round(anchor("platform_annual"))
and RAMP_MONTHS == DEFAULT_RAMP_MONTHS)
if _at_default:
_approx(kpi["net_3yr"], 400_000)
for y in YEARS:
_approx(net_by[y], ben_by_year[y] - (total_by_year[y] - BASELINE_ANNUAL))
backstage("All assertions passed.")
backstage(f" net 3-yr {money(kpi['net_3yr'])} · payback {kpi['payback']}")
```
All assertions passed.
net 3-yr $400K · payback 32 months (~Aug 2028)
<a id="section-4"></a>
## 4 · Data appendix — for the machines
Everything above, dumped as markdown tables plus one JSON block of model state, so the
exported report is complete LLM input without re-running anything. The dump renders
**backstage** (JupyterLab and the exports) and stays hidden in the Mercury app.
```python
# ── Data appendix — LLM-readable dump of every model output ──────────
# Renders backstage only (JupyterLab / nbconvert exports) — hidden on
# the Mercury stage, where the narrative and figures carry the story.
import json as _json
def _section(title, df, **kw):
backstage(f"\n#### {title}\n")
backstage(df.to_markdown(floatfmt=",.0f", **kw))
_section("Cost stack ($)", study_costs)
_section("Business-case flows vs do-nothing baseline ($)",
pd.DataFrame({"programme cost": total_by_year,
"incremental cost": inc, "net": net_by}).T[YEARS])
_section("KPIs — vendor frame vs contracted", kpis_fmt)
backstage("\n#### Model state (JSON)\n")
backstage("```json")
backstage(_json.dumps({
"scenario": "TEMPLATE — replace with the study's one-line scenario",
"benefit_by_year": {str(y): round(ben_by_year[y]) for y in YEARS},
"cost_by_year": {str(y): round(total_by_year[y]) for y in YEARS},
"net_by_year": {str(y): round(net_by[y]) for y in YEARS},
"kpis": {k: (round(v, 4) if isinstance(v, float) else v)
for k, v in kpi.items()},
"kpis_vendor_frame": {k: (round(v, 4) if isinstance(v, float) else v)
for k, v in kpi_v.items()},
"assumptions": {
"platform_annual": round(PLATFORM_ANNUAL),
"vendor_platform_annual": ANCHOR_VERBATIM["platform_annual"],
"ramp_months": RAMP_MONTHS,
"discount_rate": DISCOUNT_RATE,
"baseline_annual": round(BASELINE_ANNUAL),
},
}, indent=2))
backstage("```")
```
#### Cost stack ($)
| | 2026 | 2027 | 2028 | 3-yr |
|:------------------------------------------|----------:|----------:|--------:|----------:|
| Platform (contracted, ramp-adjusted) | 250,000 | 500,000 | 500,000 | 1,250,000 |
| Services (year 1) | 250,000 | 0 | 0 | 250,000 |
| Existing platform (term-contract run-off) | 1,000,000 | 1,000,000 | 0 | 2,000,000 |
#### Business-case flows vs do-nothing baseline ($)
| | 2026 | 2027 | 2028 |
|:-----------------|----------:|----------:|----------:|
| programme cost | 1,500,000 | 1,500,000 | 500,000 |
| incremental cost | 500,000 | 500,000 | -500,000 |
| net | -500,000 | -200,000 | 1,100,000 |
#### KPIs — vendor frame vs contracted
| | Vendor frame ($600K/yr) | Contracted ($500K/yr) |
|:----------------------|:--------------------------|:------------------------|
| 3-yr benefits | $900K | $900K |
| 3-yr incremental cost | $750K | $500K |
| 3-yr net | $150K | $400K |
| ROI | 20% | 80% |
| NPV @ 10% | $3K | $207K |
| Payback | 35 months (~Nov 2028) | 32 months (~Aug 2028) |
#### Model state (JSON)
```json
{
"scenario": "TEMPLATE \u2014 replace with the study's one-line scenario",
"benefit_by_year": {
"2026": 0,
"2027": 300000,
"2028": 600000
},
"cost_by_year": {
"2026": 1500000,
"2027": 1500000,
"2028": 500000
},
"net_by_year": {
"2026": -500000,
"2027": -200000,
"2028": 1100000
},
"kpis": {
"benefits_3yr": 900000.0,
"incremental_cost_3yr": 500000.0,
"net_3yr": 400000.0,
"roi": 0.8,
"npv": 206611.5702,
"discount_rate": 0.1,
"payback": "32 months (~Aug 2028)"
},
"kpis_vendor_frame": {
"benefits_3yr": 900000.0,
"incremental_cost_3yr": 750000.0,
"net_3yr": 150000.0,
"roi": 0.2,
"npv": 3380.9166,
"discount_rate": 0.1,
"payback": "35 months (~Nov 2028)"
},
"assumptions": {
"platform_annual": 500000,
"vendor_platform_annual": 600000,
"ramp_months": 6,
"discount_rate": 0.1,
"baseline_annual": 1000000
}
}
```

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 = "mercury_notebook_template" # rename to your study, underscores only
version = "0.1.0"
description = "Mercury Notebook Pattern template — copy me to start a study"
requires-python = ">=3.10"
# The notebooks are the deliverables (served with Mercury, exported via
# nbconvert, tables via tabulate) — the whole toolchain is a required
# runtime dependency, not an extra. `pip install -e .` must be enough.
dependencies = [
"pandas>=2.0",
"plotly>=5.18",
"openpyxl>=3.1",
"mercury>=3.2",
"jupyterlab>=4.0",
"ipywidgets>=8.0",
"nbconvert>=7",
"tabulate>=0.9",
]
[project.optional-dependencies]
dev = ["pytest>=7.4", "mypy>=1.8"]
[tool.setuptools.packages.find]
include = ["studylib*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
[tool.mypy]
strict = true
packages = ["studylib"]

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

View File

@@ -0,0 +1 @@
"""Study engine package — rename ``studylib`` to match your study."""

View File

@@ -0,0 +1,166 @@
"""
Study engine — the single source of truth for every number in the notebooks.
Replace the toy domain below with the study's real model; the *structure*
is the pattern:
- ``ANCHOR_VERBATIM`` — the client/vendor source record, never edited.
- ``ANCHOR_CONTRACTED`` — signed values layered over it; ``anchor()`` reads
through, so the source anchor survives for as-pitched comparisons.
- ``*_by_year`` schedules keyed by calendar year.
- A baseline-relative case frame (``case_flows`` / ``case_kpis``).
The notebooks hold no math — they call this module and render.
"""
from __future__ import annotations
import math
# ── Timeline ─────────────────────────────────────────────────────────
YEARS = [2026, 2027, 2028] # model window; contract starts Jan of YEARS[0]
_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}"
# ── Anchors: verbatim source record + contracted overlay ─────────────
#: The vendor's pitch / the client's source deck — VERBATIM, do not edit.
ANCHOR_VERBATIM: dict[str, float] = {
"baseline_annual": 1_000_000, # do-nothing run-rate
"platform_annual": 600_000, # pitched platform run-rate
"services_y1": 250_000, # pitched one-off services, year 1
"benefit_3yr": 900_000, # claimed 3-yr benefit
"npv_discount_rate": 0.10,
}
#: Signed values where they differ from the pitch — 🟢 contractual.
ANCHOR_CONTRACTED: dict[str, float] = {
"platform_annual": 500_000, # signed run-rate (pitch said $600K)
}
def anchor(key: str) -> float:
"""Contracted value where one exists, else the verbatim anchor."""
return ANCHOR_CONTRACTED.get(key, ANCHOR_VERBATIM[key])
DEFAULT_RAMP_MONTHS = 6 # platform billing starts month 7
DEFAULT_TERMINATION_YEAR = 2027 # existing platform bills through this year
REALIZE_MONTH = 18 # benefits realize from month 19
# ── Cost & benefit schedules (calendar-year keyed) ───────────────────
def platform_costs_by_year(
ramp_months: int = DEFAULT_RAMP_MONTHS, annual: float | None = None
) -> dict[int, float]:
"""Ramp programme: billing starts in calendar month ramp_months + 1."""
rate = ANCHOR_VERBATIM["platform_annual"] if annual is None else annual
out = {}
for yi, y in enumerate(YEARS, start=1):
start, end = 12 * (yi - 1) + 1, 12 * yi
months = max(0, end - max(start, ramp_months + 1) + 1)
out[y] = rate * months / 12
return out
def current_costs_by_year(
termination_year: int = DEFAULT_TERMINATION_YEAR, annual: float | None = None
) -> dict[int, float]:
"""Existing-platform run-off (the double-billing line)."""
rate = ANCHOR_VERBATIM["baseline_annual"] if annual is None else annual
return {y: (rate if y <= termination_year else 0.0) for y in YEARS}
def services_by_year() -> dict[int, float]:
"""One-off services — verbatim, year 1 only."""
return {y: (ANCHOR_VERBATIM["services_y1"] if y == YEARS[0] else 0.0)
for y in YEARS}
def benefits_by_year(realize_month: int = REALIZE_MONTH) -> dict[int, float]:
"""Phase the claimed 3-yr benefit across its live months."""
live = {y: max(0, 12 * yi - max(12 * (yi - 1), realize_month))
for yi, y in enumerate(YEARS, start=1)}
total = sum(live.values())
return {y: anchor("benefit_3yr") * m / total if total else 0.0
for y, m in live.items()}
# ── Business case (baseline-relative frame) ──────────────────────────
def case_flows(
total_cost_by_year: dict[int, float],
benefit_by_year: dict[int, float],
baseline_annual: float | None = None,
) -> tuple[dict[int, float], dict[int, float]]:
"""(incremental cost, net) vs the do-nothing baseline."""
base = ANCHOR_VERBATIM["baseline_annual"] if baseline_annual is None \
else baseline_annual
inc = {y: total_cost_by_year[y] - base for y in YEARS}
net = {y: benefit_by_year[y] - inc[y] for y in YEARS}
return inc, net
def npv(flows: list[float], rate: float) -> float:
"""NPV with the first flow discounted one full year."""
return sum(v / (1 + rate) ** i for i, v in enumerate(flows, start=1))
def payback_label(net_by_year: dict[int, float]) -> str:
cum = 0.0
for i, y in enumerate(YEARS):
step = net_by_year[y]
if cum + step >= 0:
if i == 0 and step >= 0:
return "immediate"
frac = (-cum / step) if step > 0 else 0.0
m = math.ceil((i + frac) * 12)
return f"{m} months (~{month_label(m)})"
cum += step
return f"beyond {YEARS[-1]}"
def case_kpis(
inc: dict[int, float],
net: dict[int, float],
discount_rate: float | None = None,
) -> dict:
"""KPIs for one cost frame. Benefits are recoverable as net + inc."""
rate = ANCHOR_VERBATIM["npv_discount_rate"] if discount_rate is None \
else discount_rate
net_list = [net[y] for y in YEARS]
inc_total = sum(inc.values())
net_total = sum(net_list)
return {
"benefits_3yr": net_total + inc_total,
"incremental_cost_3yr": inc_total,
"net_3yr": net_total,
"roi": (net_total / inc_total) if inc_total > 0 else None,
"npv": npv(net_list, rate),
"discount_rate": rate,
"payback": payback_label(net),
}
# ── Display helpers ──────────────────────────────────────────────────
def money(v: float) -> str:
sign, a = ("-" if v < 0 else ""), abs(v)
return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
def html_money(v: float) -> str:
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
annotations holding several amounts must use the HTML entity instead."""
return money(v).replace("$", "&#36;")

View File

@@ -0,0 +1,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)

View File

@@ -0,0 +1,7 @@
"""Make studylib importable without installation (the template is not
pip-installed; real studies use ``pip install -e ".[dev]"`` instead)."""
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))

View File

@@ -0,0 +1,62 @@
"""Toy-model acceptance pins — hand-checked, like every study must have.
Every number here was computed by hand before it was pinned. When you
replace the toy domain, replace these with hand-checks of YOUR math;
the in-notebook verification gate is the second layer, not a substitute.
"""
from __future__ import annotations
import pytest
from studylib import model as m
def test_contracted_overlays_verbatim():
assert m.anchor("platform_annual") == 500_000 # signed contract
assert m.ANCHOR_VERBATIM["platform_annual"] == 600_000 # pitch record intact
assert m.anchor("baseline_annual") == m.ANCHOR_VERBATIM["baseline_annual"]
def test_ramp_mechanics():
assert m.platform_costs_by_year() == {2026: 300_000, 2027: 600_000,
2028: 600_000}
assert m.platform_costs_by_year(12)[2026] == 0.0
assert m.platform_costs_by_year(0)[2026] == 600_000.0
assert m.platform_costs_by_year(annual=m.anchor("platform_annual")) == \
{2026: 250_000, 2027: 500_000, 2028: 500_000}
def test_benefits_phase_on_the_schedule():
ben = m.benefits_by_year()
assert ben[2026] == 0.0 # nothing lands in year 1
assert ben[2027] == pytest.approx(900_000 * 6 / 18) # months 19-24
assert ben[2028] == pytest.approx(900_000 * 12 / 18)
assert sum(ben.values()) == pytest.approx(m.anchor("benefit_3yr"))
def _frame(annual):
plat = m.platform_costs_by_year(annual=annual)
total = {y: m.current_costs_by_year()[y] + plat[y] + m.services_by_year()[y]
for y in m.YEARS}
return m.case_flows(total, m.benefits_by_year())
def test_contracted_frame_kpis():
inc, net = _frame(m.anchor("platform_annual"))
assert sum(net.values()) == pytest.approx(400_000)
kpi = m.case_kpis(inc, net)
assert kpi["roi"] == pytest.approx(0.8)
assert kpi["npv"] == pytest.approx(206_612, abs=1) # @10%, hand-checked
assert kpi["payback"] == "32 months (~Aug 2028)"
def test_vendor_frame_kpis():
inc, net = _frame(None) # verbatim pitch rate
assert sum(net.values()) == pytest.approx(150_000)
assert m.case_kpis(inc, net)["payback"] == "35 months (~Nov 2028)"
def test_payback_edges():
assert m.payback_label({2026: 1.0, 2027: 0.0, 2028: 0.0}) == "immediate"
assert m.payback_label({2026: -1.0, 2027: -1.0, 2028: -1.0}) == "beyond 2028"

View File

@@ -0,0 +1,15 @@
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
from studylib 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 == ""