Files
Lumotia/docs/superpowers/plans/2026-05-09-battery-gpu-aware-thread-tuning.md
jars c46c0a5ce9 docs: implementation plan for battery- and GPU-aware thread tuning
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>
2026-05-09 11:01:21 +01:00

46 KiB
Raw Blame History

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.rsPowerState enum, sysfs probe, TTL cache, test override.
  • crates/core/src/tuning.rsWorkload 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 magnotia_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:

[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:

  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. MAGNOTIA_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:

    #[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 (add tracing = "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 (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]:

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_available to 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 322350 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_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 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 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.