# vllmstat Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build `vllmstat`, an interactive, zero-infra terminal dashboard for a live vLLM server (concurrency, throughput, cache & KV-memory, latency percentiles, speculative-decode acceptance, per-GPU stats) — `nvtop` for vLLM.

**Architecture:** One-directional data flow `Providers → Core/State → UI`. All I/O lives in async providers (`vllm`, `gpu`, `mock`); all derivation is pure and unit-tested (`parse`, `histogram`, `rates`, `kv`, `metrics`, `history`); the Textual UI only renders a `Snapshot`. Read-only.

**Tech Stack:** Python ≥3.10, Textual (TUI), httpx (async scrape), prometheus-client (text parser), nvidia-ml-py (GPU, import-guarded). Tooling: pytest + pytest-asyncio, ruff, pyright. Packaging: hatchling, `src/` layout, console entry `vllmstat`.

**Spec:** `docs/superpowers/specs/2026-06-14-vllmstat-design.md` (read it first).

**Reference metric names (confirmed live on the test server, `vllm:` family):**
`num_requests_running`, `num_requests_waiting`, `num_preemptions_total`,
`generation_tokens_total`, `prompt_tokens_total`, `request_success_total`,
`iteration_tokens_total` (histogram), `prefix_cache_queries_total`,
`prefix_cache_hits_total`, `prompt_tokens_cached_total`,
`prompt_tokens_recomputed_total`, `prompt_tokens_by_source_total{source=}`,
`external_prefix_cache_{queries,hits}_total`, `kv_cache_usage_perc`,
`cache_config_info{...}`, `time_to_first_token_seconds` (hist),
`request_time_per_output_token_seconds` (hist), `e2e_request_latency_seconds` (hist),
`request_queue_time_seconds` (hist), `spec_decode_num_drafts_total`,
`spec_decode_num_draft_tokens_total`, `spec_decode_num_accepted_tokens_total`,
`spec_decode_num_accepted_tokens_per_pos_total`,
`estimated_{flops,read_bytes,write_bytes}_per_gpu_total`.

**Conventions for every task:** TDD (write the failing test first, see it fail, implement minimally, see it pass, commit). Run `ruff check . && ruff format --check . && pyright && pytest -q` before each commit once those tools exist. Commit messages: Conventional Commits; end the body with `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`.

> **Note:** Test fixtures already exist (captured from the live server): `tests/fixtures/metrics_qwen3.txt`, `tests/fixtures/models.json`, `tests/fixtures/model_config.json`. Do not regenerate them.

---

## Phase 0 — Scaffold

### Task 1: Project scaffold, packaging, tooling

**Files:**
- Create: `pyproject.toml`
- Create: `src/vllmstat/__init__.py`
- Create: `src/vllmstat/__main__.py`
- Create: `tests/__init__.py`
- Create: `tests/test_smoke.py`

- [ ] **Step 1: Write `pyproject.toml`**

```toml
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "vllmstat"
version = "0.1.0"
description = "nvtop for vLLM — an interactive terminal dashboard for vLLM serving performance"
readme = "README.md"
requires-python = ">=3.10"
license = "Apache-2.0"
authors = [{ name = "Bryan Vine", email = "bryan@bryanvine.com" }]
keywords = ["vllm", "llm", "tui", "monitoring", "nvtop", "gpu"]
classifiers = [
  "Environment :: Console",
  "Intended Audience :: Developers",
  "License :: OSI Approved :: Apache Software License",
  "Programming Language :: Python :: 3",
  "Topic :: System :: Monitoring",
]
dependencies = [
  "textual>=0.60",
  "httpx>=0.27",
  "prometheus-client>=0.20",
  "nvidia-ml-py>=12.535",
]

[project.optional-dependencies]
dev = ["pytest>=8", "pytest-asyncio>=0.23", "ruff>=0.5", "pyright>=1.1"]

[project.scripts]
vllmstat = "vllmstat.cli:main"

[project.urls]
Homepage = "https://github.com/bryanvine/vllmstat"
Issues = "https://github.com/bryanvine/vllmstat/issues"

[tool.hatch.build.targets.wheel]
packages = ["src/vllmstat"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

[tool.ruff]
line-length = 100
target-version = "py310"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "W"]

[tool.pyright]
include = ["src", "tests"]
pythonVersion = "3.10"
typeCheckingMode = "basic"
```

- [ ] **Step 2: Write package init files**

`src/vllmstat/__init__.py`:
```python
"""vllmstat — an interactive terminal dashboard for vLLM serving performance."""

__version__ = "0.1.0"
```

`src/vllmstat/__main__.py`:
```python
from vllmstat.cli import main

if __name__ == "__main__":
    raise SystemExit(main())
```

`tests/__init__.py`: empty file.

- [ ] **Step 3: Write the smoke test**

`tests/test_smoke.py`:
```python
import vllmstat


def test_version_present():
    assert isinstance(vllmstat.__version__, str)
    assert vllmstat.__version__.count(".") >= 2
```

- [ ] **Step 4: Install editable + dev deps and run the test**

Run: `pip install -e ".[dev]"` then `pytest -q`
Expected: 1 passed. (`__main__` imports `cli` which does not exist yet — that's fine, the smoke test only imports the package.)

- [ ] **Step 5: Commit**

```bash
git add pyproject.toml src tests
git commit -m "chore: scaffold vllmstat package, packaging, and tooling"
```

---

## Phase 1 — Core types & pure logic (TDD, no I/O)

### Task 2: Core dataclasses (`state.py`)

**Files:**
- Create: `src/vllmstat/core/__init__.py` (empty)
- Create: `src/vllmstat/core/state.py`
- Create: `tests/test_state.py`

- [ ] **Step 1: Write the failing test**

`tests/test_state.py`:
```python
from vllmstat.core.state import Quantiles, GpuSample, GpuSnapshot, Snapshot


def test_quantiles_defaults_none():
    q = Quantiles()
    assert q.p50 is None and q.p90 is None and q.p99 is None and q.mean is None


def test_gpu_snapshot_unavailable():
    snap = GpuSnapshot(available=False, source="none", gpus=[], error="no nvml")
    assert snap.available is False
    assert snap.gpus == []


def test_snapshot_minimal_construction():
    s = Snapshot(ts=1.0, connected=True)
    assert s.connected is True
    assert s.running == 0.0
    assert s.spec_active is False
    assert s.gpu.available is False
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/test_state.py -q`
Expected: FAIL (`ModuleNotFoundError: vllmstat.core.state`).

- [ ] **Step 3: Implement `state.py`**

```python
from __future__ import annotations

from dataclasses import dataclass, field


@dataclass(frozen=True)
class Quantiles:
    p50: float | None = None
    p90: float | None = None
    p99: float | None = None
    mean: float | None = None


@dataclass
class GpuSample:
    index: int
    name: str
    util_gpu: float | None = None      # percent 0-100
    mem_used: int | None = None        # bytes
    mem_total: int | None = None       # bytes
    temp_c: float | None = None
    power_w: float | None = None
    power_limit_w: float | None = None
    fan_pct: float | None = None
    clock_sm_mhz: int | None = None
    clock_mem_mhz: int | None = None


@dataclass
class GpuSnapshot:
    available: bool = False
    source: str = "none"               # "nvml" | "nvidia-smi" | "none"
    gpus: list[GpuSample] = field(default_factory=list)
    error: str | None = None


@dataclass
class Snapshot:
    ts: float
    connected: bool
    error: str | None = None
    model_names: list[str] = field(default_factory=list)
    engine_count: int = 0
    max_num_seqs: int | None = None
    # concurrency
    running: float = 0.0
    waiting: float = 0.0
    preempt_rate: float = 0.0
    # throughput
    gen_tps: float = 0.0
    prompt_tps: float = 0.0
    req_rate: float = 0.0
    tokens_per_iter: float | None = None
    # cache reuse
    prefix_hit_window: float | None = None     # 0-1
    prefix_hit_lifetime: float | None = None   # 0-1
    src_compute: float | None = None           # fraction 0-1
    src_cache_hit: float | None = None
    src_external: float | None = None
    cached_tokens_total: float = 0.0
    recomputed_tokens_total: float = 0.0
    external_kv_active: bool = False
    # kv memory / compression
    kv_usage: float = 0.0                      # 0-1
    kv_capacity_tokens: int | None = None
    kv_used_tokens: int | None = None
    kv_dtype: str | None = None
    kv_ratio: float | None = None
    kv_ratio_kind: str = "none"                # "achieved" | "nominal" | "none"
    kv_fp16_equiv_tokens: int | None = None
    kv_fp16_full_ctx_gb: float | None = None
    # latency quantiles (seconds)
    ttft: Quantiles = field(default_factory=Quantiles)
    tpot: Quantiles = field(default_factory=Quantiles)
    e2e: Quantiles = field(default_factory=Quantiles)
    queue: Quantiles = field(default_factory=Quantiles)
    # speculative decoding
    spec_active: bool = False
    spec_acceptance: float | None = None       # accepted / draft_tokens
    spec_accepted_per_draft: float | None = None
    spec_per_pos: list[float] = field(default_factory=list)
    # efficiency (conditional)
    eff_active: bool = False
    gflops: float | None = None
    gbps: float | None = None
    mfu: float | None = None
    bw_util: float | None = None
    # gpu
    gpu: GpuSnapshot = field(default_factory=GpuSnapshot)
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pytest tests/test_state.py -q` → Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/core/__init__.py src/vllmstat/core/state.py tests/test_state.py
git commit -m "feat(core): add snapshot and sample dataclasses"
```

---

### Task 3: Prometheus parser (`parse.py`)

**Files:**
- Create: `src/vllmstat/core/parse.py`
- Create: `tests/test_parse.py`

- [ ] **Step 1: Write the failing test**

`tests/test_parse.py`:
```python
from pathlib import Path

from vllmstat.core.parse import (
    parse_metrics, sum_value, first_value, info_labels, get_buckets,
)

FIX = Path(__file__).parent / "fixtures" / "metrics_qwen3.txt"

SMALL = """\
# HELP vllm:num_requests_running Number of requests running.
# TYPE vllm:num_requests_running gauge
vllm:num_requests_running{engine="0",model_name="m"} 2.0
# HELP vllm:generation_tokens_total gen.
# TYPE vllm:generation_tokens_total counter
vllm:generation_tokens_total{engine="0",model_name="m"} 100.0
# HELP vllm:ttft seconds
# TYPE vllm:ttft histogram
vllm:ttft_bucket{le="0.1"} 1.0
vllm:ttft_bucket{le="0.5"} 3.0
vllm:ttft_bucket{le="+Inf"} 4.0
vllm:ttft_count 4.0
vllm:ttft_sum 1.2
"""


def test_parse_small_gauge_and_counter():
    fam = parse_metrics(SMALL)
    assert sum_value(fam, "vllm:num_requests_running") == 2.0
    assert first_value(fam, "vllm:generation_tokens_total") == 100.0
    assert sum_value(fam, "vllm:does_not_exist") is None


def test_get_buckets_sorted_with_inf():
    fam = parse_metrics(SMALL)
    buckets = get_buckets(fam, "vllm:ttft")
    assert buckets[0] == (0.1, 1.0)
    assert buckets[-1][0] == float("inf")
    assert buckets[-1][1] == 4.0


def test_info_labels():
    text = 'vllm:cache_config_info{cache_dtype="turboquant_k3v4_nc",num_gpu_blocks="6947"} 1.0\n'
    fam = parse_metrics(text)
    labels = info_labels(fam, "vllm:cache_config_info")
    assert labels["cache_dtype"] == "turboquant_k3v4_nc"
    assert labels["num_gpu_blocks"] == "6947"


def test_golden_fixture_has_core_metrics():
    fam = parse_metrics(FIX.read_text())
    for name in (
        "vllm:num_requests_running",
        "vllm:generation_tokens_total",
        "vllm:prefix_cache_hits_total",
        "vllm:kv_cache_usage_perc",
        "vllm:cache_config_info",
    ):
        assert name in fam, name
    labels = info_labels(fam, "vllm:cache_config_info")
    assert labels["cache_dtype"] == "turboquant_k3v4_nc"
    assert labels["num_gpu_blocks"] == "6947"
```

- [ ] **Step 2: Run to verify it fails**

Run: `pytest tests/test_parse.py -q` → Expected: FAIL (module missing).

- [ ] **Step 3: Implement `parse.py`**

```python
from __future__ import annotations

from prometheus_client.parser import text_string_to_metric_families

Families = dict[str, list[tuple[dict[str, str], float]]]


def parse_metrics(text: str) -> Families:
    """Parse Prometheus exposition text into {sample_name: [(labels, value), ...]}."""
    families: Families = {}
    for family in text_string_to_metric_families(text):
        for sample in family.samples:
            families.setdefault(sample.name, []).append((dict(sample.labels), sample.value))
    return families


def sum_value(families: Families, name: str) -> float | None:
    rows = families.get(name)
    if not rows:
        return None
    return sum(v for _, v in rows)


def first_value(families: Families, name: str) -> float | None:
    rows = families.get(name)
    if not rows:
        return None
    return rows[0][1]


def info_labels(families: Families, name: str) -> dict[str, str]:
    rows = families.get(name)
    return rows[0][0] if rows else {}


def get_buckets(families: Families, base: str) -> list[tuple[float, float]]:
    """Aggregate `<base>_bucket` samples across labels, summing counts per `le`."""
    rows = families.get(base + "_bucket", [])
    agg: dict[float, float] = {}
    for labels, value in rows:
        le = float(labels["le"])
        agg[le] = agg.get(le, 0.0) + value
    return sorted(agg.items())


def hist_count(families: Families, base: str) -> float | None:
    return sum_value(families, base + "_count")


def hist_sum(families: Families, base: str) -> float | None:
    return sum_value(families, base + "_sum")
```

- [ ] **Step 4: Run to verify it passes**

Run: `pytest tests/test_parse.py -q` → Expected: 4 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/core/parse.py tests/test_parse.py
git commit -m "feat(core): prometheus text parser with bucket/info helpers"
```

---

### Task 4: Histogram quantiles (`histogram.py`)

**Files:**
- Create: `src/vllmstat/core/histogram.py`
- Create: `tests/test_histogram.py`

- [ ] **Step 1: Write the failing test**

`tests/test_histogram.py`:
```python
import math

from vllmstat.core.histogram import histogram_quantile, windowed_buckets


def test_quantile_linear_interpolation():
    buckets = [(0.1, 1.0), (0.5, 3.0), (float("inf"), 4.0)]
    # total=4, p50 target=2.0 falls in bucket (0.1,0.5]: prev_count=1, count=3
    # frac=(2-1)/(3-1)=0.5 -> 0.1 + 0.5*(0.5-0.1)=0.3
    assert math.isclose(histogram_quantile(buckets, 0.5), 0.3, rel_tol=1e-9)


def test_quantile_empty_or_zero_total():
    assert histogram_quantile([], 0.5) is None
    assert histogram_quantile([(0.1, 0.0), (float("inf"), 0.0)], 0.5) is None


def test_quantile_in_inf_bucket_returns_prev_le():
    buckets = [(0.1, 1.0), (float("inf"), 10.0)]
    # p99 target=9.9 > 1.0 so crosses in +Inf bucket -> returns prev finite le
    assert histogram_quantile(buckets, 0.99) == 0.1


def test_windowed_buckets_subtracts_prev():
    prev = [(0.1, 5.0), (float("inf"), 8.0)]
    cur = [(0.1, 6.0), (float("inf"), 12.0)]
    assert windowed_buckets(prev, cur) == [(0.1, 1.0), (float("inf"), 4.0)]


def test_windowed_buckets_handles_reset():
    prev = [(0.1, 50.0), (float("inf"), 80.0)]
    cur = [(0.1, 1.0), (float("inf"), 2.0)]  # counters reset (smaller)
    # falls back to current (treat prev as zero)
    assert windowed_buckets(prev, cur) == cur
```

- [ ] **Step 2: Run to verify it fails** → `pytest tests/test_histogram.py -q` → FAIL.

- [ ] **Step 3: Implement `histogram.py`**

```python
from __future__ import annotations


def histogram_quantile(buckets: list[tuple[float, float]], q: float) -> float | None:
    """Prometheus-style quantile from cumulative buckets [(le, cum_count), ...] sorted asc."""
    if not buckets:
        return None
    total = buckets[-1][1]
    if total <= 0:
        return None
    target = q * total
    prev_le = 0.0
    prev_count = 0.0
    for le, count in buckets:
        if count >= target:
            if le == float("inf"):
                return prev_le
            bucket_count = count - prev_count
            if bucket_count <= 0:
                return le
            frac = (target - prev_count) / bucket_count
            return prev_le + frac * (le - prev_le)
        prev_le, prev_count = le, count
    return buckets[-1][0]


def windowed_buckets(
    prev: list[tuple[float, float]], cur: list[tuple[float, float]]
) -> list[tuple[float, float]]:
    """Per-`le` delta cur-prev. If any delta is negative (counter reset), return cur."""
    prev_map = dict(prev)
    out: list[tuple[float, float]] = []
    for le, count in cur:
        delta = count - prev_map.get(le, 0.0)
        if delta < 0:
            return cur
        out.append((le, delta))
    return out
```

- [ ] **Step 4: Run to verify it passes** → `pytest tests/test_histogram.py -q` → 5 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/core/histogram.py tests/test_histogram.py
git commit -m "feat(core): windowed histogram quantiles"
```

---

### Task 5: Counter rates with EWMA + reset (`rates.py`)

**Files:**
- Create: `src/vllmstat/core/rates.py`
- Create: `tests/test_rates.py`

- [ ] **Step 1: Write the failing test**

`tests/test_rates.py`:
```python
import math

from vllmstat.core.rates import Rate


def test_first_update_is_zero():
    r = Rate(alpha=0.5)
    assert r.update(100.0, t=0.0) == 0.0


def test_steady_rate_converges():
    r = Rate(alpha=0.5)
    r.update(0.0, t=0.0)
    v = 0.0
    val = 0.0
    for i in range(1, 50):
        v += 10.0
        val = r.update(v, t=float(i))  # +10 per 1s -> 10/s
    assert math.isclose(val, 10.0, rel_tol=1e-3)


def test_counter_reset_does_not_spike():
    r = Rate(alpha=0.5)
    r.update(1000.0, t=0.0)
    r.update(1010.0, t=1.0)
    before = r.value
    after = r.update(5.0, t=2.0)  # reset to a small value
    assert after == before  # no negative/huge spike, value held


def test_zero_dt_held():
    r = Rate(alpha=0.5)
    r.update(0.0, t=1.0)
    r.update(10.0, t=1.0)  # dt=0
    assert r.value == 0.0
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `rates.py`**

```python
from __future__ import annotations


class Rate:
    """EWMA-smoothed per-second rate of a monotonic counter, robust to resets."""

    def __init__(self, alpha: float = 0.3) -> None:
        self.alpha = alpha
        self.value = 0.0
        self._prev_value: float | None = None
        self._prev_t: float | None = None

    def update(self, raw: float, t: float) -> float:
        if self._prev_value is None or self._prev_t is None:
            self._prev_value, self._prev_t = raw, t
            return self.value
        dt = t - self._prev_t
        if dt <= 0:
            return self.value
        if raw < self._prev_value:  # counter reset (server restart)
            self._prev_value, self._prev_t = raw, t
            return self.value
        inst = (raw - self._prev_value) / dt
        self.value = self.alpha * inst + (1 - self.alpha) * self.value
        self._prev_value, self._prev_t = raw, t
        return self.value
```

- [ ] **Step 4: Run to verify it passes** → 4 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/core/rates.py tests/test_rates.py
git commit -m "feat(core): EWMA counter rate with reset handling"
```

---

### Task 6: KV memory & compression (`kv.py`)

**Files:**
- Create: `src/vllmstat/core/kv.py`
- Create: `tests/test_kv.py`

- [ ] **Step 1: Write the failing test**

`tests/test_kv.py`:
```python
import math

from vllmstat.core.kv import (
    parse_kv_bits, nominal_ratio, fp16_bytes_per_token, KvInfo, compute_kv,
)


def test_parse_kv_bits():
    assert parse_kv_bits("turboquant_k3v4_nc") == (3, 4)
    assert parse_kv_bits("auto") == (16, 16)
    assert parse_kv_bits("fp16") == (16, 16)
    assert parse_kv_bits("fp8_e4m3") == (8, 8)
    assert parse_kv_bits("something_weird") is None


def test_nominal_ratio_k3v4():
    assert math.isclose(nominal_ratio("turboquant_k3v4_nc"), 32 / 7, rel_tol=1e-9)
    assert nominal_ratio("fp16") == 1.0
    assert nominal_ratio("unknown") is None


def test_fp16_bytes_per_token_qwen3():
    # 2(K,V) * 48 layers * 4 kv heads * 128 head_dim * 2 bytes = 98304
    assert fp16_bytes_per_token(layers=48, kv_heads=4, head_dim=128) == 98304


def test_compute_kv_nominal_when_no_memory_bytes():
    info = compute_kv(
        cache_dtype="turboquant_k3v4_nc",
        num_gpu_blocks=6947,
        block_size=64,
        kv_usage=0.10,
        kv_cache_memory_bytes=None,
        dims={"layers": 48, "kv_heads": 4, "head_dim": 128},
        max_model_len=262144,
    )
    assert info.capacity_tokens == 6947 * 64  # 444608
    assert info.used_tokens == round(444608 * 0.10)
    assert info.ratio_kind == "nominal"
    assert math.isclose(info.ratio, 32 / 7, rel_tol=1e-9)
    assert info.fp16_equiv_tokens == round(444608 / (32 / 7))
    # fp16 KV for full ctx: 262144 * 98304 bytes = ~25.77 GB
    assert math.isclose(info.fp16_full_ctx_gb, 262144 * 98304 / 1e9, rel_tol=1e-6)


def test_compute_kv_achieved_when_memory_bytes_present():
    # capacity 1000 tok, fp16=98304 B/tok -> fp16 bytes=98_304_000;
    # actual kv memory=10_000_000 -> achieved ratio=9.8304
    info = compute_kv(
        cache_dtype="turboquant_k3v4_nc",
        num_gpu_blocks=1000, block_size=1, kv_usage=0.0,
        kv_cache_memory_bytes=10_000_000,
        dims={"layers": 48, "kv_heads": 4, "head_dim": 128},
        max_model_len=1000,
    )
    assert info.ratio_kind == "achieved"
    assert math.isclose(info.ratio, 98_304_000 / 10_000_000, rel_tol=1e-9)
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `kv.py`**

```python
from __future__ import annotations

import re
from dataclasses import dataclass

_FP16 = {"auto", "fp16", "float16", "bf16", "bfloat16", "half"}


def parse_kv_bits(dtype: str | None) -> tuple[int, int] | None:
    if not dtype:
        return None
    d = dtype.lower()
    if d in _FP16:
        return (16, 16)
    if "fp8" in d or "int8" in d or "e4m3" in d or "e5m2" in d:
        return (8, 8)
    m = re.search(r"k(\d+)v(\d+)", d)
    if m:
        return (int(m.group(1)), int(m.group(2)))
    return None


def nominal_ratio(dtype: str | None) -> float | None:
    bits = parse_kv_bits(dtype)
    if not bits:
        return None
    bk, bv = bits
    return (16 + 16) / (bk + bv)


def fp16_bytes_per_token(layers: int, kv_heads: int, head_dim: int) -> int:
    return 2 * layers * kv_heads * head_dim * 2


@dataclass
class KvInfo:
    dtype: str | None
    capacity_tokens: int | None
    used_tokens: int | None
    ratio: float | None
    ratio_kind: str            # "achieved" | "nominal" | "none"
    fp16_equiv_tokens: int | None
    fp16_full_ctx_gb: float | None


def compute_kv(
    *,
    cache_dtype: str | None,
    num_gpu_blocks: int | None,
    block_size: int | None,
    kv_usage: float,
    kv_cache_memory_bytes: int | None,
    dims: dict[str, int] | None,
    max_model_len: int | None,
) -> KvInfo:
    capacity = None
    if num_gpu_blocks and block_size:
        capacity = num_gpu_blocks * block_size
    used = round(capacity * kv_usage) if capacity is not None else None

    bpt = None
    if dims and all(k in dims for k in ("layers", "kv_heads", "head_dim")):
        bpt = fp16_bytes_per_token(dims["layers"], dims["kv_heads"], dims["head_dim"])

    ratio: float | None = None
    kind = "none"
    if capacity and bpt and kv_cache_memory_bytes:
        ratio = (capacity * bpt) / kv_cache_memory_bytes
        kind = "achieved"
    else:
        nominal = nominal_ratio(cache_dtype)
        if nominal is not None:
            ratio, kind = nominal, "nominal"

    fp16_equiv = round(capacity / ratio) if (capacity and ratio) else None
    fp16_full_ctx_gb = (max_model_len * bpt / 1e9) if (max_model_len and bpt) else None

    return KvInfo(
        dtype=cache_dtype,
        capacity_tokens=capacity,
        used_tokens=used,
        ratio=ratio,
        ratio_kind=kind,
        fp16_equiv_tokens=fp16_equiv,
        fp16_full_ctx_gb=fp16_full_ctx_gb,
    )
```

- [ ] **Step 4: Run to verify it passes** → 5 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/core/kv.py tests/test_kv.py
git commit -m "feat(core): KV capacity, dtype parsing, compression ratio"
```

---

### Task 7: Ring-buffer history (`history.py`)

**Files:**
- Create: `src/vllmstat/core/history.py`
- Create: `tests/test_history.py`

- [ ] **Step 1: Write the failing test**

`tests/test_history.py`:
```python
from vllmstat.core.history import Series, History


def test_series_caps_length():
    s = Series(maxlen=3)
    for i in range(5):
        s.push(float(i))
    assert list(s.values) == [2.0, 3.0, 4.0]


def test_history_named_series_autocreate():
    h = History(maxlen=4)
    h.push("gen_tps", 1.0)
    h.push("gen_tps", 2.0)
    assert list(h.series("gen_tps").values) == [1.0, 2.0]
    assert list(h.series("never_pushed").values) == []
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `history.py`**

```python
from __future__ import annotations

from collections import deque


class Series:
    def __init__(self, maxlen: int = 120) -> None:
        self.values: deque[float] = deque(maxlen=maxlen)

    def push(self, v: float) -> None:
        self.values.append(v)


class History:
    def __init__(self, maxlen: int = 120) -> None:
        self._maxlen = maxlen
        self._series: dict[str, Series] = {}

    def series(self, name: str) -> Series:
        if name not in self._series:
            self._series[name] = Series(self._maxlen)
        return self._series[name]

    def push(self, name: str, value: float) -> None:
        self.series(name).push(value)
```

- [ ] **Step 4: Run to verify it passes** → 2 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/core/history.py tests/test_history.py
git commit -m "feat(core): ring-buffer history for sparklines"
```

---

### Task 8: Metrics engine — derive `Snapshot` (`metrics.py`)

**Files:**
- Create: `src/vllmstat/core/metrics.py`
- Create: `tests/test_metrics.py`

This integrates `parse` + `histogram` + `rates` + `kv` into a `Snapshot`. It keeps the previous parsed families and timestamp to compute windowed rates/quantiles.

- [ ] **Step 1: Write the failing test**

`tests/test_metrics.py`:
```python
from pathlib import Path

from vllmstat.core.metrics import MetricsEngine
from vllmstat.core.parse import parse_metrics

FIX = Path(__file__).parent / "fixtures" / "metrics_qwen3.txt"
DIMS = {"layers": 48, "kv_heads": 4, "head_dim": 128}


def test_derive_from_golden_two_samples_for_rates():
    text = FIX.read_text()
    fam = parse_metrics(text)
    eng = MetricsEngine(dims=DIMS, max_model_len=262144)
    # First derive establishes baselines (rates 0)
    s0 = eng.derive(fam, now=0.0)
    assert s0.connected is True
    assert s0.gen_tps == 0.0
    # Second derive: simulate +1420 generation tokens over 10s -> ~142 tok/s eventually
    fam2 = dict(fam)
    # bump generation_tokens_total by 1420
    base = fam["vllm:generation_tokens_total"][0][1]
    fam2["vllm:generation_tokens_total"] = [(fam["vllm:generation_tokens_total"][0][0], base + 1420)]
    s1 = eng.derive(fam2, now=10.0)
    assert s1.gen_tps > 0.0


def test_kv_and_cache_fields_present():
    fam = parse_metrics(FIX.read_text())
    eng = MetricsEngine(dims=DIMS, max_model_len=262144)
    s = eng.derive(fam, now=0.0)
    assert s.kv_dtype == "turboquant_k3v4_nc"
    assert s.kv_capacity_tokens == 6947 * 64
    assert s.kv_ratio_kind == "nominal"          # memory_bytes is None on fixture
    assert s.prefix_hit_lifetime is not None and 0.0 <= s.prefix_hit_lifetime <= 1.0
    # token sources: local_cache_hit + local_compute fractions sum ~1
    assert s.src_cache_hit is not None
    assert s.spec_active is True                  # fixture has spec decode
    assert s.spec_accepted_per_draft and s.spec_accepted_per_draft > 1.0


def test_efficiency_hidden_when_zero():
    fam = parse_metrics(FIX.read_text())
    eng = MetricsEngine(dims=DIMS, max_model_len=262144)
    s = eng.derive(fam, now=0.0)
    assert s.eff_active is False  # estimated_* are 0 on fixture


def test_latency_quantiles_computed():
    fam = parse_metrics(FIX.read_text())
    eng = MetricsEngine(dims=DIMS, max_model_len=262144)
    eng.derive(fam, now=0.0)
    s = eng.derive(fam, now=1.0)  # same fixture -> windowed delta 0; falls back to lifetime
    # With zero window delta, engine uses cumulative buckets so p50 is defined
    assert s.ttft.p50 is not None
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `metrics.py`**

```python
from __future__ import annotations

from vllmstat.core.histogram import histogram_quantile, windowed_buckets
from vllmstat.core.kv import compute_kv
from vllmstat.core.parse import (
    Families, first_value, get_buckets, hist_count, info_labels, sum_value,
)
from vllmstat.core.rates import Rate
from vllmstat.core.state import Quantiles, Snapshot

_LAT = {
    "ttft": "vllm:time_to_first_token_seconds",
    "tpot": "vllm:request_time_per_output_token_seconds",
    "e2e": "vllm:e2e_request_latency_seconds",
    "queue": "vllm:request_queue_time_seconds",
}


def _int(s: str | None) -> int | None:
    try:
        return int(s) if s not in (None, "None", "") else None
    except (TypeError, ValueError):
        return None


class MetricsEngine:
    def __init__(
        self, *, alpha: float = 0.3, dims: dict[str, int] | None = None,
        max_model_len: int | None = None,
    ) -> None:
        self.dims = dims
        self.max_model_len = max_model_len
        self._gen = Rate(alpha)
        self._prompt = Rate(alpha)
        self._req = Rate(alpha)
        self._preempt = Rate(alpha)
        self._flops = Rate(alpha)
        self._rbytes = Rate(alpha)
        self._wbytes = Rate(alpha)
        self._prev: Families | None = None

    def _quantiles(self, fam: Families, base: str) -> Quantiles:
        cur = get_buckets(fam, base)
        if not cur:
            return Quantiles()
        buckets = cur
        if self._prev is not None:
            prev = get_buckets(self._prev, base)
            if prev:
                delta = windowed_buckets(prev, cur)
                if delta and delta[-1][1] > 0:
                    buckets = delta
        return Quantiles(
            p50=histogram_quantile(buckets, 0.50),
            p90=histogram_quantile(buckets, 0.90),
            p99=histogram_quantile(buckets, 0.99),
        )

    def derive(self, fam: Families, now: float) -> Snapshot:
        labels = info_labels(fam, "vllm:cache_config_info")
        model_names = sorted({
            lbl.get("model_name", "") for lbl, _ in fam.get("vllm:num_requests_running", [])
        } - {""})
        engines = {lbl.get("engine") for lbl, _ in fam.get("vllm:num_requests_running", [])}

        # throughput rates
        gen = self._gen.update(sum_value(fam, "vllm:generation_tokens_total") or 0.0, now)
        prompt = self._prompt.update(sum_value(fam, "vllm:prompt_tokens_total") or 0.0, now)
        req = self._req.update(sum_value(fam, "vllm:request_success_total") or 0.0, now)
        preempt = self._preempt.update(sum_value(fam, "vllm:num_preemptions_total") or 0.0, now)

        # tokens/iter mean
        it_sum = sum_value(fam, "vllm:iteration_tokens_total_sum")
        it_cnt = sum_value(fam, "vllm:iteration_tokens_total_count")
        tokens_per_iter = (it_sum / it_cnt) if (it_sum and it_cnt) else None

        # cache reuse
        q = sum_value(fam, "vllm:prefix_cache_queries_total") or 0.0
        h = sum_value(fam, "vllm:prefix_cache_hits_total") or 0.0
        hit_life = (h / q) if q > 0 else None
        hit_win = None
        if self._prev is not None:
            pq = sum_value(self._prev, "vllm:prefix_cache_queries_total") or 0.0
            ph = sum_value(self._prev, "vllm:prefix_cache_hits_total") or 0.0
            dq, dh = q - pq, h - ph
            if dq > 0:
                hit_win = max(0.0, min(1.0, dh / dq))

        # token sources
        src = {lbl.get("source"): v for lbl, v in fam.get("vllm:prompt_tokens_by_source_total", [])}
        src_total = sum(src.values()) or 0.0
        frac = lambda k: (src.get(k, 0.0) / src_total) if src_total > 0 else None  # noqa: E731

        ext_q = sum_value(fam, "vllm:external_prefix_cache_queries_total") or 0.0
        external_active = ext_q > 0 or (src.get("external_kv_transfer", 0.0) > 0)

        # kv memory
        kv_usage = first_value(fam, "vllm:kv_cache_usage_perc") or 0.0
        kv = compute_kv(
            cache_dtype=labels.get("cache_dtype"),
            num_gpu_blocks=_int(labels.get("num_gpu_blocks")),
            block_size=_int(labels.get("block_size")),
            kv_usage=kv_usage,
            kv_cache_memory_bytes=_int(labels.get("kv_cache_memory_bytes")),
            dims=self.dims,
            max_model_len=self.max_model_len,
        )

        # spec decode
        drafts = sum_value(fam, "vllm:spec_decode_num_drafts_total")
        draft_tokens = sum_value(fam, "vllm:spec_decode_num_draft_tokens_total")
        accepted = sum_value(fam, "vllm:spec_decode_num_accepted_tokens_total")
        spec_active = bool(drafts and draft_tokens)
        spec_acceptance = (accepted / draft_tokens) if (spec_active and accepted is not None) else None
        spec_per_draft = (accepted / drafts) if (spec_active and drafts and accepted is not None) else None

        # efficiency (conditional)
        flops = self._flops.update(sum_value(fam, "vllm:estimated_flops_per_gpu_total") or 0.0, now)
        rbytes = self._rbytes.update(sum_value(fam, "vllm:estimated_read_bytes_per_gpu_total") or 0.0, now)
        wbytes = self._wbytes.update(sum_value(fam, "vllm:estimated_write_bytes_per_gpu_total") or 0.0, now)
        eff_active = (flops > 0) or (rbytes + wbytes > 0)

        snap = Snapshot(
            ts=now,
            connected=True,
            model_names=model_names or ([labels.get("model_name")] if labels.get("model_name") else []),
            engine_count=len([e for e in engines if e is not None]) or 1,
            running=sum_value(fam, "vllm:num_requests_running") or 0.0,
            waiting=sum_value(fam, "vllm:num_requests_waiting") or 0.0,
            preempt_rate=preempt,
            gen_tps=gen,
            prompt_tps=prompt,
            req_rate=req,
            tokens_per_iter=tokens_per_iter,
            prefix_hit_window=hit_win,
            prefix_hit_lifetime=hit_life,
            src_compute=frac("local_compute"),
            src_cache_hit=frac("local_cache_hit"),
            src_external=frac("external_kv_transfer"),
            cached_tokens_total=sum_value(fam, "vllm:prompt_tokens_cached_total") or 0.0,
            recomputed_tokens_total=sum_value(fam, "vllm:prompt_tokens_recomputed_total") or 0.0,
            external_kv_active=external_active,
            kv_usage=kv_usage,
            kv_capacity_tokens=kv.capacity_tokens,
            kv_used_tokens=kv.used_tokens,
            kv_dtype=kv.dtype,
            kv_ratio=kv.ratio,
            kv_ratio_kind=kv.ratio_kind,
            kv_fp16_equiv_tokens=kv.fp16_equiv_tokens,
            kv_fp16_full_ctx_gb=kv.fp16_full_ctx_gb,
            ttft=self._quantiles(fam, _LAT["ttft"]),
            tpot=self._quantiles(fam, _LAT["tpot"]),
            e2e=self._quantiles(fam, _LAT["e2e"]),
            queue=self._quantiles(fam, _LAT["queue"]),
            spec_active=spec_active,
            spec_acceptance=spec_acceptance,
            spec_accepted_per_draft=spec_per_draft,
            eff_active=eff_active,
            gflops=(flops / 1e9) if eff_active else None,
            gbps=((rbytes + wbytes) / 1e9) if eff_active else None,
        )
        self._prev = fam
        return snap
```

- [ ] **Step 4: Run to verify it passes** → `pytest tests/test_metrics.py -q` → 4 passed. Then run the whole suite: `pytest -q`.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/core/metrics.py tests/test_metrics.py
git commit -m "feat(core): metrics engine deriving Snapshot from families"
```

---

## Phase 2 — Providers (I/O)

### Task 9: vLLM provider (`providers/vllm.py`)

**Files:**
- Create: `src/vllmstat/providers/__init__.py` (empty)
- Create: `src/vllmstat/providers/vllm.py`
- Create: `tests/test_provider_vllm.py`

The provider fetches `/metrics` (text) and `/v1/models` (json) over httpx, returning raw text + parsed model info. It must support an injected `httpx.AsyncClient` for testing via `MockTransport`.

- [ ] **Step 1: Write the failing test**

`tests/test_provider_vllm.py`:
```python
import json
from pathlib import Path

import httpx

from vllmstat.providers.vllm import VllmProvider

FIXDIR = Path(__file__).parent / "fixtures"


def _client() -> httpx.AsyncClient:
    metrics = (FIXDIR / "metrics_qwen3.txt").read_text()
    models = json.loads((FIXDIR / "models.json").read_text())

    def handler(request: httpx.Request) -> httpx.Response:
        if request.url.path == "/metrics":
            return httpx.Response(200, text=metrics)
        if request.url.path == "/v1/models":
            return httpx.Response(200, json=models)
        return httpx.Response(404)

    return httpx.AsyncClient(transport=httpx.MockTransport(handler), base_url="http://t")


async def test_fetch_metrics_ok():
    p = VllmProvider(base_url="http://t", client=_client())
    raw = await p.fetch_metrics()
    assert raw.fetched_ok is True
    assert "vllm:num_requests_running" in raw.text


async def test_fetch_models_extracts_root_and_ctx():
    p = VllmProvider(base_url="http://t", client=_client())
    info = await p.fetch_model_info()
    assert info.model_names == ["qwen3-30b-tq"]
    assert info.max_model_len == 262144
    assert info.root and "Qwen3-30B" in info.root


async def test_fetch_metrics_error_sets_flag():
    def boom(request: httpx.Request) -> httpx.Response:
        raise httpx.ConnectError("refused")

    client = httpx.AsyncClient(transport=httpx.MockTransport(boom), base_url="http://t")
    p = VllmProvider(base_url="http://t", client=client)
    raw = await p.fetch_metrics()
    assert raw.fetched_ok is False
    assert raw.error is not None
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `providers/vllm.py`**

```python
from __future__ import annotations

from dataclasses import dataclass

import httpx


@dataclass
class RawText:
    text: str
    fetched_ok: bool
    error: str | None = None


@dataclass
class ModelInfo:
    model_names: list[str]
    max_model_len: int | None
    root: str | None


class VllmProvider:
    def __init__(
        self, *, base_url: str, metrics_path: str = "/metrics",
        api_key: str | None = None, timeout: float = 5.0,
        client: httpx.AsyncClient | None = None,
    ) -> None:
        self.base_url = base_url.rstrip("/")
        self.metrics_path = metrics_path
        self._timeout = timeout
        headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
        self._client = client or httpx.AsyncClient(base_url=self.base_url, headers=headers)

    async def fetch_metrics(self) -> RawText:
        try:
            r = await self._client.get(self.metrics_path, timeout=self._timeout)
            r.raise_for_status()
            return RawText(text=r.text, fetched_ok=True)
        except Exception as e:  # noqa: BLE001 - surface any transport/HTTP error to UI
            return RawText(text="", fetched_ok=False, error=str(e))

    async def fetch_model_info(self) -> ModelInfo:
        try:
            r = await self._client.get("/v1/models", timeout=self._timeout)
            r.raise_for_status()
            data = r.json().get("data", [])
        except Exception:  # noqa: BLE001
            return ModelInfo(model_names=[], max_model_len=None, root=None)
        names = [d.get("id") for d in data if d.get("id")]
        max_len = next((d.get("max_model_len") for d in data if d.get("max_model_len")), None)
        root = next((d.get("root") for d in data if d.get("root")), None)
        return ModelInfo(model_names=names, max_model_len=max_len, root=root)

    async def aclose(self) -> None:
        await self._client.aclose()
```

- [ ] **Step 4: Run to verify it passes** → 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/providers/__init__.py src/vllmstat/providers/vllm.py tests/test_provider_vllm.py
git commit -m "feat(providers): async vLLM /metrics and /v1/models fetcher"
```

---

### Task 10: Mock provider (`providers/mock.py`)

**Files:**
- Create: `src/vllmstat/providers/mock.py`
- Create: `tests/test_provider_mock.py`

Generates deterministic synthetic `/metrics` text (varying by a tick counter) so the UI can run with `--mock` and CI/screenshots are reproducible.

- [ ] **Step 1: Write the failing test**

`tests/test_provider_mock.py`:
```python
from vllmstat.core.metrics import MetricsEngine
from vllmstat.core.parse import parse_metrics
from vllmstat.providers.mock import MockProvider


def test_mock_emits_parseable_metrics_that_vary():
    p = MockProvider()
    t0 = p.metrics_text()
    t1 = p.metrics_text()
    assert "vllm:generation_tokens_total" in t0
    f0 = parse_metrics(t0)
    f1 = parse_metrics(t1)
    g0 = f0["vllm:generation_tokens_total"][0][1]
    g1 = f1["vllm:generation_tokens_total"][0][1]
    assert g1 > g0  # counter advances


def test_mock_drives_engine():
    p = MockProvider()
    eng = MetricsEngine(dims={"layers": 48, "kv_heads": 4, "head_dim": 128}, max_model_len=262144)
    eng.derive(parse_metrics(p.metrics_text()), now=0.0)
    s = eng.derive(parse_metrics(p.metrics_text()), now=1.0)
    assert s.gen_tps > 0.0
    assert s.kv_dtype is not None
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `providers/mock.py`**

```python
from __future__ import annotations

import math


class MockProvider:
    """Deterministic synthetic vLLM metrics for --mock and tests."""

    def __init__(self) -> None:
        self._tick = 0
        self._gen = 1_000_000.0
        self._prompt = 3_000_000.0
        self._q = 5_000_000.0
        self._h = 1_500_000.0

    def metrics_text(self) -> str:
        self._tick += 1
        t = self._tick
        running = 2 + int(2 * (math.sin(t / 3) + 1))
        waiting = max(0, int(3 * math.sin(t / 5)))
        self._gen += 120 + 40 * math.sin(t / 2)
        self._prompt += 300 + 80 * math.cos(t / 2)
        self._q += 1000
        self._h += 400 + 50 * math.sin(t / 4)
        kv = 0.10 + 0.05 * (math.sin(t / 6) + 1)
        ttft_buckets = self._hist("vllm:time_to_first_token_seconds", base=0.05, n=t)
        tpot_buckets = self._hist("vllm:request_time_per_output_token_seconds", base=0.01, n=t)
        return f"""\
# TYPE vllm:num_requests_running gauge
vllm:num_requests_running{{engine="0",model_name="mock-7b"}} {running}.0
# TYPE vllm:num_requests_waiting gauge
vllm:num_requests_waiting{{engine="0",model_name="mock-7b"}} {waiting}.0
# TYPE vllm:num_preemptions_total counter
vllm:num_preemptions_total{{engine="0",model_name="mock-7b"}} 0.0
# TYPE vllm:generation_tokens_total counter
vllm:generation_tokens_total{{engine="0",model_name="mock-7b"}} {self._gen:.1f}
# TYPE vllm:prompt_tokens_total counter
vllm:prompt_tokens_total{{engine="0",model_name="mock-7b"}} {self._prompt:.1f}
# TYPE vllm:request_success_total counter
vllm:request_success_total{{engine="0",model_name="mock-7b"}} {t * 4}.0
# TYPE vllm:prefix_cache_queries_total counter
vllm:prefix_cache_queries_total{{engine="0",model_name="mock-7b"}} {self._q:.1f}
# TYPE vllm:prefix_cache_hits_total counter
vllm:prefix_cache_hits_total{{engine="0",model_name="mock-7b"}} {self._h:.1f}
# TYPE vllm:prompt_tokens_cached_total counter
vllm:prompt_tokens_cached_total{{engine="0",model_name="mock-7b"}} {self._h:.1f}
# TYPE vllm:prompt_tokens_recomputed_total counter
vllm:prompt_tokens_recomputed_total{{engine="0",model_name="mock-7b"}} 12.0
# TYPE vllm:prompt_tokens_by_source_total counter
vllm:prompt_tokens_by_source_total{{engine="0",model_name="mock-7b",source="local_compute"}} {self._prompt * 0.7:.1f}
vllm:prompt_tokens_by_source_total{{engine="0",model_name="mock-7b",source="local_cache_hit"}} {self._prompt * 0.3:.1f}
vllm:prompt_tokens_by_source_total{{engine="0",model_name="mock-7b",source="external_kv_transfer"}} 0.0
# TYPE vllm:kv_cache_usage_perc gauge
vllm:kv_cache_usage_perc{{engine="0",model_name="mock-7b"}} {kv:.4f}
# TYPE vllm:cache_config_info gauge
vllm:cache_config_info{{block_size="16",cache_dtype="fp8_e4m3",num_gpu_blocks="20000",enable_prefix_caching="True",engine="0"}} 1.0
# TYPE vllm:iteration_tokens_total histogram
vllm:iteration_tokens_total_sum{{engine="0",model_name="mock-7b"}} {t * 1024}.0
vllm:iteration_tokens_total_count{{engine="0",model_name="mock-7b"}} {t}.0
# TYPE vllm:spec_decode_num_drafts_total counter
vllm:spec_decode_num_drafts_total{{engine="0",model_name="mock-7b"}} {t * 100}.0
# TYPE vllm:spec_decode_num_draft_tokens_total counter
vllm:spec_decode_num_draft_tokens_total{{engine="0",model_name="mock-7b"}} {t * 500}.0
# TYPE vllm:spec_decode_num_accepted_tokens_total counter
vllm:spec_decode_num_accepted_tokens_total{{engine="0",model_name="mock-7b"}} {t * 210}.0
# TYPE vllm:estimated_flops_per_gpu_total counter
vllm:estimated_flops_per_gpu_total{{engine="0",model_name="mock-7b"}} 0.0
{ttft_buckets}
{tpot_buckets}
"""

    def _hist(self, base: str, *, base_le: float = 0.05, n: int = 1, **kw) -> str:
        base_le = kw.get("base", base_le)
        les = [base_le * m for m in (1, 2, 4, 8, 16, 32)]
        lines = [f"# TYPE {base} histogram"]
        cum = 0.0
        for le in les:
            cum += n  # cumulative grows each tick
            lines.append(f'{base}_bucket{{le="{le}"}} {cum:.1f}')
        lines.append(f'{base}_bucket{{le="+Inf"}} {cum + n:.1f}')
        lines.append(f"{base}_count {cum + n:.1f}")
        lines.append(f"{base}_sum {cum * base_le:.3f}")
        return "\n".join(lines)
```

> Note: the `_hist` helper signature accepts `base` as the le-scale via `**kw` to keep the call sites in `metrics_text` short; keep its body exactly as above so cumulative counts increase per tick.

- [ ] **Step 4: Run to verify it passes** → 2 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/providers/mock.py tests/test_provider_mock.py
git commit -m "feat(providers): deterministic mock metrics generator"
```

---

### Task 11: GPU provider (`providers/gpu.py`)

**Files:**
- Create: `src/vllmstat/providers/gpu.py`
- Create: `tests/test_provider_gpu.py`

NVML via an injectable module; pure `nvidia-smi` CSV parser; graceful `none`.

- [ ] **Step 1: Write the failing test**

`tests/test_provider_gpu.py`:
```python
from vllmstat.providers.gpu import read_nvml, parse_nvidia_smi_csv, GpuProvider


class FakeNvml:
    NVML_TEMPERATURE_GPU = 0
    NVML_CLOCK_SM = 1
    NVML_CLOCK_MEM = 2

    def nvmlInit(self): ...
    def nvmlShutdown(self): ...
    def nvmlDeviceGetCount(self): return 1
    def nvmlDeviceGetHandleByIndex(self, i): return i
    def nvmlDeviceGetName(self, h): return "NVIDIA Test GPU"
    def nvmlDeviceGetUtilizationRates(self, h):
        class U: gpu = 81; memory = 60
        return U()
    def nvmlDeviceGetMemoryInfo(self, h):
        class M: used = 23_100_000_000; total = 24_000_000_000
        return M()
    def nvmlDeviceGetTemperature(self, h, sensor): return 61
    def nvmlDeviceGetPowerUsage(self, h): return 142_000      # mW
    def nvmlDeviceGetEnforcedPowerLimit(self, h): return 200_000
    def nvmlDeviceGetFanSpeed(self, h): return 45
    def nvmlDeviceGetClockInfo(self, h, c): return 2520 if c == 1 else 9501


def test_read_nvml_builds_samples():
    snap = read_nvml(FakeNvml())
    assert snap.available and snap.source == "nvml"
    g = snap.gpus[0]
    assert g.name == "NVIDIA Test GPU"
    assert g.util_gpu == 81
    assert g.mem_used == 23_100_000_000
    assert g.power_w == 142.0
    assert g.clock_sm_mhz == 2520


def test_parse_nvidia_smi_csv():
    line = "0, NVIDIA Test GPU, 81, 23100, 24000, 61, 142.0, 200.0, 2520, 9501, 45"
    gpus = parse_nvidia_smi_csv(line)
    g = gpus[0]
    assert g.index == 0 and g.name == "NVIDIA Test GPU"
    assert g.mem_used == 23100 * 1024 * 1024  # MiB -> bytes
    assert g.power_w == 142.0


def test_provider_disabled_returns_unavailable():
    snap = GpuProvider(enabled=False).sample()
    assert snap.available is False and snap.source == "none"
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `providers/gpu.py`**

```python
from __future__ import annotations

import shutil
import subprocess

from vllmstat.core.state import GpuSample, GpuSnapshot

_SMI_QUERY = (
    "index,name,utilization.gpu,memory.used,memory.total,temperature.gpu,"
    "power.draw,power.limit,clocks.sm,clocks.mem,fan.speed"
)


def _f(x: str) -> float | None:
    x = x.strip()
    if not x or x.upper() in ("N/A", "[N/A]"):
        return None
    try:
        return float(x)
    except ValueError:
        return None


def read_nvml(nvml) -> GpuSnapshot:
    nvml.nvmlInit()
    try:
        gpus: list[GpuSample] = []
        for i in range(nvml.nvmlDeviceGetCount()):
            h = nvml.nvmlDeviceGetHandleByIndex(i)
            util = nvml.nvmlDeviceGetUtilizationRates(h)
            mem = nvml.nvmlDeviceGetMemoryInfo(h)
            name = nvml.nvmlDeviceGetName(h)
            if isinstance(name, bytes):
                name = name.decode()

            def _try(fn, *a):
                try:
                    return fn(*a)
                except Exception:  # noqa: BLE001 - optional metric not supported
                    return None

            power = _try(nvml.nvmlDeviceGetPowerUsage, h)
            limit = _try(nvml.nvmlDeviceGetEnforcedPowerLimit, h)
            gpus.append(GpuSample(
                index=i, name=name,
                util_gpu=float(util.gpu),
                mem_used=int(mem.used), mem_total=int(mem.total),
                temp_c=_try(nvml.nvmlDeviceGetTemperature, h, nvml.NVML_TEMPERATURE_GPU),
                power_w=(power / 1000.0) if power is not None else None,
                power_limit_w=(limit / 1000.0) if limit is not None else None,
                fan_pct=_try(nvml.nvmlDeviceGetFanSpeed, h),
                clock_sm_mhz=_try(nvml.nvmlDeviceGetClockInfo, h, nvml.NVML_CLOCK_SM),
                clock_mem_mhz=_try(nvml.nvmlDeviceGetClockInfo, h, nvml.NVML_CLOCK_MEM),
            ))
        return GpuSnapshot(available=True, source="nvml", gpus=gpus)
    finally:
        try:
            nvml.nvmlShutdown()
        except Exception:  # noqa: BLE001
            pass


def parse_nvidia_smi_csv(text: str) -> list[GpuSample]:
    gpus: list[GpuSample] = []
    for line in text.strip().splitlines():
        parts = [p.strip() for p in line.split(",")]
        if len(parts) < 11:
            continue
        mu, mt = _f(parts[3]), _f(parts[4])
        gpus.append(GpuSample(
            index=int(_f(parts[0]) or 0),
            name=parts[1],
            util_gpu=_f(parts[2]),
            mem_used=int(mu * 1024 * 1024) if mu is not None else None,
            mem_total=int(mt * 1024 * 1024) if mt is not None else None,
            temp_c=_f(parts[5]),
            power_w=_f(parts[6]),
            power_limit_w=_f(parts[7]),
            clock_sm_mhz=int(_f(parts[8])) if _f(parts[8]) is not None else None,
            clock_mem_mhz=int(_f(parts[9])) if _f(parts[9]) is not None else None,
            fan_pct=_f(parts[10]),
        ))
    return gpus


class GpuProvider:
    def __init__(self, *, enabled: bool = True) -> None:
        self.enabled = enabled
        self._mode: str | None = None
        self._nvml = None

    def sample(self) -> GpuSnapshot:
        if not self.enabled:
            return GpuSnapshot(available=False, source="none", error="disabled")
        # try NVML
        if self._mode in (None, "nvml"):
            try:
                if self._nvml is None:
                    import pynvml  # nvidia-ml-py exposes the `pynvml` module
                    self._nvml = pynvml
                snap = read_nvml(self._nvml)
                self._mode = "nvml"
                return snap
            except Exception:  # noqa: BLE001 - fall back to nvidia-smi
                self._nvml = None
        # try nvidia-smi
        smi = shutil.which("nvidia-smi")
        if smi:
            try:
                out = subprocess.run(
                    [smi, f"--query-gpu={_SMI_QUERY}", "--format=csv,noheader,nounits"],
                    capture_output=True, text=True, timeout=3, check=True,
                ).stdout
                self._mode = "nvidia-smi"
                return GpuSnapshot(available=True, source="nvidia-smi", gpus=parse_nvidia_smi_csv(out))
            except Exception:  # noqa: BLE001
                pass
        return GpuSnapshot(available=False, source="none", error="no NVML or nvidia-smi")
```

- [ ] **Step 4: Run to verify it passes** → 3 passed. Run full suite `pytest -q`.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/providers/gpu.py tests/test_provider_gpu.py
git commit -m "feat(providers): GPU snapshot via NVML with nvidia-smi fallback"
```

---

## Phase 3 — Config & CLI

### Task 12: Config (`config.py`)

**Files:**
- Create: `src/vllmstat/config.py`
- Create: `tests/test_config.py`

- [ ] **Step 1: Write the failing test**

`tests/test_config.py`:
```python
from vllmstat.config import Config


def test_defaults():
    c = Config.from_sources(argv=[], env={})
    assert c.url == "http://localhost:8000"
    assert c.interval == 1.0
    assert c.gpu is True
    assert c.mock is False


def test_flags_override_env():
    c = Config.from_sources(
        argv=["--url", "http://h:9", "-i", "2.5", "--no-gpu", "--mock"],
        env={"VLLM_API_KEY": "secret"},
    )
    assert c.url == "http://h:9"
    assert c.interval == 2.5
    assert c.gpu is False
    assert c.mock is True
    assert c.api_key == "secret"


def test_once_json_flags():
    c = Config.from_sources(argv=["--once", "--json"], env={})
    assert c.once is True and c.json is True
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `config.py`**

```python
from __future__ import annotations

import argparse
from dataclasses import dataclass

from vllmstat import __version__


@dataclass
class Config:
    url: str = "http://localhost:8000"
    metrics_path: str = "/metrics"
    interval: float = 1.0
    api_key: str | None = None
    gpu: bool = True
    mock: bool = False
    once: bool = False
    json: bool = False

    @staticmethod
    def build_parser() -> argparse.ArgumentParser:
        p = argparse.ArgumentParser(prog="vllmstat", description="nvtop for vLLM")
        p.add_argument("-u", "--url", default="http://localhost:8000")
        p.add_argument("--metrics-path", default="/metrics")
        p.add_argument("-i", "--interval", type=float, default=1.0)
        p.add_argument("--api-key", default=None)
        p.add_argument("--no-gpu", dest="gpu", action="store_false", default=True)
        p.add_argument("--mock", action="store_true", default=False)
        p.add_argument("--once", action="store_true", default=False)
        p.add_argument("--json", action="store_true", default=False)
        p.add_argument("--version", action="version", version=f"vllmstat {__version__}")
        return p

    @classmethod
    def from_sources(cls, argv: list[str], env: dict[str, str]) -> "Config":
        ns = cls.build_parser().parse_args(argv)
        api_key = ns.api_key or env.get("VLLM_API_KEY")
        return cls(
            url=ns.url, metrics_path=ns.metrics_path, interval=ns.interval,
            api_key=api_key, gpu=ns.gpu, mock=ns.mock, once=ns.once, json=ns.json,
        )
```

- [ ] **Step 4: Run to verify it passes** → 3 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/config.py tests/test_config.py
git commit -m "feat: CLI config from flags + env"
```

---

### Task 13: CLI + `--once --json` (`cli.py`)

**Files:**
- Create: `src/vllmstat/cli.py`
- Create: `src/vllmstat/snapshot_json.py`
- Create: `tests/test_cli_once.py`

`--once --json` derives one Snapshot (two quick mock/real samples to seed rates) and prints JSON, no TUI. The TUI path is wired in Task 14+ (`run_app`).

- [ ] **Step 1: Write the failing test**

`tests/test_cli_once.py`:
```python
import json

from vllmstat.cli import run_once_json


def test_run_once_json_mock(capsys):
    rc = run_once_json(["--mock", "--once", "--json"])
    assert rc == 0
    out = json.loads(capsys.readouterr().out)
    assert "gen_tps" in out
    assert out["kv"]["dtype"] is not None
    assert "running" in out
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `snapshot_json.py` and `cli.py`**

`src/vllmstat/snapshot_json.py`:
```python
from __future__ import annotations

from dataclasses import asdict

from vllmstat.core.state import Snapshot


def snapshot_to_dict(s: Snapshot) -> dict:
    d = asdict(s)
    # group kv fields for readability
    d["kv"] = {
        "dtype": s.kv_dtype, "capacity_tokens": s.kv_capacity_tokens,
        "used_tokens": s.kv_used_tokens, "ratio": s.kv_ratio,
        "ratio_kind": s.kv_ratio_kind, "fp16_equiv_tokens": s.kv_fp16_equiv_tokens,
        "fp16_full_ctx_gb": s.kv_fp16_full_ctx_gb, "usage": s.kv_usage,
    }
    return d
```

`src/vllmstat/cli.py`:
```python
from __future__ import annotations

import json
import os
import sys
import time

from vllmstat.config import Config
from vllmstat.core.metrics import MetricsEngine
from vllmstat.core.parse import parse_metrics
from vllmstat.providers.mock import MockProvider
from vllmstat.snapshot_json import snapshot_to_dict


def run_once_json(argv: list[str]) -> int:
    cfg = Config.from_sources(argv, dict(os.environ))
    eng = MetricsEngine(dims=None, max_model_len=None)
    if cfg.mock:
        mp = MockProvider()
        eng.derive(parse_metrics(mp.metrics_text()), now=0.0)
        snap = eng.derive(parse_metrics(mp.metrics_text()), now=1.0)
    else:
        import asyncio

        from vllmstat.providers.vllm import VllmProvider

        async def _go():
            p = VllmProvider(base_url=cfg.url, metrics_path=cfg.metrics_path, api_key=cfg.api_key)
            r0 = await p.fetch_metrics()
            time.sleep(min(cfg.interval, 1.0))
            r1 = await p.fetch_metrics()
            await p.aclose()
            return r0, r1

        r0, r1 = asyncio.run(_go())
        if not r1.fetched_ok:
            print(json.dumps({"error": r1.error}), file=sys.stderr)
            return 1
        eng.derive(parse_metrics(r0.text), now=0.0)
        snap = eng.derive(parse_metrics(r1.text), now=1.0)
    print(json.dumps(snapshot_to_dict(snap), default=str))
    return 0


def main(argv: list[str] | None = None) -> int:
    argv = list(sys.argv[1:] if argv is None else argv)
    cfg = Config.from_sources(argv, dict(os.environ))
    if cfg.once and cfg.json:
        return run_once_json(argv)
    from vllmstat.app import run_app  # imported lazily so --once/--json needs no Textual

    return run_app(cfg)
```

- [ ] **Step 4: Run to verify it passes** → 1 passed. (Note: `run_app` does not exist yet; `run_once_json` is tested independently and does not import `app`.)

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/cli.py src/vllmstat/snapshot_json.py tests/test_cli_once.py
git commit -m "feat(cli): entrypoint with --once --json snapshot mode"
```

---

## Phase 4 — TUI (Textual)

> Rendering approach: each panel is a Textual `Static` widget. The app holds the latest `Snapshot` and on each tick builds Rich renderables and calls `widget.update(renderable)`. Widgets stay dumb. Layout mirrors the spec mockup (§8.1). UI tasks are verified with Textual's async `App.run_test()` pilot harness driving the `--mock` provider.

### Task 14: Formatting helpers + sparkline (`format.py`)

**Files:**
- Create: `src/vllmstat/format.py`
- Create: `tests/test_format.py`

- [ ] **Step 1: Write the failing test**

`tests/test_format.py`:
```python
from vllmstat.format import sparkline, fmt_si, fmt_bytes, fmt_dur, fmt_pct


def test_sparkline_basic():
    out = sparkline([0, 1, 2, 3, 4, 5, 6, 7])
    assert len(out) == 8
    assert out[0] == "▁" and out[-1] == "█"


def test_sparkline_empty_and_flat():
    assert sparkline([]) == ""
    assert set(sparkline([5, 5, 5])) <= set("▁▂▃▄▅▆▇█")


def test_fmt_si():
    assert fmt_si(1500) == "1.5k"
    assert fmt_si(5_762_688) == "5.8M"
    assert fmt_si(42) == "42"


def test_fmt_bytes():
    assert fmt_bytes(24_000_000_000).endswith("GB") or fmt_bytes(24_000_000_000).endswith("GiB")


def test_fmt_dur():
    assert fmt_dur(0.073).endswith("ms")
    assert fmt_dur(1.8).endswith("s")
    assert fmt_dur(None) == "—"


def test_fmt_pct():
    assert fmt_pct(0.381) == "38.1%"
    assert fmt_pct(None) == "—"
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `format.py`**

```python
from __future__ import annotations

from collections.abc import Sequence

_SPARK = "▁▂▃▄▅▆▇█"


def sparkline(values: Sequence[float]) -> str:
    vals = [v for v in values if v is not None]
    if not vals:
        return ""
    lo, hi = min(vals), max(vals)
    span = hi - lo
    if span <= 0:
        return _SPARK[0] * len(vals)
    out = []
    for v in vals:
        idx = int((v - lo) / span * (len(_SPARK) - 1))
        out.append(_SPARK[idx])
    return "".join(out)


def fmt_si(n: float | None) -> str:
    if n is None:
        return "—"
    n = float(n)
    for unit, div in (("T", 1e12), ("B", 1e9), ("M", 1e6), ("k", 1e3)):
        if abs(n) >= div:
            return f"{n / div:.1f}{unit}"
    return f"{n:.0f}"


def fmt_bytes(n: int | None) -> str:
    if n is None:
        return "—"
    g = n / 1e9
    return f"{g:.1f} GB"


def fmt_dur(seconds: float | None) -> str:
    if seconds is None:
        return "—"
    if seconds < 1.0:
        return f"{seconds * 1000:.0f}ms"
    return f"{seconds:.1f}s"


def fmt_pct(frac: float | None) -> str:
    if frac is None:
        return "—"
    return f"{frac * 100:.1f}%"
```

- [ ] **Step 4: Run to verify it passes** → 6 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/format.py tests/test_format.py
git commit -m "feat(ui): formatting helpers and sparkline"
```

---

### Task 15: Panel renderers (`render.py`) — pure Snapshot → text

**Files:**
- Create: `src/vllmstat/render.py`
- Create: `tests/test_render.py`

Pure functions that turn a `Snapshot` + `History` into the string content for each panel. Keeping rendering pure makes it unit-testable without a terminal; widgets just wrap these.

- [ ] **Step 1: Write the failing test**

`tests/test_render.py`:
```python
from vllmstat.core.history import History
from vllmstat.core.state import Snapshot, GpuSnapshot, GpuSample
from vllmstat import render


def _snap(**kw) -> Snapshot:
    base = dict(ts=0.0, connected=True, model_names=["m"], engine_count=1,
                gen_tps=142.0, prompt_tps=318.0, running=2.0, waiting=0.0,
                kv_dtype="turboquant_k3v4_nc", kv_capacity_tokens=444608,
                kv_ratio=32 / 7, kv_ratio_kind="nominal", kv_usage=0.0009,
                prefix_hit_lifetime=0.315, prefix_hit_window=0.381,
                src_compute=0.69, src_cache_hit=0.31, src_external=0.0,
                spec_active=True, spec_acceptance=0.398, spec_accepted_per_draft=2.16)
    base.update(kw)
    return Snapshot(**base)


def test_cache_kv_panel_shows_dtype_and_ratio():
    text = render.cache_kv(_snap(), History())
    assert "turboquant_k3v4_nc" in text
    assert "4.6x" in text or "4.57x" in text
    assert "444" in text  # capacity shown (e.g. 445k or 444,608)


def test_throughput_panel_shows_tps():
    text = render.throughput(_snap(), History())
    assert "142" in text


def test_gpu_panel_unavailable_message():
    s = _snap(gpu=GpuSnapshot(available=False, source="none", error="no NVML"))
    text = render.gpu(s)
    assert "unavailable" in text.lower() or "no nvml" in text.lower()


def test_gpu_panel_renders_device():
    s = _snap(gpu=GpuSnapshot(available=True, source="nvml", gpus=[
        GpuSample(index=0, name="NVIDIA Test", util_gpu=81, mem_used=23_100_000_000,
                  mem_total=24_000_000_000, temp_c=61, power_w=142, power_limit_w=200,
                  clock_sm_mhz=2520, clock_mem_mhz=9501, fan_pct=45)]))
    text = render.gpu(s)
    assert "NVIDIA Test" in text and "81" in text and "61" in text


def test_specdecode_hidden_when_inactive():
    assert render.specdecode(_snap(spec_active=False)) == ""
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `render.py`**

Implement one function per panel returning a string (use `vllmstat.format` helpers). Each must read only `Snapshot`/`History` fields defined in Task 2/7. Required functions and the fields they surface (match the spec mockup §8.1):

```python
from __future__ import annotations

from vllmstat.core.history import History
from vllmstat.core.state import Snapshot
from vllmstat.format import fmt_bytes, fmt_dur, fmt_pct, fmt_si, sparkline


def header(s: Snapshot, *, url: str, interval: float, uptime: str) -> str:
    state = "● connected" if s.connected else "● down"
    models = ",".join(s.model_names) or "—"
    return f"vllmstat  {models} @ {url}  engines {s.engine_count}  {state}  up {uptime}  {interval:.1f}s"


def concurrency(s: Snapshot, h: History) -> str:
    run_spark = sparkline(list(h.series("running").values))
    wait_spark = sparkline(list(h.series("waiting").values))
    seqs = f"  max-seqs {s.max_num_seqs}" if s.max_num_seqs else ""
    return (
        f"CONCURRENCY\n"
        f" running {s.running:.0f}  ▕{run_spark}▏\n"
        f" waiting {s.waiting:.0f}  ▕{wait_spark}▏\n"
        f" preempt {s.preempt_rate:.1f}/s{seqs}"
    )


def throughput(s: Snapshot, h: History) -> str:
    g = sparkline(list(h.series("gen_tps").values))
    p = sparkline(list(h.series("prompt_tps").values))
    tpi = f"{s.tokens_per_iter:.0f}" if s.tokens_per_iter else "—"
    return (
        f"THROUGHPUT\n"
        f" gen    {s.gen_tps:.0f} tok/s ▕{g}▏\n"
        f" prompt {s.prompt_tps:.0f} tok/s ▕{p}▏\n"
        f" tok/iter {tpi} · {s.req_rate:.1f} req/s"
    )


def cache_kv(s: Snapshot, h: History) -> str:
    hit_spark = sparkline(list(h.series("prefix_hit").values))
    src = (
        f"compute {fmt_pct(s.src_compute)} · "
        f"cache-hit {fmt_pct(s.src_cache_hit)} · ext {fmt_pct(s.src_external)}"
    )
    ratio = ""
    if s.kv_ratio and s.kv_ratio_kind != "none":
        tag = "~" if s.kv_ratio_kind == "nominal" else ""
        ratio = f"  {tag}{s.kv_ratio:.1f}x vs fp16"
    cap = fmt_si(s.kv_capacity_tokens) if s.kv_capacity_tokens else "—"
    used = fmt_si(s.kv_used_tokens) if s.kv_used_tokens is not None else "—"
    ctx = f" (fp16 full ctx {s.kv_fp16_full_ctx_gb:.1f}GB)" if s.kv_fp16_full_ctx_gb else ""
    return (
        f"CACHE & KV MEMORY\n"
        f" reuse  prefix hit {fmt_pct(s.prefix_hit_window)} ▕{hit_spark}▏ "
        f"life {fmt_pct(s.prefix_hit_lifetime)}   sources {src}\n"
        f" memory KV usage {fmt_pct(s.kv_usage)} ({used}/{cap} tok)   "
        f"{s.kv_dtype or '—'}{ratio}{ctx}"
    )


def _q(label: str, q) -> str:
    return f" {label:<6} {fmt_dur(q.p50):>7} {fmt_dur(q.p90):>7} {fmt_dur(q.p99):>7}"


def latency(s: Snapshot) -> str:
    return (
        "LATENCY (recent)   p50     p90     p99\n"
        + _q("TTFT", s.ttft) + "\n" + _q("TPOT", s.tpot) + "\n"
        + _q("e2e", s.e2e) + "\n" + _q("queue", s.queue)
    )


def specdecode(s: Snapshot) -> str:
    if not s.spec_active:
        return ""
    return (
        f"SPEC DECODE  acceptance {fmt_pct(s.spec_acceptance)}  "
        f"accepted/draft {s.spec_accepted_per_draft:.2f}"
    )


def efficiency(s: Snapshot) -> str:
    if not s.eff_active:
        return ""
    parts = []
    if s.gflops is not None:
        parts.append(f"{s.gflops:.0f} GFLOP/s")
    if s.gbps is not None:
        parts.append(f"{s.gbps:.0f} GB/s")
    if s.mfu is not None:
        parts.append(f"MFU {fmt_pct(s.mfu)}")
    return "EFFICIENCY  " + " · ".join(parts) if parts else ""


def gpu(s: Snapshot) -> str:
    if not s.gpu.available:
        return f"GPU  unavailable ({s.gpu.error or 'no NVML/nvidia-smi'})"
    lines = []
    for g in s.gpu.gpus:
        mem_pct = (g.mem_used / g.mem_total) if (g.mem_used and g.mem_total) else None
        lines.append(
            f"GPU {g.index}  {g.name}  {g.util_gpu:.0f}%  "
            f"{fmt_bytes(g.mem_used)}/{fmt_bytes(g.mem_total)} ({fmt_pct(mem_pct)})  "
            f"{g.temp_c:.0f}°C  {g.power_w:.0f}/{g.power_limit_w:.0f} W  "
            f"clk {g.clock_sm_mhz}/{g.clock_mem_mhz} MHz"
        )
    return "\n".join(lines)


def footer(s: Snapshot) -> str:
    gpu_badge = "GPU ✓" if s.gpu.available else "GPU ✗"
    vllm_badge = "vLLM ✓" if s.connected else "vLLM ✗"
    return f"[q]uit [p]ause [+/-]interval [g]pu [?]help    ● {vllm_badge}  {gpu_badge}"
```

- [ ] **Step 4: Run to verify it passes** → `pytest tests/test_render.py -q` → 5 passed.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/render.py tests/test_render.py
git commit -m "feat(ui): pure Snapshot->text panel renderers"
```

---

### Task 16: Textual app + panel widgets (`app.py`, `widgets.py`, `state_store.py`)

**Files:**
- Create: `src/vllmstat/widgets.py`
- Create: `src/vllmstat/app.py`
- Create: `tests/test_app.py`

The app: builds providers from `Config`, starts a `set_interval(cfg.interval, self.tick)`; `tick` awaits the vLLM + GPU samples, derives a `Snapshot`, pushes history, and updates each panel. Bindings: `q` quit, `p` pause, `g` toggle gpu, `+`/`-` interval.

- [ ] **Step 1: Write the failing test**

`tests/test_app.py`:
```python
import pytest

from vllmstat.app import VllmStatApp
from vllmstat.config import Config


@pytest.mark.asyncio
async def test_app_boots_with_mock_and_renders_cache_panel():
    cfg = Config(mock=True, interval=0.1, gpu=False)
    app = VllmStatApp(cfg)
    async with app.run_test() as pilot:
        await pilot.pause(0.3)            # allow a couple of ticks
        # the latest snapshot has been derived from mock data
        assert app.snapshot is not None
        assert app.snapshot.kv_dtype is not None
        # cache panel widget content includes the dtype
        from vllmstat.widgets import Panel
        panels = app.query(Panel)
        text = " ".join(str(p.renderable) for p in panels)
        assert "CACHE & KV MEMORY" in text


@pytest.mark.asyncio
async def test_pause_binding_stops_updates():
    cfg = Config(mock=True, interval=0.05, gpu=False)
    app = VllmStatApp(cfg)
    async with app.run_test() as pilot:
        await pilot.pause(0.2)
        await pilot.press("p")            # pause
        snap_a = app.snapshot
        await pilot.pause(0.2)
        assert app.paused is True
        assert app.snapshot is snap_a     # unchanged while paused
```

- [ ] **Step 2: Run to verify it fails** → FAIL.

- [ ] **Step 3: Implement `widgets.py` and `app.py`**

`src/vllmstat/widgets.py`:
```python
from __future__ import annotations

from textual.widgets import Static


class Panel(Static):
    """A bordered panel whose content is set via .update(str)."""
```

`src/vllmstat/app.py`:
```python
from __future__ import annotations

import time

from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import Footer

from vllmstat import render
from vllmstat.config import Config
from vllmstat.core.history import History
from vllmstat.core.metrics import MetricsEngine
from vllmstat.core.parse import parse_metrics
from vllmstat.core.state import Snapshot
from vllmstat.providers.gpu import GpuProvider
from vllmstat.providers.mock import MockProvider
from vllmstat.providers.vllm import VllmProvider
from vllmstat.widgets import Panel


class VllmStatApp(App):
    CSS = """
    Panel { border: round $primary; padding: 0 1; height: auto; }
    #row1 { height: auto; } #gpu { height: auto; }
    """
    BINDINGS = [
        ("q", "quit", "Quit"),
        ("p", "toggle_pause", "Pause"),
        ("g", "toggle_gpu", "GPU"),
        ("plus,equals_sign", "faster", "Faster"),
        ("minus", "slower", "Slower"),
    ]

    def __init__(self, cfg: Config) -> None:
        super().__init__()
        self.cfg = cfg
        self.paused = False
        self.snapshot: Snapshot | None = None
        self._history = History()
        self._engine = MetricsEngine()
        self._gpu = GpuProvider(enabled=cfg.gpu)
        self._mock = MockProvider() if cfg.mock else None
        self._vllm = None if cfg.mock else VllmProvider(
            base_url=cfg.url, metrics_path=cfg.metrics_path, api_key=cfg.api_key
        )
        self._start = time.monotonic()
        self._tick_n = 0

    def compose(self) -> ComposeResult:
        self.p_header = Panel(id="hdr")
        self.p_conc = Panel(id="conc")
        self.p_tput = Panel(id="tput")
        self.p_lat = Panel(id="lat")
        self.p_cache = Panel(id="cache")
        self.p_spec = Panel(id="spec")
        self.p_gpu = Panel(id="gpu")
        yield self.p_header
        with Horizontal(id="row1"):
            yield self.p_conc
            yield self.p_tput
            yield self.p_lat
        yield self.p_cache
        yield self.p_spec
        yield self.p_gpu
        yield Footer()

    def on_mount(self) -> None:
        self.set_interval(self.cfg.interval, self.tick)
        self.call_later(self.tick)

    async def _sample_text(self) -> tuple[str, bool, str | None]:
        if self._mock is not None:
            return self._mock.metrics_text(), True, None
        raw = await self._vllm.fetch_metrics()
        return raw.text, raw.fetched_ok, raw.error

    async def tick(self) -> None:
        if self.paused:
            return
        self._tick_n += 1
        text, ok, err = await self._sample_text()
        now = time.monotonic()
        if ok and text:
            fam = parse_metrics(text)
            snap = self._engine.derive(fam, now=now)
        else:
            snap = self.snapshot or Snapshot(ts=now, connected=False, error=err)
            snap.connected = False
            snap.error = err
        snap.gpu = self._gpu.sample()
        self._push_history(snap)
        self.snapshot = snap
        self._refresh(snap)

    def _push_history(self, s: Snapshot) -> None:
        self._history.push("running", s.running)
        self._history.push("waiting", s.waiting)
        self._history.push("gen_tps", s.gen_tps)
        self._history.push("prompt_tps", s.prompt_tps)
        if s.prefix_hit_window is not None:
            self._history.push("prefix_hit", s.prefix_hit_window)

    def _uptime(self) -> str:
        secs = int(time.monotonic() - self._start)
        h, rem = divmod(secs, 3600)
        m, _ = divmod(rem, 60)
        return f"{h}h{m:02d}m"

    def _refresh(self, s: Snapshot) -> None:
        self.p_header.update(render.header(s, url=self.cfg.url, interval=self.cfg.interval, uptime=self._uptime()))
        self.p_conc.update(render.concurrency(s, self._history))
        self.p_tput.update(render.throughput(s, self._history))
        self.p_lat.update(render.latency(s))
        self.p_cache.update(render.cache_kv(s, self._history))
        spec = render.specdecode(s)
        self.p_spec.display = bool(spec)
        self.p_spec.update(spec)
        self.p_gpu.update(render.gpu(s))

    def action_toggle_pause(self) -> None:
        self.paused = not self.paused

    def action_toggle_gpu(self) -> None:
        self._gpu.enabled = not self._gpu.enabled

    def action_faster(self) -> None:
        self.cfg.interval = max(0.1, self.cfg.interval / 2)
        self._reschedule()

    def action_slower(self) -> None:
        self.cfg.interval = min(10.0, self.cfg.interval * 2)
        self._reschedule()

    def _reschedule(self) -> None:
        # restart the interval timer at the new cadence
        for t in list(self._timers) if hasattr(self, "_timers") else []:
            pass
        self.set_interval(self.cfg.interval, self.tick)


def run_app(cfg: Config) -> int:
    VllmStatApp(cfg).run()
    return 0
```

> Implementation note for the executor: if `App`'s timer-reschedule API differs in the installed Textual version, store the `Timer` returned by `set_interval` on `self._timer`, call `self._timer.stop()` in `_reschedule`, then create a new one. Verify against the installed Textual (`python -c "import textual; print(textual.__version__)"`) and adjust the timer handling so `test_app.py` passes. Keep the public attributes (`snapshot`, `paused`) and panel ids stable — tests depend on them.

- [ ] **Step 4: Run to verify it passes** → `pytest tests/test_app.py -q` → 2 passed. Run full suite `pytest -q`.

- [ ] **Step 5: Commit**

```bash
git add src/vllmstat/widgets.py src/vllmstat/app.py tests/test_app.py
git commit -m "feat(ui): Textual app, panels, polling loop, key bindings"
```

---

### Task 17: Live smoke test + real screenshot

**Files:**
- Create: `scripts/smoke_live.py`
- Create: `docs/screenshot.svg` (generated)

- [ ] **Step 1: Smoke against the live server (manual, not in CI)**

Run: `python -m vllmstat --once --json | python -m json.tool | head -40`
Expected: JSON with non-null `gen_tps` baseline, `kv.dtype == "turboquant_k3v4_nc"`, `kv.capacity_tokens == 444608`.

- [ ] **Step 2: Run the TUI briefly against the live server**

Run: `timeout 5 python -m vllmstat --url http://localhost:8000 --no-gpu` (Ctrl-C / timeout exits cleanly).
Expected: full-screen dashboard renders the qwen3-30b-tq panels without traceback.

- [ ] **Step 3: Generate a screenshot from mock mode**

Add `scripts/smoke_live.py` that runs the app under `run_test` with `--mock`, calls `app.save_screenshot("docs/screenshot.svg")`, and exits. Run it.
Expected: `docs/screenshot.svg` created.

- [ ] **Step 4: Commit**

```bash
git add scripts/smoke_live.py docs/screenshot.svg
git commit -m "test: live smoke script and mock screenshot"
```

---

## Phase 5 — Resilience

### Task 18: Reconnect/stale + counter-reset integration test

**Files:**
- Create: `tests/test_resilience.py`

- [ ] **Step 1: Write the failing test**

`tests/test_resilience.py`:
```python
import pytest

from vllmstat.app import VllmStatApp
from vllmstat.config import Config


class FlakyVllm:
    """Returns ok once, then errors, simulating a disconnect."""
    def __init__(self):
        self.calls = 0
    async def fetch_metrics(self):
        from vllmstat.providers.vllm import RawText
        self.calls += 1
        if self.calls == 1:
            text = (
                'vllm:num_requests_running{engine="0",model_name="m"} 1.0\n'
                'vllm:generation_tokens_total{engine="0",model_name="m"} 10.0\n'
            )
            return RawText(text=text, fetched_ok=True)
        return RawText(text="", fetched_ok=False, error="connection refused")
    async def fetch_model_info(self):
        from vllmstat.providers.vllm import ModelInfo
        return ModelInfo(model_names=["m"], max_model_len=None, root=None)
    async def aclose(self): ...


@pytest.mark.asyncio
async def test_disconnect_marks_not_connected_but_keeps_running():
    cfg = Config(mock=False, interval=0.05, gpu=False)
    app = VllmStatApp(cfg)
    app._vllm = FlakyVllm()
    app._mock = None
    async with app.run_test() as pilot:
        await pilot.pause(0.3)
        assert app.snapshot is not None
        assert app.snapshot.connected is False    # later ticks failed
        assert app.snapshot.error is not None      # error surfaced, no crash
```

- [ ] **Step 2: Run to verify it fails or passes**

Run: `pytest tests/test_resilience.py -q`
Expected: PASS if Task 16's `tick` already handles the error branch (it does). If it FAILS, fix `tick` so a failed fetch sets `connected=False` and `error` on a retained snapshot without raising. (Counter-reset is already covered by `Rate` in Task 5 and `windowed_buckets` in Task 4.)

- [ ] **Step 3: Commit**

```bash
git add tests/test_resilience.py src/vllmstat/app.py
git commit -m "test: disconnect resilience; keep UI alive on fetch failure"
```

---

## Phase 6 — Docs, CI, release

### Task 19: README, LICENSE, CONTRIBUTING

**Files:**
- Create: `README.md`, `LICENSE`, `CONTRIBUTING.md`

- [ ] **Step 1: Write `LICENSE`** — the full Apache-2.0 license text (fetch the canonical text; fill copyright line `Copyright 2026 Bryan Vine`).

- [ ] **Step 2: Write `README.md`** — sections: what it is + screenshot (`docs/screenshot.svg`), why (gap vs Grafana/`vllm-top` printer), install (`pip install vllmstat`), usage (`vllmstat`, `--url`, `--mock`, `--once --json`, key bindings), what it shows (concurrency, throughput, cache & KV memory incl. compression, latency percentiles, spec-decode, GPU), requirements (Python ≥3.10; NVML optional), remote monitoring note (works without GPU access), license. No placeholders — write real prose.

- [ ] **Step 3: Write `CONTRIBUTING.md`** — dev setup (`pip install -e ".[dev]"`), run tests (`pytest -q`), lint (`ruff check . && ruff format .`), types (`pyright`), TDD expectation, link to the spec.

- [ ] **Step 4: Verify the suite + lint are green**

Run: `ruff check . && ruff format --check . && pyright && pytest -q`
Expected: all pass.

- [ ] **Step 5: Commit**

```bash
git add README.md LICENSE CONTRIBUTING.md
git commit -m "docs: README, Apache-2.0 LICENSE, CONTRIBUTING"
```

---

### Task 20: GitHub Actions CI

**Files:**
- Create: `.github/workflows/ci.yml`

- [ ] **Step 1: Write CI workflow**

```yaml
name: ci
on:
  push: { branches: [main] }
  pull_request:
jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ["3.10", "3.11", "3.12", "3.13"]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "${{ matrix.python-version }}" }
      - run: pip install -e ".[dev]"
      - run: ruff check .
      - run: ruff format --check .
      - run: pyright
      - run: pytest -q
```

- [ ] **Step 2: Commit**

```bash
git add .github/workflows/ci.yml
git commit -m "ci: lint, type-check, and test matrix"
```

---

### Task 21: Create public GitHub repo and push

> This is an outward-facing action. The orchestrator must confirm with the user before running it (the build agents should stop here and report).

- [ ] **Step 1: Create the repo and push**

```bash
cd /apps/vllm-tui
gh repo create bryanvine/vllmstat --public --source=. --remote=origin \
  --description "nvtop for vLLM — interactive terminal dashboard for vLLM serving performance" --push
```

- [ ] **Step 2: Verify**

Run: `gh repo view bryanvine/vllmstat --web` (or `gh repo view bryanvine/vllmstat`).
Expected: repo exists, `main` pushed, CI running.

---

## Self-Review (completed by plan author)

**Spec coverage:** concurrency (T8/T15), throughput (T8/T15), cache reuse + token sources (T6/T8/T15), KV memory + compression ratio (T6/T8/T15), latency percentiles (T4/T8/T15), spec-decode (T8/T15), efficiency conditional (T8/T15), per-GPU stats incl. nvidia-smi fallback + unavailable (T11/T15), multi-engine/model aggregation (T8), CLI/config + `--once --json` (T12/T13), resilience/degradation (T18), packaging/CLI entry (T1/T13), README/LICENSE/CI (T19/T20), public repo (T21). All spec sections map to tasks.

**Placeholder scan:** no "TBD/handle errors/similar to"; every code step is complete. The two explicit "implementation notes" (mock `_hist`, Textual timer API) give concrete guidance, not deferrals.

**Type consistency:** `Snapshot`/`GpuSnapshot`/`GpuSample`/`Quantiles` fields defined in Task 2 are the exact names used by `metrics.py` (T8), `render.py` (T15), `app.py` (T16), and `snapshot_json.py` (T13). `MetricsEngine.derive(fam, now)`, `Rate.update(raw, t)`, `compute_kv(**kwargs)`, `parse_metrics`, `get_buckets`, `histogram_quantile`, `windowed_buckets`, `History.push/series`, `VllmProvider.fetch_metrics`, `GpuProvider.sample`, `Config.from_sources` are referenced consistently across tasks.
