1use crate::ports::{InferenceEngine, Ports};
4use el_core::{
5 DegradeReason, DomainEvent, EdgeError, EventEnvelope, Phase, Result, SessionConfig, SessionId,
6 StopReason, Token,
7};
8use el_memory::KvRegion;
9use el_provenance::LoadPermit;
10use el_safety::{
11 Checkpoint, CheckpointManager, LogitAdjustment, RollbackPolicy, SafetyModeSelector,
12};
13
14const MIN_CHECKPOINT_BUDGET_BYTES: u64 = 64 * 1024 * 1024;
18
19pub struct InferenceSession<E: InferenceEngine> {
23 id: SessionId,
24 config: SessionConfig,
25 phase: Phase,
26 engine: E,
27 kv: KvRegion,
28 permit: LoadPermit,
29 prompt: Vec<Token>,
32 output: Vec<Token>,
33 step: u32,
34 events: Vec<EventEnvelope>,
35}
36
37enum GuardVerdict {
39 Pass,
41 RolledBack,
43 FailClosed,
45}
46
47struct GuardState {
50 checkpoints: CheckpointManager,
51 rollback_count: u8,
52 banned: Vec<Token>,
53 start_out: u32,
56 start_kv: u32,
57}
58
59impl<E: InferenceEngine> InferenceSession<E> {
60 pub fn new(id: SessionId, config: SessionConfig, engine: E, permit: LoadPermit) -> Self {
61 let mut s = Self {
62 id,
63 config,
64 phase: Phase::Initialized,
65 engine,
66 kv: KvRegion::new(),
67 permit,
68 prompt: Vec::new(),
69 output: Vec::new(),
70 step: 0,
71 events: Vec::new(),
72 };
73 s.emit(DomainEvent::SessionInitialized {
74 runtime: config.format.runtime(),
75 device: config.device,
76 safety: config.safety,
77 speculation: config.speculation,
78 });
79 s.emit(DomainEvent::ModelLoaded {
80 model: permit.model,
81 version: permit.version,
82 format: permit.format,
83 });
84 s
85 }
86
87 pub fn phase(&self) -> Phase {
88 self.phase
89 }
90 pub fn output(&self) -> &[Token] {
91 &self.output
92 }
93 pub fn kv_len(&self) -> u32 {
94 self.kv.len()
95 }
96 pub fn config(&self) -> &SessionConfig {
97 &self.config
98 }
99 pub fn permit(&self) -> LoadPermit {
102 self.permit
103 }
104 pub fn drain_events(&mut self) -> Vec<EventEnvelope> {
107 std::mem::take(&mut self.events)
108 }
109
110 fn emit(&mut self, event: DomainEvent) {
111 self.events
112 .push(EventEnvelope::new(self.id, self.step, event));
113 }
114
115 pub fn load_prompt(&mut self, ports: &Ports, prompt: &[Token]) -> Result<()> {
117 if self.phase != Phase::Initialized {
118 return Err(EdgeError::InvalidPhase {
119 expected: "Initialized",
120 found: self.phase.as_str(),
121 });
122 }
123
124 self.prompt = prompt.to_vec();
127
128 let compressed = if self.config.compress {
129 ports.compressor.compress(prompt)
130 } else {
131 prompt.to_vec()
132 };
133 if compressed.len() < prompt.len() {
134 let ratio_milli =
135 ((compressed.len() as u64 * 1000) / (prompt.len().max(1) as u64)) as u32;
136 self.emit(DomainEvent::PromptCompressed {
137 input_tokens: prompt.len() as u32,
138 output_tokens: compressed.len() as u32,
139 ratio_milli,
140 });
141 }
142
143 self.phase = Phase::Prefilling;
144 let kv_len = self.engine.prefill(&compressed)?;
145 for _ in 0..kv_len {
146 let off = self.kv.len() as u64;
147 self.kv.push(off);
148 }
149 self.emit(DomainEvent::PrefillCompleted {
150 prompt_tokens: compressed.len() as u32,
151 kv_len,
152 prefill_tps: 0,
153 });
154 self.phase = Phase::Decoding;
155 Ok(())
156 }
157
158 pub fn generate(&mut self, ports: &Ports, max_tokens: u32) -> Result<StopReason> {
161 let effective = SafetyModeSelector::resolve(self.config.safety, self.config.device);
166 self.emit(DomainEvent::SafetyModeSelected { mode: effective });
167 let policy = RollbackPolicy::for_device(self.config.device, effective);
168 self.generate_with_policy(ports, max_tokens, policy)
169 }
170
171 pub fn generate_with_policy(
191 &mut self,
192 ports: &Ports,
193 max_tokens: u32,
194 policy: RollbackPolicy,
195 ) -> Result<StopReason> {
196 if self.phase != Phase::Decoding {
197 return Err(EdgeError::InvalidPhase {
198 expected: "Decoding",
199 found: self.phase.as_str(),
200 });
201 }
202
203 if policy.active() {
209 if let Some(ingress) = ports.ingress.as_deref() {
210 let score = ingress.score(&self.prompt);
211 if score >= policy.hard_threshold {
212 self.emit(DomainEvent::SafetyViolationDetected {
213 score_milli: score.milli(),
214 threshold_milli: policy.hard_threshold.milli(),
215 });
216 self.phase = Phase::Completed;
217 self.emit(DomainEvent::GenerationCompleted {
218 total_tokens: self.output.len() as u32,
219 stop: StopReason::Stopped,
220 });
221 return Ok(StopReason::Stopped);
222 }
223 }
224 }
225
226 let eos = self.engine.eos_token();
227 let guarding = policy.guards() && ports.guard.is_some();
228
229 let checkpoints = if guarding {
232 if self.config.memory_budget_bytes < MIN_CHECKPOINT_BUDGET_BYTES {
233 self.emit(DomainEvent::SafetyDisabled {
234 reason: DegradeReason::MemoryPressure,
235 });
236 CheckpointManager::new(0)
237 } else {
238 CheckpointManager::new(policy.max_checkpoints)
239 }
240 } else {
241 CheckpointManager::new(0)
242 };
243 let mut state = GuardState {
244 checkpoints,
245 rollback_count: 0,
246 banned: Vec::new(),
247 start_out: self.output.len() as u32,
252 start_kv: self.kv.len(),
253 };
254 if state.checkpoints.enabled() {
256 state.checkpoints.push(Checkpoint {
257 output_len: state.start_out,
258 kv_len: state.start_kv,
259 });
260 }
261
262 let stop = loop {
263 let mut terminating: Option<StopReason> = None;
269
270 if self.output.len() as u32 >= max_tokens {
271 terminating = Some(StopReason::MaxTokens);
272 } else {
273 let logits = self.engine.next_logits(&self.output);
275 let vocab = logits.len();
276
277 let mut mask = ports.grammar.mask(&self.output, vocab);
280 for &t in &state.banned {
281 if let Some(slot) = mask.get_mut(t as usize) {
282 *slot = false;
283 }
284 }
285 let allowed = mask.iter().filter(|b| **b).count() as u32;
286 self.emit(DomainEvent::TokenMaskApplied { allowed });
287
288 let adj = if (self.output.len() as u32) < policy.steer_window {
295 if mask.iter().any(|&legal| !legal) {
300 let legal_logits: Vec<i32> = logits
301 .iter()
302 .zip(mask.iter())
303 .map(|(&l, &legal)| if legal { l } else { i32::MIN })
304 .collect();
305 ports.safety.adjust_with_logits(&self.output, &legal_logits)
306 } else {
307 ports.safety.adjust_with_logits(&self.output, &logits)
308 }
309 } else {
310 ports.safety.adjust(&self.output)
311 };
312 if !adj.is_empty() {
313 self.emit(DomainEvent::LogitsSteered {
314 adjustment_norm_milli: adj.l1_norm_milli(),
315 });
316 }
317
318 let token = match pick(&logits, &mask, &adj) {
322 Some(t) => t,
323 None => {
324 self.emit(DomainEvent::GrammarViolationBlocked);
325 break StopReason::Stopped;
326 }
327 };
328 self.emit(DomainEvent::TokenGenerated { sampled: false });
329
330 self.output.push(token);
332 self.kv.push(self.output.len() as u64);
333 self.step += 1;
334 self.emit(DomainEvent::TokenCommitted {
335 kv_len: self.kv.len(),
336 });
337
338 if token == eos {
339 terminating = Some(StopReason::Eos);
340 }
341 }
342
343 if guarding {
349 let guard = ports
350 .guard
351 .as_deref()
352 .expect("guarding implies a guard is wired");
353 let at_boundary = (self.output.len() as u32).is_multiple_of(policy.guard_every);
354 if terminating.is_some() || at_boundary {
355 match self.guard_chunk(guard, &policy, &mut state) {
356 GuardVerdict::FailClosed => break StopReason::Stopped,
358 GuardVerdict::RolledBack => continue,
361 GuardVerdict::Pass => {}
362 }
363 }
364 }
365
366 if let Some(reason) = terminating {
367 break reason;
368 }
369 };
370
371 self.phase = Phase::Completed;
372 self.emit(DomainEvent::GenerationCompleted {
373 total_tokens: self.output.len() as u32,
374 stop,
375 });
376 Ok(stop)
377 }
378
379 fn guard_chunk(
393 &mut self,
394 guard: &dyn el_safety::ChunkGuard,
395 policy: &RollbackPolicy,
396 state: &mut GuardState,
397 ) -> GuardVerdict {
398 let score = guard.score(&self.output);
399 if score >= policy.hard_threshold {
400 self.emit(DomainEvent::SafetyViolationDetected {
401 score_milli: score.milli(),
402 threshold_milli: policy.hard_threshold.milli(),
403 });
404 match state.checkpoints.last() {
405 Some(cp) if state.rollback_count < policy.max_rollbacks => {
406 if self.engine.rollback(cp.output_len).is_err() {
410 self.output.truncate(cp.output_len as usize);
411 self.kv.truncate(cp.kv_len);
412 return GuardVerdict::FailClosed;
413 }
414 if let Some(&bad) = self.output.get(cp.output_len as usize) {
416 state.banned.push(bad);
417 }
418 self.output.truncate(cp.output_len as usize);
419 self.kv.truncate(cp.kv_len);
420 state.rollback_count += 1;
421 self.emit(DomainEvent::ClaimBacktracked {
422 claim_index: cp.output_len,
423 });
424 GuardVerdict::RolledBack
425 }
426 _ => {
427 let (safe_out, safe_kv) = state
428 .checkpoints
429 .last()
430 .map_or((state.start_out, state.start_kv), |c| {
431 (c.output_len, c.kv_len)
432 });
433 self.output.truncate(safe_out as usize);
434 self.kv.truncate(safe_kv);
435 GuardVerdict::FailClosed
436 }
437 }
438 } else if score < policy.soft_threshold {
439 if state.checkpoints.enabled() {
441 state.checkpoints.push(Checkpoint {
442 output_len: self.output.len() as u32,
443 kv_len: self.kv.len(),
444 });
445 }
446 state.banned.clear();
447 GuardVerdict::Pass
448 } else {
449 GuardVerdict::Pass
451 }
452 }
453
454 pub fn reset(&mut self) -> Result<()> {
476 self.engine.reset_cache()?;
477 self.kv = KvRegion::new();
478 self.prompt.clear();
479 self.output.clear();
480 self.step = 0;
481 self.phase = Phase::Initialized;
482 self.emit(DomainEvent::SessionReset);
483 Ok(())
484 }
485
486 pub fn close(&mut self) -> Result<()> {
502 self.engine.reset_cache()?;
503 self.kv = KvRegion::new();
504 self.prompt = Vec::new();
505 self.output = Vec::new();
506 self.events = Vec::new();
507 self.step = 0;
508 self.phase = Phase::Initialized;
509 Ok(())
510 }
511
512 pub fn consult_relay(&mut self, ports: &Ports, query: &[Token]) -> Result<Vec<Token>> {
515 if !self.config.hybrid_mode {
516 return Err(EdgeError::AirGapViolation);
517 }
518 match &ports.relay {
519 Some(relay) => {
520 let out = relay.consult(query);
521 self.emit(DomainEvent::HybridRelayConsulted);
522 Ok(out)
523 }
524 None => Err(EdgeError::AirGapViolation),
525 }
526 }
527}
528
529fn pick(logits: &[i32], mask: &[bool], adj: &LogitAdjustment) -> Option<Token> {
534 let mut best: Option<Token> = None;
535 let mut best_val = i32::MIN;
536 for (i, &l) in logits.iter().enumerate() {
537 if mask.get(i).copied() == Some(false) {
538 continue;
539 }
540 let v = l.saturating_add(adj.delta_for(i as Token));
541 if v > best_val {
542 best_val = v;
543 best = Some(i as Token);
544 }
545 }
546 best
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use crate::defaults::NullEngine;
553 use crate::ports::{GrammarMasker, Ports};
554 use el_core::{ModelFormat, ModelId, ModelVersion};
555 use el_provenance::{ModelArtifact, SignatureVerifier};
556 use el_safety::LightweightFilter;
557
558 struct OkVerifier;
559 impl SignatureVerifier for OkVerifier {
560 fn verify(&self, _b: &[u8], _s: &[u8], _k: u32) -> bool {
561 true
562 }
563 }
564
565 fn permit() -> LoadPermit {
566 let mut a = ModelArtifact::new(ModelId(1), ModelVersion::new(0, 1, 0), ModelFormat::Gguf);
567 a.verify(&OkVerifier, b"weights", b"sig", 1);
568 a.ensure_loadable().expect("verified artifact loads")
569 }
570
571 struct FixedEngine {
574 logits: Vec<i32>,
575 }
576 impl InferenceEngine for FixedEngine {
577 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
578 Ok(t.len() as u32)
579 }
580 fn next_logits(&mut self, _c: &[Token]) -> Vec<i32> {
581 self.logits.clone()
582 }
583 fn eos_token(&self) -> Token {
584 9999
585 }
586 fn rollback(&mut self, _keep: u32) -> Result<()> {
587 Ok(()) }
589 fn reset_cache(&mut self) -> Result<()> {
590 Ok(()) }
592 }
593
594 struct DisallowMasker(Vec<Token>);
596 impl GrammarMasker for DisallowMasker {
597 fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
598 (0..vocab as Token).map(|t| !self.0.contains(&t)).collect()
599 }
600 }
601
602 #[test]
603 fn full_lifecycle_init_prefill_decode_complete_reset() {
604 let mut s = InferenceSession::new(
605 SessionId(1),
606 SessionConfig::default(),
607 NullEngine::new(3, 8),
608 permit(),
609 );
610 assert_eq!(s.phase(), Phase::Initialized);
611
612 let ports = Ports::permissive();
613 s.load_prompt(&ports, &[10, 11, 12]).unwrap();
614 assert_eq!(s.phase(), Phase::Decoding);
615
616 let stop = s.generate(&ports, 16).unwrap();
617 assert_eq!(stop, StopReason::Eos); assert_eq!(s.output(), &[3]);
619 assert_eq!(s.phase(), Phase::Completed);
620
621 s.reset().unwrap();
622 assert_eq!(s.phase(), Phase::Initialized);
623 assert!(s.output().is_empty());
624 }
625
626 #[test]
627 fn decode_applies_grammar_before_safety_before_sampling() {
628 let engine = FixedEngine {
630 logits: vec![10, 9, 8, 7],
631 };
632 let mut s = InferenceSession::new(SessionId(2), SessionConfig::default(), engine, permit());
633
634 let ports = Ports {
635 compressor: Box::new(crate::defaults::IdentityCompressor),
636 grammar: Box::new(DisallowMasker(vec![0])), safety: Box::new(LightweightFilter::new(vec![1])), guard: None,
639 ingress: None,
640 relay: None,
641 };
642 s.load_prompt(&ports, &[1]).unwrap();
643 let stop = s.generate(&ports, 1).unwrap();
644
645 assert_eq!(stop, StopReason::MaxTokens);
646 assert_eq!(s.output(), &[2]);
649 }
650
651 #[test]
652 fn generate_before_load_prompt_is_invalid_phase() {
653 let mut s = InferenceSession::new(
654 SessionId(3),
655 SessionConfig::default(),
656 NullEngine::new(0, 4),
657 permit(),
658 );
659 let ports = Ports::permissive();
660 let err = s.generate(&ports, 4).unwrap_err();
661 assert!(matches!(err, EdgeError::InvalidPhase { .. }));
662 }
663
664 #[test]
665 fn relay_is_blocked_unless_hybrid_mode_opted_in() {
666 struct EchoRelay;
667 impl crate::ports::HybridRelay for EchoRelay {
668 fn consult(&self, q: &[Token]) -> Vec<Token> {
669 q.to_vec()
670 }
671 }
672
673 let mut s = InferenceSession::new(
675 SessionId(4),
676 SessionConfig::default(),
677 NullEngine::new(0, 4),
678 permit(),
679 );
680 let ports = Ports {
681 relay: Some(Box::new(EchoRelay)),
682 ..Ports::permissive()
683 };
684 assert_eq!(
685 s.consult_relay(&ports, &[1, 2]).unwrap_err(),
686 EdgeError::AirGapViolation
687 );
688
689 let cfg = SessionConfig {
691 hybrid_mode: true,
692 ..SessionConfig::default()
693 };
694 let mut s2 = InferenceSession::new(SessionId(5), cfg, NullEngine::new(0, 4), permit());
695 assert_eq!(s2.consult_relay(&ports, &[1, 2]).unwrap(), vec![1, 2]);
696
697 let no_relay = Ports::permissive();
699 assert_eq!(
700 s2.consult_relay(&no_relay, &[1]).unwrap_err(),
701 EdgeError::AirGapViolation
702 );
703 }
704
705 #[test]
706 fn first_events_are_init_then_model_loaded() {
707 let mut s = InferenceSession::new(
708 SessionId(6),
709 SessionConfig::default(),
710 NullEngine::new(0, 4),
711 permit(),
712 );
713 let evs = s.drain_events();
714 assert!(matches!(
715 evs[0].event,
716 DomainEvent::SessionInitialized { .. }
717 ));
718 assert!(matches!(evs[1].event, DomainEvent::ModelLoaded { .. }));
719 }
720
721 use el_safety::{ChunkGuard, SafetyScore};
724
725 struct BanToken(Token);
727 impl ChunkGuard for BanToken {
728 fn score(&self, recent: &[Token]) -> SafetyScore {
729 if recent.contains(&self.0) {
730 SafetyScore::MAX
731 } else {
732 SafetyScore::SAFE
733 }
734 }
735 }
736
737 struct AlwaysHot;
739 impl ChunkGuard for AlwaysHot {
740 fn score(&self, _recent: &[Token]) -> SafetyScore {
741 SafetyScore::MAX
742 }
743 }
744
745 fn tiny_policy(max_rollbacks: u8) -> RollbackPolicy {
746 RollbackPolicy {
747 guard_every: 1,
748 steer_window: 0,
749 soft_threshold: SafetyScore::from_milli(500),
750 hard_threshold: SafetyScore::from_milli(800),
751 max_rollbacks,
752 max_checkpoints: 8,
753 }
754 }
755
756 struct DenyAllMasker;
759 impl GrammarMasker for DenyAllMasker {
760 fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
761 vec![false; vocab]
762 }
763 }
764
765 fn descending_engine() -> FixedEngine {
768 FixedEngine {
769 logits: vec![5, 4, 3, 2],
770 }
771 }
772
773 struct UnsafeThenEos {
777 eos: Token,
778 vocab: usize,
779 }
780 impl InferenceEngine for UnsafeThenEos {
781 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
782 Ok(t.len() as u32)
783 }
784 fn next_logits(&mut self, ctx: &[Token]) -> Vec<i32> {
785 let mut v = vec![0i32; self.vocab];
786 if ctx.is_empty() {
787 v[0] = 10; } else {
789 v[self.eos as usize] = 10; }
791 v
792 }
793 fn eos_token(&self) -> Token {
794 self.eos
795 }
796 fn rollback(&mut self, _keep: u32) -> Result<()> {
797 Ok(()) }
799 fn reset_cache(&mut self) -> Result<()> {
800 Ok(()) }
802 }
803
804 fn coarse_policy(max_rollbacks: u8) -> RollbackPolicy {
807 RollbackPolicy {
808 guard_every: 16,
809 steer_window: 0,
810 soft_threshold: SafetyScore::from_milli(500),
811 hard_threshold: SafetyScore::from_milli(800),
812 max_rollbacks,
813 max_checkpoints: 8,
814 }
815 }
816
817 #[test]
818 fn eos_terminated_short_completion_is_scored_not_bypassed() {
819 let mut s = InferenceSession::new(
822 SessionId(26),
823 SessionConfig::default(),
824 UnsafeThenEos { eos: 5, vocab: 8 },
825 permit(),
826 );
827 let ports = guarded_ports(Box::new(BanToken(0)));
828 s.load_prompt(&ports, &[]).unwrap();
829
830 let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
832
833 assert_eq!(stop, StopReason::Stopped);
834 assert!(s.output().is_empty()); let evs = s.drain_events();
836 assert!(evs
837 .iter()
838 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
839 }
840
841 #[test]
842 fn max_tokens_partial_chunk_is_scored_not_bypassed() {
843 let mut s = InferenceSession::new(
846 SessionId(27),
847 SessionConfig::default(),
848 descending_engine(), permit(),
850 );
851 let ports = guarded_ports(Box::new(BanToken(0)));
852 s.load_prompt(&ports, &[]).unwrap();
853
854 let stop = s.generate_with_policy(&ports, 2, coarse_policy(0)).unwrap();
856
857 assert_eq!(stop, StopReason::Stopped);
858 assert!(s.output().is_empty());
859 let evs = s.drain_events();
860 assert!(evs
861 .iter()
862 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
863 }
864
865 #[test]
866 fn eos_unsafe_tail_rolls_back_and_recovers() {
867 let mut s = InferenceSession::new(
871 SessionId(28),
872 SessionConfig::default(),
873 UnsafeThenEos { eos: 5, vocab: 8 },
874 permit(),
875 );
876 let ports = guarded_ports(Box::new(BanToken(0)));
877 s.load_prompt(&ports, &[]).unwrap();
878
879 let stop = s.generate_with_policy(&ports, 8, coarse_policy(1)).unwrap();
880
881 assert_eq!(stop, StopReason::Eos);
882 assert!(!s.output().contains(&0)); assert_eq!(s.output().last(), Some(&5)); let evs = s.drain_events();
885 assert!(evs
886 .iter()
887 .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
888 }
889
890 struct StatefulEngine {
897 fed: usize,
898 logits: Vec<i32>,
899 eos: Token,
900 rollbacks: std::rc::Rc<std::cell::Cell<u32>>,
901 last_keep: std::rc::Rc<std::cell::Cell<u32>>,
902 desynced: std::rc::Rc<std::cell::Cell<bool>>,
903 }
904 impl InferenceEngine for StatefulEngine {
905 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
906 self.fed = 0;
907 Ok(t.len() as u32)
908 }
909 fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
910 if self.fed > committed.len() {
913 self.desynced.set(true);
914 }
915 while self.fed < committed.len() {
916 self.fed += 1;
917 }
918 self.logits.clone()
919 }
920 fn eos_token(&self) -> Token {
921 self.eos
922 }
923 fn rollback(&mut self, keep_committed: u32) -> Result<()> {
924 self.fed = keep_committed as usize; self.rollbacks.set(self.rollbacks.get() + 1);
926 self.last_keep.set(keep_committed);
927 Ok(())
928 }
929 fn reset_cache(&mut self) -> Result<()> {
930 self.fed = 0; Ok(())
932 }
933 }
934
935 #[test]
936 fn rollback_restores_engine_state_not_just_session_metadata() {
937 let rollbacks = std::rc::Rc::new(std::cell::Cell::new(0u32));
941 let last_keep = std::rc::Rc::new(std::cell::Cell::new(u32::MAX));
942 let desynced = std::rc::Rc::new(std::cell::Cell::new(false));
943 let engine = StatefulEngine {
944 fed: 0,
945 logits: vec![5, 4, 3, 2], eos: 9999,
947 rollbacks: rollbacks.clone(),
948 last_keep: last_keep.clone(),
949 desynced: desynced.clone(),
950 };
951 let mut s =
952 InferenceSession::new(SessionId(29), SessionConfig::default(), engine, permit());
953 let ports = guarded_ports(Box::new(BanToken(0)));
954 s.load_prompt(&ports, &[7, 8]).unwrap(); let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
957
958 assert_eq!(stop, StopReason::MaxTokens);
959 assert_eq!(s.output(), &[1, 1, 1]); assert!(
962 rollbacks.get() >= 1,
963 "session must propagate the backtrack to the engine"
964 );
965 assert!(last_keep.get() < 3);
967 assert!(
968 !desynced.get(),
969 "engine cache must track the session rollback"
970 );
971 }
972
973 fn guarded_ports(guard: Box<dyn ChunkGuard>) -> Ports {
974 Ports {
975 compressor: Box::new(crate::defaults::IdentityCompressor),
976 grammar: Box::new(crate::defaults::AllowAllMasker),
977 safety: Box::new(el_safety::NoSafety),
978 guard: Some(guard),
979 ingress: None,
980 relay: None,
981 }
982 }
983
984 #[test]
985 fn hard_breach_rolls_back_kv_and_recovers() {
986 let mut s = InferenceSession::new(
987 SessionId(20),
988 SessionConfig::default(),
989 descending_engine(),
990 permit(),
991 );
992 let ports = guarded_ports(Box::new(BanToken(0)));
993 s.load_prompt(&ports, &[]).unwrap();
994
995 let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
996
997 assert_eq!(stop, StopReason::MaxTokens);
998 assert_eq!(s.output(), &[1, 1, 1]);
1001 assert!(!s.output().contains(&0));
1002 assert_eq!(s.kv_len(), s.output().len() as u32);
1004
1005 let evs = s.drain_events();
1006 assert!(evs
1007 .iter()
1008 .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1009 assert!(evs
1010 .iter()
1011 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1012 }
1013
1014 #[test]
1015 fn fail_closed_refusal_when_no_rollback_budget() {
1016 let mut s = InferenceSession::new(
1017 SessionId(21),
1018 SessionConfig::default(),
1019 descending_engine(),
1020 permit(),
1021 );
1022 let ports = guarded_ports(Box::new(BanToken(0)));
1023 s.load_prompt(&ports, &[]).unwrap();
1024
1025 let stop = s.generate_with_policy(&ports, 5, tiny_policy(0)).unwrap();
1027
1028 assert_eq!(stop, StopReason::Stopped);
1029 assert!(s.output().is_empty());
1030 let evs = s.drain_events();
1031 assert!(evs
1032 .iter()
1033 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1034 assert!(!evs
1035 .iter()
1036 .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1037 }
1038
1039 #[test]
1040 fn rollbacks_are_bounded_then_fail_closed() {
1041 let mut s = InferenceSession::new(
1042 SessionId(22),
1043 SessionConfig::default(),
1044 descending_engine(),
1045 permit(),
1046 );
1047 let ports = guarded_ports(Box::new(AlwaysHot));
1048 s.load_prompt(&ports, &[]).unwrap();
1049
1050 let stop = s.generate_with_policy(&ports, 8, tiny_policy(2)).unwrap();
1051
1052 assert_eq!(stop, StopReason::Stopped);
1053 let evs = s.drain_events();
1054 let rollbacks = evs
1055 .iter()
1056 .filter(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. }))
1057 .count();
1058 assert_eq!(rollbacks, 2);
1060 }
1061
1062 #[test]
1063 fn memory_pressure_disables_checkpoints_and_fails_closed() {
1064 let cfg = SessionConfig {
1065 memory_budget_bytes: 1024, ..SessionConfig::default()
1067 };
1068 let mut s = InferenceSession::new(SessionId(23), cfg, descending_engine(), permit());
1069 let ports = guarded_ports(Box::new(BanToken(0)));
1070 s.load_prompt(&ports, &[]).unwrap();
1071
1072 let stop = s.generate_with_policy(&ports, 5, tiny_policy(3)).unwrap();
1073
1074 assert_eq!(stop, StopReason::Stopped); let evs = s.drain_events();
1076 assert!(evs.iter().any(|e| matches!(
1077 e.event,
1078 DomainEvent::SafetyDisabled {
1079 reason: DegradeReason::MemoryPressure
1080 }
1081 )));
1082 }
1083
1084 #[test]
1085 fn no_legal_token_fails_closed() {
1086 let mut s = InferenceSession::new(
1089 SessionId(24),
1090 SessionConfig::default(),
1091 descending_engine(),
1092 permit(),
1093 );
1094 let ports = Ports {
1095 compressor: Box::new(crate::defaults::IdentityCompressor),
1096 grammar: Box::new(DenyAllMasker),
1097 safety: Box::new(el_safety::NoSafety),
1098 guard: None,
1099 ingress: None,
1100 relay: None,
1101 };
1102 s.load_prompt(&ports, &[]).unwrap();
1103
1104 let stop = s.generate(&ports, 4).unwrap();
1105
1106 assert_eq!(stop, StopReason::Stopped);
1107 assert!(s.output().is_empty()); let evs = s.drain_events();
1109 assert!(evs
1110 .iter()
1111 .any(|e| matches!(e.event, DomainEvent::GrammarViolationBlocked)));
1112 }
1113
1114 #[test]
1115 fn fail_closed_preserves_prefill_kv() {
1116 let mut s = InferenceSession::new(
1120 SessionId(25),
1121 SessionConfig::default(),
1122 descending_engine(),
1123 permit(),
1124 );
1125 let ports = guarded_ports(Box::new(AlwaysHot));
1126 s.load_prompt(&ports, &[7, 8, 9]).unwrap(); assert_eq!(s.kv_len(), 3);
1128
1129 let stop = s.generate_with_policy(&ports, 8, tiny_policy(1)).unwrap();
1130
1131 assert_eq!(stop, StopReason::Stopped);
1132 assert!(s.output().is_empty()); assert_eq!(s.kv_len(), 3); }
1135
1136 use el_core::SafetyMode;
1139 use el_safety::SafetySteerer;
1140
1141 struct RecordingSteerer {
1144 log: std::rc::Rc<std::cell::RefCell<Vec<(bool, usize)>>>,
1145 }
1146 impl SafetySteerer for RecordingSteerer {
1147 fn adjust(&self, recent: &[Token]) -> LogitAdjustment {
1148 self.log.borrow_mut().push((false, recent.len()));
1149 LogitAdjustment::none()
1150 }
1151 fn adjust_with_logits(&self, recent: &[Token], _base: &[i32]) -> LogitAdjustment {
1152 self.log.borrow_mut().push((true, recent.len()));
1153 LogitAdjustment::none()
1154 }
1155 fn mode(&self) -> SafetyMode {
1156 SafetyMode::SecDecoding
1157 }
1158 }
1159
1160 #[test]
1161 fn soft_steer_applies_only_inside_the_early_token_window() {
1162 let log = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1165 let mut s = InferenceSession::new(
1166 SessionId(30),
1167 SessionConfig::default(),
1168 descending_engine(), permit(),
1170 );
1171 let ports = Ports {
1172 compressor: Box::new(crate::defaults::IdentityCompressor),
1173 grammar: Box::new(crate::defaults::AllowAllMasker),
1174 safety: Box::new(RecordingSteerer { log: log.clone() }),
1175 guard: None,
1176 ingress: None,
1177 relay: None,
1178 };
1179 s.load_prompt(&ports, &[]).unwrap();
1180 let policy = RollbackPolicy {
1181 guard_every: 0,
1182 steer_window: 2,
1183 soft_threshold: SafetyScore::MAX,
1184 hard_threshold: SafetyScore::MAX,
1185 max_rollbacks: 0,
1186 max_checkpoints: 0,
1187 };
1188 s.generate_with_policy(&ports, 4, policy).unwrap();
1189
1190 let calls = log.borrow();
1191 assert_eq!(calls.len(), 4);
1192 for &(with_logits, len) in calls.iter() {
1193 assert_eq!(
1194 with_logits,
1195 len < 2,
1196 "window gate wrong at output len {len}"
1197 );
1198 }
1199 }
1200
1201 #[test]
1202 fn ingress_triage_fails_closed_before_generation() {
1203 let mut s = InferenceSession::new(
1205 SessionId(31),
1206 SessionConfig::default(),
1207 descending_engine(),
1208 permit(),
1209 );
1210 let ports = Ports {
1211 compressor: Box::new(crate::defaults::IdentityCompressor),
1212 grammar: Box::new(crate::defaults::AllowAllMasker),
1213 safety: Box::new(el_safety::NoSafety),
1214 guard: None,
1215 ingress: Some(Box::new(AlwaysHot)), relay: None,
1217 };
1218 s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1219
1220 let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
1221
1222 assert_eq!(stop, StopReason::Stopped);
1223 assert!(s.output().is_empty());
1224 let evs = s.drain_events();
1225 assert!(evs
1226 .iter()
1227 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1228 assert!(!evs
1230 .iter()
1231 .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })));
1232 }
1233
1234 #[test]
1235 fn generate_applies_safety_mode_selector_and_records_effective_mode() {
1236 let cfg = SessionConfig {
1239 device: el_core::DeviceTarget::MidRange,
1240 safety: SafetyMode::SecDecoding,
1241 ..SessionConfig::default()
1242 };
1243 let mut s = InferenceSession::new(SessionId(32), cfg, NullEngine::new(0, 4), permit());
1244 let ports = Ports::permissive();
1245 s.load_prompt(&ports, &[1]).unwrap();
1246 s.generate(&ports, 4).unwrap();
1247
1248 let evs = s.drain_events();
1249 assert!(evs.iter().any(|e| matches!(
1250 e.event,
1251 DomainEvent::SafetyModeSelected {
1252 mode: SafetyMode::Lightweight
1253 }
1254 )));
1255 }
1256
1257 struct CountingEngine {
1263 eos: Token,
1264 vocab: usize,
1265 prefills: std::rc::Rc<std::cell::Cell<u32>>,
1266 resets: std::rc::Rc<std::cell::Cell<u32>>,
1267 }
1268 impl InferenceEngine for CountingEngine {
1269 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1270 self.prefills.set(self.prefills.get() + 1);
1271 Ok(t.len() as u32)
1272 }
1273 fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1274 let mut v = vec![0i32; self.vocab];
1275 if let Some(s) = v.get_mut(self.eos as usize) {
1276 *s = 1;
1277 }
1278 v
1279 }
1280 fn eos_token(&self) -> Token {
1281 self.eos
1282 }
1283 fn rollback(&mut self, _keep: u32) -> Result<()> {
1284 Ok(())
1285 }
1286 fn reset_cache(&mut self) -> Result<()> {
1287 self.resets.set(self.resets.get() + 1);
1288 Ok(())
1289 }
1290 }
1291
1292 fn counting_engine() -> (
1293 CountingEngine,
1294 std::rc::Rc<std::cell::Cell<u32>>,
1295 std::rc::Rc<std::cell::Cell<u32>>,
1296 ) {
1297 let prefills = std::rc::Rc::new(std::cell::Cell::new(0u32));
1298 let resets = std::rc::Rc::new(std::cell::Cell::new(0u32));
1299 let engine = CountingEngine {
1300 eos: 1,
1301 vocab: 4,
1302 prefills: prefills.clone(),
1303 resets: resets.clone(),
1304 };
1305 (engine, prefills, resets)
1306 }
1307
1308 #[test]
1309 fn reset_resets_the_engine_cache() {
1310 let (engine, _prefills, resets) = counting_engine();
1314 let mut s =
1315 InferenceSession::new(SessionId(40), SessionConfig::default(), engine, permit());
1316 assert_eq!(resets.get(), 0);
1317 s.reset().unwrap();
1318 assert_eq!(
1319 resets.get(),
1320 1,
1321 "reset() must reset the engine cache (ADR-018)"
1322 );
1323 }
1324
1325 #[test]
1326 fn one_engine_serves_multiple_conversations_via_reset() {
1327 let (engine, prefills, resets) = counting_engine();
1331 let mut s =
1332 InferenceSession::new(SessionId(41), SessionConfig::default(), engine, permit());
1333 let ports = Ports::permissive();
1334
1335 s.load_prompt(&ports, &[1, 2]).unwrap();
1336 s.generate(&ports, 8).unwrap();
1337 assert_eq!(prefills.get(), 1);
1338
1339 s.reset().unwrap();
1341 s.load_prompt(&ports, &[3, 4, 5]).unwrap();
1342 s.generate(&ports, 8).unwrap();
1343
1344 assert_eq!(
1345 prefills.get(),
1346 2,
1347 "the one resident engine was prefilled again, not rebuilt"
1348 );
1349 assert!(
1350 resets.get() >= 1,
1351 "the engine cache was reset between conversations"
1352 );
1353 }
1354
1355 struct ResidentEngine {
1358 eos: Token,
1359 vocab: usize,
1360 resets: std::rc::Rc<std::cell::Cell<u32>>,
1361 dropped: std::rc::Rc<std::cell::Cell<u32>>,
1362 }
1363 impl Drop for ResidentEngine {
1364 fn drop(&mut self) {
1365 self.dropped.set(self.dropped.get() + 1);
1366 }
1367 }
1368 impl InferenceEngine for ResidentEngine {
1369 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1370 Ok(t.len() as u32)
1371 }
1372 fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1373 let mut v = vec![0i32; self.vocab];
1374 if let Some(s) = v.get_mut(self.eos as usize) {
1375 *s = 1;
1376 }
1377 v
1378 }
1379 fn eos_token(&self) -> Token {
1380 self.eos
1381 }
1382 fn rollback(&mut self, _keep: u32) -> Result<()> {
1383 Ok(())
1384 }
1385 fn reset_cache(&mut self) -> Result<()> {
1386 self.resets.set(self.resets.get() + 1);
1387 Ok(())
1388 }
1389 }
1390
1391 #[test]
1392 fn close_releases_conversation_but_keeps_engine_resident() {
1393 let resets = std::rc::Rc::new(std::cell::Cell::new(0u32));
1398 let dropped = std::rc::Rc::new(std::cell::Cell::new(0u32));
1399 let engine = ResidentEngine {
1400 eos: 1,
1401 vocab: 4,
1402 resets: resets.clone(),
1403 dropped: dropped.clone(),
1404 };
1405 let mut s =
1406 InferenceSession::new(SessionId(42), SessionConfig::default(), engine, permit());
1407 let ports = Ports::permissive();
1408 s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1409 s.generate(&ports, 8).unwrap();
1410 assert!(s.kv_len() > 0, "conversation memory is measurable (AC-4)");
1411
1412 s.close().unwrap();
1413
1414 assert_eq!(s.kv_len(), 0, "close frees the session's KV descriptors");
1415 assert!(s.output().is_empty(), "close frees committed output");
1416 assert!(
1417 resets.get() >= 1,
1418 "close releases the engine's conversation KV (keeps weights)"
1419 );
1420 assert_eq!(
1421 dropped.get(),
1422 0,
1423 "the resident engine/weights are NOT dropped by close"
1424 );
1425
1426 s.load_prompt(&ports, &[4, 5]).unwrap();
1428 s.generate(&ports, 8).unwrap();
1429 assert!(
1430 s.kv_len() > 0,
1431 "same engine serves a new conversation after close"
1432 );
1433 assert_eq!(dropped.get(), 0);
1434 }
1435
1436 #[test]
1437 fn reset_preserves_undrained_events() {
1438 let (engine, _prefills, _resets) = counting_engine();
1442 let mut s =
1443 InferenceSession::new(SessionId(44), SessionConfig::default(), engine, permit());
1444 let ports = Ports::permissive();
1445 s.load_prompt(&ports, &[1, 2]).unwrap();
1446 s.generate(&ports, 8).unwrap(); s.reset().unwrap();
1449
1450 let evs = s.drain_events();
1451 assert!(
1452 evs.iter()
1453 .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })),
1454 "pre-reset events are preserved for the consumer"
1455 );
1456 assert!(
1457 evs.iter()
1458 .any(|e| matches!(e.event, DomainEvent::SessionReset)),
1459 "and the reset itself is recorded"
1460 );
1461 }
1462
1463 struct FailResetEngine {
1465 eos: Token,
1466 vocab: usize,
1467 }
1468 impl InferenceEngine for FailResetEngine {
1469 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1470 Ok(t.len() as u32)
1471 }
1472 fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1473 let mut v = vec![0i32; self.vocab];
1474 if let Some(s) = v.get_mut(self.eos as usize) {
1475 *s = 1;
1476 }
1477 v
1478 }
1479 fn eos_token(&self) -> Token {
1480 self.eos
1481 }
1482 fn rollback(&mut self, _keep: u32) -> Result<()> {
1483 Ok(())
1484 }
1485 fn reset_cache(&mut self) -> Result<()> {
1486 Err(EdgeError::Engine("reset_cache refused"))
1487 }
1488 }
1489
1490 #[test]
1491 fn reset_propagates_engine_cache_failure_and_leaves_state() {
1492 let mut s = InferenceSession::new(
1496 SessionId(45),
1497 SessionConfig::default(),
1498 FailResetEngine { eos: 1, vocab: 4 },
1499 permit(),
1500 );
1501 let ports = Ports::permissive();
1502 s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1503 s.generate(&ports, 8).unwrap();
1504 let kv_before = s.kv_len();
1505 assert!(kv_before > 0);
1506
1507 let err = s.reset().unwrap_err();
1508 assert!(matches!(err, EdgeError::Engine(_)));
1509 assert_eq!(
1510 s.kv_len(),
1511 kv_before,
1512 "descriptors must be left intact when the engine reset fails"
1513 );
1514 assert_eq!(
1515 s.phase(),
1516 Phase::Completed,
1517 "phase is unchanged on a failed reset"
1518 );
1519 }
1520
1521 struct RetryClearEngine {
1525 eos: Token,
1526 vocab: usize,
1527 dirty: bool,
1528 fails_left: std::rc::Rc<std::cell::Cell<u32>>,
1529 cleared: std::rc::Rc<std::cell::Cell<bool>>,
1530 }
1531 impl InferenceEngine for RetryClearEngine {
1532 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1533 self.dirty = true;
1534 Ok(t.len() as u32)
1535 }
1536 fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1537 self.dirty = true;
1538 let mut v = vec![0i32; self.vocab];
1539 if let Some(s) = v.get_mut(self.eos as usize) {
1540 *s = 1;
1541 }
1542 v
1543 }
1544 fn eos_token(&self) -> Token {
1545 self.eos
1546 }
1547 fn rollback(&mut self, _keep: u32) -> Result<()> {
1548 Ok(())
1549 }
1550 fn reset_cache(&mut self) -> Result<()> {
1551 if self.dirty {
1552 if self.fails_left.get() > 0 {
1553 self.fails_left.set(self.fails_left.get() - 1);
1554 return Err(EdgeError::Engine("partial eviction")); }
1556 self.dirty = false;
1557 self.cleared.set(true);
1558 }
1559 Ok(())
1560 }
1561 }
1562
1563 #[test]
1564 fn reset_retries_clearing_after_a_failed_attempt() {
1565 let fails_left = std::rc::Rc::new(std::cell::Cell::new(1u32));
1570 let cleared = std::rc::Rc::new(std::cell::Cell::new(false));
1571 let engine = RetryClearEngine {
1572 eos: 1,
1573 vocab: 4,
1574 dirty: false,
1575 fails_left: fails_left.clone(),
1576 cleared: cleared.clone(),
1577 };
1578 let mut s =
1579 InferenceSession::new(SessionId(47), SessionConfig::default(), engine, permit());
1580 let ports = Ports::permissive();
1581 s.load_prompt(&ports, &[1, 2]).unwrap();
1582 s.generate(&ports, 8).unwrap();
1583
1584 assert!(s.reset().is_err());
1586 assert!(
1587 !cleared.get(),
1588 "a failed reset must not report the cache cleared"
1589 );
1590
1591 s.reset().unwrap();
1593 assert!(
1594 cleared.get(),
1595 "the next reset re-clears after a failed attempt"
1596 );
1597 }
1598}