# 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 { 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 { 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> = Mutex::new(None); fn test_get_override() -> Option { *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(state: Option, 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> { static SLOT: OnceLock>> = 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::() { 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::() { 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::() { 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> { static SEEN: OnceLock>> = 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::() { 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 322–350 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 1–2 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.