# Battery- and GPU-aware Inference Thread Tuning Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add power-aware and GPU-offload-aware clamps to `inference_thread_count` so Lumenote drops to ~half threads on battery and to a workload-specific floor when fully GPU-offloaded.

**Architecture:** Two new modules in `lumotia-core`: `power.rs` (Linux sysfs probe with TTL cache) and `tuning.rs` (Workload enum + helper). Both whisper and LLM call sites compute a `gpu_offloaded` bool and pass it in. `vulkan_loader_available()` moves from `src-tauri` to `lumotia-core::hardware` so transcription can call it. Old `constants::inference_thread_count` is removed once both call sites migrate.

**Tech Stack:** Rust 2021, `lumotia-core` (existing pure-Rust crate), `num_cpus`, `libloading`, `tracing`, `tempfile` (dev). No Tauri or platform FFI for the core probe — Linux sysfs only in v1.

**Spec:** [docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-design.md](../specs/2026-05-09-battery-gpu-aware-thread-tuning-design.md)

---

## File Structure

**New files:**
- `crates/core/src/power.rs` — `PowerState` enum, sysfs probe, TTL cache, test override.
- `crates/core/src/tuning.rs` — `Workload` enum, `inference_thread_count`, per-process logging.

**Modified files:**
- `crates/core/Cargo.toml` — add `libloading`, `tracing`; dev-deps add `tempfile`.
- `crates/core/src/lib.rs` — register new modules, re-export public API.
- `crates/core/src/hardware.rs` — add `vulkan_loader_available()`, ported from src-tauri.
- `crates/core/src/constants.rs` — remove `inference_thread_count` and the related min/max constants (move semantically to `tuning.rs`; the audio/RAM/VAD/etc. constants stay).
- `src-tauri/src/commands/models.rs` — delete local `vulkan_loader_available` definition, replace with `lumotia_core::hardware::vulkan_loader_available` call.
- `src-tauri/Cargo.toml` — keep `libloading` since src-tauri still depends on it elsewhere; verify with `cargo tree`.
- `crates/transcription/src/whisper_rs_backend.rs` — compute `gpu_offloaded`, call `tuning::inference_thread_count(Workload::Whisper, gpu_offloaded)`.
- `crates/llm/src/lib.rs` — compute `gpu_offloaded` after model load, call `tuning::inference_thread_count(Workload::Llm, gpu_offloaded)`.
- `crates/transcription/tests/thread_sweep.rs` — extend to print 4 panels (CPU/GPU × AC/battery).

---

## Phase 1 — `power.rs`: PowerState module

### Task 1.1: Skeleton + lib registration

**Files:**
- Create: `crates/core/src/power.rs`
- Modify: `crates/core/src/lib.rs`
- Modify: `crates/core/Cargo.toml` (add `tempfile = "3"` to dev-dependencies)

- [ ] **Step 1: Add tempfile dev-dep**

Edit `crates/core/Cargo.toml` — append after the `[dependencies]` block:

```toml
[dev-dependencies]
tempfile = "3"
```

- [ ] **Step 2: Write the failing test for the enum**

Create `crates/core/src/power.rs`:

```rust
//! Power-state probe for inference thread tuning.
//!
//! Reports whether the machine is running on AC or battery so callers
//! can drop thread counts when energy matters more than throughput.
//! Linux uses the documented sysfs ABI under
//! `/sys/class/power_supply/`. macOS and Windows return `Unknown` for
//! now; native probes (IOPSGetProvidingPowerSourceType,
//! GetSystemPowerStatus) are deferred.

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PowerState {
    OnAc,
    OnBattery,
    Unknown,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn power_state_variants_are_distinct() {
        assert_ne!(PowerState::OnAc, PowerState::OnBattery);
        assert_ne!(PowerState::OnAc, PowerState::Unknown);
        assert_ne!(PowerState::OnBattery, PowerState::Unknown);
    }
}
```

Add to `crates/core/src/lib.rs` after the existing module declarations:

```rust
pub mod power;
```

- [ ] **Step 3: Run the test**

Run: `cargo test -p lumotia-core --lib power::tests::power_state_variants_are_distinct`
Expected: PASS.

- [ ] **Step 4: Commit**

```bash
cd /home/jake/Documents/CORBEL-Projects/lumotia
git add crates/core/Cargo.toml crates/core/src/power.rs crates/core/src/lib.rs
git commit -m "feat(core): add PowerState skeleton in new power module"
```

---

### Task 1.2: Sysfs parser

**Files:**
- Modify: `crates/core/src/power.rs`

- [ ] **Step 1: Write failing tests for sysfs parsing**

Append to `crates/core/src/power.rs` inside `mod tests` (replace the existing `mod tests` block with this expanded version):

```rust
#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn power_state_variants_are_distinct() {
        assert_ne!(PowerState::OnAc, PowerState::OnBattery);
        assert_ne!(PowerState::OnAc, PowerState::Unknown);
        assert_ne!(PowerState::OnBattery, PowerState::Unknown);
    }

    fn write_supply(dir: &std::path::Path, name: &str, ty: &str, online: &str) {
        let entry = dir.join(name);
        fs::create_dir_all(&entry).unwrap();
        fs::write(entry.join("type"), format!("{ty}\n")).unwrap();
        fs::write(entry.join("online"), format!("{online}\n")).unwrap();
    }

    #[test]
    fn parses_mains_online_as_on_ac() {
        let dir = tempdir().unwrap();
        write_supply(dir.path(), "AC", "Mains", "1");
        write_supply(dir.path(), "BAT0", "Battery", "0");
        assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
    }

    #[test]
    fn parses_battery_only_as_on_battery() {
        let dir = tempdir().unwrap();
        write_supply(dir.path(), "AC", "Mains", "0");
        write_supply(dir.path(), "BAT0", "Battery", "0");
        assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnBattery);
    }

    #[test]
    fn parses_usb_pd_online_as_on_ac() {
        let dir = tempdir().unwrap();
        write_supply(dir.path(), "ucsi-source-psy-USBC000:001", "USB", "1");
        write_supply(dir.path(), "BAT0", "Battery", "0");
        assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::OnAc);
    }

    #[test]
    fn parses_empty_dir_as_unknown() {
        let dir = tempdir().unwrap();
        assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
    }

    #[test]
    fn parses_missing_dir_as_unknown() {
        let path = std::path::Path::new("/no/such/path/should/exist/at/this/inode/123456");
        assert_eq!(parse_power_state_from_dir(path), PowerState::Unknown);
    }

    #[test]
    fn parses_malformed_files_as_unknown_gracefully() {
        let dir = tempdir().unwrap();
        let entry = dir.path().join("garbage");
        fs::create_dir_all(&entry).unwrap();
        // No type, no online — should not panic.
        assert_eq!(parse_power_state_from_dir(dir.path()), PowerState::Unknown);
    }
}
```

- [ ] **Step 2: Run tests, expect compile failure**

Run: `cargo test -p lumotia-core --lib power::tests`
Expected: compile error — `parse_power_state_from_dir` not found.

- [ ] **Step 3: Implement parser**

Add above the `#[cfg(test)] mod tests` block in `crates/core/src/power.rs`:

```rust
use std::fs;
use std::path::Path;

/// Parse a `/sys/class/power_supply/`-style directory and decide
/// whether the machine is on AC, on battery, or in an unknown state.
///
/// Rules (matches `Documentation/ABI/testing/sysfs-class-power`):
/// - Any entry with `type` in {`Mains`, `USB`} and `online == 1` → OnAc.
/// - Else any entry with `type == Battery` → OnBattery.
/// - Else → Unknown.
///
/// Failures (missing dir, unreadable files, malformed contents) all
/// fall through to Unknown rather than panicking. `Unknown` is treated
/// as `OnAc` by the caller, which preserves today's pre-clamp
/// behaviour on platforms or distros where the probe doesn't fire.
pub fn parse_power_state_from_dir(supply_dir: &Path) -> PowerState {
    let read_dir = match fs::read_dir(supply_dir) {
        Ok(rd) => rd,
        Err(_) => return PowerState::Unknown,
    };

    let mut saw_battery = false;
    for entry in read_dir.flatten() {
        let path = entry.path();
        let ty = read_trimmed(&path.join("type")).unwrap_or_default();
        let online = read_trimmed(&path.join("online")).unwrap_or_default();
        match ty.as_str() {
            "Mains" | "USB" => {
                if online == "1" {
                    return PowerState::OnAc;
                }
            }
            "Battery" => {
                saw_battery = true;
            }
            _ => {}
        }
    }

    if saw_battery {
        PowerState::OnBattery
    } else {
        PowerState::Unknown
    }
}

fn read_trimmed(path: &Path) -> Option<String> {
    fs::read_to_string(path).ok().map(|s| s.trim().to_owned())
}
```

- [ ] **Step 4: Run tests, expect pass**

Run: `cargo test -p lumotia-core --lib power::tests`
Expected: all 6 tests pass.

- [ ] **Step 5: Commit**

```bash
git add crates/core/src/power.rs
git commit -m "feat(core): parse_power_state_from_dir reads sysfs power_supply ABI"
```

---

### Task 1.3: `probe_power_state` + overrides

**Files:**
- Modify: `crates/core/src/power.rs`

This task adds the public probe entry point. Two override paths:
1. `with_override(state, |closure|)` — in-process, serialised via a `TEST_LOCK` mutex held for the body. Used by unit tests because cargo's parallel runner makes env-var-based tests flaky.
2. `LUMOTIA_POWER_STATE_OVERRIDE` env var — used by `thread_sweep.rs` integration test (set externally, not from inside `cargo test`'s parallel block).

- [ ] **Step 1: Write failing unit tests using `with_override`**

Append inside `mod tests` in `crates/core/src/power.rs`:

```rust
    #[test]
    fn override_drives_battery() {
        with_override(Some(PowerState::OnBattery), || {
            assert_eq!(probe_power_state(), PowerState::OnBattery);
        });
    }

    #[test]
    fn override_drives_ac() {
        with_override(Some(PowerState::OnAc), || {
            assert_eq!(probe_power_state(), PowerState::OnAc);
        });
    }

    #[test]
    fn override_drives_unknown() {
        with_override(Some(PowerState::Unknown), || {
            assert_eq!(probe_power_state(), PowerState::Unknown);
        });
    }

    #[test]
    fn env_var_override_battery_via_set_var() {
        // env-var path tested in isolation under TEST_LOCK so it
        // doesn't race with the in-process override tests.
        with_override(None, || {
            std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", "battery");
            assert_eq!(probe_power_state(), PowerState::OnBattery);
            std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE");
        });
    }

    #[test]
    fn env_var_override_garbage_falls_through() {
        with_override(None, || {
            std::env::set_var("LUMOTIA_POWER_STATE_OVERRIDE", "nonsense");
            // Garbage value falls through to the platform probe.
            // We can't assert the platform result so just assert it
            // doesn't panic.
            let _ = probe_power_state();
            std::env::remove_var("LUMOTIA_POWER_STATE_OVERRIDE");
        });
    }
```

- [ ] **Step 2: Run tests, expect compile failure**

Run: `cargo test -p lumotia-core --lib power::tests`
Expected: compile error — `probe_power_state`, `with_override` not found.

- [ ] **Step 3: Implement probe + overrides**

Add to `crates/core/src/power.rs` above `#[cfg(test)] mod tests`:

```rust
use std::sync::Mutex;

/// Top-level power-state probe.
///
/// Resolution order (highest to lowest priority):
/// 1. In-process test override (set via `with_override` from unit tests).
/// 2. `LUMOTIA_POWER_STATE_OVERRIDE` env var (`ac` | `battery` | `unknown`,
///    case-insensitive). Used by `thread_sweep.rs` integration tests.
/// 3. Linux: `parse_power_state_from_dir("/sys/class/power_supply")`.
/// 4. macOS / Windows / other: `Unknown`.
///
/// `Unknown` is treated as `OnAc` by callers, which preserves today's
/// pre-clamp behaviour on platforms where the probe doesn't fire.
///
/// (TTL cache is added in Task 1.4.)
pub fn probe_power_state() -> PowerState {
    if let Some(state) = test_get_override() {
        return state;
    }
    if let Some(state) = env_override() {
        return state;
    }
    platform_probe()
}

fn env_override() -> Option<PowerState> {
    let raw = std::env::var("LUMOTIA_POWER_STATE_OVERRIDE").ok()?;
    match raw.trim().to_ascii_lowercase().as_str() {
        "ac" => Some(PowerState::OnAc),
        "battery" => Some(PowerState::OnBattery),
        "unknown" => Some(PowerState::Unknown),
        _ => None,
    }
}

#[cfg(target_os = "linux")]
fn platform_probe() -> PowerState {
    parse_power_state_from_dir(Path::new("/sys/class/power_supply"))
}

#[cfg(not(target_os = "linux"))]
fn platform_probe() -> PowerState {
    PowerState::Unknown
}

// In-process override slot. Tests must use `with_override` (below) to
// set it; production code never writes to this. Read on every probe.
static TEST_OVERRIDE: Mutex<Option<PowerState>> = Mutex::new(None);

fn test_get_override() -> Option<PowerState> {
    *TEST_OVERRIDE.lock().expect("power test override mutex poisoned")
}

/// Run `body` with the in-process override set to `state`. Restores
/// the previous override (always `None` in practice) on return.
///
/// Holds a dedicated `TEST_LOCK` mutex for the duration of the body,
/// so override-using unit tests run serially with respect to each
/// other even when cargo runs the test binary multi-threaded.
#[cfg(test)]
pub(crate) fn with_override<R>(state: Option<PowerState>, body: impl FnOnce() -> R) -> R {
    static TEST_LOCK: Mutex<()> = Mutex::new(());
    let _lock = TEST_LOCK.lock().expect("power TEST_LOCK poisoned");
    *TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = state;
    let result = body();
    *TEST_OVERRIDE.lock().expect("power test override mutex poisoned") = None;
    result
}
```

- [ ] **Step 4: Run tests, expect pass**

Run: `cargo test -p lumotia-core --lib power::tests`
Expected: all tests in the module pass (sysfs parser tests from Task 1.2 plus the 5 new override tests).

- [ ] **Step 5: Commit**

```bash
git add crates/core/src/power.rs
git commit -m "feat(core): probe_power_state with Linux dispatch + override paths"
```

---

### Task 1.4: 10-second TTL cache

**Files:**
- Modify: `crates/core/src/power.rs`

The cache sits between override resolution and `platform_probe`. Override paths (test + env var) are never cached so they always take effect immediately. Two test-only helpers expose the cache: `force_clear_cache()` and `force_set_cache(state)`.

The cache tests must run inside `with_override(None, || …)` so they hold the same `TEST_LOCK` mutex as the override tests; otherwise an override-using test running in parallel could pollute the assertions.

- [ ] **Step 1: Implement the cache**

Add to `crates/core/src/power.rs` near the other top-level items:

```rust
use std::sync::OnceLock;
use std::time::{Duration, Instant};

const POWER_STATE_TTL: Duration = Duration::from_secs(10);

struct CachedState {
    state: PowerState,
    fetched_at: Instant,
}

fn cache_slot() -> &'static Mutex<Option<CachedState>> {
    static SLOT: OnceLock<Mutex<Option<CachedState>>> = OnceLock::new();
    SLOT.get_or_init(|| Mutex::new(None))
}

#[cfg(test)]
pub(crate) fn force_clear_cache() {
    *cache_slot().lock().expect("power cache mutex poisoned") = None;
}

#[cfg(test)]
pub(crate) fn force_set_cache(state: PowerState) {
    *cache_slot().lock().expect("power cache mutex poisoned") = Some(CachedState {
        state,
        fetched_at: Instant::now(),
    });
}
```

- [ ] **Step 2: Wire the cache into `probe_power_state`**

Replace the body of `probe_power_state`:

```rust
pub fn probe_power_state() -> PowerState {
    if let Some(state) = test_get_override() {
        return state;
    }
    if let Some(state) = env_override() {
        return state;
    }

    let mut slot = cache_slot().lock().expect("power cache mutex poisoned");
    if let Some(cached) = &*slot {
        if cached.fetched_at.elapsed() < POWER_STATE_TTL {
            return cached.state;
        }
    }
    let fresh = platform_probe();
    *slot = Some(CachedState {
        state: fresh,
        fetched_at: Instant::now(),
    });
    fresh
}
```

- [ ] **Step 3: Write cache tests**

Append inside `mod tests` in `crates/core/src/power.rs`:

```rust
    #[test]
    fn ttl_cache_returns_cached_value_within_window() {
        with_override(None, || {
            force_clear_cache();
            force_set_cache(PowerState::OnBattery);
            // No override active, no env var; probe should hit cache.
            assert_eq!(probe_power_state(), PowerState::OnBattery);
        });
    }

    #[test]
    fn ttl_cache_clears_via_force_clear() {
        with_override(None, || {
            force_set_cache(PowerState::OnBattery);
            force_clear_cache();
            // Override flips to OnAc; with cleared cache, override
            // (which has higher priority) drives the result.
        });
        with_override(Some(PowerState::OnAc), || {
            force_clear_cache();
            assert_eq!(probe_power_state(), PowerState::OnAc);
        });
    }
```

- [ ] **Step 4: Run tests, expect pass**

Run: `cargo test -p lumotia-core --lib power::tests`
Expected: all power tests pass (sysfs parser + override + cache).

- [ ] **Step 5: Commit**

```bash
git add crates/core/src/power.rs
git commit -m "feat(core): 10s TTL cache on probe_power_state"
```

---

## Phase 2 — `tuning.rs`: Workload-aware helper

### Task 2.1: Skeleton + Workload + helper that matches today's output

**Files:**
- Create: `crates/core/src/tuning.rs`
- Modify: `crates/core/src/lib.rs`
- Modify: `crates/core/Cargo.toml` (add `tracing = "0.1"` to `[dependencies]`)

- [ ] **Step 1: Add tracing dep**

Edit `crates/core/Cargo.toml`, append in `[dependencies]`:

```toml
tracing = "0.1"
```

- [ ] **Step 2: Write the failing test for the helper's pre-clamp behaviour**

Create `crates/core/src/tuning.rs`:

```rust
//! Inference thread-count tuning. Combines physical-core budget,
//! battery awareness, and GPU-offload awareness into a single helper
//! callable from both inference call sites.

use crate::power::{self, PowerState};

/// Lower bound for inference threads. Single-threaded inference is
/// measurably worse than two on every multi-core part.
pub const MIN_INFERENCE_THREADS: usize = 2;

/// Upper bound for inference threads. whisper.cpp + llama.cpp scaling
/// flattens around physical core count; SMT siblings contend for FPU
/// during F16/F32 matmul, so going past physical cores anti-scales.
/// 8 is a conservative ceiling that leaves <5% on the table for
/// big-iron desktops while keeping consumer 6c/12t laptops out of
/// contention territory. Users can override at runtime via
/// LUMOTIA_INFERENCE_THREADS.
pub const MAX_INFERENCE_THREADS: usize = 8;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Workload {
    /// Llama-style transformer. Near-zero CPU work when fully
    /// offloaded; CPU floor in that case is GPU_FLOOR_LLM.
    Llm,
    /// Whisper transcription. Keeps mel spectrogram, decoder
    /// bookkeeping, and beam search on the CPU even with full Vulkan
    /// offload; CPU floor is higher: GPU_FLOOR_WHISPER.
    Whisper,
}

const GPU_FLOOR_LLM: usize = 2;
const GPU_FLOOR_WHISPER: usize = 4;

/// Inference thread count, clamped to the physical-core budget plus
/// the battery and GPU-offload heuristics.
///
/// Resolution order:
/// 1. `LUMOTIA_INFERENCE_THREADS=N` — absolute bypass, returns N.
/// 2. base = num_cpus::get_physical() (fallback: available_parallelism).
/// 3. on battery → base /= 2.
/// 4. gpu_offloaded → base = min(base, gpu_floor(workload)).
/// 5. clamp to [MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS].
pub fn inference_thread_count(_workload: Workload, _gpu_offloaded: bool) -> usize {
    if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") {
        if let Ok(n) = s.parse::<usize>() {
            if n > 0 {
                return n;
            }
        }
    }
    let physical = num_cpus::get_physical();
    let chosen = if physical > 0 {
        physical
    } else {
        std::thread::available_parallelism()
            .map(|p| p.get())
            .unwrap_or(MIN_INFERENCE_THREADS)
    };
    chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn matches_existing_clamp_when_no_clamps_apply() {
        // With no env override, no battery, no gpu_offload, helper
        // should return physical-core count clamped to [2, 8].
        // We can't pin physical exactly without mocking num_cpus; just
        // assert the result is in range.
        std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
        let n = inference_thread_count(Workload::Llm, false);
        assert!(n >= MIN_INFERENCE_THREADS && n <= MAX_INFERENCE_THREADS,
                "got {n}, expected within [{MIN_INFERENCE_THREADS}, {MAX_INFERENCE_THREADS}]");
    }

    #[test]
    fn env_var_bypasses_clamps() {
        std::env::set_var("LUMOTIA_INFERENCE_THREADS", "10");
        let n = inference_thread_count(Workload::Llm, true);
        assert_eq!(n, 10);
        std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
    }

    #[test]
    fn workload_variants_distinct() {
        assert_ne!(Workload::Llm, Workload::Whisper);
    }
}
```

Add to `crates/core/src/lib.rs`:

```rust
pub mod tuning;
```

(Place after `pub mod power;` alphabetically or wherever fits the existing ordering.)

- [ ] **Step 3: Run the test**

Run: `cargo test -p lumotia-core --lib tuning::tests`
Expected: 3 tests pass.

- [ ] **Step 4: Commit**

```bash
git add crates/core/Cargo.toml crates/core/src/tuning.rs crates/core/src/lib.rs
git commit -m "feat(core): tuning module with Workload + helper skeleton"
```

---

### Task 2.2: Battery clamp branch

**Files:**
- Modify: `crates/core/src/tuning.rs`

- [ ] **Step 1: Write the failing test**

Add to `mod tests` in `crates/core/src/tuning.rs`:

```rust
    #[test]
    fn battery_halves_thread_count() {
        std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
        // Lock both the test override mutex and assert.
        crate::power::with_override(Some(PowerState::OnBattery), || {
            let on_battery = inference_thread_count(Workload::Llm, false);
            crate::power::with_override(Some(PowerState::OnAc), || {
                let on_ac = inference_thread_count(Workload::Llm, false);
                // on_battery should be roughly half of on_ac, clamped
                // to MIN.
                if on_ac > MIN_INFERENCE_THREADS {
                    assert!(
                        on_battery <= on_ac,
                        "battery ({on_battery}) should be <= AC ({on_ac})"
                    );
                    assert!(on_battery >= MIN_INFERENCE_THREADS);
                }
            });
        });
    }
```

(`with_override` is private to `power`'s tests by default; expose it for cross-module unit tests by changing `#[cfg(test)] pub(crate) fn with_override` to `#[cfg(test)] pub fn with_override` so `tuning::tests` can call it. Or keep it `pub(crate)` since `tuning` is in the same crate — `pub(crate)` is sufficient.)

Verify in `power.rs`: the existing `pub(crate)` on `with_override`, `force_clear_cache`, `force_set_cache` is correct for cross-module access. No change needed.

But wait — these test helpers are gated `#[cfg(test)]` so they're only available when compiling for tests. From `tuning::tests` they ARE compiled for tests, so `pub(crate)` works. Good.

- [ ] **Step 2: Run, expect failure**

Run: `cargo test -p lumotia-core --lib tuning::tests::battery_halves_thread_count`
Expected: FAIL — assertion that on_battery <= on_ac fails because helper currently ignores power state. (On a 1c host, on_ac and on_battery would both be 2 and the test would no-op via the early `if on_ac > MIN_INFERENCE_THREADS` check; in that environment the test isn't useful. CI hosts with >2 physical cores will exercise it.)

- [ ] **Step 3: Implement battery clamp**

Modify `inference_thread_count` in `crates/core/src/tuning.rs`. Replace the body with:

```rust
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
    if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") {
        if let Ok(n) = s.parse::<usize>() {
            if n > 0 {
                return n;
            }
        }
    }
    let physical = num_cpus::get_physical();
    let mut chosen = if physical > 0 {
        physical
    } else {
        std::thread::available_parallelism()
            .map(|p| p.get())
            .unwrap_or(MIN_INFERENCE_THREADS)
    };
    if power::probe_power_state() == PowerState::OnBattery {
        chosen /= 2;
    }
    let _ = (workload, gpu_offloaded); // GPU clamp added in next task
    chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
}
```

(The `let _ = (workload, gpu_offloaded);` silences unused-arg warnings until task 2.3 wires them in.)

- [ ] **Step 4: Run, expect pass**

Run: `cargo test -p lumotia-core --lib tuning::tests`
Expected: 4 tests pass.

- [ ] **Step 5: Commit**

```bash
git add crates/core/src/tuning.rs
git commit -m "feat(core): inference_thread_count halves on battery"
```

---

### Task 2.3: GPU-offload clamp with workload-specific floor

**Files:**
- Modify: `crates/core/src/tuning.rs`

- [ ] **Step 1: Write the failing tests**

Add to `mod tests` in `crates/core/src/tuning.rs`:

```rust
    #[test]
    fn gpu_offload_clamps_llm_to_floor() {
        std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
        crate::power::with_override(Some(PowerState::OnAc), || {
            let n = inference_thread_count(Workload::Llm, true);
            assert!(n <= GPU_FLOOR_LLM.max(MIN_INFERENCE_THREADS),
                    "Llm with GPU offload should clamp to {GPU_FLOOR_LLM}, got {n}");
            assert!(n >= MIN_INFERENCE_THREADS);
        });
    }

    #[test]
    fn gpu_offload_clamps_whisper_to_floor() {
        std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
        crate::power::with_override(Some(PowerState::OnAc), || {
            let n = inference_thread_count(Workload::Whisper, true);
            // Whisper floor is 4 on machines with >=4 physical cores;
            // can't go lower than physical so on a 2c host we stay at 2.
            assert!(n <= GPU_FLOOR_WHISPER, "got {n}");
            assert!(n >= MIN_INFERENCE_THREADS);
        });
    }

    #[test]
    fn whisper_gpu_floor_is_at_least_llm_gpu_floor() {
        // Architectural invariant: whisper retains CPU work even when
        // GPU-offloaded; its floor must not be lower than the LLM's.
        assert!(GPU_FLOOR_WHISPER >= GPU_FLOOR_LLM);
    }

    #[test]
    fn gpu_offload_off_does_not_clamp_below_battery_calc() {
        std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
        crate::power::with_override(Some(PowerState::OnAc), || {
            let no_gpu = inference_thread_count(Workload::Llm, false);
            let with_gpu = inference_thread_count(Workload::Llm, true);
            // GPU-offloaded should be <= non-offloaded.
            assert!(with_gpu <= no_gpu);
        });
    }
```

- [ ] **Step 2: Run, expect failure**

Run: `cargo test -p lumotia-core --lib tuning::tests`
Expected: GPU-offload tests fail because the helper still ignores `gpu_offloaded`.

- [ ] **Step 3: Implement GPU-offload clamp**

Modify `inference_thread_count` in `crates/core/src/tuning.rs`:

```rust
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
    if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") {
        if let Ok(n) = s.parse::<usize>() {
            if n > 0 {
                return n;
            }
        }
    }
    let physical = num_cpus::get_physical();
    let mut chosen = if physical > 0 {
        physical
    } else {
        std::thread::available_parallelism()
            .map(|p| p.get())
            .unwrap_or(MIN_INFERENCE_THREADS)
    };
    if power::probe_power_state() == PowerState::OnBattery {
        chosen /= 2;
    }
    if gpu_offloaded {
        let floor = match workload {
            Workload::Llm => GPU_FLOOR_LLM,
            Workload::Whisper => GPU_FLOOR_WHISPER,
        };
        chosen = chosen.min(floor);
    }
    chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS)
}
```

- [ ] **Step 4: Run, expect pass**

Run: `cargo test -p lumotia-core --lib tuning::tests`
Expected: 8 tests pass.

- [ ] **Step 5: Commit**

```bash
git add crates/core/src/tuning.rs
git commit -m "feat(core): GPU-offload clamp with per-workload floor"
```

---

### Task 2.4: Per-process logging cache

**Files:**
- Modify: `crates/core/src/tuning.rs`

- [ ] **Step 1: Write the test (manual verification, no assertion logic)**

Add to `mod tests` in `crates/core/src/tuning.rs`:

```rust
    #[test]
    fn logging_does_not_panic() {
        // Smoke: helper should run without panicking when logging is
        // wired. This is covered by the other tests too, but kept
        // explicitly to document the behaviour.
        std::env::remove_var("LUMOTIA_INFERENCE_THREADS");
        crate::power::with_override(Some(PowerState::OnBattery), || {
            let _ = inference_thread_count(Workload::Llm, true);
            let _ = inference_thread_count(Workload::Whisper, false);
        });
    }
```

- [ ] **Step 2: Run, expect pass (already passes, the test is a smoke)**

Run: `cargo test -p lumotia-core --lib tuning::tests::logging_does_not_panic`
Expected: PASS.

- [ ] **Step 3: Implement per-process log cache**

Add to `crates/core/src/tuning.rs`:

```rust
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};

/// Whether a given (workload, on_battery, gpu_offloaded) tuple has
/// already been logged this process. Prevents log spam when the same
/// configuration is exercised on every inference call.
fn log_seen() -> &'static Mutex<HashSet<(Workload, bool, bool)>> {
    static SEEN: OnceLock<Mutex<HashSet<(Workload, bool, bool)>>> = OnceLock::new();
    SEEN.get_or_init(|| Mutex::new(HashSet::new()))
}
```

Update `inference_thread_count` to log once per tuple:

```rust
pub fn inference_thread_count(workload: Workload, gpu_offloaded: bool) -> usize {
    if let Ok(s) = std::env::var("LUMOTIA_INFERENCE_THREADS") {
        if let Ok(n) = s.parse::<usize>() {
            if n > 0 {
                return n;
            }
        }
    }
    let physical = num_cpus::get_physical();
    let mut chosen = if physical > 0 {
        physical
    } else {
        std::thread::available_parallelism()
            .map(|p| p.get())
            .unwrap_or(MIN_INFERENCE_THREADS)
    };
    let on_battery = power::probe_power_state() == PowerState::OnBattery;
    let mut clamps: Vec<&'static str> = Vec::new();
    if on_battery {
        chosen /= 2;
        clamps.push("battery");
    }
    if gpu_offloaded {
        let floor = match workload {
            Workload::Llm => GPU_FLOOR_LLM,
            Workload::Whisper => GPU_FLOOR_WHISPER,
        };
        if chosen > floor {
            chosen = floor;
            clamps.push("gpu");
        }
    }
    let final_value = chosen.clamp(MIN_INFERENCE_THREADS, MAX_INFERENCE_THREADS);

    // Log once per (workload, on_battery, gpu_offloaded) tuple.
    let key = (workload, on_battery, gpu_offloaded);
    if let Ok(mut seen) = log_seen().lock() {
        if seen.insert(key) {
            tracing::info!(
                target: "lumotia_core::tuning",
                threads = final_value,
                workload = ?workload,
                gpu_offloaded,
                on_battery,
                clamps = ?clamps,
                "inference_thread_count"
            );
        }
    }

    final_value
}
```

Note `Workload` needs to be `Hash + Eq` for the `HashSet`. Add the derives:

```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Workload {
    Llm,
    Whisper,
}
```

- [ ] **Step 4: Run all tests**

Run: `cargo test -p lumotia-core --lib`
Expected: all power + tuning tests pass.

- [ ] **Step 5: Commit**

```bash
git add crates/core/src/tuning.rs
git commit -m "feat(core): per-process INFO log on first thread-count call per tuple"
```

---

## Phase 3 — Move `vulkan_loader_available` to core

### Task 3.1: Port function to `lumotia-core::hardware`

**Files:**
- Modify: `crates/core/Cargo.toml` (add `libloading = "0.8"`)
- Modify: `crates/core/src/hardware.rs`

- [ ] **Step 1: Add libloading dep to core**

Edit `crates/core/Cargo.toml`, append in `[dependencies]`:

```toml
libloading = "0.8"
```

- [ ] **Step 2: Write a test that asserts the function is callable**

Add to `crates/core/src/hardware.rs` inside the existing `#[cfg(test)] mod tests` block (after the existing tests):

```rust
    #[test]
    fn vulkan_loader_available_does_not_panic() {
        // We can't assert the value (depends on host's libvulkan),
        // but we can assert the call completes.
        let _ = vulkan_loader_available();
    }
```

- [ ] **Step 3: Run, expect compile failure**

Run: `cargo test -p lumotia-core --lib hardware::tests::vulkan_loader_available_does_not_panic`
Expected: compile error — function not defined.

- [ ] **Step 4: Add `vulkan_loader_available` to hardware.rs**

Append to `crates/core/src/hardware.rs`:

```rust
/// Best-effort probe for the Vulkan loader shared library.
///
/// whisper.cpp and llama.cpp Vulkan backends silently drop to CPU if
/// `libvulkan.so.1` (Linux) / `vulkan-1.dll` (Windows) / the MoltenVK
/// bundle (macOS) is missing at runtime. We probe via
/// `libloading::Library::new`; a successful open means the loader is
/// resolvable and we should treat the GPU path as live.
///
/// Moved from `src-tauri/src/commands/models.rs` so non-Tauri crates
/// (transcription, llm) can call it without depending on the Tauri
/// binary.
pub fn vulkan_loader_available() -> bool {
    #[cfg(target_os = "linux")]
    let candidates: &[&str] = &["libvulkan.so.1", "libvulkan.so"];
    #[cfg(target_os = "windows")]
    let candidates: &[&str] = &["vulkan-1.dll"];
    #[cfg(target_os = "macos")]
    let candidates: &[&str] = &["libvulkan.1.dylib", "libMoltenVK.dylib"];
    #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
    let candidates: &[&str] = &[];

    for name in candidates {
        // SAFETY: libloading::Library::new loads a shared library and
        // returns a handle that is dropped at the end of this
        // iteration. We do not call any symbols, so the open-for-probe
        // pattern is sound.
        match unsafe { libloading::Library::new(*name) } {
            Ok(_lib) => return true,
            Err(_) => continue,
        }
    }
    false
}
```

- [ ] **Step 5: Run, expect pass**

Run: `cargo test -p lumotia-core --lib hardware::tests::vulkan_loader_available_does_not_panic`
Expected: PASS.

- [ ] **Step 6: Commit**

```bash
git add crates/core/Cargo.toml crates/core/src/hardware.rs
git commit -m "feat(core): port vulkan_loader_available from src-tauri"
```

---

### Task 3.2: Update src-tauri caller

**Files:**
- Modify: `src-tauri/src/commands/models.rs`

- [ ] **Step 1: Replace local definition with re-export**

Find the local `fn vulkan_loader_available()` in `src-tauri/src/commands/models.rs` (lines around 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.
