1#![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
28pub struct CandleEngine {
30 embed: Tensor,
31 w_out: Tensor,
32 vocab: usize,
33 eos: Token,
34}
35
36impl CandleEngine {
37 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 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 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 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 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 fn forward(&self, last: usize) -> candle_core::Result<Vec<f32>> {
150 let row = self.embed.narrow(0, last, 1)?; let logits = row.matmul(&self.w_out)?; 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 fn rollback(&mut self, _keep_committed: u32) -> Result<()> {
180 Ok(())
181 }
182
183 fn reset_cache(&mut self) -> Result<()> {
185 Ok(())
186 }
187}
188
189pub struct LocalLlmProvider {
195 session: std::sync::Mutex<InferenceSession<CandleEngine>>,
196 vocab: usize,
197}
198
199impl LocalLlmProvider {
200 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 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(); 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
302use candle_transformers::models::quantized_qwen2::ModelWeights as Qwen2Weights;
311use el_core::{ModelId, ModelVersion};
312use el_provenance::{ModelArtifact, SignatureVerifier};
313use tokenizers::Tokenizer;
314
315mod 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 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 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 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
357pub struct QwenEngine {
375 model: Qwen2Weights,
376 device: Device,
377 index_pos: usize,
379 fed: usize,
381 prompt: Vec<Token>,
384 last_logits: Vec<i32>,
386 vocab: usize,
387 eos: Token,
388 cache_dirty: bool,
394}
395
396impl QwenEngine {
397 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 fn forward_one(&mut self, token: Token) -> Result<Vec<i32>> {
423 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(); 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 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 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 fn reset_cache(&mut self) -> Result<()> {
521 if self.cache_dirty {
522 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 self.prompt = Vec::new();
537 self.last_logits = Vec::new();
538 Ok(())
539 }
540}
541
542const DEFAULT_UNSAFE_WORDS: &[&str] = &[
558 "bomb",
559 "explosive",
560 "detonator",
561 "methamphetamine",
562 "ricin",
563 "anthrax",
564 "sarin",
565 "nerve agent",
566];
567
568const SAFETY_REFUSAL: &str = "I can't help with that request.";
572
573const CONTRASTIVE_TOP_K: usize = 64;
577
578fn 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#[derive(Debug, Clone)]
601struct SafetyConfig {
602 mode: SafetyMode,
604 banned: Vec<Token>,
608 patterns: Vec<Vec<Token>>,
611 extra_guard_patterns: Vec<Vec<Token>>,
616}
617
618impl SafetyConfig {
619 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 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 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 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 if !self.patterns.is_empty() {
671 ports.ingress = Some(Box::new(AnchorGuard::hard(self.patterns.clone())));
672 }
673 ports
674 }
675}
676
677pub struct QwenExpert {
692 engine: std::cell::RefCell<QwenEngine>,
693 fed: std::cell::Cell<usize>,
696 _permit: LoadPermit,
699}
700
701impl QwenExpert {
702 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 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
741enum ChatSession {
749 Loaded(QwenEngine),
751 Active(InferenceSession<QwenEngine>),
753 Swapping,
755}
756
757pub struct QwenChatProvider {
771 tokenizer: Tokenizer,
772 permit: LoadPermit,
773 eos: Token,
774 default_max_tokens: u32,
775 model_label: String,
776 safety: SafetyConfig,
777 expert_model: Option<std::path::PathBuf>,
782 steer_alpha_milli: i32,
784 session: std::sync::Mutex<ChatSession>,
786}
787
788impl QwenChatProvider {
789 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 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 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 pub fn with_safety(mut self, mode: SafetyMode) -> Self {
838 self.safety.mode = mode;
839 self
840 }
841
842 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 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 pub fn with_steer_alpha(mut self, alpha_milli: i32) -> Self {
876 self.steer_alpha_milli = alpha_milli;
877 self
878 }
879
880 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 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 let effective = SafetyModeSelector::resolve(requested, cfg.device);
937
938 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 _ => return Err(EdgeError::Engine("chat session not initialized")),
964 };
965
966 session.reset()?;
969 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(); 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 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 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#[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
1184fn 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 (_, true) => SafetyMode::SecDecoding,
1210 (SafetyMode::SecDecoding | SafetyMode::Csd, false) => SafetyMode::Lightweight,
1214 (mode, false) => mode,
1215 }
1216}
1217
1218fn 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 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 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 fn make_minimal_gguf(vocab: usize, dim: usize) -> Vec<u8> {
1273 let mut w: Vec<u8> = Vec::new();
1274
1275 w.extend_from_slice(b"GGUF");
1277 w.extend_from_slice(&3u32.to_le_bytes()); w.extend_from_slice(&2u64.to_le_bytes()); w.extend_from_slice(&0u64.to_le_bytes()); let tensor_bytes = (vocab * dim * 4) as u64;
1282
1283 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()); w.extend_from_slice(&(vocab as u64).to_le_bytes()); w.extend_from_slice(&0u32.to_le_bytes()); w.extend_from_slice(&0u64.to_le_bytes()); 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()); let pad = (32usize.wrapping_sub(w.len() % 32)) % 32;
1305 w.resize(w.len() + pad, 0u8);
1306
1307 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 #[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 #[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 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()); 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 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 #[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 #[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 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 #[test]
1548 fn safety_off_wires_no_guard_or_steering() {
1549 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 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 let cfg = SafetyConfig {
1612 mode: SafetyMode::Lightweight,
1613 banned: vec![],
1614 patterns: vec![], extra_guard_patterns: vec![vec![42]], };
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 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}