Files
palladium/docs/Mercury_Notebook_Pattern_V1-00.md

20 KiB
Raw Blame History

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

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, &#36;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.


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

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:

  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.txtpip install -e . must fully provision serve/test/export.
  • Don't put two bare $ in one plotly annotation — MathJax eats the text; use html_money() (&#36;).
  • 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

# 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).
  2. Stage/backstage testmonkeypatch 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:
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.