diff --git a/studies/202512_GenesysCX/ctm-token-calculator/README.md b/studies/202512_GenesysCX/ctm-token-calculator/README.md
index 49e8992..6eb7e2e 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/README.md
+++ b/studies/202512_GenesysCX/ctm-token-calculator/README.md
@@ -25,13 +25,17 @@ outputs with sensitivity-aware **Floor / Realistic / Stretch** analysis.
```bash
cd ctm-token-calculator
python -m venv .venv && source .venv/bin/activate
-pip install -r requirements.txt
+pip install -e ".[app,notebook,dev]"
-# Streamlit app (7 pages: Inputs → Export)
-streamlit run app/streamlit_app.py
+# Serve the notebooks as interactive web apps (Mercury)
+mercury --working-dir notebooks/
-# JupyterLab notebook variant (same numbers, same library)
-jupyter lab notebooks/ctm_token_calculator.ipynb
+# Or work on them directly in JupyterLab
+jupyter lab notebooks/
+
+# Export the corrected business case as LLM-readable report sources
+# (exports/*.html for review, exports/*.md for feeding an LLM)
+python scripts/export_report.py
# Tests
pytest
@@ -39,10 +43,21 @@ pytest
## Architecture
-All math lives in the pure-Python `tokencalc/` library; the notebook and
-Streamlit app are thin presentation layers calling the same functions —
-Run-All in the notebook produces identical headline numbers to the app on
-default inputs.
+**The notebooks are the deliverables.** All math lives in the pure-Python
+`tokencalc/` library; the notebooks are thin presentation layers over it.
+[Mercury](https://runmercury.com) serves them as interactive web apps — the
+`mercury` input widgets in `ctm_business_case_corrected.ipynb` let you tune
+contract values, termination dates, token assumptions, and implementation
+pricing live for a client, and headless runs (nbconvert, the section-10 regression
+gate) simply use the widget defaults. `scripts/export_report.py` executes the
+notebook and writes HTML + markdown to `exports/`; the notebook's section-12
+machine-readable appendix carries every number behind the figures so an LLM
+can draft the client report from the export.
+
+| Notebook | Purpose |
+|---|---|
+| `notebooks/ctm_business_case_corrected.ipynb` | Client-facing corrected business case (Mercury-interactive) |
+| `notebooks/ctm_token_calculator.ipynb` | Full token-cost / scenario workbench |
| Module | Purpose |
|---|---|
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/app/streamlit_app.py b/studies/202512_GenesysCX/ctm-token-calculator/app/streamlit_app.py
deleted file mode 100644
index 283600a..0000000
--- a/studies/202512_GenesysCX/ctm-token-calculator/app/streamlit_app.py
+++ /dev/null
@@ -1,891 +0,0 @@
-"""
-NTT DATA — CTM Token Calculator (Streamlit).
-
-Run from the ctm-token-calculator root::
-
- streamlit run app/streamlit_app.py
-
-Thin presentation layer over ``tokencalc`` — all math lives in the
-library, shared with the JupyterLab notebook.
-"""
-
-from __future__ import annotations
-
-import dataclasses
-import io
-import json
-import sys
-from pathlib import Path
-
-# Import tokencalc from the project root without install
-_ROOT = Path(__file__).resolve().parent.parent
-if str(_ROOT) not in sys.path:
- sys.path.insert(0, str(_ROOT))
-
-import numpy as np
-import pandas as pd
-import plotly.express as px
-import plotly.graph_objects as go
-import streamlit as st
-
-import tokencalc.scenarios as tc_scenarios
-from tokencalc import appendix4 as a4
-from tokencalc import (
- CONTRACTED_NAMED_USERS,
- CTM_DEFAULT_FEATURE_SCOPES,
- CTM_DEFAULT_SITES,
- CTM_DEFAULT_TAKEOUTS,
- DEFAULT_METERS,
- DEFAULT_PRICING,
- Confidence,
- CostTakeout,
- FeatureScope,
- SiteInput,
- build_business_case,
- calculate_total_benefit,
- calculate_total_cost,
- export_excel,
- get_scenario,
- meters_dataframe,
- scenario_state_from_json,
- scenario_state_to_json,
- sites_dataframe,
-)
-
-st.set_page_config(page_title="NTT DATA — CTM Token Calculator",
- page_icon="🧮", layout="wide")
-
-YEARS = (1, 2, 3)
-FEATURES = list(DEFAULT_METERS)
-_DEFAULT_REALISTIC = {
- k: v["realistic"] for k, v in tc_scenarios.BENEFIT_PARAMS.items()
-}
-
-
-# ── State ────────────────────────────────────────────────────────────
-
-def _init_state(force: bool = False) -> None:
- if force or "sites" not in st.session_state:
- st.session_state.sites = list(CTM_DEFAULT_SITES)
- st.session_state.takeouts = list(CTM_DEFAULT_TAKEOUTS)
- st.session_state.scopes = [
- dataclasses.replace(s) for s in CTM_DEFAULT_FEATURE_SCOPES
- ]
- st.session_state.meters = dict(DEFAULT_METERS)
- st.session_state.pricing = dict(DEFAULT_PRICING)
- st.session_state.use_contracted = False
- st.session_state.implementation_cost = 0.0
- for k, v in _DEFAULT_REALISTIC.items(): # reset benefit sliders
- tc_scenarios.BENEFIT_PARAMS[k]["realistic"] = v
-
-
-_init_state()
-
-
-def _state_key() -> str:
- """Stable serialization of inputs for st.cache_data keys."""
- return scenario_state_to_json(
- st.session_state.sites, st.session_state.takeouts, st.session_state.scopes
- ) + json.dumps(
- {
- "params": {k: v["realistic"] for k, v in tc_scenarios.BENEFIT_PARAMS.items()},
- "contracted": st.session_state.use_contracted,
- "impl": st.session_state.implementation_cost,
- "meters": {f: m.tokens_per_unit for f, m in st.session_state.meters.items()},
- "pricing": {
- r: (p.list_rate_per_token, p.contracted_rate_per_token)
- for r, p in st.session_state.pricing.items()
- },
- }
- )
-
-
-@st.cache_data(show_spinner=False)
-def _cached_case(state_key: str, scenario: str) -> dict:
- return build_business_case(
- st.session_state.sites, st.session_state.scopes,
- st.session_state.meters, st.session_state.pricing,
- st.session_state.takeouts, scenario,
- implementation_cost=st.session_state.implementation_cost,
- use_contracted=st.session_state.use_contracted,
- )
-
-
-def _case(scenario: str) -> dict:
- return _cached_case(_state_key(), scenario)
-
-
-# ── Sidebar ──────────────────────────────────────────────────────────
-
-st.sidebar.title("NTT DATA — CTM Token Calculator")
-page = st.sidebar.radio("Page", [
- "0. Corrected Business Case",
- "1. Inputs", "2. Token Meters", "3. Cost Model", "4. Benefit Model",
- "5. Business Case", "6. Sensitivity Analysis", "7. Export",
-])
-st.sidebar.divider()
-scenario_name = st.sidebar.radio(
- "Scenario", ["floor", "realistic", "stretch"], index=1, horizontal=True
-)
-year = st.sidebar.radio("Year", YEARS, horizontal=True)
-if st.sidebar.button("Reset to CTM defaults"):
- _init_state(force=True)
- st.cache_data.clear()
- st.rerun()
-st.sidebar.caption(
- "⚠️ Planning tool — published list rates unless overridden; "
- "not contractual pricing."
-)
-
-sites: list[SiteInput] = st.session_state.sites
-scopes: list[FeatureScope] = st.session_state.scopes
-meters = st.session_state.meters
-pricing = st.session_state.pricing
-scenario = get_scenario(scenario_name)
-
-
-def _users_warning() -> None:
- total = sum(s.named_users for s in sites)
- if total != CONTRACTED_NAMED_USERS:
- st.warning(
- f"Named users across sites = {total:,} ≠ contracted licence "
- f"count {CONTRACTED_NAMED_USERS:,}."
- )
-
-
-# ── Corrected-case chart chrome (ports the notebook's TEI styling) ───
-
-_INK, _INK2, _MUTED = "#0b0b0b", "#52514e", "#898781"
-_SURFACE, _GRID, _BASELINE = "#fcfcfb", "#e1e0d9", "#c3c2b7"
-_CUMULATIVE, _CONTEXT = "#52514e", "#c3c2b7"
-_CAP_COLOR = {
- "Agent Copilot": "#2a78d6", "WFM": "#1baf7a", "Email": "#eda100",
- "STA": "#008300", "Predictive Routing": "#4a3aa7",
- "Supervisor Copilot": "#e34948",
-}
-_COST_COLOR = {
- "CCaaS platform licences (ramp-adjusted)": "#2a78d6",
- "Base professional services + training": "#1baf7a",
- "Existing platform (term-contract run-off)": "#eda100",
- "AI token consumption": "#008300",
- "AI implementation + KB readiness": "#4a3aa7",
- "AI steady-state tuning": "#e34948",
-}
-_X = [str(y) for y in a4.YEARS]
-
-
-def _tei_layout(fig, title, subtitle=None, height=460):
- t = f"{title}"
- if subtitle:
- t += f"
{subtitle}"
- fig.update_layout(
- title=dict(text=t, font=dict(size=16, color=_INK), x=0.02, xanchor="left"),
- paper_bgcolor=_SURFACE, plot_bgcolor=_SURFACE,
- font=dict(family='system-ui, -apple-system, "Segoe UI", sans-serif',
- size=12, color=_INK2),
- legend=dict(orientation="h", yanchor="top", y=-0.10, x=0,
- font=dict(size=11, color=_INK2)),
- xaxis=dict(type="category", showgrid=False, linecolor=_BASELINE,
- tickfont=dict(color=_MUTED)),
- yaxis=dict(gridcolor=_GRID, zerolinecolor=_BASELINE, zerolinewidth=1.5,
- tickformat="$~s", tickfont=dict(color=_MUTED)),
- hovermode="x unified", bargap=0.45, height=height,
- margin=dict(t=70, r=30, b=80, l=70),
- )
- return fig
-
-
-def _bar(x, y, name, color):
- return go.Bar(x=x, y=y, name=name,
- marker=dict(color=color, line=dict(width=2, color=_SURFACE)),
- hovertemplate="%{fullData.name}: %{y:$,.0f}")
-
-
-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, line=dict(width=2, color=_SURFACE)),
- hovertemplate="%{fullData.name}: %{y:$,.0f}")
-
-
-# ── Page 0: Corrected Business Case ──────────────────────────────────
-
-if page == "0. Corrected Business Case":
- st.header("Corrected Business Case — Appendix 4")
- st.caption(
- "Genesys's benefits **verbatim** ($15.0M / 3 yr, phased on their own "
- "deployment schedule) against a cost case corrected for **AI token "
- "consumption**, **AI implementation effort (V2 LoE)**, **existing-platform "
- "double-billing**, and the **ramp credit** the deck also missed. "
- "The sidebar scenario/year controls do not apply to this page. "
- "Sites and token pricing are shared with the other pages."
- )
- _users_warning()
-
- # ── Controls ─────────────────────────────────────────────────────
- c1, c2, c3, c4, c5 = st.columns(5)
- ramp_months = int(c1.number_input(
- "Ramp (licence-free months)", 0, 24, a4.DEFAULT_RAMP_MONTHS,
- help="Genesys ramp programme — the $4.3M/yr commit bills from the "
- "month after the ramp ends."))
- hours_mode = c2.selectbox("AI impl hours (V2 LoE)", ["low", "mid", "high"],
- index=1)
- blended_rate = float(c3.selectbox("Blended rate $/h", [175, 225, 275], index=1))
- include_kb = c4.toggle("Include KB readiness", value=True,
- help="500-1,500 h prerequisite project, flagged "
- "separately from AI implementation.")
- discount_rate = (a4.TCO_VERBATIM["npv_discount_rate"]
- if c5.radio("NPV discount", ["13.5% (deck)", "8% (treasury)"],
- index=0) == "13.5% (deck)" else 0.08)
-
- with st.expander("Token-model assumptions (🟡 estimated knobs)"):
- t1, t2, t3 = st.columns(3)
- copilot_includes_asia = t1.toggle(
- "Copilot tokens in ASIA", value=False,
- help="Deck claims $0 Copilot benefit in ASIA — excluded by default "
- "for apples-to-apples.")
- na_email_early = t1.toggle(
- "NA Email implements early (Jan 2027)", value=True,
- help="NA Gantt exception: Email realizes Apr 2027.")
- pr_eligibility = t2.slider(
- "Predictive Routing eligibility", 0.0, 1.0, 1.0, 0.05,
- help="Share of voice volume on PR-enabled queues. At 100% PR tokens "
- "(~$1.8M/yr) exceed the $470K/yr claimed benefit.")
- translate_eligibility = t2.slider(
- "AI Translate eligibility (SupCopilot proxy)", 0.0, 0.10, 0.01, 0.005)
- email_tokens_per_msg = t3.number_input(
- "Email Auto-Respond tokens/msg (🔴 unpublished)", 0.0, 1.0, 0.05, 0.01,
- help="Working assumption ≈1 AI action per generated response "
- "(Genesys Cloud Copilot meters 20 AI actions/token).")
- email_respond_rate = t3.slider(
- "Email auto-respond rate", 0.0, 0.60, 0.255, 0.005,
- help="Deck claims 25.5% of email interactions auto-responded.")
-
- with st.expander("Current-state contracts by region — edit as real data arrives"):
- st.caption("Seeded as $7.3M × agent share (🟡). Term contracts bill "
- "through their termination month regardless of Genesys go-live "
- "— that is the double-billing.")
- cs_default = a4.current_state_inputs(sites).reset_index()
- cs_edit = st.data_editor(
- cs_default[["region", "agents", "annual_cost", "contract_termination"]],
- key="a4_current_state", hide_index=True,
- disabled=["region", "agents"],
- column_config={
- "annual_cost": st.column_config.NumberColumn(format="$%,.0f"),
- "contract_termination": st.column_config.DateColumn(),
- },
- )
- current_state = cs_edit.set_index("region")
- current_state["contract_termination"] = [
- d if d is not None else a4.DEFAULT_TERMINATION
- for d in current_state["contract_termination"]
- ]
-
- # ── Model (all math in tokencalc.appendix4) ──────────────────────
- token_ro, email_ro, benefit_ro = a4.build_rollouts(
- sites, na_email_early, ramp_months)
- benefits_long = a4.benefits_by_year(benefit_ro, na_email_early)
- benefit_by_year = benefits_long.groupby("year")["benefit"].sum().to_dict()
-
- core_scopes, email_scopes = a4.build_scopes(
- sites, copilot_includes_asia, pr_eligibility, translate_eligibility)
- a4_meters = {**meters,
- "Email AI (Auto-Respond)": a4.autorespond_meter(email_tokens_per_msg)}
- tokens_long = a4.token_costs_by_year(
- sites, a4_meters, pricing, a4.claim_scenario(email_respond_rate),
- core_scopes, email_scopes, token_ro, email_ro,
- use_contracted=st.session_state.use_contracted)
- token_by_year = tokens_long.groupby("year")["annual_cost"].sum().to_dict()
-
- impl_detail, impl_y, kb_y, steady_y = a4.build_impl_costs(
- sites, hours_mode, blended_rate, include_kb,
- copilot_includes_asia, na_email_early)
- current_y = a4.current_costs_by_year(current_state)
- licence_y = a4.licence_costs_by_year(ramp_months)
- ps_y = a4.ps_costs_by_year()
-
- corrected_costs = pd.DataFrame({
- "CCaaS platform licences (ramp-adjusted)": licence_y,
- "Base professional services + training": ps_y,
- "Existing platform (term-contract run-off)": current_y,
- "AI token consumption": token_by_year,
- "AI implementation + KB readiness": {y: impl_y[y] + kb_y[y]
- for y in a4.YEARS},
- "AI steady-state tuning": steady_y,
- }).T[a4.YEARS]
- corrected_by_year = {y: float(corrected_costs[y].sum()) for y in a4.YEARS}
- pitched_by_year = {y: a4.TCO_VERBATIM["ccaas_annual"] + ps_y[y] for y in a4.YEARS}
-
- inc_c, net_c = a4.case_flows(corrected_by_year, benefit_by_year)
- inc_p, net_p = a4.case_flows(pitched_by_year, benefit_by_year)
- kpi_c = a4.case_kpis(inc_c, net_c, discount_rate)
- kpi_p = a4.case_kpis(inc_p, net_p, discount_rate)
-
- # ── KPIs ─────────────────────────────────────────────────────────
- m1, m2, m3, m4, m5 = st.columns(5)
- m1.metric("3-yr net (corrected)", a4.money(kpi_c["net_3yr"]),
- delta=a4.money(kpi_c["net_3yr"] - kpi_p["net_3yr"]) + " vs pitch",
- delta_color="inverse")
- m2.metric("ROI", f"{kpi_c['roi']:.0%}" if kpi_c["roi"] is not None
- else "n/a — net saving")
- m3.metric(f"NPV @ {discount_rate:.1%}", a4.money(kpi_c["npv"]))
- m4.metric("Payback", kpi_c["payback"])
- m5.metric("3-yr programme cost", a4.money(sum(corrected_by_year.values())),
- delta=a4.money(sum(corrected_by_year.values())
- - sum(pitched_by_year.values())) + " vs pitch",
- delta_color="inverse")
-
- smell = sum(impl_y.values()) / a4.SLIDE_TOTALS["total_3yr"]
- if smell < a4.SMELL_TEST_FLOOR:
- st.caption(f"⚠️ Smell test: AI implementation = {smell:.1%} of the benefit "
- f"claim, below the 15% floor (industry band 20-40%). V2 "
- f"deliberately strips vendor inflation — sweep hours × rate "
- f"above to test robustness.")
-
- # ── Figures ──────────────────────────────────────────────────────
- tab_bc, tab_ben, tab_cost, tab_cmp = st.tabs(
- ["Business case", "Benefits (verbatim)", "Costs (corrected)",
- "Pitched vs corrected"])
-
- with tab_bc:
- fig = go.Figure()
- fig.add_trace(_bar(_X, [benefit_by_year[y] for y in a4.YEARS],
- "Benefits (verbatim Genesys)", "#2a78d6"))
- fig.add_trace(_bar(_X, [-inc_c[y] for y in a4.YEARS],
- "Incremental cost vs $7.3M/yr baseline", "#e34948"))
- cum_net = pd.Series([net_c[y] for y in a4.YEARS]).cumsum()
- fig.add_trace(_cum_line(_X, cum_net, "Cumulative net"))
- for i, c in enumerate(cum_net):
- fig.add_annotation(x=i, y=float(c), text=f"{a4.money(float(c))}",
- showarrow=False, yshift=14 if c >= 0 else -14,
- font=dict(size=12, color=_INK))
- fig.update_layout(barmode="relative")
- _tei_layout(fig, "Corrected business case — benefits vs incremental cost",
- "Baseline = keep paying $7.3M/yr · double-billing hits 2026-27, "
- "cost avoidance and benefits land 2028", height=500)
- st.plotly_chart(fig, width="stretch", key="a4_fig_case")
-
- with tab_ben:
- fig = go.Figure()
- for cap in a4.CAPABILITIES:
- vals = [benefits_long.query("capability == @cap and year == @y")
- ["benefit"].sum() for y in a4.YEARS]
- fig.add_trace(_bar(_X, vals, cap, _CAP_COLOR[cap]))
- cum = pd.Series([benefit_by_year[y] for y in a4.YEARS]).cumsum()
- fig.add_trace(_cum_line(_X, cum, "Cumulative benefits"))
- for i, y in enumerate(a4.YEARS):
- fig.add_annotation(x=i, y=benefit_by_year[y],
- text=f"{a4.money(benefit_by_year[y])}",
- showarrow=False, yshift=12,
- font=dict(size=12, color=_INK))
- fig.update_layout(barmode="stack")
- _tei_layout(fig, "Benefits over 3 years — verbatim Genesys (Appendix 4)",
- "Phased by Genesys's own deployment schedule — $0 in 2026 · "
- "WFM = Workforce Forecast & Scheduling")
- st.plotly_chart(fig, width="stretch", key="a4_fig_benefits")
-
- with tab_cost:
- fig = go.Figure()
- for line in corrected_costs.index:
- fig.add_trace(_bar(_X, [corrected_costs.loc[line, y] for y in a4.YEARS],
- line, _COST_COLOR[line]))
- cum_c = pd.Series([corrected_by_year[y] for y in a4.YEARS]).cumsum()
- cum_p = pd.Series([pitched_by_year[y] for y in a4.YEARS]).cumsum()
- fig.add_trace(_cum_line(_X, cum_c, "Cumulative — corrected"))
- fig.add_trace(_cum_line(_X, cum_p, "Cumulative — as pitched",
- color=_CONTEXT, dash="dash"))
- for i, y in enumerate(a4.YEARS):
- fig.add_annotation(x=i, y=corrected_by_year[y],
- text=f"{a4.money(corrected_by_year[y])}",
- showarrow=False, yshift=12,
- font=dict(size=12, color=_INK))
- delta = float(cum_c.iloc[-1] - cum_p.iloc[-1])
- fig.add_annotation(x=len(a4.YEARS) - 1, y=float(cum_c.iloc[-1]),
- text=f"3-yr {a4.html_money(float(cum_c.iloc[-1]))} — "
- f"{a4.html_money(delta)} above the pitch",
- showarrow=False, yshift=18, xshift=-70,
- font=dict(size=12, color=_INK2))
- fig.update_layout(barmode="stack")
- _tei_layout(fig, "Programme cost over 3 years — with the missed costs",
- "Existing platforms bill until term-contract end "
- "(double-billing) · licences ramp-free · tokens + AI "
- "implementation added", height=500)
- st.plotly_chart(fig, width="stretch", key="a4_fig_costs")
-
- with tab_cmp:
- fig = go.Figure()
- fig.add_trace(_bar(_X, [pitched_by_year[y] for y in a4.YEARS],
- "As pitched (deck)", _CONTEXT))
- fig.add_trace(_bar(_X, [corrected_by_year[y] for y in a4.YEARS],
- "Corrected", "#2a78d6"))
- for i, y in enumerate(a4.YEARS):
- d = corrected_by_year[y] - pitched_by_year[y]
- fig.add_annotation(x=i, y=corrected_by_year[y], xshift=16,
- text=f"{'+' if d >= 0 else '−'}{a4.money(abs(d))}",
- showarrow=False, yshift=12,
- font=dict(size=12, color=_INK))
- fig.update_layout(barmode="group", bargap=0.35, bargroupgap=0.15)
- _tei_layout(fig, "Cost case: as pitched vs corrected, by year",
- "Delta labels = what each year's pitch understates", height=400)
- st.plotly_chart(fig, width="stretch", key="a4_fig_compare")
-
- # ── Detail tables ────────────────────────────────────────────────
- with st.expander("Cost stack detail"):
- show = corrected_costs.copy()
- show.columns = [str(c) for c in show.columns]
- show["3-yr"] = show.sum(axis=1)
- show.loc["TOTAL — corrected"] = show.sum()
- st.dataframe(show, width="stretch",
- column_config={c: st.column_config.NumberColumn(
- str(c), format="$%,.0f") for c in show.columns})
- with st.expander("Token consumption detail"):
- tok = tokens_long.pivot_table(index="cost_line", columns="year",
- values="annual_cost", aggfunc="sum")
- tok.columns = [str(c) for c in tok.columns]
- tok.loc["WFM (no token meter — licence-included)"] = 0.0
- tok["3-yr"] = tok.sum(axis=1)
- tok = tok.sort_values("3-yr", ascending=False)
- tok.loc["TOTAL"] = tok.sum()
- st.dataframe(tok, width="stretch",
- column_config={c: st.column_config.NumberColumn(
- str(c), format="$%,.0f") for c in tok.columns})
- with st.expander("AI implementation detail (V2 LoE)"):
- impl_show = impl_detail.copy()
- impl_show.columns = [str(c) for c in impl_show.columns]
- st.dataframe(impl_show, width="stretch", hide_index=True,
- column_config={str(c): st.column_config.NumberColumn(
- str(c), format="$%,.0f")
- for c in ["cost", *a4.YEARS]})
-
- st.caption(
- "Not modelled: early-termination fees, migration costs beyond PS/impl. "
- "Non-NAM site volumes are tokencalc placeholders (🟡). Full method, "
- "assertions and sensitivity grids: "
- "`notebooks/ctm_business_case_corrected.ipynb`."
- )
-
-# ── Page 1: Inputs ───────────────────────────────────────────────────
-
-elif page == "1. Inputs":
- st.header("Inputs")
- st.caption("Site data outside NAM is **estimated — confirm with CTM data**.")
- _users_warning()
-
- df = sites_dataframe(sites)
- edited = st.data_editor(df, num_rows="dynamic", key="sites_editor")
- if st.button("Apply site changes"):
- try:
- st.session_state.sites = [
- SiteInput(
- **{
- **row,
- "languages": [
- x.strip() for x in str(row["languages"]).split(",") if x.strip()
- ],
- }
- )
- for row in edited.to_dict("records")
- ]
- st.cache_data.clear()
- st.success("Sites updated.")
- st.rerun()
- except (ValueError, TypeError) as e:
- st.error(f"Validation failed: {e}")
-
- st.subheader("Cost takeouts")
- tdf = pd.DataFrame(
- [
- {"name": t.name, "annual_cost": t.annual_cost,
- "start_year": t.start_year, "confidence": t.confidence.value,
- "notes": t.notes}
- for t in st.session_state.takeouts
- ]
- )
- tedit = st.data_editor(
- tdf, num_rows="dynamic", key="takeouts_editor",
- column_config={
- "confidence": st.column_config.SelectboxColumn(
- options=[c.value for c in Confidence]
- )
- },
- )
- if st.button("Apply takeout changes"):
- try:
- st.session_state.takeouts = [
- CostTakeout(
- name=r["name"], annual_cost=float(r["annual_cost"] or 0),
- start_year=int(r["start_year"] or 1),
- confidence=Confidence(r["confidence"]), notes=r["notes"] or "",
- )
- for r in tedit.to_dict("records")
- ]
- st.cache_data.clear()
- st.success("Takeouts updated.")
- st.rerun()
- except (ValueError, TypeError) as e:
- st.error(f"Validation failed: {e}")
-
- st.subheader("Save / load scenario")
- col1, col2 = st.columns(2)
- with col1:
- st.download_button(
- "Download scenario JSON",
- scenario_state_to_json(sites, st.session_state.takeouts, scopes),
- file_name="ctm_scenario.json", mime="application/json",
- )
- with col2:
- up = st.file_uploader("Load scenario JSON", type="json")
- if up is not None and st.button("Load"):
- # 4th element is the rollout plan (None for legacy files) —
- # not used by these pages yet.
- s, t, sc, _rollout = scenario_state_from_json(up.read().decode())
- st.session_state.sites, st.session_state.takeouts = s, t
- st.session_state.scopes = sc
- st.cache_data.clear()
- st.success("Scenario loaded.")
- st.rerun()
-
-# ── Page 2: Token Meters ─────────────────────────────────────────────
-
-elif page == "2. Token Meters":
- st.header("Token Meters")
- st.dataframe(meters_dataframe(meters), width="stretch", hide_index=True)
-
- st.subheader("Override a meter rate")
- feature = st.selectbox("Feature", FEATURES)
- m = meters[feature]
- override = st.toggle("Override default", key=f"ovr_{feature}")
- if override:
- new_rate = st.number_input(
- "tokens per unit (per user/month for per-user meters)",
- value=float(m.tokens_per_unit), min_value=0.0, step=0.005,
- format="%.4f",
- )
- if st.button("Apply override"):
- meters[feature] = dataclasses.replace(
- m,
- tokens_per_unit=new_rate,
- units_per_token=(1 / new_rate if new_rate and m.units_per_token else 0.0),
- confidence=Confidence.ESTIMATED,
- notes=m.notes + " [rate overridden by user]",
- )
- st.cache_data.clear()
- st.success(f"{feature} now {new_rate} tokens/unit (flagged estimated).")
-
- st.subheader("Token pricing per region")
- st.session_state.use_contracted = st.toggle(
- "Apply contracted rate (if known) instead of list rate",
- value=st.session_state.use_contracted,
- )
- for region, p in pricing.items():
- c1, c2 = st.columns(2)
- with c1:
- lr = st.number_input(
- f"{region} — list $/token", value=float(p.list_rate_per_token),
- min_value=0.0, key=f"list_{region}",
- )
- with c2:
- cr = st.number_input(
- f"{region} — contracted $/token (0 = unknown)",
- value=float(p.contracted_rate_per_token or 0.0),
- min_value=0.0, key=f"con_{region}",
- )
- pricing[region] = dataclasses.replace(
- p, list_rate_per_token=lr,
- contracted_rate_per_token=cr or None,
- )
-
-# ── Page 3: Cost Model ───────────────────────────────────────────────
-
-elif page == "3. Cost Model":
- st.header("Cost Model")
- _users_warning()
-
- st.subheader("Feature enablement & phasing")
- st.caption("Phase = model year the feature switches on at that site; 0 = off.")
- site_names = [s.site_name for s in sites]
- matrix = pd.DataFrame(0, index=site_names, columns=FEATURES, dtype=int)
- for sc in scopes:
- for sn in sc.enabled_sites:
- if sn in matrix.index:
- matrix.loc[sn, sc.feature] = sc.phase
- edited_matrix = st.data_editor(matrix, key="phasing_matrix")
- if st.button("Apply phasing"):
- new_scopes: list[FeatureScope] = []
- for feature in FEATURES:
- for phase in (1, 2, 3):
- enabled = [sn for sn in site_names
- if int(edited_matrix.loc[sn, feature]) == phase]
- if enabled:
- template = next(
- (s for s in scopes if s.feature == feature), None
- )
- new_scopes.append(
- FeatureScope(
- feature, enabled, phase=phase,
- adoption_curve=(
- template.adoption_curve if template else {}
- ),
- deflection_target=(
- template.deflection_target if template else None
- ),
- eligibility_pct=(
- template.eligibility_pct if template else None
- ),
- )
- )
- st.session_state.scopes = new_scopes
- st.cache_data.clear()
- st.success("Phasing updated.")
- st.rerun()
-
- frames = []
- for y in YEARS:
- d = calculate_total_cost(
- sites, scopes, meters, pricing, scenario, y,
- use_contracted=st.session_state.use_contracted,
- )
- d["year"] = f"Y{y}"
- frames.append(d)
- cost_3y = pd.concat(frames, ignore_index=True)
-
- this_year = frames[year - 1]
- total = this_year["annual_cost"].sum()
- unknown = this_year[this_year["confidence"] == "unknown"]["annual_cost"].sum()
- c1, c2 = st.columns(2)
- c1.metric(f"Year {year} total cost ({scenario_name})", f"${total:,.0f}")
- c2.metric("of which 🔴 unknown-rate features", f"${unknown:,.0f}",
- help="Range driven by unsourced meter rates — total could move "
- "materially once these are confirmed.")
-
- st.plotly_chart(
- px.bar(cost_3y, x="year", y="annual_cost", color="cost_line",
- title=f"Cost breakdown by feature — {scenario_name}",
- labels={"annual_cost": "$/yr"}),
- width="stretch", key="cost_stack",
- )
- icon_map = {c.value: c.icon for c in Confidence}
- show = this_year.copy()
- show["confidence"] = show["confidence"].map(
- lambda v: f"{icon_map.get(v, '')} {v}"
- )
- st.dataframe(show.sort_values("annual_cost", ascending=False),
- width="stretch", hide_index=True)
-
-# ── Page 4: Benefit Model ────────────────────────────────────────────
-
-elif page == "4. Benefit Model":
- st.header("Benefit Model")
- st.caption("Sliders adjust the pressure-tested (realistic) parameters; "
- "the Genesys-claim figures stay fixed for comparison.")
-
- cols = st.columns(3)
- for i, (key, vals) in enumerate(tc_scenarios.BENEFIT_PARAMS.items()):
- with cols[i % 3]:
- tc_scenarios.BENEFIT_PARAMS[key]["realistic"] = st.slider(
- key.replace("_", " "),
- 0.0, max(1.0, vals["claim"]),
- value=float(vals["realistic"]), step=0.005, format="%.3f",
- key=f"bp_{key}",
- )
-
- frames = []
- for y in YEARS:
- d = calculate_total_benefit(sites, scopes, scenario, y, params="realistic")
- d["year"] = f"Y{y}"
- frames.append(d)
- ben_3y = pd.concat(frames, ignore_index=True)
-
- st.metric(f"Year {year} total benefit ({scenario_name})",
- f"${frames[year - 1]['annual_value'].sum():,.0f}")
- st.plotly_chart(
- px.bar(ben_3y, x="year", y="annual_value", color="benefit_line",
- title=f"Benefit breakdown by source — {scenario_name}",
- labels={"annual_value": "$/yr"}),
- width="stretch", key="benefit_stack",
- )
-
- claim = calculate_total_benefit(sites, scopes, scenario, year, params="claim")
- realistic = frames[year - 1]
- comp = pd.merge(
- claim[["benefit_line", "annual_value"]].rename(
- columns={"annual_value": "Genesys claim"}),
- realistic[["benefit_line", "annual_value"]].rename(
- columns={"annual_value": "Pressure-tested"}),
- on="benefit_line", how="outer",
- ).fillna(0)
- fig = go.Figure([
- go.Bar(name="Genesys claim", x=comp.benefit_line, y=comp["Genesys claim"]),
- go.Bar(name="Pressure-tested realistic", x=comp.benefit_line,
- y=comp["Pressure-tested"]),
- ])
- fig.update_layout(barmode="group", yaxis_tickformat="$,.0f",
- title=f"Genesys claim vs pressure-tested — Year {year}")
- st.plotly_chart(fig, width="stretch", key="claim_vs_real")
-
-# ── Page 5: Business Case ────────────────────────────────────────────
-
-elif page == "5. Business Case":
- st.header("Business Case")
- st.session_state.implementation_cost = st.number_input(
- "One-off implementation cost (amortized over 3 years)",
- value=float(st.session_state.implementation_cost), min_value=0.0,
- step=50_000.0,
- )
- case = _case(scenario_name)
-
- pb = case["payback_period_years"]
- c1, c2, c3 = st.columns(3)
- c1.metric("NPV @ 8%", f"${case['npv']:,.0f}")
- c2.metric("Payback", f"{pb:.2f} yrs" if pb is not None else "never")
- c3.metric("3-Year ROI", f"{case['roi_3yr']:.0%}" if case["roi_3yr"] else "n/a")
-
- pnl = pd.concat(
- [
- case["cost_by_year"].drop(columns="confidence"),
- case["takeouts_by_year"].drop(columns="confidence"),
- case["benefit_by_year"].drop(columns="confidence"),
- case["net_by_year"],
- ],
- ignore_index=True,
- )
- pnl["3-yr Total"] = pnl[["Y1", "Y2", "Y3"]].sum(axis=1)
- st.dataframe(
- pnl, width="stretch", hide_index=True,
- column_config={
- c: st.column_config.NumberColumn(c, format="$%,.0f")
- for c in ("Y1", "Y2", "Y3", "3-yr Total")
- },
- )
-
- fig = go.Figure()
- for name in ("floor", "realistic", "stretch"):
- c = _case(name)
- fig.add_scatter(
- x=c["cumulative_net"].year, y=c["cumulative_net"].cumulative_net,
- mode="lines+markers", name=name.capitalize(),
- )
- fig.update_layout(title="Cumulative net cash flow by scenario",
- xaxis_title="Year", yaxis_tickformat="$,.0f")
- st.plotly_chart(fig, width="stretch", key="cum_net")
-
-# ── Page 6: Sensitivity ──────────────────────────────────────────────
-
-elif page == "6. Sensitivity Analysis":
- st.header("Sensitivity Analysis")
- base_npv = _case(scenario_name)["npv"]
- st.caption(f"Base 3-yr NPV ({scenario_name}): ${base_npv:,.0f}")
-
- def _npv_with(**overrides) -> float:
- sc = dataclasses.replace(scenario, **overrides)
- return build_business_case(
- sites, scopes, meters, pricing, st.session_state.takeouts, sc,
- implementation_cost=st.session_state.implementation_cost,
- use_contracted=st.session_state.use_contracted,
- )["npv"]
-
- drivers = [
- "voice_bot_deflection", "voice_bot_avg_minutes", "agentic_va_deflection",
- "voice_summarization_eligibility", "voice_knowledge_eligibility",
- "email_auto_respond_rate", "email_auto_suggest_acceptance",
- ]
- rows = []
- for d in drivers:
- base_v = getattr(scenario, d)
- lo = base_v * 0.75 if d == "voice_bot_avg_minutes" else min(base_v * 0.75, 1.0)
- hi = base_v * 1.25 if d == "voice_bot_avg_minutes" else min(base_v * 1.25, 1.0)
- rows.append({"driver": d,
- "low": _npv_with(**{d: lo}) - base_npv,
- "high": _npv_with(**{d: hi}) - base_npv})
- torn = pd.DataFrame(rows)
- torn["swing"] = (torn.high - torn.low).abs()
- torn = torn.sort_values("swing")
- fig = go.Figure([
- go.Bar(y=torn.driver, x=torn.low, orientation="h", name="-25%"),
- go.Bar(y=torn.driver, x=torn.high, orientation="h", name="+25%"),
- ])
- fig.update_layout(barmode="overlay", title="Tornado — NPV impact of ±25%",
- xaxis_tickformat="$,.0f")
- st.plotly_chart(fig, width="stretch", key="tornado")
-
- st.subheader("Two-variable heatmap")
- xs = np.linspace(0.0, 0.50, 6) # Email Auto-Respond rate
- ys = np.linspace(0.0, 0.25, 6) # Agentic VA deflection
- z = [[_npv_with(email_auto_respond_rate=float(x),
- agentic_va_deflection=float(yv)) for x in xs] for yv in ys]
- fig = go.Figure(go.Heatmap(
- x=[f"{x:.0%}" for x in xs], y=[f"{yv:.0%}" for yv in ys], z=z,
- colorbar={"title": "3-yr NPV"},
- ))
- fig.update_layout(title="NPV: Email Auto-Respond rate × Agentic VA deflection",
- xaxis_title="Email Auto-Respond rate",
- yaxis_title="Agentic VA deflection")
- st.plotly_chart(fig, width="stretch", key="heatmap")
-
- st.subheader("Break-even finder")
- rates = np.linspace(0.0, 0.50, 26)
- npvs = [_npv_with(email_auto_respond_rate=float(r)) for r in rates]
- breakeven = next((r for r, v in zip(rates, npvs) if v >= 0), None)
- if npvs[0] >= 0:
- st.success(f"Case is NPV-positive even at 0% Auto-Respond "
- f"(${npvs[0]:,.0f}).")
- elif breakeven is not None:
- st.info(f"Break-even at ~{breakeven:.0%} email Auto-Respond rate.")
- else:
- st.error("No break-even within 0–50% Auto-Respond.")
- st.plotly_chart(
- px.line(x=rates, y=npvs,
- labels={"x": "Email Auto-Respond rate", "y": "3-yr NPV ($)"}),
- width="stretch", key="breakeven",
- )
-
-# ── Page 7: Export ───────────────────────────────────────────────────
-
-elif page == "7. Export":
- st.header("Export")
- case = _case(scenario_name)
- cost_frames, ben_frames = [], []
- for y in YEARS:
- d = calculate_total_cost(sites, scopes, meters, pricing, scenario, y,
- use_contracted=st.session_state.use_contracted)
- d["year"] = f"Y{y}"
- cost_frames.append(d)
- b = calculate_total_benefit(sites, scopes, scenario, y)
- b["year"] = f"Y{y}"
- ben_frames.append(b)
-
- comparison = pd.DataFrame([
- {"scenario": n, "NPV": _case(n)["npv"],
- "payback_years": _case(n)["payback_period_years"],
- "roi_3yr": _case(n)["roi_3yr"]}
- for n in ("floor", "realistic", "stretch")
- ])
-
- pnl = pd.concat(
- [case["cost_by_year"].drop(columns="confidence"),
- case["takeouts_by_year"].drop(columns="confidence"),
- case["benefit_by_year"].drop(columns="confidence"),
- case["net_by_year"]],
- ignore_index=True,
- )
-
- buf = io.BytesIO()
- with pd.ExcelWriter(buf, engine="openpyxl") as writer:
- sites_dataframe(sites).to_excel(writer, sheet_name="Inputs", index=False)
- meters_dataframe(meters).to_excel(writer, sheet_name="Meters", index=False)
- pd.concat(cost_frames).to_excel(writer, sheet_name="Cost detail", index=False)
- pd.concat(ben_frames).to_excel(writer, sheet_name="Benefit detail", index=False)
- pnl.to_excel(writer, sheet_name="Business case", index=False)
- comparison.to_excel(writer, sheet_name="Scenario comparison", index=False)
- st.download_button(
- "⬇️ Download Excel workbook",
- buf.getvalue(),
- file_name=f"ctm_token_calculator_{scenario_name}.xlsx",
- mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- )
- st.download_button(
- "⬇️ Download scenario JSON",
- scenario_state_to_json(sites, st.session_state.takeouts, scopes),
- file_name="ctm_scenario.json", mime="application/json",
- )
- st.dataframe(comparison, width="stretch", hide_index=True)
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/config.toml b/studies/202512_GenesysCX/ctm-token-calculator/config.toml
new file mode 100644
index 0000000..67631c7
--- /dev/null
+++ b/studies/202512_GenesysCX/ctm-token-calculator/config.toml
@@ -0,0 +1,43 @@
+# Mercury app-shell theme — matched to the notebooks' chart chrome
+# (same warm-neutral surfaces and capability blue as the figures).
+#
+# Loaded from the directory where you launch `mercury` (this project root);
+# restart the server to apply changes. Full key list: mercury/config.py
+# DEFAULT_THEME — anything omitted is derived from the values below.
+
+[main]
+title = "CTM × Genesys — Business Case"
+favicon_emoji = "📊"
+footer = "CTM × Genesys CCaaS study"
+notebooks_button_label = "Analyses"
+
+[welcome]
+header = "CTM × Genesys CCaaS"
+message = """
+Interactive business-case notebooks. **Corrected Business Case** keeps
+Genesys's claimed benefits verbatim and adds the costs the pitch omitted —
+tune the 🟡 inputs live for the client, then export the personalized report
+source with `python scripts/export_report.py`.
+"""
+
+[theme]
+# text — same ink scale as the figures
+font_family = "system-ui, -apple-system, 'Segoe UI', sans-serif"
+heading_font_family = "system-ui, -apple-system, 'Segoe UI', sans-serif"
+text_color = "#0b0b0b"
+muted_text_color = "#52514e"
+
+# surfaces — warm neutrals from the chart chrome
+background_color = "#f4f3ef"
+content_background_color = "#fcfcfb"
+surface_color = "#fcfcfb"
+sidebar_background_color = "#f4f3ef"
+sidebar_text_color = "#0b0b0b"
+border_color = "#e1e0d9"
+
+# accents — the figures' capability blue
+primary_color = "#2a78d6"
+accent_color = "#2a78d6"
+focus_border_color = "#2a78d6"
+topbar_background_color = "#0b0b0b"
+topbar_text_color = "#fcfcfb"
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb b/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb
index 1b70081..ff9ed39 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb
+++ b/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_business_case_corrected.ipynb
@@ -24,8 +24,10 @@
"Confidence legend: 🟢 confirmed (published/contractual) · 🟡 estimated (working assumption) · 🔴 unknown.\n",
"\n",
"*Scope note: AI implementation uses the V2 hours-range × blended-rate model; the activity-level\n",
- "`ImplementationEffort` engine sketched in the labour doc is deferred (see §11). Timeline = 2026–2028,\n",
- "contract start Jan 2026.*"
+ "`ImplementationEffort` engine sketched in the labour doc is deferred (see section 11). Timeline = 2026–2028,\n",
+ "contract start Jan 2026.*\n",
+ "\n",
+ "*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`.*"
]
},
{
@@ -34,10 +36,10 @@
"id": "26632e8d",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:53.934907Z",
- "iopub.status.busy": "2026-07-07T15:40:53.934727Z",
- "iopub.status.idle": "2026-07-07T15:40:54.324524Z",
- "shell.execute_reply": "2026-07-07T15:40:54.323917Z"
+ "iopub.execute_input": "2026-07-07T20:49:38.764421Z",
+ "iopub.status.busy": "2026-07-07T20:49:38.764239Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.211068Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.210297Z"
}
},
"outputs": [
@@ -64,9 +66,11 @@
"import pandas as pd\n",
"import plotly.graph_objects as go\n",
"\n",
+ "import mercury as mr\n",
+ "\n",
"from tokencalc import *\n",
- "# Single source of truth for the corrected case — shared with the\n",
- "# Streamlit \"Corrected Business Case\" view. Only presentation lives here.\n",
+ "# Single source of truth for the corrected case — all math lives in the\n",
+ "# library; only presentation (and Mercury input widgets) lives here.\n",
"from tokencalc.appendix4 import (\n",
" YEARS, YEAR_INDEX, REGIONS, CAPABILITIES,\n",
" VERBATIM_BENEFITS, SLIDE_TOTALS, TCO_VERBATIM,\n",
@@ -149,12 +153,81 @@
" f\"(contracted: {CONTRACTED_NAMED_USERS:,})\")"
]
},
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "7b33a037",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-07T20:49:39.213225Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.213029Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.226449Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.223770Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "ddce655a7deb4730987a05a2c99ce93c",
+ "position": "sidebar",
+ "widget": "MarkdownWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "ddce655a7deb4730987a05a2c99ce93c",
+ "version_major": 2,
+ "version_minor": 0
+ },
+ "text/plain": [
+ "MarkdownWidget(value='
{label}'\n",
+ " for n, label in _TOC)\n",
+ "_toc = mr.Markdown(\n",
+ " text=(f'
Jump to section'\n",
+ " f'
{_items}
'),\n",
+ " position=\"sidebar\")\n"
+ ]
+ },
{
"cell_type": "markdown",
"id": "fda63800",
"metadata": {},
"source": [
- "## §1 · Current state & contract inputs — data collection\n",
+ "
\n",
+ "\n",
+ "## 1 · Current state & contract inputs — data collection\n",
"\n",
"Verbatim anchors (Appendix 4, slide 5–6): current solution costs **$7.3M/yr globally**\n",
"(ANZ, APAC, EMEA, NA — $22M over 3 years); CCaaS is **$4.3M/yr** with **$2.4M professional\n",
@@ -172,22 +245,193 @@
},
{
"cell_type": "code",
- "execution_count": 2,
+ "execution_count": 3,
"id": "7e4e12ce",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:54.327578Z",
- "iopub.status.busy": "2026-07-07T15:40:54.327172Z",
- "iopub.status.idle": "2026-07-07T15:40:54.363991Z",
- "shell.execute_reply": "2026-07-07T15:40:54.363033Z"
+ "iopub.execute_input": "2026-07-07T20:49:39.228308Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.228191Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.266036Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.265486Z"
}
},
"outputs": [
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "0b0538c483cf4a6783153739f90bb1b5",
+ "position": "inline",
+ "widget": "NumberInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "0b0538c483cf4a6783153739f90bb1b5",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ "
"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "65f7b3fc0e4a445cbf94e0f9a9b071fd",
+ "position": "inline",
+ "widget": "NumberInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "65f7b3fc0e4a445cbf94e0f9a9b071fd",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "53f8e2cfd0374fb19d23a7a12739be3b",
+ "position": "inline",
+ "widget": "DateInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "53f8e2cfd0374fb19d23a7a12739be3b",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "c10ea88b28a341ec8af4cef985011cea",
+ "position": "inline",
+ "widget": "NumberInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "c10ea88b28a341ec8af4cef985011cea",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "847b42fc33c44f81b7eec003b73b2863",
+ "position": "inline",
+ "widget": "DateInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "847b42fc33c44f81b7eec003b73b2863",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "3ac268955c964c9eb3c36c44efd8567d",
+ "position": "inline",
+ "widget": "NumberInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "3ac268955c964c9eb3c36c44efd8567d",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "8bd7c31269154630b34671b9bf271ca9",
+ "position": "inline",
+ "widget": "DateInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "8bd7c31269154630b34671b9bf271ca9",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "010206a4cbbc44dfb096f2cf38fdcc57",
+ "position": "inline",
+ "widget": "NumberInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "010206a4cbbc44dfb096f2cf38fdcc57",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "4b717193ae34473998db70c7bbeb213e",
+ "position": "inline",
+ "widget": "DateInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "4b717193ae34473998db70c7bbeb213e",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
{
"name": "stdout",
"output_type": "stream",
"text": [
- "current 3-yr = $21.9M (deck: $22.0M) — deck rounding\n",
+ "current-state total $7.3M/yr (seed: $7.3M; deck 3-yr $22.0M — deck rounding)\n",
"deck CCaaS 3-yr (no ramp) = $15.5M (deck: $15.4M)\n",
"ramp-adjusted licences by year: {2026: '$0K', 2027: '$4.3M', 2028: '$4.3M'}\n"
]
@@ -368,8 +612,11 @@
],
"source": [
"# ── Contract inputs (model: tokencalc.appendix4) ─────────────────────\n",
- "RAMP_MONTHS = DEFAULT_RAMP_MONTHS # Genesys ramp: licence-free months from contract start\n",
- "DISCOUNT_RATE = TCO_VERBATIM[\"npv_discount_rate\"] # 0.08 = CTM treasury alternative\n",
+ "# ⚙ Interactive when served with Mercury; headless runs (nbconvert /\n",
+ "# regression gate) use the widget defaults below.\n",
+ "RAMP_MONTHS = int(mr.NumberInput(\n",
+ " label=\"Genesys ramp — licence-free months\", value=DEFAULT_RAMP_MONTHS,\n",
+ " min=0, max=24, step=1, position=\"inline\").value)\n",
"\n",
"sites = list(CTM_DEFAULT_SITES)\n",
"ALL_SITES = [s.site_name for s in sites]\n",
@@ -377,17 +624,29 @@
"REGION_SITES = region_site_names(sites)\n",
"agents_by_region = region_agents(sites)\n",
"\n",
- "# ── Per-region current-state inputs (EDIT HERE as real data arrives) ─\n",
+ "# ── Per-region current-state inputs (EDIT as real data arrives) ──────\n",
"# Seeded as $7.3M × agent share; annual_cost and contract_termination\n",
- "# are the editable columns.\n",
- "current_state = current_state_inputs(sites)\n",
+ "# are the editable inputs.\n",
+ "_seed = current_state_inputs(sites)\n",
+ "assert abs(_seed[\"annual_cost\"].sum() - TCO_VERBATIM[\"current_annual\"]) < 1\n",
+ "\n",
+ "current_state = _seed.copy()\n",
+ "for _r in REGIONS:\n",
+ " current_state.loc[_r, \"annual_cost\"] = float(mr.NumberInput(\n",
+ " label=f\"{_r} — current platform cost ($/yr)\",\n",
+ " value=round(float(_seed.loc[_r, \"annual_cost\"])),\n",
+ " min=0, max=8_000_000, step=10_000, position=\"inline\").value)\n",
+ " current_state.loc[_r, \"contract_termination\"] = dt.date.fromisoformat(\n",
+ " mr.DateInput(label=f\"{_r} — contract termination\",\n",
+ " value=_seed.loc[_r, \"contract_termination\"].isoformat(),\n",
+ " position=\"inline\").value)\n",
"\n",
"current_by_year = current_costs_by_year(current_state)\n",
"licence_by_year = licence_costs_by_year(RAMP_MONTHS)\n",
"\n",
- "assert abs(current_state[\"annual_cost\"].sum() - TCO_VERBATIM[\"current_annual\"]) < 1\n",
- "print(f\"current 3-yr = {money(3 * TCO_VERBATIM['current_annual'])} \"\n",
- " f\"(deck: {money(TCO_VERBATIM['current_3yr'])}) — deck rounding\")\n",
+ "print(f\"current-state total {money(current_state['annual_cost'].sum())}/yr \"\n",
+ " f\"(seed: {money(TCO_VERBATIM['current_annual'])}; deck 3-yr \"\n",
+ " f\"{money(TCO_VERBATIM['current_3yr'])} — deck rounding)\")\n",
"print(f\"deck CCaaS 3-yr (no ramp) = \"\n",
" f\"{money(3 * TCO_VERBATIM['ccaas_annual'] + TCO_VERBATIM['prof_services_y1'] + TCO_VERBATIM['training_y1'])} \"\n",
" f\"(deck: {money(TCO_VERBATIM['ccaas_3yr'])})\")\n",
@@ -403,7 +662,7 @@
" (\"Contracted token rate (vs $1.00 US list)\", \"🔴 not sourced — list rate used\"),\n",
" (\"Non-NAM site volumes & AHTs\", \"🟡 tokencalc placeholders (defaults.py warning)\"),\n",
" (\"Early-termination / co-term options on NICE IEX et al.\", \"🔴 unknown\"),\n",
- "], columns=[\"data item\", \"status\"]))"
+ "], columns=[\"data item\", \"status\"]))\n"
]
},
{
@@ -411,7 +670,9 @@
"id": "0c74e449",
"metadata": {},
"source": [
- "## §2 · Verbatim Genesys benefits & deployment schedule\n",
+ "\n",
+ "\n",
+ "## 2 · Verbatim Genesys benefits & deployment schedule\n",
"\n",
"Benefits are taken **verbatim** from Appendix 4 slides 12–15 (per-region × capability, annual and\n",
"3-yr values). The deck gives no per-year split, so each cell's 3-yr value is **phased by Genesys's\n",
@@ -429,14 +690,14 @@
},
{
"cell_type": "code",
- "execution_count": 3,
+ "execution_count": 4,
"id": "c7e86e80",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:54.368463Z",
- "iopub.status.busy": "2026-07-07T15:40:54.368220Z",
- "iopub.status.idle": "2026-07-07T15:40:54.416525Z",
- "shell.execute_reply": "2026-07-07T15:40:54.415639Z"
+ "iopub.execute_input": "2026-07-07T20:49:39.268094Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.267985Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.298740Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.297928Z"
}
},
"outputs": [
@@ -585,14 +846,53 @@
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": 5,
+ "id": "5ace8c50",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-07T20:49:39.300729Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.300615Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.305099Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.304490Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "fb2bde654c82408ea195b113684a2916",
+ "position": "sidebar",
+ "widget": "CheckboxWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "fb2bde654c82408ea195b113684a2916",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# ── Schedule option (Mercury sidebar — widgets only, no output) ──────\n",
+ "NA_EMAIL_EARLY = bool(mr.CheckBox(\n",
+ " label=\"NA Email implements early (Jan 2027)\", value=True).value)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
"id": "fe3572d2",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:54.418553Z",
- "iopub.status.busy": "2026-07-07T15:40:54.418349Z",
- "iopub.status.idle": "2026-07-07T15:40:54.432146Z",
- "shell.execute_reply": "2026-07-07T15:40:54.431545Z"
+ "iopub.execute_input": "2026-07-07T20:49:39.307255Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.307149Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.317529Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.316827Z"
}
},
"outputs": [
@@ -725,8 +1025,7 @@
"# ── Genesys/Broadreach deployment schedule (slides 17-21) ────────────\n",
"# IMPL_MONTH = {NA: 18, ANZ: 21, EMEA: 24, ASIA: 27}; benefits realize\n",
"# +3 months; NA Email implements early (Jan 2027, realizes Apr 2027).\n",
- "# go_live_month = label − 1 so the labelled month is included (§2 note).\n",
- "NA_EMAIL_EARLY = True\n",
+ "# go_live_month = label − 1 so the labelled month is included (section 2 note).\n",
"\n",
"TOKEN_ROLLOUT, EMAIL_TOKEN_ROLLOUT, BENEFIT_ROLLOUT = build_rollouts(\n",
" sites, NA_EMAIL_EARLY, RAMP_MONTHS)\n",
@@ -751,14 +1050,14 @@
},
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": 7,
"id": "a06e90b9",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:54.433929Z",
- "iopub.status.busy": "2026-07-07T15:40:54.433731Z",
- "iopub.status.idle": "2026-07-07T15:40:54.508761Z",
- "shell.execute_reply": "2026-07-07T15:40:54.508003Z"
+ "iopub.execute_input": "2026-07-07T20:49:39.319374Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.319266Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.377874Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.376939Z"
}
},
"outputs": [
@@ -897,7 +1196,9 @@
"id": "3b9174ab",
"metadata": {},
"source": [
- "## §3 · Feature enablement → token consumption (missing cost #1)\n",
+ "\n",
+ "\n",
+ "## 3 · Feature enablement → token consumption (missing cost #1)\n",
"\n",
"Every deck capability maps to Genesys AI Experience token meters (all 🟢 published rates unless\n",
"noted), consumed from each region's **implementation month** — three months *before* benefits:\n",
@@ -905,7 +1206,7 @@
"| Deck capability | Token meter(s) | Treatment |\n",
"|---|---|---|\n",
"| Agent Copilot | Agent Copilot **[named]** — 40 tokens/user/mo | Per V2 correction #1, this **includes email/chat Auto-Suggest** and interaction summarization. Scoped to NA/ANZ/EMEA by default (ASIA claims $0 Copilot benefit — toggle below). |\n",
- "| Email | Email AI (**Auto-Respond**) — per message | 🔴→🟡 rate not published; working assumption below (anchored to Genesys Cloud Copilot's 20 AI actions/token), deck's 25.5% auto-respond rate. Sensitivity in §9. |\n",
+ "| Email | Email AI (**Auto-Respond**) — per message | 🔴→🟡 rate not published; working assumption below (anchored to Genesys Cloud Copilot's 20 AI actions/token), deck's 25.5% auto-respond rate. Sensitivity in section 9. |\n",
"| STA | Speech & Text Analytics **[named]** — 30 tokens/user/mo | All sites. |\n",
"| Predictive Routing | Predictive Routing — 17 routed interactions/token | All sites; `PR_ELIGIBILITY` scopes to PR-enabled queue share. |\n",
"| Supervisor Copilot | AI Summary & Insights (**$0 by Rule 1** — Copilot's rate already covers summarization) + AI Translate as small-volume proxy | Kept visible to show the coverage rule. |\n",
@@ -918,37 +1219,147 @@
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": 8,
"id": "977418e1",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:54.510564Z",
- "iopub.status.busy": "2026-07-07T15:40:54.510380Z",
- "iopub.status.idle": "2026-07-07T15:40:54.514601Z",
- "shell.execute_reply": "2026-07-07T15:40:54.512950Z"
+ "iopub.execute_input": "2026-07-07T20:49:39.381442Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.381217Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.397801Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.396935Z"
}
},
- "outputs": [],
+ "outputs": [
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "812c1a012db540ba98c297af3e6bfa4a",
+ "position": "sidebar",
+ "widget": "CheckboxWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "812c1a012db540ba98c297af3e6bfa4a",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "c3b301dd6fa24c02903a5588fb99f898",
+ "position": "sidebar",
+ "widget": "NumberInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "c3b301dd6fa24c02903a5588fb99f898",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "574ab86c4f754bf985f74dbb27ca85c0",
+ "position": "sidebar",
+ "widget": "NumberInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "574ab86c4f754bf985f74dbb27ca85c0",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "6c8053d24e944c48b01fe4bd1f3ab620",
+ "position": "sidebar",
+ "widget": "SliderWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "6c8053d24e944c48b01fe4bd1f3ab620",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "016c53e5a5f24aa586282e3d64512f57",
+ "position": "sidebar",
+ "widget": "NumberInputWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "016c53e5a5f24aa586282e3d64512f57",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
"source": [
- "# ── CONFIG — token model knobs ───────────────────────────────────────\n",
- "COPILOT_INCLUDES_ASIA = False # deck claims $0 Copilot benefit in ASIA → excluded by default\n",
- "EMAIL_AUTO_RESPOND_RATE = 0.255 # deck: \"Reduced Email Interactions with Auto-Respond 25.5%\"\n",
- "EMAIL_AUTORESPOND_TOKENS_PER_MSG = 0.05 # 🟡 ≈ one AI action per generated response (20/token)\n",
- "PR_ELIGIBILITY = 1.0 # share of voice volume on PR-enabled queues\n",
- "AI_TRANSLATE_ELIGIBILITY = 0.01 # 🟡 supervisor-evaluation slice of interactions\n",
- "USE_CONTRACTED_RATES = False # no contracted token rate sourced yet"
+ "# ── CONFIG — token model knobs (Mercury sidebar; defaults when headless) ─\n",
+ "COPILOT_INCLUDES_ASIA = bool(mr.CheckBox(\n",
+ " label=\"Copilot includes ASIA sites\", value=False).value)\n",
+ "# deck claims $0 Copilot benefit in ASIA → excluded by default\n",
+ "EMAIL_AUTO_RESPOND_RATE = float(mr.NumberInput(\n",
+ " label=\"Email auto-respond rate\", value=0.255,\n",
+ " min=0.0, max=0.6, step=0.005).value)\n",
+ "# deck: \"Reduced Email Interactions with Auto-Respond 25.5%\"\n",
+ "EMAIL_AUTORESPOND_TOKENS_PER_MSG = float(mr.NumberInput(\n",
+ " label=\"Auto-respond tokens per message 🟡\", value=0.05,\n",
+ " min=0.0, max=0.5, step=0.005).value)\n",
+ "# 🟡 ≈ one AI action per generated response (20 actions/token)\n",
+ "PR_ELIGIBILITY = mr.Slider(\n",
+ " label=\"Predictive Routing eligibility (%)\", value=100,\n",
+ " min=0, max=100).value / 100\n",
+ "# share of voice volume on PR-enabled queues\n",
+ "AI_TRANSLATE_ELIGIBILITY = float(mr.NumberInput(\n",
+ " label=\"AI Translate eligibility 🟡\", value=0.01,\n",
+ " min=0.0, max=1.0, step=0.01).value)\n",
+ "# 🟡 supervisor-evaluation slice of interactions\n",
+ "USE_CONTRACTED_RATES = False # no contracted token rate sourced yet\n"
]
},
{
"cell_type": "code",
- "execution_count": 7,
+ "execution_count": 9,
"id": "9767177f",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:54.516706Z",
- "iopub.status.busy": "2026-07-07T15:40:54.516514Z",
- "iopub.status.idle": "2026-07-07T15:40:54.529884Z",
- "shell.execute_reply": "2026-07-07T15:40:54.529284Z"
+ "iopub.execute_input": "2026-07-07T20:49:39.399814Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.399621Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.412702Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.412176Z"
}
},
"outputs": [
@@ -1087,14 +1498,14 @@
},
{
"cell_type": "code",
- "execution_count": 8,
+ "execution_count": 10,
"id": "3f3102f4",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:54.531888Z",
- "iopub.status.busy": "2026-07-07T15:40:54.531696Z",
- "iopub.status.idle": "2026-07-07T15:40:54.584270Z",
- "shell.execute_reply": "2026-07-07T15:40:54.583627Z"
+ "iopub.execute_input": "2026-07-07T20:49:39.414519Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.414357Z",
+ "iopub.status.idle": "2026-07-07T20:49:39.455275Z",
+ "shell.execute_reply": "2026-07-07T20:49:39.454430Z"
}
},
"outputs": [
@@ -1262,14 +1673,14 @@
},
{
"cell_type": "code",
- "execution_count": 9,
+ "execution_count": 11,
"id": "e62ecfa7",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:54.586011Z",
- "iopub.status.busy": "2026-07-07T15:40:54.585789Z",
- "iopub.status.idle": "2026-07-07T15:40:55.863721Z",
- "shell.execute_reply": "2026-07-07T15:40:55.860941Z"
+ "iopub.execute_input": "2026-07-07T20:49:39.457613Z",
+ "iopub.status.busy": "2026-07-07T20:49:39.457497Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.596596Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.595776Z"
}
},
"outputs": [
@@ -2296,7 +2707,9 @@
"id": "bf0108e5",
"metadata": {},
"source": [
- "## §4 · AI implementation effort (missing cost #2)\n",
+ "\n",
+ "\n",
+ "## 4 · AI implementation effort (missing cost #2)\n",
"\n",
"The deck's $2.4M professional services covers **base platform implementation only** — none of the\n",
"five AI capabilities are turn-key. Hours below are the **V2 corrected LoE**\n",
@@ -2313,14 +2726,98 @@
},
{
"cell_type": "code",
- "execution_count": 10,
+ "execution_count": 12,
"id": "3b395fbd",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:55.866389Z",
- "iopub.status.busy": "2026-07-07T15:40:55.866192Z",
- "iopub.status.idle": "2026-07-07T15:40:55.875774Z",
- "shell.execute_reply": "2026-07-07T15:40:55.874992Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.599803Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.599565Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.612416Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.611655Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "c16ba94a7214410fa65f180d3f4c9d48",
+ "position": "sidebar",
+ "widget": "SelectWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "c16ba94a7214410fa65f180d3f4c9d48",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "e0f44ed6c0524e3cb36422e63c87b127",
+ "position": "sidebar",
+ "widget": "SelectWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "e0f44ed6c0524e3cb36422e63c87b127",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "add57740f2c94a799241fda2111b4b1f",
+ "position": "sidebar",
+ "widget": "CheckboxWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "add57740f2c94a799241fda2111b4b1f",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# ── CONFIG — AI implementation (V2 LoE; Mercury sidebar, widgets only) ─\n",
+ "# Hours ranges live in tokencalc.appendix4 (AI_IMPL_HOURS / KB_READINESS_HOURS\n",
+ "# / STEADY_STATE_HOURS), sourced from docs/ctm_ai_labour_estimate_V2.md.\n",
+ "HOURS_MODE = str(mr.Select(label=\"Impl hours (V2 range)\", value=\"mid\",\n",
+ " choices=[\"low\", \"mid\", \"high\"]).value)\n",
+ "BLENDED_RATE = int(mr.Select( # 175 offshore-heavy | 225 typical | 275 onshore\n",
+ " label=\"Blended rate ($/h)\", value=\"225\",\n",
+ " choices=[\"175\", \"225\", \"275\"]).value)\n",
+ "INCLUDE_KB_READINESS = bool(mr.CheckBox(\n",
+ " label=\"Include KB readiness (500–1,500 h)\", value=True).value)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "id": "23756537",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-07T20:49:40.614438Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.614248Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.641691Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.640910Z"
}
},
"outputs": [
@@ -2346,6 +2843,7 @@
" \n",
" | \n",
" workstream | \n",
+ " regions | \n",
" low h | \n",
" high h | \n",
"
\n",
@@ -2354,48 +2852,56 @@
" \n",
" | 0 | \n",
" Agent Copilot | \n",
+ " NA, ANZ, EMEA | \n",
" 1200 | \n",
" 1800 | \n",
"
\n",
" \n",
" | 1 | \n",
" Email Auto-Respond | \n",
+ " NA, ANZ, EMEA, ASIA | \n",
" 800 | \n",
" 1400 | \n",
"
\n",
" \n",
" | 2 | \n",
" STA | \n",
+ " NA, ANZ, EMEA, ASIA | \n",
" 800 | \n",
" 1200 | \n",
"
\n",
" \n",
" | 3 | \n",
" Supervisor Copilot | \n",
+ " NA, ANZ, EMEA | \n",
" 200 | \n",
" 400 | \n",
"
\n",
" \n",
" | 4 | \n",
" Predictive Routing | \n",
+ " NA, ANZ, EMEA, ASIA | \n",
" 400 | \n",
" 700 | \n",
"
\n",
" \n",
" | 5 | \n",
- " Cross-cutting | \n",
+ " Cross-cutting (PM, governance, testing, integr... | \n",
+ " NA, ANZ, EMEA, ASIA | \n",
" 1000 | \n",
" 1800 | \n",
"
\n",
" \n",
" | 6 | \n",
" KB readiness (prerequisite) | \n",
+ " NA, ANZ, EMEA, ASIA | \n",
" 500 | \n",
" 1500 | \n",
"
\n",
" \n",
" | 7 | \n",
" Steady-state (h/yr, 2027-28) | \n",
+ " NA, ANZ, EMEA, ASIA | \n",
" 500 | \n",
" 900 | \n",
"
\n",
@@ -2404,57 +2910,30 @@
" "
],
"text/plain": [
- " workstream low h high h\n",
- "0 Agent Copilot 1200 1800\n",
- "1 Email Auto-Respond 800 1400\n",
- "2 STA 800 1200\n",
- "3 Supervisor Copilot 200 400\n",
- "4 Predictive Routing 400 700\n",
- "5 Cross-cutting 1000 1800\n",
- "6 KB readiness (prerequisite) 500 1500\n",
- "7 Steady-state (h/yr, 2027-28) 500 900"
+ " workstream regions \\\n",
+ "0 Agent Copilot NA, ANZ, EMEA \n",
+ "1 Email Auto-Respond NA, ANZ, EMEA, ASIA \n",
+ "2 STA NA, ANZ, EMEA, ASIA \n",
+ "3 Supervisor Copilot NA, ANZ, EMEA \n",
+ "4 Predictive Routing NA, ANZ, EMEA, ASIA \n",
+ "5 Cross-cutting (PM, governance, testing, integr... NA, ANZ, EMEA, ASIA \n",
+ "6 KB readiness (prerequisite) NA, ANZ, EMEA, ASIA \n",
+ "7 Steady-state (h/yr, 2027-28) NA, ANZ, EMEA, ASIA \n",
+ "\n",
+ " low h high h \n",
+ "0 1200 1800 \n",
+ "1 800 1400 \n",
+ "2 800 1200 \n",
+ "3 200 400 \n",
+ "4 400 700 \n",
+ "5 1000 1800 \n",
+ "6 500 1500 \n",
+ "7 500 900 "
]
},
"metadata": {},
"output_type": "display_data"
},
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "Regions served per workstream: {'Agent Copilot': ['NA', 'ANZ', 'EMEA'], 'Email Auto-Respond': ['NA', 'ANZ', 'EMEA', 'ASIA'], 'STA': ['NA', 'ANZ', 'EMEA', 'ASIA'], 'Supervisor Copilot': ['NA', 'ANZ', 'EMEA'], 'Predictive Routing': ['NA', 'ANZ', 'EMEA', 'ASIA'], 'Cross-cutting': ['NA', 'ANZ', 'EMEA', 'ASIA']}\n"
- ]
- }
- ],
- "source": [
- "# ── CONFIG — AI implementation (V2 LoE) ──────────────────────────────\n",
- "# Hours ranges live in tokencalc.appendix4 (AI_IMPL_HOURS / KB_READINESS_HOURS\n",
- "# / STEADY_STATE_HOURS), sourced from docs/ctm_ai_labour_estimate_V2.md.\n",
- "HOURS_MODE = \"mid\" # \"low\" | \"mid\" | \"high\"\n",
- "BLENDED_RATE = 225 # $/h — 175 offshore-heavy | 225 typical | 275 onshore\n",
- "INCLUDE_KB_READINESS = True\n",
- "\n",
- "display(pd.DataFrame(\n",
- " [{\"workstream\": f, \"low h\": lo, \"high h\": hi}\n",
- " for f, (lo, hi) in {**AI_IMPL_HOURS,\n",
- " \"KB readiness (prerequisite)\": KB_READINESS_HOURS,\n",
- " \"Steady-state (h/yr, 2027-28)\": STEADY_STATE_HOURS}.items()]))\n",
- "print(\"Regions served per workstream:\", impl_feature_regions(COPILOT_INCLUDES_ASIA))"
- ]
- },
- {
- "cell_type": "code",
- "execution_count": 11,
- "id": "23756537",
- "metadata": {
- "execution": {
- "iopub.execute_input": "2026-07-07T15:40:55.877796Z",
- "iopub.status.busy": "2026-07-07T15:40:55.877548Z",
- "iopub.status.idle": "2026-07-07T15:40:55.901736Z",
- "shell.execute_reply": "2026-07-07T15:40:55.900707Z"
- }
- },
- "outputs": [
{
"name": "stdout",
"output_type": "stream",
@@ -2508,7 +2987,7 @@
" 0 | \n",
" \n",
" \n",
- " | Cross-cutting | \n",
+ " Cross-cutting (PM, governance, testing, integration) | \n",
" 1,400 | \n",
" 315,000 | \n",
" 178,711 | \n",
@@ -2576,17 +3055,29 @@
""
],
"text/plain": [
- " hours cost 2026 2027 2028\n",
- "workstream \n",
- "Agent Copilot 1,500 337,500 207,888 129,612 0\n",
- "Cross-cutting 1,400 315,000 178,711 126,366 9,923\n",
- "Email Auto-Respond 1,100 247,500 169,530 70,174 7,796\n",
- "KB readiness (prerequisite) 1,000 225,000 127,651 90,261 7,088\n",
- "Predictive Routing 550 123,750 70,208 49,644 3,898\n",
- "STA 1,000 225,000 127,651 90,261 7,088\n",
- "Supervisor Copilot 300 67,500 41,578 25,922 0\n",
- "Steady-state tuning (2027-28) 1,400 315,000 0 157,500 157,500\n",
- "TOTAL 8,250 1,856,250 923,217 739,741 193,293"
+ " hours cost 2026 \\\n",
+ "workstream \n",
+ "Agent Copilot 1,500 337,500 207,888 \n",
+ "Cross-cutting (PM, governance, testing, integra... 1,400 315,000 178,711 \n",
+ "Email Auto-Respond 1,100 247,500 169,530 \n",
+ "KB readiness (prerequisite) 1,000 225,000 127,651 \n",
+ "Predictive Routing 550 123,750 70,208 \n",
+ "STA 1,000 225,000 127,651 \n",
+ "Supervisor Copilot 300 67,500 41,578 \n",
+ "Steady-state tuning (2027-28) 1,400 315,000 0 \n",
+ "TOTAL 8,250 1,856,250 923,217 \n",
+ "\n",
+ " 2027 2028 \n",
+ "workstream \n",
+ "Agent Copilot 129,612 0 \n",
+ "Cross-cutting (PM, governance, testing, integra... 126,366 9,923 \n",
+ "Email Auto-Respond 70,174 7,796 \n",
+ "KB readiness (prerequisite) 90,261 7,088 \n",
+ "Predictive Routing 49,644 3,898 \n",
+ "STA 90,261 7,088 \n",
+ "Supervisor Copilot 25,922 0 \n",
+ "Steady-state tuning (2027-28) 157,500 157,500 \n",
+ "TOTAL 739,741 193,293 "
]
},
"metadata": {},
@@ -2596,6 +3087,14 @@
"source": [
"# ── V2 hours-range × rate model (tokencalc.appendix4.build_impl_costs) ─\n",
"# Swap point for the future activity-level LoE engine.\n",
+ "_regions = impl_feature_regions(COPILOT_INCLUDES_ASIA)\n",
+ "display(pd.DataFrame(\n",
+ " [{\"workstream\": f, \"regions\": \", \".join(_regions.get(f, REGIONS)),\n",
+ " \"low h\": lo, \"high h\": hi}\n",
+ " for f, (lo, hi) in {**AI_IMPL_HOURS,\n",
+ " \"KB readiness (prerequisite)\": KB_READINESS_HOURS,\n",
+ " \"Steady-state (h/yr, 2027-28)\": STEADY_STATE_HOURS}.items()]))\n",
+ "\n",
"impl_detail, impl_by_year, kb_by_year, steady_by_year = build_impl_costs(\n",
" sites, HOURS_MODE, BLENDED_RATE, INCLUDE_KB_READINESS,\n",
" COPILOT_INCLUDES_ASIA, NA_EMAIL_EARLY)\n",
@@ -2614,14 +3113,14 @@
},
{
"cell_type": "code",
- "execution_count": 12,
+ "execution_count": 14,
"id": "1ffaa74a",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:55.904200Z",
- "iopub.status.busy": "2026-07-07T15:40:55.903948Z",
- "iopub.status.idle": "2026-07-07T15:40:55.909345Z",
- "shell.execute_reply": "2026-07-07T15:40:55.908669Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.643451Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.643249Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.647670Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.647072Z"
}
},
"outputs": [
@@ -2633,7 +3132,7 @@
"Industry benchmark: 20-40% of the Y1 benefit claim goes to implementation.\n",
"Reading: V2 deliberately strips vendor-services inflation from V1 (which sat at ~31%).\n",
"If the corrected case still looks good at V2 hours, it is robust to the higher estimate;\n",
- "§9 sweeps hours × rate to show exactly how much the conclusion depends on this line.\n"
+ "Section 9 sweeps hours × rate to show exactly how much the conclusion depends on this line.\n"
]
}
],
@@ -2648,7 +3147,7 @@
"print(\"Industry benchmark: 20-40% of the Y1 benefit claim goes to implementation.\")\n",
"print(\"Reading: V2 deliberately strips vendor-services inflation from V1 (which sat at ~31%).\")\n",
"print(\"If the corrected case still looks good at V2 hours, it is robust to the higher estimate;\")\n",
- "print(\"§9 sweeps hours × rate to show exactly how much the conclusion depends on this line.\")"
+ "print(\"Section 9 sweeps hours × rate to show exactly how much the conclusion depends on this line.\")"
]
},
{
@@ -2656,7 +3155,9 @@
"id": "b958f93d",
"metadata": {},
"source": [
- "## §5 · Corrected cost stack\n",
+ "\n",
+ "\n",
+ "## 5 · Corrected cost stack\n",
"\n",
"The pitched case vs the same case with the three missing costs added — and one *credit* the deck\n",
"also missed: the 12-month ramp means licences don't actually bill in 2026. Double-billing is\n",
@@ -2665,14 +3166,14 @@
},
{
"cell_type": "code",
- "execution_count": 13,
+ "execution_count": 15,
"id": "ad880bda",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:55.911950Z",
- "iopub.status.busy": "2026-07-07T15:40:55.911733Z",
- "iopub.status.idle": "2026-07-07T15:40:55.936700Z",
- "shell.execute_reply": "2026-07-07T15:40:55.935983Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.649256Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.649076Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.672079Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.671297Z"
}
},
"outputs": [
@@ -2728,10 +3229,10 @@
"
\n",
" \n",
" | Existing platform (term-contract run-off) | \n",
- " 7,300,000 | \n",
- " 7,300,000 | \n",
+ " 7,300,001 | \n",
+ " 7,300,001 | \n",
" 0 | \n",
- " 14,600,000 | \n",
+ " 14,600,002 | \n",
"
\n",
" \n",
" | AI token consumption | \n",
@@ -2756,10 +3257,10 @@
"
\n",
" \n",
" | TOTAL — corrected | \n",
- " 10,790,217 | \n",
- " 13,515,924 | \n",
+ " 10,790,218 | \n",
+ " 13,515,925 | \n",
" 7,838,449 | \n",
- " 32,144,589 | \n",
+ " 32,144,591 | \n",
"
\n",
" \n",
"\n",
@@ -2769,20 +3270,20 @@
" 2026 2027 2028 \\\n",
"CCaaS platform licences (ramp-adjusted) 0 4,300,000 4,300,000 \n",
"Base professional services + training 2,567,000 0 0 \n",
- "Existing platform (term-contract run-off) 7,300,000 7,300,000 0 \n",
+ "Existing platform (term-contract run-off) 7,300,001 7,300,001 0 \n",
"AI token consumption 0 1,176,183 3,345,156 \n",
"AI implementation + KB readiness 923,217 582,241 35,793 \n",
"AI steady-state tuning 0 157,500 157,500 \n",
- "TOTAL — corrected 10,790,217 13,515,924 7,838,449 \n",
+ "TOTAL — corrected 10,790,218 13,515,925 7,838,449 \n",
"\n",
" 3-yr \n",
"CCaaS platform licences (ramp-adjusted) 8,600,000 \n",
"Base professional services + training 2,567,000 \n",
- "Existing platform (term-contract run-off) 14,600,000 \n",
+ "Existing platform (term-contract run-off) 14,600,002 \n",
"AI token consumption 4,521,339 \n",
"AI implementation + KB readiness 1,541,250 \n",
"AI steady-state tuning 315,000 \n",
- "TOTAL — corrected 32,144,589 "
+ "TOTAL — corrected 32,144,591 "
]
},
"metadata": {},
@@ -2896,19 +3397,21 @@
"id": "be744ec4",
"metadata": {},
"source": [
- "## §6 · Figure 1 — Benefits over 3 years (verbatim Genesys)"
+ "\n",
+ "\n",
+ "## 6 · Figure 1 — Benefits over 3 years (verbatim Genesys)"
]
},
{
"cell_type": "code",
- "execution_count": 14,
+ "execution_count": 16,
"id": "2e2b207c",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:55.938459Z",
- "iopub.status.busy": "2026-07-07T15:40:55.938278Z",
- "iopub.status.idle": "2026-07-07T15:40:56.023062Z",
- "shell.execute_reply": "2026-07-07T15:40:56.022206Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.674803Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.674580Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.754410Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.753608Z"
}
},
"outputs": [
@@ -3989,7 +4492,9 @@
"id": "95e3aa5a",
"metadata": {},
"source": [
- "## §7 · Figure 2 — Costs over 3 years, corrected\n",
+ "\n",
+ "\n",
+ "## 7 · Figure 2 — Costs over 3 years, corrected\n",
"\n",
"The stack is the full programme cost. The dashed grey line is the deck's cumulative cost case\n",
"($15.4M) for reference — the gap between the lines is what the pitch left out (net of the ramp\n",
@@ -3998,14 +4503,14 @@
},
{
"cell_type": "code",
- "execution_count": 15,
+ "execution_count": 17,
"id": "c2c9418f",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:56.024916Z",
- "iopub.status.busy": "2026-07-07T15:40:56.024780Z",
- "iopub.status.idle": "2026-07-07T15:40:56.065404Z",
- "shell.execute_reply": "2026-07-07T15:40:56.064597Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.759627Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.759410Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.805032Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.804216Z"
}
},
"outputs": [
@@ -4077,8 +4582,8 @@
"2028"
],
"y": [
- 7300000.0,
- 7300000.0,
+ 7300001.0,
+ 7300001.0,
0.0
]
},
@@ -4170,7 +4675,7 @@
"2028"
],
"y": {
- "bdata": "/1aBFqmUZEHYbpbHHS53QQAAANDMp35B",
+ "bdata": "/1aBNqmUZEHYbpbnHS53QQAAAPDMp35B",
"dtype": "f8"
}
},
@@ -4212,7 +4717,7 @@
"showarrow": false,
"text": "$10.8M",
"x": 0,
- "y": 10790216.703288553,
+ "y": 10790217.703288553,
"yshift": 12
},
{
@@ -4223,7 +4728,7 @@
"showarrow": false,
"text": "$13.5M",
"x": 1,
- "y": 13515923.770938251,
+ "y": 13515924.770938251,
"yshift": 12
},
{
@@ -4246,7 +4751,7 @@
"text": "3-yr $32.1M — $16.7M above the pitch",
"x": 2,
"xshift": -70,
- "y": 32144589.0,
+ "y": 32144591.0,
"yshift": 18
}
],
@@ -5112,14 +5617,14 @@
},
{
"cell_type": "code",
- "execution_count": 16,
+ "execution_count": 18,
"id": "82335f50",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:56.068585Z",
- "iopub.status.busy": "2026-07-07T15:40:56.068410Z",
- "iopub.status.idle": "2026-07-07T15:40:56.206163Z",
- "shell.execute_reply": "2026-07-07T15:40:56.205208Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.808240Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.808054Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.835729Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.834592Z"
}
},
"outputs": [
@@ -5169,8 +5674,8 @@
"2028"
],
"y": [
- 10790216.703288553,
- 13515923.770938251,
+ 10790217.703288553,
+ 13515924.770938251,
7838448.525773196
]
}
@@ -5186,7 +5691,7 @@
"text": "+$3.9M",
"x": 0,
"xshift": 16,
- "y": 10790216.703288553,
+ "y": 10790217.703288553,
"yshift": 12
},
{
@@ -5198,7 +5703,7 @@
"text": "+$9.2M",
"x": 1,
"xshift": 16,
- "y": 13515923.770938251,
+ "y": 13515924.770938251,
"yshift": 12
},
{
@@ -6071,7 +6576,9 @@
"id": "bde5fbf2",
"metadata": {},
"source": [
- "## §8 · Figure 3 — Overall business case / ROI\n",
+ "\n",
+ "\n",
+ "## 8 · Figure 3 — Overall business case / ROI\n",
"\n",
"**Frame:** baseline-relative, against *do nothing* (keep paying $7.3M/yr).\n",
"Incremental cost = programme cost − $7.3M baseline; net = verbatim benefits − incremental cost.\n",
@@ -6079,19 +6586,60 @@
"**cost-avoidance credit** once the old contracts terminate — no separate \"savings\" line needed.\n",
"The deck's implicit frame is the same, minus the three missing costs.\n",
"\n",
- "*Not modelled: any early-termination fees, and migration costs beyond the PS/impl lines (§11).*"
+ "*Not modelled: any early-termination fees, and migration costs beyond the PS/impl lines (section 11).*"
]
},
{
"cell_type": "code",
- "execution_count": 17,
+ "execution_count": 19,
+ "id": "de7679d8",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-07T20:49:40.838365Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.838139Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.846661Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.845907Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "application/mercury+json": {
+ "model_id": "5862e4cca2334188b348156fadee8558",
+ "position": "sidebar",
+ "widget": "SelectWidget"
+ },
+ "application/vnd.jupyter.widget-view+json": {
+ "model_id": "5862e4cca2334188b348156fadee8558",
+ "version_major": 2,
+ "version_minor": 1
+ },
+ "text/plain": [
+ ""
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# ── NPV rate (Mercury sidebar — widgets only, no output) ────────────\n",
+ "_rate_pick = mr.Select(label=\"NPV discount rate\", value=\"13.5% (deck)\",\n",
+ " choices=[\"13.5% (deck)\", \"8.0% (CTM treasury)\"]).value\n",
+ "DISCOUNT_RATE = (TCO_VERBATIM[\"npv_discount_rate\"]\n",
+ " if _rate_pick.startswith(\"13.5\") else 0.08)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
"id": "08433116",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:56.209075Z",
- "iopub.status.busy": "2026-07-07T15:40:56.208829Z",
- "iopub.status.idle": "2026-07-07T15:40:56.215238Z",
- "shell.execute_reply": "2026-07-07T15:40:56.214438Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.849344Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.849130Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.856103Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.854709Z"
}
},
"outputs": [
@@ -6123,14 +6671,14 @@
},
{
"cell_type": "code",
- "execution_count": 18,
+ "execution_count": 21,
"id": "04c43658",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:56.217292Z",
- "iopub.status.busy": "2026-07-07T15:40:56.217094Z",
- "iopub.status.idle": "2026-07-07T15:40:56.245223Z",
- "shell.execute_reply": "2026-07-07T15:40:56.244293Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.859501Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.859279Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.896797Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.895369Z"
}
},
"outputs": [
@@ -6180,8 +6728,8 @@
"2028"
],
"y": [
- -3490216.7032885533,
- -6215923.770938251,
+ -3490217.7032885533,
+ -6215924.770938251,
-538448.5257731955
]
},
@@ -6207,7 +6755,7 @@
"2028"
],
"y": {
- "bdata": "/FsFWtSgSsECwbO+bA1bwQAAAMDyTVJB",
+ "bdata": "/FsF2tSgSsECwbM+bQ1bwQAAAEDyTVJB",
"dtype": "f8"
}
}
@@ -6222,7 +6770,7 @@
"showarrow": false,
"text": "-$3.5M",
"x": 0,
- "y": -3490216.7032885533,
+ "y": -3490217.7032885533,
"yshift": -14
},
{
@@ -6233,7 +6781,7 @@
"showarrow": false,
"text": "-$7.1M",
"x": 1,
- "y": -7091634.97972131,
+ "y": -7091636.97972131,
"yshift": -14
},
{
@@ -6244,7 +6792,7 @@
"showarrow": false,
"text": "$4.8M",
"x": 2,
- "y": 4798411.0,
+ "y": 4798409.0,
"yshift": 14
},
{
@@ -7127,14 +7675,14 @@
},
{
"cell_type": "code",
- "execution_count": 19,
+ "execution_count": 22,
"id": "a8c9584e",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:56.247631Z",
- "iopub.status.busy": "2026-07-07T15:40:56.247492Z",
- "iopub.status.idle": "2026-07-07T15:40:56.256445Z",
- "shell.execute_reply": "2026-07-07T15:40:56.255875Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.902301Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.902043Z",
+ "iopub.status.idle": "2026-07-07T20:49:40.915235Z",
+ "shell.execute_reply": "2026-07-07T20:49:40.914543Z"
}
},
"outputs": [
@@ -7251,7 +7799,9 @@
"id": "e7d54d0d",
"metadata": {},
"source": [
- "## §9 · Sensitivity\n",
+ "\n",
+ "\n",
+ "## 9 · Sensitivity\n",
"\n",
"The two weakest inputs, swept: the **Email Auto-Respond token rate** (🔴 unpublished — the working\n",
"assumption is the only estimated meter in the token stack) and the **AI implementation LoE**\n",
@@ -7260,14 +7810,14 @@
},
{
"cell_type": "code",
- "execution_count": 20,
+ "execution_count": 23,
"id": "6671014d",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:56.258411Z",
- "iopub.status.busy": "2026-07-07T15:40:56.258284Z",
- "iopub.status.idle": "2026-07-07T15:40:56.336874Z",
- "shell.execute_reply": "2026-07-07T15:40:56.336009Z"
+ "iopub.execute_input": "2026-07-07T20:49:40.917246Z",
+ "iopub.status.busy": "2026-07-07T20:49:40.917047Z",
+ "iopub.status.idle": "2026-07-07T20:49:41.011793Z",
+ "shell.execute_reply": "2026-07-07T20:49:41.010772Z"
}
},
"outputs": [
@@ -8334,14 +8884,14 @@
},
{
"cell_type": "code",
- "execution_count": 21,
+ "execution_count": 24,
"id": "faa1e95f",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:56.339516Z",
- "iopub.status.busy": "2026-07-07T15:40:56.339332Z",
- "iopub.status.idle": "2026-07-07T15:40:56.368102Z",
- "shell.execute_reply": "2026-07-07T15:40:56.367009Z"
+ "iopub.execute_input": "2026-07-07T20:49:41.014456Z",
+ "iopub.status.busy": "2026-07-07T20:49:41.014264Z",
+ "iopub.status.idle": "2026-07-07T20:49:41.048942Z",
+ "shell.execute_reply": "2026-07-07T20:49:41.048051Z"
}
},
"outputs": [
@@ -8381,21 +8931,21 @@
" \n",
" \n",
" | low hours | \n",
- " 5,622,161 | \n",
- " 5,327,161 | \n",
- " 5,032,161 | \n",
+ " 5,622,159 | \n",
+ " 5,327,159 | \n",
+ " 5,032,159 | \n",
"
\n",
" \n",
" | mid hours | \n",
- " 5,210,911 | \n",
- " 4,798,411 | \n",
- " 4,385,911 | \n",
+ " 5,210,909 | \n",
+ " 4,798,409 | \n",
+ " 4,385,909 | \n",
"
\n",
" \n",
" | high hours | \n",
- " 4,799,661 | \n",
- " 4,269,661 | \n",
- " 3,739,661 | \n",
+ " 4,799,659 | \n",
+ " 4,269,659 | \n",
+ " 3,739,659 | \n",
"
\n",
" \n",
"\n",
@@ -8403,9 +8953,9 @@
],
"text/plain": [
" $175/h $225/h $275/h\n",
- "low hours 5,622,161 5,327,161 5,032,161\n",
- "mid hours 5,210,911 4,798,411 4,385,911\n",
- "high hours 4,799,661 4,269,661 3,739,661"
+ "low hours 5,622,159 5,327,159 5,032,159\n",
+ "mid hours 5,210,909 4,798,409 4,385,909\n",
+ "high hours 4,799,659 4,269,659 3,739,659"
]
},
"metadata": {},
@@ -8447,7 +8997,9 @@
"id": "46cd8894",
"metadata": {},
"source": [
- "## §10 · Verification & assertions\n",
+ "\n",
+ "\n",
+ "## 10 · Verification & assertions\n",
"\n",
"The next cell re-derives key numbers independently and **raises on any failure**, so\n",
"`jupyter nbconvert --execute` acts as a regression gate for this notebook. Config-dependent\n",
@@ -8456,14 +9008,14 @@
},
{
"cell_type": "code",
- "execution_count": 22,
+ "execution_count": 25,
"id": "6516ea96",
"metadata": {
"execution": {
- "iopub.execute_input": "2026-07-07T15:40:56.370749Z",
- "iopub.status.busy": "2026-07-07T15:40:56.370411Z",
- "iopub.status.idle": "2026-07-07T15:40:56.403374Z",
- "shell.execute_reply": "2026-07-07T15:40:56.402523Z"
+ "iopub.execute_input": "2026-07-07T20:49:41.051044Z",
+ "iopub.status.busy": "2026-07-07T20:49:41.050862Z",
+ "iopub.status.idle": "2026-07-07T20:49:41.095258Z",
+ "shell.execute_reply": "2026-07-07T20:49:41.094268Z"
}
},
"outputs": [
@@ -8481,16 +9033,24 @@
" assert abs(got - want) <= tol, f\"got {got:,.2f}, want {want:,.2f}\"\n",
"\n",
"\n",
- "# §1 — contract mechanics\n",
- "_approx(current_state[\"annual_cost\"].sum(), 7_300_000)\n",
- "if RAMP_MONTHS == 12:\n",
- " _approx(licence_by_year[2026], 0)\n",
- " _approx(licence_by_year[2027], 4_300_000)\n",
- "if all(row[\"contract_termination\"] == dt.date(2027, 12, 31) for _, row in current_state.iterrows()):\n",
- " _approx(current_by_year[2026], 7_300_000)\n",
+ "# Section 1 — contract mechanics. Engine pins use explicit default arguments so the\n",
+ "# gate tests tokencalc.appendix4, not the current widget state; live-state\n",
+ "# checks only run when the inputs sit at their defaults.\n",
+ "_seed_check = current_state_inputs(sites)\n",
+ "_approx(_seed_check[\"annual_cost\"].sum(), 7_300_000)\n",
+ "_l12 = licence_costs_by_year(12)\n",
+ "_approx(_l12[2026], 0)\n",
+ "_approx(_l12[2027], 4_300_000)\n",
+ "_contracts_at_default = (\n",
+ " (current_state[\"annual_cost\"] - _seed_check[\"annual_cost\"]).abs().max() < 1\n",
+ " and all(t == dt.date(2027, 12, 31) for t in current_state[\"contract_termination\"])\n",
+ ")\n",
+ "if _contracts_at_default:\n",
+ " # widget seeds are whole dollars → per-region rounding of ≤ $0.50\n",
+ " _approx(current_by_year[2026], 7_300_000, tol=len(REGIONS))\n",
" _approx(current_by_year[2028], 0)\n",
"\n",
- "# §2 — verbatim benefits reproduce the deck\n",
+ "# Section 2 — verbatim benefits reproduce the deck\n",
"_approx(benefit_total_by_year[2026], 0)\n",
"_approx(sum(benefit_total_by_year.values()), verbatim[\"three_yr\"].sum())\n",
"assert abs(sum(benefit_total_by_year.values()) - SLIDE_TOTALS[\"total_3yr\"]) <= _tol(15e6)\n",
@@ -8498,7 +9058,7 @@
" _approx(benefits_long.query(\"region == @r\")[\"benefit\"].sum(),\n",
" verbatim.query(\"region == @r\")[\"three_yr\"].sum())\n",
"\n",
- "# §3 — token hand-checks (independent derivations)\n",
+ "# Section 3 — token hand-checks (independent derivations)\n",
"sta = tokens_long.query(\"cost_line == 'Speech & Text Analytics [named]'\")\n",
"_named = {s.site_name: s.named_users for s in sites}\n",
"_sta_2028 = sum(_named[n] * 30 * TOKEN_ROLLOUT.live_months_in_year(n, 3) for n in ALL_SITES)\n",
@@ -8512,14 +9072,13 @@
"assert math.ceil(1_214_358 * DEFAULT_METERS[\"Predictive Routing\"].tokens_per_unit) == 71_433\n",
"_approx(tokens_long.query(\"year == 2026\")[\"annual_cost\"].sum(), 0) # nothing live in 2026\n",
"\n",
- "# §4 — impl model reconciles with the V2 doc\n",
- "if HOURS_MODE == \"mid\" and BLENDED_RATE == 225:\n",
- " _approx(sum(impl_by_year.values()), 5_850 * 225) # $1,316,250 — V2's \"$1.3M\"\n",
- " _approx(sum(steady_by_year.values()), 700 * 225 * 2)\n",
- " if INCLUDE_KB_READINESS:\n",
- " _approx(sum(kb_by_year.values()), 1_000 * 225)\n",
+ "# Section 4 — impl model reconciles with the V2 doc (explicit default args)\n",
+ "_, _impl_p, _kb_p, _steady_p = build_impl_costs(sites, \"mid\", 225, True, False, True)\n",
+ "_approx(sum(_impl_p.values()), 5_850 * 225) # $1,316,250 — V2's \"$1.3M\"\n",
+ "_approx(sum(_kb_p.values()), 1_000 * 225)\n",
+ "_approx(sum(_steady_p.values()), 700 * 225 * 2)\n",
"\n",
- "# §5 — cost stacks\n",
+ "# Section 5 — cost stacks\n",
"_approx(sum(pitched_total_by_year.values()),\n",
" 3 * TCO_VERBATIM[\"ccaas_annual\"] + ps_by_year[2026]) # ≈ deck's $15.4M\n",
"_approx(sum(corrected_total_by_year.values()),\n",
@@ -8527,7 +9086,7 @@
" + sum(token_total_by_year.values()) + sum(impl_kb_by_year.values())\n",
" + sum(steady_by_year.values()))\n",
"\n",
- "# §8 — flows tie out\n",
+ "# Section 8 — flows tie out\n",
"for y in YEARS:\n",
" _approx(net_corrected[y],\n",
" benefit_total_by_year[y] - (corrected_total_by_year[y] - BASELINE_ANNUAL))\n",
@@ -8543,7 +9102,9 @@
"id": "8275984f",
"metadata": {},
"source": [
- "## §11 · Risks, gaps & next steps\n",
+ "\n",
+ "\n",
+ "## 11 · Risks, gaps & next steps\n",
"\n",
"**Findings to lead with**\n",
"- **Predictive Routing is net-negative standalone at claimed scope:** ~$1.8M/yr in tokens at 100%\n",
@@ -8557,22 +9118,289 @@
"**Known gaps / data wanted**\n",
"- Non-NAM site volumes & AHTs are `tokencalc` placeholders (🟡) — all non-NA token figures inherit\n",
" that. Regional current-cost split is an agent-share allocation pending real contract data.\n",
- "- Email Auto-Respond token rate is unpublished (🔴) — §9 bounds it; even the worst corner is small\n",
+ "- Email Auto-Respond token rate is unpublished (🔴) — section 9 bounds it; even the worst corner is small\n",
" relative to the Email benefit claim.\n",
"- Early-termination fees, co-term options, and migration costs beyond PS/impl are not modelled.\n",
"- Contract termination default (31 Dec 2027) zeroes current costs in 2028; if any region's term\n",
- " runs longer, 2028 worsens — edit the per-region dates in §1.\n",
- "- The smell test (§4) flags V2 implementation hours as below the 20–40% industry band — V2\n",
- " deliberately strips vendor inflation; §9 shows the conclusion is robust across the whole\n",
+ " runs longer, 2028 worsens — edit the per-region dates in section 1.\n",
+ "- The smell test (section 4) flags V2 implementation hours as below the 20–40% industry band — V2\n",
+ " deliberately strips vendor inflation; section 9 shows the conclusion is robust across the whole\n",
" V2 range × rate grid.\n",
"- Deck internal rounding: cell-level benefits cross-foot to $15.04M vs the $15.0M headline\n",
" (and $13.72M vs $13.6M annual); tolerated, not \"fixed\".\n",
"\n",
"**Next steps**\n",
- "- Replace §4's hours-range model with the activity-level `ImplementationEffort` engine sketched in\n",
+ "- Replace section 4's hours-range model with the activity-level `ImplementationEffort` engine sketched in\n",
" `docs/ctm_ai_labour_estimate.md` (dataclasses + `tokencalc/implementation.py` + tests).\n",
"- Confirm contracted token rates and regional pricing (EU/AU/APAC flagged TBD at $1.00 list).\n",
- "- Feed real per-region current-platform contract values and termination dates into §1."
+ "- Feed real per-region current-platform contract values and termination dates into section 1."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8ce1bad2",
+ "metadata": {},
+ "source": [
+ "\n",
+ "\n",
+ "## 12 · Machine-readable data appendix\n",
+ "\n",
+ "Every number behind the figures above, emitted as markdown tables plus a JSON block, so an\n",
+ "LLM can draft the client report or presentation directly from the HTML/markdown export\n",
+ "(`python scripts/export_report.py`). Plotly figures export as JavaScript an LLM cannot\n",
+ "read — this section carries the data. The tables reflect the **current** input state, so a\n",
+ "client-customized session exports a client-customized appendix.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 26,
+ "id": "c4c025b5",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-07-07T20:49:41.097634Z",
+ "iopub.status.busy": "2026-07-07T20:49:41.097469Z",
+ "iopub.status.idle": "2026-07-07T20:49:41.128153Z",
+ "shell.execute_reply": "2026-07-07T20:49:41.127202Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "#### Current-state contracts by region (inputs)\n",
+ "\n",
+ "| region | agents | annual_cost | contract_termination | confidence |\n",
+ "|:---------|---------:|--------------:|:-----------------------|:------------------------------------------------|\n",
+ "| NA | 890 | 3,348,969 | 2027-12-31 | 🟡 agent-share allocation of the verbatim total |\n",
+ "| ANZ | 180 | 677,320 | 2027-12-31 | 🟡 agent-share allocation of the verbatim total |\n",
+ "| EMEA | 320 | 1,204,124 | 2027-12-31 | 🟡 agent-share allocation of the verbatim total |\n",
+ "| ASIA | 550 | 2,069,588 | 2027-12-31 | 🟡 agent-share allocation of the verbatim total |\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "#### Verbatim Genesys benefits — capability × region (3-yr $)\n",
+ "\n",
+ "| capability | ANZ | ASIA | EMEA | NA |\n",
+ "|:-------------------|--------:|-------:|-------:|--------:|\n",
+ "| Agent Copilot | 3900000 | 0 | 64000 | 3400000 |\n",
+ "| WFM | 1400000 | 914000 | 687000 | 0 |\n",
+ "| Email | 143000 | 93000 | 235000 | 2500000 |\n",
+ "| STA | 105000 | 72000 | 131000 | 506000 |\n",
+ "| Predictive Routing | 302000 | 51000 | 6000 | 167000 |\n",
+ "| Supervisor Copilot | 27000 | 0 | 49000 | 291000 |\n",
+ "\n",
+ "#### Benefits by capability × year (schedule-phased, $)\n",
+ "\n",
+ "| capability | 2026 | 2027 | 2028 |\n",
+ "|:-------------------|-------:|----------:|----------:|\n",
+ "| Agent Copilot | 0 | 1,150,000 | 6,214,000 |\n",
+ "| WFM | 0 | 107,692 | 2,893,308 |\n",
+ "| Email | 0 | 1,082,429 | 1,888,571 |\n",
+ "| STA | 0 | 134,577 | 679,423 |\n",
+ "| Predictive Routing | 0 | 64,981 | 461,019 |\n",
+ "| Supervisor Copilot | 0 | 74,827 | 292,173 |\n",
+ "\n",
+ "#### AI token cost by meter × year ($)\n",
+ "\n",
+ "| cost_line | 2026 | 2027 | 2028 | 3-yr |\n",
+ "|:----------------------------------------|-------:|----------:|----------:|----------:|\n",
+ "| Predictive Routing | 0 | 583,561 | 1,764,870 | 2,348,431 |\n",
+ "| Agent Copilot [named] | 0 | 311,000 | 715,200 | 1,026,200 |\n",
+ "| Speech & Text Analytics [named] | 0 | 233,250 | 715,800 | 949,050 |\n",
+ "| Email AI (Auto-Respond) | 0 | 46,272 | 87,136 | 133,408 |\n",
+ "| AI Translate | 0 | 2,100 | 62,150 | 64,250 |\n",
+ "| AI Summary & Insights | 0 | 0 | 0 | 0 |\n",
+ "| WFM (no token meter — licence-included) | 0 | 0 | 0 | 0 |\n",
+ "| TOTAL | 0 | 1,176,183 | 3,345,156 | 4,521,339 |\n",
+ "\n",
+ "#### AI implementation by workstream ($, phased)\n",
+ "\n",
+ "| workstream | hours | cost | 2026 | 2027 | 2028 |\n",
+ "|:-----------------------------------------------------|--------:|----------:|--------:|--------:|--------:|\n",
+ "| Agent Copilot | 1,500 | 337,500 | 207,888 | 129,612 | 0 |\n",
+ "| Cross-cutting (PM, governance, testing, integration) | 1,400 | 315,000 | 178,711 | 126,366 | 9,923 |\n",
+ "| Email Auto-Respond | 1,100 | 247,500 | 169,530 | 70,174 | 7,796 |\n",
+ "| KB readiness (prerequisite) | 1,000 | 225,000 | 127,651 | 90,261 | 7,088 |\n",
+ "| Predictive Routing | 550 | 123,750 | 70,208 | 49,644 | 3,898 |\n",
+ "| STA | 1,000 | 225,000 | 127,651 | 90,261 | 7,088 |\n",
+ "| Supervisor Copilot | 300 | 67,500 | 41,578 | 25,922 | 0 |\n",
+ "| Steady-state tuning (2027-28) | 1,400 | 315,000 | 0 | 157,500 | 157,500 |\n",
+ "| TOTAL | 8,250 | 1,856,250 | 923,217 | 739,741 | 193,293 |\n",
+ "\n",
+ "#### Corrected programme cost stack ($)\n",
+ "\n",
+ "| | 2026 | 2027 | 2028 | 3-yr |\n",
+ "|:------------------------------------------|----------:|----------:|----------:|-----------:|\n",
+ "| CCaaS platform licences (ramp-adjusted) | 0 | 4,300,000 | 4,300,000 | 8,600,000 |\n",
+ "| Base professional services + training | 2,567,000 | 0 | 0 | 2,567,000 |\n",
+ "| Existing platform (term-contract run-off) | 7,300,001 | 7,300,001 | 0 | 14,600,002 |\n",
+ "| AI token consumption | 0 | 1,176,183 | 3,345,156 | 4,521,339 |\n",
+ "| AI implementation + KB readiness | 923,217 | 582,241 | 35,793 | 1,541,250 |\n",
+ "| AI steady-state tuning | 0 | 157,500 | 157,500 | 315,000 |\n",
+ "\n",
+ "#### As-pitched cost stack ($)\n",
+ "\n",
+ "| | 2026 | 2027 | 2028 | 3-yr |\n",
+ "|:----------------------------------------------|----------:|----------:|----------:|-----------:|\n",
+ "| CCaaS platform licences (as pitched, no ramp) | 4,300,000 | 4,300,000 | 4,300,000 | 12,900,000 |\n",
+ "| Base professional services + training | 2,567,000 | 0 | 0 | 2,567,000 |\n",
+ "\n",
+ "#### KPIs — as pitched vs corrected\n",
+ "\n",
+ "| | As pitched (deck) | Corrected |\n",
+ "|:-----------------------------|:----------------------|:----------------------|\n",
+ "| 3-yr benefits | $15.0M | $15.0M |\n",
+ "| 3-yr incremental cost | -$6.4M | $10.2M |\n",
+ "| 3-yr net | $21.5M | $4.8M |\n",
+ "| ROI (net ÷ incremental cost) | n/a — net cost saving | 47% |\n",
+ "| NPV @ 13.5% (deck rate) | $15.3M | $2.3M |\n",
+ "| NPV @ 8.0% (CTM treasury) | $17.5M | $3.1M |\n",
+ "| Payback | immediate | 32 months (~Aug 2028) |\n",
+ "\n",
+ "#### Model state (JSON)\n",
+ "\n",
+ "```json\n",
+ "{\n",
+ " \"benefits_by_year\": {\n",
+ " \"2026\": 0,\n",
+ " \"2027\": 2614505,\n",
+ " \"2028\": 12428495\n",
+ " },\n",
+ " \"corrected_cost_by_year\": {\n",
+ " \"2026\": 10790218,\n",
+ " \"2027\": 13515925,\n",
+ " \"2028\": 7838449\n",
+ " },\n",
+ " \"pitched_cost_by_year\": {\n",
+ " \"2026\": 6867000,\n",
+ " \"2027\": 4300000,\n",
+ " \"2028\": 4300000\n",
+ " },\n",
+ " \"net_by_year_corrected\": {\n",
+ " \"2026\": -3490218,\n",
+ " \"2027\": -3601419,\n",
+ " \"2028\": 11890046\n",
+ " },\n",
+ " \"kpis_corrected\": {\n",
+ " \"benefits_3yr\": 15043000,\n",
+ " \"incremental_cost_3yr\": 10244591,\n",
+ " \"net_3yr\": 4798409,\n",
+ " \"roi\": 0.4684,\n",
+ " \"npv\": 2261247,\n",
+ " \"discount_rate\": 0.135,\n",
+ " \"payback\": \"32 months (~Aug 2028)\"\n",
+ " },\n",
+ " \"kpis_pitched\": {\n",
+ " \"benefits_3yr\": 15043000,\n",
+ " \"incremental_cost_3yr\": -6433000,\n",
+ " \"net_3yr\": 21476000,\n",
+ " \"roi\": null,\n",
+ " \"npv\": 15291853,\n",
+ " \"discount_rate\": 0.135,\n",
+ " \"payback\": \"immediate\"\n",
+ " },\n",
+ " \"assumptions\": {\n",
+ " \"ramp_months\": 12,\n",
+ " \"hours_mode\": \"mid\",\n",
+ " \"blended_rate\": 225,\n",
+ " \"include_kb_readiness\": true,\n",
+ " \"copilot_includes_asia\": false,\n",
+ " \"na_email_early\": true,\n",
+ " \"pr_eligibility\": 1.0,\n",
+ " \"email_auto_respond_rate\": 0.255,\n",
+ " \"email_tokens_per_msg\": 0.05,\n",
+ " \"use_contracted_rates\": false,\n",
+ " \"discount_rate\": 0.135,\n",
+ " \"current_state_by_region\": {\n",
+ " \"NA\": {\n",
+ " \"annual_cost\": 3348969,\n",
+ " \"contract_termination\": \"2027-12-31\"\n",
+ " },\n",
+ " \"ANZ\": {\n",
+ " \"annual_cost\": 677320,\n",
+ " \"contract_termination\": \"2027-12-31\"\n",
+ " },\n",
+ " \"EMEA\": {\n",
+ " \"annual_cost\": 1204124,\n",
+ " \"contract_termination\": \"2027-12-31\"\n",
+ " },\n",
+ " \"ASIA\": {\n",
+ " \"annual_cost\": 2069588,\n",
+ " \"contract_termination\": \"2027-12-31\"\n",
+ " }\n",
+ " }\n",
+ " }\n",
+ "}\n",
+ "```\n"
+ ]
+ }
+ ],
+ "source": [
+ "# ── Data appendix — LLM-readable dump of every model output ──────────\n",
+ "import json as _json\n",
+ "\n",
+ "\n",
+ "def _section(title, df, **kw):\n",
+ " print(f\"\\n#### {title}\\n\")\n",
+ " print(df.to_markdown(floatfmt=\",.0f\", **kw))\n",
+ "\n",
+ "\n",
+ "_section(\"Current-state contracts by region (inputs)\",\n",
+ " current_state.reset_index().drop(columns=[\"share\"]), index=False)\n",
+ "_section(\"Verbatim Genesys benefits — capability × region (3-yr $)\",\n",
+ " verbatim.pivot_table(index=\"capability\", columns=\"region\",\n",
+ " values=\"three_yr\", aggfunc=\"sum\").reindex(CAPABILITIES))\n",
+ "_section(\"Benefits by capability × year (schedule-phased, $)\",\n",
+ " benefits_long.pivot_table(index=\"capability\", columns=\"year\",\n",
+ " values=\"benefit\", aggfunc=\"sum\").reindex(CAPABILITIES))\n",
+ "_section(\"AI token cost by meter × year ($)\", tokens_pivot.drop(columns=[\"conf\"]))\n",
+ "_section(\"AI implementation by workstream ($, phased)\", impl_summary)\n",
+ "_section(\"Corrected programme cost stack ($)\", corrected_costs)\n",
+ "_section(\"As-pitched cost stack ($)\", pitched_costs)\n",
+ "_section(\"KPIs — as pitched vs corrected\", kpis_fmt)\n",
+ "\n",
+ "\n",
+ "def _jval(v):\n",
+ " if isinstance(v, float):\n",
+ " return round(v, 4) if abs(v) < 10 else round(v)\n",
+ " return v\n",
+ "\n",
+ "\n",
+ "print(\"\\n#### Model state (JSON)\\n\")\n",
+ "print(\"```json\")\n",
+ "print(_json.dumps({\n",
+ " \"benefits_by_year\": {str(y): round(benefit_total_by_year[y]) for y in YEARS},\n",
+ " \"corrected_cost_by_year\": {str(y): round(corrected_total_by_year[y]) for y in YEARS},\n",
+ " \"pitched_cost_by_year\": {str(y): round(pitched_total_by_year[y]) for y in YEARS},\n",
+ " \"net_by_year_corrected\": {str(y): round(net_corrected[y]) for y in YEARS},\n",
+ " \"kpis_corrected\": {k: _jval(v) for k, v in kpi_corrected.items()},\n",
+ " \"kpis_pitched\": {k: _jval(v) for k, v in kpi_pitched.items()},\n",
+ " \"assumptions\": {\n",
+ " \"ramp_months\": RAMP_MONTHS,\n",
+ " \"hours_mode\": HOURS_MODE,\n",
+ " \"blended_rate\": BLENDED_RATE,\n",
+ " \"include_kb_readiness\": INCLUDE_KB_READINESS,\n",
+ " \"copilot_includes_asia\": COPILOT_INCLUDES_ASIA,\n",
+ " \"na_email_early\": NA_EMAIL_EARLY,\n",
+ " \"pr_eligibility\": PR_ELIGIBILITY,\n",
+ " \"email_auto_respond_rate\": EMAIL_AUTO_RESPOND_RATE,\n",
+ " \"email_tokens_per_msg\": EMAIL_AUTORESPOND_TOKENS_PER_MSG,\n",
+ " \"use_contracted_rates\": USE_CONTRACTED_RATES,\n",
+ " \"discount_rate\": DISCOUNT_RATE,\n",
+ " \"current_state_by_region\": {\n",
+ " r: {\"annual_cost\": round(float(current_state.loc[r, \"annual_cost\"])),\n",
+ " \"contract_termination\": current_state.loc[r, \"contract_termination\"].isoformat()}\n",
+ " for r in REGIONS},\n",
+ " },\n",
+ "}, indent=2))\n",
+ "print(\"```\")\n"
]
}
],
@@ -8593,6 +9421,1766 @@
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.13.7"
+ },
+ "widgets": {
+ "application/vnd.jupyter.widget-state+json": {
+ "state": {
+ "010206a4cbbc44dfb096f2cf38fdcc57": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.number.NumberInputWidget",
+ "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "ASIA — current platform cost ($/yr)",
+ "layout": "IPY_MODEL_8db8c0db25e444218423ddaf789a7f2d",
+ "layout_path": null,
+ "max": 8000000.0,
+ "min": 0.0,
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "step": 10000.0,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 2069588.0
+ }
+ },
+ "016c53e5a5f24aa586282e3d64512f57": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.number.NumberInputWidget",
+ "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "AI Translate eligibility 🟡",
+ "layout": "IPY_MODEL_d59470b46ebd414bad557f9f486d3b40",
+ "layout_path": null,
+ "max": 1.0,
+ "min": 0.0,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "step": 0.01,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 0.01
+ }
+ },
+ "06d8b6b1afdd423482bfd0df7eb5a9aa": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "0964f99d1a1044519f3bfa01263f69ab": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "0a480f63e0d54c109ba867039bb3cc43": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "0b0538c483cf4a6783153739f90bb1b5": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.number.NumberInputWidget",
+ "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "Genesys ramp — licence-free months",
+ "layout": "IPY_MODEL_558acc00845042f0bcd938945fa1c596",
+ "layout_path": null,
+ "max": 24.0,
+ "min": 0.0,
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "step": 1.0,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 12.0
+ }
+ },
+ "0f2134b5c99240118266729a13f0bcdb": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "1a022b6b25d146a7be50e9c06b8245a2": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "2c0cb1d4828244d9ba53a4a451b5717e": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "2ee8949d4dfc410f8537fb93394f2d22": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "380e5cfb888042aa8d496a8c9813c809": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "3ac268955c964c9eb3c36c44efd8567d": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.number.NumberInputWidget",
+ "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "EMEA — current platform cost ($/yr)",
+ "layout": "IPY_MODEL_06d8b6b1afdd423482bfd0df7eb5a9aa",
+ "layout_path": null,
+ "max": 8000000.0,
+ "min": 0.0,
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "step": 10000.0,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 1204124.0
+ }
+ },
+ "4b717193ae34473998db70c7bbeb213e": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.date.DateInputWidget",
+ "_css": "\n .mljar-date-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-date-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-date-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n color: #0f172a;\n box-sizing: border-box;\n line-height: 1.4;\n }\n\n .mljar-date-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-date-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n @media (max-width: 768px) {\n .mljar-date-input {\n min-height: 44px;\n padding: 10px 12px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-date-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-date-label\");\n\n const input = document.createElement(\"input\");\n input.type = \"date\";\n input.classList.add(\"mljar-date-input\");\n\n container.appendChild(topLabel);\n container.appendChild(input);\n el.appendChild(container);\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Date\";\n input.value = model.get(\"value\") || \"\";\n\n const min = model.get(\"min\") || \"\";\n const max = model.get(\"max\") || \"\";\n if (min) input.min = min; else input.removeAttribute(\"min\");\n if (max) input.max = max; else input.removeAttribute(\"max\");\n\n input.disabled = !!model.get(\"disabled\");\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", input.value);\n model.save_changes();\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "ASIA — contract termination",
+ "layout": "IPY_MODEL_66659f5b4f114c8393f2c6ea7d10e4f3",
+ "layout_path": null,
+ "max": "",
+ "min": "",
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": "2027-12-31"
+ }
+ },
+ "53f8e2cfd0374fb19d23a7a12739be3b": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.date.DateInputWidget",
+ "_css": "\n .mljar-date-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-date-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-date-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n color: #0f172a;\n box-sizing: border-box;\n line-height: 1.4;\n }\n\n .mljar-date-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-date-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n @media (max-width: 768px) {\n .mljar-date-input {\n min-height: 44px;\n padding: 10px 12px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-date-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-date-label\");\n\n const input = document.createElement(\"input\");\n input.type = \"date\";\n input.classList.add(\"mljar-date-input\");\n\n container.appendChild(topLabel);\n container.appendChild(input);\n el.appendChild(container);\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Date\";\n input.value = model.get(\"value\") || \"\";\n\n const min = model.get(\"min\") || \"\";\n const max = model.get(\"max\") || \"\";\n if (min) input.min = min; else input.removeAttribute(\"min\");\n if (max) input.max = max; else input.removeAttribute(\"max\");\n\n input.disabled = !!model.get(\"disabled\");\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", input.value);\n model.save_changes();\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "NA — contract termination",
+ "layout": "IPY_MODEL_a6307688a7d44d9cbfbcb2bd951118e3",
+ "layout_path": null,
+ "max": "",
+ "min": "",
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": "2027-12-31"
+ }
+ },
+ "558acc00845042f0bcd938945fa1c596": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "574ab86c4f754bf985f74dbb27ca85c0": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.number.NumberInputWidget",
+ "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "Auto-respond tokens per message 🟡",
+ "layout": "IPY_MODEL_d2e49eccf06d465c853fa8548d636365",
+ "layout_path": null,
+ "max": 0.5,
+ "min": 0.0,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "step": 0.005,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 0.05
+ }
+ },
+ "5862e4cca2334188b348156fadee8558": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.select.SelectWidget",
+ "_css": "\n .mljar-select-container {\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n }\n\n .mljar-select-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: none;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n margin-top: 6px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n container.appendChild(dropdown);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target)) {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n isEditing = false;\n setOpen(false);\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n document.removeEventListener(\"click\", handleDocumentClick);\n };\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "choices": [
+ "13.5% (deck)",
+ "8.0% (CTM treasury)"
+ ],
+ "disabled": false,
+ "hidden": false,
+ "label": "NPV discount rate",
+ "layout": "IPY_MODEL_be078e536b5f4a538d12ba2eb80786b0",
+ "layout_path": null,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": "13.5% (deck)"
+ }
+ },
+ "5a62799d57694cb590c5e65927ee1d6e": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "65f7b3fc0e4a445cbf94e0f9a9b071fd": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.number.NumberInputWidget",
+ "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "NA — current platform cost ($/yr)",
+ "layout": "IPY_MODEL_7b0bce0214be49fab2aede163fe76b61",
+ "layout_path": null,
+ "max": 8000000.0,
+ "min": 0.0,
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "step": 10000.0,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 3348969.0
+ }
+ },
+ "66659f5b4f114c8393f2c6ea7d10e4f3": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "6c8053d24e944c48b01fe4bd1f3ab620": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.slider.SliderWidget",
+ "_css": "\n .mljar-slider-container {\n --mljar-slider-thumb-size: 16px;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 0;\n width: 100%;\n max-width: 100%;\n min-width: 120px;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n font-weight: normal;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-slider-stage {\n position: relative;\n width: 100%;\n max-width: 100%;\n min-width: 0;\n padding-top: 20px;\n overflow: visible;\n box-sizing: border-box;\n }\n\n .mljar-slider-floating-value {\n position: absolute;\n top: 0;\n left: 8px;\n transform: translateX(-50%);\n color: #007bff;\n font-weight: 700;\n font-size: 0.95em;\n line-height: 1;\n text-align: center;\n white-space: nowrap;\n pointer-events: none;\n z-index: 1;\n }\n\n .mljar-slider-top-label {\n margin-bottom: 6px;\n font-weight: 600;\n line-height: 1.2;\n }\n\n .mljar-slider-row {\n position: relative;\n width: 100%;\n max-width: 100%;\n min-width: 0;\n overflow: visible;\n box-sizing: border-box;\n }\n\n .mljar-slider-minmax-row {\n display: flex;\n justify-content: space-between;\n align-items: center;\n width: 100%;\n max-width: 100%;\n min-width: 0;\n margin-top: 6px;\n color: #616673;\n font-size: 0.9em;\n line-height: 1.2;\n box-sizing: border-box;\n }\n\n .mljar-slider-min-label,\n .mljar-slider-max-label {\n color: inherit;\n white-space: nowrap;\n }\n\n .mljar-slider-input {\n display: block;\n width: 100%;\n max-width: 100%;\n min-width: 0;\n background: transparent;\n -webkit-appearance: none;\n appearance: none;\n border: none;\n height: 24px;\n padding: 0;\n margin: 0;\n cursor: pointer;\n box-sizing: border-box;\n }\n\n .mljar-slider-input:focus {\n outline: none;\n }\n\n .mljar-slider-input:focus-visible::-webkit-slider-thumb {\n box-shadow: 0 0 0 3px #e6f2ff;\n }\n .mljar-slider-input:focus-visible::-moz-range-thumb {\n box-shadow: 0 0 0 3px #e6f2ff;\n }\n\n /* Track */\n .mljar-slider-input::-webkit-slider-runnable-track {\n height: 6px;\n background: #e0e0e0;\n border-radius: 6px;\n margin: auto;\n }\n .mljar-slider-input::-moz-range-track {\n height: 6px;\n background: #e0e0e0;\n border-radius: 6px;\n }\n\n /* Thumb */\n .mljar-slider-input::-webkit-slider-thumb {\n -webkit-appearance: none;\n appearance: none;\n width: var(--mljar-slider-thumb-size);\n height: var(--mljar-slider-thumb-size);\n border-radius: 50%;\n background: #007bff;\n cursor: pointer;\n margin-top: -5px;\n transition: transform 0.14s ease, background-color 0.14s ease;\n }\n .mljar-slider-input::-moz-range-thumb {\n width: var(--mljar-slider-thumb-size);\n height: var(--mljar-slider-thumb-size);\n border-radius: 50%;\n background: #007bff;\n cursor: pointer;\n transition: transform 0.14s ease, background-color 0.14s ease;\n }\n\n .mljar-slider-input:disabled {\n cursor: not-allowed;\n opacity: 0.7;\n }\n\n .mljar-slider-input:disabled::-webkit-slider-thumb {\n cursor: not-allowed;\n transform: none;\n }\n\n .mljar-slider-input:disabled::-moz-range-thumb {\n cursor: not-allowed;\n transform: none;\n }\n\n .mljar-slider-input:active::-webkit-slider-thumb {\n transform: scale(1.08);\n background: #007bff;\n }\n .mljar-slider-input:active::-moz-range-thumb {\n transform: scale(1.08);\n background: #007bff;\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-slider-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-slider-top-label\");\n\n const sliderStage = document.createElement(\"div\");\n sliderStage.classList.add(\"mljar-slider-stage\");\n\n const floatingValueLabel = document.createElement(\"div\");\n floatingValueLabel.classList.add(\"mljar-slider-floating-value\");\n\n const sliderRow = document.createElement(\"div\");\n sliderRow.classList.add(\"mljar-slider-row\");\n\n const slider = document.createElement(\"input\");\n slider.type = \"range\";\n slider.classList.add(\"mljar-slider-input\");\n\n const minMaxRow = document.createElement(\"div\");\n minMaxRow.classList.add(\"mljar-slider-minmax-row\");\n\n const minLabel = document.createElement(\"span\");\n minLabel.classList.add(\"mljar-slider-min-label\");\n\n const maxLabel = document.createElement(\"span\");\n maxLabel.classList.add(\"mljar-slider-max-label\");\n\n function positionFloatingValue() {\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const value = Number(model.get(\"value\"));\n const range = max - min;\n const ratio = range <= 0 ? 0 : (value - min) / range;\n const clampedRatio = Math.max(0, Math.min(1, ratio));\n const computed = getComputedStyle(container);\n const thumbSize =\n parseFloat(computed.getPropertyValue(\"--mljar-slider-thumb-size\")) || 16;\n const inputWidth = slider.clientWidth || 0;\n const stageWidth = sliderStage.clientWidth || inputWidth || 0;\n const labelWidth = floatingValueLabel.offsetWidth || 0;\n\n if (inputWidth <= 0 || stageWidth <= 0) {\n return;\n }\n\n const sliderOffsetLeft = slider.offsetLeft || 0;\n const usableWidth = Math.max(0, inputWidth - thumbSize);\n const idealCenter =\n sliderOffsetLeft + thumbSize / 2 + clampedRatio * usableWidth;\n const halfLabel = labelWidth / 2;\n const minCenter = halfLabel;\n const maxCenter = Math.max(halfLabel, stageWidth - halfLabel);\n const finalCenter = Math.min(Math.max(idealCenter, minCenter), maxCenter);\n\n floatingValueLabel.style.left = `${finalCenter}px`;\n }\n\n function syncFromModel() {\n slider.min = model.get(\"min\");\n slider.max = model.get(\"max\");\n slider.value = model.get(\"value\");\n floatingValueLabel.textContent = String(model.get(\"value\"));\n minLabel.textContent = String(model.get(\"min\"));\n maxLabel.textContent = String(model.get(\"max\"));\n\n slider.disabled = !!model.get(\"disabled\");\n topLabel.textContent = model.get(\"label\") || \"Select number\";\n\n // hidden (exists but not visible)\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n positionFloatingValue();\n }\n\n let debounceTimer = null;\n slider.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", Number(slider.value));\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n model.save_changes();\n }, 100);\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n sliderRow.appendChild(slider);\n minMaxRow.appendChild(minLabel);\n minMaxRow.appendChild(maxLabel);\n\n container.appendChild(topLabel);\n sliderStage.appendChild(floatingValueLabel);\n sliderStage.appendChild(sliderRow);\n sliderStage.appendChild(minMaxRow);\n container.appendChild(sliderStage);\n el.appendChild(container);\n\n syncFromModel();\n\n const resizeObserver = new ResizeObserver(() => {\n positionFloatingValue();\n });\n resizeObserver.observe(sliderStage);\n\n return () => {\n resizeObserver.disconnect();\n };\n\n // ---- read cell id (no DOM modifications) ----\n /*const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "Predictive Routing eligibility (%)",
+ "layout": "IPY_MODEL_a3781a09d1bb48b1a3ce727fb1f5caac",
+ "layout_path": null,
+ "max": 100,
+ "min": 0,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 100
+ }
+ },
+ "7995543f1936426b9f4c957c9efaa30a": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "7b0bce0214be49fab2aede163fe76b61": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "812c1a012db540ba98c297af3e6bfa4a": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.checkbox.CheckboxWidget",
+ "_css": "\n .mljar-checkbox-container {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n cursor: pointer;\n user-select: none;\n -webkit-tap-highlight-color: transparent;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n color: #0f172a;\n padding-left: 5px;\n }\n .mljar-checkbox-container.is-disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n .mljar-checkbox-input {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n .mljar-checkbox-input:focus-visible + .mljar-checkbox-control {\n box-shadow: inset 0 0 0 2px #007bff;\n }\n\n .mljar-checkbox-label {\n font-size: 14px;\n line-height: 1.2;\n padding-top: 2px;\n }\n\n /* --- Toggle style (15% smaller) --- */\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control {\n position: relative;\n width: 34px;\n height: 19px;\n border-radius: 999px;\n background: #f1f1f2;\n border: 1px solid #cfd1d5;\n transition: background 150ms ease, border-color 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control {\n background: #007bff;\n border-color: #007bff;\n }\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 15px;\n height: 15px;\n border-radius: 50%;\n background: #ffffff;\n box-shadow: 0 1px 2px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);\n transition: transform 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control::after {\n transform: translateX(15px);\n }\n\n /* --- Classic box style --- */\n .mljar-checkbox-container.is-box .mljar-checkbox-control {\n width: 16px;\n height: 16px;\n border-radius: 4px;\n border: 1px solid #cfd1d5;\n background: #ffffff;\n display: inline-block;\n position: relative;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control {\n border-color: #007bff;\n background: #007bff;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n left: 4px;\n top: 0px;\n width: 5px;\n height: 10px;\n border: solid #fff;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n }\n\n .mljar-checkbox-container:not(.is-disabled):hover .mljar-checkbox-control {\n border-color: #007bff;\n background: #f3f3f4;\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"label\");\n container.classList.add(\"mljar-checkbox-container\");\n\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.classList.add(\"mljar-checkbox-input\");\n\n const control = document.createElement(\"span\");\n control.classList.add(\"mljar-checkbox-control\");\n\n const text = document.createElement(\"span\");\n text.classList.add(\"mljar-checkbox-label\");\n\n container.appendChild(input);\n container.appendChild(control);\n container.appendChild(text);\n el.appendChild(container);\n\n function syncFromModel() {\n // appearance\n const a = model.get(\"appearance\") || \"toggle\";\n container.classList.remove(\"is-toggle\",\"is-box\");\n container.classList.add(`is-${a}`);\n input.setAttribute(\"role\", a === \"toggle\" ? \"switch\" : \"checkbox\");\n\n // value\n const v = !!model.get(\"value\");\n if (input.checked !== v) {\n input.checked = v;\n input.setAttribute(\"aria-checked\", String(v));\n }\n container.classList.toggle(\"is-checked\", v);\n\n // disabled\n const d = !!model.get(\"disabled\");\n input.disabled = d;\n container.classList.toggle(\"is-disabled\", d);\n\n // label\n text.textContent = model.get(\"label\") || \"\";\n\n // hidden (exists but not visible)\n container.style.display = model.get(\"hidden\") ? \"none\" : \"inline-flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n\n const v = input.checked;\n if (model.get(\"value\") !== v) {\n model.set(\"value\", v);\n model.set(\"last_changed_at\", new Date().toISOString());\n model.set(\"n_toggles\", (model.get(\"n_toggles\") || 0) + 1);\n model.save_changes();\n // model.send({ type: \"changed\", value: v });\n }\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:appearance\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n \n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }\n */\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "appearance": "toggle",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "Copilot includes ASIA sites",
+ "last_changed_at": "",
+ "layout": "IPY_MODEL_380e5cfb888042aa8d496a8c9813c809",
+ "layout_path": null,
+ "n_toggles": 0,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": false
+ }
+ },
+ "847b42fc33c44f81b7eec003b73b2863": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.date.DateInputWidget",
+ "_css": "\n .mljar-date-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-date-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-date-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n color: #0f172a;\n box-sizing: border-box;\n line-height: 1.4;\n }\n\n .mljar-date-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-date-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n @media (max-width: 768px) {\n .mljar-date-input {\n min-height: 44px;\n padding: 10px 12px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-date-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-date-label\");\n\n const input = document.createElement(\"input\");\n input.type = \"date\";\n input.classList.add(\"mljar-date-input\");\n\n container.appendChild(topLabel);\n container.appendChild(input);\n el.appendChild(container);\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Date\";\n input.value = model.get(\"value\") || \"\";\n\n const min = model.get(\"min\") || \"\";\n const max = model.get(\"max\") || \"\";\n if (min) input.min = min; else input.removeAttribute(\"min\");\n if (max) input.max = max; else input.removeAttribute(\"max\");\n\n input.disabled = !!model.get(\"disabled\");\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", input.value);\n model.save_changes();\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "ANZ — contract termination",
+ "layout": "IPY_MODEL_1a022b6b25d146a7be50e9c06b8245a2",
+ "layout_path": null,
+ "max": "",
+ "min": "",
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": "2027-12-31"
+ }
+ },
+ "8bd7c31269154630b34671b9bf271ca9": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.date.DateInputWidget",
+ "_css": "\n .mljar-date-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-date-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-date-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n color: #0f172a;\n box-sizing: border-box;\n line-height: 1.4;\n }\n\n .mljar-date-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-date-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n @media (max-width: 768px) {\n .mljar-date-input {\n min-height: 44px;\n padding: 10px 12px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-date-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-date-label\");\n\n const input = document.createElement(\"input\");\n input.type = \"date\";\n input.classList.add(\"mljar-date-input\");\n\n container.appendChild(topLabel);\n container.appendChild(input);\n el.appendChild(container);\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Date\";\n input.value = model.get(\"value\") || \"\";\n\n const min = model.get(\"min\") || \"\";\n const max = model.get(\"max\") || \"\";\n if (min) input.min = min; else input.removeAttribute(\"min\");\n if (max) input.max = max; else input.removeAttribute(\"max\");\n\n input.disabled = !!model.get(\"disabled\");\n container.style.display = model.get(\"hidden\") ? \"none\" : \"flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n model.set(\"value\", input.value);\n model.save_changes();\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "EMEA — contract termination",
+ "layout": "IPY_MODEL_7995543f1936426b9f4c957c9efaa30a",
+ "layout_path": null,
+ "max": "",
+ "min": "",
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": "2027-12-31"
+ }
+ },
+ "8db8c0db25e444218423ddaf789a7f2d": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "a3781a09d1bb48b1a3ce727fb1f5caac": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "a6307688a7d44d9cbfbcb2bd951118e3": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "add57740f2c94a799241fda2111b4b1f": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.checkbox.CheckboxWidget",
+ "_css": "\n .mljar-checkbox-container {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n cursor: pointer;\n user-select: none;\n -webkit-tap-highlight-color: transparent;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n color: #0f172a;\n padding-left: 5px;\n }\n .mljar-checkbox-container.is-disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n .mljar-checkbox-input {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n .mljar-checkbox-input:focus-visible + .mljar-checkbox-control {\n box-shadow: inset 0 0 0 2px #007bff;\n }\n\n .mljar-checkbox-label {\n font-size: 14px;\n line-height: 1.2;\n padding-top: 2px;\n }\n\n /* --- Toggle style (15% smaller) --- */\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control {\n position: relative;\n width: 34px;\n height: 19px;\n border-radius: 999px;\n background: #f1f1f2;\n border: 1px solid #cfd1d5;\n transition: background 150ms ease, border-color 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control {\n background: #007bff;\n border-color: #007bff;\n }\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 15px;\n height: 15px;\n border-radius: 50%;\n background: #ffffff;\n box-shadow: 0 1px 2px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);\n transition: transform 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control::after {\n transform: translateX(15px);\n }\n\n /* --- Classic box style --- */\n .mljar-checkbox-container.is-box .mljar-checkbox-control {\n width: 16px;\n height: 16px;\n border-radius: 4px;\n border: 1px solid #cfd1d5;\n background: #ffffff;\n display: inline-block;\n position: relative;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control {\n border-color: #007bff;\n background: #007bff;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n left: 4px;\n top: 0px;\n width: 5px;\n height: 10px;\n border: solid #fff;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n }\n\n .mljar-checkbox-container:not(.is-disabled):hover .mljar-checkbox-control {\n border-color: #007bff;\n background: #f3f3f4;\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"label\");\n container.classList.add(\"mljar-checkbox-container\");\n\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.classList.add(\"mljar-checkbox-input\");\n\n const control = document.createElement(\"span\");\n control.classList.add(\"mljar-checkbox-control\");\n\n const text = document.createElement(\"span\");\n text.classList.add(\"mljar-checkbox-label\");\n\n container.appendChild(input);\n container.appendChild(control);\n container.appendChild(text);\n el.appendChild(container);\n\n function syncFromModel() {\n // appearance\n const a = model.get(\"appearance\") || \"toggle\";\n container.classList.remove(\"is-toggle\",\"is-box\");\n container.classList.add(`is-${a}`);\n input.setAttribute(\"role\", a === \"toggle\" ? \"switch\" : \"checkbox\");\n\n // value\n const v = !!model.get(\"value\");\n if (input.checked !== v) {\n input.checked = v;\n input.setAttribute(\"aria-checked\", String(v));\n }\n container.classList.toggle(\"is-checked\", v);\n\n // disabled\n const d = !!model.get(\"disabled\");\n input.disabled = d;\n container.classList.toggle(\"is-disabled\", d);\n\n // label\n text.textContent = model.get(\"label\") || \"\";\n\n // hidden (exists but not visible)\n container.style.display = model.get(\"hidden\") ? \"none\" : \"inline-flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n\n const v = input.checked;\n if (model.get(\"value\") !== v) {\n model.set(\"value\", v);\n model.set(\"last_changed_at\", new Date().toISOString());\n model.set(\"n_toggles\", (model.get(\"n_toggles\") || 0) + 1);\n model.save_changes();\n // model.send({ type: \"changed\", value: v });\n }\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:appearance\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n \n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }\n */\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "appearance": "toggle",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "Include KB readiness (500–1,500 h)",
+ "last_changed_at": "",
+ "layout": "IPY_MODEL_0964f99d1a1044519f3bfa01263f69ab",
+ "layout_path": null,
+ "n_toggles": 0,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": true
+ }
+ },
+ "be078e536b5f4a538d12ba2eb80786b0": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "c10ea88b28a341ec8af4cef985011cea": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.number.NumberInputWidget",
+ "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "ANZ — current platform cost ($/yr)",
+ "layout": "IPY_MODEL_5a62799d57694cb590c5e65927ee1d6e",
+ "layout_path": null,
+ "max": 8000000.0,
+ "min": 0.0,
+ "position": "inline",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "step": 10000.0,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 677320.0
+ }
+ },
+ "c16ba94a7214410fa65f180d3f4c9d48": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.select.SelectWidget",
+ "_css": "\n .mljar-select-container {\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n }\n\n .mljar-select-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: none;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n margin-top: 6px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n container.appendChild(dropdown);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target)) {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n isEditing = false;\n setOpen(false);\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n document.removeEventListener(\"click\", handleDocumentClick);\n };\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "choices": [
+ "low",
+ "mid",
+ "high"
+ ],
+ "disabled": false,
+ "hidden": false,
+ "label": "Impl hours (V2 range)",
+ "layout": "IPY_MODEL_e0798db48c114f4085c8d55e8ef9d434",
+ "layout_path": null,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": "mid"
+ }
+ },
+ "c3b301dd6fa24c02903a5588fb99f898": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.number.NumberInputWidget",
+ "_css": "\n .mljar-number-container {\n display: flex;\n flex-direction: column;\n width: 100%;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n box-sizing: border-box;\n }\n\n .mljar-number-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-number-field-row {\n display: flex;\n align-items: stretch;\n width: 100%;\n min-height: 40px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n overflow: hidden;\n }\n\n .mljar-number-input {\n flex: 1 1 auto;\n min-width: 0;\n min-height: 100%;\n padding: 7px 10px;\n border: 0;\n border-radius: 0;\n background: #ffffff;\n box-sizing: border-box;\n background-color: #ffffff !important;\n color: #0f172a !important;\n font: inherit;\n line-height: 1.2;\n -moz-appearance: textfield;\n }\n\n .mljar-number-input::-webkit-outer-spin-button,\n .mljar-number-input::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n }\n\n .mljar-number-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-number-input:focus {\n outline: none;\n }\n\n .mljar-number-field-row:focus-within {\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n }\n\n .mljar-number-field-row:focus-within .mljar-number-controls {\n border-left-color: #007bff;\n }\n\n .mljar-number-controls {\n display: flex;\n align-items: stretch;\n flex: 0 0 auto;\n border-left: 1px solid #cfd1d5;\n background: #f1f1f2;\n }\n\n .mljar-number-step-btn {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: 38px;\n min-width: 38px;\n min-height: 100%;\n border: 0;\n border-radius: 0;\n background: transparent;\n color: #0f172a;\n font: inherit;\n font-size: 18px;\n font-weight: 700;\n line-height: 1;\n cursor: pointer;\n padding: 0;\n user-select: none;\n -webkit-user-select: none;\n touch-action: manipulation;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-number-step-up {\n border-left: 1px solid #cfd1d5;\n }\n\n .mljar-number-step-btn:hover {\n background: #f3f3f4;\n }\n\n .mljar-number-step-btn:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:focus-visible {\n outline: none;\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-number-step-btn:disabled {\n background: #f5f5f5;\n color: #aaa;\n cursor: not-allowed;\n }\n\n @media (max-width: 768px) {\n .mljar-number-field-row {\n min-height: 44px;\n }\n\n .mljar-number-input {\n min-height: 44px;\n padding: 8px 12px;\n }\n\n .mljar-number-step-btn {\n font-size: 19px;\n width: 44px;\n min-width: 44px;\n }\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-number-container\");\n\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-number-label\");\n\n const fieldRow = document.createElement(\"div\");\n fieldRow.classList.add(\"mljar-number-field-row\");\n\n const input = document.createElement(\"input\");\n input.type = \"number\";\n input.classList.add(\"mljar-number-input\");\n\n const decrementBtn = document.createElement(\"button\");\n decrementBtn.type = \"button\";\n decrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-down\");\n decrementBtn.textContent = \"-\";\n decrementBtn.setAttribute(\"aria-label\", \"Decrease value\");\n\n const incrementBtn = document.createElement(\"button\");\n incrementBtn.type = \"button\";\n incrementBtn.classList.add(\"mljar-number-step-btn\", \"mljar-number-step-up\");\n incrementBtn.textContent = \"+\";\n incrementBtn.setAttribute(\"aria-label\", \"Increase value\");\n\n const controls = document.createElement(\"div\");\n controls.classList.add(\"mljar-number-controls\");\n\n controls.appendChild(decrementBtn);\n controls.appendChild(incrementBtn);\n fieldRow.appendChild(input);\n fieldRow.appendChild(controls);\n\n container.appendChild(topLabel);\n container.appendChild(fieldRow);\n el.appendChild(container);\n\n function clamp(val, min, max) {\n if (Number.isFinite(min) && val < min) return min;\n if (Number.isFinite(max) && val > max) return max;\n return val;\n }\n\n function normalizeStep(step) {\n return Number.isFinite(step) && step > 0 ? step : 1;\n }\n\n function snapToStep(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = Math.round((value - base) / safeStep);\n const snapped = base + steps * safeStep;\n const precision = Math.max(\n 0,\n (String(safeStep).split(\".\")[1] || \"\").length\n );\n\n return Number(snapped.toFixed(precision + 2));\n }\n\n function isOnStepGrid(value, min, step) {\n const safeStep = normalizeStep(step);\n const base = Number.isFinite(min) ? min : 0;\n const steps = (value - base) / safeStep;\n const nearest = Math.round(steps);\n const epsilon = Math.max(1e-9, safeStep * 1e-9);\n\n return Math.abs(steps - nearest) <= epsilon;\n }\n\n function getCurrentBounds() {\n return {\n min: Number(model.get(\"min\")),\n max: Number(model.get(\"max\")),\n };\n }\n\n function isTransientDraft(raw) {\n return raw === \"\" || raw === \"-\" || raw === \".\" || raw === \"-.\";\n }\n\n let isEditing = false;\n const INPUT_COMMIT_DEBOUNCE_MS = 400;\n\n function clearPendingDraftCommit() {\n if (debounceTimer) clearTimeout(debounceTimer);\n pendingDraftValue = null;\n }\n\n function parseDraftValue(rawValue) {\n const raw = String(rawValue).trim();\n if (isTransientDraft(raw)) {\n return { kind: \"transient\" };\n }\n\n const value = Number(raw);\n if (!Number.isFinite(value)) {\n return { kind: \"invalid\" };\n }\n\n return { kind: \"number\", value };\n }\n\n function commitValue(nextValue, saveNow = true) {\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n const parsed = parseDraftValue(nextValue);\n if (parsed.kind !== \"number\") {\n syncFromModel();\n return;\n }\n\n let v = parsed.value;\n v = clamp(v, min, max);\n v = snapToStep(v, min, step);\n v = clamp(v, min, max);\n input.value = String(v);\n model.set(\"value\", v);\n\n if (saveNow) {\n model.save_changes();\n }\n }\n\n function syncFromModel() {\n topLabel.innerHTML = model.get(\"label\") || \"Enter number\";\n\n const min = Number(model.get(\"min\"));\n const max = Number(model.get(\"max\"));\n const step = Number(model.get(\"step\"));\n\n if (Number.isFinite(min)) input.min = String(min); else input.removeAttribute(\"min\");\n if (Number.isFinite(max)) input.max = String(max); else input.removeAttribute(\"max\");\n if (Number.isFinite(step)) input.step = String(step); else input.removeAttribute(\"step\");\n\n const v = Number(model.get(\"value\"));\n if (!isEditing) {\n input.value = Number.isFinite(v) ? String(v) : \"\";\n }\n\n const disabled = !!model.get(\"disabled\");\n input.disabled = disabled;\n incrementBtn.disabled = disabled;\n decrementBtn.disabled = disabled;\n\n const hidden = !!model.get(\"hidden\");\n container.style.display = hidden ? \"none\" : \"flex\";\n }\n\n let debounceTimer = null;\n let pendingDraftValue = null;\n input.addEventListener(\"focus\", () => {\n isEditing = true;\n });\n\n input.addEventListener(\"input\", () => {\n if (model.get(\"disabled\")) return;\n\n const parsed = parseDraftValue(input.value);\n if (parsed.kind !== \"number\") {\n clearPendingDraftCommit();\n return;\n }\n\n const v = parsed.value;\n const { min, max } = getCurrentBounds();\n const step = Number(model.get(\"step\"));\n if (Number.isFinite(min) && v < min) {\n clearPendingDraftCommit();\n return;\n }\n if (Number.isFinite(max) && v > max) {\n clearPendingDraftCommit();\n return;\n }\n if (!isOnStepGrid(v, min, step)) {\n clearPendingDraftCommit();\n return;\n }\n\n pendingDraftValue = v;\n if (debounceTimer) clearTimeout(debounceTimer);\n debounceTimer = setTimeout(() => {\n if (pendingDraftValue === null) return;\n model.set(\"value\", pendingDraftValue);\n model.save_changes();\n pendingDraftValue = null;\n }, INPUT_COMMIT_DEBOUNCE_MS);\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n clearPendingDraftCommit();\n commitValue(input.value, true);\n });\n\n input.addEventListener(\"keydown\", event => {\n if (event.key === \"Enter\") {\n event.preventDefault();\n input.blur();\n }\n });\n\n incrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base + step, min, step));\n });\n\n decrementBtn.addEventListener(\"click\", () => {\n if (model.get(\"disabled\")) return;\n const current = Number(model.get(\"value\"));\n const step = normalizeStep(Number(model.get(\"step\")));\n const min = Number(model.get(\"min\"));\n const base = Number.isFinite(current) ? current : 0;\n commitValue(snapToStep(base - step, min, step));\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:min\", syncFromModel);\n model.on(\"change:max\", syncFromModel);\n model.on(\"change:step\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }*/\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "Email auto-respond rate",
+ "layout": "IPY_MODEL_0f2134b5c99240118266729a13f0bcdb",
+ "layout_path": null,
+ "max": 0.6,
+ "min": 0.0,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "step": 0.005,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": 0.255
+ }
+ },
+ "d2e49eccf06d465c853fa8548d636365": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "d44327dfcd474f4f863ec5d853042bd8": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "2.0.0",
+ "model_name": "HTMLStyleModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "2.0.0",
+ "_model_name": "HTMLStyleModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "StyleView",
+ "background": null,
+ "description_width": "",
+ "font_size": null,
+ "text_color": null
+ }
+ },
+ "d59470b46ebd414bad557f9f486d3b40": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "ddce655a7deb4730987a05a2c99ce93c": {
+ "model_module": "@jupyter-widgets/controls",
+ "model_module_version": "2.0.0",
+ "model_name": "HTMLModel",
+ "state": {
+ "_dom_classes": [],
+ "_model_module": "@jupyter-widgets/controls",
+ "_model_module_version": "2.0.0",
+ "_model_name": "HTMLModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/controls",
+ "_view_module_version": "2.0.0",
+ "_view_name": "HTMLView",
+ "cell_id": "",
+ "description": "",
+ "description_allow_html": false,
+ "layout": "IPY_MODEL_2ee8949d4dfc410f8537fb93394f2d22",
+ "layout_path": null,
+ "placeholder": "",
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "style": "IPY_MODEL_d44327dfcd474f4f863ec5d853042bd8",
+ "tabbable": null,
+ "tooltip": null,
+ "value": ""
+ }
+ },
+ "e0798db48c114f4085c8d55e8ef9d434": {
+ "model_module": "@jupyter-widgets/base",
+ "model_module_version": "2.0.0",
+ "model_name": "LayoutModel",
+ "state": {
+ "_model_module": "@jupyter-widgets/base",
+ "_model_module_version": "2.0.0",
+ "_model_name": "LayoutModel",
+ "_view_count": null,
+ "_view_module": "@jupyter-widgets/base",
+ "_view_module_version": "2.0.0",
+ "_view_name": "LayoutView",
+ "align_content": null,
+ "align_items": null,
+ "align_self": null,
+ "border_bottom": null,
+ "border_left": null,
+ "border_right": null,
+ "border_top": null,
+ "bottom": null,
+ "display": null,
+ "flex": null,
+ "flex_flow": null,
+ "grid_area": null,
+ "grid_auto_columns": null,
+ "grid_auto_flow": null,
+ "grid_auto_rows": null,
+ "grid_column": null,
+ "grid_gap": null,
+ "grid_row": null,
+ "grid_template_areas": null,
+ "grid_template_columns": null,
+ "grid_template_rows": null,
+ "height": null,
+ "justify_content": null,
+ "justify_items": null,
+ "left": null,
+ "margin": null,
+ "max_height": null,
+ "max_width": null,
+ "min_height": null,
+ "min_width": null,
+ "object_fit": null,
+ "object_position": null,
+ "order": null,
+ "overflow": null,
+ "padding": null,
+ "right": null,
+ "top": null,
+ "visibility": null,
+ "width": null
+ }
+ },
+ "e0f44ed6c0524e3cb36422e63c87b127": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.select.SelectWidget",
+ "_css": "\n .mljar-select-container {\n display: flex;\n flex-direction: column;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n font-size: 14px;\n color: #0f172a;\n margin-bottom: 8px;\n padding-left: 4px;\n padding-right: 4px;\n }\n\n .mljar-select-label {\n margin-bottom: 4px;\n font-weight: 600;\n }\n\n .mljar-select-control {\n position: relative;\n display: flex;\n align-items: center;\n cursor: default;\n }\n\n .mljar-select-widget-input {\n width: 100%;\n min-height: 40px;\n padding: 9px 36px 9px 10px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-sizing: border-box;\n line-height: 1.4;\n transition: border-color 0.15s ease, box-shadow 0.15s ease;\n\n appearance: none !important;\n background-color: #ffffff !important;\n color: #0f172a !important;\n cursor: default;\n }\n\n .mljar-select-widget-input:focus {\n outline: none;\n border-color: #007bff;\n border-width: 2px;\n box-shadow: none;\n cursor: text;\n }\n\n .mljar-select-caret {\n position: absolute;\n right: 12px;\n top: 50%;\n width: 8px;\n height: 8px;\n border-right: 1.5px solid #0f172a;\n border-bottom: 1.5px solid #0f172a;\n transform: translateY(-65%) rotate(45deg);\n pointer-events: none;\n opacity: 0.5;\n transition: transform 0.18s ease, opacity 0.18s ease;\n }\n\n .mljar-select-container.is-open .mljar-select-caret {\n opacity: 1;\n transform: translateY(-35%) rotate(225deg);\n }\n\n .mljar-select-dropdown {\n display: none;\n margin-top: 6px;\n border: 1px solid #cfd1d5;\n border-radius: 6px;\n background: #ffffff;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);\n overflow: hidden;\n }\n\n .mljar-select-list {\n max-height: 260px;\n overflow-y: auto;\n }\n\n .mljar-select-option {\n display: block;\n width: 100%;\n padding: 9px 10px;\n border: 0;\n background: transparent;\n color: #0f172a;\n text-align: left;\n cursor: pointer;\n font: inherit;\n transition: background-color 0.14s ease, color 0.14s ease;\n }\n\n .mljar-select-option:hover {\n background: #f3f3f4;\n }\n\n .mljar-select-option:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-option.is-selected {\n background: #e6f2ff;\n color: #007bff;\n font-weight: 600;\n }\n\n .mljar-select-option.is-selected:hover,\n .mljar-select-option.is-selected:active {\n background: #e6f2ff;\n color: #007bff;\n }\n\n .mljar-select-empty {\n display: none;\n padding: 10px;\n color: #616673;\n font-size: 0.95em;\n }\n\n .mljar-select-widget-input:disabled {\n background: #f5f5f5;\n color: #888;\n cursor: not-allowed;\n }\n\n .mljar-select-control.is-disabled .mljar-select-caret {\n opacity: 0.45;\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const normalize = value => String(value ?? \"\").toLowerCase().trim();\n const getChoices = () =>\n Array.isArray(model.get(\"choices\")) ? [...model.get(\"choices\")] : [];\n const isDisabled = () => !!model.get(\"disabled\");\n const isHidden = () => !!model.get(\"hidden\");\n\n const container = document.createElement(\"div\");\n container.classList.add(\"mljar-select-container\");\n\n if (model.get(\"label\")) {\n const topLabel = document.createElement(\"div\");\n topLabel.classList.add(\"mljar-select-label\");\n topLabel.innerHTML = model.get(\"label\");\n container.appendChild(topLabel);\n }\n\n const control = document.createElement(\"div\");\n control.classList.add(\"mljar-select-control\");\n\n const input = document.createElement(\"input\");\n input.type = \"text\";\n input.classList.add(\"mljar-select-widget-input\");\n input.autocomplete = \"off\";\n input.spellcheck = false;\n\n const caret = document.createElement(\"div\");\n caret.classList.add(\"mljar-select-caret\");\n\n control.appendChild(input);\n control.appendChild(caret);\n\n const dropdown = document.createElement(\"div\");\n dropdown.classList.add(\"mljar-select-dropdown\");\n\n const list = document.createElement(\"div\");\n list.classList.add(\"mljar-select-list\");\n\n const emptyState = document.createElement(\"div\");\n emptyState.classList.add(\"mljar-select-empty\");\n emptyState.textContent = \"No matches\";\n\n dropdown.appendChild(list);\n dropdown.appendChild(emptyState);\n\n container.appendChild(control);\n container.appendChild(dropdown);\n el.appendChild(container);\n\n let isOpen = false;\n let filteredChoices = [];\n let lastCommittedValue = \"\";\n let isEditing = false;\n\n const setOpen = next => {\n if (isDisabled()) {\n isOpen = false;\n } else {\n isOpen = !!next;\n }\n container.classList.toggle(\"is-open\", isOpen);\n dropdown.style.display = isOpen ? \"block\" : \"none\";\n };\n\n const updateDisabledState = () => {\n const disabled = isDisabled();\n input.disabled = disabled;\n control.classList.toggle(\"is-disabled\", disabled);\n };\n\n const updateHiddenState = () => {\n container.style.display = isHidden() ? \"none\" : \"\";\n };\n\n const syncInputWithValue = () => {\n const value = model.get(\"value\") || \"\";\n lastCommittedValue = value;\n if (!isEditing) {\n input.value = value;\n }\n };\n\n const filterChoices = query => {\n const normalizedQuery = normalize(query);\n const allChoices = getChoices();\n if (!normalizedQuery) {\n return allChoices;\n }\n return allChoices.filter(choice =>\n normalize(choice).includes(normalizedQuery)\n );\n };\n\n const renderList = () => {\n list.innerHTML = \"\";\n filteredChoices.forEach(choice => {\n const option = document.createElement(\"button\");\n option.type = \"button\";\n option.classList.add(\"mljar-select-option\");\n if (choice === model.get(\"value\")) {\n option.classList.add(\"is-selected\");\n }\n option.textContent = choice;\n option.addEventListener(\"mousedown\", event => {\n event.preventDefault();\n event.stopPropagation();\n model.set(\"value\", choice);\n model.save_changes();\n isEditing = false;\n syncInputWithValue();\n renderList();\n setOpen(false);\n });\n list.appendChild(option);\n });\n\n const hasMatches = filteredChoices.length > 0;\n list.style.display = hasMatches ? \"block\" : \"none\";\n emptyState.style.display = hasMatches ? \"none\" : \"block\";\n };\n\n const refreshList = () => {\n filteredChoices = filterChoices(input.value);\n renderList();\n };\n\n const openWithCurrentQuery = () => {\n isEditing = true;\n input.value = \"\";\n refreshList();\n setOpen(true);\n };\n\n control.addEventListener(\"click\", event => {\n event.stopPropagation();\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n input.focus();\n });\n\n input.addEventListener(\"input\", () => {\n if (isDisabled()) {\n return;\n }\n refreshList();\n setOpen(true);\n });\n\n input.addEventListener(\"focus\", () => {\n if (isDisabled()) {\n return;\n }\n openWithCurrentQuery();\n });\n\n input.addEventListener(\"blur\", () => {\n isEditing = false;\n input.value = lastCommittedValue;\n });\n\n const handleDocumentClick = event => {\n if (!container.contains(event.target)) {\n isEditing = false;\n setOpen(false);\n input.value = lastCommittedValue;\n }\n };\n\n document.addEventListener(\"click\", handleDocumentClick);\n\n model.on(\"change:value\", () => {\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:choices\", () => {\n const choices = getChoices();\n if (!choices.includes(model.get(\"value\")) && choices.length > 0) {\n model.set(\"value\", choices[0]);\n model.save_changes();\n return;\n }\n syncInputWithValue();\n refreshList();\n });\n\n model.on(\"change:disabled\", () => {\n updateDisabledState();\n if (isDisabled()) {\n isEditing = false;\n setOpen(false);\n }\n });\n\n model.on(\"change:hidden\", () => {\n updateHiddenState();\n });\n\n updateDisabledState();\n updateHiddenState();\n syncInputWithValue();\n refreshList();\n setOpen(false);\n\n return () => {\n document.removeEventListener(\"click\", handleDocumentClick);\n };\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "cell_id": "",
+ "choices": [
+ "175",
+ "225",
+ "275"
+ ],
+ "disabled": false,
+ "hidden": false,
+ "label": "Blended rate ($/h)",
+ "layout": "IPY_MODEL_2c0cb1d4828244d9ba53a4a451b5717e",
+ "layout_path": null,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": "225"
+ }
+ },
+ "fb2bde654c82408ea195b113684a2916": {
+ "model_module": "anywidget",
+ "model_module_version": "~0.11.*",
+ "model_name": "AnyModel",
+ "state": {
+ "_anywidget_id": "mercury.checkbox.CheckboxWidget",
+ "_css": "\n .mljar-checkbox-container {\n display: inline-flex;\n align-items: center;\n gap: 10px;\n cursor: pointer;\n user-select: none;\n -webkit-tap-highlight-color: transparent;\n font-family: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;\n color: #0f172a;\n padding-left: 5px;\n }\n .mljar-checkbox-container.is-disabled {\n opacity: 0.6;\n cursor: not-allowed;\n }\n\n .mljar-checkbox-input {\n position: absolute;\n opacity: 0;\n width: 0;\n height: 0;\n }\n\n .mljar-checkbox-input:focus-visible + .mljar-checkbox-control {\n box-shadow: inset 0 0 0 2px #007bff;\n }\n\n .mljar-checkbox-label {\n font-size: 14px;\n line-height: 1.2;\n padding-top: 2px;\n }\n\n /* --- Toggle style (15% smaller) --- */\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control {\n position: relative;\n width: 34px;\n height: 19px;\n border-radius: 999px;\n background: #f1f1f2;\n border: 1px solid #cfd1d5;\n transition: background 150ms ease, border-color 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control {\n background: #007bff;\n border-color: #007bff;\n }\n .mljar-checkbox-container.is-toggle .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n top: 2px;\n left: 2px;\n width: 15px;\n height: 15px;\n border-radius: 50%;\n background: #ffffff;\n box-shadow: 0 1px 2px rgba(0,0,0,0.12), 0 0 0 1px rgba(0,0,0,0.04);\n transition: transform 150ms ease;\n }\n .mljar-checkbox-container.is-toggle.is-checked .mljar-checkbox-control::after {\n transform: translateX(15px);\n }\n\n /* --- Classic box style --- */\n .mljar-checkbox-container.is-box .mljar-checkbox-control {\n width: 16px;\n height: 16px;\n border-radius: 4px;\n border: 1px solid #cfd1d5;\n background: #ffffff;\n display: inline-block;\n position: relative;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control {\n border-color: #007bff;\n background: #007bff;\n }\n .mljar-checkbox-container.is-box.is-checked .mljar-checkbox-control::after {\n content: \"\";\n position: absolute;\n left: 4px;\n top: 0px;\n width: 5px;\n height: 10px;\n border: solid #fff;\n border-width: 0 2px 2px 0;\n transform: rotate(45deg);\n }\n\n .mljar-checkbox-container:not(.is-disabled):hover .mljar-checkbox-control {\n border-color: #007bff;\n background: #f3f3f4;\n }\n ",
+ "_dom_classes": [],
+ "_esm": "\n function render({ model, el }) {\n const container = document.createElement(\"label\");\n container.classList.add(\"mljar-checkbox-container\");\n\n const input = document.createElement(\"input\");\n input.type = \"checkbox\";\n input.classList.add(\"mljar-checkbox-input\");\n\n const control = document.createElement(\"span\");\n control.classList.add(\"mljar-checkbox-control\");\n\n const text = document.createElement(\"span\");\n text.classList.add(\"mljar-checkbox-label\");\n\n container.appendChild(input);\n container.appendChild(control);\n container.appendChild(text);\n el.appendChild(container);\n\n function syncFromModel() {\n // appearance\n const a = model.get(\"appearance\") || \"toggle\";\n container.classList.remove(\"is-toggle\",\"is-box\");\n container.classList.add(`is-${a}`);\n input.setAttribute(\"role\", a === \"toggle\" ? \"switch\" : \"checkbox\");\n\n // value\n const v = !!model.get(\"value\");\n if (input.checked !== v) {\n input.checked = v;\n input.setAttribute(\"aria-checked\", String(v));\n }\n container.classList.toggle(\"is-checked\", v);\n\n // disabled\n const d = !!model.get(\"disabled\");\n input.disabled = d;\n container.classList.toggle(\"is-disabled\", d);\n\n // label\n text.textContent = model.get(\"label\") || \"\";\n\n // hidden (exists but not visible)\n container.style.display = model.get(\"hidden\") ? \"none\" : \"inline-flex\";\n }\n\n input.addEventListener(\"change\", () => {\n if (model.get(\"disabled\")) return;\n\n const v = input.checked;\n if (model.get(\"value\") !== v) {\n model.set(\"value\", v);\n model.set(\"last_changed_at\", new Date().toISOString());\n model.set(\"n_toggles\", (model.get(\"n_toggles\") || 0) + 1);\n model.save_changes();\n // model.send({ type: \"changed\", value: v });\n }\n });\n\n model.on(\"change:value\", syncFromModel);\n model.on(\"change:disabled\", syncFromModel);\n model.on(\"change:appearance\", syncFromModel);\n model.on(\"change:label\", syncFromModel);\n model.on(\"change:hidden\", syncFromModel);\n\n syncFromModel();\n\n // ---- read cell id (no DOM modifications) ----\n /*\n const ID_ATTR = \"data-cell-id\";\n const hostWithId = el.closest(`[${ID_ATTR}]`);\n const cellId = hostWithId ? hostWithId.getAttribute(ID_ATTR) : null;\n\n if (cellId) {\n model.set(\"cell_id\", cellId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: cellId });\n } else {\n const mo = new MutationObserver(() => {\n const host = el.closest(`[${ID_ATTR}]`);\n const newId = host?.getAttribute(ID_ATTR);\n \n if (newId) {\n model.set(\"cell_id\", newId);\n model.save_changes();\n model.send({ type: \"cell_id_detected\", value: newId });\n mo.disconnect();\n }\n });\n mo.observe(document.body, { attributes: true, subtree: true, attributeFilter: [ID_ATTR] });\n }\n */\n }\n export default { render };\n ",
+ "_model_module": "anywidget",
+ "_model_module_version": "~0.11.*",
+ "_model_name": "AnyModel",
+ "_view_count": null,
+ "_view_module": "anywidget",
+ "_view_module_version": "~0.11.*",
+ "_view_name": "AnyView",
+ "appearance": "toggle",
+ "cell_id": "",
+ "disabled": false,
+ "hidden": false,
+ "label": "NA Email implements early (Jan 2027)",
+ "last_changed_at": "",
+ "layout": "IPY_MODEL_0a480f63e0d54c109ba867039bb3cc43",
+ "layout_path": null,
+ "n_toggles": 0,
+ "position": "sidebar",
+ "render_slot_id": null,
+ "source_cell_id": null,
+ "tabbable": null,
+ "tooltip": null,
+ "url_key": "",
+ "value": true
+ }
+ }
+ },
+ "version_major": 2,
+ "version_minor": 0
+ }
}
},
"nbformat": 4,
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_token_calculator.ipynb b/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_token_calculator.ipynb
index c2fbb98..a73a527 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_token_calculator.ipynb
+++ b/studies/202512_GenesysCX/ctm-token-calculator/notebooks/ctm_token_calculator.ipynb
@@ -16,7 +16,7 @@
"> ⚠️ **Planning tool.** List rates unless overridden; not contractual pricing.\n",
"> Site data outside NAM is **estimated — confirm with CTM**.\n",
"\n",
- "Same `tokencalc` library as the Streamlit app (`streamlit run app/streamlit_app.py`) —\n",
+ "Same `tokencalc` library that powers the corrected business case notebook — serve either interactively with `mercury --working-dir notebooks/` —\n",
"Run-All here produces identical headline numbers on default inputs."
]
},
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/pyproject.toml b/studies/202512_GenesysCX/ctm-token-calculator/pyproject.toml
index 688439f..eaa1465 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/pyproject.toml
+++ b/studies/202512_GenesysCX/ctm-token-calculator/pyproject.toml
@@ -15,8 +15,8 @@ dependencies = [
]
[project.optional-dependencies]
-app = ["streamlit>=1.30"]
-notebook = ["jupyterlab>=4.0", "ipywidgets>=8.0"]
+app = ["mercury>=3.2"]
+notebook = ["jupyterlab>=4.0", "ipywidgets>=8.0", "nbconvert>=7", "tabulate>=0.9"]
dev = ["pytest>=7.4", "mypy>=1.8"]
[tool.setuptools.packages.find]
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/requirements.txt b/studies/202512_GenesysCX/ctm-token-calculator/requirements.txt
index 7a47203..c6dce39 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/requirements.txt
+++ b/studies/202512_GenesysCX/ctm-token-calculator/requirements.txt
@@ -1,4 +1,4 @@
-streamlit>=1.30
+mercury>=3.2
pandas>=2.0
numpy>=1.25
plotly>=5.18
@@ -6,4 +6,6 @@ openpyxl>=3.1
pydantic>=2.0
jupyterlab>=4.0
ipywidgets>=8.0
+nbconvert>=7
+tabulate>=0.9
pytest>=7.4
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/scripts/export_report.py b/studies/202512_GenesysCX/ctm-token-calculator/scripts/export_report.py
new file mode 100644
index 0000000..9606fdf
--- /dev/null
+++ b/studies/202512_GenesysCX/ctm-token-calculator/scripts/export_report.py
@@ -0,0 +1,39 @@
+"""Export the corrected business case notebook as LLM-readable report sources.
+
+Executes the notebook fresh (widget defaults — or whatever defaults you edit in),
+then writes both formats to exports/:
+
+ exports/ctm_business_case_corrected.html — human-reviewable, tables render
+ exports/ctm_business_case_corrected.md — leanest LLM input
+
+Plotly figures export as JavaScript an LLM cannot read; the notebook's section-12
+machine-readable appendix carries every number behind them.
+
+Run from the project root: python scripts/export_report.py
+"""
+from __future__ import annotations
+
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+NOTEBOOK = ROOT / "notebooks" / "ctm_business_case_corrected.ipynb"
+EXPORTS = ROOT / "exports"
+
+
+def main() -> None:
+ EXPORTS.mkdir(exist_ok=True)
+ for fmt in ("html", "markdown"):
+ subprocess.run(
+ [sys.executable, "-m", "nbconvert", "--execute",
+ "--to", fmt, "--output-dir", str(EXPORTS), str(NOTEBOOK)],
+ check=True, cwd=ROOT,
+ )
+ for p in sorted(EXPORTS.iterdir()):
+ if p.suffix in (".html", ".md"):
+ print(f"wrote {p.relative_to(ROOT)} ({p.stat().st_size / 1024:,.0f} KB)")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/__init__.py b/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/__init__.py
index 7afa93f..82ac545 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/__init__.py
+++ b/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/__init__.py
@@ -1,8 +1,8 @@
"""
tokencalc — Genesys AI token cost & business case calculator core.
-Pure-Python, UI-agnostic. The JupyterLab notebook and the Streamlit
-app are thin presentation layers over these functions.
+Pure-Python, UI-agnostic. The notebooks (served interactively with
+Mercury) are thin presentation layers over these functions.
"""
from .benefit_model import calculate_total_benefit
diff --git a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py b/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py
index 9ed9853..06e74ad 100644
--- a/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py
+++ b/studies/202512_GenesysCX/ctm-token-calculator/tokencalc/appendix4.py
@@ -4,9 +4,9 @@ kept verbatim, with the costs the deck omitted: AI Experience token
consumption, AI implementation effort (V2 LoE), and double-billing of
the existing platforms until their term contracts end.
-Single source of truth shared by the notebook
-(``notebooks/ctm_business_case_corrected.ipynb``) and the Streamlit
-"Corrected Business Case" view — presentation layers hold no math.
+Single source of truth behind the deliverable notebook
+(``notebooks/ctm_business_case_corrected.ipynb``, served with
+Mercury) — the presentation layer holds no math.
Sources: ``docs/Appendix 4 - CCaaS Platform Benefit Calculations
(Consolidated).pptx`` (verbatim figures, deployment schedule) and
@@ -370,7 +370,7 @@ AI_IMPL_HOURS: dict[str, tuple[float, float]] = {
"STA": (800, 1_200), # topics, programs, tuning × 7 languages
"Supervisor Copilot": (200, 400),
"Predictive Routing": (400, 700),
- "Cross-cutting": (1_000, 1_800), # governance, PM, test env, integration
+ "Cross-cutting (PM, governance, testing, integration)": (1_000, 1_800),
}
KB_READINESS_HOURS = (500, 1_500) # prerequisite project — flagged separately
STEADY_STATE_HOURS = (500, 900) # absolute h/yr, 2027-2028
@@ -387,7 +387,7 @@ def impl_feature_regions(copilot_includes_asia: bool = False) -> dict[str, list[
"STA": list(REGIONS),
"Supervisor Copilot": ["NA", "ANZ", "EMEA"], # deck: $0 SupCopilot in ASIA
"Predictive Routing": list(REGIONS),
- "Cross-cutting": list(REGIONS),
+ "Cross-cutting (PM, governance, testing, integration)": list(REGIONS),
}