docs: introduce Mercury Notebook Deliverable Pattern
This commit is contained in:
@@ -43,6 +43,12 @@ Palladium is a Jupyter notebook + Streamlit toolkit for building Total Economic
|
|||||||
| **`core/cli`** | `python -m palladium` command-line interface |
|
| **`core/cli`** | `python -m palladium` command-line interface |
|
||||||
| **`app/`** | Streamlit data-entry UI with version management — *study-agnostic* |
|
| **`app/`** | Streamlit data-entry UI with version management — *study-agnostic* |
|
||||||
| **`studies/`** | One folder per TEI engagement (notebooks, seed data, config, source PDF) |
|
| **`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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
440
docs/Mercury_Notebook_Pattern_V1-00.md
Normal file
440
docs/Mercury_Notebook_Pattern_V1-00.md
Normal file
@@ -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):
|
||||||
|
|
||||||
|
```
|
||||||
|
<study>/
|
||||||
|
├── <studylib>/ # 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 id="section-N"></a>`; 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.
|
||||||
152
docs/Pattern_Outline_V1-00.md
Normal file
152
docs/Pattern_Outline_V1-00.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
# CTM Token Calculator
|
# 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,
|
**Genesys AI Token Cost & Business Case Calculator** — interactive,
|
||||||
defensible modeling of Genesys Cloud **CX 3** platform + AI feature costs
|
defensible modeling of Genesys Cloud **CX 3** platform + AI feature costs
|
||||||
against realistic benefit scenarios, replacing single-point vendor ROI
|
against realistic benefit scenarios, replacing single-point vendor ROI
|
||||||
47
template/MercuryNotebook/README.md
Normal file
47
template/MercuryNotebook/README.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# Mercury Notebook Template
|
||||||
|
|
||||||
|
Copy-me starting point for a Palladium study, per
|
||||||
|
[`docs/Mercury_Notebook_Pattern_V1-00.md`](../../docs/Mercury_Notebook_Pattern_V1-00.md).
|
||||||
|
The toy model is complete on purpose — every pattern mechanism (verbatim
|
||||||
|
anchor + contracted overlay, ramp mechanics, baseline-relative frame,
|
||||||
|
widget-pair reactivity, verification gate, backstage appendix, exports)
|
||||||
|
is present and runnable, so you replace math, not plumbing.
|
||||||
|
|
||||||
|
## Start a study
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r template/MercuryNotebook studies/YYYYMM_Client_EngagementName
|
||||||
|
cd studies/YYYYMM_Client_EngagementName
|
||||||
|
# 1. Rename the package (underscores only — dashes break Python imports):
|
||||||
|
# studylib/ → <yourstudy>lib/, then fix pyproject.toml + imports.
|
||||||
|
# 2. Replace studylib/model.py's toy domain with your math;
|
||||||
|
# re-pin tests/test_model.py with hand-checked numbers.
|
||||||
|
# 3. Rework notebooks/business_case.ipynb section by section —
|
||||||
|
# keep the widget-pair cells, gate cell, and appendix cell structure.
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|---|---|
|
||||||
|
| Tests | `pytest` |
|
||||||
|
| Serve (the stage) | `mercury --working-dir notebooks/` (run from project root so `config.toml` loads) |
|
||||||
|
| Analyst view (backstage) | `jupyter lab` |
|
||||||
|
| Headless check | `jupyter nbconvert --to notebook --execute --inplace notebooks/business_case.ipynb` |
|
||||||
|
| Export for LLMs | `python scripts/export_report.py` |
|
||||||
|
|
||||||
|
## What's here
|
||||||
|
|
||||||
|
```
|
||||||
|
studylib/model.py # ALL math — notebooks hold none
|
||||||
|
studylib/staging.py # on_stage()/backstage() — Mercury vs JupyterLab/nbconvert
|
||||||
|
notebooks/business_case.ipynb
|
||||||
|
scripts/export_report.py
|
||||||
|
tests/ # hand-checked pinned acceptance numbers
|
||||||
|
config.toml # Mercury theme (NTT DATA brand)
|
||||||
|
pyproject.toml # full toolchain as core deps — no requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Reference implementation (a full multi-notebook study):
|
||||||
|
`studies/202512_GenesysCX/ctm-token-calculator/`.
|
||||||
60
template/MercuryNotebook/config.toml
Normal file
60
template/MercuryNotebook/config.toml
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Mercury app-shell theme — NTT DATA brand (light).
|
||||||
|
# See docs/brand.md (repo root) for the source palette, and the CTM study's
|
||||||
|
# config.toml for a fully-tuned example.
|
||||||
|
#
|
||||||
|
# Loaded from the directory where you launch `mercury` (this project root);
|
||||||
|
# restart the server to apply changes. Only keys in mercury/config.py
|
||||||
|
# CSS_VARIABLE_MAP emit a CSS variable; omitted keys are derived.
|
||||||
|
|
||||||
|
[main]
|
||||||
|
title = "Study Title — Business Case"
|
||||||
|
favicon_emoji = "📊"
|
||||||
|
footer = "Study footer line"
|
||||||
|
notebooks_button_label = "Analyses"
|
||||||
|
|
||||||
|
[welcome]
|
||||||
|
header = "Study Title"
|
||||||
|
message = """
|
||||||
|
Interactive business-case notebooks. Tune the 🟡 inputs live for the
|
||||||
|
client, then export the personalized report source with
|
||||||
|
`python scripts/export_report.py`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
[theme]
|
||||||
|
# ── Type — Georgia headings, Arial body (web-safe; no font fetch) ──
|
||||||
|
font_family = "Arial, 'Helvetica Neue', Helvetica, sans-serif"
|
||||||
|
heading_font_family = "Georgia, 'Times New Roman', Times, serif"
|
||||||
|
font_size = "15px"
|
||||||
|
font_weight = "normal"
|
||||||
|
heading_font_weight = "700"
|
||||||
|
|
||||||
|
# ── Text — NTT ink scale ──
|
||||||
|
text_color = "#2e404d"
|
||||||
|
muted_text_color = "#586671"
|
||||||
|
|
||||||
|
# ── Surfaces — white content on a soft neutral canvas ──
|
||||||
|
background_color = "#f4f5f6"
|
||||||
|
content_background_color = "#ffffff"
|
||||||
|
surface_color = "#ffffff"
|
||||||
|
card_background_color = "#f8f8f8"
|
||||||
|
border_color = "#d5d9db"
|
||||||
|
border_radius = "10px"
|
||||||
|
|
||||||
|
# ── Accents — Future Blue; primary_color also drives Run button + focus ──
|
||||||
|
primary_color = "#0072bc"
|
||||||
|
accent_color = "#0072bc"
|
||||||
|
focus_border_color = "#0072bc"
|
||||||
|
hover_background_color = "#eef5fb"
|
||||||
|
selected_background_color = "#dcecfa"
|
||||||
|
|
||||||
|
# ── Sidebar / top bar / footer ──
|
||||||
|
sidebar_background_color = "#ffffff"
|
||||||
|
sidebar_text_color = "#2e404d"
|
||||||
|
sidebar_title_color = "#151d2c"
|
||||||
|
sidebar_shadow = "1px 0 0 #d5d9db"
|
||||||
|
topbar_background_color = "#151d2c"
|
||||||
|
topbar_text_color = "#ffffff"
|
||||||
|
topbar_border_color = "rgba(255,255,255,0.08)"
|
||||||
|
footer_background_color = "#ffffff"
|
||||||
|
footer_text_color = "#586671"
|
||||||
|
footer_border_color = "#d5d9db"
|
||||||
8521
template/MercuryNotebook/exports/business_case.html
Normal file
8521
template/MercuryNotebook/exports/business_case.html
Normal file
File diff suppressed because one or more lines are too long
509
template/MercuryNotebook/exports/business_case.md
Normal file
509
template/MercuryNotebook/exports/business_case.md
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
# [Study Title] — Business Case
|
||||||
|
|
||||||
|
**Thesis:** one paragraph stating what this notebook demonstrates and the frame it uses
|
||||||
|
(here: a platform migration priced against *doing nothing*, with the vendor's pitched
|
||||||
|
numbers kept verbatim as the anchor and the signed contract layered over them).
|
||||||
|
|
||||||
|
This notebook **is** the deliverable: serve it interactively with
|
||||||
|
`mercury --working-dir notebooks/`, tune the 🟡 inputs live for the client, then export
|
||||||
|
an LLM-readable report source with `python scripts/export_report.py`. All math lives in
|
||||||
|
`studylib/` — the notebook renders it.
|
||||||
|
|
||||||
|
Confidence legend: 🟢 confirmed (published/contractual) · 🟡 estimated (working
|
||||||
|
assumption) · 🔴 unknown.
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── Setup ──────────────────────────────────────────────────────────
|
||||||
|
import sys, pathlib
|
||||||
|
_ROOT = pathlib.Path.cwd()
|
||||||
|
if not (_ROOT / "studylib").exists(): # notebook lives in notebooks/
|
||||||
|
_ROOT = _ROOT.parent
|
||||||
|
sys.path.insert(0, str(_ROOT))
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
import plotly.graph_objects as go
|
||||||
|
|
||||||
|
import mercury as mr
|
||||||
|
|
||||||
|
# Single source of truth — all math lives in the library; only
|
||||||
|
# presentation (and Mercury input widgets) lives here.
|
||||||
|
from studylib.model import (
|
||||||
|
YEARS, ANCHOR_VERBATIM, DEFAULT_RAMP_MONTHS,
|
||||||
|
anchor, benefits_by_year, case_flows, case_kpis, current_costs_by_year,
|
||||||
|
money, html_money, payback_label, platform_costs_by_year, services_by_year,
|
||||||
|
)
|
||||||
|
from studylib.staging import backstage
|
||||||
|
|
||||||
|
pd.options.display.float_format = "{:,.0f}".format
|
||||||
|
|
||||||
|
# ── Chart chrome (dataviz reference palette, light surface) ─────────
|
||||||
|
INK, INK2, MUTED = "#0b0b0b", "#52514e", "#898781"
|
||||||
|
SURFACE, GRID = "#fcfcfb", "#e1e0d9"
|
||||||
|
CUMULATIVE, CONTEXT = "#52514e", "#c3c2b7"
|
||||||
|
FONT_STACK = 'system-ui, -apple-system, "Segoe UI", sans-serif'
|
||||||
|
|
||||||
|
|
||||||
|
def tei_layout(fig, title, subtitle=None, height=420):
|
||||||
|
"""House chart chrome — recessive grid, ink text, title top-left."""
|
||||||
|
t = f"<b>{title}</b>"
|
||||||
|
if subtitle:
|
||||||
|
t += f"<br><span style='font-size:12px;color:{INK2}'>{subtitle}</span>"
|
||||||
|
fig.update_layout(
|
||||||
|
title=dict(text=t, font=dict(size=16, color=INK), x=0, xanchor="left"),
|
||||||
|
font=dict(family=FONT_STACK, size=12, color=INK),
|
||||||
|
paper_bgcolor=SURFACE, plot_bgcolor=SURFACE,
|
||||||
|
margin=dict(l=60, r=30, t=80, b=45), height=height,
|
||||||
|
legend=dict(orientation="h", yanchor="bottom", y=1.0, x=0,
|
||||||
|
bgcolor="rgba(0,0,0,0)"),
|
||||||
|
xaxis=dict(showgrid=False, zeroline=False),
|
||||||
|
yaxis=dict(gridcolor=GRID, zeroline=False, tickformat="$,.0f"),
|
||||||
|
hovermode="x unified",
|
||||||
|
)
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def bar(x, y, name, color):
|
||||||
|
return go.Bar(x=x, y=y, name=name, marker_color=color,
|
||||||
|
marker_line=dict(color=SURFACE, width=2),
|
||||||
|
hovertemplate="%{y:$,.0f}<extra>" + name + "</extra>")
|
||||||
|
|
||||||
|
|
||||||
|
def cum_line(x, y, name, color=CUMULATIVE, dash=None):
|
||||||
|
return go.Scatter(x=x, y=y, name=name, mode="lines+markers",
|
||||||
|
line=dict(color=color, width=2, dash=dash),
|
||||||
|
marker=dict(size=8),
|
||||||
|
hovertemplate="%{y:$,.0f}<extra>" + name + "</extra>")
|
||||||
|
|
||||||
|
|
||||||
|
X = [str(y) for y in YEARS]
|
||||||
|
backstage(f"studylib loaded — window {YEARS[0]}–{YEARS[-1]}")
|
||||||
|
```
|
||||||
|
|
||||||
|
studylib loaded — window 2026–2028
|
||||||
|
|
||||||
|
|
||||||
|
<a id="section-1"></a>
|
||||||
|
|
||||||
|
## 1 · Inputs
|
||||||
|
|
||||||
|
Contract inputs collected as live widgets. Sidebar widgets are scenario knobs; use
|
||||||
|
`position="inline"` for data-collection tables that belong in the page flow.
|
||||||
|
|
||||||
|
> **Reactivity contract:** Mercury re-executes only the cells *below* a changed
|
||||||
|
> widget's cell — never the defining cell itself. So the next cell constructs
|
||||||
|
> widgets ONLY (no other output), and `.value` is read one cell further down.
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── Inputs (Mercury sidebar — widgets only, NO other output) ────────
|
||||||
|
# NB: Mercury re-executes only cells BELOW a changed widget's cell, so
|
||||||
|
# this cell constructs widgets ONLY — .value is read downstream.
|
||||||
|
_platform_w = mr.NumberInput(label="Platform run-rate ($/yr) — contracted",
|
||||||
|
value=round(anchor("platform_annual")),
|
||||||
|
min=0, max=10_000_000, step=25_000)
|
||||||
|
_ramp_w = mr.NumberInput(label="Ramp — billing-free months",
|
||||||
|
value=DEFAULT_RAMP_MONTHS, min=0, max=24, step=3)
|
||||||
|
_npv_w = mr.Select(label="NPV discount rate", value="10% (vendor)",
|
||||||
|
choices=["10% (vendor)", "8% (treasury)"])
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
<mercury.number.NumberInputWidget object at 0x726bea6f2cf0>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<mercury.number.NumberInputWidget object at 0x726bea609950>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<mercury.select.SelectWidget object at 0x726c48215160>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── Model state (re-runs on any change to the widgets above) ────────
|
||||||
|
PLATFORM_ANNUAL = float(_platform_w.value)
|
||||||
|
RAMP_MONTHS = int(_ramp_w.value)
|
||||||
|
DISCOUNT_RATE = 0.10 if _npv_w.value.startswith("10") else 0.08
|
||||||
|
|
||||||
|
platform_by_year = platform_costs_by_year(RAMP_MONTHS, PLATFORM_ANNUAL)
|
||||||
|
services_by = services_by_year()
|
||||||
|
current_by_year = current_costs_by_year()
|
||||||
|
ben_by_year = benefits_by_year()
|
||||||
|
BASELINE_ANNUAL = anchor("baseline_annual")
|
||||||
|
|
||||||
|
backstage(f"platform by year: { {y: money(v) for y, v in platform_by_year.items()} }")
|
||||||
|
print(f"platform run-rate: contracted {money(PLATFORM_ANNUAL)}/yr "
|
||||||
|
f"(vendor pitched {money(ANCHOR_VERBATIM['platform_annual'])}/yr) · "
|
||||||
|
f"ramp {RAMP_MONTHS} months")
|
||||||
|
```
|
||||||
|
|
||||||
|
platform by year: {2026: '$250K', 2027: '$500K', 2028: '$500K'}
|
||||||
|
platform run-rate: contracted $500K/yr (vendor pitched $600K/yr) · ramp 6 months
|
||||||
|
|
||||||
|
|
||||||
|
<a id="section-2"></a>
|
||||||
|
|
||||||
|
## 2 · Business case vs doing nothing
|
||||||
|
|
||||||
|
**Frame:** baseline-relative. Incremental cost = programme cost − baseline
|
||||||
|
(the do-nothing run-rate); net = benefits − incremental cost. Double-billing
|
||||||
|
while the old platform runs off and the post-termination cost-avoidance credit
|
||||||
|
both fall out of this one frame.
|
||||||
|
|
||||||
|
The KPI table keeps a **vendor-frame column** (pitched rate, verbatim anchors)
|
||||||
|
beside the **contracted column**, so the client can walk from their own numbers
|
||||||
|
to the corrected reality.
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
study_costs = pd.DataFrame({
|
||||||
|
"Platform (contracted, ramp-adjusted)": platform_by_year,
|
||||||
|
"Services (year 1)": services_by,
|
||||||
|
"Existing platform (term-contract run-off)": current_by_year,
|
||||||
|
}).T[YEARS]
|
||||||
|
study_costs["3-yr"] = study_costs.sum(axis=1)
|
||||||
|
total_by_year = {y: float(study_costs[y].sum()) for y in YEARS}
|
||||||
|
|
||||||
|
inc, net_by = case_flows(total_by_year, ben_by_year)
|
||||||
|
kpi = case_kpis(inc, net_by, DISCOUNT_RATE)
|
||||||
|
|
||||||
|
# Vendor-anchored comparison: same frame at the pitched (verbatim) rate.
|
||||||
|
_plat_vendor = platform_costs_by_year(RAMP_MONTHS, ANCHOR_VERBATIM["platform_annual"])
|
||||||
|
total_vendor = {y: current_by_year[y] + _plat_vendor[y] + services_by[y] for y in YEARS}
|
||||||
|
inc_v, net_v = case_flows(total_vendor, ben_by_year)
|
||||||
|
kpi_v = case_kpis(inc_v, net_v, DISCOUNT_RATE)
|
||||||
|
|
||||||
|
|
||||||
|
def kpi_col(k):
|
||||||
|
return {
|
||||||
|
"3-yr benefits": money(k["benefits_3yr"]),
|
||||||
|
"3-yr incremental cost": money(k["incremental_cost_3yr"]),
|
||||||
|
"3-yr net": money(k["net_3yr"]),
|
||||||
|
"ROI": f"{k['roi']:.0%}" if k["roi"] is not None else "n/a — net saving",
|
||||||
|
f"NPV @ {k['discount_rate']:.0%}": money(k["npv"]),
|
||||||
|
"Payback": k["payback"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
kpis_fmt = pd.DataFrame({
|
||||||
|
f"Vendor frame ({money(ANCHOR_VERBATIM['platform_annual'])}/yr)": kpi_col(kpi_v),
|
||||||
|
f"Contracted ({money(PLATFORM_ANNUAL)}/yr)": kpi_col(kpi),
|
||||||
|
})
|
||||||
|
backstage(f"net by year: { {y: money(v) for y, v in net_by.items()} }")
|
||||||
|
display(study_costs)
|
||||||
|
display(kpis_fmt)
|
||||||
|
```
|
||||||
|
|
||||||
|
net by year: {2026: '-$500K', 2027: '-$200K', 2028: '$1.1M'}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<style scoped>
|
||||||
|
.dataframe tbody tr th:only-of-type {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataframe tbody tr th {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataframe thead th {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<table border="1" class="dataframe">
|
||||||
|
<thead>
|
||||||
|
<tr style="text-align: right;">
|
||||||
|
<th></th>
|
||||||
|
<th>2026</th>
|
||||||
|
<th>2027</th>
|
||||||
|
<th>2028</th>
|
||||||
|
<th>3-yr</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>Platform (contracted, ramp-adjusted)</th>
|
||||||
|
<td>250,000</td>
|
||||||
|
<td>500,000</td>
|
||||||
|
<td>500,000</td>
|
||||||
|
<td>1,250,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Services (year 1)</th>
|
||||||
|
<td>250,000</td>
|
||||||
|
<td>0</td>
|
||||||
|
<td>0</td>
|
||||||
|
<td>250,000</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Existing platform (term-contract run-off)</th>
|
||||||
|
<td>1,000,000</td>
|
||||||
|
<td>1,000,000</td>
|
||||||
|
<td>0</td>
|
||||||
|
<td>2,000,000</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<style scoped>
|
||||||
|
.dataframe tbody tr th:only-of-type {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataframe tbody tr th {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dataframe thead th {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<table border="1" class="dataframe">
|
||||||
|
<thead>
|
||||||
|
<tr style="text-align: right;">
|
||||||
|
<th></th>
|
||||||
|
<th>Vendor frame ($600K/yr)</th>
|
||||||
|
<th>Contracted ($500K/yr)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th>3-yr benefits</th>
|
||||||
|
<td>$900K</td>
|
||||||
|
<td>$900K</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>3-yr incremental cost</th>
|
||||||
|
<td>$750K</td>
|
||||||
|
<td>$500K</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>3-yr net</th>
|
||||||
|
<td>$150K</td>
|
||||||
|
<td>$400K</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>ROI</th>
|
||||||
|
<td>20%</td>
|
||||||
|
<td>80%</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>NPV @ 10%</th>
|
||||||
|
<td>$3K</td>
|
||||||
|
<td>$207K</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Payback</th>
|
||||||
|
<td>35 months (~Nov 2028)</td>
|
||||||
|
<td>32 months (~Aug 2028)</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
fig = go.Figure()
|
||||||
|
fig.add_trace(bar(X, [ben_by_year[y] for y in YEARS], "Benefits", "#1baf7a"))
|
||||||
|
fig.add_trace(bar(X, [-inc[y] for y in YEARS],
|
||||||
|
f"Incremental cost vs {money(BASELINE_ANNUAL)}/yr baseline",
|
||||||
|
"#e34948"))
|
||||||
|
cum_net = pd.Series([net_by[y] for y in YEARS]).cumsum()
|
||||||
|
fig.add_trace(cum_line(X, cum_net, "Cumulative net"))
|
||||||
|
fig.update_layout(barmode="relative")
|
||||||
|
# Several amounts in one annotation → html_money, or MathJax eats the text.
|
||||||
|
fig.add_annotation(
|
||||||
|
xref="paper", yref="paper", x=0.01, y=0.98, align="left", showarrow=False,
|
||||||
|
font=dict(size=12, color=INK2), bgcolor=SURFACE, bordercolor=GRID, borderwidth=1,
|
||||||
|
text=(f"3-yr net <b>{html_money(kpi['net_3yr'])}</b> · "
|
||||||
|
f"NPV@{DISCOUNT_RATE:.0%} <b>{html_money(kpi['npv'])}</b> · "
|
||||||
|
f"payback <b>{kpi['payback']}</b>"))
|
||||||
|
tei_layout(fig, "Business case vs doing nothing",
|
||||||
|
"Baseline-relative: net = benefits − (programme cost − baseline)")
|
||||||
|
fig.show()
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
<a id="section-3"></a>
|
||||||
|
|
||||||
|
## 3 · Verification & assertions
|
||||||
|
|
||||||
|
Engine pins use **explicit default arguments**, so the gate tests `studylib`, not the
|
||||||
|
current widget state; live-state checks only run when the inputs sit at their defaults.
|
||||||
|
This cell must pass under headless `nbconvert --execute` — it is the study's smoke test.
|
||||||
|
Output renders backstage only.
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _approx(got, want, tol=0.5):
|
||||||
|
assert abs(got - want) <= tol, f"got {got:,.2f}, want {want:,.2f}"
|
||||||
|
|
||||||
|
|
||||||
|
# Engine pins — explicit defaults, independent of widget state
|
||||||
|
_approx(anchor("platform_annual"), 500_000) # signed overlay
|
||||||
|
_approx(ANCHOR_VERBATIM["platform_annual"], 600_000) # vendor record intact
|
||||||
|
_p = platform_costs_by_year() # verbatim rate, 6-mo ramp
|
||||||
|
_approx(_p[2026], 300_000)
|
||||||
|
_approx(_p[2027], 600_000)
|
||||||
|
_b = benefits_by_year()
|
||||||
|
_approx(sum(_b.values()), anchor("benefit_3yr"))
|
||||||
|
_approx(_b[2026], 0) # nothing lands in year 1
|
||||||
|
|
||||||
|
# Default-flow pins (contracted frame at engine defaults)
|
||||||
|
_pc = platform_costs_by_year(annual=anchor("platform_annual"))
|
||||||
|
_tot = {y: current_costs_by_year()[y] + _pc[y] + services_by_year()[y] for y in YEARS}
|
||||||
|
_, _net = case_flows(_tot, _b)
|
||||||
|
_approx(sum(_net.values()), 400_000)
|
||||||
|
assert payback_label(_net) == "32 months (~Aug 2028)"
|
||||||
|
|
||||||
|
# Live state — only checked when the widgets sit at their defaults
|
||||||
|
_at_default = (PLATFORM_ANNUAL == round(anchor("platform_annual"))
|
||||||
|
and RAMP_MONTHS == DEFAULT_RAMP_MONTHS)
|
||||||
|
if _at_default:
|
||||||
|
_approx(kpi["net_3yr"], 400_000)
|
||||||
|
for y in YEARS:
|
||||||
|
_approx(net_by[y], ben_by_year[y] - (total_by_year[y] - BASELINE_ANNUAL))
|
||||||
|
|
||||||
|
backstage("All assertions passed.")
|
||||||
|
backstage(f" net 3-yr {money(kpi['net_3yr'])} · payback {kpi['payback']}")
|
||||||
|
```
|
||||||
|
|
||||||
|
All assertions passed.
|
||||||
|
net 3-yr $400K · payback 32 months (~Aug 2028)
|
||||||
|
|
||||||
|
|
||||||
|
<a id="section-4"></a>
|
||||||
|
|
||||||
|
## 4 · Data appendix — for the machines
|
||||||
|
|
||||||
|
Everything above, dumped as markdown tables plus one JSON block of model state, so the
|
||||||
|
exported report is complete LLM input without re-running anything. The dump renders
|
||||||
|
**backstage** (JupyterLab and the exports) and stays hidden in the Mercury app.
|
||||||
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ── Data appendix — LLM-readable dump of every model output ──────────
|
||||||
|
# Renders backstage only (JupyterLab / nbconvert exports) — hidden on
|
||||||
|
# the Mercury stage, where the narrative and figures carry the story.
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
|
||||||
|
def _section(title, df, **kw):
|
||||||
|
backstage(f"\n#### {title}\n")
|
||||||
|
backstage(df.to_markdown(floatfmt=",.0f", **kw))
|
||||||
|
|
||||||
|
|
||||||
|
_section("Cost stack ($)", study_costs)
|
||||||
|
_section("Business-case flows vs do-nothing baseline ($)",
|
||||||
|
pd.DataFrame({"programme cost": total_by_year,
|
||||||
|
"incremental cost": inc, "net": net_by}).T[YEARS])
|
||||||
|
_section("KPIs — vendor frame vs contracted", kpis_fmt)
|
||||||
|
|
||||||
|
backstage("\n#### Model state (JSON)\n")
|
||||||
|
backstage("```json")
|
||||||
|
backstage(_json.dumps({
|
||||||
|
"scenario": "TEMPLATE — replace with the study's one-line scenario",
|
||||||
|
"benefit_by_year": {str(y): round(ben_by_year[y]) for y in YEARS},
|
||||||
|
"cost_by_year": {str(y): round(total_by_year[y]) for y in YEARS},
|
||||||
|
"net_by_year": {str(y): round(net_by[y]) for y in YEARS},
|
||||||
|
"kpis": {k: (round(v, 4) if isinstance(v, float) else v)
|
||||||
|
for k, v in kpi.items()},
|
||||||
|
"kpis_vendor_frame": {k: (round(v, 4) if isinstance(v, float) else v)
|
||||||
|
for k, v in kpi_v.items()},
|
||||||
|
"assumptions": {
|
||||||
|
"platform_annual": round(PLATFORM_ANNUAL),
|
||||||
|
"vendor_platform_annual": ANCHOR_VERBATIM["platform_annual"],
|
||||||
|
"ramp_months": RAMP_MONTHS,
|
||||||
|
"discount_rate": DISCOUNT_RATE,
|
||||||
|
"baseline_annual": round(BASELINE_ANNUAL),
|
||||||
|
},
|
||||||
|
}, indent=2))
|
||||||
|
backstage("```")
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
#### Cost stack ($)
|
||||||
|
|
||||||
|
| | 2026 | 2027 | 2028 | 3-yr |
|
||||||
|
|:------------------------------------------|----------:|----------:|--------:|----------:|
|
||||||
|
| Platform (contracted, ramp-adjusted) | 250,000 | 500,000 | 500,000 | 1,250,000 |
|
||||||
|
| Services (year 1) | 250,000 | 0 | 0 | 250,000 |
|
||||||
|
| Existing platform (term-contract run-off) | 1,000,000 | 1,000,000 | 0 | 2,000,000 |
|
||||||
|
|
||||||
|
#### Business-case flows vs do-nothing baseline ($)
|
||||||
|
|
||||||
|
| | 2026 | 2027 | 2028 |
|
||||||
|
|:-----------------|----------:|----------:|----------:|
|
||||||
|
| programme cost | 1,500,000 | 1,500,000 | 500,000 |
|
||||||
|
| incremental cost | 500,000 | 500,000 | -500,000 |
|
||||||
|
| net | -500,000 | -200,000 | 1,100,000 |
|
||||||
|
|
||||||
|
#### KPIs — vendor frame vs contracted
|
||||||
|
|
||||||
|
| | Vendor frame ($600K/yr) | Contracted ($500K/yr) |
|
||||||
|
|:----------------------|:--------------------------|:------------------------|
|
||||||
|
| 3-yr benefits | $900K | $900K |
|
||||||
|
| 3-yr incremental cost | $750K | $500K |
|
||||||
|
| 3-yr net | $150K | $400K |
|
||||||
|
| ROI | 20% | 80% |
|
||||||
|
| NPV @ 10% | $3K | $207K |
|
||||||
|
| Payback | 35 months (~Nov 2028) | 32 months (~Aug 2028) |
|
||||||
|
|
||||||
|
#### Model state (JSON)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scenario": "TEMPLATE \u2014 replace with the study's one-line scenario",
|
||||||
|
"benefit_by_year": {
|
||||||
|
"2026": 0,
|
||||||
|
"2027": 300000,
|
||||||
|
"2028": 600000
|
||||||
|
},
|
||||||
|
"cost_by_year": {
|
||||||
|
"2026": 1500000,
|
||||||
|
"2027": 1500000,
|
||||||
|
"2028": 500000
|
||||||
|
},
|
||||||
|
"net_by_year": {
|
||||||
|
"2026": -500000,
|
||||||
|
"2027": -200000,
|
||||||
|
"2028": 1100000
|
||||||
|
},
|
||||||
|
"kpis": {
|
||||||
|
"benefits_3yr": 900000.0,
|
||||||
|
"incremental_cost_3yr": 500000.0,
|
||||||
|
"net_3yr": 400000.0,
|
||||||
|
"roi": 0.8,
|
||||||
|
"npv": 206611.5702,
|
||||||
|
"discount_rate": 0.1,
|
||||||
|
"payback": "32 months (~Aug 2028)"
|
||||||
|
},
|
||||||
|
"kpis_vendor_frame": {
|
||||||
|
"benefits_3yr": 900000.0,
|
||||||
|
"incremental_cost_3yr": 750000.0,
|
||||||
|
"net_3yr": 150000.0,
|
||||||
|
"roi": 0.2,
|
||||||
|
"npv": 3380.9166,
|
||||||
|
"discount_rate": 0.1,
|
||||||
|
"payback": "35 months (~Nov 2028)"
|
||||||
|
},
|
||||||
|
"assumptions": {
|
||||||
|
"platform_annual": 500000,
|
||||||
|
"vendor_platform_annual": 600000,
|
||||||
|
"ramp_months": 6,
|
||||||
|
"discount_rate": 0.1,
|
||||||
|
"baseline_annual": 1000000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
1935
template/MercuryNotebook/notebooks/business_case.ipynb
Normal file
1935
template/MercuryNotebook/notebooks/business_case.ipynb
Normal file
File diff suppressed because one or more lines are too long
36
template/MercuryNotebook/pyproject.toml
Normal file
36
template/MercuryNotebook/pyproject.toml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "mercury_notebook_template" # rename to your study, underscores only
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Mercury Notebook Pattern template — copy me to start a study"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
# The notebooks are the deliverables (served with Mercury, exported via
|
||||||
|
# nbconvert, tables via tabulate) — the whole toolchain is a required
|
||||||
|
# runtime dependency, not an extra. `pip install -e .` must be enough.
|
||||||
|
dependencies = [
|
||||||
|
"pandas>=2.0",
|
||||||
|
"plotly>=5.18",
|
||||||
|
"openpyxl>=3.1",
|
||||||
|
"mercury>=3.2",
|
||||||
|
"jupyterlab>=4.0",
|
||||||
|
"ipywidgets>=8.0",
|
||||||
|
"nbconvert>=7",
|
||||||
|
"tabulate>=0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = ["pytest>=7.4", "mypy>=1.8"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
include = ["studylib*"]
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
addopts = "-q"
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
strict = true
|
||||||
|
packages = ["studylib"]
|
||||||
47
template/MercuryNotebook/scripts/export_report.py
Normal file
47
template/MercuryNotebook/scripts/export_report.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Export the deliverable notebooks as LLM-readable report sources.
|
||||||
|
|
||||||
|
Executes each notebook fresh (widget defaults — or whatever defaults you edit in),
|
||||||
|
then writes both formats to exports/:
|
||||||
|
|
||||||
|
exports/<notebook>.html — human-reviewable, tables render
|
||||||
|
exports/<notebook>.md — leanest LLM input
|
||||||
|
|
||||||
|
Plotly figures export as JavaScript an LLM cannot read; each notebook's
|
||||||
|
machine-readable appendix section carries every number behind them.
|
||||||
|
|
||||||
|
Run from the project root: python scripts/export_report.py [name-filter]
|
||||||
|
An optional argument exports only notebooks whose filename contains it.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
NOTEBOOKS = [
|
||||||
|
ROOT / "notebooks" / "business_case.ipynb",
|
||||||
|
]
|
||||||
|
EXPORTS = ROOT / "exports"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
picked = [nb for nb in NOTEBOOKS
|
||||||
|
if len(sys.argv) < 2 or sys.argv[1] in nb.name]
|
||||||
|
if not picked:
|
||||||
|
sys.exit(f"no notebook matches {sys.argv[1]!r}")
|
||||||
|
EXPORTS.mkdir(exist_ok=True)
|
||||||
|
for nb in picked:
|
||||||
|
for fmt in ("html", "markdown"):
|
||||||
|
subprocess.run(
|
||||||
|
[sys.executable, "-m", "nbconvert", "--execute",
|
||||||
|
"--to", fmt, "--output-dir", str(EXPORTS), str(nb)],
|
||||||
|
check=True, cwd=ROOT,
|
||||||
|
)
|
||||||
|
for p in sorted(EXPORTS.iterdir()):
|
||||||
|
if p.suffix in (".html", ".md"):
|
||||||
|
print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1
template/MercuryNotebook/studylib/__init__.py
Normal file
1
template/MercuryNotebook/studylib/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Study engine package — rename ``studylib`` to match your study."""
|
||||||
166
template/MercuryNotebook/studylib/model.py
Normal file
166
template/MercuryNotebook/studylib/model.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
Study engine — the single source of truth for every number in the notebooks.
|
||||||
|
|
||||||
|
Replace the toy domain below with the study's real model; the *structure*
|
||||||
|
is the pattern:
|
||||||
|
|
||||||
|
- ``ANCHOR_VERBATIM`` — the client/vendor source record, never edited.
|
||||||
|
- ``ANCHOR_CONTRACTED`` — signed values layered over it; ``anchor()`` reads
|
||||||
|
through, so the source anchor survives for as-pitched comparisons.
|
||||||
|
- ``*_by_year`` schedules keyed by calendar year.
|
||||||
|
- A baseline-relative case frame (``case_flows`` / ``case_kpis``).
|
||||||
|
|
||||||
|
The notebooks hold no math — they call this module and render.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
# ── Timeline ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
YEARS = [2026, 2027, 2028] # model window; contract starts Jan of YEARS[0]
|
||||||
|
_MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||||
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]
|
||||||
|
|
||||||
|
|
||||||
|
def month_label(m: int) -> str:
|
||||||
|
"""Calendar label for a 1-indexed month from Jan of YEARS[0]."""
|
||||||
|
return f"{_MONTHS[(m - 1) % 12]} {YEARS[0] + (m - 1) // 12}"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Anchors: verbatim source record + contracted overlay ─────────────
|
||||||
|
|
||||||
|
#: The vendor's pitch / the client's source deck — VERBATIM, do not edit.
|
||||||
|
ANCHOR_VERBATIM: dict[str, float] = {
|
||||||
|
"baseline_annual": 1_000_000, # do-nothing run-rate
|
||||||
|
"platform_annual": 600_000, # pitched platform run-rate
|
||||||
|
"services_y1": 250_000, # pitched one-off services, year 1
|
||||||
|
"benefit_3yr": 900_000, # claimed 3-yr benefit
|
||||||
|
"npv_discount_rate": 0.10,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Signed values where they differ from the pitch — 🟢 contractual.
|
||||||
|
ANCHOR_CONTRACTED: dict[str, float] = {
|
||||||
|
"platform_annual": 500_000, # signed run-rate (pitch said $600K)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def anchor(key: str) -> float:
|
||||||
|
"""Contracted value where one exists, else the verbatim anchor."""
|
||||||
|
return ANCHOR_CONTRACTED.get(key, ANCHOR_VERBATIM[key])
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_RAMP_MONTHS = 6 # platform billing starts month 7
|
||||||
|
DEFAULT_TERMINATION_YEAR = 2027 # existing platform bills through this year
|
||||||
|
REALIZE_MONTH = 18 # benefits realize from month 19
|
||||||
|
|
||||||
|
|
||||||
|
# ── Cost & benefit schedules (calendar-year keyed) ───────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def platform_costs_by_year(
|
||||||
|
ramp_months: int = DEFAULT_RAMP_MONTHS, annual: float | None = None
|
||||||
|
) -> dict[int, float]:
|
||||||
|
"""Ramp programme: billing starts in calendar month ramp_months + 1."""
|
||||||
|
rate = ANCHOR_VERBATIM["platform_annual"] if annual is None else annual
|
||||||
|
out = {}
|
||||||
|
for yi, y in enumerate(YEARS, start=1):
|
||||||
|
start, end = 12 * (yi - 1) + 1, 12 * yi
|
||||||
|
months = max(0, end - max(start, ramp_months + 1) + 1)
|
||||||
|
out[y] = rate * months / 12
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def current_costs_by_year(
|
||||||
|
termination_year: int = DEFAULT_TERMINATION_YEAR, annual: float | None = None
|
||||||
|
) -> dict[int, float]:
|
||||||
|
"""Existing-platform run-off (the double-billing line)."""
|
||||||
|
rate = ANCHOR_VERBATIM["baseline_annual"] if annual is None else annual
|
||||||
|
return {y: (rate if y <= termination_year else 0.0) for y in YEARS}
|
||||||
|
|
||||||
|
|
||||||
|
def services_by_year() -> dict[int, float]:
|
||||||
|
"""One-off services — verbatim, year 1 only."""
|
||||||
|
return {y: (ANCHOR_VERBATIM["services_y1"] if y == YEARS[0] else 0.0)
|
||||||
|
for y in YEARS}
|
||||||
|
|
||||||
|
|
||||||
|
def benefits_by_year(realize_month: int = REALIZE_MONTH) -> dict[int, float]:
|
||||||
|
"""Phase the claimed 3-yr benefit across its live months."""
|
||||||
|
live = {y: max(0, 12 * yi - max(12 * (yi - 1), realize_month))
|
||||||
|
for yi, y in enumerate(YEARS, start=1)}
|
||||||
|
total = sum(live.values())
|
||||||
|
return {y: anchor("benefit_3yr") * m / total if total else 0.0
|
||||||
|
for y, m in live.items()}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Business case (baseline-relative frame) ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def case_flows(
|
||||||
|
total_cost_by_year: dict[int, float],
|
||||||
|
benefit_by_year: dict[int, float],
|
||||||
|
baseline_annual: float | None = None,
|
||||||
|
) -> tuple[dict[int, float], dict[int, float]]:
|
||||||
|
"""(incremental cost, net) vs the do-nothing baseline."""
|
||||||
|
base = ANCHOR_VERBATIM["baseline_annual"] if baseline_annual is None \
|
||||||
|
else baseline_annual
|
||||||
|
inc = {y: total_cost_by_year[y] - base for y in YEARS}
|
||||||
|
net = {y: benefit_by_year[y] - inc[y] for y in YEARS}
|
||||||
|
return inc, net
|
||||||
|
|
||||||
|
|
||||||
|
def npv(flows: list[float], rate: float) -> float:
|
||||||
|
"""NPV with the first flow discounted one full year."""
|
||||||
|
return sum(v / (1 + rate) ** i for i, v in enumerate(flows, start=1))
|
||||||
|
|
||||||
|
|
||||||
|
def payback_label(net_by_year: dict[int, float]) -> str:
|
||||||
|
cum = 0.0
|
||||||
|
for i, y in enumerate(YEARS):
|
||||||
|
step = net_by_year[y]
|
||||||
|
if cum + step >= 0:
|
||||||
|
if i == 0 and step >= 0:
|
||||||
|
return "immediate"
|
||||||
|
frac = (-cum / step) if step > 0 else 0.0
|
||||||
|
m = math.ceil((i + frac) * 12)
|
||||||
|
return f"{m} months (~{month_label(m)})"
|
||||||
|
cum += step
|
||||||
|
return f"beyond {YEARS[-1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def case_kpis(
|
||||||
|
inc: dict[int, float],
|
||||||
|
net: dict[int, float],
|
||||||
|
discount_rate: float | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""KPIs for one cost frame. Benefits are recoverable as net + inc."""
|
||||||
|
rate = ANCHOR_VERBATIM["npv_discount_rate"] if discount_rate is None \
|
||||||
|
else discount_rate
|
||||||
|
net_list = [net[y] for y in YEARS]
|
||||||
|
inc_total = sum(inc.values())
|
||||||
|
net_total = sum(net_list)
|
||||||
|
return {
|
||||||
|
"benefits_3yr": net_total + inc_total,
|
||||||
|
"incremental_cost_3yr": inc_total,
|
||||||
|
"net_3yr": net_total,
|
||||||
|
"roi": (net_total / inc_total) if inc_total > 0 else None,
|
||||||
|
"npv": npv(net_list, rate),
|
||||||
|
"discount_rate": rate,
|
||||||
|
"payback": payback_label(net),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Display helpers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def money(v: float) -> str:
|
||||||
|
sign, a = ("-" if v < 0 else ""), abs(v)
|
||||||
|
return f"{sign}${a/1e6:,.1f}M" if a >= 1e6 else f"{sign}${a/1e3:,.0f}K"
|
||||||
|
|
||||||
|
|
||||||
|
def html_money(v: float) -> str:
|
||||||
|
"""Plotly text with two or more bare ``$`` triggers MathJax math mode —
|
||||||
|
annotations holding several amounts must use the HTML entity instead."""
|
||||||
|
return money(v).replace("$", "$")
|
||||||
29
template/MercuryNotebook/studylib/staging.py
Normal file
29
template/MercuryNotebook/studylib/staging.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
Stage vs backstage — is this notebook render stakeholder-facing?
|
||||||
|
|
||||||
|
The Mercury CLI (``mercury --working-dir …``) exports ``MERCURY_CONFIG_DIR``
|
||||||
|
into the server process so the widget library can locate ``config.toml``
|
||||||
|
(see ``mercury/config.py``); every kernel that server spawns inherits it.
|
||||||
|
JupyterLab and nbconvert kernels don't have it. That makes the variable a
|
||||||
|
reliable signal for "the audience is looking" (the stage) versus an
|
||||||
|
analyst session or a headless export run (backstage).
|
||||||
|
|
||||||
|
Diagnostics routed through :func:`backstage` stay visible in JupyterLab
|
||||||
|
and land in the nbconvert exports (where the machine-readable appendix
|
||||||
|
must appear for LLM consumption) but never render in the Mercury app.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def on_stage() -> bool:
|
||||||
|
"""True when running under the Mercury app (stakeholder-facing)."""
|
||||||
|
return os.getenv("MERCURY_CONFIG_DIR") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def backstage(*args, **kwargs) -> None:
|
||||||
|
"""``print`` that renders only backstage (JupyterLab, nbconvert)."""
|
||||||
|
if not on_stage():
|
||||||
|
print(*args, **kwargs)
|
||||||
7
template/MercuryNotebook/tests/conftest.py
Normal file
7
template/MercuryNotebook/tests/conftest.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
"""Make studylib importable without installation (the template is not
|
||||||
|
pip-installed; real studies use ``pip install -e ".[dev]"`` instead)."""
|
||||||
|
|
||||||
|
import pathlib
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
||||||
62
template/MercuryNotebook/tests/test_model.py
Normal file
62
template/MercuryNotebook/tests/test_model.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
"""Toy-model acceptance pins — hand-checked, like every study must have.
|
||||||
|
|
||||||
|
Every number here was computed by hand before it was pinned. When you
|
||||||
|
replace the toy domain, replace these with hand-checks of YOUR math;
|
||||||
|
the in-notebook verification gate is the second layer, not a substitute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from studylib import model as m
|
||||||
|
|
||||||
|
|
||||||
|
def test_contracted_overlays_verbatim():
|
||||||
|
assert m.anchor("platform_annual") == 500_000 # signed contract
|
||||||
|
assert m.ANCHOR_VERBATIM["platform_annual"] == 600_000 # pitch record intact
|
||||||
|
assert m.anchor("baseline_annual") == m.ANCHOR_VERBATIM["baseline_annual"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_ramp_mechanics():
|
||||||
|
assert m.platform_costs_by_year() == {2026: 300_000, 2027: 600_000,
|
||||||
|
2028: 600_000}
|
||||||
|
assert m.platform_costs_by_year(12)[2026] == 0.0
|
||||||
|
assert m.platform_costs_by_year(0)[2026] == 600_000.0
|
||||||
|
assert m.platform_costs_by_year(annual=m.anchor("platform_annual")) == \
|
||||||
|
{2026: 250_000, 2027: 500_000, 2028: 500_000}
|
||||||
|
|
||||||
|
|
||||||
|
def test_benefits_phase_on_the_schedule():
|
||||||
|
ben = m.benefits_by_year()
|
||||||
|
assert ben[2026] == 0.0 # nothing lands in year 1
|
||||||
|
assert ben[2027] == pytest.approx(900_000 * 6 / 18) # months 19-24
|
||||||
|
assert ben[2028] == pytest.approx(900_000 * 12 / 18)
|
||||||
|
assert sum(ben.values()) == pytest.approx(m.anchor("benefit_3yr"))
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(annual):
|
||||||
|
plat = m.platform_costs_by_year(annual=annual)
|
||||||
|
total = {y: m.current_costs_by_year()[y] + plat[y] + m.services_by_year()[y]
|
||||||
|
for y in m.YEARS}
|
||||||
|
return m.case_flows(total, m.benefits_by_year())
|
||||||
|
|
||||||
|
|
||||||
|
def test_contracted_frame_kpis():
|
||||||
|
inc, net = _frame(m.anchor("platform_annual"))
|
||||||
|
assert sum(net.values()) == pytest.approx(400_000)
|
||||||
|
kpi = m.case_kpis(inc, net)
|
||||||
|
assert kpi["roi"] == pytest.approx(0.8)
|
||||||
|
assert kpi["npv"] == pytest.approx(206_612, abs=1) # @10%, hand-checked
|
||||||
|
assert kpi["payback"] == "32 months (~Aug 2028)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vendor_frame_kpis():
|
||||||
|
inc, net = _frame(None) # verbatim pitch rate
|
||||||
|
assert sum(net.values()) == pytest.approx(150_000)
|
||||||
|
assert m.case_kpis(inc, net)["payback"] == "35 months (~Nov 2028)"
|
||||||
|
|
||||||
|
|
||||||
|
def test_payback_edges():
|
||||||
|
assert m.payback_label({2026: 1.0, 2027: 0.0, 2028: 0.0}) == "immediate"
|
||||||
|
assert m.payback_label({2026: -1.0, 2027: -1.0, 2028: -1.0}) == "beyond 2028"
|
||||||
15
template/MercuryNotebook/tests/test_staging.py
Normal file
15
template/MercuryNotebook/tests/test_staging.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""Stage/backstage detection — Mercury kernels carry MERCURY_CONFIG_DIR."""
|
||||||
|
|
||||||
|
from studylib import staging
|
||||||
|
|
||||||
|
|
||||||
|
def test_backstage_prints_only_off_stage(monkeypatch, capsys):
|
||||||
|
monkeypatch.delenv("MERCURY_CONFIG_DIR", raising=False)
|
||||||
|
assert not staging.on_stage()
|
||||||
|
staging.backstage("visible")
|
||||||
|
assert capsys.readouterr().out == "visible\n"
|
||||||
|
|
||||||
|
monkeypatch.setenv("MERCURY_CONFIG_DIR", "/tmp/app")
|
||||||
|
assert staging.on_stage()
|
||||||
|
staging.backstage("hidden")
|
||||||
|
assert capsys.readouterr().out == ""
|
||||||
Reference in New Issue
Block a user