docs: introduce Mercury Notebook Deliverable Pattern
This commit is contained in:
509
template/MercuryNotebook/exports/business_case.md
Normal file
509
template/MercuryNotebook/exports/business_case.md
Normal 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 2026–2028
|
||||
|
||||
|
||||
<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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user