20 KiB
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/ — 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/.
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.
# 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:
# ── 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)
# ── 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
.valueread 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=toNumberInput— 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.
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.
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.
# 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.
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 hrefs 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.
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:
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.
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.
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_clientat study setup, instead of re-keying it. - Outbound (roadmap): on study completion, push the study export — the
.mdreport 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.
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.
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.
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:
- Model it in the study package (e.g.
studylib/model.pygrowsmanaged_services_by_year()), keeping verbatim anchors separate from contracted overlays. - Pin it in
tests/with hand-checked numbers before wiring the notebook. - 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.valuein 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; usehtml_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_defaultguard — 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-
hrefToC 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
NumberInputwithoutmin=/max=— Mercury clamps to a small default range and out-of-range seeds break.
Settings
# 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 themercuryCLI 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/(soconfig.tomlloads). Analyst view:jupyter lab. Exports:python scripts/export_report.py.
Testing
Layers, from inner to outer — every study ships all four:
- 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 (seetemplate/MercuryNotebook/tests/test_model.py). - Stage/backstage test —
monkeypatchMERCURY_CONFIG_DIR, assertbackstage()prints only off stage (copytests/test_staging.pyfrom the template). - In-notebook gate (Required §4) — proves the rendered notebook matches the engine; runs on every execution, interactive or headless.
- Headless execution + export — the CI-style check:
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.