Files
Lumotia/docs/superpowers/plans/2026-05-09-battery-gpu-aware-thread-tuning.md
Jake 2491c7a7dd
Some checks failed
check / cargo check (macos-latest) (push) Has been cancelled
check / cargo check (ubuntu-22.04) (push) Has been cancelled
check / cargo check (windows-latest) (push) Has been cancelled
check / svelte build + lint (push) Has been cancelled
agent: lumotia-rebrand — fix Codex cross-model review blockers
Codex independent review found 11 blockers post-cascade. All addressed.

CRITICAL (data-loss / crash):

10. crates/core/src/paths.rs — migrate_legacy_data_dir_inner used
    fs::rename which fails with EXDEV when source + target are on
    different filesystems (encrypted-home, bind mounts, separate
    $XDG_DATA_HOME partition). Combined with the Phase 5 QC fix that
    made migration errors fatal, this would crash on first launch
    for any user whose data dir spans filesystems. Added
    rename_or_copy_tree() that falls back to copy_dir_recursive +
    remove_dir_all on CrossesDevices / errno 18 (EXDEV). Symlinks
    preserved verbatim. Same fallback applied to magnotia.db ->
    lumotia.db inside the dir.

11. Added 4 unit tests: copy_dir_recursive preserves nested
    structure, rename_or_copy_tree same-filesystem happy path,
    is_cross_device classifies CrossesDevices kind + raw errno 18.

Doc residuals (blockers 1-9):

1. crates/cloud-providers/Cargo.toml — "Wyrdnote pending rebrand"
   description.
2. crates/cloud-providers/src/provider.rs — module docs + test
   fixture Wyrdnote refs.
3. crates/transcription/src/orchestrator.rs — module docs + test
   fixture Wyrdnote refs.
4. docs/roadmap/2026-05-10-pkm-phase-tooling-shortlist.md — phase
   name + outputs/wyrdnote path refs.
5. docs/architecture-map/04-llm-formatting-mcp/llm-tests.md —
   MAGNOTIA_LLM_TEST_MODEL env var.
6. .../cloud-providers-stubs.md — MAGNOTIA_API_KEY_*.
7. docs/architecture-map/03-audio-transcription/tests-and-fixtures.md
   — MAGNOTIA_WHISPER_*.
8. docs/gpu-tuning/plan.md — MAGNOTIA_BENCH_RUN.
9. docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-
   design.md + corresponding plan — MAGNOTIA_POWER_STATE_OVERRIDE,
   MAGNOTIA_INFERENCE_THREADS.

cargo test --workspace: 343 pass / 0 fail (up from 339; +4 EXDEV
fallback tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:39:21 +01:00

1404 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Battery- and GPU-aware Inference Thread Tuning 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:** Add power-aware and GPU-offload-aware clamps to `inference_thread_count` so Lumenote drops to ~half threads on battery and to a workload-specific floor when fully GPU-offloaded.
**Architecture:** Two new modules in `lumotia-core`: `power.rs` (Linux sysfs probe with TTL cache) and `tuning.rs` (Workload enum + helper). Both whisper and LLM call sites compute a `gpu_offloaded` bool and pass it in. `vulkan_loader_available()` moves from `src-tauri` to `lumotia-core::hardware` so transcription can call it. Old `constants::inference_thread_count` is removed once both call sites migrate.
**Tech Stack:** Rust 2021, `lumotia-core` (existing pure-Rust crate), `num_cpus`, `libloading`, `tracing`, `tempfile` (dev). No Tauri or platform FFI for the core probe — Linux sysfs only in v1.
**Spec:** [docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-design.md](../specs/2026-05-09-battery-gpu-aware-thread-tuning-design.md)
---
## File Structure
**New files:**
- `crates/core/src/power.rs``PowerState` enum, sysfs probe, TTL cache, test override.
- `crates/core/src/tuning.rs``Workload` enum, `inference_thread_count`, per-process logging.
**Modified files:**
- `crates/core/Cargo.toml` — add `libloading`, `tracing`; dev-deps add `tempfile`.
- `crates/core/src/lib.rs` — register new modules, re-export public API.
- `crates/core/src/hardware.rs` — add `vulkan_loader_available()`, ported from src-tauri.
- `crates/core/src/constants.rs` — remove `inference_thread_count` and the related min/max constants (move semantically to `tuning.rs`; the audio/RAM/VAD/etc. constants stay).
- `src-tauri/src/commands/models.rs` — delete local `vulkan_loader_available` definition, replace with `lumotia_core::hardware::vulkan_loader_available` call.
- `src-tauri/Cargo.toml` — keep `libloading` since src-tauri still depends on it elsewhere; verify with `cargo tree`.
- `crates/transcription/src/whisper_rs_backend.rs` — compute `gpu_offloaded`, call `tuning::inference_thread_count(Workload::Whisper, gpu_offloaded)`.
- `crates/llm/src/lib.rs` — compute `gpu_offloaded` after model load, call `tuning::inference_thread_count(Workload::Llm, gpu_offloaded)`.
- `crates/transcription/tests/thread_sweep.rs` — extend to print 4 panels (CPU/GPU × AC/battery).
---
## Phase 1 — `power.rs`: PowerState module
### Task 1.1: Skeleton + lib registration
**Files:**
- Create: `crates/core/src/power.rs`
- Modify: `crates/core/src/lib.rs`
- Modify: `crates/core/Cargo.toml` (add `tempfile = "3"` to dev-dependencies)
- [ ] **Step 1: Add tempfile dev-dep**
Edit `crates/core/Cargo.toml` — append after the `[dependencies]` block:
```toml
[dev-dependencies]
tempfile = "3"
```
- [ ] **Step 2: Write the failing test for the enum**
Create `crates/core/src/power.rs`:
```rust
//! Power-state probe for inference thread tuning.
//!
//! Reports whether the machine is running on AC or battery so callers
//! can drop thread counts when energy matters more than throughput.
//! Linux uses the documented sysfs ABI under
//! `/sys/class/power_supply/`. macOS and Windows return `Unknown` for
//! now; native probes (IOPSGetProvidingPowerSourceType,
//! GetSystemPowerStatus) are deferred.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerState {
OnAc,
OnBattery,
Unknown,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn power_state_variants_are_distinct() {
assert_ne!(PowerState::OnAc, PowerState::OnBattery);
assert_ne!(PowerState::OnAc, PowerState::Unknown);
assert_ne!(PowerState::OnBattery, PowerState::Unknown);
}
}
```
Add to `crates/core/src/lib.rs` after the existing module declarations:
```rust
pub mod power;
```
- [ ] **Step 3: Run the test**
Run: `cargo test -p lumotia-core --lib power::tests::power_state_variants_are_distinct`
Expected: PASS.
- [ ] **Step 4: Commit**
```bash
cd /home/jake/Documents/CORBEL-Projects/lumotia
git add crates/core/Cargo.toml crates/core/src/power.rs crates/core/src/lib.rs
git commit -m "feat(core): add PowerState skeleton in new power module"
```
---
### Task 1.2: Sysfs parser
**Files:**
- Modify: `crates/core/src/power.rs`
- [ ] **Step 1: Write failing tests for sysfs parsing**
Append to `crates/core/src/power.rs` inside `mod tests` (replace the existing `mod tests` block with this expanded version):
```rust
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn power_state_variants_are_distinct() {
assert_ne!(PowerState::OnAc, PowerState::OnBattery);
assert_ne!(PowerState::OnAc, PowerState::Unknown);
assert_ne!(PowerState::OnBattery, PowerState::Unknown);
}
fn write_supply(dir: &std::path::Path, name: &str, ty: &str, online: &str) {
let entry = dir.join(name);
fs::create_dir_all(&entry).unwrap();
fs::write(entry.join("type"), format!("{ty}\n")).unwrap();
fs::write(entry.join("online"), format!("{online}\n")).unwrap();
}
#[test]
fn parses_mains_online_as_on_ac() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "AC", "Mains", "1");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
}
#[test]
fn parses_battery_only_as_on_battery() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "AC", "Mains", "0");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnBattery);
}
#[test]
fn parses_usb_pd_online_as_on_ac() {
let dir = tempdir().unwrap();
write_supply(dir.path(), "ucsi-source-psy-USBC000:001", "USB", "1");
write_supply(dir.path(), "BAT0", "Battery", "0");
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
}
#[test]
fn parses_empty_dir_as_unknown() {
let dir = tempdir().unwrap();
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
}
#[test]
fn parses_missing_dir_as_unknown() {
let path = std::path::Path::new("/no/such/path/should/exist/at/this/inode/123456");
assert_eq!(parse_power_state_from_dir(path), PowerState::Unknown);
}
#[test]
fn parses_malformed_files_as_unknown_gracefully() {
let dir = tempdir().unwrap();
let entry = dir.path().join("garbage");
fs::create_dir_all(&entry).unwrap();
// No type, no online — should not panic.
assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
}
}
```
- [ ] **Step 2: Run tests, expect compile failure**
Run: `cargo test -p lumotia-core --lib power::tests`
Expected: compile error — `parse_power_state_from_dir` not found.
- [ ] **Step 3: Implement parser**
Add above the `#[cfg(test)] mod tests` block in `crates/core/src/power.rs`:
```rust
use std::fs;
use std::path::Path;
/// Parse a `/sys/class/power_supply/`-style directory and decide
/// whether the machine is on AC, on battery, or in an unknown state.
///
/// Rules (matches `Documentation/ABI/testing/sysfs-class-power`):
/// - Any entry with `type` in {`Mains`, `USB`} and `online == 1` → OnAc.
/// - Else any entry with `type == Battery` → OnBattery.
/// - Else → Unknown.
///
/// Failures (missing dir, unreadable files, malformed contents) all
/// fall through to Unknown rather than panicking. `Unknown` is treated
/// as `OnAc` by the caller, which preserves today's pre-clamp
/// behaviour on platforms or distros where the probe doesn't fire.
pub fn parse_power_state_from_dir(supply_dir: &Path) -> PowerState {
let read_dir = match fs::read_dir(supply_dir) {
Ok(rd) => rd,
Err(_) => return PowerState::Unknown,
};
let mut saw_battery = false;
for entry in read_dir.flatten() {
let path = entry.path();
let ty = read_trimmed(&path.join("type")).unwrap_or_default();
let online = read_trimmed(&path.join("online")).unwrap_or_default();
match ty.as_str() {
"Mains" | "USB" => {
if online == "1" {
return PowerState::OnAc;
}
}
"Battery" => {
saw_battery = true;
}
_ => {}
}
}
if saw_battery {
PowerState::OnBattery
} else {
PowerState::Unknown
}
}
fn read_trimmed(path: &Path) -> Option<String> {
fs::read_to_string(path).ok().map(|s| s.trim().to_owned())
}
```
- [ ] **Step 4: Run tests, expect pass**
Run: `cargo test -p lumotia-core --lib power::tests`
Expected: all 6 tests pass.
- [ ] **Step 5: Commit**
```bash
git add crates/core/src/power.rs
git commit -m "feat(core): parse_power_state_from_dir reads sysfs power_supply ABI"
```
---
### Task 1.3: `probe_power_state` + overrides
**Files:**
- Modify: `crates/core/src/power.rs`
This task adds the public probe entry point. Two override paths:
1. `with_override(state, |closure|)` — in-process, serialised via a `TEST_LOCK` mutex held for the body. Used by unit tests because cargo's parallel runner makes env-var-based tests flaky.
2. `LUMOTIA_POWER_STATE_OVERRIDE` env var — used by `thread_sweep.rs` integration test (set externally, not from inside `cargo test`'s parallel block).
- [ ] **Step 1: Write failing unit tests using `with_override`**
Append inside `mod tests` in `crates/core/src/power.rs`:
```rust
#[test]
fn override_drives_battery() {
with_override(Some(PowerState::OnBattery), || {
assert_eq!(probe_power_state(), PowerState::OnBattery);
});
}
#[test]
fn override_drives_ac() {
with_override(Some(PowerState::OnAc), || {
assert_eq!(probe_power_state(), PowerState::OnAc);
});
}
#[test]
fn override_drives_unknown() {
with_override(Some(PowerState::Unknown), || {
assert_eq!(probe_power_state(), PowerState::Unknown);
});
}
#[test]
fn env_var_override_battery_via_set_var() {
// env-var path tested in isolation under TEST_LOCK so it
// doesn't race with the in-process override tests.
with_override(None, || {
std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", "battery");
assert_eq!(probe_power_state(), PowerState::OnBattery);
std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE");
});
}
#[test]
fn env_var_override_garbage_falls_through() {
with_override(None, || {
std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", "nonsense");
// Garbage value falls through to the platform probe.
// We can't assert the platform result so just assert it
// doesn't panic.
let _ = probe_power_state();
std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE");
});
}
```
- [ ] **Step 2: Run tests, expect compile failure**
Run: `cargo test -p lumotia-core --lib power::tests`
Expected: compile error — `probe_power_state`, `with_override` not found.
- [ ] **Step 3: Implement probe + overrides**
Add to `crates/core/src/power.rs` above `#[cfg(test)] mod tests`:
```rust
use std::sync::Mutex;
/// Top-level power-state probe.
///
/// Resolution order (highest to lowest priority):
/// 1. In-process test override (set via `with_override` from unit tests).
/// 2. `LUMOTIA_POWER_STATE_OVERRIDE` env var (`ac` | `battery` | `unknown`,
/// case-insensitive). Used by `thread_sweep.rs` integration tests.
/// 3. Linux: `parse_power_state_from_dir("/sys/class/power_supply")`.
/// 4. macOS / Windows / other: `Unknown`.
///
/// `Unknown` is treated as `OnAc` by callers, which preserves today's
/// pre-clamp behaviour on platforms where the probe doesn't fire.
///
/// (TTL cache is added in Task 1.4.)
pub fn probe_power_state() -> PowerState {
if let Some(state) = test_get_override() {
return state;
}
if let Some(state) = env_override() {
return state;
}
platform_probe()
}
fn env_override() -> Option<PowerState> {
let raw = std::env::var("LUMOTIA_POWER_STATE_OVERRIDE").ok()?;
match raw.trim().to_ascii_lowercase().as_str() {
"ac" => Some(PowerState::OnAc),
"battery" => Some(PowerState::OnBattery),
"unknown" => Some(PowerState::Unknown),
_ => None,
}
}
#[cfg(target_os = "linux")]
fn platform_probe() -> PowerState {
parse_power_state_from_dir(Path::new("/sys/class/power_supply"))
}
#[cfg(not(target_os = "linux"))]
fn platform_probe() -> PowerState {
PowerState::Unknown
}
// In-process override slot. Tests must use `with_override` (below) to
// set it; production code never writes to this. Read on every probe.
static TEST_OVERRIDE: Mutex<Option<PowerState>> = Mutex::new(None);
fn test_get_override() -> Option<PowerState> {
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned")
}
/// Run `body` with the in-process override set to `state`. Restores
/// the previous override (always `None` in practice) on return.
///
/// Holds a dedicated `TEST_LOCK` mutex for the duration of the body,
/// so override-using unit tests run serially with respect to each
/// other even when cargo runs the test binary multi-threaded.
#[cfg(test)]
pub(crate) fn with_override<R>(state: Option<PowerState>, body: impl FnOnce() -> R) -> R {
static TEST_LOCK: Mutex<()> = Mutex::new(());
let _lock = TEST_LOCK.lock().expect("power TEST_LOCK poisoned");
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = state;
let result = body();
*TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = None;
result
}
```
- [ ] **Step 4: Run tests, expect pass**
Run: `cargo test -p lumotia-core --lib power::tests`
Expected: all tests in the module pass (sysfs parser tests from Task 1.2 plus the 5 new override tests).
- [ ] **Step 5: Commit**
```bash
git add crates/core/src/power.rs
git commit -m "feat(core): probe_power_state with Linux dispatch + override paths"
```
---
### Task 1.4: 10-second TTL cache
**Files:**
- Modify: `crates/core/src/power.rs`
The cache sits between override resolution and `platform_probe`. Override paths (test + env var) are never cached so they always take effect immediately. Two test-only helpers expose the cache: `force_clear_cache()` and `force_set_cache(state)`.
The cache tests must run inside `with_override(None, || …)` so they hold the same `TEST_LOCK` mutex as the override tests; otherwise an override-using test running in parallel could pollute the assertions.
- [ ] **Step 1: Implement the cache**
Add to `crates/core/src/power.rs` near the other top-level items:
```rust
use std::sync::OnceLock;
use std::time::{Duration, Instant};
const POWER_STATE_TTL: Duration = Duration::from_secs(10);
struct CachedState {
state: PowerState,
fetched_at: Instant,
}
fn cache_slot() -> &'static Mutex<Option<CachedState>> {
static SLOT: OnceLock<Mutex<Option<CachedState>>> = OnceLock::new();
SLOT.get_or_init(|| Mutex::new(None))
}
#[cfg(test)]
pub(crate) fn force_clear_cache() {
*cache_slot().lock().expect("power cache mutex poisoned") = None;
}
#[cfg(test)]
pub(crate) fn force_set_cache(state: PowerState) {
*cache_slot().lock().expect("power cache mutex poisoned") = Some(CachedState {
state,
fetched_at: Instant::now(),
});
}
```
- [ ] **Step 2: Wire the cache into `probe_power_state`**
Replace the body of `probe_power_state`:
```rust
pub fn probe_power_state() -> PowerState {
if let Some(state) = test_get_override() {
return state;
}
if let Some(state) = env_override() {
return state;
}
let mut slot = cache_slot().lock().expect("power cache mutex poisoned");
if let Some(cached) = &*slot {
if cached.fetched_at.elapsed() < POWER_STATE_TTL {
return cached.state;
}
}
let fresh = platform_probe();
*slot = Some(CachedState {
state: fresh,
fetched_at: Instant::now(),
});
fresh
}
```
- [ ] **Step 3: Write cache tests**
Append inside `mod tests` in `crates/core/src/power.rs`:
```rust
#[test]
fn ttl_cache_returns_cached_value_within_window() {
with_override(None, || {
force_clear_cache();
force_set_cache(PowerState::OnBattery);
// No override active, no env var; probe should hit cache.
assert_eq!(probe_power_state(), PowerState::OnBattery);
});
}
#[test]
fn ttl_cache_clears_via_force_clear() {
with_override(None, || {
force_set_cache(PowerState::OnBattery);
force_clear_cache();
// Override flips to OnAc; with cleared cache, override
// (which has higher priority) drives the result.
});
with_override(Some(PowerState::OnAc), || {
force_clear_cache();
assert_eq!(probe_power_state(), PowerState::OnAc);
});
}
```
- [ ] **Step 4: Run tests, expect pass**
Run: `cargo test -p lumotia-core --lib power::tests`
Expected: all power tests pass (sysfs parser + override + cache).
- [ ] **Step 5: Commit**
```bash
git add crates/core/src/power.rs
git commit -m "feat(core): 10s TTL cache on probe_power_state"
```
---
## Phase 2 — `tuning.rs`: Workload-aware helper
### Task 2.1: Skeleton + Workload + helper that matches today's output
**Files:**
- Create: `crates/core/src/tuning.rs`
- Modify: `crates/core/src/lib.rs`
- Modify: `crates/core/Cargo.toml` (add `tracing = "0.1"` to `[dependencies]`)
- [ ] **Step 1: Add tracing dep**
Edit `crates/core/Cargo.toml`, append in `[dependencies]`:
```toml
tracing = "0.1"
```
- [ ] **Step 2: Write the failing test for the helper's pre-clamp behaviour**
Create `crates/core/src/tuning.rs`:
```rust
//! Inference thread-count tuning. Combines physical-core budget,
//! battery awareness, and GPU-offload awareness into a single helper
//! callable from both inference call sites.
use crate::power::{self, PowerState};
/// Lower bound for inference threads. Single-threaded inference is
/// measurably worse than two on every multi-core part.
pub const MIN_INFERENCE_THREADS: usize = 2;
/// Upper bound for inference threads. whisper.cpp + llama.cpp scaling
/// flattens around physical core count; SMT siblings contend for FPU
/// during F16/F32 matmul, so going past physical cores anti-scales.
/// 8 is a conservative ceiling that leaves <5% on the table for
/// big-iron desktops while keeping consumer 6c/12t laptops out of
/// contention territory. Users can override at runtime via
/// LUMOTIA_INFERENCE_THREADS.
pub const MAX_INFERENCE_THREADS: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Workload {
/// Llama-style transformer. Near-zero CPU work when fully
/// offloaded; CPU floor in that case is GPU_FLOOR_LLM.
Llm,
/// Whisper transcription. Keeps mel spectrogram, decoder
/// bookkeeping, and beam search on the CPU even with full Vulkan
/// offload; CPU floor is higher: GPU_FLOOR_WHISPER.
Whisper,
}
const GPU_FLOOR_LLM: usize = 2;
const GPU_FLOOR_WHISPER: usize = 4;
/// Inference thread count, clamped to the physical-core budget plus
/// the battery and GPU-offload heuristics.
///
/// Resolution order:
/// 1. `LUMOTIA_INFERENCE_THREADS=N` — absolute bypass, returns N.
/// 2. base = num_cpus::get_physical() (fallback: available_parallelism).
/// 3. on battery → base /= 2.
/// 4. gpu_offloaded → base = min(base, gpu_floor(workload)).
/// 5. clamp to [MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS].
pub fn inference_thread_count(_workload: Workload, _gpu_offloaded: bool) -> usize {
if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") {
if let Ok(n) = s.parse::<usize>() {
if n > 0 {
return n;
}
}
}
let physical = num_cpus::get_physical();
let chosen = if physical > 0 {
physical
} else {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(MIN_INFERENCE_THREADS)
};
chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn matches_existing_clamp_when_no_clamps_apply() {
// With no env override, no battery, no gpu_offload, helper
// should return physical-core count clamped to [2, 8].
// We can't pin physical exactly without mocking num_cpus; just
// assert the result is in range.
std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
let n = inference_thread_count(Workload::Llm, false);
assert!(n >= MIN_INFERENCE_THREADS && n <= MAX_INFERENCE_THREADS,
"got {n}, expected within [{MIN_INFERENCE_THREADS}, {MAX_INFERENCE_THREADS}]");
}
#[test]
fn env_var_bypasses_clamps() {
std::env::set_var("LUMOTIA_INFERENCE_THREADS", "10");
let n = inference_thread_count(Workload::Llm, true);
assert_eq!(n, 10);
std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
}
#[test]
fn workload_variants_distinct() {
assert_ne!(Workload::Llm, Workload::Whisper);
}
}
```
Add to `crates/core/src/lib.rs`:
```rust
pub mod tuning;
```
(Place after `pub mod power;` alphabetically or wherever fits the existing ordering.)
- [ ] **Step 3: Run the test**
Run: `cargo test -p lumotia-core --lib tuning::tests`
Expected: 3 tests pass.
- [ ] **Step 4: Commit**
```bash
git add crates/core/Cargo.toml crates/core/src/tuning.rs crates/core/src/lib.rs
git commit -m "feat(core): tuning module with Workload + helper skeleton"
```
---
### Task 2.2: Battery clamp branch
**Files:**
- Modify: `crates/core/src/tuning.rs`
- [ ] **Step 1: Write the failing test**
Add to `mod tests` in `crates/core/src/tuning.rs`:
```rust
#[test]
fn battery_halves_thread_count() {
std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
// Lock both the test override mutex and assert.
crate::power::with_override(Some(PowerState::OnBattery), || {
let on_battery = inference_thread_count(Workload::Llm, false);
crate::power::with_override(Some(PowerState::OnAc), || {
let on_ac = inference_thread_count(Workload::Llm, false);
// on_battery should be roughly half of on_ac, clamped
// to MIN.
if on_ac > MIN_INFERENCE_THREADS {
assert!(
on_battery <= on_ac,
"battery ({on_battery}) should be <= AC ({on_ac})"
);
assert!(on_battery >= MIN_INFERENCE_THREADS);
}
});
});
}
```
(`with_override` is private to `power`'s tests by default; expose it for cross-module unit tests by changing `#[cfg(test)] pub(crate) fn with_override` to `#[cfg(test)] pub fn with_override` so `tuning::tests` can call it. Or keep it `pub(crate)` since `tuning` is in the same crate — `pub(crate)` is sufficient.)
Verify in `power.rs`: the existing `pub(crate)` on `with_override`, `force_clear_cache`, `force_set_cache` is correct for cross-module access. No change needed.
But wait — these test helpers are gated `#[cfg(test)]` so they're only available when compiling for tests. From `tuning::tests` they ARE compiled for tests, so `pub(crate)` works. Good.
- [ ] **Step 2: Run, expect failure**
Run: `cargo test -p lumotia-core --lib tuning::tests::battery_halves_thread_count`
Expected: FAIL — assertion that on_battery <= on_ac fails because helper currently ignores power state. (On a 1c host, on_ac and on_battery would both be 2 and the test would no-op via the early `if on_ac > MIN_INFERENCE_THREADS` check; in that environment the test isn't useful. CI hosts with >2 physical cores will exercise it.)
- [ ] **Step 3: Implement battery clamp**
Modify `inference_thread_count` in `crates/core/src/tuning.rs`. Replace the body with:
```rust
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") {
if let Ok(n) = s.parse::<usize>() {
if n > 0 {
return n;
}
}
}
let physical = num_cpus::get_physical();
let mut chosen = if physical > 0 {
physical
} else {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(MIN_INFERENCE_THREADS)
};
if power::probe_power_state() == PowerState::OnBattery {
chosen /= 2;
}
let _ = (workload, gpu_offloaded); // GPU clamp added in next task
chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
}
```
(The `let _ = (workload, gpu_offloaded);` silences unused-arg warnings until task 2.3 wires them in.)
- [ ] **Step 4: Run, expect pass**
Run: `cargo test -p lumotia-core --lib tuning::tests`
Expected: 4 tests pass.
- [ ] **Step 5: Commit**
```bash
git add crates/core/src/tuning.rs
git commit -m "feat(core): inference_thread_count halves on battery"
```
---
### Task 2.3: GPU-offload clamp with workload-specific floor
**Files:**
- Modify: `crates/core/src/tuning.rs`
- [ ] **Step 1: Write the failing tests**
Add to `mod tests` in `crates/core/src/tuning.rs`:
```rust
#[test]
fn gpu_offload_clamps_llm_to_floor() {
std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
crate::power::with_override(Some(PowerState::OnAc), || {
let n = inference_thread_count(Workload::Llm, true);
assert!(n <= GPU_FLOOR_LLM.max(MIN_INFERENCE_THREADS),
"Llm with GPU offload should clamp to {GPU_FLOOR_LLM}, got {n}");
assert!(n >= MIN_INFERENCE_THREADS);
});
}
#[test]
fn gpu_offload_clamps_whisper_to_floor() {
std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
crate::power::with_override(Some(PowerState::OnAc), || {
let n = inference_thread_count(Workload::Whisper, true);
// Whisper floor is 4 on machines with >=4 physical cores;
// can't go lower than physical so on a 2c host we stay at 2.
assert!(n <= GPU_FLOOR_WHISPER, "got {n}");
assert!(n >= MIN_INFERENCE_THREADS);
});
}
#[test]
fn whisper_gpu_floor_is_at_least_llm_gpu_floor() {
// Architectural invariant: whisper retains CPU work even when
// GPU-offloaded; its floor must not be lower than the LLM's.
assert!(GPU_FLOOR_WHISPER >= GPU_FLOOR_LLM);
}
#[test]
fn gpu_offload_off_does_not_clamp_below_battery_calc() {
std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
crate::power::with_override(Some(PowerState::OnAc), || {
let no_gpu = inference_thread_count(Workload::Llm, false);
let with_gpu = inference_thread_count(Workload::Llm, true);
// GPU-offloaded should be <= non-offloaded.
assert!(with_gpu <= no_gpu);
});
}
```
- [ ] **Step 2: Run, expect failure**
Run: `cargo test -p lumotia-core --lib tuning::tests`
Expected: GPU-offload tests fail because the helper still ignores `gpu_offloaded`.
- [ ] **Step 3: Implement GPU-offload clamp**
Modify `inference_thread_count` in `crates/core/src/tuning.rs`:
```rust
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") {
if let Ok(n) = s.parse::<usize>() {
if n > 0 {
return n;
}
}
}
let physical = num_cpus::get_physical();
let mut chosen = if physical > 0 {
physical
} else {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(MIN_INFERENCE_THREADS)
};
if power::probe_power_state() == PowerState::OnBattery {
chosen /= 2;
}
if gpu_offloaded {
let floor = match workload {
Workload::Llm => GPU_FLOOR_LLM,
Workload::Whisper => GPU_FLOOR_WHISPER,
};
chosen = chosen.min(floor);
}
chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
}
```
- [ ] **Step 4: Run, expect pass**
Run: `cargo test -p lumotia-core --lib tuning::tests`
Expected: 8 tests pass.
- [ ] **Step 5: Commit**
```bash
git add crates/core/src/tuning.rs
git commit -m "feat(core): GPU-offload clamp with per-workload floor"
```
---
### Task 2.4: Per-process logging cache
**Files:**
- Modify: `crates/core/src/tuning.rs`
- [ ] **Step 1: Write the test (manual verification, no assertion logic)**
Add to `mod tests` in `crates/core/src/tuning.rs`:
```rust
#[test]
fn logging_does_not_panic() {
// Smoke: helper should run without panicking when logging is
// wired. This is covered by the other tests too, but kept
// explicitly to document the behaviour.
std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
crate::power::with_override(Some(PowerState::OnBattery), || {
let _ = inference_thread_count(Workload::Llm, true);
let _ = inference_thread_count(Workload::Whisper, false);
});
}
```
- [ ] **Step 2: Run, expect pass (already passes, the test is a smoke)**
Run: `cargo test -p lumotia-core --lib tuning::tests::logging_does_not_panic`
Expected: PASS.
- [ ] **Step 3: Implement per-process log cache**
Add to `crates/core/src/tuning.rs`:
```rust
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
/// Whether a given (workload, on_battery, gpu_offloaded) tuple has
/// already been logged this process. Prevents log spam when the same
/// configuration is exercised on every inference call.
fn log_seen() -> &'static Mutex<HashSet<(Workload, bool, bool)>> {
static SEEN: OnceLock<Mutex<HashSet<(Workload, bool, bool)>>> = OnceLock::new();
SEEN.get_or_init(|| Mutex::new(HashSet::new()))
}
```
Update `inference_thread_count` to log once per tuple:
```rust
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") {
if let Ok(n) = s.parse::<usize>() {
if n > 0 {
return n;
}
}
}
let physical = num_cpus::get_physical();
let mut chosen = if physical > 0 {
physical
} else {
std::thread::available_parallelism()
.map(|p| p.get())
.unwrap_or(MIN_INFERENCE_THREADS)
};
let on_battery = power::probe_power_state() == PowerState::OnBattery;
let mut clamps: Vec<&'static str> = Vec::new();
if on_battery {
chosen /= 2;
clamps.push("battery");
}
if gpu_offloaded {
let floor = match workload {
Workload::Llm => GPU_FLOOR_LLM,
Workload::Whisper => GPU_FLOOR_WHISPER,
};
if chosen > floor {
chosen = floor;
clamps.push("gpu");
}
}
let final_value = chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS);
// Log once per (workload, on_battery, gpu_offloaded) tuple.
let key = (workload, on_battery, gpu_offloaded);
if let Ok(mut seen) = log_seen().lock() {
if seen.insert(key) {
tracing::info!(
target: "lumotia_core::tuning",
threads = final_value,
workload = ?workload,
gpu_offloaded,
on_battery,
clamps = ?clamps,
"inference_thread_count"
);
}
}
final_value
}
```
Note `Workload` needs to be `Hash + Eq` for the `HashSet`. Add the derives:
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Workload {
Llm,
Whisper,
}
```
- [ ] **Step 4: Run all tests**
Run: `cargo test -p lumotia-core --lib`
Expected: all power + tuning tests pass.
- [ ] **Step 5: Commit**
```bash
git add crates/core/src/tuning.rs
git commit -m "feat(core): per-process INFO log on first thread-count call per tuple"
```
---
## Phase 3 — Move `vulkan_loader_available` to core
### Task 3.1: Port function to `lumotia-core::hardware`
**Files:**
- Modify: `crates/core/Cargo.toml` (add `libloading = "0.8"`)
- Modify: `crates/core/src/hardware.rs`
- [ ] **Step 1: Add libloading dep to core**
Edit `crates/core/Cargo.toml`, append in `[dependencies]`:
```toml
libloading = "0.8"
```
- [ ] **Step 2: Write a test that asserts the function is callable**
Add to `crates/core/src/hardware.rs` inside the existing `#[cfg(test)] mod tests` block (after the existing tests):
```rust
#[test]
fn vulkan_loader_available_does_not_panic() {
// We can't assert the value (depends on host's libvulkan),
// but we can assert the call completes.
let _ = vulkan_loader_available();
}
```
- [ ] **Step 3: Run, expect compile failure**
Run: `cargo test -p lumotia-core --lib hardware::tests::vulkan_loader_available_does_not_panic`
Expected: compile error — function not defined.
- [ ] **Step 4: Add `vulkan_loader_available` to hardware.rs**
Append to `crates/core/src/hardware.rs`:
```rust
/// Best-effort probe for the Vulkan loader shared library.
///
/// whisper.cpp and llama.cpp Vulkan backends silently drop to CPU if
/// `libvulkan.so.1` (Linux) / `vulkan-1.dll` (Windows) / the MoltenVK
/// bundle (macOS) is missing at runtime. We probe via
/// `libloading::Library::new`; a successful open means the loader is
/// resolvable and we should treat the GPU path as live.
///
/// Moved from `src-tauri/src/commands/models.rs` so non-Tauri crates
/// (transcription, llm) can call it without depending on the Tauri
/// binary.
pub fn vulkan_loader_available() -> bool {
#[cfg(target_os = "linux")]
let candidates: &[&str] = &["libvulkan.so.1", "libvulkan.so"];
#[cfg(target_os = "windows")]
let candidates: &[&str] = &["vulkan-1.dll"];
#[cfg(target_os = "macos")]
let candidates: &[&str] = &["libvulkan.1.dylib", "libMoltenVK.dylib"];
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
let candidates: &[&str] = &[];
for name in candidates {
// SAFETY: libloading::Library::new loads a shared library and
// returns a handle that is dropped at the end of this
// iteration. We do not call any symbols, so the open-for-probe
// pattern is sound.
match unsafe { libloading::Library::new(*name) } {
Ok(_lib) => return true,
Err(_) => continue,
}
}
false
}
```
- [ ] **Step 5: Run, expect pass**
Run: `cargo test -p lumotia-core --lib hardware::tests::vulkan_loader_available_does_not_panic`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add crates/core/Cargo.toml crates/core/src/hardware.rs
git commit -m "feat(core): port vulkan_loader_available from src-tauri"
```
---
### Task 3.2: Update src-tauri caller
**Files:**
- Modify: `src-tauri/src/commands/models.rs`
- [ ] **Step 1: Replace local definition with re-export**
Find the local `fn vulkan_loader_available()` in `src-tauri/src/commands/models.rs` (lines around 322350 of the file as of the spec date). Delete the function entirely. Add at the top of the file (or near other `lumotia_core` imports):
```rust
use lumotia_core::hardware::vulkan_loader_available;
```
The two existing call sites in `models.rs` (around lines 401 and 419) will now resolve to the imported function. Verify with grep that no other callers exist inside src-tauri:
```bash
grep -n "vulkan_loader_available" src-tauri/src/
```
Expected output: only the `use` line and the two existing call sites.
- [ ] **Step 2: Build and run src-tauri's tests**
Run: `cargo test -p lumotia` (the src-tauri crate name as listed in `src-tauri/Cargo.toml`; verify if unsure with `grep '^name' src-tauri/Cargo.toml`).
Expected: PASS.
- [ ] **Step 3: Run the existing accelerator-list smoke**
Run: `cargo test -p lumotia compose_accelerators`
Expected: PASS — confirms the move didn't change behaviour.
- [ ] **Step 4: Commit**
```bash
git add src-tauri/src/commands/models.rs
git commit -m "refactor(tauri): use lumotia_core::hardware::vulkan_loader_available"
```
---
## Phase 4 — Whisper call site
### Task 4.1: Wire whisper backend through new helper
**Files:**
- Modify: `crates/transcription/src/whisper_rs_backend.rs`
- [ ] **Step 1: Replace the call site**
Find the existing line (around line 81):
```rust
params.set_n_threads(inference_thread_count() as i32);
```
…and the import at the top:
```rust
use lumotia_core::constants::inference_thread_count;
```
Replace the import:
```rust
use lumotia_core::tuning::{inference_thread_count, Workload};
use lumotia_core::hardware::vulkan_loader_available;
```
Replace the call site:
```rust
let gpu_offloaded = cfg!(feature = "whisper-vulkan") && vulkan_loader_available();
params.set_n_threads(inference_thread_count(Workload::Whisper, gpu_offloaded) as i32);
```
- [ ] **Step 2: Build and run the existing whisper smoke**
Run: `cargo test -p lumotia-transcription whisper_rs_smoke`
Expected: PASS — the helper returns a sensible value (likely 4 or 6 depending on host).
- [ ] **Step 3: Commit**
```bash
git add crates/transcription/src/whisper_rs_backend.rs
git commit -m "feat(transcription): whisper threads use Workload::Whisper + GPU detection"
```
---
## Phase 5 — LLM call site
### Task 5.1: Wire LLM call site through new helper
**Files:**
- Modify: `crates/llm/src/lib.rs`
- [ ] **Step 1: Locate the current thread-count computation**
The existing code in `crates/llm/src/lib.rs` (around line 174 in `load_model`) reads:
```rust
let gpu_layers = if use_gpu { u32::MAX } else { 0 };
let params = LlamaModelParams::default().with_n_gpu_layers(gpu_layers);
// ... LlamaModel::load_from_file_with_params(...) returns `model` ...
let thread_count = i32::try_from(lumotia_core::constants::inference_thread_count())
.unwrap_or(4);
let ctx_params = LlamaContextParams::default()
.with_n_ctx(Some(NonZeroU32::new(n_ctx).expect("n_ctx must be non-zero")))
.with_n_threads(thread_count)
.with_n_threads_batch(thread_count);
```
The model has been loaded by this point (the `model` binding above this block returns from `LlamaModel::load_from_file_with_params`), so `model.n_layer()` is callable.
- [ ] **Step 2: Replace the import + computation**
Replace the existing `use lumotia_core::constants::inference_thread_count;` (or wherever `constants::inference_thread_count` is referenced) with:
```rust
use lumotia_core::tuning::{inference_thread_count, Workload};
```
Replace the thread-count computation block. The new shape uses the `gpu_layers` value already computed above and cross-checks against the loaded model's layer count:
```rust
let gpu_offloaded = use_gpu && gpu_layers >= model.n_layer();
let thread_count = i32::try_from(inference_thread_count(Workload::Llm, gpu_offloaded))
.unwrap_or(4);
```
The cross-check is trivially true today (`gpu_layers = u32::MAX` when `use_gpu`), but the explicit comparison reads as a future-proofed assertion if we ever pass a specific N instead of `u32::MAX`.
- [ ] **Step 3: Build and run LLM smoke**
Run: `cargo test -p lumotia-llm`
Expected: PASS (smoke tests in `crates/llm/tests/` exercise the load path; if any fail unrelated to this change, investigate before moving on).
- [ ] **Step 4: Commit**
```bash
git add crates/llm/src/lib.rs
git commit -m "feat(llm): LLM threads use Workload::Llm + GPU offload detection"
```
---
## Phase 6 — Remove old facade
### Task 6.1: Delete `inference_thread_count` from `constants.rs`
**Files:**
- Modify: `crates/core/src/constants.rs`
- [ ] **Step 1: Confirm zero remaining callers**
Run:
```bash
cd /home/jake/Documents/CORBEL-Projects/lumotia
grep -rn "constants::inference_thread_count\|MIN_INFERENCE_THREADS\|MAX_INFERENCE_THREADS" \
--include='*.rs' \
crates/ src-tauri/
```
Expected output: only references inside `crates/core/src/tuning.rs` (the new home of MIN/MAX) and the test file `crates/transcription/tests/thread_sweep.rs` (touched in phase 7 next; OK to leave for now).
If any other callers turn up, update them to call `tuning::inference_thread_count(workload, gpu_offloaded)` first and re-run the grep.
- [ ] **Step 2: Delete the old definitions**
Edit `crates/core/src/constants.rs`. Delete:
- The `pub const MIN_INFERENCE_THREADS` block.
- The `pub const MAX_INFERENCE_THREADS` block.
- The entire `pub fn inference_thread_count() -> usize { ... }` function and its docstring.
The remaining file should keep audio/RAM/VAD/download constants intact.
- [ ] **Step 3: Build the workspace**
Run: `cargo build --workspace`
Expected: clean build, no warnings about unused items.
- [ ] **Step 4: Run the full test suite**
Run: `cargo test --workspace`
Expected: PASS for `lumotia-core`, `lumotia-llm`, `lumotia-transcription`, `lumotia` (src-tauri). Other crate tests should be unaffected.
- [ ] **Step 5: Commit**
```bash
git add crates/core/src/constants.rs
git commit -m "refactor(core): remove old inference_thread_count facade"
```
---
## Phase 7 — Extend `thread_sweep.rs`
### Task 7.1: Four-panel power-aware sweep
**Files:**
- Modify: `crates/transcription/tests/thread_sweep.rs`
- [ ] **Step 1: Refactor existing sweep into a helper**
Open `crates/transcription/tests/thread_sweep.rs` and locate the existing single-panel sweep (the test that prints `=== n_threads scaling ===` followed by the table). Wrap the panel-printing logic into a helper:
```rust
fn run_sweep_panel(
label: &str,
model_path: &Path,
audio: &[f32],
n_threads_values: &[i32],
) -> std::io::Result<()> {
eprintln!("=== n_threads scaling: {label} ===");
eprintln!("n_threads | xc_time | RTF | speedup_vs_1");
eprintln!("----------|---------|--------|-------------");
// ... existing per-n_threads loop, copied verbatim ...
Ok(())
}
```
(Copy the existing loop body inside; only the header strings change.)
- [ ] **Step 2: Drive four panels from the test entrypoint**
Replace the existing test body:
```rust
#[test]
fn thread_count_scaling() -> std::io::Result<()> {
if std::env::var("MAGNOTIA_THREAD_SWEEP").is_err() {
return Ok(()); // env-gated, opt-in
}
let (model_path, audio) = load_fixture()?;
let n_threads_values = &[1, 2, 4, 6, 8, 12];
for (label, power_var) in &[
("AC, CPU", "ac"),
("battery, CPU", "battery"),
("AC, GPU (Vulkan if available)", "ac"),
("battery, GPU (Vulkan if available)", "battery"),
] {
std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", power_var);
run_sweep_panel(label, &model_path, &audio, n_threads_values)?;
}
std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE");
Ok(())
}
```
(Leave the existing `load_fixture` helper intact — same fixture loading as before.)
For the GPU panels: the fixture sweep doesn't reload the whisper context with the Vulkan backend per panel; `whisper-rs` initialises Vulkan if the feature is on at compile time and the loader resolves at runtime. The panel labels reflect intent — actual GPU residency is what the runtime delivers. The point of these panels is empirical RTF tables to inform whether the GPU floor of 4 is right for whisper.
- [ ] **Step 3: Run the sweep manually on a host with libvulkan1 + battery**
Run:
```bash
cd /home/jake/Documents/CORBEL-Projects/lumotia
MAGNOTIA_THREAD_SWEEP=1 cargo test -p lumotia-transcription --release thread_count_scaling -- --nocapture
```
Expected: 4 RTF panels print. Note the per-panel sweet spots — they inform whether the constants in `tuning.rs` need adjusting.
- [ ] **Step 4: Commit**
```bash
git add crates/transcription/tests/thread_sweep.rs
git commit -m "test(transcription): thread_sweep prints power-aware 4-panel table"
```
---
## Phase 8 — Acceptance smoke
### Task 8.1: Manual battery validation
This is a manual step; no commit unless the smoke uncovers a defect that needs fixing.
- [ ] **Step 1: Build a release binary**
Run:
```bash
cd /home/jake/Documents/CORBEL-Projects/lumotia
cargo build --release -p lumotia-transcription -p lumotia-llm
```
- [ ] **Step 2: Unplug the laptop, run a transcription session**
Open Lumenote, dictate a short clip, watch the log output (Tauri devtools or stdout if running via `cargo tauri dev`). Expect a line of the shape:
```
INFO lumotia_core::tuning: inference_thread_count threads=3 workload=Whisper gpu_offloaded=false on_battery=true clamps=["battery"]
```
(Numbers vary by host. On a 6c12t Ryzen 4650U the expected `threads` is 3.)
- [ ] **Step 3: Plug back in, run another transcription**
Expect a separate INFO line with `on_battery=false clamps=[]` (no clamps fired) and `threads=6` on the same host.
- [ ] **Step 4: If the LLM has a model loaded, exercise it (e.g. extract tasks from a transcript)**
Expect a third INFO line with `workload=Llm`, `gpu_offloaded` reflecting actual GPU presence, and a thread count consistent with the truth table in the spec.
- [ ] **Step 5: Watch CPU usage with `htop` during inference**
Expected:
- AC + CPU LLM: ~6 cores busy on the 4650U.
- Battery + CPU LLM: ~3 cores busy.
- AC + GPU LLM (Vulkan available): ~2 cores busy, GPU briefly spiking.
- Battery + GPU LLM: ~2 cores busy.
Roughly halved CPU occupancy on battery validates the heuristic in real conditions.
If CPU usage doesn't change between AC and battery, the probe isn't firing — read `/sys/class/power_supply/AC*/online` directly to debug.
---
## Self-Review (already run)
- **Spec coverage**: every section of the spec maps to at least one task. Architecture → Phase 1+2+3. PowerState → Phase 1. Workload + helper → Phase 2. Vulkan-loader move → Phase 3. GPU-offload detection (LLM + Whisper) → Phase 4 + Phase 5. Override env var → Task 1.3, Task 2.1. Tests → Phases 12 unit tests, Phase 7 integration. Logging → Task 2.4. Acceptance → Phase 8.
- **Placeholders**: none. Every step has concrete code or commands.
- **Type consistency**: `Workload` derives `Hash + Eq` (added in Task 2.4 specifically for the log set). `PowerState` derives `Copy + PartialEq + Eq` for cache + override use. `with_override`, `force_clear_cache`, `force_set_cache` are `pub(crate)` so cross-module unit tests can call them. `vulkan_loader_available` returns `bool` everywhere.
- **Known wart**: `tuning::tests::battery_halves_thread_count` and other power-override-using tests share a global mutex. They're serialised by the inner `TEST_LOCK` inside `with_override`. If cargo's parallel runner exposes a flake, run those tests with `--test-threads=1` for the affected module.