# [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.

In [1]:

    # ── 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

## 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.

In [2]:

    # ── 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)"])

In [3]:

    # ── 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.

In [4]:

    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)

In [5]:

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

## 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.

In [6]:

    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.

In [7]:

    # ── 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
      }
    }
    ```