Codex independent review found 11 blockers post-cascade. All addressed.
CRITICAL (data-loss / crash):
10. crates/core/src/paths.rs — migrate_legacy_data_dir_inner used
fs::rename which fails with EXDEV when source + target are on
different filesystems (encrypted-home, bind mounts, separate
$XDG_DATA_HOME partition). Combined with the Phase 5 QC fix that
made migration errors fatal, this would crash on first launch
for any user whose data dir spans filesystems. Added
rename_or_copy_tree() that falls back to copy_dir_recursive +
remove_dir_all on CrossesDevices / errno 18 (EXDEV). Symlinks
preserved verbatim. Same fallback applied to magnotia.db ->
lumotia.db inside the dir.
11. Added 4 unit tests: copy_dir_recursive preserves nested
structure, rename_or_copy_tree same-filesystem happy path,
is_cross_device classifies CrossesDevices kind + raw errno 18.
Doc residuals (blockers 1-9):
1. crates/cloud-providers/Cargo.toml — "Wyrdnote pending rebrand"
description.
2. crates/cloud-providers/src/provider.rs — module docs + test
fixture Wyrdnote refs.
3. crates/transcription/src/orchestrator.rs — module docs + test
fixture Wyrdnote refs.
4. docs/roadmap/2026-05-10-pkm-phase-tooling-shortlist.md — phase
name + outputs/wyrdnote path refs.
5. docs/architecture-map/04-llm-formatting-mcp/llm-tests.md —
MAGNOTIA_LLM_TEST_MODEL env var.
6. .../cloud-providers-stubs.md — MAGNOTIA_API_KEY_*.
7. docs/architecture-map/03-audio-transcription/tests-and-fixtures.md
— MAGNOTIA_WHISPER_*.
8. docs/gpu-tuning/plan.md — MAGNOTIA_BENCH_RUN.
9. docs/superpowers/specs/2026-05-09-battery-gpu-aware-thread-tuning-
design.md + corresponding plan — MAGNOTIA_POWER_STATE_OVERRIDE,
MAGNOTIA_INFERENCE_THREADS.
cargo test --workspace: 343 pass / 0 fail (up from 339; +4 EXDEV
fallback tests).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1404 lines
46 KiB
Markdown
1404 lines
46 KiB
Markdown
# 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.
|