diff --git a/README.md b/README.md index 443c7f5..3824447 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,12 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic | **`core/cli`** | `python -m palladium` command-line interface | | **`app/`** | Streamlit data-entry UI with version management β€” *study-agnostic* | | **`studies/`** | One folder per TEI engagement (notebooks, seed data, config, source PDF) | +| **`template/`** | Copy-me study templates β€” start here for new studies | + +> **New studies follow the [Mercury Notebook Deliverable Pattern](docs/Mercury_Notebook_Pattern_V1-00.md)**: +> the notebook *is* the artifact β€” self-contained study package, Mercury-served, +> gate-verified, LLM-exportable. Start from [`template/MercuryNotebook/`](template/MercuryNotebook/). +> The Streamlit `app/` path is retired by that pattern; existing TEI studies migrate to it. --- diff --git a/docs/Mercury_Notebook_Pattern_V1-00.md b/docs/Mercury_Notebook_Pattern_V1-00.md new file mode 100644 index 0000000..3fa6bdf --- /dev/null +++ b/docs/Mercury_Notebook_Pattern_V1-00.md @@ -0,0 +1,440 @@ +# Mercury Notebook Deliverable Pattern v1.0.0 + +Standardizes how Palladium studies ship business-case deliverables: a Mercury-served +Jupyter notebook **is** the artifact β€” math in a self-contained study package, +presentation in reactive notebooks, verification gates pinning every number, and +LLM-readable exports. All new studies follow this pattern; the Streamlit app path is +retired by it. + +## 🐾 Red Panda Approvalβ„’ + +This pattern follows Red Panda Approval standards. + +**Audience note:** this document is written to be loaded whole as context by an LLM +agent building or modifying a study. Rules are imperative (MUST/SHOULD/NEVER), each +with a one-line *why*. Long code lives in the runnable template +[`template/MercuryNotebook/`](../template/MercuryNotebook/) β€” copy it to start a study; +snippets here are excerpts from it. The full-scale reference implementation is the CTM +Genesys study, [`studies/202512_GenesysCX/ctm-token-calculator/`](../studies/202512_GenesysCX/ctm-token-calculator/). + +--- + +## Why a Pattern, Not a Shared Implementation + +Every study's domain math is different, and studies are **frozen deliverables**: + +- The CTM Genesys study models per-feature AI token meters, WFM benefit scoping, and + signed-contract mechanics (ramp, SOW milestones, managed services). +- A TEI study reproduces a Forrester composite organization's benefits/costs and then + personalizes them to a client. +- A migration scenario strips capabilities out and prices the platform move alone. + +A shared calculation engine would make every signed business case a hostage of the next +study's refactor β€” a change to shared NPV rounding could silently move a number the +client already approved. So: + +- **Each study package owns ALL of its math**, including ~50 lines of finance + primitives (NPV, payback). Duplication is the accepted price of immutability. +- The pattern standardizes **structure and contracts** β€” layout, reactivity, staging, + verification, export β€” not code. +- The only shared plumbing is Athena access (`core/tei_client`) for client/opportunity + context; it touches no study math. + +Instead, this pattern defines: + +- **Required structure & contracts** β€” every study must have +- **Standard choice values** β€” conventions for interoperability (and LLM legibility) +- **Recommended practices** β€” most studies should include +- **Anti-patterns** β€” the mistakes that cost us debugging sessions, catalogued + +--- + +## Repository Layout & Naming + +``` +palladium/ +β”œβ”€β”€ docs/ # repo-wide docs (this pattern, brand.md) +β”œβ”€β”€ template/ +β”‚ └── MercuryNotebook/ # copy-me starting point (runnable) +└── studies/ + β”œβ”€β”€ YYYYMM_TEI_Vendor_Product/ # vendor TEI study, e.g. 202602_TEI_Amazon_Connect + └── YYYYMM_Client_EngagementName/ # client study, e.g. 202512_CTM_GenesysCX +``` + +- Names MUST use **underscores, never dashes** β€” dashed directories can't be Python + packages, and everything in a study is importable code. + *(The CTM study's inner `ctm-token-calculator/` predates this rule; it gets renamed + when the studies migrate.)* +- Every study is self-contained with this layout (from the template): + +``` +/ +β”œβ”€β”€ / # THE study package β€” all math lives here, renamed per study +β”‚ β”œβ”€β”€ model.py # domain model: anchors, schedules, case frame, KPIs +β”‚ └── staging.py # stage/backstage detection (copy verbatim) +β”œβ”€β”€ notebooks/ # the deliverables β€” presentation only +β”œβ”€β”€ tests/ # hand-checked pinned acceptance numbers +β”œβ”€β”€ scripts/export_report.py +β”œβ”€β”€ docs/ # source material (vendor decks, contracts, LoE docs) +β”œβ”€β”€ exports/ # generated .html + .md report sources +β”œβ”€β”€ config.toml # Mercury app-shell theme (NTT DATA brand) +└── pyproject.toml # full toolchain as core deps +``` + +--- + +## Required Structure & Contracts + +The non-negotiables. Every study MUST satisfy all seven. + +### 1 Β· The notebook is the artifact + +There is exactly **one** implementation of the study: the notebook(s), served +interactively with `mercury --working-dir notebooks/` for stakeholders, opened in +JupyterLab by analysts, executed headless by nbconvert for exports. NEVER build a +parallel UI (Streamlit, Dash, a second "app" rendering the same model) β€” two surfaces +over one model always diverge, and the notebook already *is* the interactive surface. + +### 2 Β· Engine/presentation split + +Notebooks hold **no math**. Every number a stakeholder sees is computed in the study +package and imported. *Why:* the package is testable and diffable; notebook cells are +neither. The test suite pins the engine; the notebook only arranges its outputs. + +```python +# notebook cell β€” arrange and render, never compute +from studylib.model import case_flows, case_kpis, money +inc, net_by = case_flows(total_by_year, ben_by_year) +kpi = case_kpis(inc, net_by, DISCOUNT_RATE) +``` + +### 3 Β· The Mercury reactivity contract + +Mercury re-executes only the cells **below** a changed widget's cell β€” never the +defining cell itself (upstream: `onWidgetUpdate` re-runs from `updatedIndex + 1`). +Therefore, always a **widget-pair**: + +```python +# ── Cell A: widgets ONLY β€” no other statements, no output ─────────── +_platform_w = mr.NumberInput(label="Platform run-rate ($/yr) β€” contracted", + value=round(anchor("platform_annual")), + min=0, max=10_000_000, step=25_000) +``` + +```python +# ── Cell B (below A): read .value, derive model state ─────────────── +PLATFORM_ANNUAL = float(_platform_w.value) +platform_by_year = platform_costs_by_year(RAMP_MONTHS, PLATFORM_ANNUAL) +``` + +- A `.value` read in the defining cell is **frozen at first render** β€” the classic + "I changed the input and nothing happened" bug. +- Widget cells MUST produce no other output β€” stray output in a widget cell leaks into + the Mercury sidebar. +- Always pass explicit `min=`/`max=` to `NumberInput` β€” Mercury clamps out-of-range + seeds to a small default range (and older builds crash doing it). +- Headless nbconvert takes every widget at its seed value, which is why defaults must + form a coherent, gate-passing scenario. + +### 4 Β· Verification gate cell + +Every deliverable notebook ends its analytical sections with a gate cell that MUST pass +under headless `jupyter nbconvert --execute` β€” it is the study's smoke test and the +reviewer's proof that the rendered numbers match the engine. + +```python +def _approx(got, want, tol=0.5): + assert abs(got - want) <= tol, f"got {got:,.2f}, want {want:,.2f}" + +# Engine pins β€” EXPLICIT default arguments, independent of widget state +_approx(anchor("platform_annual"), 500_000) # signed overlay +_approx(platform_costs_by_year()[2026], 300_000) # hand-checked + +# Live-state checks β€” guarded, so a stakeholder moving a slider +# doesn't crash the notebook +_at_default = (PLATFORM_ANNUAL == round(anchor("platform_annual"))) +if _at_default: + _approx(kpi["net_3yr"], 400_000) +for y in YEARS: # ties hold at ANY widget state + _approx(net_by[y], ben_by_year[y] - (total_by_year[y] - BASELINE_ANNUAL)) + +backstage("All assertions passed.") +``` + +Pin *engine calls with explicit defaults* (widget-independent), guard *live-state +value* checks with `_at_default`, and assert *structural ties* (stack sums, flow +identities) unconditionally β€” they must hold at any widget setting. + +### 5 Β· Machine-readable data appendix + +The last section dumps every model output as markdown tables plus **one JSON block of +model state** (KPIs + assumptions). *Why:* plotly figures export as JavaScript an LLM +cannot read; the appendix makes the exported `.md` complete LLM input β€” and it is the +future Athena study-export payload. Renders backstage only (Β§6). See the appendix cell +in [`template/MercuryNotebook/notebooks/business_case.ipynb`](../template/MercuryNotebook/notebooks/business_case.ipynb). + +### 6 Β· Stage / backstage + +The Mercury app is **the stage** β€” stakeholder-facing. JupyterLab and nbconvert are +**backstage** β€” analyst diagnostics and the data appendix belong there. Detection: +the `mercury` CLI exports `MERCURY_CONFIG_DIR` into its server and every kernel +inherits it; JupyterLab/nbconvert kernels don't have it. + +```python +# studylib/staging.py β€” copy verbatim +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) +``` + +Route through `backstage()`: dict echoes, seeds/cross-foots, gate chatter, analyst +prose, the whole data appendix. Keep on stage (plain `print`): only curated one-line +insights a stakeholder should read (e.g. *"the licence correction moves the 3-yr case +by $2.2M"*). + +### 7 Β· Packaging + +`pyproject.toml` declares the **whole toolchain as core dependencies** β€” mercury, +jupyterlab, ipywidgets, nbconvert, tabulate, pandas, plotly, openpyxl. `pip install +-e .` must be enough to serve, edit, test, and export. NEVER hide a runtime dep in an +extra and NEVER keep a parallel `requirements.txt` β€” a stakeholder demo failing on a +missing `tabulate` is how this rule was learned. Only `dev = ["pytest", "mypy"]` +remains an extra. See [`template/MercuryNotebook/pyproject.toml`](../template/MercuryNotebook/pyproject.toml). + +--- + +## Standard Choice Values + +Use these exact conventions β€” LLM agents and humans navigate studies by them. + +### Confidence legend + +Every input and cost/benefit line carries one, in narrative and in tables: + +| Icon | Meaning | +|---|---| +| 🟒 | confirmed β€” published or contractual | +| 🟑 | estimated β€” working assumption, stated | +| πŸ”΄ | unknown β€” flagged, bounded by sensitivity if material | + +### Naming + +| Convention | Example | Why | +|---|---|---| +| `_*_w` for widget objects | `_licence_w`, `_ramp_w` | underscore = presentation plumbing, `_w` = widget-pair member | +| UPPERCASE for widget-derived state | `LICENCE_ANNUAL = float(_licence_w.value)` | reads as the notebook's scenario constants | +| `*_by_year` dicts keyed by **calendar year** | `{2026: 1_600_000, ...}` | calendar years, never year-index 1/2/3, in anything displayed | +| `*_VERBATIM` for source anchors | `TCO_VERBATIM`, `ANCHOR_VERBATIM` | the client/vendor record β€” NEVER edited (see Recommended) | +| `money()` / `html_money()` | `$3.2M`, `$3.2M` | one house format; see the MathJax trap in Anti-Patterns | + +### Section anchors + +Each section heading carries ``; a sidebar table of contents uses +**onclick-JS** navigation (fragment `href`s don't scroll in Mercury's SPA, and +python-markdown escapes any raw `<` inside handler attributes β€” keep handlers +comparison-free). Recipe: the ToC cell in +[`studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb`](../studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb). + +--- + +## Recommended Practices + +Most studies should include these; deviate knowingly. + +### Verbatim anchors + contracted overlay + +Keep the client's/vendor's source record **verbatim and immutable**; layer signed or +corrected values over it; read through a helper: + +```python +ANCHOR_VERBATIM = {"platform_annual": 600_000, ...} # the pitch β€” do not edit +ANCHOR_CONTRACTED = {"platform_annual": 500_000} # 🟒 signed + +def anchor(key): + return ANCHOR_CONTRACTED.get(key, ANCHOR_VERBATIM[key]) +``` + +*Why:* the stakeholder walk starts from *their own numbers*. Corrections presented as +overlays are auditable; corrections made by editing the source are arguments. Show a +**vendor/deck-frame KPI column beside the contracted column** so the walk from the +pitch to reality stays explicit. Reference: `TCO_VERBATIM`/`TCO_CONTRACTED`/`tco()` in +[`studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py`](../studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py). + +### Baseline-relative case frame + +Price against *doing nothing*: incremental cost = programme cost βˆ’ baseline run-rate; +net = benefits βˆ’ incremental cost. One frame captures double-billing penalties and +post-termination cost-avoidance credits without special cases. Where a case doesn't pay +back in-window, add a **run-rate breakeven extrapolation** KPI (steady-state saving per +year fills the end-of-window deficit). + +### Chart chrome + +One `tei_layout()` helper per study (title top-left, recessive grid, ink text, house +font stack); `bar()` with a 2px surface `marker_line` (the gap that separates stacked +segments); fixed entityβ†’color maps (`COST_COLOR`, `REGION_COLOR`) so color follows the +entity across every figure; diverging heatmaps centered with `zmid=0`; selective direct +labels, not a number on every mark. Skeleton in the template's setup cell; palette +rules in the repo dataviz reference and [`docs/brand.md`](brand.md). + +### Widget placement + +Sidebar (default) for scenario knobs; `position="inline"` for data-collection tables +that belong in the page flow (per-region costs, termination dates). Seed every widget +from the engine's defaults (`value=round(anchor(...))`) so notebook and engine can't +disagree about the starting scenario. + +### Contract tables on stage + +Milestones, billing schedules, and scope tables render on stage as small DataFrames β€” +stakeholders verify contracts by looking at them, not by trusting prose. + +--- + +## Athena Integration + +- **Inbound (now):** pull client/opportunity context (company, industry, engagement + metadata) via `core/tei_client` at study setup, instead of re-keying it. +- **Outbound (roadmap):** on study completion, push the study export β€” the `.md` report + source and the appendix JSON model state β€” to Athena, which becomes the **repository + of the study**. Athena exposes an MCP server, so an LLM can pull a completed study + export directly. This is why the data appendix (Required Β§5) is a contract, not a + nicety: it is the export payload. + +--- + +## Pattern Variants + +### Variant 1 β€” Corrected / pressure-tested business case + +Keep the vendor's claimed benefits **verbatim**, add the costs the pitch omitted +(consumption meters, implementation labour, double-billing), and bill contract +mechanics as signed (ramp, milestones, managed services). The headline is the walk: +*as-pitched β†’ corrected*. Reference: +[`notebooks/ctm_business_case_corrected.ipynb`](../studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb). + +### Variant 2 β€” Scenario notebook on a thin module + +A second question over the same engine (e.g. "migration + WFM only, no AI") gets a +**thin scenario module** that scopes and extrapolates but duplicates nothing, plus its +own notebook and test pins. Reference: `tokencalc/migration_wfm.py` + +[`notebooks/ctm_migration_wfm.ipynb`](../studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_migration_wfm.ipynb). + +### Variant 3 β€” Exploratory calculator + +Early-phase what-if surface: scenario selectors, tornado/break-even sweeps, no +contract anchoring yet. Still engine-backed and gate-checked; it graduates into +Variant 1 as facts arrive. Reference: +[`notebooks/ctm_token_calculator.ipynb`](../studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_token_calculator.ipynb). + +### Variant 4 β€” TEI composite reproduction + +Reproduce a published TEI study's composite organization as the verbatim anchor +(`ANCHOR_VERBATIM` = Forrester's tables), verify the reproduction against the published +ROI/NPV/payback in the gate, then personalize with client inputs as the overlay. This +is the target shape for `studies/202602_AmazonConnect/` when it migrates off Streamlit. + +--- + +## Domain Extension Examples + +### Adding domain math to a study + +New capability = new engine code + pins + a notebook section, in that order: + +1. Model it in the study package (e.g. `studylib/model.py` grows + `managed_services_by_year()`), keeping verbatim anchors separate from contracted + overlays. +2. Pin it in `tests/` with hand-checked numbers **before** wiring the notebook. +3. Add the notebook section: markdown narrative β†’ (widget pair if tunable) β†’ engine + call β†’ table/figure β†’ new gate pins β†’ appendix keys. + +### Worked example (CTM, contract facts) + +The signed SOW added billing milestones and managed services. The change landed as: +`PS_MILESTONES` + `managed_services_by_year()` in `tokencalc/appendix4.py`; pins in +`tests/test_appendix4.py` (milestone shares cross-foot, proration hand-checked); a +milestone table on stage; gate re-pinned; appendix JSON gained +`ps_milestones`/`managed_services_annual`. The deck-frame column kept the vendor's +original PS lump β€” anchors stay verbatim. + +--- + +## Anti-Patterns + +Each of these cost a debugging session or a client-facing embarrassment. Don't. + +- ❌ **Don't read `widget.value` in the cell that defines the widget** β€” Mercury never + re-runs the defining cell; the value freezes at first render. +- ❌ **Don't put math in notebook cells** β€” untestable, undiffable; it will drift from + the engine. +- ❌ **Don't edit verbatim anchors** β€” layer a contracted/corrected overlay and read + through a helper; the source record is the client's trust anchor. +- ❌ **Don't build a parallel UI** (Streamlit twin, second app) β€” two surfaces over one + model always diverge; the notebook is the surface. +- ❌ **Don't hide runtime deps in extras or a requirements.txt** β€” `pip install -e .` + must fully provision serve/test/export. +- ❌ **Don't put two bare `$` in one plotly annotation** β€” MathJax eats the text; use + `html_money()` (`$`). +- ❌ **Don't emit any output from a widget cell** β€” it leaks into the Mercury sidebar. +- ❌ **Don't gate on live widget state without an `_at_default` guard** β€” a stakeholder + moving a slider must not crash the notebook. +- ❌ **Don't hardcode KPI numbers in markdown** β€” compute them into prints/annotations, + or the gate can't catch the drift when inputs change. +- ❌ **Don't use kebab-case directories** β€” they can't be imported. +- ❌ **Don't use fragment-`href` ToC links** β€” they don't scroll in Mercury's SPA; use + onclick-JS (and no raw `<` inside handler attributes β€” python-markdown escapes the + tag). +- ❌ **Don't construct `NumberInput` without `min=`/`max=`** β€” Mercury clamps to a small + default range and out-of-range seeds break. + +--- + +## Settings + +```toml +# config.toml β€” loaded from the directory where you launch `mercury`; +# restart the server to apply. Template: template/MercuryNotebook/config.toml +[main] # title, favicon_emoji, footer, notebooks_button_label +[welcome] # gallery header + message (name every notebook and the export command) +[theme] # NTT DATA brand palette β€” see docs/brand.md +``` + +- `MERCURY_CONFIG_DIR` β€” set by the `mercury` CLI for its server; kernels inherit it. + The pattern uses its **presence** as the stage signal (`studylib/staging.py`). Do not + set it manually except to simulate the stage in tests. +- Serve from the project root: `mercury --working-dir notebooks/` (so `config.toml` + loads). Analyst view: `jupyter lab`. Exports: `python scripts/export_report.py`. + +--- + +## Testing + +Layers, from inner to outer β€” every study ships all four: + +1. **Engine pins** (`tests/`) β€” hand-checked acceptance numbers for every model + function; the contracted overlay AND the verbatim record both pinned, so neither can + drift. Compute by hand first, then pin (see + [`template/MercuryNotebook/tests/test_model.py`](../template/MercuryNotebook/tests/test_model.py)). +2. **Stage/backstage test** β€” `monkeypatch` `MERCURY_CONFIG_DIR`, assert `backstage()` + prints only off stage (copy `tests/test_staging.py` from the template). +3. **In-notebook gate** (Required Β§4) β€” proves the *rendered* notebook matches the + engine; runs on every execution, interactive or headless. +4. **Headless execution + export** β€” the CI-style check: + +```bash +pytest +jupyter nbconvert --to notebook --execute --inplace notebooks/*.ipynb # gates green? +python scripts/export_report.py # exports carry appendix + JSON? +MERCURY_CONFIG_DIR=$(mktemp -d) jupyter nbconvert --to notebook --execute \ + --inplace notebooks/_stage_sim.ipynb # stage sim: gate+appendix silent on stage +``` + +After any engine change: recompute expected numbers first, update test pins and gate +pins together, re-execute all notebooks, regenerate exports β€” and report the KPI moves +honestly. diff --git a/docs/Pattern_Outline_V1-00.md b/docs/Pattern_Outline_V1-00.md new file mode 100644 index 0000000..b1b60cc --- /dev/null +++ b/docs/Pattern_Outline_V1-00.md @@ -0,0 +1,152 @@ +# [Pattern Name] Pattern v1.0.0 + +One-sentence description of what this pattern standardizes and which Django applications use it. + +## 🐾 Red Panda Approvalβ„’ + +This pattern follows Red Panda Approval standards. + +--- + +## Why a Pattern, Not a Shared [Implementation] + +Explain the domain variability that makes a single shared model/function/utility impractical. + +List concrete examples of how different domains need different behavior: + +- A [domain A] app needs [field/behavior X] +- A [domain B] app needs [field/behavior Y] +- A [domain C] app needs [field/behavior Z] + +Instead, this pattern defines: + +- **Required [fields/interface]** β€” every implementation must have +- **Recommended [fields/behaviors]** β€” most apps should include +- **Extension guidelines** β€” for domain-specific needs +- **Standard choice values** β€” for interoperability + +--- + +## Required [Fields / Interface] + +The non-negotiable minimum every implementation must provide. + +```python +# Required fields or function signature +``` + +--- + +## Standard Choice Values + +Use these exact values for interoperability between apps. + +### CHOICE_A + +```python +CHOICE_A = [ + ("value-a", "Label A"), + ("value-b", "Label B"), +] +``` + +### CHOICE_B + +```python +CHOICE_B = [ + ("value-x", "Label X"), + ("value-y", "Label Y"), +] +``` + +--- + +## Recommended [Fields / Behaviors] + +Fields or behaviors that most apps should include but are not strictly required. + +```python +# Recommended additions +``` + +--- + +## [Pattern Variant 1] + +Description of the first common implementation approach. + +```python +# Code example +``` + +--- + +## [Pattern Variant 2] + +Description of the second common implementation approach. + +```python +# Code example +``` + +--- + +## [Pattern Variant 3] + +Description of a third approach (e.g., background task, management command, signal). + +```python +# Code example +``` + +--- + +## Domain Extension Examples + +### [Domain A] App + +```python +# Domain-specific extension example +``` + +### [Domain B] App + +```python +# Domain-specific extension example +``` + +--- + +## Anti-Patterns + +- ❌ Don't [common mistake 1] +- ❌ Don't [common mistake 2] +- ❌ Don't [common mistake 3] + +--- + +## Settings + +Document any Django settings this pattern recognizes: + +```python +# settings.py +SETTING_NAME = default_value # Description of what it controls +``` + +--- + +## Testing + +Standard test cases every implementation should cover. + +```python +class My[Pattern]Test(TestCase): + def test_[happy_path](self): + """[Happy path scenario].""" + pass + + def test_[edge_case](self): + """[Edge case or negative test].""" + pass +``` diff --git a/studies/202512_GenesysCX/ctm-token-calculator/.gitignore b/studies/202607_CTM_GenesysCX/.gitignore similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/.gitignore rename to studies/202607_CTM_GenesysCX/.gitignore diff --git a/studies/202512_GenesysCX/ctm-token-calculator/README.md b/studies/202607_CTM_GenesysCX/README.md similarity index 97% rename from studies/202512_GenesysCX/ctm-token-calculator/README.md rename to studies/202607_CTM_GenesysCX/README.md index f2d4a87..0f96c50 100644 --- a/studies/202512_GenesysCX/ctm-token-calculator/README.md +++ b/studies/202607_CTM_GenesysCX/README.md @@ -1,5 +1,8 @@ # CTM Token Calculator +> πŸ“ **Reference implementation** of the +> [Mercury Notebook Deliverable Pattern](../../../docs/Mercury_Notebook_Pattern_V1-00.md). + **Genesys AI Token Cost & Business Case Calculator** β€” interactive, defensible modeling of Genesys Cloud **CX 3** platform + AI feature costs against realistic benefit scenarios, replacing single-point vendor ROI diff --git a/studies/202512_GenesysCX/ctm-token-calculator/config.toml b/studies/202607_CTM_GenesysCX/config.toml similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/config.toml rename to studies/202607_CTM_GenesysCX/config.toml diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (ASIA_Conservative).PDF b/studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (ASIA_Conservative).PDF similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (ASIA_Conservative).PDF rename to studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (ASIA_Conservative).PDF diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (AnZ_Conservative).PDF b/studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (AnZ_Conservative).PDF similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (AnZ_Conservative).PDF rename to studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (AnZ_Conservative).PDF diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (Consolidated).pptx b/studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (Consolidated).pptx similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (Consolidated).pptx rename to studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (Consolidated).pptx diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (EMEA_Conservative).PDF b/studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (EMEA_Conservative).PDF similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (EMEA_Conservative).PDF rename to studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (EMEA_Conservative).PDF diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (NA_Conservative).PDF b/studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (NA_Conservative).PDF similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Appendix 4 - CCaaS Platform Benefit Calculations (NA_Conservative).PDF rename to studies/202607_CTM_GenesysCX/docs/Appendix 4 - CCaaS Platform Benefit Calculations (NA_Conservative).PDF diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Genesys Solutions - CTM ROI - ASIA - Conservative - v.1.4 (1).PDF b/studies/202607_CTM_GenesysCX/docs/Genesys Solutions - CTM ROI - ASIA - Conservative - v.1.4 (1).PDF similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Genesys Solutions - CTM ROI - ASIA - Conservative - v.1.4 (1).PDF rename to studies/202607_CTM_GenesysCX/docs/Genesys Solutions - CTM ROI - ASIA - Conservative - v.1.4 (1).PDF diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Genesys Solutions - CTM ROI - AnZ - Conservative - v.1.4 (1).PDF b/studies/202607_CTM_GenesysCX/docs/Genesys Solutions - CTM ROI - AnZ - Conservative - v.1.4 (1).PDF similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Genesys Solutions - CTM ROI - AnZ - Conservative - v.1.4 (1).PDF rename to studies/202607_CTM_GenesysCX/docs/Genesys Solutions - CTM ROI - AnZ - Conservative - v.1.4 (1).PDF diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Genesys Solutions - CTM ROI - EMEA - Conservative - v.1.4 (1).PDF b/studies/202607_CTM_GenesysCX/docs/Genesys Solutions - CTM ROI - EMEA - Conservative - v.1.4 (1).PDF similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Genesys Solutions - CTM ROI - EMEA - Conservative - v.1.4 (1).PDF rename to studies/202607_CTM_GenesysCX/docs/Genesys Solutions - CTM ROI - EMEA - Conservative - v.1.4 (1).PDF diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/Genesys Solutions - CTM ROI - NA - Conservative - v.1.4 (1).PDF b/studies/202607_CTM_GenesysCX/docs/Genesys Solutions - CTM ROI - NA - Conservative - v.1.4 (1).PDF similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/Genesys Solutions - CTM ROI - NA - Conservative - v.1.4 (1).PDF rename to studies/202607_CTM_GenesysCX/docs/Genesys Solutions - CTM ROI - NA - Conservative - v.1.4 (1).PDF diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/ctm_ai_labour_estimate.md b/studies/202607_CTM_GenesysCX/docs/ctm_ai_labour_estimate.md similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/ctm_ai_labour_estimate.md rename to studies/202607_CTM_GenesysCX/docs/ctm_ai_labour_estimate.md diff --git a/studies/202512_GenesysCX/ctm-token-calculator/docs/ctm_ai_labour_estimate_V2.md b/studies/202607_CTM_GenesysCX/docs/ctm_ai_labour_estimate_V2.md similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/docs/ctm_ai_labour_estimate_V2.md rename to studies/202607_CTM_GenesysCX/docs/ctm_ai_labour_estimate_V2.md diff --git a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb b/studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_corrected.ipynb similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb rename to studies/202607_CTM_GenesysCX/notebooks/ctm_business_case_corrected.ipynb diff --git a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_migration_wfm.ipynb b/studies/202607_CTM_GenesysCX/notebooks/ctm_migration_wfm.ipynb similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_migration_wfm.ipynb rename to studies/202607_CTM_GenesysCX/notebooks/ctm_migration_wfm.ipynb diff --git a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_token_calculator.ipynb b/studies/202607_CTM_GenesysCX/notebooks/ctm_token_calculator.ipynb similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_token_calculator.ipynb rename to studies/202607_CTM_GenesysCX/notebooks/ctm_token_calculator.ipynb diff --git a/studies/202512_GenesysCX/ctm-token-calculator/pyproject.toml b/studies/202607_CTM_GenesysCX/pyproject.toml similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/pyproject.toml rename to studies/202607_CTM_GenesysCX/pyproject.toml diff --git a/studies/202512_GenesysCX/ctm-token-calculator/scripts/export_report.py b/studies/202607_CTM_GenesysCX/scripts/export_report.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/scripts/export_report.py rename to studies/202607_CTM_GenesysCX/scripts/export_report.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tests/test_appendix4.py b/studies/202607_CTM_GenesysCX/tests/test_appendix4.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tests/test_appendix4.py rename to studies/202607_CTM_GenesysCX/tests/test_appendix4.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tests/test_benefit_model.py b/studies/202607_CTM_GenesysCX/tests/test_benefit_model.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tests/test_benefit_model.py rename to studies/202607_CTM_GenesysCX/tests/test_benefit_model.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tests/test_business_case.py b/studies/202607_CTM_GenesysCX/tests/test_business_case.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tests/test_business_case.py rename to studies/202607_CTM_GenesysCX/tests/test_business_case.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tests/test_cost_model.py b/studies/202607_CTM_GenesysCX/tests/test_cost_model.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tests/test_cost_model.py rename to studies/202607_CTM_GenesysCX/tests/test_cost_model.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tests/test_meters.py b/studies/202607_CTM_GenesysCX/tests/test_meters.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tests/test_meters.py rename to studies/202607_CTM_GenesysCX/tests/test_meters.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tests/test_migration_wfm.py b/studies/202607_CTM_GenesysCX/tests/test_migration_wfm.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tests/test_migration_wfm.py rename to studies/202607_CTM_GenesysCX/tests/test_migration_wfm.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tests/test_staging.py b/studies/202607_CTM_GenesysCX/tests/test_staging.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tests/test_staging.py rename to studies/202607_CTM_GenesysCX/tests/test_staging.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/__init__.py b/studies/202607_CTM_GenesysCX/tokencalc/__init__.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/__init__.py rename to studies/202607_CTM_GenesysCX/tokencalc/__init__.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py b/studies/202607_CTM_GenesysCX/tokencalc/appendix4.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py rename to studies/202607_CTM_GenesysCX/tokencalc/appendix4.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/benefit_model.py b/studies/202607_CTM_GenesysCX/tokencalc/benefit_model.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/benefit_model.py rename to studies/202607_CTM_GenesysCX/tokencalc/benefit_model.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/business_case.py b/studies/202607_CTM_GenesysCX/tokencalc/business_case.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/business_case.py rename to studies/202607_CTM_GenesysCX/tokencalc/business_case.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/cost_model.py b/studies/202607_CTM_GenesysCX/tokencalc/cost_model.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/cost_model.py rename to studies/202607_CTM_GenesysCX/tokencalc/cost_model.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/defaults.py b/studies/202607_CTM_GenesysCX/tokencalc/defaults.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/defaults.py rename to studies/202607_CTM_GenesysCX/tokencalc/defaults.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/exports.py b/studies/202607_CTM_GenesysCX/tokencalc/exports.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/exports.py rename to studies/202607_CTM_GenesysCX/tokencalc/exports.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/inputs.py b/studies/202607_CTM_GenesysCX/tokencalc/inputs.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/inputs.py rename to studies/202607_CTM_GenesysCX/tokencalc/inputs.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/meters.py b/studies/202607_CTM_GenesysCX/tokencalc/meters.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/meters.py rename to studies/202607_CTM_GenesysCX/tokencalc/meters.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/migration_wfm.py b/studies/202607_CTM_GenesysCX/tokencalc/migration_wfm.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/migration_wfm.py rename to studies/202607_CTM_GenesysCX/tokencalc/migration_wfm.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/rollout.py b/studies/202607_CTM_GenesysCX/tokencalc/rollout.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/rollout.py rename to studies/202607_CTM_GenesysCX/tokencalc/rollout.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/scenarios.py b/studies/202607_CTM_GenesysCX/tokencalc/scenarios.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/scenarios.py rename to studies/202607_CTM_GenesysCX/tokencalc/scenarios.py diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/staging.py b/studies/202607_CTM_GenesysCX/tokencalc/staging.py similarity index 100% rename from studies/202512_GenesysCX/ctm-token-calculator/tokencalc/staging.py rename to studies/202607_CTM_GenesysCX/tokencalc/staging.py diff --git a/template/MercuryNotebook/README.md b/template/MercuryNotebook/README.md new file mode 100644 index 0000000..e5e2383 --- /dev/null +++ b/template/MercuryNotebook/README.md @@ -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/ β†’ 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/`. diff --git a/template/MercuryNotebook/config.toml b/template/MercuryNotebook/config.toml new file mode 100644 index 0000000..f6ae285 --- /dev/null +++ b/template/MercuryNotebook/config.toml @@ -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" diff --git a/template/MercuryNotebook/exports/business_case.html b/template/MercuryNotebook/exports/business_case.html new file mode 100644 index 0000000..e64de65 --- /dev/null +++ b/template/MercuryNotebook/exports/business_case.html @@ -0,0 +1,8521 @@ + + + + + +business_case + + + + + + + + + + + + +
+ + + + + +
+ + + diff --git a/template/MercuryNotebook/exports/business_case.md b/template/MercuryNotebook/exports/business_case.md new file mode 100644 index 0000000..1e65e90 --- /dev/null +++ b/template/MercuryNotebook/exports/business_case.md @@ -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"{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'} + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2026202720283-yr
Platform (contracted, ramp-adjusted)250,000500,000500,0001,250,000
Services (year 1)250,00000250,000
Existing platform (term-contract run-off)1,000,0001,000,00002,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
ROI20%80%
NPV @ 10%$3K$207K
Payback35 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 + } + } + ``` + diff --git a/template/MercuryNotebook/notebooks/business_case.ipynb b/template/MercuryNotebook/notebooks/business_case.ipynb new file mode 100644 index 0000000..2562d50 --- /dev/null +++ b/template/MercuryNotebook/notebooks/business_case.ipynb @@ -0,0 +1,1935 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "cell-0", + "metadata": {}, + "source": [ + "# [Study Title] β€” Business Case\n", + "\n", + "**Thesis:** one paragraph stating what this notebook demonstrates and the frame it uses\n", + "(here: a platform migration priced against *doing nothing*, with the vendor's pitched\n", + "numbers kept verbatim as the anchor and the signed contract layered over them).\n", + "\n", + "This notebook **is** the deliverable: serve it interactively with\n", + "`mercury --working-dir notebooks/`, tune the 🟑 inputs live for the client, then export\n", + "an LLM-readable report source with `python scripts/export_report.py`. All math lives in\n", + "`studylib/` β€” the notebook renders it.\n", + "\n", + "Confidence legend: 🟒 confirmed (published/contractual) Β· 🟑 estimated (working\n", + "assumption) Β· πŸ”΄ unknown." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "cell-1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-08T16:36:50.535111Z", + "iopub.status.busy": "2026-07-08T16:36:50.534789Z", + "iopub.status.idle": "2026-07-08T16:36:50.959725Z", + "shell.execute_reply": "2026-07-08T16:36:50.956659Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "studylib loaded β€” window 2026–2028\n" + ] + } + ], + "source": [ + "# ── Setup ──────────────────────────────────────────────────────────\n", + "import sys, pathlib\n", + "_ROOT = pathlib.Path.cwd()\n", + "if not (_ROOT / \"studylib\").exists(): # notebook lives in notebooks/\n", + " _ROOT = _ROOT.parent\n", + "sys.path.insert(0, str(_ROOT))\n", + "\n", + "import pandas as pd\n", + "import plotly.graph_objects as go\n", + "\n", + "import mercury as mr\n", + "\n", + "# Single source of truth β€” all math lives in the library; only\n", + "# presentation (and Mercury input widgets) lives here.\n", + "from studylib.model import (\n", + " YEARS, ANCHOR_VERBATIM, DEFAULT_RAMP_MONTHS,\n", + " anchor, benefits_by_year, case_flows, case_kpis, current_costs_by_year,\n", + " money, html_money, payback_label, platform_costs_by_year, services_by_year,\n", + ")\n", + "from studylib.staging import backstage\n", + "\n", + "pd.options.display.float_format = \"{:,.0f}\".format\n", + "\n", + "# ── Chart chrome (dataviz reference palette, light surface) ─────────\n", + "INK, INK2, MUTED = \"#0b0b0b\", \"#52514e\", \"#898781\"\n", + "SURFACE, GRID = \"#fcfcfb\", \"#e1e0d9\"\n", + "CUMULATIVE, CONTEXT = \"#52514e\", \"#c3c2b7\"\n", + "FONT_STACK = 'system-ui, -apple-system, \"Segoe UI\", sans-serif'\n", + "\n", + "\n", + "def tei_layout(fig, title, subtitle=None, height=420):\n", + " \"\"\"House chart chrome β€” recessive grid, ink text, title top-left.\"\"\"\n", + " t = f\"{title}\"\n", + " if subtitle:\n", + " t += f\"
{subtitle}\"\n", + " fig.update_layout(\n", + " title=dict(text=t, font=dict(size=16, color=INK), x=0, xanchor=\"left\"),\n", + " font=dict(family=FONT_STACK, size=12, color=INK),\n", + " paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,\n", + " margin=dict(l=60, r=30, t=80, b=45), height=height,\n", + " legend=dict(orientation=\"h\", yanchor=\"bottom\", y=1.0, x=0,\n", + " bgcolor=\"rgba(0,0,0,0)\"),\n", + " xaxis=dict(showgrid=False, zeroline=False),\n", + " yaxis=dict(gridcolor=GRID, zeroline=False, tickformat=\"$,.0f\"),\n", + " hovermode=\"x unified\",\n", + " )\n", + " return fig\n", + "\n", + "\n", + "def bar(x, y, name, color):\n", + " return go.Bar(x=x, y=y, name=name, marker_color=color,\n", + " marker_line=dict(color=SURFACE, width=2),\n", + " hovertemplate=\"%{y:$,.0f}\" + name + \"\")\n", + "\n", + "\n", + "def cum_line(x, y, name, color=CUMULATIVE, dash=None):\n", + " return go.Scatter(x=x, y=y, name=name, mode=\"lines+markers\",\n", + " line=dict(color=color, width=2, dash=dash),\n", + " marker=dict(size=8),\n", + " hovertemplate=\"%{y:$,.0f}\" + name + \"\")\n", + "\n", + "\n", + "X = [str(y) for y in YEARS]\n", + "backstage(f\"studylib loaded β€” window {YEARS[0]}–{YEARS[-1]}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-2", + "metadata": {}, + "source": [ + "\n", + "\n", + "## 1 Β· Inputs\n", + "\n", + "Contract inputs collected as live widgets. Sidebar widgets are scenario knobs; use\n", + "`position=\"inline\"` for data-collection tables that belong in the page flow.\n", + "\n", + "> **Reactivity contract:** Mercury re-executes only the cells *below* a changed\n", + "> widget's cell β€” never the defining cell itself. So the next cell constructs\n", + "> widgets ONLY (no other output), and `.value` is read one cell further down." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "cell-3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-08T16:36:50.962324Z", + "iopub.status.busy": "2026-07-08T16:36:50.961887Z", + "iopub.status.idle": "2026-07-08T16:36:50.977751Z", + "shell.execute_reply": "2026-07-08T16:36:50.976977Z" + } + }, + "outputs": [ + { + "data": { + "application/mercury+json": { + "model_id": "924aaf1b9a7244388d4fdc29e0f544e2", + "position": "sidebar", + "widget": "NumberInputWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "924aaf1b9a7244388d4fdc29e0f544e2", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/mercury+json": { + "model_id": "60e56739936e4f2ab8ce7a25fe31c092", + "position": "sidebar", + "widget": "NumberInputWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "60e56739936e4f2ab8ce7a25fe31c092", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/mercury+json": { + "model_id": "56c2f21d17cf40009b6d4bcee7bcdd9a", + "position": "sidebar", + "widget": "SelectWidget" + }, + "application/vnd.jupyter.widget-view+json": { + "model_id": "56c2f21d17cf40009b6d4bcee7bcdd9a", + "version_major": 2, + "version_minor": 1 + }, + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "# ── Inputs (Mercury sidebar β€” widgets only, NO other output) ────────\n", + "# NB: Mercury re-executes only cells BELOW a changed widget's cell, so\n", + "# this cell constructs widgets ONLY β€” .value is read downstream.\n", + "_platform_w = mr.NumberInput(label=\"Platform run-rate ($/yr) β€” contracted\",\n", + " value=round(anchor(\"platform_annual\")),\n", + " min=0, max=10_000_000, step=25_000)\n", + "_ramp_w = mr.NumberInput(label=\"Ramp β€” billing-free months\",\n", + " value=DEFAULT_RAMP_MONTHS, min=0, max=24, step=3)\n", + "_npv_w = mr.Select(label=\"NPV discount rate\", value=\"10% (vendor)\",\n", + " choices=[\"10% (vendor)\", \"8% (treasury)\"])" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cell-4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-08T16:36:50.980200Z", + "iopub.status.busy": "2026-07-08T16:36:50.979922Z", + "iopub.status.idle": "2026-07-08T16:36:50.985441Z", + "shell.execute_reply": "2026-07-08T16:36:50.984712Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "platform by year: {2026: '$250K', 2027: '$500K', 2028: '$500K'}\n", + "platform run-rate: contracted $500K/yr (vendor pitched $600K/yr) Β· ramp 6 months\n" + ] + } + ], + "source": [ + "# ── Model state (re-runs on any change to the widgets above) ────────\n", + "PLATFORM_ANNUAL = float(_platform_w.value)\n", + "RAMP_MONTHS = int(_ramp_w.value)\n", + "DISCOUNT_RATE = 0.10 if _npv_w.value.startswith(\"10\") else 0.08\n", + "\n", + "platform_by_year = platform_costs_by_year(RAMP_MONTHS, PLATFORM_ANNUAL)\n", + "services_by = services_by_year()\n", + "current_by_year = current_costs_by_year()\n", + "ben_by_year = benefits_by_year()\n", + "BASELINE_ANNUAL = anchor(\"baseline_annual\")\n", + "\n", + "backstage(f\"platform by year: { {y: money(v) for y, v in platform_by_year.items()} }\")\n", + "print(f\"platform run-rate: contracted {money(PLATFORM_ANNUAL)}/yr \"\n", + " f\"(vendor pitched {money(ANCHOR_VERBATIM['platform_annual'])}/yr) Β· \"\n", + " f\"ramp {RAMP_MONTHS} months\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-5", + "metadata": {}, + "source": [ + "\n", + "\n", + "## 2 Β· Business case vs doing nothing\n", + "\n", + "**Frame:** baseline-relative. Incremental cost = programme cost βˆ’ baseline\n", + "(the do-nothing run-rate); net = benefits βˆ’ incremental cost. Double-billing\n", + "while the old platform runs off and the post-termination cost-avoidance credit\n", + "both fall out of this one frame.\n", + "\n", + "The KPI table keeps a **vendor-frame column** (pitched rate, verbatim anchors)\n", + "beside the **contracted column**, so the client can walk from their own numbers\n", + "to the corrected reality." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "cell-6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-08T16:36:50.987674Z", + "iopub.status.busy": "2026-07-08T16:36:50.987427Z", + "iopub.status.idle": "2026-07-08T16:36:51.023013Z", + "shell.execute_reply": "2026-07-08T16:36:51.022122Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "net by year: {2026: '-$500K', 2027: '-$200K', 2028: '$1.1M'}\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
2026202720283-yr
Platform (contracted, ramp-adjusted)250,000500,000500,0001,250,000
Services (year 1)250,00000250,000
Existing platform (term-contract run-off)1,000,0001,000,00002,000,000
\n", + "
" + ], + "text/plain": [ + " 2026 2027 2028 \\\n", + "Platform (contracted, ramp-adjusted) 250,000 500,000 500,000 \n", + "Services (year 1) 250,000 0 0 \n", + "Existing platform (term-contract run-off) 1,000,000 1,000,000 0 \n", + "\n", + " 3-yr \n", + "Platform (contracted, ramp-adjusted) 1,250,000 \n", + "Services (year 1) 250,000 \n", + "Existing platform (term-contract run-off) 2,000,000 " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Vendor frame ($600K/yr)Contracted ($500K/yr)
3-yr benefits$900K$900K
3-yr incremental cost$750K$500K
3-yr net$150K$400K
ROI20%80%
NPV @ 10%$3K$207K
Payback35 months (~Nov 2028)32 months (~Aug 2028)
\n", + "
" + ], + "text/plain": [ + " Vendor frame ($600K/yr) Contracted ($500K/yr)\n", + "3-yr benefits $900K $900K\n", + "3-yr incremental cost $750K $500K\n", + "3-yr net $150K $400K\n", + "ROI 20% 80%\n", + "NPV @ 10% $3K $207K\n", + "Payback 35 months (~Nov 2028) 32 months (~Aug 2028)" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "study_costs = pd.DataFrame({\n", + " \"Platform (contracted, ramp-adjusted)\": platform_by_year,\n", + " \"Services (year 1)\": services_by,\n", + " \"Existing platform (term-contract run-off)\": current_by_year,\n", + "}).T[YEARS]\n", + "study_costs[\"3-yr\"] = study_costs.sum(axis=1)\n", + "total_by_year = {y: float(study_costs[y].sum()) for y in YEARS}\n", + "\n", + "inc, net_by = case_flows(total_by_year, ben_by_year)\n", + "kpi = case_kpis(inc, net_by, DISCOUNT_RATE)\n", + "\n", + "# Vendor-anchored comparison: same frame at the pitched (verbatim) rate.\n", + "_plat_vendor = platform_costs_by_year(RAMP_MONTHS, ANCHOR_VERBATIM[\"platform_annual\"])\n", + "total_vendor = {y: current_by_year[y] + _plat_vendor[y] + services_by[y] for y in YEARS}\n", + "inc_v, net_v = case_flows(total_vendor, ben_by_year)\n", + "kpi_v = case_kpis(inc_v, net_v, DISCOUNT_RATE)\n", + "\n", + "\n", + "def kpi_col(k):\n", + " return {\n", + " \"3-yr benefits\": money(k[\"benefits_3yr\"]),\n", + " \"3-yr incremental cost\": money(k[\"incremental_cost_3yr\"]),\n", + " \"3-yr net\": money(k[\"net_3yr\"]),\n", + " \"ROI\": f\"{k['roi']:.0%}\" if k[\"roi\"] is not None else \"n/a β€” net saving\",\n", + " f\"NPV @ {k['discount_rate']:.0%}\": money(k[\"npv\"]),\n", + " \"Payback\": k[\"payback\"],\n", + " }\n", + "\n", + "\n", + "kpis_fmt = pd.DataFrame({\n", + " f\"Vendor frame ({money(ANCHOR_VERBATIM['platform_annual'])}/yr)\": kpi_col(kpi_v),\n", + " f\"Contracted ({money(PLATFORM_ANNUAL)}/yr)\": kpi_col(kpi),\n", + "})\n", + "backstage(f\"net by year: { {y: money(v) for y, v in net_by.items()} }\")\n", + "display(study_costs)\n", + "display(kpis_fmt)" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "cell-7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-08T16:36:51.025383Z", + "iopub.status.busy": "2026-07-08T16:36:51.025145Z", + "iopub.status.idle": "2026-07-08T16:36:52.252524Z", + "shell.execute_reply": "2026-07-08T16:36:52.251601Z" + } + }, + "outputs": [ + { + "data": { + "application/vnd.plotly.v1+json": { + "config": { + "plotlyServerURL": "https://plot.ly" + }, + "data": [ + { + "hovertemplate": "%{y:$,.0f}Benefits", + "marker": { + "color": "#1baf7a", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Benefits", + "type": "bar", + "x": [ + "2026", + "2027", + "2028" + ], + "y": [ + 0.0, + 300000.0, + 600000.0 + ] + }, + { + "hovertemplate": "%{y:$,.0f}Incremental cost vs $1.0M/yr baseline", + "marker": { + "color": "#e34948", + "line": { + "color": "#fcfcfb", + "width": 2 + } + }, + "name": "Incremental cost vs $1.0M/yr baseline", + "type": "bar", + "x": [ + "2026", + "2027", + "2028" + ], + "y": [ + -500000.0, + -500000.0, + 500000.0 + ] + }, + { + "hovertemplate": "%{y:$,.0f}Cumulative net", + "line": { + "color": "#52514e", + "width": 2 + }, + "marker": { + "size": 8 + }, + "mode": "lines+markers", + "name": "Cumulative net", + "type": "scatter", + "x": [ + "2026", + "2027", + "2028" + ], + "y": { + "bdata": "AAAAAICEHsEAAAAAwFwlwQAAAAAAahhB", + "dtype": "f8" + } + } + ], + "layout": { + "annotations": [ + { + "align": "left", + "bgcolor": "#fcfcfb", + "bordercolor": "#e1e0d9", + "borderwidth": 1, + "font": { + "color": "#52514e", + "size": 12 + }, + "showarrow": false, + "text": "3-yr net $400K Β· NPV@10% $207K Β· payback 32 months (~Aug 2028)", + "x": 0.01, + "xref": "paper", + "y": 0.98, + "yref": "paper" + } + ], + "barmode": "relative", + "font": { + "color": "#0b0b0b", + "family": "system-ui, -apple-system, \"Segoe UI\", sans-serif", + "size": 12 + }, + "height": 420, + "hovermode": "x unified", + "legend": { + "bgcolor": "rgba(0,0,0,0)", + "orientation": "h", + "x": 0, + "y": 1.0, + "yanchor": "bottom" + }, + "margin": { + "b": 45, + "l": 60, + "r": 30, + "t": 80 + }, + "paper_bgcolor": "#fcfcfb", + "plot_bgcolor": "#fcfcfb", + "template": { + "data": { + "bar": [ + { + "error_x": { + "color": "#2a3f5f" + }, + "error_y": { + "color": "#2a3f5f" + }, + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "bar" + } + ], + "barpolar": [ + { + "marker": { + "line": { + "color": "#E5ECF6", + "width": 0.5 + }, + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "barpolar" + } + ], + "carpet": [ + { + "aaxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "baxis": { + "endlinecolor": "#2a3f5f", + "gridcolor": "white", + "linecolor": "white", + "minorgridcolor": "white", + "startlinecolor": "#2a3f5f" + }, + "type": "carpet" + } + ], + "choropleth": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "choropleth" + } + ], + "contour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "contour" + } + ], + "contourcarpet": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "contourcarpet" + } + ], + "heatmap": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "heatmap" + } + ], + "histogram": [ + { + "marker": { + "pattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + } + }, + "type": "histogram" + } + ], + "histogram2d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2d" + } + ], + "histogram2dcontour": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "histogram2dcontour" + } + ], + "mesh3d": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "type": "mesh3d" + } + ], + "parcoords": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "parcoords" + } + ], + "pie": [ + { + "automargin": true, + "type": "pie" + } + ], + "scatter": [ + { + "fillpattern": { + "fillmode": "overlay", + "size": 10, + "solidity": 0.2 + }, + "type": "scatter" + } + ], + "scatter3d": [ + { + "line": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatter3d" + } + ], + "scattercarpet": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattercarpet" + } + ], + "scattergeo": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergeo" + } + ], + "scattergl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattergl" + } + ], + "scattermap": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermap" + } + ], + "scattermapbox": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scattermapbox" + } + ], + "scatterpolar": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolar" + } + ], + "scatterpolargl": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterpolargl" + } + ], + "scatterternary": [ + { + "marker": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "type": "scatterternary" + } + ], + "surface": [ + { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + }, + "colorscale": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "type": "surface" + } + ], + "table": [ + { + "cells": { + "fill": { + "color": "#EBF0F8" + }, + "line": { + "color": "white" + } + }, + "header": { + "fill": { + "color": "#C8D4E3" + }, + "line": { + "color": "white" + } + }, + "type": "table" + } + ] + }, + "layout": { + "annotationdefaults": { + "arrowcolor": "#2a3f5f", + "arrowhead": 0, + "arrowwidth": 1 + }, + "autotypenumbers": "strict", + "coloraxis": { + "colorbar": { + "outlinewidth": 0, + "ticks": "" + } + }, + "colorscale": { + "diverging": [ + [ + 0, + "#8e0152" + ], + [ + 0.1, + "#c51b7d" + ], + [ + 0.2, + "#de77ae" + ], + [ + 0.3, + "#f1b6da" + ], + [ + 0.4, + "#fde0ef" + ], + [ + 0.5, + "#f7f7f7" + ], + [ + 0.6, + "#e6f5d0" + ], + [ + 0.7, + "#b8e186" + ], + [ + 0.8, + "#7fbc41" + ], + [ + 0.9, + "#4d9221" + ], + [ + 1, + "#276419" + ] + ], + "sequential": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ], + "sequentialminus": [ + [ + 0.0, + "#0d0887" + ], + [ + 0.1111111111111111, + "#46039f" + ], + [ + 0.2222222222222222, + "#7201a8" + ], + [ + 0.3333333333333333, + "#9c179e" + ], + [ + 0.4444444444444444, + "#bd3786" + ], + [ + 0.5555555555555556, + "#d8576b" + ], + [ + 0.6666666666666666, + "#ed7953" + ], + [ + 0.7777777777777778, + "#fb9f3a" + ], + [ + 0.8888888888888888, + "#fdca26" + ], + [ + 1.0, + "#f0f921" + ] + ] + }, + "colorway": [ + "#636efa", + "#EF553B", + "#00cc96", + "#ab63fa", + "#FFA15A", + "#19d3f3", + "#FF6692", + "#B6E880", + "#FF97FF", + "#FECB52" + ], + "font": { + "color": "#2a3f5f" + }, + "geo": { + "bgcolor": "white", + "lakecolor": "white", + "landcolor": "#E5ECF6", + "showlakes": true, + "showland": true, + "subunitcolor": "white" + }, + "hoverlabel": { + "align": "left" + }, + "hovermode": "closest", + "mapbox": { + "style": "light" + }, + "paper_bgcolor": "white", + "plot_bgcolor": "#E5ECF6", + "polar": { + "angularaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "radialaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "scene": { + "xaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "yaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + }, + "zaxis": { + "backgroundcolor": "#E5ECF6", + "gridcolor": "white", + "gridwidth": 2, + "linecolor": "white", + "showbackground": true, + "ticks": "", + "zerolinecolor": "white" + } + }, + "shapedefaults": { + "line": { + "color": "#2a3f5f" + } + }, + "ternary": { + "aaxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "baxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + }, + "bgcolor": "#E5ECF6", + "caxis": { + "gridcolor": "white", + "linecolor": "white", + "ticks": "" + } + }, + "title": { + "x": 0.05 + }, + "xaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + }, + "yaxis": { + "automargin": true, + "gridcolor": "white", + "linecolor": "white", + "ticks": "", + "title": { + "standoff": 15 + }, + "zerolinecolor": "white", + "zerolinewidth": 2 + } + } + }, + "title": { + "font": { + "color": "#0b0b0b", + "size": 16 + }, + "text": "Business case vs doing nothing
Baseline-relative: net = benefits βˆ’ (programme cost βˆ’ baseline)", + "x": 0, + "xanchor": "left" + }, + "xaxis": { + "showgrid": false, + "zeroline": false + }, + "yaxis": { + "gridcolor": "#e1e0d9", + "tickformat": "$,.0f", + "zeroline": false + } + } + } + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig = go.Figure()\n", + "fig.add_trace(bar(X, [ben_by_year[y] for y in YEARS], \"Benefits\", \"#1baf7a\"))\n", + "fig.add_trace(bar(X, [-inc[y] for y in YEARS],\n", + " f\"Incremental cost vs {money(BASELINE_ANNUAL)}/yr baseline\",\n", + " \"#e34948\"))\n", + "cum_net = pd.Series([net_by[y] for y in YEARS]).cumsum()\n", + "fig.add_trace(cum_line(X, cum_net, \"Cumulative net\"))\n", + "fig.update_layout(barmode=\"relative\")\n", + "# Several amounts in one annotation β†’ html_money, or MathJax eats the text.\n", + "fig.add_annotation(\n", + " xref=\"paper\", yref=\"paper\", x=0.01, y=0.98, align=\"left\", showarrow=False,\n", + " font=dict(size=12, color=INK2), bgcolor=SURFACE, bordercolor=GRID, borderwidth=1,\n", + " text=(f\"3-yr net {html_money(kpi['net_3yr'])} Β· \"\n", + " f\"NPV@{DISCOUNT_RATE:.0%} {html_money(kpi['npv'])} Β· \"\n", + " f\"payback {kpi['payback']}\"))\n", + "tei_layout(fig, \"Business case vs doing nothing\",\n", + " \"Baseline-relative: net = benefits βˆ’ (programme cost βˆ’ baseline)\")\n", + "fig.show()" + ] + }, + { + "cell_type": "markdown", + "id": "cell-8", + "metadata": {}, + "source": [ + "\n", + "\n", + "## 3 Β· Verification & assertions\n", + "\n", + "Engine pins use **explicit default arguments**, so the gate tests `studylib`, not the\n", + "current widget state; live-state checks only run when the inputs sit at their defaults.\n", + "This cell must pass under headless `nbconvert --execute` β€” it is the study's smoke test.\n", + "Output renders backstage only." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "cell-9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-08T16:36:52.254794Z", + "iopub.status.busy": "2026-07-08T16:36:52.254464Z", + "iopub.status.idle": "2026-07-08T16:36:52.262674Z", + "shell.execute_reply": "2026-07-08T16:36:52.261927Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "All assertions passed.\n", + " net 3-yr $400K Β· payback 32 months (~Aug 2028)\n" + ] + } + ], + "source": [ + "def _approx(got, want, tol=0.5):\n", + " assert abs(got - want) <= tol, f\"got {got:,.2f}, want {want:,.2f}\"\n", + "\n", + "\n", + "# Engine pins β€” explicit defaults, independent of widget state\n", + "_approx(anchor(\"platform_annual\"), 500_000) # signed overlay\n", + "_approx(ANCHOR_VERBATIM[\"platform_annual\"], 600_000) # vendor record intact\n", + "_p = platform_costs_by_year() # verbatim rate, 6-mo ramp\n", + "_approx(_p[2026], 300_000)\n", + "_approx(_p[2027], 600_000)\n", + "_b = benefits_by_year()\n", + "_approx(sum(_b.values()), anchor(\"benefit_3yr\"))\n", + "_approx(_b[2026], 0) # nothing lands in year 1\n", + "\n", + "# Default-flow pins (contracted frame at engine defaults)\n", + "_pc = platform_costs_by_year(annual=anchor(\"platform_annual\"))\n", + "_tot = {y: current_costs_by_year()[y] + _pc[y] + services_by_year()[y] for y in YEARS}\n", + "_, _net = case_flows(_tot, _b)\n", + "_approx(sum(_net.values()), 400_000)\n", + "assert payback_label(_net) == \"32 months (~Aug 2028)\"\n", + "\n", + "# Live state β€” only checked when the widgets sit at their defaults\n", + "_at_default = (PLATFORM_ANNUAL == round(anchor(\"platform_annual\"))\n", + " and RAMP_MONTHS == DEFAULT_RAMP_MONTHS)\n", + "if _at_default:\n", + " _approx(kpi[\"net_3yr\"], 400_000)\n", + "for y in YEARS:\n", + " _approx(net_by[y], ben_by_year[y] - (total_by_year[y] - BASELINE_ANNUAL))\n", + "\n", + "backstage(\"All assertions passed.\")\n", + "backstage(f\" net 3-yr {money(kpi['net_3yr'])} Β· payback {kpi['payback']}\")" + ] + }, + { + "cell_type": "markdown", + "id": "cell-10", + "metadata": {}, + "source": [ + "\n", + "\n", + "## 4 Β· Data appendix β€” for the machines\n", + "\n", + "Everything above, dumped as markdown tables plus one JSON block of model state, so the\n", + "exported report is complete LLM input without re-running anything. The dump renders\n", + "**backstage** (JupyterLab and the exports) and stays hidden in the Mercury app." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "cell-11", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-08T16:36:52.264962Z", + "iopub.status.busy": "2026-07-08T16:36:52.264712Z", + "iopub.status.idle": "2026-07-08T16:36:52.284958Z", + "shell.execute_reply": "2026-07-08T16:36:52.284097Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "#### Cost stack ($)\n", + "\n", + "| | 2026 | 2027 | 2028 | 3-yr |\n", + "|:------------------------------------------|----------:|----------:|--------:|----------:|\n", + "| Platform (contracted, ramp-adjusted) | 250,000 | 500,000 | 500,000 | 1,250,000 |\n", + "| Services (year 1) | 250,000 | 0 | 0 | 250,000 |\n", + "| Existing platform (term-contract run-off) | 1,000,000 | 1,000,000 | 0 | 2,000,000 |\n", + "\n", + "#### Business-case flows vs do-nothing baseline ($)\n", + "\n", + "| | 2026 | 2027 | 2028 |\n", + "|:-----------------|----------:|----------:|----------:|\n", + "| programme cost | 1,500,000 | 1,500,000 | 500,000 |\n", + "| incremental cost | 500,000 | 500,000 | -500,000 |\n", + "| net | -500,000 | -200,000 | 1,100,000 |\n", + "\n", + "#### KPIs β€” vendor frame vs contracted\n", + "\n", + "| | Vendor frame ($600K/yr) | Contracted ($500K/yr) |\n", + "|:----------------------|:--------------------------|:------------------------|\n", + "| 3-yr benefits | $900K | $900K |\n", + "| 3-yr incremental cost | $750K | $500K |\n", + "| 3-yr net | $150K | $400K |\n", + "| ROI | 20% | 80% |\n", + "| NPV @ 10% | $3K | $207K |\n", + "| Payback | 35 months (~Nov 2028) | 32 months (~Aug 2028) |\n", + "\n", + "#### Model state (JSON)\n", + "\n", + "```json\n", + "{\n", + " \"scenario\": \"TEMPLATE \\u2014 replace with the study's one-line scenario\",\n", + " \"benefit_by_year\": {\n", + " \"2026\": 0,\n", + " \"2027\": 300000,\n", + " \"2028\": 600000\n", + " },\n", + " \"cost_by_year\": {\n", + " \"2026\": 1500000,\n", + " \"2027\": 1500000,\n", + " \"2028\": 500000\n", + " },\n", + " \"net_by_year\": {\n", + " \"2026\": -500000,\n", + " \"2027\": -200000,\n", + " \"2028\": 1100000\n", + " },\n", + " \"kpis\": {\n", + " \"benefits_3yr\": 900000.0,\n", + " \"incremental_cost_3yr\": 500000.0,\n", + " \"net_3yr\": 400000.0,\n", + " \"roi\": 0.8,\n", + " \"npv\": 206611.5702,\n", + " \"discount_rate\": 0.1,\n", + " \"payback\": \"32 months (~Aug 2028)\"\n", + " },\n", + " \"kpis_vendor_frame\": {\n", + " \"benefits_3yr\": 900000.0,\n", + " \"incremental_cost_3yr\": 750000.0,\n", + " \"net_3yr\": 150000.0,\n", + " \"roi\": 0.2,\n", + " \"npv\": 3380.9166,\n", + " \"discount_rate\": 0.1,\n", + " \"payback\": \"35 months (~Nov 2028)\"\n", + " },\n", + " \"assumptions\": {\n", + " \"platform_annual\": 500000,\n", + " \"vendor_platform_annual\": 600000,\n", + " \"ramp_months\": 6,\n", + " \"discount_rate\": 0.1,\n", + " \"baseline_annual\": 1000000\n", + " }\n", + "}\n", + "```\n" + ] + } + ], + "source": [ + "# ── Data appendix β€” LLM-readable dump of every model output ──────────\n", + "# Renders backstage only (JupyterLab / nbconvert exports) β€” hidden on\n", + "# the Mercury stage, where the narrative and figures carry the story.\n", + "import json as _json\n", + "\n", + "\n", + "def _section(title, df, **kw):\n", + " backstage(f\"\\n#### {title}\\n\")\n", + " backstage(df.to_markdown(floatfmt=\",.0f\", **kw))\n", + "\n", + "\n", + "_section(\"Cost stack ($)\", study_costs)\n", + "_section(\"Business-case flows vs do-nothing baseline ($)\",\n", + " pd.DataFrame({\"programme cost\": total_by_year,\n", + " \"incremental cost\": inc, \"net\": net_by}).T[YEARS])\n", + "_section(\"KPIs β€” vendor frame vs contracted\", kpis_fmt)\n", + "\n", + "backstage(\"\\n#### Model state (JSON)\\n\")\n", + "backstage(\"```json\")\n", + "backstage(_json.dumps({\n", + " \"scenario\": \"TEMPLATE β€” replace with the study's one-line scenario\",\n", + " \"benefit_by_year\": {str(y): round(ben_by_year[y]) for y in YEARS},\n", + " \"cost_by_year\": {str(y): round(total_by_year[y]) for y in YEARS},\n", + " \"net_by_year\": {str(y): round(net_by[y]) for y in YEARS},\n", + " \"kpis\": {k: (round(v, 4) if isinstance(v, float) else v)\n", + " for k, v in kpi.items()},\n", + " \"kpis_vendor_frame\": {k: (round(v, 4) if isinstance(v, float) else v)\n", + " for k, v in kpi_v.items()},\n", + " \"assumptions\": {\n", + " \"platform_annual\": round(PLATFORM_ANNUAL),\n", + " \"vendor_platform_annual\": ANCHOR_VERBATIM[\"platform_annual\"],\n", + " \"ramp_months\": RAMP_MONTHS,\n", + " \"discount_rate\": DISCOUNT_RATE,\n", + " \"baseline_annual\": round(BASELINE_ANNUAL),\n", + " },\n", + "}, indent=2))\n", + "backstage(\"```\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.7" + }, + "widgets": { + "application/vnd.jupyter.widget-state+json": { + "state": { + "2cd79c44365840a4af77e279bf99a67f": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "56c2f21d17cf40009b6d4bcee7bcdd9a": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.select.SelectWidget", + "_css": "\n .mljar-select-container {\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n }\n\n .mljar-select-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: none;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n margin-top: 6px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n container.appendChild(dropdown);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target)) {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n isEditing = false;\n setOpen(false);\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n document.removeEventListener(\"click\", handleDocumentClick);\n };\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "choices": [ + "10% (vendor)", + "8% (treasury)" + ], + "disabled": false, + "hidden": false, + "label": "NPV discount rate", + "layout": "IPY_MODEL_2cd79c44365840a4af77e279bf99a67f", + "layout_path": null, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": "10% (vendor)" + } + }, + "60e56739936e4f2ab8ce7a25fe31c092": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.number.NumberInputWidget", + "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "disabled": false, + "hidden": false, + "label": "Ramp β€” billing-free months", + "layout": "IPY_MODEL_e41731dbca994eeeb59b0b6edcfc9276", + "layout_path": null, + "max": 24.0, + "min": 0.0, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "step": 3.0, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": 6.0 + } + }, + "924aaf1b9a7244388d4fdc29e0f544e2": { + "model_module": "anywidget", + "model_module_version": "~0.11.*", + "model_name": "AnyModel", + "state": { + "_anywidget_id": "mercury.number.NumberInputWidget", + "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ", + "_dom_classes": [], + "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ", + "_model_module": "anywidget", + "_model_module_version": "~0.11.*", + "_model_name": "AnyModel", + "_view_count": null, + "_view_module": "anywidget", + "_view_module_version": "~0.11.*", + "_view_name": "AnyView", + "cell_id": "", + "disabled": false, + "hidden": false, + "label": "Platform run-rate ($/yr) β€” contracted", + "layout": "IPY_MODEL_dab8383f122d4e85b850290ec384a030", + "layout_path": null, + "max": 10000000.0, + "min": 0.0, + "position": "sidebar", + "render_slot_id": null, + "source_cell_id": null, + "step": 25000.0, + "tabbable": null, + "tooltip": null, + "url_key": "", + "value": 500000.0 + } + }, + "dab8383f122d4e85b850290ec384a030": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + }, + "e41731dbca994eeeb59b0b6edcfc9276": { + "model_module": "@jupyter-widgets/base", + "model_module_version": "2.0.0", + "model_name": "LayoutModel", + "state": { + "_model_module": "@jupyter-widgets/base", + "_model_module_version": "2.0.0", + "_model_name": "LayoutModel", + "_view_count": null, + "_view_module": "@jupyter-widgets/base", + "_view_module_version": "2.0.0", + "_view_name": "LayoutView", + "align_content": null, + "align_items": null, + "align_self": null, + "border_bottom": null, + "border_left": null, + "border_right": null, + "border_top": null, + "bottom": null, + "display": null, + "flex": null, + "flex_flow": null, + "grid_area": null, + "grid_auto_columns": null, + "grid_auto_flow": null, + "grid_auto_rows": null, + "grid_column": null, + "grid_gap": null, + "grid_row": null, + "grid_template_areas": null, + "grid_template_columns": null, + "grid_template_rows": null, + "height": null, + "justify_content": null, + "justify_items": null, + "left": null, + "margin": null, + "max_height": null, + "max_width": null, + "min_height": null, + "min_width": null, + "object_fit": null, + "object_position": null, + "order": null, + "overflow": null, + "padding": null, + "right": null, + "top": null, + "visibility": null, + "width": null + } + } + }, + "version_major": 2, + "version_minor": 0 + } + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/template/MercuryNotebook/pyproject.toml b/template/MercuryNotebook/pyproject.toml new file mode 100644 index 0000000..40439a6 --- /dev/null +++ b/template/MercuryNotebook/pyproject.toml @@ -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"] diff --git a/template/MercuryNotebook/scripts/export_report.py b/template/MercuryNotebook/scripts/export_report.py new file mode 100644 index 0000000..2cf4e3d --- /dev/null +++ b/template/MercuryNotebook/scripts/export_report.py @@ -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/.html β€” human-reviewable, tables render + exports/.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() diff --git a/template/MercuryNotebook/studylib/__init__.py b/template/MercuryNotebook/studylib/__init__.py new file mode 100644 index 0000000..ef9e8cc --- /dev/null +++ b/template/MercuryNotebook/studylib/__init__.py @@ -0,0 +1 @@ +"""Study engine package β€” rename ``studylib`` to match your study.""" diff --git a/template/MercuryNotebook/studylib/model.py b/template/MercuryNotebook/studylib/model.py new file mode 100644 index 0000000..c7e9dec --- /dev/null +++ b/template/MercuryNotebook/studylib/model.py @@ -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("$", "$") diff --git a/template/MercuryNotebook/studylib/staging.py b/template/MercuryNotebook/studylib/staging.py new file mode 100644 index 0000000..2ec7bec --- /dev/null +++ b/template/MercuryNotebook/studylib/staging.py @@ -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) diff --git a/template/MercuryNotebook/tests/conftest.py b/template/MercuryNotebook/tests/conftest.py new file mode 100644 index 0000000..c80d3a0 --- /dev/null +++ b/template/MercuryNotebook/tests/conftest.py @@ -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)) diff --git a/template/MercuryNotebook/tests/test_model.py b/template/MercuryNotebook/tests/test_model.py new file mode 100644 index 0000000..a317ac7 --- /dev/null +++ b/template/MercuryNotebook/tests/test_model.py @@ -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" diff --git a/template/MercuryNotebook/tests/test_staging.py b/template/MercuryNotebook/tests/test_staging.py new file mode 100644 index 0000000..96ff1ca --- /dev/null +++ b/template/MercuryNotebook/tests/test_staging.py @@ -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 == ""