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