From c187bde18868e0dbaccf5060f95aabea21e99e75 Mon Sep 17 00:00:00 2001 From: Robert Helewka Date: Tue, 7 Jul 2026 17:04:53 -0400 Subject: [PATCH] feat: update ctm-token-calculator to use Mercury and notebook deliverables Replace Streamlit and JupyterLab commands with Mercury for serving interactive notebooks as web apps. Update README to reflect new architecture where notebooks are the primary deliverables, utilizing Mercury input widgets for live client tuning. Add export_report.py script to generate LLM-readable HTML/Markdown reports from the notebooks. Update corrected business case notebook to include Mercury dependency and usage instructions. --- .../ctm-token-calculator/README.md | 33 +- .../ctm-token-calculator/app/streamlit_app.py | 891 ----- .../ctm-token-calculator/config.toml | 43 + .../ctm_business_case_corrected.ipynb | 3114 +++++++++++++++-- .../notebooks/ctm_token_calculator.ipynb | 2 +- .../ctm-token-calculator/pyproject.toml | 4 +- .../ctm-token-calculator/requirements.txt | 4 +- .../scripts/export_report.py | 39 + .../tokencalc/__init__.py | 4 +- .../tokencalc/appendix4.py | 10 +- 10 files changed, 2970 insertions(+), 1174 deletions(-) delete mode 100644 studies/202512_GenesysCX/ctm-token-calculator/app/streamlit_app.py create mode 100644 studies/202512_GenesysCX/ctm-token-calculator/config.toml create mode 100644 studies/202512_GenesysCX/ctm-token-calculator/scripts/export_report.py 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": "

Jump to section

  1. Current state & contracts
  2. Verbatim benefits & schedule
  3. Token consumption
  4. AI implementation effort
  5. Corrected cost stack
  6. Fig 1 — Benefits
  7. Fig 2 — Costs
  8. Fig 3 — Business case
  9. Sensitivity
  10. Verification & assertions
  11. Risks & next steps
  12. Data appendix

" + } + }, + "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), }