Skip to content

Releases: zzallirog/coolstep

v0.5.21 — logic-flaw audit sweep

Choose a tag to compare

@zzallirog zzallirog released this 10 Jul 14:17

[0.5.21] — 2026-07-10

Logic-flaw audit sweep (2026-07-10): «stated intent ↔ actual behavior»
gaps hunted across daemon/predictor/dashboard/docs, then fixed. Six
confirmed findings, all with file:line proofs; 1025 tests green after.

Fixed — the predictor actually predicts in fallback mode

  • Physics trajectory overlay was welded to the KNN path. The
    recognition-independent safety gate («94°C rising — act regardless of
    neighbours») lived inside KnnPredictor only, so the deployed
    fallback (MetaPredictor(TrajectoryBaseline), Chroma disabled) had
    throttle_prob hard-0 forever: no NOTIFY, no safety net, exactly in
    the mode with no other line of defence. Overlay extracted to
    predictor.trajectory_signal() and merged by TrajectoryBaseline too.
  • Dry-run applies poisoned the causal ledger. In dry-run (the
    shipped default) every apply() returned error=None, so the daemon
    armed the action AND recorded an intervention window → validated
    residuals inside it were excluded from ResidualBank training. Pure
    passive thermal samples thrown away exactly during hot moments, for
    every monitoring-only deployment. Windows now recorded only when
    COOLSTEP_ACTUATOR_ENABLE is truthy.
  • decisions.jsonl silently dropped every low-confidence call via the
    confidence < min_arm_confidence early return — while the module
    contract says «records EVERY decide() call». The log was blind exactly
    in the region used to tune min_arm_confidence. Now logs before the
    early return.
  • Orphan-label recovery poisoned the 82-90°C band. Restart-orphaned
    chroma vectors were blanket-relabelled COOL, contradicting the live
    labeler (whose lookahead window includes the frame itself, so ≥82°C
    provably labels HOT). Recovery now splits on HOT_THRESHOLD_C.

Fixed — one readiness predicate, honestly surfaced

  • Calibration split-brain. Docs said «8 gates», the daemon evaluated
    6 (cost gates never received inputs), the dashboard recomputed its own
    5. Daemon now feeds real cost metrics (/proc/self/status VmRSS,
    process-time CPU%) → all 8 code gates live; the full gate report is
    dumped into ml-state.json and /api/calibration serves it
    (source: "daemon"), falling back to a local recompute
    (source: "dashboard-recompute") only when the daemon is dead.
  • Drift «red disarms actuator» was doc-fiction — the word disarm
    didn't exist in code. Implemented: any indicator at severity ≥ 0.8
    (env COOLSTEP_DRIFT_DISARM_SEVERITY) holds calibration_ready
    False. Sub-red indicators (embedder_cold, knn_low_confidence) surface
    without blocking, so intentional fallback deployments stay armed-able.
  • Degraded predictor rendered as maximally healthy. The
    training-tile fallback banner matched only always_idle_baseline — a
    model no longer wired — so the real fallback
    (trajectory_baseline+meta) showed no warning and the cockpit
    displayed structural confidence as reflective. Banner now keys on
    chroma_available === false / non-KNN model; cockpit gains a
    degraded field + «⛔ fallback» chip.
  • Python 3.14 auto-fallback now exists. README promised the daemon
    «detects and falls back automatically» on the chromadb 3.14 segfault;
    detection now happens before import (a segfault can't be caught).
    Opt back in via COOLSTEP_CHROMA_FORCE=1.

Fixed — interference S15 closed (asusctl baseline drift)

  • revert() re-reads the live curve first: matches neither the last
    issued curve nor the saved baseline → user edited it externally →
    restore skipped, stale baseline dropped, cession journaled.
  • _ensure_baseline() refuses to adopt coolstep's own issued curve as
    «user baseline» when the TTL lapses while a bias is applied.
  • Matrix: 10 covered / 3 open gaps (S08, S10, S14) / 4 structural.

Added — long-horizon thermal ledger (daily_rollup)

  • New permanent sqlite table: one row per UTC day × workload label
    (+ pooled _all row) with cpu-temp/power/fan p50/p95/max and daily
    throttle-event count. Populated by rotate() before frame
    eviction; survives the 14-day frames TTL indefinitely (~KB/day).
    This is the substrate for seasonal phase shifts (winter/summer
    ambient), thermal-interface degradation and dust tracking: same
    workload bucket, rising temp_p95/fan_p95 at flat power_p50 = the
    cooling path got worse.
  • GET /api/thermal-history?since=YYYY-MM-DD&workload=X serves the
    ledger (raw read-only query — dashboard still never writes sqlite).

Fixed — small correctness

  • _recover_armed_actions no longer rebuilds corrupt (nameless)
    entries under a None key — they are skipped.
  • Documentation sweep: SSE-zombie sections, stale gate/env/tick-rate/
    store-size/test-count numbers, ghost env vars (COOLSTEP_TICK_HZ,
    COOLSTEP_MIN_ARM_*, COOLSTEP_RYZENADJ_CMD), AlwaysIdleBaseline
    references, interference tallies.

v0.5.20 — hotfix orchestrator mount race

Choose a tag to compare

@zzallirog zzallirog released this 26 May 08:35

Hotfix on top of v0.5.19. The v0.5.19 "all 19 tiles live" Playwright verification was a lucky run on a warm module cache; on a cold cache (real users on first navigation) the orchestrator boot raced module script execution and snapshotted the registry with only one tile in it. The remaining 18 sat in the registry with mounted: false for the lifetime of the page — visible empty cards despite a live daemon, no API calls firing for tile endpoints (/api/adapters, /api/reliability, /api/actuator-journal, /api/predictor-cockpit, …).

Fixed — correctness

  • Orchestrator boot race: 18/19 tiles silently never mounted. _scheduleBoot() queued a microtask after the first register() call. Module scripts execute as separate tasks, so the microtask drained before the other 18 tile modules finished loading; their registrations landed in _registry after _runBoot had already snapshotted it, and _bootScheduled=true blocked any re-run. Replaced the whole boot-snapshot design with per-tile mount-on-register (_mountByPriority called directly from register()). The connection budget still handles burst protection; no global synchronization point survives, so no race. Verified via Playwright on a cold cache: 19/19 tiles mount, every tile endpoint fires.

  • StaticFiles served no Cache-Control header. Without it browsers fall back to Last-Modified heuristic freshness (~10% of age) and can pin stale JS for hours after a fix ships — exactly why this bug survived 28h+ unnoticed on the running dashboard. Mounted via a _NoCacheStatic subclass that adds Cache-Control: no-cache on successful static responses; ETag revalidation now fires on every load (~1 ms warm), so future patches are picked up on the next navigation.

  • One-shot cache-bust on the orchestrator import URL. Even after the no-cache header ships, the already-cached _orchestrator.js URL stays valid by browser heuristic until eviction. Added ?v=mount-on-register query to every tile's orchestrator import + the modulepreload hint, forcing one fresh fetch under the new cache policy. Future orchestrator edits don't need the query string bumped — they'll be picked up via no-cache revalidation.

Verified end-to-end

Playwright on cold cache after dashboard restart:

  • Mounted: 19/19 tiles (was 1/19 — only live-telemetry-tile, the sole priority='critical' entry)
  • Network: every tile endpoint fires within first second of load
  • Static assets carry Cache-Control: no-cache, ETag revalidates 1ms

Full diff: v0.5.19...v0.5.20

v0.5.19 — perf + correctness sweep (3 rounds via Playwright)

Choose a tag to compare

@zzallirog zzallirog released this 25 May 01:31

Three-round perf + correctness sweep driven by Playwright dynamic screencast (10×1s tile snapshots, not static screenshots). Surfaced 15+ issues invisible to single-shot screenshots: frozen-data windows, silent failure modes, cache stampede memory bursts.

Each round = 5 parallel research agents → manual audit + fix → live verify. Total: 15 sonnet researchers + 1 long-running 10-minute memory probe.

🔧 Fixed — correctness (silent failures)

  • daemon_seen lied green for 60s after collector death. Health pill checked store.db.exists() — always True since the file persists. Switched to ml-state.json mtime (per-tick liveness signal). Fail-detection window: 60s → 15s.
  • /api/event-segments was a stub returning []. Daemon emitted SessionBoundary every load_jump but discarded them. Now persists to data/segments.jsonl, handler tail-reads. Default limit=50500.
  • Telemetry tiles froze for 6-7s windows. Orchestrator's _telemetryLastTs dedup aliased with server cache TTL=1s + poller 1Hz + collector 1.5Hz. Dedup removed.
  • calibration-state-tile showed 0/— indefinitely on cold-miss. Loading skeleton + lock-gated pre-warm.
  • training-tile lazy + 5s poll = 7s freeze on tick counter. Changed to normal + 2s.

⚡ Fixed — perf (cache stampede + cold-miss)

  • /api/efficiency cache stampede. TOCTOU between cache-check and to_thread compute spawned N concurrent compute_historical calls. RSS burst 150MB → 2.5GB. Fix: per-key asyncio.Lock + double-check. Verified: 5 parallel cold = 1× compute + 4× cache-hit.
  • /api/calibration same stampede. Cold 16-30s → 30s timeouts on concurrent first-hits. Same lock + _warm_calibration_locked() so warm thread + first user serialize.
  • /api/predictor-cockpit missed prior cache pass. 1030ms → 6ms warm (0.75s TTL with scope_s in key).
  • Vendor Lit shrink: 125KB dev → 15.5KB minified (-87%) via local esbuild bundle.
  • 240 background HTTP fetches/min when tab not focused → 0 (visibility pause + immediate-refresh on return).

🎨 Fixed — UX

  • efficiency-tile "below sweet -25°" read like a CPU temp. Suffix added: "below sweet -25.0° from sweet".
  • self-monitor-tile 4 false-positive TRIGGER alerts. Thresholds rebalanced against real prod baseline: 80%→92% mem, 1MB→200MB swap, 0.1%→5.0% PSI. Pill now green at healthy steady state.

🛡️ Fixed — robustness

  • Orchestrator: tile reconnect orphaned. register() pushed duplicates; reconnected tile silently never re-mounted. Dedup + re-mount in place.
  • Predictor-cockpit canvas: NaN slipped through null guard. Added isNaN check.
  • self-monitor-tile: duplicate orchestrator import removed.

✨ Added — observability

  • Per-route latency middleware ring in /api/self-monitor ({p50_ms, p95_ms, p99_ms, samples} per route, 60-sample deque). Foundation for regression detection.
  • Per-route failure tracking in orchestrator. Emits coolstep:api-degraded / coolstep:api-recovered events after 3 consecutive failures / first recovery.

✅ Verified end-to-end

Final Playwright 10×1s screencast post-fix:

  • All 19 tiles live (was: 6 in placeholder/stale state)
  • Live telemetry age oscillates 0-3s naturally (was: monotonic 30s→36s freeze)
  • Training tick +10/10s (was: 1/10s frozen)
  • Calibration 5/5 gates passed (was: 0/—)
  • Event segments 221 visible (was: "no segments yet")
  • Self-monitor 0 TRIGGER (was: 4 false-positive)

Endpoint timings (warm cached): all in 3-12ms range on Ryzen 9 7940HS / NVMe Gen4 / DDR5.

📦 Install

# AUR (Arch Linux)
yay -S coolstep        # stable
yay -S coolstep-git    # git/master

# Manual
pip install --user .

See CHANGELOG.md for full notes including the v0.5.18 changes (PR #16 + #17 from external contributors that shipped between v0.5.17 and this release).

v0.5.17 — first community contribution

Choose a tag to compare

@zzallirog zzallirog released this 23 May 10:56

Patch release shipping the first external community contribution to coolstep, tagged as its own release so the credit is durable.

Highlights

  • Issue #2 / G-10 closedcoolstep tail no longer reports cpu_tctl=0.0°C on Intel hosts.

Fixed

The G-9 fix in v0.5.2 wired a vendor-neutral CPU temperature fallback (tctl → tdie → package → max-of-cores) into the store writer and the efficiency calibrator — but the coolstep tail live formatter kept the older tctl or tdie or 0.0 shortcut. On Intel hosts (no tctl/tdie exposed, but package present) the live tail printed a flat zero while the daemon itself was reading the correct value.

This release collapses the three inlined fallback copies into a single helper, coolstep.core._helpers.canonical_cpu_temp(frame), used by store.write_frame, efficiency_calibration._tctl, and inspect/cli.py:_format_frame_oneline. Regression test added at tests/test_cpu_temp_fallback.py::test_tail_formatter_picks_intel_package.

Verified end-to-end on Hetzner EX44 / Intel i5-13500:

Version Live coolstep tail output
0.5.1 (pre-fix) cpu_tctl= 0.0°C × 3 ticks
0.5.17 (post-fix) cpu_tctl= 74.0°C / 74.0 / 71.0

Acknowledgement

This release exists because @puneetdixit200 opened PR #7 — the first external contribution to coolstep. Clean extract-method refactor, the right anchor test, considered author notes on platform caveats. The bug was invisible on the maintainer's AMD primary machine; PR landed on Intel hardware where the symptom is reproducible and stayed unsurfaced for a month.

Thank you, Puneet. 🌱

Changelog

v0.5.16 — dashboard hardening + audit journal

Choose a tag to compare

@zzallirog zzallirog released this 19 May 19:44

Security hardening release. Pure additive: no behaviour regressions,
no breaking API changes.

Added

  • Dashboard hardening middleware (coolstep/dashboard/security.py)
    — validates the HTTP Host header on every request and the Origin
    / Sec-Fetch-Site headers on mutating endpoints. Non-browser clients
    (curl, systemd timers, internal CLI) are unaffected.
  • coolstep-dashboard --allow-public bind guard. Non-loopback
    --host now requires the explicit flag. --allow-host <fqdn>
    extends the Host allowlist for reverse-proxy deployments.
  • Mode-switch audit journal/api/mode/{cool,quiet,off} append
    to actuator-journal.jsonl with prior mode + peer + X-Forwarded-For.
    Surfaced by /api/actuator-journal and coolstep inspect.
  • SECURITY.md + docs/security.md — single-user trust model + how
    to plug an authentication layer upstream.
  • docs/headless-deployment.md — SSH-tunnel, reverse-proxy-with-auth,
    and WireGuard deployment patterns.
  • README § 08 — short pointer to the security model.

Trust model

coolstep is a single-user local daemon. The dashboard binds
127.0.0.1:18889 by default; loopback is the gate. Authentication for
remote exposure is the operator's choice — the daemon stays small-scope
and pluggable. See docs/security.md for the
recommended patterns.

Tests

19 new cases in tests/dashboard/test_security.py covering middleware
behaviour, bind guard, and audit journal. 908/908 green.

Diff

12 files, +808 / −18 LOC vs v0.5.15. No data/ or internal files.

v0.5.8 — Pin lifetime + hero glide

Choose a tag to compare

@zzallirog zzallirog released this 13 May 05:38

Two display-layer refinements on top of the 10 Hz cockpit that landed in v0.5.7. Past-prediction pins now share their visible lifetime with the active forecast horizon, and hero metric numbers tween toward each freshly fetched value via requestAnimationFrame so the eye glides across cache-flips instead of stepping through them.

Hero glide

live now and the Δ-prediction now read from displayT / displayPred Lit state which cubic-eases toward the freshest fetched value over ≈400 ms. At 10 Hz refresh + 3 s predict-cache, a 13.9° → 7.5° flip used to teleport the moment the cache rolled over; now it becomes a perceptually smooth glide. The data layer is untouched — fetch cadence, predict-cache TTL, and cur.t semantics are exactly what v0.5.7 shipped. Only the visual layer is interpolated.

requestAnimationFrame self-cancels when a value lands, and disconnectedCallback cleans up an in-flight tween so navigating away doesn't leak a frame loop.

Pin lifetime

Past-prediction pins now decay with the active horizon.

Earlier hotfix clamped expired pins to the left canvas edge, producing an ever-growing fan of red rays anchored at -T_PAST. The right idea: a pin's visible age equals the active horizon (5 / 15 / 30 s), then it fades to nothing — same memory window as the forecast curve points to. Quadratic alpha gives a soft tail; ring radius also tapers with age so the newest pin is the brightest mark on the canvas.

The horizon toggle becomes a single semantic lens: what you see ahead = what you remember behind. At +5s mode the canvas shows last-5-s of pins next to a +5 s forecast curve; at +30s, a full half-minute of memory on either side.

Fixed

  • Cockpit's hero live now reading is no longer momentarily blank during the very first refresh — displayT falls back to cur.t before the first tween completes.

Tests

715+ tests pass. No backend contract changes.

v0.5.7 — Instrument-flow: 10Hz cockpit + dual-curve canvas

Choose a tag to compare

@zzallirog zzallirog released this 13 May 05:20

Daemon tick rate raised from 1 Hz to 10 Hz on the same hardware after caching synchronous chroma stats off the hot path. Cockpit gains a horizon toggle (+5s / +15s / +30s, default +5s) plus a second forecast line — pure hardware-current Newton trajectory laid over the KNN-anchored archive envelope, so the operator reads the gap between the two curves as installed cooling margin instead of one ambiguous prediction.

New surface

  • Multi-horizon toggle in the cockpit — segmented control [ +5s | +15s | +30s ] with a meta-led / balanced / archive-led weight label. Backend ships forecasts: {h5, h15, h30} sampled from the same meta-anchored saturation curve; UI picks which point to render. Selection persisted in localStorage. The Δ block, canvas span, and forecast curve endpoint all respect the active horizon.
  • Dual-curve canvas — hardware-current Newton τ=4s trajectory drawn alongside the existing KNN-anchored saturation curve. Solid teal reads as «now», dashed teal as «remembered»; the gap is the installed cooling margin.
  • Signed ±err pills — negative median residual reads as «cooling outperforms historical envelope» (teal/cooling tint) instead of an alarming red. Red is reserved for positive median (real under-prediction warning). Backend exports median_signed_err_c alongside median_abs_err_c.
  • spike_detector predictor-margin exit gate — a spike that opens on a real residual but then stays open because the predictor systematically over-predicts (signed residual ≤ −N°C for M ticks) now closes with closure_reason=\"predictor_margin_exceeded\". Distinguishes «predictor was right, cooling caught up» from «real interactive burst calmed» in the incident journal.

Changed

  • Daemon tick rate 1 Hz → 10 Hz (DEFAULT_PERIOD = 0.1). Hot path is sample → cached predict → cached decision → store → ml-state dump → out (~25 ms steady-state). Periodic ops (backfill_labels, drift history, chroma stats refresh) keep their wall-clock cadence via ticks_per_sec scaling.
  • Chroma writes moved to a background drain worker (bounded queue, single consumer — chroma client is not thread-safe for concurrent writes). Per-tick chroma.add no longer blocks the tick loop on HNSW index rebuild.
  • Chroma stats cached. chroma.count(), count_labeled(), dir_size_bytes() previously walked the 42k-vector HNSW index on every _dump_ml_state (~6-25 s). Refreshed every 30 s off the hot path.
  • Cockpit canvas repaint is signature-gated — repaints only when data has materially changed, eliminating per-poll flicker at 10 Hz refresh.
  • Cockpit refresh chain is exception-safe. A transient fetch failure no longer breaks the poll loop and freezes the tile.

Fixed

  • chromadb 0.6.3 posthog telemetry wrapper silenced via monkey-patch in ChromaStore.discover() — the wrapper's capture() signature mismatch raised a TypeError on every chroma operation and blocked the tick loop indirectly.
  • Test isolation — daemon_factory monkeypatch.setenv's COOLSTEP_HOME to the test's tmp_path, so throttle-FSM tests no longer inherit state from the live daemon's runtime-state.json.

Tests

715+ tests pass. Suite extends test_spike_detector (margin-exceeded exit cases), test_routes (forecasts + signed median fields), and test_embedding (stats save/load roundtrip).

v0.5.6 — Embedder stats persistence + drift indicator refinement

Choose a tag to compare

@zzallirog zzallirog released this 13 May 03:01

The KNN predictor and the meta layer share an embedding space defined by per-feature median/MAD normalization. Until this release those statistics were a daemon-process artifact — a restart re-fit them on the post-boot ring window, drifting the normalization away from vectors already in the chroma index. v0.5.6 promotes them to a persistent file (data/embedder-stats.json) loaded on boot, so the archive and live queries stay in the same vector space across restarts.

New surface

  • Embedder.save_stats() / Embedder.load_stats() persist per-feature median/MAD to data/embedder-stats.json. Daemon reloads on boot and freezes refits while the file is present — newly-embedded frames share the same vector space as vectors already in the chroma index. Remove the file to re-enable adaptation.
  • scripts/reindex_chroma_from_store.py — one-shot helper that rebuilds the chroma index from store.db.frames: reconstructs TelemetryFrame from raw_json, embeds, writes to chroma, then runs backfill_labels against throttle_events. Idempotent — skips ids already present, safe to re-run.
  • drift detectorchroma_no_growth indicator now gates on base_count > 0, distinguishing a deliberately-disabled feature (count stays at 0) from a genuinely-stuck collector (count was growing, then stopped).

Dependency

chromadb venv installation aligned with the pyproject.toml constraint (>=0.5,<1.0) on Python 3.14 — matches the spec already in the source tree.

Tests

  • test_save_load_stats_roundtrip — byte-for-byte vector equivalence across Embedder instances after save/load. The invariant that warm-start reindex depends on: vectors in chroma and queries from the daemon must agree given the same input frame.
  • Missing/corrupt stats file handled gracefully (returns False, embedder stays unfitted).
  • test_evaluate_chroma_disabled_no_false_positive — drift detector remains silent when chroma is intentionally not in use.

700+ tests green.

v0.5.5 — Cockpit visual documentation

Choose a tag to compare

@zzallirog zzallirog released this 12 May 18:58

Five live cockpit screenshots captured during a stress burst, keyed into the README "Read the cockpit" section. Demonstrates the v0.5.4 shrinkage prior in action: the bucket strip in 02-hero shows n=5 with σ=2.78°C — exactly the band-widening the prior was added to produce.

README "05 · Read the cockpit"

The section now opens with a full-width hero shot, a three-column row of detail crops with sub-captions (header chips · hero metrics · bucket strip), and a canvas zoom under the components table showing past-prediction rings + σ-corridor + knee/danger reference lines.

asymptote eq ≈ <T> TREND-phrase example added to the documented branches — previously this state (rising but won't reach the knee at the current slope) wasn't represented in the docs, despite being the most informative branch the cockpit reports.

Screenshots

  • 00-full.png — full cockpit at asymptote state (+1.7°C in +5s, trend phrase asymptote eq ≈ 76°, bucket n=5 with shrinkage-wide σ=2.78°C)
  • 01-header-chips.png — ⚡ spike-active chip + twin ±err pills
  • 02-hero.png — hero block with LIVE NOW / Δ / TREND / dT/dt
  • 03-canvas-rings.png — canvas zoom: past-prediction rings, σ-corridor, knee/danger reference lines
  • 04-bucket-strip.png — meta correction + σ + sample-count n with the shrinkage prior visibly widening σ on a young bucket

Runtime: no code paths touched. 798 / 25 tests pass.

v0.5.4 — Predictor cockpit becomes a relational instrument

Choose a tag to compare

@zzallirog zzallirog released this 12 May 18:24

Every cockpit metric now sits next to the parameter that gives it meaning. Three numbers standing alone became two relational pairs + a time-axis canvas; residual streams gained a spike-archive substrate so the multi-angle similarity search has training data of its own surprises; the meta layer learns honestly at small sample counts through a Bayesian shrinkage prior (ADR-020) — n=2 buckets no longer report σ=0.01°C overconfidence.

Cockpit re-composition

hero block              NOW + Δ in +5s   ┃   TREND phrase + raw dT/dt
canvas                  t × T time-axis (-30s..+5s), gold trail +
                         dashed cool forecast + σ-band, 78°/90°
                         knee/danger reference lines
past predictions        hollow rings at where it was supposed to
                         land, segment to where it did — newest 8
                         α-faded by age (newest 1.0 → oldest 0.4)
spike chip              ⚡ while |residual|≥5°C for 2 ticks; closure
                         emits Incident(kind=predictor_spike) with
                         workload + entry features
±err chips              twin 15m (slow) + 30s (fast) — 15m green
                         steady while 30s flags the transient
meta-anchored forecast  T(t) = T0 + (Tpred − T0)·sat_factor (ADR-021)

New surface

  • core/spike_detector.py — state machine watching the validated-residual stream; emits Incident(kind="predictor_spike") when the predictor was surprised by a real workload step (entry: |residual|≥5°C for 2 ticks; exit: <2°C for 3 ticks). Closure records carry workload + entry features — the snapshot the future KNN lookup will match against. Routes through the existing incidents.jsonl + multi-angle similarity; no parallel substrate. 10 tests.
  • ResidualBank.correct() shrinkage prior (ADR-020) — each bucket starts with k=5 pseudo-observations at zero mean, σ₀ = log1p(2°C) in log-space. Damps correction by ≥60% in the first 3 obs of a fresh bucket; converges to ~95% of pure data by n=15. Posterior σ widens when data and prior disagree.
  • cpu_temp_now feature separated from cpu_temp_max (rolling-window max). Live-NOW and residual validation now use the latest sample; rolling max is kept only for aggregate analytics (efficiency, cluster drift, event segmentation).

JS refine

Three duplicated abs < 2 ? : abs < 5 ? decision sites collapsed onto a single source of truth: static RES_OK_C=2 / RES_WARN_C=5 + _resClass(absR) returning ok|warn|err|cold + _resHex(absR, palette) for canvas use. Threshold drift between the accuracy chip, the token strip, and the canvas rings becomes structurally impossible. One getComputedStyle pass per redraw instead of six.

Tests + ADRs

798 passed, 25 skipped. ADR-020 (shrinkage prior), ADR-021 (forecast anchor).