# Task 01 — Severity module **Phase:** `01_severity_language` · **Source:** `TODO.md:5` — "proper green (good), yellow (warn), red (danger) color coding" and `TODO.md:7` — "charge, load, and runtime left to be big, obvious, and color coded" **Story:** n/a ## Objective Move the good/worrying/bad decision out of the browser and into one Python module, so the four pages cannot drift into four different opinions about what red means. Today the thresholds live in call sites: `page-system.js:129` passes `75, 90`, `page-system.js:139` and `:144` pass `80, 92`, `page-index.js:89` passes `80, 92`, `page-llm.js:195` and `:307` pass `75, 90`, and `Dash.severityFor` (`app.js:105`) defaults to `75/90`. That is the drift this task ends. ## Work 1. `src/dashboards/severity.py` (new) — - `THRESHOLDS: dict[str, tuple[float, float]]` keyed by metric name, with the ratified defaults: `cpu.usage_pct` 75/90, `mem.used_pct` 80/92, `disk.used_pct` 80/90, `cpu.temp_c` 75/90, `gpu.busy_pct` 85/95, `gpu.mem_used_pct` 85/95, `llm.kv_cache_usage_pct` 75/90, `ups.charge_pct` 50/20, `ups.battery_runtime` 600/300, `ups.load_pct` 70/90. - `INVERTED: set[str]` — metrics where **lower is worse** (`ups.charge_pct`, `ups.battery_runtime`), so callers cannot get the direction wrong. - `classify(metric: str, value: float | None) -> str | None` returning `ok`, `warn`, `fail`, or `None`. `None` in → `None` out, always. - `attach(metric: str, value: float | None) -> dict` returning `{"value": value, "severity": classify(...)}` for the payload shapes that are plain numbers, and `with_severity(metric, cell)` for payloads that already are `{"value":…, "source":…, "derived":…}` (LLM metrics) — additive, never a rename. - Module docstring stating the two rules: unknown is **not** ok (it is `None`, the page renders `n/a` with no colour), and a threshold exists nowhere else in the app. Ties to the `AGENTS.md` "never invent a number" rule. 2. `tests/test_severity.py` (new) — - boundaries inclusive on the warn edge (`75 → warn`, `74.9 → ok`), danger edge (`90 → fail`), every metric in the table, both directions for the inverted ones, unknown metric name → `None` (no exception), `None` and `float("nan")` → `None`. - a table sanity test asserting each entry's bounds are ordered correctly for its direction, so a future edit cannot silently invert a metric. - ASSUMPTION: the numbers above were confirmed in chat on 2026-10-16; they match what the call sites already pass, except the two UPS metrics which are new. - ASSUMPTION: the vocabulary is **`ok|warn|fail`**, not `ok|warn|danger` — it is what `dash.css` (`.bar.ok`, `.dot.warn`, `.chip.fail`, `ul.checks li.fail`) and `sources/nut.py`'s `health.checks[].status` already use. The TODO's word "danger" maps to `fail`; do not introduce a synonym for the same colour. - ASSUMPTION: `sources/nut.py`'s existing `health.checks[].status` values are left untouched (additive-only anchor). `severity` is a new field on *values*, computed by this module; `nut.assess()` keeps its own judgement about *conditions*. ## Testing & Quality - Unit only, no I/O, no fixtures needed. - Coverage: **>90%** on `severity.py`. ## Completion Criteria - [ ] `uv run pytest tests/test_severity.py -v` green - [ ] `uv run python -c "from dashboards.severity import classify as c; print(c('cpu.usage_pct',95), c('cpu.usage_pct',60), c('cpu.usage_pct',None), c('ups.charge_pct',15))"` → `fail ok None fail` - [ ] `uv run pytest` green overall; `ruff` + `pyright` clean - [ ] Nothing else changed yet — this task adds a leaf module and its tests