Phase 9 of the rebrand cascade. Sweep covers everything the Phase 8
frontend pass deliberately skipped: docs/, root markdown, scripts,
Cargo.toml descriptions, code comments that survived earlier
word-boundary sed, plus a handful of identifiers caught on the final
verify pass.
transcription-app changes:
- README.md, HANDOVER.md, KNOWN-ISSUES.md, run.sh — magnotia/Magnotia
-> lumotia/Lumotia.
- docs/ — sweep across all subdirs except docs/handovers/ (preserved
as immutable audit trail). Includes architecture-map references
to magnotia_core::*, magnotia_storage::*, etc. now pointing at
lumotia_*; dev-setup.md tracing output examples (lumotia_startup
target); brief/ + superpowers/ + issues/ + whisper-ecosystem/ +
audit/.
- Cargo.toml descriptions on 9 crates (core, audio, cloud-providers,
hotkey, llm, mcp, plus referenced others).
- crates/core/src/{error,hardware,recommendation,paths}.rs +
crates/audio/src/wav.rs + crates/llm/src/model_manager.rs +
crates/cloud-providers/src/keystore.rs + crates/mcp/src/lib.rs —
doc comments and a model-manager user-agent string.
- Caught on final pass: BroadcastChannel("magnotia_task_sync") -> ...
("lumotia_task_sync"); magnotia_locale i18n localStorage key
renamed + migration shim added; CSS keyframe names
magnotiaPulse / magnotiaBar / magnotiaFade renamed in the design-
system kit; magnotia_viewer_item / magnotia_viewer_mode handoff
keys renamed in HistoryPage + viewer/+page.svelte; src/assets/
wordmark.svg text.
- src-tauri/src/lib.rs comment cleanup ("magnotia era" was sed'd
to "lumotia era" earlier — restored).
Preserved (intentional):
- crates/core/src/paths.rs — keeps "magnotia" / "Magnotia" / ".magnotia"
legacy detection strings in legacy_and_target_paths() so the
migration shim can still find user data from the magnotia era.
- src/lib/stores/{page,focusTimer}.svelte.ts + src/lib/i18n/index.ts
— migration call sites reference the legacy magnotia keys
deliberately.
- docs/handovers/ — historical audit trail.
cargo build --workspace passes. npm run check: 0 errors / 0 warnings
(3958 files). cargo test --workspace: 339 pass / 0 fail.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
13 KiB
name, type, slice, last_verified
| name | type | slice | last_verified |
|---|---|---|---|
| LLM engine and llama-cpp-2 lifecycle | architecture-map-page | 04-llm-formatting-mcp | 2026/05/09 |
LLM engine and llama-cpp-2 lifecycle
Where you are: Architecture map → LLM, Formatting, MCP → LLM engine
Plain English summary. LlmEngine is the cloneable handle every part of Lumotia uses to talk to a local Qwen model. It owns the llama-cpp-2 backend, holds a single loaded model behind a mutex, and exposes one low-level generate plus four high-level surfaces. Everything else in the LLM crate (prompts, grammars, model manager) feeds into this type.
At a glance
- Crate:
lumotia-llm - Path:
crates/llm/src/lib.rs - LOC: 570
- Public surface (from
lib.rs):pub struct LlmEngine(crates/llm/src/lib.rs:84) withpub fn new,pub fn load,pub fn load_model,pub fn unload,pub fn is_loaded,pub fn loaded_model,pub fn loaded_model_id,pub fn generate,pub fn cleanup_text,pub fn decompose_task,pub fn decompose_task_with_feedback,pub fn extract_tasks,pub fn extract_tasks_with_feedback,pub fn extract_content_tags.pub enum EngineError(crates/llm/src/lib.rs:29) with variantsNotLoaded,LoadFailed,PromptTooLong,Inference,InvalidJson.pub struct GenerationConfig(crates/llm/src/lib.rs:50).pub struct LoadedModelState(crates/llm/src/lib.rs:70).- Re-exports:
CONTENT_TAGS_GRAMMAR,recommend_tier,LlmModelId,LlmModelInfo,is_valid_intent,ContentTags,CONTENT_TAGS_SYSTEM,INTENT_CLOSED_SET.
- External deps that matter:
llama-cpp-2 = 0.1.144,tokio(only for the async download path inmodel_manager;generateitself is sync),serde,tracing,lumotia-corefor thread tuning. - Tauri command that calls this (slice 2, best guess):
- Lifecycle:
llm_load_model,llm_unload_model,llm_load_recommended_model,llm_is_loadedetc. defined insrc-tauri/src/commands/llm.rs. Observed call sites atsrc-tauri/src/commands/llm.rs:116(engine.load_model),:128(engine.unload),:147(engine.is_loaded),:244(load_modelagain from the recommended-tier path).
- Lifecycle:
What's in here
LlmEngine struct (crates/llm/src/lib.rs:84)
A Clone-able newtype around Arc<Mutex<LlmState>>. Cloning shares the loaded model — every Tauri command holds its own clone but they all act on the same in-memory state. The state itself is a small LlmState struct (:77) with three options: the LlamaBackend, the LlamaModel, and the LoadedModelState metadata. Both backend and model are Arcs so a load while one inference is mid-flight does not free the model out from under the borrowed handle.
load and load_model (crates/llm/src/lib.rs:93, :97)
load is a thin wrapper that pins the default tier (LlmModelId::default_tier()) and asks for GPU. The full load_model takes (LlmModelId, &Path, use_gpu: bool).
Logic:
- If a model with the same id, path, and GPU flag is already loaded, no-op return
Ok(()). This makes repeated calls from the frontend cheap. - Reuse the cached
LlamaBackendif present; otherwise initialise one. The backend is a process-global singleton inside llama-cpp-2; double-init is undefined-behaviour territory, so we keep ours alive across reloads. gpu_layers = if use_gpu { u32::MAX } else { 0 }. There is no partial-offload path. Either every layer goes to the GPU or none do.LlamaModel::load_from_file(...)with the path the model manager produced.
Failures collapse into EngineError::LoadFailed(String).
unload (crates/llm/src/lib.rs:137)
Drops the Arc<LlamaModel> and Arc<LlamaBackend>, clears loaded. The Tauri layer calls this from llm_unload_model and indirectly via llm_delete_model when the active model is the one being deleted.
is_loaded, loaded_model, loaded_model_id (crates/llm/src/lib.rs:145, :149, :153)
Cheap getters. loaded_model returns Option<LoadedModelState> so the frontend can show which tier is active.
generate (crates/llm/src/lib.rs:157)
The sync, low-level inference primitive. Steps:
- Tokenise the prompt.
model.str_to_token(prompt, AddBos::Never)— theAddBos::Nevermatters:render_chat_promptalready emits the BOS token via the Qwen chat template. - Empty-prompt short-circuit. If tokenisation produced zero tokens, return
Ok(String::new())without touching the GPU. - Preflight context window.
preflight_context_window(prompt_tokens.len(), config.max_tokens)(:436) errors withEngineError::PromptTooLong { ... }whenprompt_tokens + max_tokens + 64 reserveexceeds the 8192 cap. Fixed sizing — see the watch-out about MAX_CONTEXT_TOKENS below. This was RB-10 from the 2026-04-22 review (docs/issues/llm-prompt-preflight.md). - Compute thread count.
gpu_offloaded = use_gpu && gpu_layers >= model.n_layer(). The compiler can prove this is trivially true today becausegpu_layersisu32::MAXwheneveruse_gpuis set. The redundant check is documented inline (:169-173) as a placeholder for future per-layer residency parsing of llama.cpp's log output.inference_thread_count(Workload::Llm, gpu_offloaded)fromlumotia_core::tuningreturns the physical core count adjusted for the workload class. - Build context params.
n_ctxfrom preflight,n_batchandn_ubatchclamped to[max(prompt_tokens, 512), n_ctx],n_threadsandn_threads_batchboth set to the computed thread count. - Prefill. A single
LlamaBatchis built with every prompt token, the last token marked as the only logits-bearing position, thenctx.decode(&mut batch). - Sample loop. A custom sampler chain (
build_sampler,:400) is built fromconfig.grammar(optional GBNF),temperature, and a fixedGENERATION_SEED = 0. For temperature 0.0 (the only value the high-level surfaces use) we attachLlamaSampler::greedy()after the optional grammar. For non-zero temperatures we attachtempthendistwith the seed. - Per-token loop. Until either the model emits an EOG / EOS token, or
max_tokensis hit, or a stop sequence appears in the running output:- Sample, then check
is_eog_tokenandtoken_eos(Qwen's chat templates use both). - Detokenise the new token via a UTF-8
encoding_rsdecoder so multi-byte sequences split across token boundaries do not garble. - Push to the running
String, accept the token in the sampler. - Test for any of
config.stop_sequencesin the running buffer; truncate and break if one is found. - Otherwise re-batch the new token alone and
ctx.decodeit for the next round.
- Sample, then check
- Trim and return.
Ok(generated.trim().to_string()).
High-level surfaces (also on LlmEngine)
Each is documented in its own page:
cleanup_text—crates/llm/src/lib.rs:232decompose_taskanddecompose_task_with_feedback—:254,:267extract_tasksandextract_tasks_with_feedback—:294,:358extract_content_tags—:306
All four set temperature: 0.0 and pass at least one llama-cpp stop sequence (<|im_end|> and <|im_end_of_text|>). All four call render_chat_prompt to apply the model's tokenizer-bundled chat template, with a ChatML fallback.
render_chat_prompt (crates/llm/src/lib.rs:462)
Tries model.chat_template(None). If the GGUF carries a tokenizer-defined template, that is used. Otherwise we log a tracing::warn! and fall back to a built-in LlamaChatTemplate::new("chatml"). ChatML is what the Qwen3.5 / 3.6 family expects, so the fallback is safe in practice; the warn is there for the day someone tries a model from outside the registered family.
parse_string_array (crates/llm/src/lib.rs:489)
Helper that the array-returning surfaces share. Calls serde_json::from_str::<Vec<String>>(raw.trim()), then trims items, drops empties, and dedupes case-insensitively while preserving first-seen ordering. The case-insensitive dedupe matters: the LLM occasionally emits "Buy milk" and "buy milk" in the same array, and the user does not want to see both.
build_sampler (crates/llm/src/lib.rs:400)
Composes LlamaSampler::grammar(model, grammar, "root") (when config.grammar is set), then either LlamaSampler::greedy() for temperature 0.0 or LlamaSampler::temp(temperature) plus LlamaSampler::dist(GENERATION_SEED) for non-zero temperatures. Single-element chains are returned as-is; multi-element chains use LlamaSampler::chain_simple.
Internal constants (crates/llm/src/lib.rs:23-26)
DEFAULT_CONTEXT_TOKENS = 4096— minimum context window we ever allocate.MAX_CONTEXT_TOKENS = 8192— hard cap. Both the preflight error and the realisedn_ctxare bounded here.CONTEXT_RESERVE_TOKENS = 64— extra headroom subtracted from the prompt budget so a tight fit never wedges the sampler.GENERATION_SEED = 0— fixed sampling seed. Combined with temperature 0.0, makes greedy decoding fully deterministic for the high-level surfaces.
Data flow
caller prompt + GenerationConfig
→ str_to_token (AddBos::Never)
→ preflight_context_window (or PromptTooLong error)
→ tuning::inference_thread_count (with gpu_offloaded)
→ LlamaContextParams (n_ctx, n_batch, n_ubatch, n_threads)
→ LlamaModel::new_context
→ LlamaBatch (prefill, last token logits)
→ ctx.decode
→ loop:
LlamaSampler::sample (greedy or temp+dist, optional grammar)
→ is_eog_token / token_eos check
→ token_to_piece (UTF-8 incremental decoder)
→ stop-sequence check
→ batch.clear + add(next, cursor) + ctx.decode
→ trim + return String
The high-level surfaces wrap this with: a chat-template render in front, and (for array surfaces) parse_string_array plus a typed JSON deserialise behind.
Prompts and grammars
This file does not hold prompts or grammars itself. See llm-prompts-and-grammars.md for the catalogue. The engine consumes them by reference in each surface.
Watch-outs
- Mutex-protected single model.
LlmEngineallows only one model loaded at a time. Two concurrentgeneratecalls serialise on the underlying llama context (each call builds its ownnew_contextfrom the sharedLlamaModel, so the model weights are shared but the KV cache is per-call). The Tauri layer wraps each high-level call intokio::task::spawn_blockingbecausegenerateis sync and blocks the executor for hundreds of milliseconds at minimum. MAX_CONTEXT_TOKENS = 8192is a process-wide cap regardless of which tier is loaded. The 27B tier's native context is much larger; we are deliberately leaving headroom on the table to keep the preflight predictable. Surfaced in the slice README's debt section.u32::MAXGPU offload. The engine has no concept of partial offload. On a low-VRAM machine that cannot fit all layers, llama.cpp will emit warnings and fall back to mixed CPU/GPU automatically, but ourgpu_offloadedboolean tellsinference_thread_countwe are fully GPU-resident. When this matters (battery, throttling), the consumer islumotia_core::tuning— it picks a bigger thread count when it thinks the CPU is idle. Trivial-true today; tracked as observability gap (commit052265b).- GBNF parser quirks. llama-cpp-2's GBNF is strict about whitespace. Each grammar in
llm-prompts-and-grammars.mdcarries an explicitwsrule andr#""#raw strings — refactors that try to "tidy" the grammar literal by stripping the trailing newline have, in the past, brokenLlamaSampler::grammarwith cryptic parse errors. - Stop sequences are post-detokenisation substring matches. They run on the running
String, not on token ids. A multi-byte stop string that splits across a token boundary still matches because the UTF-8 decoder buffers partial bytes. A stop string that contains characters the chat template re-emits as part of normal output (e.g. a literal<|) will trigger early termination — only use the EOG sentinels we already use. - Chat template fallback to ChatML. If
model.chat_template(None)errors, we warn-log and useLlamaChatTemplate::new("chatml"). The warn-log fires once pergeneratecall, not once per session — keep an eye on log volume if a non-Qwen model is ever loaded. - Backend reuse across loads.
LlmState.backendis intentionally not dropped onunload. Re-init ofLlamaBackendafter a previous init is unsupported by llama-cpp-2; we keep one alive for the lifetime of the process.