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 continue_prompt(&mut self, _ports: &Ports, full_context: &[Token]) -> Result<()> {
190 if self.phase != Phase::Completed {
191 return Err(EdgeError::InvalidPhase {
192 expected: "Completed",
193 found: self.phase.as_str(),
194 });
195 }
196 self.step = 0;
200 self.prompt = full_context.to_vec();
203
204 self.phase = Phase::Prefilling;
205 let kv_len = self.engine.prefill_reuse(full_context)?;
206 self.kv = KvRegion::new();
208 for _ in 0..kv_len {
209 let off = self.kv.len() as u64;
210 self.kv.push(off);
211 }
212 self.output.clear();
215 self.emit(DomainEvent::PrefillCompleted {
216 prompt_tokens: full_context.len() as u32,
217 kv_len,
218 prefill_tps: 0,
221 });
222 self.phase = Phase::Decoding;
223 Ok(())
224 }
225
226 pub fn generate(&mut self, ports: &Ports, max_tokens: u32) -> Result<StopReason> {
229 let effective = SafetyModeSelector::resolve(self.config.safety, self.config.device);
234 self.emit(DomainEvent::SafetyModeSelected { mode: effective });
235 let policy = RollbackPolicy::for_device(self.config.device, effective);
236 self.generate_with_policy(ports, max_tokens, policy)
237 }
238
239 pub fn generate_with_policy(
259 &mut self,
260 ports: &Ports,
261 max_tokens: u32,
262 policy: RollbackPolicy,
263 ) -> Result<StopReason> {
264 if self.phase != Phase::Decoding {
265 return Err(EdgeError::InvalidPhase {
266 expected: "Decoding",
267 found: self.phase.as_str(),
268 });
269 }
270
271 if policy.active() {
277 if let Some(ingress) = ports.ingress.as_deref() {
278 let score = ingress.score(&self.prompt);
279 if score >= policy.hard_threshold {
280 self.emit(DomainEvent::SafetyViolationDetected {
281 score_milli: score.milli(),
282 threshold_milli: policy.hard_threshold.milli(),
283 });
284 self.phase = Phase::Completed;
285 self.emit(DomainEvent::GenerationCompleted {
286 total_tokens: self.output.len() as u32,
287 stop: StopReason::Stopped,
288 });
289 return Ok(StopReason::Stopped);
290 }
291 }
292 }
293
294 let eos = self.engine.eos_token();
295 let guarding = policy.guards() && ports.guard.is_some();
296
297 let checkpoints = if guarding {
300 if self.config.memory_budget_bytes < MIN_CHECKPOINT_BUDGET_BYTES {
301 self.emit(DomainEvent::SafetyDisabled {
302 reason: DegradeReason::MemoryPressure,
303 });
304 CheckpointManager::new(0)
305 } else {
306 CheckpointManager::new(policy.max_checkpoints)
307 }
308 } else {
309 CheckpointManager::new(0)
310 };
311 let mut state = GuardState {
312 checkpoints,
313 rollback_count: 0,
314 banned: Vec::new(),
315 start_out: self.output.len() as u32,
320 start_kv: self.kv.len(),
321 };
322 if state.checkpoints.enabled() {
324 state.checkpoints.push(Checkpoint {
325 output_len: state.start_out,
326 kv_len: state.start_kv,
327 });
328 }
329
330 let stop = loop {
331 let mut terminating: Option<StopReason> = None;
337
338 if self.output.len() as u32 >= max_tokens {
339 terminating = Some(StopReason::MaxTokens);
340 } else {
341 let logits = self.engine.next_logits(&self.output);
343 let vocab = logits.len();
344
345 let mut mask = ports.grammar.mask(&self.output, vocab);
348 for &t in &state.banned {
349 if let Some(slot) = mask.get_mut(t as usize) {
350 *slot = false;
351 }
352 }
353 let allowed = mask.iter().filter(|b| **b).count() as u32;
354 self.emit(DomainEvent::TokenMaskApplied { allowed });
355
356 let adj = if (self.output.len() as u32) < policy.steer_window {
363 if mask.iter().any(|&legal| !legal) {
368 let legal_logits: Vec<i32> = logits
369 .iter()
370 .zip(mask.iter())
371 .map(|(&l, &legal)| if legal { l } else { i32::MIN })
372 .collect();
373 ports.safety.adjust_with_logits(&self.output, &legal_logits)
374 } else {
375 ports.safety.adjust_with_logits(&self.output, &logits)
376 }
377 } else {
378 ports.safety.adjust(&self.output)
379 };
380 if !adj.is_empty() {
381 self.emit(DomainEvent::LogitsSteered {
382 adjustment_norm_milli: adj.l1_norm_milli(),
383 });
384 }
385
386 let token = match pick(&logits, &mask, &adj) {
390 Some(t) => t,
391 None => {
392 self.emit(DomainEvent::GrammarViolationBlocked);
393 break StopReason::Stopped;
394 }
395 };
396 self.emit(DomainEvent::TokenGenerated { sampled: false });
397
398 self.output.push(token);
400 self.kv.push(self.output.len() as u64);
401 self.step += 1;
402 self.emit(DomainEvent::TokenCommitted {
403 kv_len: self.kv.len(),
404 });
405
406 if token == eos {
407 terminating = Some(StopReason::Eos);
408 }
409 }
410
411 if guarding {
417 let guard = ports
418 .guard
419 .as_deref()
420 .expect("guarding implies a guard is wired");
421 let at_boundary = (self.output.len() as u32).is_multiple_of(policy.guard_every);
422 if terminating.is_some() || at_boundary {
423 match self.guard_chunk(guard, &policy, &mut state) {
424 GuardVerdict::FailClosed => break StopReason::Stopped,
426 GuardVerdict::RolledBack => continue,
429 GuardVerdict::Pass => {}
430 }
431 }
432 }
433
434 if let Some(reason) = terminating {
435 break reason;
436 }
437 };
438
439 self.phase = Phase::Completed;
440 self.emit(DomainEvent::GenerationCompleted {
441 total_tokens: self.output.len() as u32,
442 stop,
443 });
444 Ok(stop)
445 }
446
447 fn guard_chunk(
461 &mut self,
462 guard: &dyn el_safety::ChunkGuard,
463 policy: &RollbackPolicy,
464 state: &mut GuardState,
465 ) -> GuardVerdict {
466 let score = guard.score(&self.output);
467 if score >= policy.hard_threshold {
468 self.emit(DomainEvent::SafetyViolationDetected {
469 score_milli: score.milli(),
470 threshold_milli: policy.hard_threshold.milli(),
471 });
472 match state.checkpoints.last() {
473 Some(cp) if state.rollback_count < policy.max_rollbacks => {
474 if self.engine.rollback(cp.output_len).is_err() {
478 self.output.truncate(cp.output_len as usize);
479 self.kv.truncate(cp.kv_len);
480 return GuardVerdict::FailClosed;
481 }
482 if let Some(&bad) = self.output.get(cp.output_len as usize) {
484 state.banned.push(bad);
485 }
486 self.output.truncate(cp.output_len as usize);
487 self.kv.truncate(cp.kv_len);
488 state.rollback_count += 1;
489 self.emit(DomainEvent::ClaimBacktracked {
490 claim_index: cp.output_len,
491 });
492 GuardVerdict::RolledBack
493 }
494 _ => {
495 let (safe_out, safe_kv) = state
496 .checkpoints
497 .last()
498 .map_or((state.start_out, state.start_kv), |c| {
499 (c.output_len, c.kv_len)
500 });
501 self.output.truncate(safe_out as usize);
502 self.kv.truncate(safe_kv);
503 GuardVerdict::FailClosed
504 }
505 }
506 } else if score < policy.soft_threshold {
507 if state.checkpoints.enabled() {
509 state.checkpoints.push(Checkpoint {
510 output_len: self.output.len() as u32,
511 kv_len: self.kv.len(),
512 });
513 }
514 state.banned.clear();
515 GuardVerdict::Pass
516 } else {
517 GuardVerdict::Pass
519 }
520 }
521
522 pub fn reset(&mut self) -> Result<()> {
544 self.engine.reset_cache()?;
545 self.kv = KvRegion::new();
546 self.prompt.clear();
547 self.output.clear();
548 self.step = 0;
549 self.phase = Phase::Initialized;
550 self.emit(DomainEvent::SessionReset);
551 Ok(())
552 }
553
554 pub fn close(&mut self) -> Result<()> {
570 self.engine.reset_cache()?;
571 self.kv = KvRegion::new();
572 self.prompt = Vec::new();
573 self.output = Vec::new();
574 self.events = Vec::new();
575 self.step = 0;
576 self.phase = Phase::Initialized;
577 Ok(())
578 }
579
580 pub fn consult_relay(&mut self, ports: &Ports, query: &[Token]) -> Result<Vec<Token>> {
583 if !self.config.hybrid_mode {
584 return Err(EdgeError::AirGapViolation);
585 }
586 match &ports.relay {
587 Some(relay) => {
588 let out = relay.consult(query);
589 self.emit(DomainEvent::HybridRelayConsulted);
590 Ok(out)
591 }
592 None => Err(EdgeError::AirGapViolation),
593 }
594 }
595}
596
597fn pick(logits: &[i32], mask: &[bool], adj: &LogitAdjustment) -> Option<Token> {
602 let mut best: Option<Token> = None;
603 let mut best_val = i32::MIN;
604 for (i, &l) in logits.iter().enumerate() {
605 if mask.get(i).copied() == Some(false) {
606 continue;
607 }
608 let v = l.saturating_add(adj.delta_for(i as Token));
609 if v > best_val {
610 best_val = v;
611 best = Some(i as Token);
612 }
613 }
614 best
615}
616
617#[cfg(test)]
618mod tests {
619 use super::*;
620 use crate::defaults::NullEngine;
621 use crate::ports::{GrammarMasker, Ports};
622 use el_core::{ModelFormat, ModelId, ModelVersion};
623 use el_provenance::{ModelArtifact, SignatureVerifier};
624 use el_safety::LightweightFilter;
625
626 struct OkVerifier;
627 impl SignatureVerifier for OkVerifier {
628 fn verify(&self, _b: &[u8], _s: &[u8], _k: u32) -> bool {
629 true
630 }
631 }
632
633 fn permit() -> LoadPermit {
634 let mut a = ModelArtifact::new(ModelId(1), ModelVersion::new(0, 1, 0), ModelFormat::Gguf);
635 a.verify(&OkVerifier, b"weights", b"sig", 1);
636 a.ensure_loadable().expect("verified artifact loads")
637 }
638
639 struct FixedEngine {
642 logits: Vec<i32>,
643 }
644 impl InferenceEngine for FixedEngine {
645 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
646 Ok(t.len() as u32)
647 }
648 fn next_logits(&mut self, _c: &[Token]) -> Vec<i32> {
649 self.logits.clone()
650 }
651 fn eos_token(&self) -> Token {
652 9999
653 }
654 fn rollback(&mut self, _keep: u32) -> Result<()> {
655 Ok(()) }
657 fn reset_cache(&mut self) -> Result<()> {
658 Ok(()) }
660 }
661
662 struct DisallowMasker(Vec<Token>);
664 impl GrammarMasker for DisallowMasker {
665 fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
666 (0..vocab as Token).map(|t| !self.0.contains(&t)).collect()
667 }
668 }
669
670 #[test]
671 fn full_lifecycle_init_prefill_decode_complete_reset() {
672 let mut s = InferenceSession::new(
673 SessionId(1),
674 SessionConfig::default(),
675 NullEngine::new(3, 8),
676 permit(),
677 );
678 assert_eq!(s.phase(), Phase::Initialized);
679
680 let ports = Ports::permissive();
681 s.load_prompt(&ports, &[10, 11, 12]).unwrap();
682 assert_eq!(s.phase(), Phase::Decoding);
683
684 let stop = s.generate(&ports, 16).unwrap();
685 assert_eq!(stop, StopReason::Eos); assert_eq!(s.output(), &[3]);
687 assert_eq!(s.phase(), Phase::Completed);
688
689 s.reset().unwrap();
690 assert_eq!(s.phase(), Phase::Initialized);
691 assert!(s.output().is_empty());
692 }
693
694 #[test]
695 fn decode_applies_grammar_before_safety_before_sampling() {
696 let engine = FixedEngine {
698 logits: vec![10, 9, 8, 7],
699 };
700 let mut s = InferenceSession::new(SessionId(2), SessionConfig::default(), engine, permit());
701
702 let ports = Ports {
703 compressor: Box::new(crate::defaults::IdentityCompressor),
704 grammar: Box::new(DisallowMasker(vec![0])), safety: Box::new(LightweightFilter::new(vec![1])), guard: None,
707 ingress: None,
708 relay: None,
709 };
710 s.load_prompt(&ports, &[1]).unwrap();
711 let stop = s.generate(&ports, 1).unwrap();
712
713 assert_eq!(stop, StopReason::MaxTokens);
714 assert_eq!(s.output(), &[2]);
717 }
718
719 #[test]
720 fn generate_before_load_prompt_is_invalid_phase() {
721 let mut s = InferenceSession::new(
722 SessionId(3),
723 SessionConfig::default(),
724 NullEngine::new(0, 4),
725 permit(),
726 );
727 let ports = Ports::permissive();
728 let err = s.generate(&ports, 4).unwrap_err();
729 assert!(matches!(err, EdgeError::InvalidPhase { .. }));
730 }
731
732 #[test]
733 fn relay_is_blocked_unless_hybrid_mode_opted_in() {
734 struct EchoRelay;
735 impl crate::ports::HybridRelay for EchoRelay {
736 fn consult(&self, q: &[Token]) -> Vec<Token> {
737 q.to_vec()
738 }
739 }
740
741 let mut s = InferenceSession::new(
743 SessionId(4),
744 SessionConfig::default(),
745 NullEngine::new(0, 4),
746 permit(),
747 );
748 let ports = Ports {
749 relay: Some(Box::new(EchoRelay)),
750 ..Ports::permissive()
751 };
752 assert_eq!(
753 s.consult_relay(&ports, &[1, 2]).unwrap_err(),
754 EdgeError::AirGapViolation
755 );
756
757 let cfg = SessionConfig {
759 hybrid_mode: true,
760 ..SessionConfig::default()
761 };
762 let mut s2 = InferenceSession::new(SessionId(5), cfg, NullEngine::new(0, 4), permit());
763 assert_eq!(s2.consult_relay(&ports, &[1, 2]).unwrap(), vec![1, 2]);
764
765 let no_relay = Ports::permissive();
767 assert_eq!(
768 s2.consult_relay(&no_relay, &[1]).unwrap_err(),
769 EdgeError::AirGapViolation
770 );
771 }
772
773 #[test]
774 fn first_events_are_init_then_model_loaded() {
775 let mut s = InferenceSession::new(
776 SessionId(6),
777 SessionConfig::default(),
778 NullEngine::new(0, 4),
779 permit(),
780 );
781 let evs = s.drain_events();
782 assert!(matches!(
783 evs[0].event,
784 DomainEvent::SessionInitialized { .. }
785 ));
786 assert!(matches!(evs[1].event, DomainEvent::ModelLoaded { .. }));
787 }
788
789 use el_safety::{ChunkGuard, SafetyScore};
792
793 struct BanToken(Token);
795 impl ChunkGuard for BanToken {
796 fn score(&self, recent: &[Token]) -> SafetyScore {
797 if recent.contains(&self.0) {
798 SafetyScore::MAX
799 } else {
800 SafetyScore::SAFE
801 }
802 }
803 }
804
805 struct AlwaysHot;
807 impl ChunkGuard for AlwaysHot {
808 fn score(&self, _recent: &[Token]) -> SafetyScore {
809 SafetyScore::MAX
810 }
811 }
812
813 fn tiny_policy(max_rollbacks: u8) -> RollbackPolicy {
814 RollbackPolicy {
815 guard_every: 1,
816 steer_window: 0,
817 soft_threshold: SafetyScore::from_milli(500),
818 hard_threshold: SafetyScore::from_milli(800),
819 max_rollbacks,
820 max_checkpoints: 8,
821 }
822 }
823
824 struct DenyAllMasker;
827 impl GrammarMasker for DenyAllMasker {
828 fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
829 vec![false; vocab]
830 }
831 }
832
833 fn descending_engine() -> FixedEngine {
836 FixedEngine {
837 logits: vec![5, 4, 3, 2],
838 }
839 }
840
841 struct UnsafeThenEos {
845 eos: Token,
846 vocab: usize,
847 }
848 impl InferenceEngine for UnsafeThenEos {
849 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
850 Ok(t.len() as u32)
851 }
852 fn next_logits(&mut self, ctx: &[Token]) -> Vec<i32> {
853 let mut v = vec![0i32; self.vocab];
854 if ctx.is_empty() {
855 v[0] = 10; } else {
857 v[self.eos as usize] = 10; }
859 v
860 }
861 fn eos_token(&self) -> Token {
862 self.eos
863 }
864 fn rollback(&mut self, _keep: u32) -> Result<()> {
865 Ok(()) }
867 fn reset_cache(&mut self) -> Result<()> {
868 Ok(()) }
870 }
871
872 fn coarse_policy(max_rollbacks: u8) -> RollbackPolicy {
875 RollbackPolicy {
876 guard_every: 16,
877 steer_window: 0,
878 soft_threshold: SafetyScore::from_milli(500),
879 hard_threshold: SafetyScore::from_milli(800),
880 max_rollbacks,
881 max_checkpoints: 8,
882 }
883 }
884
885 #[test]
886 fn eos_terminated_short_completion_is_scored_not_bypassed() {
887 let mut s = InferenceSession::new(
890 SessionId(26),
891 SessionConfig::default(),
892 UnsafeThenEos { eos: 5, vocab: 8 },
893 permit(),
894 );
895 let ports = guarded_ports(Box::new(BanToken(0)));
896 s.load_prompt(&ports, &[]).unwrap();
897
898 let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
900
901 assert_eq!(stop, StopReason::Stopped);
902 assert!(s.output().is_empty()); let evs = s.drain_events();
904 assert!(evs
905 .iter()
906 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
907 }
908
909 #[test]
910 fn max_tokens_partial_chunk_is_scored_not_bypassed() {
911 let mut s = InferenceSession::new(
914 SessionId(27),
915 SessionConfig::default(),
916 descending_engine(), permit(),
918 );
919 let ports = guarded_ports(Box::new(BanToken(0)));
920 s.load_prompt(&ports, &[]).unwrap();
921
922 let stop = s.generate_with_policy(&ports, 2, coarse_policy(0)).unwrap();
924
925 assert_eq!(stop, StopReason::Stopped);
926 assert!(s.output().is_empty());
927 let evs = s.drain_events();
928 assert!(evs
929 .iter()
930 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
931 }
932
933 #[test]
934 fn eos_unsafe_tail_rolls_back_and_recovers() {
935 let mut s = InferenceSession::new(
939 SessionId(28),
940 SessionConfig::default(),
941 UnsafeThenEos { eos: 5, vocab: 8 },
942 permit(),
943 );
944 let ports = guarded_ports(Box::new(BanToken(0)));
945 s.load_prompt(&ports, &[]).unwrap();
946
947 let stop = s.generate_with_policy(&ports, 8, coarse_policy(1)).unwrap();
948
949 assert_eq!(stop, StopReason::Eos);
950 assert!(!s.output().contains(&0)); assert_eq!(s.output().last(), Some(&5)); let evs = s.drain_events();
953 assert!(evs
954 .iter()
955 .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
956 }
957
958 struct StatefulEngine {
965 fed: usize,
966 logits: Vec<i32>,
967 eos: Token,
968 rollbacks: std::rc::Rc<std::cell::Cell<u32>>,
969 last_keep: std::rc::Rc<std::cell::Cell<u32>>,
970 desynced: std::rc::Rc<std::cell::Cell<bool>>,
971 }
972 impl InferenceEngine for StatefulEngine {
973 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
974 self.fed = 0;
975 Ok(t.len() as u32)
976 }
977 fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
978 if self.fed > committed.len() {
981 self.desynced.set(true);
982 }
983 while self.fed < committed.len() {
984 self.fed += 1;
985 }
986 self.logits.clone()
987 }
988 fn eos_token(&self) -> Token {
989 self.eos
990 }
991 fn rollback(&mut self, keep_committed: u32) -> Result<()> {
992 self.fed = keep_committed as usize; self.rollbacks.set(self.rollbacks.get() + 1);
994 self.last_keep.set(keep_committed);
995 Ok(())
996 }
997 fn reset_cache(&mut self) -> Result<()> {
998 self.fed = 0; Ok(())
1000 }
1001 }
1002
1003 #[test]
1004 fn rollback_restores_engine_state_not_just_session_metadata() {
1005 let rollbacks = std::rc::Rc::new(std::cell::Cell::new(0u32));
1009 let last_keep = std::rc::Rc::new(std::cell::Cell::new(u32::MAX));
1010 let desynced = std::rc::Rc::new(std::cell::Cell::new(false));
1011 let engine = StatefulEngine {
1012 fed: 0,
1013 logits: vec![5, 4, 3, 2], eos: 9999,
1015 rollbacks: rollbacks.clone(),
1016 last_keep: last_keep.clone(),
1017 desynced: desynced.clone(),
1018 };
1019 let mut s =
1020 InferenceSession::new(SessionId(29), SessionConfig::default(), engine, permit());
1021 let ports = guarded_ports(Box::new(BanToken(0)));
1022 s.load_prompt(&ports, &[7, 8]).unwrap(); let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
1025
1026 assert_eq!(stop, StopReason::MaxTokens);
1027 assert_eq!(s.output(), &[1, 1, 1]); assert!(
1030 rollbacks.get() >= 1,
1031 "session must propagate the backtrack to the engine"
1032 );
1033 assert!(last_keep.get() < 3);
1035 assert!(
1036 !desynced.get(),
1037 "engine cache must track the session rollback"
1038 );
1039 }
1040
1041 fn guarded_ports(guard: Box<dyn ChunkGuard>) -> Ports {
1042 Ports {
1043 compressor: Box::new(crate::defaults::IdentityCompressor),
1044 grammar: Box::new(crate::defaults::AllowAllMasker),
1045 safety: Box::new(el_safety::NoSafety),
1046 guard: Some(guard),
1047 ingress: None,
1048 relay: None,
1049 }
1050 }
1051
1052 #[test]
1053 fn hard_breach_rolls_back_kv_and_recovers() {
1054 let mut s = InferenceSession::new(
1055 SessionId(20),
1056 SessionConfig::default(),
1057 descending_engine(),
1058 permit(),
1059 );
1060 let ports = guarded_ports(Box::new(BanToken(0)));
1061 s.load_prompt(&ports, &[]).unwrap();
1062
1063 let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
1064
1065 assert_eq!(stop, StopReason::MaxTokens);
1066 assert_eq!(s.output(), &[1, 1, 1]);
1069 assert!(!s.output().contains(&0));
1070 assert_eq!(s.kv_len(), s.output().len() as u32);
1072
1073 let evs = s.drain_events();
1074 assert!(evs
1075 .iter()
1076 .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1077 assert!(evs
1078 .iter()
1079 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1080 }
1081
1082 #[test]
1083 fn fail_closed_refusal_when_no_rollback_budget() {
1084 let mut s = InferenceSession::new(
1085 SessionId(21),
1086 SessionConfig::default(),
1087 descending_engine(),
1088 permit(),
1089 );
1090 let ports = guarded_ports(Box::new(BanToken(0)));
1091 s.load_prompt(&ports, &[]).unwrap();
1092
1093 let stop = s.generate_with_policy(&ports, 5, tiny_policy(0)).unwrap();
1095
1096 assert_eq!(stop, StopReason::Stopped);
1097 assert!(s.output().is_empty());
1098 let evs = s.drain_events();
1099 assert!(evs
1100 .iter()
1101 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1102 assert!(!evs
1103 .iter()
1104 .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1105 }
1106
1107 #[test]
1108 fn rollbacks_are_bounded_then_fail_closed() {
1109 let mut s = InferenceSession::new(
1110 SessionId(22),
1111 SessionConfig::default(),
1112 descending_engine(),
1113 permit(),
1114 );
1115 let ports = guarded_ports(Box::new(AlwaysHot));
1116 s.load_prompt(&ports, &[]).unwrap();
1117
1118 let stop = s.generate_with_policy(&ports, 8, tiny_policy(2)).unwrap();
1119
1120 assert_eq!(stop, StopReason::Stopped);
1121 let evs = s.drain_events();
1122 let rollbacks = evs
1123 .iter()
1124 .filter(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. }))
1125 .count();
1126 assert_eq!(rollbacks, 2);
1128 }
1129
1130 #[test]
1131 fn memory_pressure_disables_checkpoints_and_fails_closed() {
1132 let cfg = SessionConfig {
1133 memory_budget_bytes: 1024, ..SessionConfig::default()
1135 };
1136 let mut s = InferenceSession::new(SessionId(23), cfg, descending_engine(), permit());
1137 let ports = guarded_ports(Box::new(BanToken(0)));
1138 s.load_prompt(&ports, &[]).unwrap();
1139
1140 let stop = s.generate_with_policy(&ports, 5, tiny_policy(3)).unwrap();
1141
1142 assert_eq!(stop, StopReason::Stopped); let evs = s.drain_events();
1144 assert!(evs.iter().any(|e| matches!(
1145 e.event,
1146 DomainEvent::SafetyDisabled {
1147 reason: DegradeReason::MemoryPressure
1148 }
1149 )));
1150 }
1151
1152 #[test]
1153 fn no_legal_token_fails_closed() {
1154 let mut s = InferenceSession::new(
1157 SessionId(24),
1158 SessionConfig::default(),
1159 descending_engine(),
1160 permit(),
1161 );
1162 let ports = Ports {
1163 compressor: Box::new(crate::defaults::IdentityCompressor),
1164 grammar: Box::new(DenyAllMasker),
1165 safety: Box::new(el_safety::NoSafety),
1166 guard: None,
1167 ingress: None,
1168 relay: None,
1169 };
1170 s.load_prompt(&ports, &[]).unwrap();
1171
1172 let stop = s.generate(&ports, 4).unwrap();
1173
1174 assert_eq!(stop, StopReason::Stopped);
1175 assert!(s.output().is_empty()); let evs = s.drain_events();
1177 assert!(evs
1178 .iter()
1179 .any(|e| matches!(e.event, DomainEvent::GrammarViolationBlocked)));
1180 }
1181
1182 #[test]
1183 fn fail_closed_preserves_prefill_kv() {
1184 let mut s = InferenceSession::new(
1188 SessionId(25),
1189 SessionConfig::default(),
1190 descending_engine(),
1191 permit(),
1192 );
1193 let ports = guarded_ports(Box::new(AlwaysHot));
1194 s.load_prompt(&ports, &[7, 8, 9]).unwrap(); assert_eq!(s.kv_len(), 3);
1196
1197 let stop = s.generate_with_policy(&ports, 8, tiny_policy(1)).unwrap();
1198
1199 assert_eq!(stop, StopReason::Stopped);
1200 assert!(s.output().is_empty()); assert_eq!(s.kv_len(), 3); }
1203
1204 use el_core::SafetyMode;
1207 use el_safety::SafetySteerer;
1208
1209 struct RecordingSteerer {
1212 log: std::rc::Rc<std::cell::RefCell<Vec<(bool, usize)>>>,
1213 }
1214 impl SafetySteerer for RecordingSteerer {
1215 fn adjust(&self, recent: &[Token]) -> LogitAdjustment {
1216 self.log.borrow_mut().push((false, recent.len()));
1217 LogitAdjustment::none()
1218 }
1219 fn adjust_with_logits(&self, recent: &[Token], _base: &[i32]) -> LogitAdjustment {
1220 self.log.borrow_mut().push((true, recent.len()));
1221 LogitAdjustment::none()
1222 }
1223 fn mode(&self) -> SafetyMode {
1224 SafetyMode::SecDecoding
1225 }
1226 }
1227
1228 #[test]
1229 fn soft_steer_applies_only_inside_the_early_token_window() {
1230 let log = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1233 let mut s = InferenceSession::new(
1234 SessionId(30),
1235 SessionConfig::default(),
1236 descending_engine(), permit(),
1238 );
1239 let ports = Ports {
1240 compressor: Box::new(crate::defaults::IdentityCompressor),
1241 grammar: Box::new(crate::defaults::AllowAllMasker),
1242 safety: Box::new(RecordingSteerer { log: log.clone() }),
1243 guard: None,
1244 ingress: None,
1245 relay: None,
1246 };
1247 s.load_prompt(&ports, &[]).unwrap();
1248 let policy = RollbackPolicy {
1249 guard_every: 0,
1250 steer_window: 2,
1251 soft_threshold: SafetyScore::MAX,
1252 hard_threshold: SafetyScore::MAX,
1253 max_rollbacks: 0,
1254 max_checkpoints: 0,
1255 };
1256 s.generate_with_policy(&ports, 4, policy).unwrap();
1257
1258 let calls = log.borrow();
1259 assert_eq!(calls.len(), 4);
1260 for &(with_logits, len) in calls.iter() {
1261 assert_eq!(
1262 with_logits,
1263 len < 2,
1264 "window gate wrong at output len {len}"
1265 );
1266 }
1267 }
1268
1269 #[test]
1270 fn ingress_triage_fails_closed_before_generation() {
1271 let mut s = InferenceSession::new(
1273 SessionId(31),
1274 SessionConfig::default(),
1275 descending_engine(),
1276 permit(),
1277 );
1278 let ports = Ports {
1279 compressor: Box::new(crate::defaults::IdentityCompressor),
1280 grammar: Box::new(crate::defaults::AllowAllMasker),
1281 safety: Box::new(el_safety::NoSafety),
1282 guard: None,
1283 ingress: Some(Box::new(AlwaysHot)), relay: None,
1285 };
1286 s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1287
1288 let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
1289
1290 assert_eq!(stop, StopReason::Stopped);
1291 assert!(s.output().is_empty());
1292 let evs = s.drain_events();
1293 assert!(evs
1294 .iter()
1295 .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1296 assert!(!evs
1298 .iter()
1299 .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })));
1300 }
1301
1302 #[test]
1303 fn generate_applies_safety_mode_selector_and_records_effective_mode() {
1304 let cfg = SessionConfig {
1307 device: el_core::DeviceTarget::MidRange,
1308 safety: SafetyMode::SecDecoding,
1309 ..SessionConfig::default()
1310 };
1311 let mut s = InferenceSession::new(SessionId(32), cfg, NullEngine::new(0, 4), permit());
1312 let ports = Ports::permissive();
1313 s.load_prompt(&ports, &[1]).unwrap();
1314 s.generate(&ports, 4).unwrap();
1315
1316 let evs = s.drain_events();
1317 assert!(evs.iter().any(|e| matches!(
1318 e.event,
1319 DomainEvent::SafetyModeSelected {
1320 mode: SafetyMode::Lightweight
1321 }
1322 )));
1323 }
1324
1325 struct CountingEngine {
1331 eos: Token,
1332 vocab: usize,
1333 prefills: std::rc::Rc<std::cell::Cell<u32>>,
1334 resets: std::rc::Rc<std::cell::Cell<u32>>,
1335 }
1336 impl InferenceEngine for CountingEngine {
1337 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1338 self.prefills.set(self.prefills.get() + 1);
1339 Ok(t.len() as u32)
1340 }
1341 fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1342 let mut v = vec![0i32; self.vocab];
1343 if let Some(s) = v.get_mut(self.eos as usize) {
1344 *s = 1;
1345 }
1346 v
1347 }
1348 fn eos_token(&self) -> Token {
1349 self.eos
1350 }
1351 fn rollback(&mut self, _keep: u32) -> Result<()> {
1352 Ok(())
1353 }
1354 fn reset_cache(&mut self) -> Result<()> {
1355 self.resets.set(self.resets.get() + 1);
1356 Ok(())
1357 }
1358 }
1359
1360 fn counting_engine() -> (
1361 CountingEngine,
1362 std::rc::Rc<std::cell::Cell<u32>>,
1363 std::rc::Rc<std::cell::Cell<u32>>,
1364 ) {
1365 let prefills = std::rc::Rc::new(std::cell::Cell::new(0u32));
1366 let resets = std::rc::Rc::new(std::cell::Cell::new(0u32));
1367 let engine = CountingEngine {
1368 eos: 1,
1369 vocab: 4,
1370 prefills: prefills.clone(),
1371 resets: resets.clone(),
1372 };
1373 (engine, prefills, resets)
1374 }
1375
1376 #[test]
1377 fn reset_resets_the_engine_cache() {
1378 let (engine, _prefills, resets) = counting_engine();
1382 let mut s =
1383 InferenceSession::new(SessionId(40), SessionConfig::default(), engine, permit());
1384 assert_eq!(resets.get(), 0);
1385 s.reset().unwrap();
1386 assert_eq!(
1387 resets.get(),
1388 1,
1389 "reset() must reset the engine cache (ADR-018)"
1390 );
1391 }
1392
1393 #[test]
1394 fn one_engine_serves_multiple_conversations_via_reset() {
1395 let (engine, prefills, resets) = counting_engine();
1399 let mut s =
1400 InferenceSession::new(SessionId(41), SessionConfig::default(), engine, permit());
1401 let ports = Ports::permissive();
1402
1403 s.load_prompt(&ports, &[1, 2]).unwrap();
1404 s.generate(&ports, 8).unwrap();
1405 assert_eq!(prefills.get(), 1);
1406
1407 s.reset().unwrap();
1409 s.load_prompt(&ports, &[3, 4, 5]).unwrap();
1410 s.generate(&ports, 8).unwrap();
1411
1412 assert_eq!(
1413 prefills.get(),
1414 2,
1415 "the one resident engine was prefilled again, not rebuilt"
1416 );
1417 assert!(
1418 resets.get() >= 1,
1419 "the engine cache was reset between conversations"
1420 );
1421 }
1422
1423 struct ResidentEngine {
1426 eos: Token,
1427 vocab: usize,
1428 resets: std::rc::Rc<std::cell::Cell<u32>>,
1429 dropped: std::rc::Rc<std::cell::Cell<u32>>,
1430 }
1431 impl Drop for ResidentEngine {
1432 fn drop(&mut self) {
1433 self.dropped.set(self.dropped.get() + 1);
1434 }
1435 }
1436 impl InferenceEngine for ResidentEngine {
1437 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1438 Ok(t.len() as u32)
1439 }
1440 fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1441 let mut v = vec![0i32; self.vocab];
1442 if let Some(s) = v.get_mut(self.eos as usize) {
1443 *s = 1;
1444 }
1445 v
1446 }
1447 fn eos_token(&self) -> Token {
1448 self.eos
1449 }
1450 fn rollback(&mut self, _keep: u32) -> Result<()> {
1451 Ok(())
1452 }
1453 fn reset_cache(&mut self) -> Result<()> {
1454 self.resets.set(self.resets.get() + 1);
1455 Ok(())
1456 }
1457 }
1458
1459 #[test]
1460 fn close_releases_conversation_but_keeps_engine_resident() {
1461 let resets = std::rc::Rc::new(std::cell::Cell::new(0u32));
1466 let dropped = std::rc::Rc::new(std::cell::Cell::new(0u32));
1467 let engine = ResidentEngine {
1468 eos: 1,
1469 vocab: 4,
1470 resets: resets.clone(),
1471 dropped: dropped.clone(),
1472 };
1473 let mut s =
1474 InferenceSession::new(SessionId(42), SessionConfig::default(), engine, permit());
1475 let ports = Ports::permissive();
1476 s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1477 s.generate(&ports, 8).unwrap();
1478 assert!(s.kv_len() > 0, "conversation memory is measurable (AC-4)");
1479
1480 s.close().unwrap();
1481
1482 assert_eq!(s.kv_len(), 0, "close frees the session's KV descriptors");
1483 assert!(s.output().is_empty(), "close frees committed output");
1484 assert!(
1485 resets.get() >= 1,
1486 "close releases the engine's conversation KV (keeps weights)"
1487 );
1488 assert_eq!(
1489 dropped.get(),
1490 0,
1491 "the resident engine/weights are NOT dropped by close"
1492 );
1493
1494 s.load_prompt(&ports, &[4, 5]).unwrap();
1496 s.generate(&ports, 8).unwrap();
1497 assert!(
1498 s.kv_len() > 0,
1499 "same engine serves a new conversation after close"
1500 );
1501 assert_eq!(dropped.get(), 0);
1502 }
1503
1504 #[test]
1505 fn reset_preserves_undrained_events() {
1506 let (engine, _prefills, _resets) = counting_engine();
1510 let mut s =
1511 InferenceSession::new(SessionId(44), SessionConfig::default(), engine, permit());
1512 let ports = Ports::permissive();
1513 s.load_prompt(&ports, &[1, 2]).unwrap();
1514 s.generate(&ports, 8).unwrap(); s.reset().unwrap();
1517
1518 let evs = s.drain_events();
1519 assert!(
1520 evs.iter()
1521 .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })),
1522 "pre-reset events are preserved for the consumer"
1523 );
1524 assert!(
1525 evs.iter()
1526 .any(|e| matches!(e.event, DomainEvent::SessionReset)),
1527 "and the reset itself is recorded"
1528 );
1529 }
1530
1531 struct FailResetEngine {
1533 eos: Token,
1534 vocab: usize,
1535 }
1536 impl InferenceEngine for FailResetEngine {
1537 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1538 Ok(t.len() as u32)
1539 }
1540 fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1541 let mut v = vec![0i32; self.vocab];
1542 if let Some(s) = v.get_mut(self.eos as usize) {
1543 *s = 1;
1544 }
1545 v
1546 }
1547 fn eos_token(&self) -> Token {
1548 self.eos
1549 }
1550 fn rollback(&mut self, _keep: u32) -> Result<()> {
1551 Ok(())
1552 }
1553 fn reset_cache(&mut self) -> Result<()> {
1554 Err(EdgeError::Engine("reset_cache refused"))
1555 }
1556 }
1557
1558 #[test]
1559 fn reset_propagates_engine_cache_failure_and_leaves_state() {
1560 let mut s = InferenceSession::new(
1564 SessionId(45),
1565 SessionConfig::default(),
1566 FailResetEngine { eos: 1, vocab: 4 },
1567 permit(),
1568 );
1569 let ports = Ports::permissive();
1570 s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1571 s.generate(&ports, 8).unwrap();
1572 let kv_before = s.kv_len();
1573 assert!(kv_before > 0);
1574
1575 let err = s.reset().unwrap_err();
1576 assert!(matches!(err, EdgeError::Engine(_)));
1577 assert_eq!(
1578 s.kv_len(),
1579 kv_before,
1580 "descriptors must be left intact when the engine reset fails"
1581 );
1582 assert_eq!(
1583 s.phase(),
1584 Phase::Completed,
1585 "phase is unchanged on a failed reset"
1586 );
1587 }
1588
1589 struct RetryClearEngine {
1593 eos: Token,
1594 vocab: usize,
1595 dirty: bool,
1596 fails_left: std::rc::Rc<std::cell::Cell<u32>>,
1597 cleared: std::rc::Rc<std::cell::Cell<bool>>,
1598 }
1599 impl InferenceEngine for RetryClearEngine {
1600 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1601 self.dirty = true;
1602 Ok(t.len() as u32)
1603 }
1604 fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1605 self.dirty = true;
1606 let mut v = vec![0i32; self.vocab];
1607 if let Some(s) = v.get_mut(self.eos as usize) {
1608 *s = 1;
1609 }
1610 v
1611 }
1612 fn eos_token(&self) -> Token {
1613 self.eos
1614 }
1615 fn rollback(&mut self, _keep: u32) -> Result<()> {
1616 Ok(())
1617 }
1618 fn reset_cache(&mut self) -> Result<()> {
1619 if self.dirty {
1620 if self.fails_left.get() > 0 {
1621 self.fails_left.set(self.fails_left.get() - 1);
1622 return Err(EdgeError::Engine("partial eviction")); }
1624 self.dirty = false;
1625 self.cleared.set(true);
1626 }
1627 Ok(())
1628 }
1629 }
1630
1631 #[test]
1632 fn reset_retries_clearing_after_a_failed_attempt() {
1633 let fails_left = std::rc::Rc::new(std::cell::Cell::new(1u32));
1638 let cleared = std::rc::Rc::new(std::cell::Cell::new(false));
1639 let engine = RetryClearEngine {
1640 eos: 1,
1641 vocab: 4,
1642 dirty: false,
1643 fails_left: fails_left.clone(),
1644 cleared: cleared.clone(),
1645 };
1646 let mut s =
1647 InferenceSession::new(SessionId(47), SessionConfig::default(), engine, permit());
1648 let ports = Ports::permissive();
1649 s.load_prompt(&ports, &[1, 2]).unwrap();
1650 s.generate(&ports, 8).unwrap();
1651
1652 assert!(s.reset().is_err());
1654 assert!(
1655 !cleared.get(),
1656 "a failed reset must not report the cache cleared"
1657 );
1658
1659 s.reset().unwrap();
1661 assert!(
1662 cleared.get(),
1663 "the next reset re-clears after a failed attempt"
1664 );
1665 }
1666
1667 struct ReuseEngine {
1677 eos: Token,
1678 vocab: usize,
1679 index_pos: usize,
1680 fed: usize,
1681 cached: Vec<Token>,
1682 prompt: Vec<Token>,
1683 last_logits: Vec<i32>,
1686 forwards: std::rc::Rc<std::cell::Cell<u32>>,
1687 eos_immediately: bool,
1690 prefill_delay: std::time::Duration,
1691 }
1692 impl ReuseEngine {
1693 fn new(eos: Token, vocab: usize, eos_immediately: bool) -> Self {
1694 Self {
1695 eos,
1696 vocab,
1697 index_pos: 0,
1698 fed: 0,
1699 cached: Vec::new(),
1700 prompt: Vec::new(),
1701 last_logits: Vec::new(),
1702 forwards: std::rc::Rc::new(std::cell::Cell::new(0)),
1703 eos_immediately,
1704 prefill_delay: std::time::Duration::ZERO,
1705 }
1706 }
1707 fn with_prefill_delay(
1708 eos: Token,
1709 vocab: usize,
1710 eos_immediately: bool,
1711 prefill_delay: std::time::Duration,
1712 ) -> Self {
1713 let mut engine = Self::new(eos, vocab, eos_immediately);
1714 engine.prefill_delay = prefill_delay;
1715 engine
1716 }
1717 fn forward(&mut self, t: Token) {
1718 self.forwards.set(self.forwards.get() + 1);
1719 self.cached.push(t);
1720 self.index_pos += 1;
1721 self.last_logits = self.logits_for_cache();
1722 }
1723 fn logits_for_cache(&self) -> Vec<i32> {
1724 let mut v = vec![0i32; self.vocab];
1725 if self.eos_immediately {
1726 if let Some(s) = v.get_mut(self.eos as usize) {
1727 *s = 1_000_000;
1728 }
1729 return v;
1730 }
1731 let h = self
1734 .cached
1735 .iter()
1736 .fold(0i32, |a, &t| a.wrapping_mul(31).wrapping_add(t as i32 + 1));
1737 for (i, slot) in v.iter_mut().enumerate() {
1738 *slot = h.wrapping_add(i as i32);
1739 }
1740 v
1741 }
1742 }
1743 impl InferenceEngine for ReuseEngine {
1744 fn prefill(&mut self, tokens: &[Token]) -> Result<u32> {
1745 self.index_pos = 0;
1746 self.fed = 0;
1747 self.cached = Vec::new();
1748 self.last_logits = Vec::new();
1749 self.prompt = tokens.to_vec();
1750 for &t in tokens {
1751 self.forward(t);
1752 }
1753 Ok(tokens.len() as u32)
1754 }
1755 fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
1756 while self.fed < committed.len() {
1757 self.forward(committed[self.fed]);
1758 self.fed += 1;
1759 }
1760 self.last_logits.clone()
1761 }
1762 fn eos_token(&self) -> Token {
1763 self.eos
1764 }
1765 fn rollback(&mut self, _keep: u32) -> Result<()> {
1766 self.index_pos = 0;
1767 self.fed = 0;
1768 self.cached = Vec::new();
1769 self.last_logits = Vec::new();
1770 let prompt = self.prompt.clone();
1771 for &t in &prompt {
1772 self.forward(t);
1773 }
1774 Ok(())
1775 }
1776 fn reset_cache(&mut self) -> Result<()> {
1777 self.index_pos = 0;
1778 self.fed = 0;
1779 self.cached = Vec::new();
1780 self.last_logits = Vec::new();
1781 self.prompt = Vec::new();
1782 Ok(())
1783 }
1784 fn prefill_reuse(&mut self, full_context: &[Token]) -> Result<u32> {
1785 std::thread::sleep(self.prefill_delay);
1786 let reuse = self
1787 .cached
1788 .iter()
1789 .zip(full_context)
1790 .take_while(|(a, b)| a == b)
1791 .count();
1792 if reuse == self.cached.len() && reuse == self.index_pos {
1793 for &t in &full_context[reuse..] {
1794 self.forward(t);
1795 }
1796 } else {
1797 self.index_pos = 0;
1798 self.cached = Vec::new();
1799 self.last_logits = Vec::new(); for &t in full_context {
1801 self.forward(t);
1802 }
1803 }
1804 self.fed = 0;
1805 self.prompt = full_context.to_vec();
1806 Ok(self.index_pos as u32)
1807 }
1808 }
1809
1810 struct FailReuseEngine {
1813 eos: Token,
1814 vocab: usize,
1815 }
1816 impl InferenceEngine for FailReuseEngine {
1817 fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1818 Ok(t.len() as u32)
1819 }
1820 fn next_logits(&mut self, _c: &[Token]) -> Vec<i32> {
1821 let mut v = vec![0i32; self.vocab];
1822 if let Some(s) = v.get_mut(self.eos as usize) {
1823 *s = 1;
1824 }
1825 v
1826 }
1827 fn eos_token(&self) -> Token {
1828 self.eos
1829 }
1830 fn rollback(&mut self, _keep: u32) -> Result<()> {
1831 Ok(())
1832 }
1833 fn reset_cache(&mut self) -> Result<()> {
1834 Ok(())
1835 }
1836 fn prefill_reuse(&mut self, _full_context: &[Token]) -> Result<u32> {
1837 Err(EdgeError::Engine("prefill_reuse failed"))
1838 }
1839 }
1840
1841 #[test]
1842 fn continue_prompt_reuses_cached_prefix_and_feeds_only_suffix() {
1843 let engine = ReuseEngine::new(1, 4, true);
1846 let forwards = engine.forwards.clone();
1847 let mut s =
1848 InferenceSession::new(SessionId(50), SessionConfig::default(), engine, permit());
1849 let ports = Ports::permissive();
1850
1851 s.load_prompt(&ports, &[10, 11]).unwrap();
1852 s.generate(&ports, 8).unwrap(); assert_eq!(forwards.get(), 2, "turn 1 prefilled the 2 prompt tokens");
1854
1855 s.continue_prompt(&ports, &[10, 11, 20, 21, 22]).unwrap();
1857 assert_eq!(
1858 forwards.get() - 2,
1859 3,
1860 "only the 3-token suffix was prefilled, not the whole 5-token context"
1861 );
1862 assert_eq!(s.phase(), Phase::Decoding);
1863 assert_eq!(s.kv_len(), 5);
1865
1866 s.generate(&ports, 8).unwrap();
1867 assert_eq!(s.output(), &[1]);
1868 assert_eq!(s.phase(), Phase::Completed);
1869 }
1870
1871 #[test]
1872 fn continue_prompt_does_not_report_throughput_for_reused_tokens() {
1873 let engine =
1874 ReuseEngine::with_prefill_delay(1, 4, true, std::time::Duration::from_millis(2));
1875 let mut s =
1876 InferenceSession::new(SessionId(55), SessionConfig::default(), engine, permit());
1877 let ports = Ports::permissive();
1878
1879 s.load_prompt(&ports, &[10, 11]).unwrap();
1880 s.generate(&ports, 8).unwrap();
1881 s.drain_events();
1882
1883 s.continue_prompt(&ports, &[10, 11, 20, 21, 22]).unwrap();
1884 let prefill = s
1885 .drain_events()
1886 .into_iter()
1887 .find_map(|envelope| match envelope.event {
1888 DomainEvent::PrefillCompleted {
1889 prompt_tokens,
1890 kv_len,
1891 prefill_tps,
1892 } => Some((prompt_tokens, kv_len, prefill_tps)),
1893 _ => None,
1894 })
1895 .expect("continue_prompt emits PrefillCompleted");
1896
1897 assert_eq!(prefill.0, 5);
1898 assert_eq!(prefill.1, 5);
1899 assert_eq!(
1900 prefill.2, 0,
1901 "the engine API does not report forwarded-token count, so reused tokens must not be included in prefill throughput"
1902 );
1903 }
1904
1905 #[test]
1906 fn continue_prompt_requires_a_completed_prior_turn() {
1907 let engine = ReuseEngine::new(1, 4, true);
1910 let mut s =
1911 InferenceSession::new(SessionId(51), SessionConfig::default(), engine, permit());
1912 let ports = Ports::permissive();
1913 let err = s.continue_prompt(&ports, &[1, 2]).unwrap_err();
1914 assert!(matches!(err, EdgeError::InvalidPhase { .. }));
1915 }
1916
1917 #[test]
1918 fn prefill_reuse_leaves_same_state_as_a_fresh_prefill() {
1919 let mut fresh = ReuseEngine::new(99, 6, false);
1924 fresh.prefill(&[1, 2, 3, 4]).unwrap();
1925 let want = fresh.next_logits(&[]);
1926
1927 let mut extend = ReuseEngine::new(99, 6, false);
1929 extend.prefill(&[1, 2]).unwrap();
1930 extend.prefill_reuse(&[1, 2, 3, 4]).unwrap();
1931 assert_eq!(
1932 extend.next_logits(&[]),
1933 want,
1934 "extend-reuse == fresh prefill"
1935 );
1936
1937 let mut rebuild = ReuseEngine::new(99, 6, false);
1939 rebuild.prefill(&[1, 9, 3]).unwrap();
1940 rebuild.prefill_reuse(&[1, 2, 3, 4]).unwrap();
1941 assert_eq!(
1942 rebuild.next_logits(&[]),
1943 want,
1944 "rebuild-on-divergence == fresh prefill"
1945 );
1946 }
1947
1948 #[test]
1949 fn default_prefill_reuse_does_a_full_recompute() {
1950 let mut e = NullEngine::new(1, 4);
1954 assert_eq!(e.prefill(&[1, 2]).unwrap(), 2);
1955 assert_eq!(
1956 e.prefill_reuse(&[1, 2, 3, 4, 5]).unwrap(),
1957 5,
1958 "default reuse re-prefills the full context length"
1959 );
1960 }
1961
1962 #[test]
1963 fn continued_turn_still_runs_the_safety_control_loop() {
1964 let mut s = InferenceSession::new(
1968 SessionId(52),
1969 SessionConfig::default(),
1970 descending_engine(), permit(),
1972 );
1973 let ports = guarded_ports(Box::new(BanToken(0)));
1974
1975 s.load_prompt(&ports, &[7]).unwrap();
1976 s.generate_with_policy(&ports, 2, tiny_policy(3)).unwrap();
1977 assert_eq!(s.phase(), Phase::Completed);
1978
1979 s.continue_prompt(&ports, &[7, 1, 1, 8]).unwrap();
1981 let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
1982
1983 assert_eq!(stop, StopReason::MaxTokens);
1984 assert!(
1985 !s.output().contains(&0),
1986 "the guard still bans the unsafe token on a continued turn"
1987 );
1988 assert_eq!(s.output(), &[1, 1, 1]);
1989 let evs = s.drain_events();
1990 assert!(evs
1991 .iter()
1992 .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1993 }
1994
1995 #[test]
1996 fn prefill_reuse_into_empty_context_drops_stale_logits() {
1997 let mut reused = ReuseEngine::new(9, 4, false);
2002 reused.prefill(&[1, 2, 3]).unwrap();
2003 assert!(!reused.next_logits(&[]).is_empty());
2004 reused.prefill_reuse(&[]).unwrap();
2005
2006 let mut fresh = ReuseEngine::new(9, 4, false);
2007 fresh.reset_cache().unwrap();
2008 fresh.prefill(&[]).unwrap();
2009
2010 assert_eq!(
2011 reused.next_logits(&[]),
2012 fresh.next_logits(&[]),
2013 "rebuild into an empty context == a fresh empty prefill"
2014 );
2015 assert!(
2016 reused.next_logits(&[]).is_empty(),
2017 "no stale distribution may survive the rebuild"
2018 );
2019 }
2020
2021 #[test]
2022 fn failed_continue_prompt_leaves_a_recoverable_session() {
2023 let mut s = InferenceSession::new(
2028 SessionId(54),
2029 SessionConfig::default(),
2030 FailReuseEngine { eos: 1, vocab: 4 },
2031 permit(),
2032 );
2033 let ports = Ports::permissive();
2034 s.load_prompt(&ports, &[10, 11]).unwrap();
2035 s.generate(&ports, 8).unwrap();
2036 assert_eq!(s.phase(), Phase::Completed);
2037
2038 let err = s.continue_prompt(&ports, &[10, 11, 12]).unwrap_err();
2039 assert!(matches!(err, EdgeError::Engine(_)));
2040 assert_ne!(
2041 s.phase(),
2042 Phase::Completed,
2043 "a failed reuse must not masquerade as a finished turn"
2044 );
2045
2046 s.reset().unwrap();
2048 s.load_prompt(&ports, &[20, 21]).unwrap();
2049 assert_eq!(s.phase(), Phase::Decoding);
2050 assert_eq!(s.generate(&ports, 8).unwrap(), StopReason::Eos);
2051 }
2052}