#!/usr/bin/env python3
"""
Lab 06 — Speculative-decoding & prefill/decode-disaggregation simulator.

Pure stdlib, fully deterministic (no wall clock, no RNG): every number is a
closed-form evaluation, so runs are reproducible bit-for-bit on any laptop.

Two analytic models:

  PART 1 (specdec)  Speculative decoding — draft/verify cycle economics.
      E[n] = (1 - alpha^(k+1)) / (1 - alpha)      accepted tokens per cycle
      cycle = k*t_draft + t_verify(k, u)          with compute-roof gating
      speedup = E[n] * t_target / cycle
      (Knowledge 01 §9; Leviathan et al. 2211.17192, Chen et al. 2302.01318)

  PART 2 (disagg)   Colocated vs disaggregated prefill/decode serving.
      Prefill queueing  : M/M/c via Erlang-C (exact, stdlib math)
      Colocated decode  : TPOT inflated by chunked-prefill interference
      Disaggregated     : KV-transfer time = KV_bytes(P) / link_bandwidth
      Goodput           : lambda * P(TTFT <= X) * 1[TPOT <= Y]
      (Sarathi-Serve 2403.02310, DistServe 2401.09670, Mooncake 2407.00079)

Usage:
    python spec_decode_sim.py specdec [--preset mtp|small-draft|medusa|eagle3]
    python spec_decode_sim.py disagg  [--preset chat|rag|agent] [--link ...]
    python spec_decode_sim.py all

See README.md for the derivations and the honesty section (what this model
deliberately ignores).
"""
from __future__ import annotations

import argparse
import math
from dataclasses import dataclass, replace

# =========================================================================
# PART 1 — SPECULATIVE DECODING
# =========================================================================

#: Guards the 1/(1-u) gating term at u == 1.0 (fully saturated compute roof).
EPS_UTIL = 1e-3


@dataclass(frozen=True)
class SpecDecodeConfig:
    """Parameter bundle for the speculative-decoding cycle model.

    alpha           per-token acceptance probability (i.i.d. approximation)
    draft_cost      d = t_draft / t_target   (relative cost of one draft token)
    verify_overhead c in  t_verify(k) = t_target * (1 + c*k)  at u = 0
                    (marginal compute cost of verifying one extra draft token)
    utilization     u in [0,1): fraction of the compute roof already consumed
                    by batched decode. The verification width is only "free"
                    while the GPU is memory-bound; as u -> 1 the extra k
                    tokens of verification compete for compute and the c*k
                    term is scaled by 1/(1 - u + eps)  (documented in README).
    t_target_ms     baseline target-model per-token decode latency. Only sets
                    the absolute time scale; speedups are independent of it.
    """
    alpha: float
    draft_cost: float
    verify_overhead: float = 0.05
    utilization: float = 0.0
    t_target_ms: float = 25.0


#: Presets are just parameter bundles (see each entry's comment string).
SPECDEC_PRESETS: dict[str, tuple[SpecDecodeConfig, str]] = {
    "small-draft": (
        SpecDecodeConfig(alpha=0.70, draft_cost=0.08, verify_overhead=0.05),
        "Separate small draft model (~1-2B for a 70B target). Draft cost "
        "5-15% of target; acceptance 0.6-0.8 (distribution mismatch).",
    ),
    "mtp": (
        SpecDecodeConfig(alpha=0.85, draft_cost=0.10, verify_overhead=0.05),
        "DeepSeek-V3-style Multi-Token-Prediction module: shares the trunk, "
        "draft cost ~0.1x target, high acceptance (trained jointly).",
    ),
    "medusa": (
        SpecDecodeConfig(alpha=0.60, draft_cost=0.03, verify_overhead=0.10),
        "Medusa decoding heads: near-free draft (extra heads on the last "
        "hidden state), lower acceptance, wider tree verification (higher c).",
    ),
    "eagle3": (
        SpecDecodeConfig(alpha=0.90, draft_cost=0.06, verify_overhead=0.06),
        "EAGLE-3-style feature-level draft with training-time test "
        "alignment: highest acceptance of the family.",
    ),
}


def expected_tokens(alpha: float, k: int) -> float:
    """E[n] = expected tokens emitted per draft-verify cycle.

    n = (accepted draft prefix length) + 1 bonus token from the target's own
    verification pass. With i.i.d. per-token acceptance probability alpha:

        E[n] = sum_{i=0..k} alpha^i = (1 - alpha^(k+1)) / (1 - alpha)

    (geometric series; = k+1 exactly when alpha == 1). See README §2.1.
    """
    if not 0.0 <= alpha <= 1.0:
        raise ValueError(f"alpha must be in [0,1], got {alpha}")
    if k < 0:
        raise ValueError(f"k must be >= 0, got {k}")
    if alpha == 1.0:
        return float(k + 1)
    return (1.0 - alpha ** (k + 1)) / (1.0 - alpha)


def verify_time_ms(cfg: SpecDecodeConfig, k: int) -> float:
    """Target forward pass verifying k drafted tokens (+1 next-token logit).

    t_verify(k, u) = t_target * (1 + c * k / (1 - u + eps))

    At u=0 this is the plain width model t_target*(1+c*k): one memory-bound
    pass whose weight traffic is unchanged, plus a small compute term per
    extra verified token. As u -> 1 the spare compute vanishes and the width
    term inflates without bound (saturating-roof gating).
    """
    if not 0.0 <= cfg.utilization <= 1.0:
        raise ValueError(f"utilization must be in [0,1], got {cfg.utilization}")
    gate = 1.0 / (1.0 - cfg.utilization + EPS_UTIL)
    return cfg.t_target_ms * (1.0 + cfg.verify_overhead * k * gate)


def cycle_time_ms(cfg: SpecDecodeConfig, k: int) -> float:
    """One cycle = k sequential draft tokens + one verification pass."""
    return k * cfg.draft_cost * cfg.t_target_ms + verify_time_ms(cfg, k)


def speedup(cfg: SpecDecodeConfig, k: int) -> float:
    """Throughput ratio vs plain autoregressive decode (baseline 1/t_target).

    speedup = (E[n] / cycle_time) / (1 / t_target)
    """
    return expected_tokens(cfg.alpha, k) * cfg.t_target_ms / cycle_time_ms(cfg, k)


def optimal_k(cfg: SpecDecodeConfig, k_max: int = 8) -> tuple[int, float]:
    """(argmax_k, max) of speedup over k in 1..k_max. Deterministic scan."""
    best_k, best_s = 1, speedup(cfg, 1)
    for k in range(2, k_max + 1):
        s = speedup(cfg, k)
        if s > best_s:
            best_k, best_s = k, s
    return best_k, best_s


# =========================================================================
# PART 2 — COLOCATED vs DISAGGREGATED PREFILL/DECODE
# =========================================================================

GB = 1e9  # decimal gigabyte, matching link-bandwidth marketing units


@dataclass(frozen=True)
class ModelPreset:
    """KV-cache geometry (Knowledge 01 §3): bytes/token = 2*L*H_kv*d_h*bytes."""
    name: str
    n_layers: int
    n_kv_heads: int
    head_dim: int
    kv_bytes_per_elem: float = 2.0  # FP16/BF16 KV cache

    def kv_bytes_per_token(self) -> float:
        # 2 accounts for K and V.
        return (2 * self.n_layers * self.n_kv_heads * self.head_dim
                * self.kv_bytes_per_elem)

    def kv_bytes(self, tokens: int) -> float:
        return self.kv_bytes_per_token() * tokens


MODELS: dict[str, ModelPreset] = {
    # 320 KiB/token -> ~0.33 GB per 1k prompt tokens in FP16.
    "llama-70b-gqa8": ModelPreset("llama-70b-gqa8", n_layers=80,
                                  n_kv_heads=8, head_dim=128),
    # 128 KiB/token.
    "llama-8b-gqa8": ModelPreset("llama-8b-gqa8", n_layers=32,
                                 n_kv_heads=8, head_dim=128),
}

#: KV-transfer link presets (bytes/second).
LINKS: dict[str, float] = {
    "nvlink": 400 * GB,   # NVLink4-class intra-node fabric
    "100gbe": 12.5 * GB,  # 100 Gbit Ethernet / low-end RDMA
    "tcp": 1.25 * GB,     # 10 Gbit TCP — worst case for KV migration
}


@dataclass(frozen=True)
class NodeSpec:
    """One serving node (e.g. an 8-GPU HGX box running one model instance).

    prefill_tps   prompt tokens/s the node sustains while prefilling
    decode_tps    aggregate decode tokens/s at its operating batch
    base_tpot_ms  per-request TPOT with no prefill interference
    """
    prefill_tps: float = 8000.0
    decode_tps: float = 4000.0
    base_tpot_ms: float = 25.0


@dataclass(frozen=True)
class Workload:
    arrival_rate: float   # lambda, requests/second (Poisson assumption)
    prompt_tokens: int    # P
    output_tokens: int    # O


@dataclass(frozen=True)
class SLO:
    ttft_ms: float
    tpot_ms: float


@dataclass(frozen=True)
class ServiceMetrics:
    ttft_mean_ms: float   # mean TTFT (inf if unstable/infeasible)
    tpot_ms: float        # deterministic TPOT in this model
    ttft_ok_prob: float   # P(TTFT <= SLO.ttft)
    goodput_rps: float    # lambda * ttft_ok_prob * 1[TPOT <= SLO.tpot]
    stable: bool
    note: str = ""


#: Chunked-prefill interference coefficient (colocated only):
#: TPOT_infl = base_tpot * (1 + BETA * rho_prefill).
BETA_DEFAULT = 0.5


def erlang_c(c: int, a: float) -> float:
    """Erlang-C: P(an arrival must queue) in an M/M/c system.

    a = lambda/mu is the offered load in Erlangs; requires a < c for
    stability (returns 1.0 otherwise — every arrival waits, queue grows
    without bound). Computed via the numerically stable Erlang-B recursion
        B(0) = 1;  B(n) = a*B(n-1) / (n + a*B(n-1))
    then  C = B / (1 - rho * (1 - B)),  rho = a/c.  (README §3.2.)
    """
    if c < 1:
        raise ValueError("need at least one server")
    if a < 0:
        raise ValueError("offered load must be >= 0")
    if a >= c:
        return 1.0
    b = 1.0
    for n in range(1, c + 1):
        b = a * b / (n + a * b)
    rho = a / c
    return b / (1.0 - rho * (1.0 - b))


def mmc_wait_mean_s(c: int, lam: float, mu: float) -> float:
    """Mean queueing delay E[W_q] = C(c,a) / (c*mu - lambda) for M/M/c."""
    a = lam / mu
    if a >= c:
        return math.inf
    return erlang_c(c, a) / (c * mu - lam)


def mmc_wait_prob_le_s(c: int, lam: float, mu: float, t: float) -> float:
    """P(W_q <= t) = 1 - C(c,a) * exp(-(c*mu - lambda) * t)   for t >= 0.

    With probability 1 - C the arrival does not wait at all; the conditional
    wait is Exp(c*mu - lambda).
    """
    if t < 0:
        return 0.0
    a = lam / mu
    if a >= c:
        return 0.0
    return 1.0 - erlang_c(c, a) * math.exp(-(c * mu - lam) * t)


def _goodput(lam: float, ttft_ok: float, tpot_ms: float, slo: SLO) -> float:
    return lam * ttft_ok if tpot_ms <= slo.tpot_ms else 0.0


def colocated_metrics(w: Workload, node: NodeSpec, n_nodes: int, slo: SLO,
                      beta: float = BETA_DEFAULT) -> ServiceMetrics:
    """All n_nodes run chunked prefill + decode together.

    - Prefill: M/M/c queue with c = n_nodes servers, service time
      S = P / prefill_tps (chunked prefill lets the whole node work on one
      prompt). TTFT = W_q + S.
    - Decode: stable iff lambda*O <= n_nodes*decode_tps. TPOT inflated by
      prefill interference: TPOT = base * (1 + beta * rho_prefill).
    """
    s_prefill = w.prompt_tokens / node.prefill_tps           # seconds
    mu = 1.0 / s_prefill
    a = w.arrival_rate * s_prefill
    rho_p = a / n_nodes
    rho_d = w.arrival_rate * w.output_tokens / (n_nodes * node.decode_tps)
    note = f"rho_p={rho_p:.2f} rho_d={rho_d:.2f}"
    if rho_p >= 1.0 or rho_d >= 1.0:
        return ServiceMetrics(math.inf, math.inf, 0.0, 0.0, False,
                              note + " UNSTABLE")

    tpot = node.base_tpot_ms * (1.0 + beta * rho_p)
    wq = mmc_wait_mean_s(n_nodes, w.arrival_rate, mu)
    ttft_mean_ms = (wq + s_prefill) * 1e3
    slack_s = slo.ttft_ms / 1e3 - s_prefill
    ttft_ok = mmc_wait_prob_le_s(n_nodes, w.arrival_rate, mu, slack_s)
    return ServiceMetrics(ttft_mean_ms, tpot, ttft_ok,
                          _goodput(w.arrival_rate, ttft_ok, tpot, slo),
                          True, note)


def kv_transfer_s(model: ModelPreset, prompt_tokens: int,
                  link_bandwidth: float) -> float:
    """Time to ship the prompt's KV cache from prefill to decode node."""
    return model.kv_bytes(prompt_tokens) / link_bandwidth


def disagg_metrics(w: Workload, node: NodeSpec, n_prefill: int, n_decode: int,
                   model: ModelPreset, link_bandwidth: float,
                   slo: SLO) -> ServiceMetrics:
    """Dedicated prefill pool (n_prefill) + decode pool (n_decode).

    - Prefill: M/M/c with c = n_prefill; TTFT = W_q + S + KV-transfer.
    - Decode: no prefill interference -> TPOT = base_tpot; stable iff
      lambda*O <= n_decode*decode_tps.
    """
    s_prefill = w.prompt_tokens / node.prefill_tps
    mu = 1.0 / s_prefill
    a = w.arrival_rate * s_prefill
    rho_p = a / n_prefill
    rho_d = w.arrival_rate * w.output_tokens / (n_decode * node.decode_tps)
    t_x = kv_transfer_s(model, w.prompt_tokens, link_bandwidth)
    note = f"Np={n_prefill}/Nd={n_decode} xfer={t_x*1e3:.1f}ms"
    if rho_p >= 1.0 or rho_d >= 1.0:
        return ServiceMetrics(math.inf, math.inf, 0.0, 0.0, False,
                              note + " UNSTABLE")

    tpot = node.base_tpot_ms
    wq = mmc_wait_mean_s(n_prefill, w.arrival_rate, mu)
    ttft_mean_ms = (wq + s_prefill + t_x) * 1e3
    slack_s = slo.ttft_ms / 1e3 - s_prefill - t_x
    ttft_ok = mmc_wait_prob_le_s(n_prefill, w.arrival_rate, mu, slack_s)
    return ServiceMetrics(ttft_mean_ms, tpot, ttft_ok,
                          _goodput(w.arrival_rate, ttft_ok, tpot, slo),
                          True, note)


def best_disagg_split(w: Workload, node: NodeSpec, n_nodes: int,
                      model: ModelPreset, link_bandwidth: float,
                      slo: SLO) -> tuple[int, ServiceMetrics]:
    """Scan every split (N_p in 1..N-1) and return the goodput-maximizing one
    (ties broken by lower mean TTFT). Deterministic exhaustive search."""
    if n_nodes < 2:
        raise ValueError("disaggregation needs >= 2 nodes")
    best: tuple[int, ServiceMetrics] | None = None
    for n_p in range(1, n_nodes):
        m = disagg_metrics(w, node, n_p, n_nodes - n_p, model,
                           link_bandwidth, slo)
        if (best is None
                or m.goodput_rps > best[1].goodput_rps + 1e-12
                or (abs(m.goodput_rps - best[1].goodput_rps) <= 1e-12
                    and m.ttft_mean_ms < best[1].ttft_mean_ms)):
            best = (n_p, m)
    assert best is not None
    return best


#: Bandwidth scan grid for the crossover search (bytes/second).
BW_GRID = [0.625 * GB, 1.25 * GB, 2.5 * GB, 5 * GB, 12.5 * GB,
           25 * GB, 50 * GB, 100 * GB, 200 * GB, 400 * GB]


def crossover_bandwidth(w: Workload, node: NodeSpec, n_nodes: int,
                        model: ModelPreset, slo: SLO,
                        beta: float = BETA_DEFAULT) -> float | None:
    """Smallest grid bandwidth at which disagg goodput STRICTLY beats
    colocated goodput. None if disagg never wins outright on the grid
    (ties go to colocated: fewer moving parts, no transfer dependency)."""
    colo = colocated_metrics(w, node, n_nodes, slo, beta)
    for bw in BW_GRID:
        _, dis = best_disagg_split(w, node, n_nodes, model, bw, slo)
        if dis.goodput_rps > colo.goodput_rps + 1e-9:
            return bw
    return None


#: Workload presets: (Workload, SLO, n_nodes, default link name).
DISAGG_PRESETS: dict[str, tuple[Workload, SLO, int, str]] = {
    # Interactive chat: short prompts, short answers, latency-tolerant SLO.
    "chat": (Workload(8.0, 512, 512), SLO(ttft_ms=800, tpot_ms=30), 8,
             "100gbe"),
    # RAG: huge retrieved context, short answer — prefill-heavy, and the
    # product wants smooth streaming (tight TPOT).
    "rag": (Workload(4.0, 6144, 512), SLO(ttft_ms=2000, tpot_ms=28), 8,
            "100gbe"),
    # Agent: medium context, very long tool-calling generations —
    # decode-heavy with a tight TPOT SLO.
    "agent": (Workload(10.0, 1024, 2048), SLO(ttft_ms=1000, tpot_ms=26), 8,
              "nvlink"),
}

#: Prompt fraction of total tokens for the mix sweep.
MIX_SWEEP = [("prefill-heavy", 0.875), ("balanced", 0.500),
             ("decode-heavy", 0.250)]


# =========================================================================
# CLI / REPORTS
# =========================================================================

RULE = "=" * 76
THIN = "-" * 76


def _fmt_ms(x: float) -> str:
    return "   inf" if math.isinf(x) else f"{x:6.0f}"


def _fmt_speedup(s: float, star: bool) -> str:
    return f"{s:5.2f}{'*' if star else ' '}"


def report_specdec(draft_cost: float, verify_overhead: float,
                   utilization: float, k_max: int,
                   preset_name: str | None) -> None:
    print(RULE)
    print("  PART 1 — SPECULATIVE DECODING  (analytic, i.i.d. acceptance)")
    print(RULE)

    # --- 1. speedup grid -------------------------------------------------
    alphas = [0.6, 0.7, 0.8, 0.9]
    print(f"\n[1] SPEEDUP GRID   d={draft_cost} c={verify_overhead} "
          f"u={utilization}   (* = optimal k for that alpha)")
    header = "  k  " + "".join(f"  a={a:<4}" for a in alphas)
    print(header)
    print("  " + "-" * (len(header) - 2))
    opt = {}
    for a in alphas:
        cfg = SpecDecodeConfig(alpha=a, draft_cost=draft_cost,
                               verify_overhead=verify_overhead,
                               utilization=utilization)
        opt[a] = optimal_k(cfg, k_max)
    for k in range(1, k_max + 1):
        row = f"  {k:<3}"
        for a in alphas:
            cfg = SpecDecodeConfig(alpha=a, draft_cost=draft_cost,
                                   verify_overhead=verify_overhead,
                                   utilization=utilization)
            row += "  " + _fmt_speedup(speedup(cfg, k), opt[a][0] == k)
        print(row)
    opt_line = ", ".join(f"a={a}: k*={opt[a][0]} ({opt[a][1]:.2f}x)"
                         for a in alphas)
    print(f"  optimal k grows with alpha -> {opt_line}")

    # --- 2. presets -------------------------------------------------------
    print(f"\n[2] PRESETS  (best k in 1..{k_max}; speedup at u=0.0 and u=0.7)")
    print(f"  {'preset':<12} {'d':>5} {'alpha':>6} {'c':>5} "
          f"{'k*':>3} {'E[n]':>5} {'x@u=0':>6} {'x@u=.7':>7}")
    print("  " + "-" * 58)
    for name, (cfg, _desc) in SPECDEC_PRESETS.items():
        k0, s0 = optimal_k(replace(cfg, utilization=0.0), k_max)
        _, s7 = optimal_k(replace(cfg, utilization=0.7), k_max)
        print(f"  {name:<12} {cfg.draft_cost:>5.2f} {cfg.alpha:>6.2f} "
              f"{cfg.verify_overhead:>5.2f} {k0:>3} "
              f"{expected_tokens(cfg.alpha, k0):>5.2f} {s0:>5.2f}x "
              f"{s7:>6.2f}x")

    # --- 3. utilization decay ----------------------------------------------
    if preset_name:
        cfg0, desc = SPECDEC_PRESETS[preset_name]
        label = f"preset '{preset_name}'"
        print(f"\n    {preset_name}: {desc}")
    else:
        cfg0 = SpecDecodeConfig(alpha=0.8, draft_cost=draft_cost,
                                verify_overhead=verify_overhead)
        label = f"generic (alpha=0.8, d={draft_cost}, c={verify_overhead})"
    print(f"\n[3] SPEEDUP vs GPU UTILIZATION — {label}")
    print("    (spec decode spends spare compute; at high batch there is none)")
    print(f"  {'u':>5} {'k*':>4} {'speedup':>8}   verdict")
    print("  " + "-" * 44)
    for u in [0.0, 0.3, 0.5, 0.7, 0.9, 0.95, 0.99]:
        k_u, s_u = optimal_k(replace(cfg0, utilization=u), k_max)
        verdict = "helps" if s_u > 1.0 else "HURTS (turn it off)"
        print(f"  {u:>5.2f} {k_u:>4} {s_u:>7.2f}x   {verdict}")
    print()


def report_disagg(preset: str, n_nodes: int | None, link_name: str | None,
                  model_name: str, beta: float) -> None:
    w0, slo, n_default, link_default = DISAGG_PRESETS[preset]
    n = n_nodes or n_default
    link = link_name or link_default
    bw = LINKS[link]
    model = MODELS[model_name]
    node = NodeSpec()

    print(RULE)
    print(f"  PART 2 — COLOCATED vs DISAGGREGATED   preset '{preset}'")
    print(RULE)
    print(f"  workload : lambda={w0.arrival_rate} req/s  "
          f"P={w0.prompt_tokens}  O={w0.output_tokens}")
    print(f"  cluster  : N={n} nodes (prefill {node.prefill_tps:.0f} tok/s, "
          f"decode {node.decode_tps:.0f} tok/s, base TPOT "
          f"{node.base_tpot_ms:.0f} ms)")
    print(f"  KV       : {model.name} "
          f"({model.kv_bytes_per_token()/1024:.0f} KiB/token), link {link} "
          f"({bw/GB:.2f} GB/s), beta={beta}")
    print(f"  SLO      : TTFT <= {slo.ttft_ms:.0f} ms, "
          f"TPOT <= {slo.tpot_ms:.0f} ms")

    total = w0.prompt_tokens + w0.output_tokens
    rows: list[tuple[str, Workload]] = [(f"preset mix", w0)]
    for mix_name, frac in MIX_SWEEP:
        p = max(1, int(total * frac))
        rows.append((mix_name, Workload(w0.arrival_rate, p, total - p)))

    print(f"\n  {'mix':<13} {'P':>5} {'O':>5} | "
          f"{'-- colocated --':^18} | {'---- disaggregated ----':^24} | win")
    print(f"  {'':<13} {'':>5} {'':>5} | "
          f"{'TTFT':>6} {'TPOT':>5} {'good':>5} | "
          f"{'split':>5} {'TTFT':>6} {'TPOT':>5} {'good':>5} |")
    print("  " + "-" * 74)
    for label, w in rows:
        colo = colocated_metrics(w, node, n, slo, beta)
        n_p, dis = best_disagg_split(w, node, n, model, bw, slo)
        if dis.goodput_rps > colo.goodput_rps + 1e-9:
            winner = "disagg"
        elif colo.goodput_rps > dis.goodput_rps + 1e-9:
            winner = "coloc"
        else:
            winner = "tie"
        tpot_c = "  inf" if math.isinf(colo.tpot_ms) else f"{colo.tpot_ms:5.1f}"
        tpot_d = "  inf" if math.isinf(dis.tpot_ms) else f"{dis.tpot_ms:5.1f}"
        print(f"  {label:<13} {w.prompt_tokens:>5} {w.output_tokens:>5} | "
              f"{_fmt_ms(colo.ttft_mean_ms)} {tpot_c} "
              f"{colo.goodput_rps:5.2f} | "
              f"{n_p}p/{n - n_p}d {_fmt_ms(dis.ttft_mean_ms)} {tpot_d} "
              f"{dis.goodput_rps:5.2f} | {winner}")
    print("  (TTFT = mean ms; good = goodput req/s meeting BOTH SLOs; "
          "on a tie prefer coloc)")

    # crossover: minimum link bandwidth where disagg > colocated (preset mix)
    x = crossover_bandwidth(w0, node, n, model, slo, beta)
    print(f"\n[CROSSOVER] preset mix, scanning link bandwidth "
          f"{BW_GRID[0]/GB:.3g}-{BW_GRID[-1]/GB:.0f} GB/s:")
    if x is None:
        print("  disagg never STRICTLY beats colocated on this grid — stay "
              "colocated (simpler,")
        print("  no transfer dependency); the interference penalty fits "
              "inside the TPOT SLO.")
    else:
        print(f"  disagg wins when link >= {x/GB:.3g} GB/s  "
              f"(KV transfer {kv_transfer_s(model, w0.prompt_tokens, x)*1e3:.0f}"
              f" ms at that bandwidth).")
        print("  Below that, the KV-transfer time eats the TTFT budget and "
              "colocated wins.")
    print()


def main(argv: list[str] | None = None) -> None:
    ap = argparse.ArgumentParser(
        description=__doc__.split("\n\n")[0],
        formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = ap.add_subparsers(dest="cmd", required=True)

    sp = sub.add_parser("specdec", help="speculative-decoding economics")
    sp.add_argument("--preset", choices=sorted(SPECDEC_PRESETS),
                    help="parameter bundle for the utilization sweep")
    sp.add_argument("--draft-cost", type=float, default=0.10,
                    help="d = t_draft/t_target for the grid (default 0.10)")
    sp.add_argument("--overhead", type=float, default=0.05,
                    help="c, verification width overhead (default 0.05)")
    sp.add_argument("--util", type=float, default=0.0,
                    help="GPU compute utilization u for the grid (default 0)")
    sp.add_argument("--kmax", type=int, default=8)

    dp = sub.add_parser("disagg", help="colocated vs disaggregated serving")
    dp.add_argument("--preset", choices=sorted(DISAGG_PRESETS),
                    default="chat")
    dp.add_argument("--nodes", type=int, default=None,
                    help="override node count")
    dp.add_argument("--link", choices=sorted(LINKS), default=None,
                    help="override KV-transfer link")
    dp.add_argument("--model", choices=sorted(MODELS),
                    default="llama-70b-gqa8")
    dp.add_argument("--beta", type=float, default=BETA_DEFAULT,
                    help="chunked-prefill interference coefficient")

    sub.add_parser("all", help="run both parts with defaults")

    args = ap.parse_args(argv)
    if args.cmd == "specdec":
        report_specdec(args.draft_cost, args.overhead, args.util,
                       args.kmax, args.preset)
    elif args.cmd == "disagg":
        report_disagg(args.preset, args.nodes, args.link, args.model,
                      args.beta)
    else:  # all
        report_specdec(0.10, 0.05, 0.0, 8, None)
        for preset in ("chat", "rag", "agent"):
            report_disagg(preset, None, None, "llama-70b-gqa8", BETA_DEFAULT)


if __name__ == "__main__":
    main()
