# [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"{title}" if subtitle: t += f"
{subtitle}" 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}" + name + "") 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}" + name + "") X = [str(y) for y in YEARS] backstage(f"studylib loaded โ€” window {YEARS[0]}โ€“{YEARS[-1]}") ``` studylib loaded โ€” window 2026โ€“2028 ## 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)"]) ``` ```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 ## 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'}
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
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)
```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 {html_money(kpi['net_3yr'])} ยท " f"NPV@{DISCOUNT_RATE:.0%} {html_money(kpi['npv'])} ยท " f"payback {kpi['payback']}")) tei_layout(fig, "Business case vs doing nothing", "Baseline-relative: net = benefits โˆ’ (programme cost โˆ’ baseline)") fig.show() ``` ## 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) ## 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 } } ```