Releases: zzallirog/coolstep
Release list
v0.5.21 — logic-flaw audit sweep
[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 insideKnnPredictoronly, so the deployed
fallback (MetaPredictor(TrajectoryBaseline), Chroma disabled) had
throttle_probhard-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) everyapply()returnederror=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_ENABLEis truthy. - decisions.jsonl silently dropped every low-confidence call via the
confidence < min_arm_confidenceearly return — while the module
contract says «records EVERY decide() call». The log was blind exactly
in the region used to tunemin_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 onHOT_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/statusVmRSS,
process-time CPU%) → all 8 code gates live; the full gate report is
dumped intoml-state.jsonand/api/calibrationserves 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
(envCOOLSTEP_DRIFT_DISARM_SEVERITY) holdscalibration_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 onlyalways_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
degradedfield + «⛔ 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 viaCOOLSTEP_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_allrow) with cpu-temp/power/fan p50/p95/max and daily
throttle-event count. Populated byrotate()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=Xserves the
ledger (raw read-only query — dashboard still never writes sqlite).
Fixed — small correctness
_recover_armed_actionsno longer rebuilds corrupt (nameless)
entries under aNonekey — 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
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 firstregister()call. Module scripts execute as separate tasks, so the microtask drained before the other 18 tile modules finished loading; their registrations landed in_registryafter_runBoothad already snapshotted it, and_bootScheduled=trueblocked any re-run. Replaced the whole boot-snapshot design with per-tile mount-on-register (_mountByPrioritycalled directly fromregister()). 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. -
StaticFilesserved noCache-Controlheader. 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_NoCacheStaticsubclass that addsCache-Control: no-cacheon 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.jsURL stays valid by browser heuristic until eviction. Added?v=mount-on-registerquery to every tile's orchestrator import + themodulepreloadhint, 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 solepriority='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)
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_seenlied green for 60s after collector death. Health pill checkedstore.db.exists()— always True since the file persists. Switched toml-state.jsonmtime (per-tick liveness signal). Fail-detection window: 60s → 15s./api/event-segmentswas a stub returning[]. Daemon emittedSessionBoundaryevery load_jump but discarded them. Now persists todata/segments.jsonl, handler tail-reads. Defaultlimit=50→500.- Telemetry tiles froze for 6-7s windows. Orchestrator's
_telemetryLastTsdedup aliased with server cache TTL=1s + poller 1Hz + collector 1.5Hz. Dedup removed. calibration-state-tileshowed0/—indefinitely on cold-miss. Loading skeleton + lock-gated pre-warm.training-tilelazy + 5s poll = 7s freeze on tick counter. Changed tonormal+ 2s.
⚡ Fixed — perf (cache stampede + cold-miss)
/api/efficiencycache stampede. TOCTOU between cache-check andto_threadcompute spawned N concurrentcompute_historicalcalls. RSS burst 150MB → 2.5GB. Fix: per-keyasyncio.Lock+ double-check. Verified: 5 parallel cold = 1× compute + 4× cache-hit./api/calibrationsame stampede. Cold 16-30s → 30s timeouts on concurrent first-hits. Same lock +_warm_calibration_locked()so warm thread + first user serialize./api/predictor-cockpitmissed prior cache pass. 1030ms → 6ms warm (0.75s TTL withscope_sin 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-tile4 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
isNaNcheck. 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-recoveredevents 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
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 closed —
coolstep tailno longer reportscpu_tctl=0.0°Con 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
- See
CHANGELOG.mdfor the full entry. - Compare:
v0.5.16...v0.5.17
v0.5.16 — dashboard hardening + audit journal
Security hardening release. Pure additive: no behaviour regressions,
no breaking API changes.
Added
- Dashboard hardening middleware (
coolstep/dashboard/security.py)
— validates the HTTPHostheader on every request and theOrigin
/Sec-Fetch-Siteheaders on mutating endpoints. Non-browser clients
(curl, systemd timers, internal CLI) are unaffected. coolstep-dashboard --allow-publicbind guard. Non-loopback
--hostnow requires the explicit flag.--allow-host <fqdn>
extends theHostallowlist for reverse-proxy deployments.- Mode-switch audit journal —
/api/mode/{cool,quiet,off}append
toactuator-journal.jsonlwith prior mode + peer +X-Forwarded-For.
Surfaced by/api/actuator-journalandcoolstep 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
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 nowreading is no longer momentarily blank during the very first refresh —displayTfalls back tocur.tbefore the first tween completes.
Tests
715+ tests pass. No backend contract changes.
v0.5.7 — Instrument-flow: 10Hz cockpit + dual-curve canvas
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 ameta-led / balanced / archive-ledweight label. Backend shipsforecasts: {h5, h15, h30}sampled from the same meta-anchored saturation curve; UI picks which point to render. Selection persisted inlocalStorage. 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
±errpills — 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 exportsmedian_signed_err_calongsidemedian_abs_err_c. spike_detectorpredictor-margin exit gate — a spike that opens on a real residual but then stays open because the predictor systematically over-predicts (signed residual ≤ −N°Cfor M ticks) now closes withclosure_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 viaticks_per_secscaling. - Chroma writes moved to a background drain worker (bounded queue, single consumer — chroma client is not thread-safe for concurrent writes). Per-tick
chroma.addno 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'scapture()signature mismatch raised aTypeErroron every chroma operation and blocked the tick loop indirectly. - Test isolation —
daemon_factorymonkeypatch.setenv'sCOOLSTEP_HOMEto the test'stmp_path, so throttle-FSM tests no longer inherit state from the live daemon'sruntime-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
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 todata/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 fromstore.db.frames: reconstructsTelemetryFramefromraw_json, embeds, writes to chroma, then runsbackfill_labelsagainstthrottle_events. Idempotent — skips ids already present, safe to re-run.- drift detector —
chroma_no_growthindicator now gates onbase_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 acrossEmbedderinstances 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
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 phraseasymptote eq ≈ 76°, bucketn=5with shrinkage-wide σ=2.78°C)01-header-chips.png— ⚡ spike-active chip + twin±errpills02-hero.png— hero block with LIVE NOW / Δ / TREND / dT/dt03-canvas-rings.png— canvas zoom: past-prediction rings, σ-corridor, knee/danger reference lines04-bucket-strip.png— meta correction + σ + sample-countnwith 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
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; emitsIncident(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 existingincidents.jsonl+ multi-angle similarity; no parallel substrate. 10 tests.ResidualBank.correct()shrinkage prior (ADR-020) — each bucket starts withk=5pseudo-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_nowfeature separated fromcpu_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).