Skip to main content

el_engine_candle/
lib.rs

1//! `el-engine-candle` — inference engine adapter over **Candle** (ADR-002),
2//! implementing [`el_runtime::InferenceEngine`] / `RuntimeAcl`.
3//!
4//! Consumers supply their own model file; see [`CandleEngine::from_path`] and
5//! [`CandleEngine::from_bytes`].  For tests that need a working engine without
6//! a model asset, use [`CandleEngine::toy`].
7//!
8//! Expected GGUF tensor names:
9//! - `token_embd.weight`  — embedding table  `[vocab, dim]`
10//! - `output.weight` or `lm_head.weight` — lm-head  `[vocab, dim]`  (standard Llama layout)
11//!
12//! Float logits are quantised to integer milli-logits at the ACL boundary, so
13//! Candle's `Tensor`/`Device` types never cross into the domain.
14
15#![forbid(unsafe_code)]
16
17use candle_core::{Device, Tensor};
18use el_core::{
19    ChatMessage, ChatRequest, ChatResponse, ChatRole, ChatToken, DomainEvent, EdgeError,
20    LlmProvider, Result, SafetyMode, SessionConfig, SessionId, StopReason, Token,
21};
22use el_provenance::LoadPermit;
23use el_runtime::{
24    AnchorGuard, ContrastiveSteerer, ExpertLogits, InferenceEngine, InferenceSession,
25    LightweightFilter, NoSafety, Ports, SafetyModeSelector, SafetySteerer,
26};
27
28/// Candle-backed inference engine.
29pub struct CandleEngine {
30    embed: Tensor,
31    w_out: Tensor,
32    vocab: usize,
33    eos: Token,
34}
35
36impl CandleEngine {
37    /// Build a deterministic toy model on the CPU — no model file required.
38    ///
39    /// Uses fixed synthetic weights so tests are deterministic.
40    pub fn toy(vocab: usize, dim: usize, eos: Token) -> Result<Self> {
41        let device = Device::Cpu;
42
43        let embed_data: Vec<f32> = (0..vocab * dim)
44            .map(|k| {
45                let (i, j) = (k / dim, k % dim);
46                (((i + j) % 7) as f32) * 0.1
47            })
48            .collect();
49        let wout_data: Vec<f32> = (0..dim * vocab)
50            .map(|k| {
51                let (a, b) = (k / vocab, k % vocab);
52                ((((a * 31 + b * 17) % 13) as f32) * 0.1) - 0.6
53            })
54            .collect();
55
56        let embed = Tensor::from_vec(embed_data, (vocab, dim), &device)
57            .map_err(|_| EdgeError::Engine("candle: embed tensor build failed"))?;
58        let w_out = Tensor::from_vec(wout_data, (dim, vocab), &device)
59            .map_err(|_| EdgeError::Engine("candle: w_out tensor build failed"))?;
60
61        Ok(Self {
62            embed,
63            w_out,
64            vocab,
65            eos,
66        })
67    }
68
69    /// Load `token_embd.weight` and `output.weight` from a consumer-supplied GGUF file.
70    ///
71    /// # Limitations
72    /// This engine's forward pass is `embed[last_token] · w_out` — a single linear
73    /// projection.  Only these two tensors are used; transformer blocks, attention,
74    /// RoPE, and norms present in the GGUF are ignored.  Logits will not match a
75    /// real Llama/Mistral/etc. forward.  This is the ADR-002 engine-seam proof; for
76    /// a full transformer forward implement a separate [`InferenceEngine`] using
77    /// `candle-transformers`.
78    pub fn from_path(path: impl AsRef<std::path::Path>, eos: Token) -> Result<Self> {
79        let file = std::fs::File::open(path.as_ref())
80            .map_err(|_| EdgeError::Engine("model file not found or not readable"))?;
81        Self::load_gguf(&mut std::io::BufReader::new(file), eos)
82    }
83
84    /// Load from raw bytes (WASM / memory-mapped scenarios).
85    ///
86    /// Same limitations as [`Self::from_path`]: only `token_embd.weight` and
87    /// `output.weight` are used; the forward is `embed[last] · w_out`.
88    pub fn from_bytes(data: &[u8], eos: Token) -> Result<Self> {
89        Self::load_gguf(&mut std::io::Cursor::new(data), eos)
90    }
91
92    fn load_gguf<R: std::io::Read + std::io::Seek>(reader: &mut R, eos: Token) -> Result<Self> {
93        use candle_core::quantized::gguf_file;
94
95        let content = gguf_file::Content::read(reader)
96            .map_err(|_| EdgeError::Engine("GGUF: invalid or unrecognised file"))?;
97        let device = Device::Cpu;
98
99        let embed = content
100            .tensor(reader, "token_embd.weight", &device)
101            .map_err(|_| EdgeError::Engine("GGUF: missing 'token_embd.weight'"))?
102            .dequantize(&device)
103            .map_err(|_| EdgeError::Engine("GGUF: cannot dequantize embed tensor"))?;
104
105        let (vocab, dim) = match embed.shape().dims() {
106            [v, d] => (*v, *d),
107            _ => return Err(EdgeError::Engine("GGUF: 'token_embd.weight' must be 2-D")),
108        };
109
110        let raw_w_q = match content.tensor(reader, "output.weight", &device) {
111            Ok(t) => t,
112            Err(_) => content
113                .tensor(reader, "lm_head.weight", &device)
114                .map_err(|_| {
115                    EdgeError::Engine("GGUF: missing 'output.weight' / 'lm_head.weight'")
116                })?,
117        };
118        let raw_w = raw_w_q
119            .dequantize(&device)
120            .map_err(|_| EdgeError::Engine("GGUF: cannot dequantize output weight"))?;
121
122        // Standard GGUF / Llama convention: output.weight is [vocab, dim].
123        // We need [dim, vocab] so that embed_row [1,dim] × w_out [dim,vocab] → logits [1,vocab].
124        let w_out = match raw_w.shape().dims() {
125            [v, _d] if *v == vocab => raw_w
126                .t()
127                .map_err(|_| EdgeError::Engine("GGUF: failed to transpose output weight"))?,
128            _ => raw_w,
129        };
130
131        // Validate that the output weight's inner dimension matches the embedding dimension.
132        // A mismatch would silently produce all-zero logits at inference time.
133        match w_out.shape().dims() {
134            [d, v] if *d == dim && *v == vocab => {}
135            _ => return Err(EdgeError::Engine(
136                "GGUF: output weight shape incompatible with embed dim — expected [dim, vocab] after transpose",
137            )),
138        }
139
140        Ok(Self {
141            embed,
142            w_out,
143            vocab,
144            eos,
145        })
146    }
147
148    /// One real Candle forward: `embed[last] · w_out` → length-`vocab` logits.
149    fn forward(&self, last: usize) -> candle_core::Result<Vec<f32>> {
150        let row = self.embed.narrow(0, last, 1)?; // [1, dim]
151        let logits = row.matmul(&self.w_out)?; // [1, vocab]
152        Ok(logits.to_vec2::<f32>()?.remove(0))
153    }
154}
155
156impl InferenceEngine for CandleEngine {
157    fn prefill(&mut self, tokens: &[Token]) -> Result<u32> {
158        Ok(tokens.len() as u32)
159    }
160
161    fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
162        let last = committed
163            .last()
164            .copied()
165            .unwrap_or(0)
166            .min(self.vocab as u32 - 1) as usize;
167        match self.forward(last) {
168            Ok(logits) => logits.iter().map(|x| (x * 1000.0).round() as i32).collect(),
169            Err(_) => vec![0; self.vocab],
170        }
171    }
172
173    fn eos_token(&self) -> Token {
174        self.eos
175    }
176
177    /// Stateless: this engine's forward is `embed[committed.last()] · w_out`, so
178    /// it holds no KV cache to restore — a rollback is a no-op.
179    fn rollback(&mut self, _keep_committed: u32) -> Result<()> {
180        Ok(())
181    }
182
183    /// Stateless: no conversation cache to discard between turns (ADR-018).
184    fn reset_cache(&mut self) -> Result<()> {
185        Ok(())
186    }
187}
188
189// ── LlmProvider (text-level) wrapper (ADR-010) ───────────────────────────────
190
191/// Wraps a `CandleEngine` behind the `LlmProvider` trait using a byte-level
192/// tokenizer.  A production build would swap in a HuggingFace tokenizer loaded
193/// from the model file.
194pub struct LocalLlmProvider {
195    session: std::sync::Mutex<InferenceSession<CandleEngine>>,
196    vocab: usize,
197}
198
199impl LocalLlmProvider {
200    /// Load from a consumer-supplied GGUF file.
201    pub fn from_path(
202        path: impl AsRef<std::path::Path>,
203        eos: Token,
204        permit: LoadPermit,
205    ) -> Result<Self> {
206        let engine = CandleEngine::from_path(path, eos)?;
207        let vocab = engine.vocab;
208        let session = InferenceSession::new(SessionId(1), SessionConfig::default(), engine, permit);
209        Ok(Self {
210            session: std::sync::Mutex::new(session),
211            vocab,
212        })
213    }
214
215    /// Build a toy provider for testing.
216    pub fn toy(vocab: usize, dim: usize, eos: Token, permit: LoadPermit) -> Result<Self> {
217        let engine = CandleEngine::toy(vocab, dim, eos)?;
218        let session = InferenceSession::new(SessionId(1), SessionConfig::default(), engine, permit);
219        Ok(Self {
220            session: std::sync::Mutex::new(session),
221            vocab,
222        })
223    }
224
225    fn encode(&self, text: &str) -> Vec<Token> {
226        text.bytes()
227            .map(|b| (b as Token) % self.vocab as Token)
228            .collect()
229    }
230
231    fn decode(tokens: &[Token]) -> String {
232        tokens
233            .iter()
234            .map(|&t| {
235                let b = (t & 0xFF) as u8;
236                if b.is_ascii_graphic() || b == b' ' {
237                    b as char
238                } else {
239                    '?'
240                }
241            })
242            .collect()
243    }
244
245    fn format_messages(messages: &[ChatMessage]) -> String {
246        messages
247            .iter()
248            .map(|m| {
249                let role = match m.role {
250                    ChatRole::System => "system",
251                    ChatRole::User => "user",
252                    ChatRole::Assistant => "assistant",
253                };
254                format!("{role}: {}", m.content)
255            })
256            .collect::<Vec<_>>()
257            .join("\n")
258    }
259}
260
261impl LlmProvider for LocalLlmProvider {
262    fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
263        let prompt = Self::format_messages(&req.messages);
264        let prompt_tokens = self.encode(&prompt);
265        let prompt_len = prompt_tokens.len() as u32;
266        let max = req.max_tokens.unwrap_or(64);
267
268        let mut session = self.session.lock().unwrap();
269        session.reset()?;
270        let _ = session.drain_events(); // bound buffered events across reused turns
271        let ports = Ports::permissive();
272        session.load_prompt(&ports, &prompt_tokens)?;
273        session.generate(&ports, max)?;
274
275        let output = session.output().to_vec();
276        let completion_len = output.len() as u32;
277
278        Ok(ChatResponse {
279            content: Self::decode(&output),
280            model: "local/candle".into(),
281            prompt_tokens: prompt_len,
282            completion_tokens: completion_len,
283        })
284    }
285
286    fn chat_stream(&self, req: &ChatRequest, on_token: &mut dyn FnMut(ChatToken)) -> Result<()> {
287        let resp = self.chat(req)?;
288        for ch in resp.content.chars() {
289            on_token(ChatToken {
290                text: ch.to_string(),
291                is_final: false,
292            });
293        }
294        on_token(ChatToken {
295            text: String::new(),
296            is_final: true,
297        });
298        Ok(())
299    }
300}
301
302// ── Real Qwen2 transformer engine + chat provider (ADR-002 + ADR-010) ────────
303//
304// Unlike `CandleEngine` (a single linear projection used as the engine-seam
305// proof) this runs a genuine Qwen2 transformer forward via `candle-transformers`
306// with a real HuggingFace tokenizer, so it produces coherent chat. It plugs into
307// the SAME `el_runtime::InferenceSession` decode loop as every other engine —
308// nothing in the SDK pipeline is bypassed.
309
310use candle_transformers::models::quantized_qwen2::ModelWeights as Qwen2Weights;
311use el_core::{ModelId, ModelVersion};
312use el_provenance::{ModelArtifact, SignatureVerifier};
313use tokenizers::Tokenizer;
314
315// ── Opt-in benchmark instrumentation (EL_BENCH=1) ────────────────────────────
316//
317// Zero-cost when `EL_BENCH` is unset: `enabled()` short-circuits and no timing
318// is taken. When set, `QwenChatProvider::chat` prints a per-phase breakdown and
319// per-forward attribution (model compute vs. seam quantisation vs. runtime loop)
320// to stderr. Diagnostics only — not part of the SDK's public behaviour.
321mod bench {
322    use std::cell::Cell;
323    use std::sync::OnceLock;
324    use std::time::Duration;
325
326    static ENABLED: OnceLock<bool> = OnceLock::new();
327
328    /// True iff the `EL_BENCH` environment variable is present (read once).
329    pub fn enabled() -> bool {
330        *ENABLED.get_or_init(|| std::env::var_os("EL_BENCH").is_some())
331    }
332
333    thread_local! {
334        static FWD_TOTAL: Cell<Duration> = const { Cell::new(Duration::ZERO) };
335        static FWD_MODEL: Cell<Duration> = const { Cell::new(Duration::ZERO) };
336        static FWD_CALLS: Cell<u64> = const { Cell::new(0) };
337    }
338
339    /// Accumulate one `forward_one` sample: `total` is the whole seam call,
340    /// `model` is just the candle transformer forward inside it.
341    pub fn record(total: Duration, model: Duration) {
342        FWD_TOTAL.with(|c| c.set(c.get() + total));
343        FWD_MODEL.with(|c| c.set(c.get() + model));
344        FWD_CALLS.with(|c| c.set(c.get() + 1));
345    }
346
347    /// Read and reset the forward accumulators: `(total, model, calls)`.
348    pub fn take() -> (Duration, Duration, u64) {
349        (
350            FWD_TOTAL.replace(Duration::ZERO),
351            FWD_MODEL.replace(Duration::ZERO),
352            FWD_CALLS.replace(0),
353        )
354    }
355}
356
357/// A real Qwen2 transformer `InferenceEngine`.
358///
359/// Holds candle's stateful KV cache. Within one generation it is fed
360/// incrementally (prefill, then one new token per `next_logits` call). The engine
361/// is **loaded once and reused across conversations** (ADR-018): candle exposes no
362/// public cache-clear, but its attention *replaces* the cache on a forward at
363/// `index_pos == 0`, so [`reset_cache`](InferenceEngine::reset_cache) evicts the
364/// previous conversation's KV with a single benign position-0 forward — no engine
365/// reconstruction and no reload from disk between turns.
366///
367/// A *within-generation* safety backtrack (ADR-012) is supported via
368/// [`InferenceEngine::rollback`]: candle's attention discards its cache when a
369/// forward runs at `index_pos == 0`, so we retain the prompt and replay it from
370/// position 0 to rebuild the cache for the safe prefix (the session then
371/// re-feeds the retained committed tokens). Float logits are quantised to
372/// integer milli-logits at the seam, exactly like [`CandleEngine`], so the
373/// runtime stays float-free.
374pub struct QwenEngine {
375    model: Qwen2Weights,
376    device: Device,
377    /// Absolute KV position written so far (candle's `index_pos`).
378    index_pos: usize,
379    /// How many of the runtime-`committed` tokens have already been fed.
380    fed: usize,
381    /// The prefill prompt, retained so a rollback can replay it from position 0
382    /// to rebuild candle's KV cache (which has no public truncation).
383    prompt: Vec<Token>,
384    /// Milli-logits produced after the most recent forward.
385    last_logits: Vec<i32>,
386    vocab: usize,
387    eos: Token,
388    /// Whether candle's per-layer KV cache may hold conversation-derived K/V that
389    /// still needs clearing (ADR-018). Set by every `forward_one` (before the
390    /// fallible model forward) and cleared **only** after a fully successful
391    /// eviction forward in `reset_cache`, so a partially-failed eviction is retried
392    /// rather than skipped — `index_pos` alone can't carry that signal.
393    cache_dirty: bool,
394}
395
396impl QwenEngine {
397    /// Load Qwen2 weights from a consumer-supplied GGUF file.
398    pub fn from_path(path: impl AsRef<std::path::Path>, eos: Token) -> Result<Self> {
399        use candle_core::quantized::gguf_file;
400        let mut file = std::fs::File::open(path.as_ref())
401            .map_err(|_| EdgeError::Engine("model file not found or not readable"))?;
402        let content = gguf_file::Content::read(&mut file)
403            .map_err(|_| EdgeError::Engine("GGUF: invalid or unrecognised file"))?;
404        let device = Device::Cpu;
405        let model = Qwen2Weights::from_gguf(content, &mut file, &device)
406            .map_err(|_| EdgeError::Engine("GGUF: failed to load Qwen2 weights"))?;
407        Ok(Self {
408            model,
409            device,
410            index_pos: 0,
411            fed: 0,
412            prompt: Vec::new(),
413            last_logits: Vec::new(),
414            vocab: 0,
415            eos,
416            cache_dirty: false,
417        })
418    }
419
420    /// One forward over a single token at the current position; advances the KV
421    /// cache and returns milli-logits for the next token.
422    fn forward_one(&mut self, token: Token) -> Result<Vec<i32>> {
423        // Any forward may write conversation K/V into candle's cache; mark dirty
424        // before the fallible call so a forward that fails part-way still leaves
425        // the cache flagged for clearing (ADR-018).
426        self.cache_dirty = true;
427        let t_total = bench::enabled().then(std::time::Instant::now);
428
429        let input = Tensor::from_vec(vec![token], (1, 1), &self.device)
430            .map_err(|_| EdgeError::Engine("candle: input tensor build failed"))?;
431
432        let t_model = bench::enabled().then(std::time::Instant::now);
433        let logits = self
434            .model
435            .forward(&input, self.index_pos)
436            .map_err(|_| EdgeError::Engine("candle: Qwen2 forward failed"))?;
437        let model_dur = t_model.map(|t| t.elapsed()).unwrap_or_default();
438
439        self.index_pos += 1;
440        let row = logits
441            .squeeze(0)
442            .map_err(|_| EdgeError::Engine("candle: squeeze logits failed"))?;
443        let floats = row
444            .to_vec1::<f32>()
445            .map_err(|_| EdgeError::Engine("candle: logits to vec failed"))?;
446        let out: Vec<i32> = floats.iter().map(|x| (x * 1000.0).round() as i32).collect();
447
448        if let Some(t) = t_total {
449            bench::record(t.elapsed(), model_dur);
450        }
451        Ok(out)
452    }
453}
454
455impl InferenceEngine for QwenEngine {
456    fn prefill(&mut self, tokens: &[Token]) -> Result<u32> {
457        self.index_pos = 0;
458        self.fed = 0;
459        self.prompt = tokens.to_vec(); // retained for rollback replay
460        for &t in tokens {
461            self.last_logits = self.forward_one(t)?;
462        }
463        self.vocab = self.last_logits.len();
464        Ok(tokens.len() as u32)
465    }
466
467    fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
468        // Feed any newly committed (generated) tokens beyond what we've seen.
469        // `committed` grows by exactly one per decode step, so this feeds the
470        // token the runtime just sampled and returns the next distribution.
471        while self.fed < committed.len() {
472            let t = committed[self.fed];
473            match self.forward_one(t) {
474                Ok(l) => self.last_logits = l,
475                Err(_) => return vec![0; self.vocab.max(1)],
476            }
477            self.fed += 1;
478        }
479        self.last_logits.clone()
480    }
481
482    fn eos_token(&self) -> Token {
483        self.eos
484    }
485
486    fn rollback(&mut self, _keep_committed: u32) -> Result<()> {
487        // candle's KV cache is append-only with no public truncation, but its
488        // attention discards the cache on a forward at `index_pos == 0` (see
489        // quantized_qwen2). So rebuild deterministically: replay the prompt from
490        // position 0 — the first forward resets the cache, the rest re-append it —
491        // leaving the engine in its exact post-prefill state. We reset `fed` to 0
492        // so the session's next `next_logits` re-feeds the retained committed
493        // prefix (already truncated to `keep_committed`) on top. Cost is bounded
494        // by `max_rollbacks` (ADR-012).
495        self.index_pos = 0;
496        self.fed = 0;
497        for i in 0..self.prompt.len() {
498            let t = self.prompt[i];
499            self.last_logits = self.forward_one(t)?;
500        }
501        Ok(())
502    }
503
504    /// Release the current conversation's KV **while keeping the resident weights
505    /// loaded** (ADR-018) — the separation of conversation lifecycle from model
506    /// lifecycle, and the engine half of [`InferenceSession::close`] / `reset`.
507    ///
508    /// candle's `quantized_qwen2` owns its per-layer KV with no public clear API,
509    /// but its attention *ignores and replaces* the cache on a forward at
510    /// `index_pos == 0`. So one forward over a benign token (id 0) drops the prior
511    /// (user) K/V tensors — freeing that memory and clearing the user's data from
512    /// the cache (PRD line 131) — without touching the weights. What remains is a
513    /// single non-user token's KV, itself overwritten by the next prefill or freed
514    /// when the engine is dropped. Skipped when nothing has been cached yet
515    /// (`index_pos == 0`), so a pristine or already-cleared engine does no work.
516    ///
517    /// Distinct from `rollback`, which *replays* a retained prefix to rewind
518    /// within a single generation. Fallible (it runs a forward); on error the
519    /// caller (`reset`/`close`) leaves session state untouched and surfaces it.
520    fn reset_cache(&mut self) -> Result<()> {
521        if self.cache_dirty {
522            // Overwrite (and thereby drop) the user K/V by forwarding a benign
523            // token at position 0; the resulting logits are discarded. `cache_dirty`
524            // is cleared **only after** a fully successful forward — if candle
525            // fails after replacing some layers, it stays set so the next call
526            // re-clears (a partially-cleared cache is never reported as clean).
527            self.index_pos = 0;
528            self.forward_one(0)?;
529            self.cache_dirty = false;
530        }
531        self.index_pos = 0;
532        self.fed = 0;
533        // Release (not just `clear`) the conversation-derived buffers so their
534        // bytes aren't retained in an owned allocation (P2): `Vec::new()` drops
535        // the old allocation; `clear()` would keep capacity and the stale ids.
536        self.prompt = Vec::new();
537        self.last_logits = Vec::new();
538        Ok(())
539    }
540}
541
542// ── On-device safety wiring (ADR-005 tier + ADR-012 control loop) ────────────
543//
544// The runtime ships the *primitives* (steerer, chunk guard, checkpointed
545// rollback). They only engage when a session is given a real steerer + guard in
546// its `Ports` — `Ports::permissive()` wires neither, so a provider must opt in.
547// This adapter does: it owns the tokenizer, so it is the one place that can turn
548// a human-readable unsafe-word list into the token-id patterns the runtime's
549// float-free guard consumes. The resolved patterns/bans then drive the standard
550// `InferenceSession::generate` control loop — nothing in the SDK is bypassed.
551
552/// A small, conservative built-in `Lightweight` safety list (ADR-005). These are
553/// unambiguous weapons/mass-harm manufacture terms — content the decode-time
554/// guard should never let the model emit. It is intentionally narrow to avoid
555/// false positives in ordinary chat; production swaps in the active tier's real
556/// safety model (the LoRA adapter / classifier of ADR-012's model inventory).
557const DEFAULT_UNSAFE_WORDS: &[&str] = &[
558    "bomb",
559    "explosive",
560    "detonator",
561    "methamphetamine",
562    "ricin",
563    "anthrax",
564    "sarin",
565    "nerve agent",
566];
567
568/// Deterministic hard refusal emitted when the control loop fails closed —
569/// rollbacks exhausted, no safe checkpoint, or refused at ingress (ADR-012
570/// §"Bounded rollback, fail-closed"; ADR-013 ingress triage).
571const SAFETY_REFUSAL: &str = "I can't help with that request.";
572
573/// Contrastive steering is restricted to the top-K base-logit tokens (ADR-013 /
574/// SafeDecoding): it keeps the per-step adjustment small (so the runtime's
575/// `pick` stays linear in the vocab) and avoids amplifying long-tail noise.
576const CONTRASTIVE_TOP_K: usize = 64;
577
578/// Encode a word to the token-id sequence(s) the model may actually emit for
579/// it. A word tokenizes differently depending on what precedes it, so both the
580/// **leading-space** form (mid-sentence, after another token) and the **bare**
581/// form (start of a line/turn or after punctuation) are returned — each as a
582/// distinct anchor n-gram. Empty/duplicate encodings are dropped, so the caller
583/// gets only matchable patterns.
584fn word_to_patterns(tokenizer: &Tokenizer, word: &str) -> Vec<Vec<Token>> {
585    let mut out: Vec<Vec<Token>> = Vec::new();
586    for variant in [format!(" {word}"), word.to_string()] {
587        if let Ok(enc) = tokenizer.encode(variant, false) {
588            let seq = enc.get_ids().to_vec();
589            if !seq.is_empty() && !out.contains(&seq) {
590                out.push(seq);
591            }
592        }
593    }
594    out
595}
596
597/// Resolved safety wiring for the provider, derived from the tokenizer once at
598/// construction. Holds only token-id data, so rebuilding a turn's `Ports` is a
599/// cheap clone with no tokenizer access on the hot path.
600#[derive(Debug, Clone)]
601struct SafetyConfig {
602    /// The ADR-005 tier. `Off` runs the plain single-pass decode (legacy path).
603    mode: SafetyMode,
604    /// Hard-banned single tokens — the always-on per-step `LightweightFilter`
605    /// layer (only words that encode to exactly one token; banning a shared
606    /// subword would be too blunt).
607    banned: Vec<Token>,
608    /// Built-in unsafe token-id n-grams — drive **both** the ADR-012 output
609    /// chunk guard and the ADR-013 prompt ingress triage.
610    patterns: Vec<Vec<Token>>,
611    /// Caller-supplied `--guard-word` n-grams — a **guard-only** demo/test hook.
612    /// Deliberately excluded from ingress so the documented rollback demo
613    /// (`--guard-word banana --prompt "…banana…"`) fires the *trajectory* loop
614    /// instead of refusing the prompt before decoding.
615    extra_guard_patterns: Vec<Vec<Token>>,
616}
617
618impl SafetyConfig {
619    /// Resolve the built-in `Lightweight` list against `tokenizer`.
620    fn lightweight(tokenizer: &Tokenizer) -> Self {
621        let mut banned = Vec::new();
622        let mut patterns = Vec::new();
623        for &word in DEFAULT_UNSAFE_WORDS {
624            for seq in word_to_patterns(tokenizer, word) {
625                // A single-token form is safe to hard-ban per step; multi-token
626                // forms are caught by the guard (banning a shared subword could
627                // hurt benign text).
628                if seq.len() == 1 && !banned.contains(&seq[0]) {
629                    banned.push(seq[0]);
630                }
631                if !patterns.contains(&seq) {
632                    patterns.push(seq);
633                }
634            }
635        }
636        Self {
637            mode: SafetyMode::Lightweight,
638            banned,
639            patterns,
640            extra_guard_patterns: Vec::new(),
641        }
642    }
643
644    /// Build the per-turn safety `Ports` (steerer + chunk guard) for this tier.
645    /// `Off`, or an empty list, yields no steering/guarding.
646    fn ports(&self) -> Ports {
647        let mut ports = Ports::permissive();
648        if matches!(self.mode, SafetyMode::Off) {
649            return ports;
650        }
651        let steerer: Box<dyn SafetySteerer> = if self.banned.is_empty() {
652            Box::new(NoSafety)
653        } else {
654            Box::new(LightweightFilter::new(self.banned.clone()))
655        };
656        ports.safety = steerer;
657        // Output chunk guard (ADR-012): built-in unsafe patterns + caller's
658        // --guard-word extras.
659        let guard_patterns: Vec<Vec<Token>> = self
660            .patterns
661            .iter()
662            .chain(self.extra_guard_patterns.iter())
663            .cloned()
664            .collect();
665        if !guard_patterns.is_empty() {
666            ports.guard = Some(Box::new(AnchorGuard::hard(guard_patterns)));
667        }
668        // Prompt ingress triage (ADR-013): built-in patterns ONLY — the
669        // --guard-word extras are a trajectory demo hook, not ingress refusals.
670        if !self.patterns.is_empty() {
671            ports.ingress = Some(Box::new(AnchorGuard::hard(self.patterns.clone())));
672        }
673        ports
674    }
675}
676
677/// A safety **expert** logit source for contrastive steering (ADR-013): a second
678/// Qwen engine — in production base + a safety LoRA; here any same-tokenizer Qwen
679/// GGUF — loaded through the ADR-006 provenance gate and **primed with the turn's
680/// prompt** so its logits align with the base engine's. The session feeds it the
681/// committed tokens via [`ExpertLogits::logits`]; interior mutability is required
682/// because each forward advances candle's KV cache.
683///
684/// Steering is bounded to the early-token window (ADR-013), so the expert runs
685/// only for the first `steer_window` tokens. When the base engine rolls back
686/// (committed output shrinks), the expert **re-primes to the prompt** and
687/// re-feeds the retained prefix, so its contrastive context stays aligned with
688/// the base rather than serving logits from the abandoned branch. Pointing this
689/// at the chat model itself yields ~zero contrast (a no-op); a safety-tuned Qwen
690/// GGUF gives real steering.
691pub struct QwenExpert {
692    engine: std::cell::RefCell<QwenEngine>,
693    /// How many committed tokens the expert has fed since its last prime — used
694    /// to detect a base rollback (committed shrinks below this) and re-sync.
695    fed: std::cell::Cell<usize>,
696    /// Evidence the expert weights passed the ADR-006 load gate (R5). Held for
697    /// the engine's lifetime; never used after construction.
698    _permit: LoadPermit,
699}
700
701impl QwenExpert {
702    /// Load the expert GGUF, gate it (ADR-006 — `permit` is required, not
703    /// optional), and prime it with `prompt` so its KV state matches the base
704    /// engine's post-prefill state.
705    pub fn from_path_primed(
706        path: impl AsRef<std::path::Path>,
707        eos: Token,
708        prompt: &[Token],
709        permit: LoadPermit,
710    ) -> Result<Self> {
711        let mut engine = QwenEngine::from_path(path, eos)?;
712        engine.prefill(prompt)?;
713        Ok(Self {
714            engine: std::cell::RefCell::new(engine),
715            fed: std::cell::Cell::new(0),
716            _permit: permit,
717        })
718    }
719}
720
721impl ExpertLogits for QwenExpert {
722    fn logits(&self, committed: &[Token]) -> Vec<i32> {
723        let mut engine = self.engine.borrow_mut();
724        // Base rolled back? `committed` shrank below what we've fed. Re-prime the
725        // expert to the prompt (QwenEngine::rollback replays the prompt and
726        // resets its feed cursor) so it re-feeds the retained prefix from a clean
727        // state — keeping the contrastive context aligned with the base. Cost is
728        // bounded by `max_rollbacks` (ADR-012), same as the base engine.
729        if committed.len() < self.fed.get() {
730            if engine.rollback(0).is_err() {
731                return Vec::new();
732            }
733            self.fed.set(0);
734        }
735        let out = engine.next_logits(committed);
736        self.fed.set(committed.len());
737        out
738    }
739}
740
741/// The resident model behind a [`QwenChatProvider`] (ADR-018).
742///
743/// The weights are loaded **once** (in [`QwenChatProvider::from_paths`]) into
744/// `Loaded`. The first `chat` promotes them into a reusable [`InferenceSession`]
745/// (`Active`) — done lazily so the builder-configured safety tier / expert is
746/// finalized first — and every later turn reuses that one session via
747/// `reset()` + `load_prompt()`. The model is never re-read from disk per turn.
748enum ChatSession {
749    /// Weights resident, no conversation session yet.
750    Loaded(QwenEngine),
751    /// Reusable session wrapping the resident engine.
752    Active(InferenceSession<QwenEngine>),
753    /// Transient placeholder held only during the `Loaded` → `Active` swap.
754    Swapping,
755}
756
757/// A real local chat backend: a Qwen2 GGUF model + its tokenizer, driven
758/// through [`el_runtime::InferenceSession`].
759///
760/// The model weights are loaded **once** at construction and kept resident
761/// (ADR-018): each `chat` renders the whole conversation to Qwen2.5 ChatML and
762/// reuses one persistent provenance-gated session — `reset` (discard the prior
763/// conversation + engine KV cache) → `load_prompt` (prefill) → `generate`
764/// (grammar mask → safety steer → guard + checkpointed rollback → greedy commit)
765/// — instead of rebuilding the engine and re-reading the GGUF every turn.
766/// On-device safety (ADR-005 `Lightweight` tier + the ADR-012 control loop) is
767/// **on by default**; see [`with_safety`](Self::with_safety). The resident model
768/// lives behind a `Mutex`, so the provider stays `Send + Sync` and concurrent
769/// `chat` calls serialize on the one conversation.
770pub struct QwenChatProvider {
771    tokenizer: Tokenizer,
772    permit: LoadPermit,
773    eos: Token,
774    default_max_tokens: u32,
775    model_label: String,
776    safety: SafetyConfig,
777    /// Optional safety **expert** GGUF for ADR-013 contrastive steering. `None`
778    /// runs the token-only `Lightweight` steerer. NOTE (ADR-018 follow-up): the
779    /// expert is still loaded per turn; persisting it like the base model is the
780    /// next increment. The default (no-expert) path is fully resident.
781    expert_model: Option<std::path::PathBuf>,
782    /// Contrastive steering strength ×1000 (1000 = 1.0×).
783    steer_alpha_milli: i32,
784    /// Resident model + reusable session (ADR-018).
785    session: std::sync::Mutex<ChatSession>,
786}
787
788impl QwenChatProvider {
789    /// Load a Qwen2 GGUF model and its `tokenizer.json` from local paths.
790    pub fn from_paths(
791        model_path: impl AsRef<std::path::Path>,
792        tokenizer_path: impl AsRef<std::path::Path>,
793    ) -> Result<Self> {
794        let model_path = model_path.as_ref().to_path_buf();
795        if !model_path.exists() {
796            return Err(EdgeError::Engine("model file not found"));
797        }
798        let tokenizer = Tokenizer::from_file(tokenizer_path.as_ref())
799            .map_err(|_| EdgeError::Engine("failed to load tokenizer.json"))?;
800
801        // Stop token: Qwen2.5 ChatML turn terminator (fallback to its known id).
802        let eos = tokenizer.token_to_id("<|im_end|>").unwrap_or(151_645);
803
804        let model_label = model_path
805            .file_stem()
806            .and_then(|s| s.to_str())
807            .map(|s| format!("local/{s}"))
808            .unwrap_or_else(|| "local/qwen2".to_string());
809
810        let safety = SafetyConfig::lightweight(&tokenizer);
811
812        let permit = local_load_permit(&model_path)?;
813
814        // ADR-018: load the weights ONCE here and keep them resident, instead of
815        // re-reading the GGUF on every `chat`. The first `chat` promotes this into
816        // a reusable session (see `ChatSession`).
817        let engine = QwenEngine::from_path(&model_path, eos)?;
818
819        Ok(Self {
820            tokenizer,
821            permit,
822            eos,
823            default_max_tokens: 512,
824            model_label,
825            safety,
826            expert_model: None,
827            steer_alpha_milli: 1000,
828            session: std::sync::Mutex::new(ChatSession::Loaded(engine)),
829        })
830    }
831
832    /// Select the on-device safety tier (ADR-005). [`SafetyMode::Off`] disables
833    /// the steerer and the ADR-012 control loop (the plain single-pass decode);
834    /// [`SafetyMode::Lightweight`] (the default) runs the token-anchor guard +
835    /// hard-ban steerer + checkpointed rollback. `SecDecoding`/`Csd` need model
836    /// assets not shipped here and fall back to the `Lightweight` wiring.
837    pub fn with_safety(mut self, mode: SafetyMode) -> Self {
838        self.safety.mode = mode;
839        self
840    }
841
842    /// Add extra words to the chunk guard's unsafe patterns (resolved to token
843    /// ids via this model's tokenizer). Primarily a **test/demo hook**: e.g.
844    /// `--guard-word banana` lets you watch the ADR-012 rollback / fail-closed
845    /// refusal fire on a benign word, without needing the model to emit genuinely
846    /// harmful content. Guard-only — these are not added to the hard-ban list, so
847    /// the trajectory loop (not silent suppression) is what engages.
848    pub fn with_extra_guard_words<I, S>(mut self, words: I) -> Self
849    where
850        I: IntoIterator<Item = S>,
851        S: AsRef<str>,
852    {
853        for word in words {
854            for seq in word_to_patterns(&self.tokenizer, word.as_ref()) {
855                if !self.safety.extra_guard_patterns.contains(&seq) {
856                    self.safety.extra_guard_patterns.push(seq);
857                }
858            }
859        }
860        self
861    }
862
863    /// Enable model-backed **contrastive** steering (ADR-013) with a safety
864    /// **expert** GGUF (same tokenizer/family as the chat model). Steering runs
865    /// only inside the early-token window. Pointing this at the chat model itself
866    /// gives ~zero contrast (a no-op); a safety-tuned Qwen GGUF gives real
867    /// steering. No effect under `--safety off`.
868    pub fn with_expert_model(mut self, path: impl AsRef<std::path::Path>) -> Self {
869        self.expert_model = Some(path.as_ref().to_path_buf());
870        self
871    }
872
873    /// Contrastive steering strength ×1000 (`1000` = 1.0×). Only meaningful with
874    /// [`with_expert_model`](Self::with_expert_model).
875    pub fn with_steer_alpha(mut self, alpha_milli: i32) -> Self {
876        self.steer_alpha_milli = alpha_milli;
877        self
878    }
879
880    /// End the current conversation, releasing its KV / output / prompt / buffered
881    /// events **while keeping the model resident** (ADR-018 separation of
882    /// conversation and model lifecycles; the AC-4 explicit release / PRD line 131
883    /// "KV caches … cleared on session end"). The next `chat` starts a fresh
884    /// conversation on the same loaded weights — no reload. A no-op if no
885    /// conversation has started yet. To free the weights too, drop the provider
886    /// (Rust ownership).
887    pub fn end_session(&self) -> Result<()> {
888        let mut cell = self
889            .session
890            .lock()
891            .map_err(|_| EdgeError::Engine("chat session mutex poisoned"))?;
892        if let ChatSession::Active(session) = &mut *cell {
893            session.close()?;
894        }
895        Ok(())
896    }
897
898    fn encode(&self, text: &str) -> Result<Vec<Token>> {
899        let enc = self
900            .tokenizer
901            .encode(text, false)
902            .map_err(|_| EdgeError::Engine("tokenizer encode failed"))?;
903        Ok(enc.get_ids().to_vec())
904    }
905
906    fn decode(&self, ids: &[Token]) -> Result<String> {
907        self.tokenizer
908            .decode(ids, true)
909            .map_err(|_| EdgeError::Engine("tokenizer decode failed"))
910    }
911}
912
913impl LlmProvider for QwenChatProvider {
914    fn chat(&self, req: &ChatRequest) -> Result<ChatResponse> {
915        let prompt = render_chatml(&req.messages);
916
917        let t_encode = bench::enabled().then(std::time::Instant::now);
918        let prompt_tokens = self.encode(&prompt)?;
919        let d_encode = t_encode.map(|t| t.elapsed()).unwrap_or_default();
920
921        // Carry the active safety tier on the session config so the runtime
922        // derives the tier-aware ADR-012 `RollbackPolicy` and records the true
923        // mode. A supplied expert promotes the tier to `SecDecoding`, so the
924        // runtime's `SafetyModeSelector` can gate it on device class instead of
925        // it masquerading as `Lightweight`. Both are deterministic from the
926        // builder-set config, so they are identical on every turn.
927        let requested = requested_session_safety(self.safety.mode, self.expert_model.is_some());
928        let cfg = SessionConfig {
929            safety: requested,
930            ..SessionConfig::default()
931        };
932        // Resolve the same effective tier the runtime will: only install the
933        // contrastive steerer if `SecDecoding` survives device selection (it
934        // downgrades to `Lightweight` on non-accelerator devices, where the
935        // expert is dropped — honest tier-aware behaviour).
936        let effective = SafetyModeSelector::resolve(requested, cfg.device);
937
938        // ADR-018: reuse the resident model. Lock the session cell; on first use
939        // promote the loaded weights into a reusable session (created with the now
940        // final builder config); every later turn reuses it — no disk reload.
941        let mut cell = self
942            .session
943            .lock()
944            .map_err(|_| EdgeError::Engine("chat session mutex poisoned"))?;
945        let t_load = bench::enabled().then(std::time::Instant::now);
946        if matches!(&*cell, ChatSession::Loaded(_)) {
947            let engine = match std::mem::replace(&mut *cell, ChatSession::Swapping) {
948                ChatSession::Loaded(e) => e,
949                _ => unreachable!("guarded by the matches! above"),
950            };
951            *cell = ChatSession::Active(InferenceSession::new(
952                SessionId(1),
953                cfg,
954                engine,
955                self.permit,
956            ));
957        }
958        let d_load = t_load.map(|t| t.elapsed()).unwrap_or_default();
959        let session = match &mut *cell {
960            ChatSession::Active(s) => s,
961            // `Swapping` only persists if a prior promotion panicked — in which
962            // case `lock()` above would already have failed on the poisoned mutex.
963            _ => return Err(EdgeError::Engine("chat session not initialized")),
964        };
965
966        // Discard the previous conversation and the engine's KV cache, then run
967        // this turn over the SAME resident weights (ADR-018) — no disk reload.
968        session.reset()?;
969        // Provider-owned turn isolation: drop any events buffered by a prior turn
970        // (e.g. one that errored before its end-of-turn drain) so this turn's
971        // safety count below cannot include another turn's stale violations.
972        // `reset()` deliberately preserves events (generic semantics); bounding
973        // them per turn is the reusing provider's job.
974        let _ = session.drain_events();
975        let mut ports = self.safety.ports();
976
977        if matches!(effective, SafetyMode::SecDecoding) {
978            if let Some(expert_path) = &self.expert_model {
979                let expert = QwenExpert::from_path_primed(
980                    expert_path,
981                    self.eos,
982                    &prompt_tokens,
983                    local_load_permit(expert_path)?,
984                )?;
985                ports.safety = Box::new(ContrastiveSteerer::new(
986                    expert,
987                    self.safety.banned.clone(),
988                    self.steer_alpha_milli,
989                    CONTRASTIVE_TOP_K,
990                    effective,
991                ));
992            }
993        }
994
995        let _ = bench::take(); // clear forward accumulators before prefill
996        let t_prefill = bench::enabled().then(std::time::Instant::now);
997        session.load_prompt(&ports, &prompt_tokens)?;
998        let d_prefill = t_prefill.map(|t| t.elapsed()).unwrap_or_default();
999        let (pf_total, pf_model, pf_calls) = bench::take();
1000
1001        let max = req.max_tokens.unwrap_or(self.default_max_tokens);
1002        let t_decode = bench::enabled().then(std::time::Instant::now);
1003        let stop = session.generate(&ports, max)?;
1004        let d_decode = t_decode.map(|t| t.elapsed()).unwrap_or_default();
1005        let (dc_total, dc_model, dc_calls) = bench::take();
1006
1007        let out = session.output().to_vec();
1008        let completion_tokens = out.len() as u32;
1009
1010        let t_detok = bench::enabled().then(std::time::Instant::now);
1011        let decoded = self.decode(&out)?.trim().to_string();
1012        let d_detok = t_detok.map(|t| t.elapsed()).unwrap_or_default();
1013
1014        // ADR-018: always drain — a persistent session would otherwise accumulate
1015        // events across turns. ADR-012: surface what the decode-time control loop
1016        // did. A fail-closed stop (rollbacks exhausted / no safe checkpoint)
1017        // returns the deterministic refusal rather than the truncated unsafe
1018        // prefix; any intervention is reported on stderr so the test client can
1019        // show the guard working without corrupting the reply on stdout.
1020        let events = session.drain_events();
1021        let safety_active = !matches!(self.safety.mode, SafetyMode::Off);
1022        let content = if safety_active {
1023            let violations = events
1024                .iter()
1025                .filter(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. }))
1026                .count();
1027            let rollbacks = events
1028                .iter()
1029                .filter(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. }))
1030                .count();
1031            let refused = stop == StopReason::Stopped && violations > 0;
1032            if violations > 0 || rollbacks > 0 {
1033                eprintln!(
1034                    "[safety] {violations} violation(s), {rollbacks} rollback(s){}",
1035                    if refused {
1036                        " → refused (fail-closed)"
1037                    } else {
1038                        " → recovered"
1039                    }
1040                );
1041            }
1042            if refused {
1043                SAFETY_REFUSAL.to_string()
1044            } else {
1045                decoded
1046            }
1047        } else {
1048            decoded
1049        };
1050
1051        if bench::enabled() {
1052            report_breakdown(
1053                prompt_tokens.len() as u32,
1054                completion_tokens,
1055                d_load,
1056                d_encode,
1057                d_prefill,
1058                d_decode,
1059                d_detok,
1060                (pf_total, pf_model, pf_calls),
1061                (dc_total, dc_model, dc_calls),
1062            );
1063        }
1064
1065        Ok(ChatResponse {
1066            content,
1067            model: self.model_label.clone(),
1068            prompt_tokens: prompt_tokens.len() as u32,
1069            completion_tokens,
1070        })
1071    }
1072
1073    fn chat_stream(&self, req: &ChatRequest, on_token: &mut dyn FnMut(ChatToken)) -> Result<()> {
1074        // The runtime decode loop runs to completion internally (no per-token
1075        // hook), so — like the toy `LocalLlmProvider` — we stream the finished
1076        // reply out character by character.
1077        let resp = self.chat(req)?;
1078        for ch in resp.content.chars() {
1079            on_token(ChatToken {
1080                text: ch.to_string(),
1081                is_final: false,
1082            });
1083        }
1084        on_token(ChatToken {
1085            text: String::new(),
1086            is_final: true,
1087        });
1088        Ok(())
1089    }
1090}
1091
1092/// Print an `EL_BENCH` per-phase + per-forward breakdown for one `chat()` call.
1093#[allow(clippy::too_many_arguments)]
1094fn report_breakdown(
1095    prompt_tokens: u32,
1096    completion_tokens: u32,
1097    d_load: std::time::Duration,
1098    d_encode: std::time::Duration,
1099    d_prefill: std::time::Duration,
1100    d_decode: std::time::Duration,
1101    d_detok: std::time::Duration,
1102    prefill_fwd: (std::time::Duration, std::time::Duration, u64),
1103    decode_fwd: (std::time::Duration, std::time::Duration, u64),
1104) {
1105    let ms = |d: std::time::Duration| d.as_secs_f64() * 1000.0;
1106    let total = d_load + d_encode + d_prefill + d_decode + d_detok;
1107    let pct = |d: std::time::Duration| {
1108        if total.as_secs_f64() > 0.0 {
1109            d.as_secs_f64() / total.as_secs_f64() * 100.0
1110        } else {
1111            0.0
1112        }
1113    };
1114    let tps = |n: u32, d: std::time::Duration| {
1115        if d.as_secs_f64() > 0.0 {
1116            n as f64 / d.as_secs_f64()
1117        } else {
1118            0.0
1119        }
1120    };
1121
1122    let (pf_total, pf_model, pf_calls) = prefill_fwd;
1123    let (dc_total, dc_model, dc_calls) = decode_fwd;
1124    let dc_loop = d_decode.saturating_sub(dc_total);
1125    let dc_seam = dc_total.saturating_sub(dc_model);
1126    let per_tok = |d: std::time::Duration, n: u64| if n > 0 { ms(d) / n as f64 } else { 0.0 };
1127
1128    eprintln!("\n┌─ EL_BENCH chat() breakdown ───────────────────────────────");
1129    eprintln!("│ prompt_tokens={prompt_tokens}  completion_tokens={completion_tokens}");
1130    eprintln!("│ phase           wall(ms)    %total   throughput");
1131    eprintln!(
1132        "│ session setup {:>9.1}  {:>6.1}%   (weights loaded once at startup — ADR-018)",
1133        ms(d_load),
1134        pct(d_load)
1135    );
1136    eprintln!(
1137        "│ tokenize       {:>9.2}  {:>6.1}%",
1138        ms(d_encode),
1139        pct(d_encode)
1140    );
1141    eprintln!(
1142        "│ prefill       {:>9.1}  {:>6.1}%   {:>7.1} tok/s",
1143        ms(d_prefill),
1144        pct(d_prefill),
1145        tps(prompt_tokens, d_prefill)
1146    );
1147    eprintln!(
1148        "│ decode        {:>9.1}  {:>6.1}%   {:>7.1} tok/s",
1149        ms(d_decode),
1150        pct(d_decode),
1151        tps(completion_tokens, d_decode)
1152    );
1153    eprintln!(
1154        "│ detokenize     {:>9.2}  {:>6.1}%",
1155        ms(d_detok),
1156        pct(d_detok)
1157    );
1158    eprintln!("│ TOTAL         {:>9.1}", ms(total));
1159    eprintln!("│ ─ forward attribution (where prefill+decode time goes) ─");
1160    eprintln!(
1161        "│ prefill: {} fwd calls, model {:.1}ms, seam {:.1}ms, loop {:.1}ms",
1162        pf_calls,
1163        ms(pf_model),
1164        ms(pf_total.saturating_sub(pf_model)),
1165        ms(d_prefill.saturating_sub(pf_total)),
1166    );
1167    eprintln!(
1168        "│ decode : {} fwd calls, model {:.1}ms, seam {:.1}ms, loop {:.1}ms",
1169        dc_calls,
1170        ms(dc_model),
1171        ms(dc_seam),
1172        ms(dc_loop),
1173    );
1174    eprintln!(
1175        "│ per decoded token: {:.2}ms total = model {:.2} + seam {:.2} + loop {:.2}",
1176        per_tok(d_decode, dc_calls),
1177        per_tok(dc_model, dc_calls),
1178        per_tok(dc_seam, dc_calls),
1179        per_tok(dc_loop, dc_calls),
1180    );
1181    eprintln!("└───────────────────────────────────────────────────────────");
1182}
1183
1184/// Render a conversation as Qwen2.5 ChatML and open an assistant turn.
1185fn render_chatml(messages: &[ChatMessage]) -> String {
1186    let mut s = String::new();
1187    for m in messages {
1188        let role = match m.role {
1189            ChatRole::System => "system",
1190            ChatRole::User => "user",
1191            ChatRole::Assistant => "assistant",
1192        };
1193        s.push_str("<|im_start|>");
1194        s.push_str(role);
1195        s.push('\n');
1196        s.push_str(&m.content);
1197        s.push_str("<|im_end|>\n");
1198    }
1199    s.push_str("<|im_start|>assistant\n");
1200    s
1201}
1202
1203fn requested_session_safety(configured: SafetyMode, has_expert: bool) -> SafetyMode {
1204    match (configured, has_expert) {
1205        (SafetyMode::Off, _) => SafetyMode::Off,
1206        // A supplied expert is the only backed SecDecoding implementation in
1207        // this adapter. Promote any non-Off configured tier to that concrete
1208        // model-backed path so the runtime selector can gate it by device.
1209        (_, true) => SafetyMode::SecDecoding,
1210        // These public enum variants are not backed here without an expert.
1211        // Keep telemetry/policy honest by reflecting the lightweight ports that
1212        // will actually be installed.
1213        (SafetyMode::SecDecoding | SafetyMode::Csd, false) => SafetyMode::Lightweight,
1214        (mode, false) => mode,
1215    }
1216}
1217
1218/// Obtain a [`LoadPermit`] through the ADR-006 gate for a user-supplied local
1219/// model. There is no detached signature to check for a file the user downloaded
1220/// themselves, so this uses a trust-the-local-file verifier. This is explicitly
1221/// **not** cryptographic integrity over the GGUF bytes; production signed assets
1222/// must use a separate verifier path that reads the whole artifact and verifies
1223/// its detached signature before issuing a permit.
1224fn local_load_permit(path: &std::path::Path) -> Result<LoadPermit> {
1225    struct LocalFileTrust;
1226    impl SignatureVerifier for LocalFileTrust {
1227        fn verify(&self, _bytes: &[u8], _sig: &[u8], _key: u32) -> bool {
1228            true
1229        }
1230    }
1231    // Keep the local-trust path cheap: it proves callers go through the permit
1232    // gate, while deliberately avoiding fake "verification" of path strings or
1233    // header fragments that could be mistaken for artifact integrity.
1234    let _ = path;
1235    let mut artifact = ModelArtifact::new(
1236        ModelId(1),
1237        ModelVersion::new(0, 1, 0),
1238        el_core::ModelFormat::Gguf,
1239    );
1240    artifact.verify(&LocalFileTrust, b"local-trust", b"", 0);
1241    artifact.ensure_loadable()
1242}
1243
1244#[cfg(test)]
1245mod tests {
1246    use super::*;
1247    use el_runtime::InferenceEngine;
1248
1249    // ── helpers ──────────────────────────────────────────────────────────────
1250
1251    fn ok_permit() -> LoadPermit {
1252        use el_core::{ModelFormat, ModelId, ModelVersion};
1253        use el_provenance::{ModelArtifact, SignatureVerifier};
1254        struct OkV;
1255        impl SignatureVerifier for OkV {
1256            fn verify(&self, _: &[u8], _: &[u8], _: u32) -> bool {
1257                true
1258            }
1259        }
1260        let mut a = ModelArtifact::new(ModelId(1), ModelVersion::new(0, 1, 0), ModelFormat::Gguf);
1261        a.verify(&OkV, b"w", b"s", 0);
1262        a.ensure_loadable().unwrap()
1263    }
1264
1265    /// Build a minimal but spec-compliant GGUF v3 file in memory.
1266    ///
1267    /// Layout:  no KV metadata, two F32 tensors:
1268    ///   `token_embd.weight`  [vocab, dim]  at offset 0
1269    ///   `output.weight`      [vocab, dim]  at offset vocab*dim*4
1270    ///
1271    /// GGUF stores dimensions innermost-first; candle reverses them on read.
1272    fn make_minimal_gguf(vocab: usize, dim: usize) -> Vec<u8> {
1273        let mut w: Vec<u8> = Vec::new();
1274
1275        // Header
1276        w.extend_from_slice(b"GGUF");
1277        w.extend_from_slice(&3u32.to_le_bytes()); // version 3
1278        w.extend_from_slice(&2u64.to_le_bytes()); // n_tensors
1279        w.extend_from_slice(&0u64.to_le_bytes()); // n_kv (none)
1280
1281        let tensor_bytes = (vocab * dim * 4) as u64;
1282
1283        // token_embd.weight: [vocab, dim] → GGUF dims [dim, vocab]
1284        let name = b"token_embd.weight";
1285        w.extend_from_slice(&(name.len() as u64).to_le_bytes());
1286        w.extend_from_slice(name);
1287        w.extend_from_slice(&2u32.to_le_bytes());
1288        w.extend_from_slice(&(dim as u64).to_le_bytes()); // innermost
1289        w.extend_from_slice(&(vocab as u64).to_le_bytes()); // outermost
1290        w.extend_from_slice(&0u32.to_le_bytes()); // F32
1291        w.extend_from_slice(&0u64.to_le_bytes()); // offset 0
1292
1293        // output.weight: [vocab, dim] → GGUF dims [dim, vocab]; loader will transpose
1294        let name = b"output.weight";
1295        w.extend_from_slice(&(name.len() as u64).to_le_bytes());
1296        w.extend_from_slice(name);
1297        w.extend_from_slice(&2u32.to_le_bytes());
1298        w.extend_from_slice(&(dim as u64).to_le_bytes());
1299        w.extend_from_slice(&(vocab as u64).to_le_bytes());
1300        w.extend_from_slice(&0u32.to_le_bytes());
1301        w.extend_from_slice(&tensor_bytes.to_le_bytes()); // offset after embed
1302
1303        // Pad to 32-byte alignment
1304        let pad = (32usize.wrapping_sub(w.len() % 32)) % 32;
1305        w.resize(w.len() + pad, 0u8);
1306
1307        // Tensor data (both tensors, row-major f32)
1308        for i in 0..(vocab * dim * 2) {
1309            w.extend_from_slice(&(i as f32 * 0.1f32).to_le_bytes());
1310        }
1311
1312        w
1313    }
1314
1315    // ── toy-model tests (unchanged) ──────────────────────────────────────────
1316
1317    #[test]
1318    fn real_candle_forward_is_deterministic_and_right_shape() {
1319        let mut eng = CandleEngine::toy(8, 4, 7).unwrap();
1320        let a = eng.next_logits(&[2]);
1321        let b = eng.next_logits(&[2]);
1322        assert_eq!(a.len(), 8, "logits length == vocab");
1323        assert_eq!(a, b, "fixed weights → deterministic real-tensor forward");
1324        let c = eng.next_logits(&[5]);
1325        assert_ne!(a, c);
1326    }
1327
1328    #[test]
1329    fn drives_the_runtime_end_to_end() {
1330        use el_core::{ModelFormat, ModelId, ModelVersion, SessionConfig, SessionId, StopReason};
1331        use el_provenance::{ModelArtifact, SignatureVerifier};
1332
1333        struct OkVerifier;
1334        impl SignatureVerifier for OkVerifier {
1335            fn verify(&self, _: &[u8], _: &[u8], _: u32) -> bool {
1336                true
1337            }
1338        }
1339        let mut art = ModelArtifact::new(
1340            ModelId(1),
1341            ModelVersion::new(0, 1, 0),
1342            ModelFormat::Safetensors,
1343        );
1344        art.verify(&OkVerifier, b"w", b"s", 1);
1345        let permit = art.ensure_loadable().unwrap();
1346
1347        let eng = CandleEngine::toy(16, 8, 9999).unwrap();
1348        let mut session =
1349            InferenceSession::new(SessionId(1), SessionConfig::default(), eng, permit);
1350        let ports = Ports::permissive();
1351        session.load_prompt(&ports, &[1, 2, 3]).unwrap();
1352
1353        let stop = session.generate(&ports, 4).unwrap();
1354        assert_eq!(stop, StopReason::MaxTokens);
1355        assert_eq!(session.output().len(), 4);
1356    }
1357
1358    // ── GGUF loading tests ───────────────────────────────────────────────────
1359
1360    #[test]
1361    fn from_bytes_rejects_invalid_magic() {
1362        let r = CandleEngine::from_bytes(b"not a gguf file", 0);
1363        assert!(matches!(r, Err(EdgeError::Engine(_))));
1364    }
1365
1366    #[test]
1367    fn from_bytes_loads_minimal_gguf_and_forward_has_correct_vocab() {
1368        let vocab = 8;
1369        let dim = 4;
1370        let gguf = make_minimal_gguf(vocab, dim);
1371        let mut engine = CandleEngine::from_bytes(&gguf, 7).unwrap();
1372
1373        let logits = engine.next_logits(&[0]);
1374        assert_eq!(logits.len(), vocab, "logit vec width == vocab from GGUF");
1375        assert_eq!(engine.eos_token(), 7);
1376    }
1377
1378    #[test]
1379    fn from_bytes_gguf_forward_is_deterministic() {
1380        let gguf = make_minimal_gguf(8, 4);
1381        let mut eng = CandleEngine::from_bytes(&gguf, 0).unwrap();
1382        assert_eq!(eng.next_logits(&[3]), eng.next_logits(&[3]));
1383    }
1384
1385    /// Same as `make_minimal_gguf` but `output.weight` has `wrong_dim` instead of `dim`,
1386    /// so the embed / output dimensions are incompatible.
1387    fn make_mismatched_gguf(vocab: usize, embed_dim: usize, output_dim: usize) -> Vec<u8> {
1388        let mut w: Vec<u8> = Vec::new();
1389        w.extend_from_slice(b"GGUF");
1390        w.extend_from_slice(&3u32.to_le_bytes());
1391        w.extend_from_slice(&2u64.to_le_bytes());
1392        w.extend_from_slice(&0u64.to_le_bytes());
1393
1394        let embed_bytes = (vocab * embed_dim * 4) as u64;
1395
1396        let name = b"token_embd.weight";
1397        w.extend_from_slice(&(name.len() as u64).to_le_bytes());
1398        w.extend_from_slice(name);
1399        w.extend_from_slice(&2u32.to_le_bytes());
1400        w.extend_from_slice(&(embed_dim as u64).to_le_bytes());
1401        w.extend_from_slice(&(vocab as u64).to_le_bytes());
1402        w.extend_from_slice(&0u32.to_le_bytes());
1403        w.extend_from_slice(&0u64.to_le_bytes());
1404
1405        let name = b"output.weight";
1406        w.extend_from_slice(&(name.len() as u64).to_le_bytes());
1407        w.extend_from_slice(name);
1408        w.extend_from_slice(&2u32.to_le_bytes());
1409        w.extend_from_slice(&(output_dim as u64).to_le_bytes()); // wrong dim
1410        w.extend_from_slice(&(vocab as u64).to_le_bytes());
1411        w.extend_from_slice(&0u32.to_le_bytes());
1412        w.extend_from_slice(&embed_bytes.to_le_bytes());
1413
1414        let pad = (32usize.wrapping_sub(w.len() % 32)) % 32;
1415        w.resize(w.len() + pad, 0u8);
1416
1417        for i in 0..(vocab * embed_dim + vocab * output_dim) {
1418            w.extend_from_slice(&(i as f32 * 0.1f32).to_le_bytes());
1419        }
1420        w
1421    }
1422
1423    #[test]
1424    fn from_path_missing_file_returns_engine_error() {
1425        let r = CandleEngine::from_path(std::path::Path::new("/nonexistent/model.gguf"), 0);
1426        assert!(matches!(r, Err(EdgeError::Engine(_))));
1427    }
1428
1429    #[test]
1430    fn from_bytes_rejects_mismatched_output_dim_at_load_time() {
1431        // embed dim=4, output dim=7 — incompatible; must error at load, not silently at forward.
1432        let gguf = make_mismatched_gguf(8, 4, 7);
1433        let r = CandleEngine::from_bytes(&gguf, 0);
1434        assert!(
1435            matches!(r, Err(EdgeError::Engine(_))),
1436            "mismatched output weight dim must be rejected at load time"
1437        );
1438    }
1439
1440    // ── LocalLlmProvider tests (unchanged + new from_path error path) ────────
1441
1442    #[test]
1443    fn local_provider_chat_returns_response() {
1444        let p = LocalLlmProvider::toy(32, 8, 31, ok_permit()).unwrap();
1445        let req = el_core::ChatRequest::new("local", vec![el_core::ChatMessage::user("hello")])
1446            .with_max_tokens(4);
1447        let resp = p.chat(&req).unwrap();
1448        assert_eq!(resp.model, "local/candle");
1449        assert_eq!(resp.completion_tokens, 4);
1450        assert!(!resp.content.is_empty());
1451    }
1452
1453    #[test]
1454    fn local_provider_stream_ends_with_final_token() {
1455        let p = LocalLlmProvider::toy(32, 8, 31, ok_permit()).unwrap();
1456        let req = el_core::ChatRequest::new("local", vec![el_core::ChatMessage::user("hi")])
1457            .with_max_tokens(3);
1458        let mut tokens: Vec<el_core::ChatToken> = Vec::new();
1459        p.chat_stream(&req, &mut |t| tokens.push(t)).unwrap();
1460        assert!(tokens.last().unwrap().is_final);
1461        assert!(tokens.len() > 1);
1462    }
1463
1464    #[test]
1465    fn local_provider_session_resets_between_calls() {
1466        let p = LocalLlmProvider::toy(32, 8, 31, ok_permit()).unwrap();
1467        let req = el_core::ChatRequest::new("local", vec![el_core::ChatMessage::user("a")])
1468            .with_max_tokens(4);
1469        let r1 = p.chat(&req).unwrap();
1470        let r2 = p.chat(&req).unwrap();
1471        assert_eq!(r1.content, r2.content);
1472    }
1473
1474    #[test]
1475    fn local_provider_from_path_missing_file_returns_error() {
1476        let r = LocalLlmProvider::from_path(
1477            std::path::Path::new("/nonexistent/model.gguf"),
1478            0,
1479            ok_permit(),
1480        );
1481        assert!(matches!(r, Err(EdgeError::Engine(_))));
1482    }
1483
1484    // ── Qwen provider helpers ─────────────────────────────────────────────────
1485
1486    #[test]
1487    fn render_chatml_wraps_each_turn_and_opens_assistant() {
1488        let msgs = vec![
1489            ChatMessage::system("be nice"),
1490            ChatMessage::user("hi"),
1491            ChatMessage::assistant("hello"),
1492            ChatMessage::user("bye"),
1493        ];
1494        let got = render_chatml(&msgs);
1495        let want = "<|im_start|>system\nbe nice<|im_end|>\n\
1496                    <|im_start|>user\nhi<|im_end|>\n\
1497                    <|im_start|>assistant\nhello<|im_end|>\n\
1498                    <|im_start|>user\nbye<|im_end|>\n\
1499                    <|im_start|>assistant\n";
1500        assert_eq!(got, want);
1501    }
1502
1503    #[test]
1504    fn local_load_permit_passes_the_provenance_gate() {
1505        // The runtime requires a LoadPermit; the local-trust path must yield one
1506        // for a GGUF artifact (ADR-006 gate exercised, not bypassed).
1507        let permit = local_load_permit(std::path::Path::new("models/qwen.gguf"))
1508            .expect("local permit issued");
1509        assert_eq!(permit.format, el_core::ModelFormat::Gguf);
1510    }
1511
1512    #[test]
1513    fn requested_safety_matches_the_backed_steerer_surface() {
1514        assert_eq!(
1515            requested_session_safety(SafetyMode::Off, true),
1516            SafetyMode::Off,
1517            "Off must stay off even if an expert path is configured"
1518        );
1519        assert_eq!(
1520            requested_session_safety(SafetyMode::Lightweight, true),
1521            SafetyMode::SecDecoding,
1522            "an expert promotes the concrete model-backed path"
1523        );
1524        assert_eq!(
1525            requested_session_safety(SafetyMode::SecDecoding, false),
1526            SafetyMode::Lightweight,
1527            "unbacked SecDecoding must not be reported as active"
1528        );
1529        assert_eq!(
1530            requested_session_safety(SafetyMode::Csd, false),
1531            SafetyMode::Lightweight,
1532            "unbacked Csd must not be reported as active"
1533        );
1534    }
1535
1536    #[test]
1537    fn qwen_provider_from_paths_missing_model_errors() {
1538        let r = QwenChatProvider::from_paths(
1539            std::path::Path::new("/nonexistent/model.gguf"),
1540            std::path::Path::new("/nonexistent/tokenizer.json"),
1541        );
1542        assert!(matches!(r, Err(EdgeError::Engine(_))));
1543    }
1544
1545    // ── safety wiring (ADR-005 tier + ADR-012 control loop) ──────────────────
1546
1547    #[test]
1548    fn safety_off_wires_no_guard_or_steering() {
1549        // Off → the plain single-pass decode: `Ports::permissive()` semantics
1550        // regardless of any resolved bans/patterns.
1551        let cfg = SafetyConfig {
1552            mode: SafetyMode::Off,
1553            banned: vec![1],
1554            patterns: vec![vec![2]],
1555            extra_guard_patterns: vec![],
1556        };
1557        let ports = cfg.ports();
1558        assert!(ports.guard.is_none(), "Off must not wire the chunk guard");
1559        assert!(ports.ingress.is_none(), "Off must not wire ingress triage");
1560        assert_eq!(
1561            ports.safety.mode(),
1562            SafetyMode::Off,
1563            "Off must keep the no-op steerer"
1564        );
1565    }
1566
1567    #[test]
1568    fn lightweight_wires_guard_and_hard_ban_steerer() {
1569        let cfg = SafetyConfig {
1570            mode: SafetyMode::Lightweight,
1571            banned: vec![1],
1572            patterns: vec![vec![2, 3]],
1573            extra_guard_patterns: vec![],
1574        };
1575        let ports = cfg.ports();
1576        assert!(
1577            ports.guard.is_some(),
1578            "Lightweight must wire the chunk guard"
1579        );
1580        assert!(
1581            ports.ingress.is_some(),
1582            "Lightweight must wire prompt ingress triage (ADR-013)"
1583        );
1584        assert_eq!(
1585            ports.safety.mode(),
1586            SafetyMode::Lightweight,
1587            "a non-empty ban list selects the LightweightFilter steerer"
1588        );
1589    }
1590
1591    #[test]
1592    fn lightweight_without_patterns_has_no_guard_or_ingress() {
1593        // No resolvable unsafe patterns (e.g. all multi-token and tokenizer
1594        // produced nothing) → guard/ingress stay off; the per-step ban can still
1595        // apply.
1596        let cfg = SafetyConfig {
1597            mode: SafetyMode::Lightweight,
1598            banned: vec![7],
1599            patterns: vec![],
1600            extra_guard_patterns: vec![],
1601        };
1602        let ports = cfg.ports();
1603        assert!(ports.guard.is_none());
1604        assert!(ports.ingress.is_none());
1605    }
1606
1607    #[test]
1608    fn extra_guard_words_drive_guard_but_not_ingress() {
1609        // Regression (review P2): --guard-word extras must NOT trigger ingress
1610        // refusal, or the documented rollback demo would refuse before decoding.
1611        let cfg = SafetyConfig {
1612            mode: SafetyMode::Lightweight,
1613            banned: vec![],
1614            patterns: vec![],                     // no built-in unsafe terms
1615            extra_guard_patterns: vec![vec![42]], // a --guard-word trip token
1616        };
1617        let ports = cfg.ports();
1618        assert!(
1619            ports.guard.is_some(),
1620            "extra guard words must drive the output guard"
1621        );
1622        assert!(
1623            ports.ingress.is_none(),
1624            "extra guard words must NOT drive ingress (trajectory demo, not refusal)"
1625        );
1626    }
1627
1628    #[test]
1629    fn qwen_expert_missing_file_errors_and_is_permit_gated() {
1630        // R5: the expert load requires an ADR-006 permit (required arg) and a
1631        // missing file is rejected, not silently ignored.
1632        let r = QwenExpert::from_path_primed(
1633            std::path::Path::new("/nonexistent/expert.gguf"),
1634            0,
1635            &[1, 2],
1636            ok_permit(),
1637        );
1638        assert!(matches!(r, Err(EdgeError::Engine(_))));
1639    }
1640}