Skip to main content

el_runtime/
session.rs

1//! The `InferenceSession` aggregate root and decode-loop orchestrator.
2
3use 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
14/// Below this static-memory budget there is no room to retain rollback
15/// checkpoints, so the control loop degrades to guard-only (no rollback) and
16/// emits `SafetyDisabled` (ADR-012 tier-aware degradation; ADR-003 budget).
17const MIN_CHECKPOINT_BUDGET_BYTES: u64 = 64 * 1024 * 1024;
18
19/// One live generation. Constructing it requires a [`LoadPermit`], so a model
20/// that has not passed the provenance gate (ADR-006) cannot reach the runtime —
21/// the Conformist relationship is enforced in the type system.
22pub struct InferenceSession<E: InferenceEngine> {
23    id: SessionId,
24    config: SessionConfig,
25    phase: Phase,
26    engine: E,
27    kv: KvRegion,
28    permit: LoadPermit,
29    /// The prompt fed at `load_prompt`, retained for ADR-013 ingress triage
30    /// (scored before generation).
31    prompt: Vec<Token>,
32    output: Vec<Token>,
33    step: u32,
34    events: Vec<EventEnvelope>,
35}
36
37/// Outcome of one chunk-guard evaluation in the control loop (ADR-012).
38enum GuardVerdict {
39    /// Below the hard threshold — safe (safe checkpoint advanced) or tolerated.
40    Pass,
41    /// Hard breach rolled back to a safe checkpoint; decoding should resume.
42    RolledBack,
43    /// Hard breach with no rollback budget or target — refuse (fail closed).
44    FailClosed,
45}
46
47/// Mutable bookkeeping for the checkpointed-rollback loop, threaded through both
48/// the cadence guard check and the mandatory final guard check.
49struct GuardState {
50    checkpoints: CheckpointManager,
51    rollback_count: u8,
52    banned: Vec<Token>,
53    /// The post-prefill safe baseline `(output_len, kv_len)` — the fail-closed
54    /// restore target when no checkpoint exists, so prompt prefill KV survives.
55    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    /// The load permit this session was constructed with — evidence the model
100    /// passed the provenance gate (ADR-006).
101    pub fn permit(&self) -> LoadPermit {
102        self.permit
103    }
104    /// Take the buffered domain events (a real build would stream these to the
105    /// Telemetry subscriber).
106    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    /// Compress (optional) → prefill → build KV. Valid only from `Initialized`.
116    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        // Retain the raw prompt for ADR-013 ingress triage (scored in
125        // `generate_with_policy` before any token is generated).
126        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    /// Run the decode loop until EOS or `max_tokens`, deriving the rollback
159    /// policy from the session's device tier and safety mode (ADR-005/ADR-012).
160    pub fn generate(&mut self, ports: &Ports, max_tokens: u32) -> Result<StopReason> {
161        // Resolve the *effective* tier in the decode path (ADR-013): a tier the
162        // device cannot run (e.g. `SecDecoding` on `MidRange`) is downgraded
163        // here, and the effective mode — not just the requested one — is what
164        // drives the policy and is recorded for telemetry.
165        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    /// The checkpointed-rollback safety control loop (ADR-012).
172    ///
173    /// Every step preserves the invariant order **grammar mask → safety adjust →
174    /// sample → commit** (ADR-005). When a [`ChunkGuard`](el_safety::ChunkGuard)
175    /// is wired and the policy enables guarding, the loop additionally captures a
176    /// checkpoint at each guard-verified-safe boundary, scores recent output
177    /// every `guard_every` tokens, and on a hard-threshold breach rolls the KV
178    /// cache *and* the committed output back to the last safe checkpoint —
179    /// banning the offending token through the grammar mask so the resumed
180    /// decode necessarily diverges. Rollbacks are bounded by `max_rollbacks`; on
181    /// exhaustion — or with no checkpoint (e.g. under memory pressure) — the loop
182    /// fails closed with a deterministic refusal (`StopReason::Stopped`).
183    ///
184    /// Termination (EOS or `max_tokens`) is gated behind a **mandatory final
185    /// guard check**: the loop scores the trailing chunk before honouring either
186    /// stop condition, so no completion is ever returned unscored — including one
187    /// shorter than `guard_every` or whose unsafe tail ends in EOS. A final check
188    /// coincident with a cadence boundary is idempotent (re-scoring identical
189    /// output yields the same verdict).
190    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        // ---- ingress / prompt-risk triage (ADR-013) ----
204        // Score the prompt before generating anything. A hard breach fails
205        // closed deterministically — no unsafe trajectory is ever started. This
206        // is the heterogeneous monitor's ingress layer, distinct from the
207        // output-side chunk guard below.
208        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        // Tier-aware degradation (ADR-003/ADR-012): without budget for
230        // checkpoints, run guard-only with no rollback capability.
231        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            // The post-prefill baseline: the safe prefix to restore to when no
248            // checkpoint exists (e.g. checkpointing disabled under memory
249            // pressure). Captured as (output, KV) so fail-closed never drops
250            // prompt prefill KV.
251            start_out: self.output.len() as u32,
252            start_kv: self.kv.len(),
253        };
254        // Seed the safe prefix at generation start (an empty continuation is safe).
255        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            // A *candidate* termination for this iteration: the token cap is
264            // reached (checked before generating), or — set below — the model
265            // emitted EOS. With a guard active, neither is honoured until the
266            // final chunk passes the mandatory guard check, so a short or
267            // EOS-terminated tail cannot bypass scoring.
268            let mut terminating: Option<StopReason> = None;
269
270            if self.output.len() as u32 >= max_tokens {
271                terminating = Some(StopReason::MaxTokens);
272            } else {
273                // 2. next-token logits (drafting off by default).
274                let logits = self.engine.next_logits(&self.output);
275                let vocab = logits.len();
276
277                // 3. grammar mask (BEFORE safety). Rollback bans ride the mask so
278                //    the resumed decode cannot re-pick the off-trajectory token.
279                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                // 4. safety adjust (AFTER mask, BEFORE sampling). Inside the
289                //    early-token soft-steering window (ADR-013) the steerer is
290                //    given the base logits so a model-backed (contrastive)
291                //    steerer can run; outside the window it is token-only (hard
292                //    bans every step). For token-only steerers the two paths are
293                //    identical (the default `adjust_with_logits` delegates).
294                let adj = if (self.output.len() as u32) < policy.steer_window {
295                    // Hide grammar-illegal tokens from the steerer so a top-K
296                    // model-backed steerer ranks only legal candidates — otherwise
297                    // the whole top-K could be illegal and legal tokens get no
298                    // adjustment. Skip the copy when the grammar allows everything.
299                    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                // 5. sample (greedy argmax over legal, steered logits). If grammar
319                //    + rollback bans leave no legal token, fail closed rather than
320                //    emit a masked/banned token.
321                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                // 6. commit.
331                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            // ---- chunk guard + checkpointed rollback (ADR-012) ----
344            // Score at each `guard_every` cadence boundary AND before any
345            // termination (the mandatory final check). This closes the bypass
346            // where EOS or the token cap returned a tail shorter than
347            // `guard_every` unscored.
348            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                        // Fail closed: no checkpoint, or rollback budget spent.
357                        GuardVerdict::FailClosed => break StopReason::Stopped,
358                        // Rolled back: the candidate termination (if any) was
359                        // undone with it, so resume decoding from the safe prefix.
360                        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    /// Score the committed output and apply the ADR-012 rollback policy:
380    /// advance the safe checkpoint when verified safe, roll back (banning the
381    /// divergence token) on a hard breach within budget, or fail closed.
382    ///
383    /// Invoked both at `guard_every` cadence boundaries and as the mandatory
384    /// final check before termination, so no completion is returned unscored.
385    /// On [`GuardVerdict::RolledBack`]/[`GuardVerdict::FailClosed`] the output
386    /// **and** KV are truncated together to the safe prefix (or the post-prefill
387    /// baseline) so prompt prefill descriptors are never dropped (AC-5). A
388    /// rollback also restores the *engine's* internal state via
389    /// [`InferenceEngine::rollback`] — a stateful engine (real KV cache) that
390    /// kept the abandoned branch would otherwise serve logits from the unsafe
391    /// path and skip the replacement tokens.
392    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                    // Restore the engine's internal state (real KV cache /
407                    // position) to the checkpoint too. If it cannot, fail closed
408                    // rather than resume decoding on an inconsistent cache.
409                    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                    // Ban the token that began the unsafe span → divergence.
415                    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            // Verified safe: advance the last-safe checkpoint, drop bans.
440            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            // soft ≤ score < hard: tolerated but not checkpointed (still risky).
450            GuardVerdict::Pass
451        }
452    }
453
454    /// Clear KV/output for a fresh conversation (volatile memory only).
455    pub fn reset(&mut self) {
456        self.kv = KvRegion::new();
457        self.prompt.clear();
458        self.output.clear();
459        self.step = 0;
460        self.phase = Phase::Initialized;
461        self.emit(DomainEvent::SessionReset);
462    }
463
464    /// Consult the opt-in LAN relay. Hard-fails with [`EdgeError::AirGapViolation`]
465    /// unless `hybrid_mode` is enabled AND a relay is wired (ADR-004).
466    pub fn consult_relay(&mut self, ports: &Ports, query: &[Token]) -> Result<Vec<Token>> {
467        if !self.config.hybrid_mode {
468            return Err(EdgeError::AirGapViolation);
469        }
470        match &ports.relay {
471            Some(relay) => {
472                let out = relay.consult(query);
473                self.emit(DomainEvent::HybridRelayConsulted);
474                Ok(out)
475            }
476            None => Err(EdgeError::AirGapViolation),
477        }
478    }
479}
480
481/// Greedy pick over legal, safety-steered logits. Masked-out tokens are skipped
482/// entirely; the safety delta is added to surviving logits before argmax.
483/// Returns `None` when no token is legal (every token masked out or banned), so
484/// the caller fails closed instead of emitting a rejected token.
485fn pick(logits: &[i32], mask: &[bool], adj: &LogitAdjustment) -> Option<Token> {
486    let mut best: Option<Token> = None;
487    let mut best_val = i32::MIN;
488    for (i, &l) in logits.iter().enumerate() {
489        if mask.get(i).copied() == Some(false) {
490            continue;
491        }
492        let v = l.saturating_add(adj.delta_for(i as Token));
493        if v > best_val {
494            best_val = v;
495            best = Some(i as Token);
496        }
497    }
498    best
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504    use crate::defaults::NullEngine;
505    use crate::ports::{GrammarMasker, Ports};
506    use el_core::{ModelFormat, ModelId, ModelVersion};
507    use el_provenance::{ModelArtifact, SignatureVerifier};
508    use el_safety::LightweightFilter;
509
510    struct OkVerifier;
511    impl SignatureVerifier for OkVerifier {
512        fn verify(&self, _b: &[u8], _s: &[u8], _k: u32) -> bool {
513            true
514        }
515    }
516
517    fn permit() -> LoadPermit {
518        let mut a = ModelArtifact::new(ModelId(1), ModelVersion::new(0, 1, 0), ModelFormat::Gguf);
519        a.verify(&OkVerifier, b"weights", b"sig", 1);
520        a.ensure_loadable().expect("verified artifact loads")
521    }
522
523    /// A deterministic engine returning fixed logits; eos out of vocab range so
524    /// it never self-terminates (used for the composition-order test).
525    struct FixedEngine {
526        logits: Vec<i32>,
527    }
528    impl InferenceEngine for FixedEngine {
529        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
530            Ok(t.len() as u32)
531        }
532        fn next_logits(&mut self, _c: &[Token]) -> Vec<i32> {
533            self.logits.clone()
534        }
535        fn eos_token(&self) -> Token {
536            9999
537        }
538        fn rollback(&mut self, _keep: u32) -> Result<()> {
539            Ok(()) // stateless
540        }
541    }
542
543    // Grammar masker that disallows specific token ids.
544    struct DisallowMasker(Vec<Token>);
545    impl GrammarMasker for DisallowMasker {
546        fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
547            (0..vocab as Token).map(|t| !self.0.contains(&t)).collect()
548        }
549    }
550
551    #[test]
552    fn full_lifecycle_init_prefill_decode_complete_reset() {
553        let mut s = InferenceSession::new(
554            SessionId(1),
555            SessionConfig::default(),
556            NullEngine::new(3, 8),
557            permit(),
558        );
559        assert_eq!(s.phase(), Phase::Initialized);
560
561        let ports = Ports::permissive();
562        s.load_prompt(&ports, &[10, 11, 12]).unwrap();
563        assert_eq!(s.phase(), Phase::Decoding);
564
565        let stop = s.generate(&ports, 16).unwrap();
566        assert_eq!(stop, StopReason::Eos); // NullEngine emits EOS first step
567        assert_eq!(s.output(), &[3]);
568        assert_eq!(s.phase(), Phase::Completed);
569
570        s.reset();
571        assert_eq!(s.phase(), Phase::Initialized);
572        assert!(s.output().is_empty());
573    }
574
575    #[test]
576    fn decode_applies_grammar_before_safety_before_sampling() {
577        // logits favour token 0 (10), then 1 (9), then 2 (8), then 3 (7).
578        let engine = FixedEngine {
579            logits: vec![10, 9, 8, 7],
580        };
581        let mut s = InferenceSession::new(SessionId(2), SessionConfig::default(), engine, permit());
582
583        let ports = Ports {
584            compressor: Box::new(crate::defaults::IdentityCompressor),
585            grammar: Box::new(DisallowMasker(vec![0])), // grammar removes the top token
586            safety: Box::new(LightweightFilter::new(vec![1])), // safety bans the next-best
587            guard: None,
588            ingress: None,
589            relay: None,
590        };
591        s.load_prompt(&ports, &[1]).unwrap();
592        let stop = s.generate(&ports, 1).unwrap();
593
594        assert_eq!(stop, StopReason::MaxTokens);
595        // Token 0 removed by grammar, token 1 banned by safety → token 2 wins.
596        // Proves order: mask → adjust → sample.
597        assert_eq!(s.output(), &[2]);
598    }
599
600    #[test]
601    fn generate_before_load_prompt_is_invalid_phase() {
602        let mut s = InferenceSession::new(
603            SessionId(3),
604            SessionConfig::default(),
605            NullEngine::new(0, 4),
606            permit(),
607        );
608        let ports = Ports::permissive();
609        let err = s.generate(&ports, 4).unwrap_err();
610        assert!(matches!(err, EdgeError::InvalidPhase { .. }));
611    }
612
613    #[test]
614    fn relay_is_blocked_unless_hybrid_mode_opted_in() {
615        struct EchoRelay;
616        impl crate::ports::HybridRelay for EchoRelay {
617            fn consult(&self, q: &[Token]) -> Vec<Token> {
618                q.to_vec()
619            }
620        }
621
622        // Air-gapped by default: even with a relay wired, consulting fails.
623        let mut s = InferenceSession::new(
624            SessionId(4),
625            SessionConfig::default(),
626            NullEngine::new(0, 4),
627            permit(),
628        );
629        let ports = Ports {
630            relay: Some(Box::new(EchoRelay)),
631            ..Ports::permissive()
632        };
633        assert_eq!(
634            s.consult_relay(&ports, &[1, 2]).unwrap_err(),
635            EdgeError::AirGapViolation
636        );
637
638        // Opt in → allowed.
639        let cfg = SessionConfig {
640            hybrid_mode: true,
641            ..SessionConfig::default()
642        };
643        let mut s2 = InferenceSession::new(SessionId(5), cfg, NullEngine::new(0, 4), permit());
644        assert_eq!(s2.consult_relay(&ports, &[1, 2]).unwrap(), vec![1, 2]);
645
646        // Opted in but no relay wired → still air-gapped.
647        let no_relay = Ports::permissive();
648        assert_eq!(
649            s2.consult_relay(&no_relay, &[1]).unwrap_err(),
650            EdgeError::AirGapViolation
651        );
652    }
653
654    #[test]
655    fn first_events_are_init_then_model_loaded() {
656        let mut s = InferenceSession::new(
657            SessionId(6),
658            SessionConfig::default(),
659            NullEngine::new(0, 4),
660            permit(),
661        );
662        let evs = s.drain_events();
663        assert!(matches!(
664            evs[0].event,
665            DomainEvent::SessionInitialized { .. }
666        ));
667        assert!(matches!(evs[1].event, DomainEvent::ModelLoaded { .. }));
668    }
669
670    // ----- ADR-012 checkpointed-rollback control loop -----
671
672    use el_safety::{ChunkGuard, SafetyScore};
673
674    /// Hard-unsafe whenever the given token appears in the output.
675    struct BanToken(Token);
676    impl ChunkGuard for BanToken {
677        fn score(&self, recent: &[Token]) -> SafetyScore {
678            if recent.contains(&self.0) {
679                SafetyScore::MAX
680            } else {
681                SafetyScore::SAFE
682            }
683        }
684    }
685
686    /// Always hard-unsafe — exercises the rollback bound and fail-closed path.
687    struct AlwaysHot;
688    impl ChunkGuard for AlwaysHot {
689        fn score(&self, _recent: &[Token]) -> SafetyScore {
690            SafetyScore::MAX
691        }
692    }
693
694    fn tiny_policy(max_rollbacks: u8) -> RollbackPolicy {
695        RollbackPolicy {
696            guard_every: 1,
697            steer_window: 0,
698            soft_threshold: SafetyScore::from_milli(500),
699            hard_threshold: SafetyScore::from_milli(800),
700            max_rollbacks,
701            max_checkpoints: 8,
702        }
703    }
704
705    /// Grammar masker that disallows every token — exercises the no-legal-token
706    /// fail-closed path.
707    struct DenyAllMasker;
708    impl GrammarMasker for DenyAllMasker {
709        fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
710            vec![false; vocab]
711        }
712    }
713
714    /// Engine that always prefers token 0, then 1, 2, 3 — so banning the
715    /// chosen token forces the next-best, giving deterministic divergence.
716    fn descending_engine() -> FixedEngine {
717        FixedEngine {
718            logits: vec![5, 4, 3, 2],
719        }
720    }
721
722    /// Emits the unsafe token `0` first, then EOS — a completion shorter than a
723    /// large `guard_every`, so only the *final* mandatory guard check can catch
724    /// it. Banning token `0` forces the next-best (a safe token), then EOS.
725    struct UnsafeThenEos {
726        eos: Token,
727        vocab: usize,
728    }
729    impl InferenceEngine for UnsafeThenEos {
730        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
731            Ok(t.len() as u32)
732        }
733        fn next_logits(&mut self, ctx: &[Token]) -> Vec<i32> {
734            let mut v = vec![0i32; self.vocab];
735            if ctx.is_empty() {
736                v[0] = 10; // unsafe token 0 wins the first step
737            } else {
738                v[self.eos as usize] = 10; // then terminate with EOS
739            }
740            v
741        }
742        fn eos_token(&self) -> Token {
743            self.eos
744        }
745        fn rollback(&mut self, _keep: u32) -> Result<()> {
746            Ok(()) // stateless: logits depend only on the passed ctx
747        }
748    }
749
750    /// `guard_every` larger than any completion here, so the *cadence* check
751    /// never fires — isolating the mandatory final guard check.
752    fn coarse_policy(max_rollbacks: u8) -> RollbackPolicy {
753        RollbackPolicy {
754            guard_every: 16,
755            steer_window: 0,
756            soft_threshold: SafetyScore::from_milli(500),
757            hard_threshold: SafetyScore::from_milli(800),
758            max_rollbacks,
759            max_checkpoints: 8,
760        }
761    }
762
763    #[test]
764    fn eos_terminated_short_completion_is_scored_not_bypassed() {
765        // Regression (P1): EOS was handled before guard evaluation, so an unsafe
766        // tail ending in EOS within < guard_every tokens escaped scoring.
767        let mut s = InferenceSession::new(
768            SessionId(26),
769            SessionConfig::default(),
770            UnsafeThenEos { eos: 5, vocab: 8 },
771            permit(),
772        );
773        let ports = guarded_ports(Box::new(BanToken(0)));
774        s.load_prompt(&ports, &[]).unwrap();
775
776        // No rollback budget → the final check must refuse, not return EOS.
777        let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
778
779        assert_eq!(stop, StopReason::Stopped);
780        assert!(s.output().is_empty()); // unsafe EOS-terminated tail not emitted
781        let evs = s.drain_events();
782        assert!(evs
783            .iter()
784            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
785    }
786
787    #[test]
788    fn max_tokens_partial_chunk_is_scored_not_bypassed() {
789        // Regression (P1): hitting max_tokens exited before flushing a partial
790        // chunk, so a completion shorter than guard_every was never scored.
791        let mut s = InferenceSession::new(
792            SessionId(27),
793            SessionConfig::default(),
794            descending_engine(), // always prefers unsafe token 0
795            permit(),
796        );
797        let ports = guarded_ports(Box::new(BanToken(0)));
798        s.load_prompt(&ports, &[]).unwrap();
799
800        // 2 tokens < guard_every (16): only the final check can catch the breach.
801        let stop = s.generate_with_policy(&ports, 2, coarse_policy(0)).unwrap();
802
803        assert_eq!(stop, StopReason::Stopped);
804        assert!(s.output().is_empty());
805        let evs = s.drain_events();
806        assert!(evs
807            .iter()
808            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
809    }
810
811    #[test]
812    fn eos_unsafe_tail_rolls_back_and_recovers() {
813        // With rollback budget, an unsafe EOS-terminated tail is rolled back to
814        // the seeded safe prefix, the offending token banned, and decoding
815        // resumes to a safe completion that then terminates cleanly.
816        let mut s = InferenceSession::new(
817            SessionId(28),
818            SessionConfig::default(),
819            UnsafeThenEos { eos: 5, vocab: 8 },
820            permit(),
821        );
822        let ports = guarded_ports(Box::new(BanToken(0)));
823        s.load_prompt(&ports, &[]).unwrap();
824
825        let stop = s.generate_with_policy(&ports, 8, coarse_policy(1)).unwrap();
826
827        assert_eq!(stop, StopReason::Eos);
828        assert!(!s.output().contains(&0)); // unsafe token banned out of the result
829        assert_eq!(s.output().last(), Some(&5)); // ends on EOS, scored safe
830        let evs = s.drain_events();
831        assert!(evs
832            .iter()
833            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
834    }
835
836    /// Mirrors `QwenEngine`'s statefulness: tracks how many committed tokens it
837    /// has "fed" into its (mock) KV cache. If the session truncated its own
838    /// output without telling the engine, `committed` would shrink below `fed` —
839    /// the desync the real engine hits as serving stale logits and never
840    /// re-feeding. Shared cells let the test observe behaviour after the engine
841    /// is moved into the session.
842    struct StatefulEngine {
843        fed: usize,
844        logits: Vec<i32>,
845        eos: Token,
846        rollbacks: std::rc::Rc<std::cell::Cell<u32>>,
847        last_keep: std::rc::Rc<std::cell::Cell<u32>>,
848        desynced: std::rc::Rc<std::cell::Cell<bool>>,
849    }
850    impl InferenceEngine for StatefulEngine {
851        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
852            self.fed = 0;
853            Ok(t.len() as u32)
854        }
855        fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
856            // The engine should never be "ahead" of the committed context; if it
857            // is, a rollback was not propagated here (the bug).
858            if self.fed > committed.len() {
859                self.desynced.set(true);
860            }
861            while self.fed < committed.len() {
862                self.fed += 1;
863            }
864            self.logits.clone()
865        }
866        fn eos_token(&self) -> Token {
867            self.eos
868        }
869        fn rollback(&mut self, keep_committed: u32) -> Result<()> {
870            self.fed = keep_committed as usize; // re-sync to the retained prefix
871            self.rollbacks.set(self.rollbacks.get() + 1);
872            self.last_keep.set(keep_committed);
873            Ok(())
874        }
875    }
876
877    #[test]
878    fn rollback_restores_engine_state_not_just_session_metadata() {
879        // Regression (P1): the loop truncated only the session's output + KV
880        // descriptors; a stateful engine kept the abandoned branch. The session
881        // must drive `InferenceEngine::rollback` on every backtrack.
882        let rollbacks = std::rc::Rc::new(std::cell::Cell::new(0u32));
883        let last_keep = std::rc::Rc::new(std::cell::Cell::new(u32::MAX));
884        let desynced = std::rc::Rc::new(std::cell::Cell::new(false));
885        let engine = StatefulEngine {
886            fed: 0,
887            logits: vec![5, 4, 3, 2], // prefers the unsafe token 0
888            eos: 9999,
889            rollbacks: rollbacks.clone(),
890            last_keep: last_keep.clone(),
891            desynced: desynced.clone(),
892        };
893        let mut s =
894            InferenceSession::new(SessionId(29), SessionConfig::default(), engine, permit());
895        let ports = guarded_ports(Box::new(BanToken(0)));
896        s.load_prompt(&ports, &[7, 8]).unwrap(); // non-empty prompt
897
898        let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
899
900        assert_eq!(stop, StopReason::MaxTokens);
901        assert_eq!(s.output(), &[1, 1, 1]); // recovered safe completion
902                                            // The engine was told to roll back — not just the session metadata...
903        assert!(
904            rollbacks.get() >= 1,
905            "session must propagate the backtrack to the engine"
906        );
907        // ...to a real prefix, and the cache never desynced from `committed`.
908        assert!(last_keep.get() < 3);
909        assert!(
910            !desynced.get(),
911            "engine cache must track the session rollback"
912        );
913    }
914
915    fn guarded_ports(guard: Box<dyn ChunkGuard>) -> Ports {
916        Ports {
917            compressor: Box::new(crate::defaults::IdentityCompressor),
918            grammar: Box::new(crate::defaults::AllowAllMasker),
919            safety: Box::new(el_safety::NoSafety),
920            guard: Some(guard),
921            ingress: None,
922            relay: None,
923        }
924    }
925
926    #[test]
927    fn hard_breach_rolls_back_kv_and_recovers() {
928        let mut s = InferenceSession::new(
929            SessionId(20),
930            SessionConfig::default(),
931            descending_engine(),
932            permit(),
933        );
934        let ports = guarded_ports(Box::new(BanToken(0)));
935        s.load_prompt(&ports, &[]).unwrap();
936
937        let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
938
939        assert_eq!(stop, StopReason::MaxTokens);
940        // Token 0 is unsafe; each occurrence is rolled back and banned, so the
941        // recovered output contains only the safe next-best token.
942        assert_eq!(s.output(), &[1, 1, 1]);
943        assert!(!s.output().contains(&0));
944        // KV rewound in lock-step with the committed output (AC-5).
945        assert_eq!(s.kv_len(), s.output().len() as u32);
946
947        let evs = s.drain_events();
948        assert!(evs
949            .iter()
950            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
951        assert!(evs
952            .iter()
953            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
954    }
955
956    #[test]
957    fn fail_closed_refusal_when_no_rollback_budget() {
958        let mut s = InferenceSession::new(
959            SessionId(21),
960            SessionConfig::default(),
961            descending_engine(),
962            permit(),
963        );
964        let ports = guarded_ports(Box::new(BanToken(0)));
965        s.load_prompt(&ports, &[]).unwrap();
966
967        // No rollback budget → first hard breach refuses deterministically.
968        let stop = s.generate_with_policy(&ports, 5, tiny_policy(0)).unwrap();
969
970        assert_eq!(stop, StopReason::Stopped);
971        assert!(s.output().is_empty());
972        let evs = s.drain_events();
973        assert!(evs
974            .iter()
975            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
976        assert!(!evs
977            .iter()
978            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
979    }
980
981    #[test]
982    fn rollbacks_are_bounded_then_fail_closed() {
983        let mut s = InferenceSession::new(
984            SessionId(22),
985            SessionConfig::default(),
986            descending_engine(),
987            permit(),
988        );
989        let ports = guarded_ports(Box::new(AlwaysHot));
990        s.load_prompt(&ports, &[]).unwrap();
991
992        let stop = s.generate_with_policy(&ports, 8, tiny_policy(2)).unwrap();
993
994        assert_eq!(stop, StopReason::Stopped);
995        let evs = s.drain_events();
996        let rollbacks = evs
997            .iter()
998            .filter(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. }))
999            .count();
1000        // Exactly max_rollbacks attempts, then a deterministic refusal (AC-6).
1001        assert_eq!(rollbacks, 2);
1002    }
1003
1004    #[test]
1005    fn memory_pressure_disables_checkpoints_and_fails_closed() {
1006        let cfg = SessionConfig {
1007            memory_budget_bytes: 1024, // far below MIN_CHECKPOINT_BUDGET_BYTES
1008            ..SessionConfig::default()
1009        };
1010        let mut s = InferenceSession::new(SessionId(23), cfg, descending_engine(), permit());
1011        let ports = guarded_ports(Box::new(BanToken(0)));
1012        s.load_prompt(&ports, &[]).unwrap();
1013
1014        let stop = s.generate_with_policy(&ports, 5, tiny_policy(3)).unwrap();
1015
1016        assert_eq!(stop, StopReason::Stopped); // no checkpoint to roll back to
1017        let evs = s.drain_events();
1018        assert!(evs.iter().any(|e| matches!(
1019            e.event,
1020            DomainEvent::SafetyDisabled {
1021                reason: DegradeReason::MemoryPressure
1022            }
1023        )));
1024    }
1025
1026    #[test]
1027    fn no_legal_token_fails_closed() {
1028        // Grammar disallows everything → pick() has no legal token. The loop must
1029        // fail closed, not commit token 0 (which could be EOS).
1030        let mut s = InferenceSession::new(
1031            SessionId(24),
1032            SessionConfig::default(),
1033            descending_engine(),
1034            permit(),
1035        );
1036        let ports = Ports {
1037            compressor: Box::new(crate::defaults::IdentityCompressor),
1038            grammar: Box::new(DenyAllMasker),
1039            safety: Box::new(el_safety::NoSafety),
1040            guard: None,
1041            ingress: None,
1042            relay: None,
1043        };
1044        s.load_prompt(&ports, &[]).unwrap();
1045
1046        let stop = s.generate(&ports, 4).unwrap();
1047
1048        assert_eq!(stop, StopReason::Stopped);
1049        assert!(s.output().is_empty()); // no illegal token committed
1050        let evs = s.drain_events();
1051        assert!(evs
1052            .iter()
1053            .any(|e| matches!(e.event, DomainEvent::GrammarViolationBlocked)));
1054    }
1055
1056    #[test]
1057    fn fail_closed_preserves_prefill_kv() {
1058        // Non-empty prompt → prefill KV descriptors must survive a
1059        // budget-exhausted refusal (regression: fail-closed once truncated KV to
1060        // output_len, dropping prefill).
1061        let mut s = InferenceSession::new(
1062            SessionId(25),
1063            SessionConfig::default(),
1064            descending_engine(),
1065            permit(),
1066        );
1067        let ports = guarded_ports(Box::new(AlwaysHot));
1068        s.load_prompt(&ports, &[7, 8, 9]).unwrap(); // prefill KV length = 3
1069        assert_eq!(s.kv_len(), 3);
1070
1071        let stop = s.generate_with_policy(&ports, 8, tiny_policy(1)).unwrap();
1072
1073        assert_eq!(stop, StopReason::Stopped);
1074        assert!(s.output().is_empty()); // refused back to the post-prefill prefix
1075        assert_eq!(s.kv_len(), 3); // prefill KV intact, not truncated to 0
1076    }
1077
1078    // ----- ADR-013 model-backed steering: window, ingress, mode selector -----
1079
1080    use el_core::SafetyMode;
1081    use el_safety::SafetySteerer;
1082
1083    /// Records, per step, whether the logit-aware path was taken and the output
1084    /// length at that step — so a test can prove the early-token window gating.
1085    struct RecordingSteerer {
1086        log: std::rc::Rc<std::cell::RefCell<Vec<(bool, usize)>>>,
1087    }
1088    impl SafetySteerer for RecordingSteerer {
1089        fn adjust(&self, recent: &[Token]) -> LogitAdjustment {
1090            self.log.borrow_mut().push((false, recent.len()));
1091            LogitAdjustment::none()
1092        }
1093        fn adjust_with_logits(&self, recent: &[Token], _base: &[i32]) -> LogitAdjustment {
1094            self.log.borrow_mut().push((true, recent.len()));
1095            LogitAdjustment::none()
1096        }
1097        fn mode(&self) -> SafetyMode {
1098            SafetyMode::SecDecoding
1099        }
1100    }
1101
1102    #[test]
1103    fn soft_steer_applies_only_inside_the_early_token_window() {
1104        // AC-1: adjust_with_logits runs for output positions < steer_window, and
1105        // plain token-only adjust() afterwards.
1106        let log = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1107        let mut s = InferenceSession::new(
1108            SessionId(30),
1109            SessionConfig::default(),
1110            descending_engine(), // never EOS (eos 9999)
1111            permit(),
1112        );
1113        let ports = Ports {
1114            compressor: Box::new(crate::defaults::IdentityCompressor),
1115            grammar: Box::new(crate::defaults::AllowAllMasker),
1116            safety: Box::new(RecordingSteerer { log: log.clone() }),
1117            guard: None,
1118            ingress: None,
1119            relay: None,
1120        };
1121        s.load_prompt(&ports, &[]).unwrap();
1122        let policy = RollbackPolicy {
1123            guard_every: 0,
1124            steer_window: 2,
1125            soft_threshold: SafetyScore::MAX,
1126            hard_threshold: SafetyScore::MAX,
1127            max_rollbacks: 0,
1128            max_checkpoints: 0,
1129        };
1130        s.generate_with_policy(&ports, 4, policy).unwrap();
1131
1132        let calls = log.borrow();
1133        assert_eq!(calls.len(), 4);
1134        for &(with_logits, len) in calls.iter() {
1135            assert_eq!(
1136                with_logits,
1137                len < 2,
1138                "window gate wrong at output len {len}"
1139            );
1140        }
1141    }
1142
1143    #[test]
1144    fn ingress_triage_fails_closed_before_generation() {
1145        // AC-3: a prompt scored at/above the hard threshold refuses with no decode.
1146        let mut s = InferenceSession::new(
1147            SessionId(31),
1148            SessionConfig::default(),
1149            descending_engine(),
1150            permit(),
1151        );
1152        let ports = Ports {
1153            compressor: Box::new(crate::defaults::IdentityCompressor),
1154            grammar: Box::new(crate::defaults::AllowAllMasker),
1155            safety: Box::new(el_safety::NoSafety),
1156            guard: None,
1157            ingress: Some(Box::new(AlwaysHot)), // prompt scores MAX
1158            relay: None,
1159        };
1160        s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1161
1162        let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
1163
1164        assert_eq!(stop, StopReason::Stopped);
1165        assert!(s.output().is_empty());
1166        let evs = s.drain_events();
1167        assert!(evs
1168            .iter()
1169            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1170        // Fail-closed at ingress means nothing was ever generated/committed.
1171        assert!(!evs
1172            .iter()
1173            .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })));
1174    }
1175
1176    #[test]
1177    fn generate_applies_safety_mode_selector_and_records_effective_mode() {
1178        // AC-4: SecDecoding on MidRange downgrades to Lightweight in the decode
1179        // path, and the effective mode is what gets recorded.
1180        let cfg = SessionConfig {
1181            device: el_core::DeviceTarget::MidRange,
1182            safety: SafetyMode::SecDecoding,
1183            ..SessionConfig::default()
1184        };
1185        let mut s = InferenceSession::new(SessionId(32), cfg, NullEngine::new(0, 4), permit());
1186        let ports = Ports::permissive();
1187        s.load_prompt(&ports, &[1]).unwrap();
1188        s.generate(&ports, 4).unwrap();
1189
1190        let evs = s.drain_events();
1191        assert!(evs.iter().any(|e| matches!(
1192            e.event,
1193            DomainEvent::SafetyModeSelected {
1194                mode: SafetyMode::Lightweight
1195            }
1196        )));
1197    }
1198}