Bite-sized TDD plan implementing the design at docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-design.md. 8 phases, ~13 commits: - Phase 1: power.rs (PowerState enum, sysfs parser, probe + overrides, 10s TTL cache) - Phase 2: tuning.rs (Workload enum, helper, battery clamp, GPU clamp, per-process logging) - Phase 3: vulkan_loader_available moves from src-tauri to magnotia-core - Phase 4: whisper backend wires through new helper - Phase 5: LLM call site wires through new helper after model load - Phase 6: remove old constants::inference_thread_count facade - Phase 7: thread_sweep.rs prints 4-panel power-aware RTF table - Phase 8: manual battery validation smoke Each task is TDD: write failing test, run, implement, run, commit. Tests live in unit-test modules inside power.rs and tuning.rs. Override design uses an in-process with_override(state, |closure|) helper for unit tests (cargo runs them in parallel; env-var path would race) and MAGNOTIA_POWER_STATE_OVERRIDE env var for integration tests like thread_sweep.rs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
46 KiB
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 magnotia-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 magnotia-core::hardware so transcription can call it. Old constants::inference_thread_count is removed once both call sites migrate.
Tech Stack: Rust 2021, magnotia-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
File Structure
New files:
crates/core/src/power.rs—PowerStateenum, sysfs probe, TTL cache, test override.crates/core/src/tuning.rs—Workloadenum,inference_thread_count, per-process logging.
Modified files:
crates/core/Cargo.toml— addlibloading,tracing; dev-deps addtempfile.crates/core/src/lib.rs— register new modules, re-export public API.crates/core/src/hardware.rs— addvulkan_loader_available(), ported from src-tauri.crates/core/src/constants.rs— removeinference_thread_countand the related min/max constants (move semantically totuning.rs; the audio/RAM/VAD/etc. constants stay).src-tauri/src/commands/models.rs— delete localvulkan_loader_availabledefinition, replace withmagnotia_core::hardware::vulkan_loader_availablecall.src-tauri/Cargo.toml— keeplibloadingsince src-tauri still depends on it elsewhere; verify withcargo tree.crates/transcription/src/whisper_rs_backend.rs— computegpu_offloaded, calltuning::inference_thread_count(Workload::Whisper, gpu_offloaded).crates/llm/src/lib.rs— computegpu_offloadedafter model load, calltuning::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(addtempfile = "3"to dev-dependencies) -
Step 1: Add tempfile dev-dep
Edit crates/core/Cargo.toml — append after the [dependencies] block:
[dev-dependencies]
tempfile = "3"
- Step 2: Write the failing test for the enum
Create crates/core/src/power.rs:
//! 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:
pub mod power;
- Step 3: Run the test
Run: cargo test -p magnotia-core --lib power::tests::power_state_variants_are_distinct
Expected: PASS.
- Step 4: Commit
cd /home/jake/Documents/CORBEL-Projects/magnotia
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):
#[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 magnotia-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:
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 magnotia-core --lib power::tests
Expected: all 6 tests pass.
- Step 5: Commit
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:
with_override(state, |closure|)— in-process, serialised via aTEST_LOCKmutex held for the body. Used by unit tests because cargo's parallel runner makes env-var-based tests flaky.MAGNOTIA_POWER_STATE_OVERRIDEenv var — used bythread_sweep.rsintegration test (set externally, not from insidecargo test's parallel block).
- Step 1: Write failing unit tests using
with_override
Append inside mod tests in crates/core/src/power.rs:
#[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("MAGNOTIA_POWER_STATE_OVERRIDE", "battery");
assert_eq!(probe_power_state(), PowerState::OnBattery);
std::env::remove_var("MAGNOTIA_POWER_STATE_OVERRIDE");
});
}
#[test]
fn env_var_override_garbage_falls_through() {
with_override(None, || {
std::env::set_var("MAGNOTIA_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("MAGNOTIA_POWER_STATE_OVERRIDE");
});
}
- Step 2: Run tests, expect compile failure
Run: cargo test -p magnotia-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:
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. `MAGNOTIA_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("MAGNOTIA_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 magnotia-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
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:
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:
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:
#[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 magnotia-core --lib power::tests
Expected: all power tests pass (sysfs parser + override + cache).
- Step 5: Commit
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(addtracing = "0.1"to[dependencies]) -
Step 1: Add tracing dep
Edit crates/core/Cargo.toml, append in [dependencies]:
tracing = "0.1"
- Step 2: Write the failing test for the helper's pre-clamp behaviour
Create crates/core/src/tuning.rs:
//! 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
/// MAGNOTIA_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. `MAGNOTIA_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("MAGNOTIA_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("MAGNOTIA_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("MAGNOTIA_INFERENCE_THREADS", "10");
let n = inference_thread_count(Workload::Llm, true);
assert_eq!(n, 10);
std::env::remove_var("MAGNOTIA_INFERENCE_THREADS");
}
#[test]
fn workload_variants_distinct() {
assert_ne!(Workload::Llm, Workload::Whisper);
}
}
Add to crates/core/src/lib.rs:
pub mod tuning;
(Place after pub mod power; alphabetically or wherever fits the existing ordering.)
- Step 3: Run the test
Run: cargo test -p magnotia-core --lib tuning::tests
Expected: 3 tests pass.
- Step 4: Commit
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:
#[test]
fn battery_halves_thread_count() {
std::env::remove_var("MAGNOTIA_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 magnotia-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:
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
if let Ok(s) = std::env::var("MAGNOTIA_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 magnotia-core --lib tuning::tests
Expected: 4 tests pass.
- Step 5: Commit
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:
#[test]
fn gpu_offload_clamps_llm_to_floor() {
std::env::remove_var("MAGNOTIA_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("MAGNOTIA_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("MAGNOTIA_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 magnotia-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:
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
if let Ok(s) = std::env::var("MAGNOTIA_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 magnotia-core --lib tuning::tests
Expected: 8 tests pass.
- Step 5: Commit
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:
#[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("MAGNOTIA_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 magnotia-core --lib tuning::tests::logging_does_not_panic
Expected: PASS.
- Step 3: Implement per-process log cache
Add to crates/core/src/tuning.rs:
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:
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
if let Ok(s) = std::env::var("MAGNOTIA_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: "magnotia_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:
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Workload {
Llm,
Whisper,
}
- Step 4: Run all tests
Run: cargo test -p magnotia-core --lib
Expected: all power + tuning tests pass.
- Step 5: Commit
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 magnotia-core::hardware
Files:
-
Modify:
crates/core/Cargo.toml(addlibloading = "0.8") -
Modify:
crates/core/src/hardware.rs -
Step 1: Add libloading dep to core
Edit crates/core/Cargo.toml, append in [dependencies]:
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):
#[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 magnotia-core --lib hardware::tests::vulkan_loader_available_does_not_panic
Expected: compile error — function not defined.
- Step 4: Add
vulkan_loader_availableto hardware.rs
Append to crates/core/src/hardware.rs:
/// 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 magnotia-core --lib hardware::tests::vulkan_loader_available_does_not_panic
Expected: PASS.
- Step 6: Commit
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 magnotia_core imports):
use magnotia_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:
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 magnotia (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 magnotia compose_accelerators
Expected: PASS — confirms the move didn't change behaviour.
- Step 4: Commit
git add src-tauri/src/commands/models.rs
git commit -m "refactor(tauri): use magnotia_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):
params.set_n_threads(inference_thread_count() as i32);
…and the import at the top:
use magnotia_core::constants::inference_thread_count;
Replace the import:
use magnotia_core::tuning::{inference_thread_count, Workload};
use magnotia_core::hardware::vulkan_loader_available;
Replace the call site:
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 magnotia-transcription whisper_rs_smoke
Expected: PASS — the helper returns a sensible value (likely 4 or 6 depending on host).
- Step 3: Commit
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:
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(magnotia_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 magnotia_core::constants::inference_thread_count; (or wherever constants::inference_thread_count is referenced) with:
use magnotia_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:
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 magnotia-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
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:
cd /home/jake/Documents/CORBEL-Projects/magnotia
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_THREADSblock. - The
pub const MAX_INFERENCE_THREADSblock. - 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 magnotia-core, magnotia-llm, magnotia-transcription, magnotia (src-tauri). Other crate tests should be unaffected.
- Step 5: Commit
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:
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:
#[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("MAGNOTIA_POWER_STATE_OVERRIDE", power_var);
run_sweep_panel(label, &model_path, &audio, n_threads_values)?;
}
std::env::remove_var("MAGNOTIA_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:
cd /home/jake/Documents/CORBEL-Projects/magnotia
MAGNOTIA_THREAD_SWEEP=1 cargo test -p magnotia-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
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:
cd /home/jake/Documents/CORBEL-Projects/magnotia
cargo build --release -p magnotia-transcription -p magnotia-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 magnotia_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
htopduring 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:
WorkloadderivesHash + Eq(added in Task 2.4 specifically for the log set).PowerStatederivesCopy + PartialEq + Eqfor cache + override use.with_override,force_clear_cache,force_set_cachearepub(crate)so cross-module unit tests can call them.vulkan_loader_availablereturnsbooleverywhere. -
Known wart:
tuning::tests::battery_halves_thread_countand other power-override-using tests share a global mutex. They're serialised by the innerTEST_LOCKinsidewith_override. If cargo's parallel runner exposes a flake, run those tests with--test-threads=1for the affected module.