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    /// Reset for a fresh conversation on the **same resident weights** — the seam
455    /// that turns a provider from "rebuild the engine every turn" into "load once,
456    /// reuse" (ADR-018). Clears the session's KV descriptors / output / prompt and
457    /// resets the engine's *logical* cache; distinct from a safety
458    /// [`rollback`](InferenceEngine::rollback) (which rewinds *within* a
459    /// generation).
460    ///
461    /// Returns the engine's `reset_cache` error rather than swallowing it: an
462    /// engine that fails to discard its cache must not be reported as a clean
463    /// fresh session. On error the session state is left **untouched** (so an empty
464    /// session can never desync from a stale engine cache) and the caller is
465    /// notified — it should drop/rebuild rather than reuse.
466    ///
467    /// Buffered events are **preserved** (generic semantics): a telemetry consumer
468    /// may still [`drain_events`](Self::drain_events) after a reset. Turn-level
469    /// event isolation is the caller's concern — a provider that reuses one session
470    /// across turns should drain between turns (see the adapter providers).
471    ///
472    /// `reset_cache` releases the *previous conversation's* KV while keeping the
473    /// resident weights loaded — that separation of conversation lifecycle from
474    /// model lifecycle is the point of ADR-018.
475    pub fn reset(&mut self) -> Result<()> {
476        self.engine.reset_cache()?;
477        self.kv = KvRegion::new();
478        self.prompt.clear();
479        self.output.clear();
480        self.step = 0;
481        self.phase = Phase::Initialized;
482        self.emit(DomainEvent::SessionReset);
483        Ok(())
484    }
485
486    /// End the current conversation: release its volatile memory — engine KV
487    /// (via [`reset_cache`](InferenceEngine::reset_cache)), KV descriptors,
488    /// committed output, retained prompt, and buffered events — while keeping the
489    /// **resident model loaded** so the session can serve a new conversation
490    /// without reloading weights (ADR-018; PRD line 131 "KV caches … cleared on
491    /// session end"; the AC-4 explicit release).
492    ///
493    /// Takes `&mut self`, not `self`: dropping the session would also drop the
494    /// (expensive) weights, which is the opposite of "load once, reuse." Instead
495    /// the engine releases the conversation's KV in place — for candle's
496    /// `quantized_qwen2`, a position-0 forward overwrites and frees the prior K/V
497    /// tensors (see its `reset_cache`). Unlike [`reset`](Self::reset), `close` also
498    /// frees the buffers' *capacity* and discards buffered events, minimizing the
499    /// idle footprint. Propagates a `reset_cache` failure (state untouched on
500    /// error). To free the weights too, drop the session/provider (ownership).
501    pub fn close(&mut self) -> Result<()> {
502        self.engine.reset_cache()?;
503        self.kv = KvRegion::new();
504        self.prompt = Vec::new();
505        self.output = Vec::new();
506        self.events = Vec::new();
507        self.step = 0;
508        self.phase = Phase::Initialized;
509        Ok(())
510    }
511
512    /// Consult the opt-in LAN relay. Hard-fails with [`EdgeError::AirGapViolation`]
513    /// unless `hybrid_mode` is enabled AND a relay is wired (ADR-004).
514    pub fn consult_relay(&mut self, ports: &Ports, query: &[Token]) -> Result<Vec<Token>> {
515        if !self.config.hybrid_mode {
516            return Err(EdgeError::AirGapViolation);
517        }
518        match &ports.relay {
519            Some(relay) => {
520                let out = relay.consult(query);
521                self.emit(DomainEvent::HybridRelayConsulted);
522                Ok(out)
523            }
524            None => Err(EdgeError::AirGapViolation),
525        }
526    }
527}
528
529/// Greedy pick over legal, safety-steered logits. Masked-out tokens are skipped
530/// entirely; the safety delta is added to surviving logits before argmax.
531/// Returns `None` when no token is legal (every token masked out or banned), so
532/// the caller fails closed instead of emitting a rejected token.
533fn pick(logits: &[i32], mask: &[bool], adj: &LogitAdjustment) -> Option<Token> {
534    let mut best: Option<Token> = None;
535    let mut best_val = i32::MIN;
536    for (i, &l) in logits.iter().enumerate() {
537        if mask.get(i).copied() == Some(false) {
538            continue;
539        }
540        let v = l.saturating_add(adj.delta_for(i as Token));
541        if v > best_val {
542            best_val = v;
543            best = Some(i as Token);
544        }
545    }
546    best
547}
548
549#[cfg(test)]
550mod tests {
551    use super::*;
552    use crate::defaults::NullEngine;
553    use crate::ports::{GrammarMasker, Ports};
554    use el_core::{ModelFormat, ModelId, ModelVersion};
555    use el_provenance::{ModelArtifact, SignatureVerifier};
556    use el_safety::LightweightFilter;
557
558    struct OkVerifier;
559    impl SignatureVerifier for OkVerifier {
560        fn verify(&self, _b: &[u8], _s: &[u8], _k: u32) -> bool {
561            true
562        }
563    }
564
565    fn permit() -> LoadPermit {
566        let mut a = ModelArtifact::new(ModelId(1), ModelVersion::new(0, 1, 0), ModelFormat::Gguf);
567        a.verify(&OkVerifier, b"weights", b"sig", 1);
568        a.ensure_loadable().expect("verified artifact loads")
569    }
570
571    /// A deterministic engine returning fixed logits; eos out of vocab range so
572    /// it never self-terminates (used for the composition-order test).
573    struct FixedEngine {
574        logits: Vec<i32>,
575    }
576    impl InferenceEngine for FixedEngine {
577        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
578            Ok(t.len() as u32)
579        }
580        fn next_logits(&mut self, _c: &[Token]) -> Vec<i32> {
581            self.logits.clone()
582        }
583        fn eos_token(&self) -> Token {
584            9999
585        }
586        fn rollback(&mut self, _keep: u32) -> Result<()> {
587            Ok(()) // stateless
588        }
589        fn reset_cache(&mut self) -> Result<()> {
590            Ok(()) // stateless
591        }
592    }
593
594    // Grammar masker that disallows specific token ids.
595    struct DisallowMasker(Vec<Token>);
596    impl GrammarMasker for DisallowMasker {
597        fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
598            (0..vocab as Token).map(|t| !self.0.contains(&t)).collect()
599        }
600    }
601
602    #[test]
603    fn full_lifecycle_init_prefill_decode_complete_reset() {
604        let mut s = InferenceSession::new(
605            SessionId(1),
606            SessionConfig::default(),
607            NullEngine::new(3, 8),
608            permit(),
609        );
610        assert_eq!(s.phase(), Phase::Initialized);
611
612        let ports = Ports::permissive();
613        s.load_prompt(&ports, &[10, 11, 12]).unwrap();
614        assert_eq!(s.phase(), Phase::Decoding);
615
616        let stop = s.generate(&ports, 16).unwrap();
617        assert_eq!(stop, StopReason::Eos); // NullEngine emits EOS first step
618        assert_eq!(s.output(), &[3]);
619        assert_eq!(s.phase(), Phase::Completed);
620
621        s.reset().unwrap();
622        assert_eq!(s.phase(), Phase::Initialized);
623        assert!(s.output().is_empty());
624    }
625
626    #[test]
627    fn decode_applies_grammar_before_safety_before_sampling() {
628        // logits favour token 0 (10), then 1 (9), then 2 (8), then 3 (7).
629        let engine = FixedEngine {
630            logits: vec![10, 9, 8, 7],
631        };
632        let mut s = InferenceSession::new(SessionId(2), SessionConfig::default(), engine, permit());
633
634        let ports = Ports {
635            compressor: Box::new(crate::defaults::IdentityCompressor),
636            grammar: Box::new(DisallowMasker(vec![0])), // grammar removes the top token
637            safety: Box::new(LightweightFilter::new(vec![1])), // safety bans the next-best
638            guard: None,
639            ingress: None,
640            relay: None,
641        };
642        s.load_prompt(&ports, &[1]).unwrap();
643        let stop = s.generate(&ports, 1).unwrap();
644
645        assert_eq!(stop, StopReason::MaxTokens);
646        // Token 0 removed by grammar, token 1 banned by safety → token 2 wins.
647        // Proves order: mask → adjust → sample.
648        assert_eq!(s.output(), &[2]);
649    }
650
651    #[test]
652    fn generate_before_load_prompt_is_invalid_phase() {
653        let mut s = InferenceSession::new(
654            SessionId(3),
655            SessionConfig::default(),
656            NullEngine::new(0, 4),
657            permit(),
658        );
659        let ports = Ports::permissive();
660        let err = s.generate(&ports, 4).unwrap_err();
661        assert!(matches!(err, EdgeError::InvalidPhase { .. }));
662    }
663
664    #[test]
665    fn relay_is_blocked_unless_hybrid_mode_opted_in() {
666        struct EchoRelay;
667        impl crate::ports::HybridRelay for EchoRelay {
668            fn consult(&self, q: &[Token]) -> Vec<Token> {
669                q.to_vec()
670            }
671        }
672
673        // Air-gapped by default: even with a relay wired, consulting fails.
674        let mut s = InferenceSession::new(
675            SessionId(4),
676            SessionConfig::default(),
677            NullEngine::new(0, 4),
678            permit(),
679        );
680        let ports = Ports {
681            relay: Some(Box::new(EchoRelay)),
682            ..Ports::permissive()
683        };
684        assert_eq!(
685            s.consult_relay(&ports, &[1, 2]).unwrap_err(),
686            EdgeError::AirGapViolation
687        );
688
689        // Opt in → allowed.
690        let cfg = SessionConfig {
691            hybrid_mode: true,
692            ..SessionConfig::default()
693        };
694        let mut s2 = InferenceSession::new(SessionId(5), cfg, NullEngine::new(0, 4), permit());
695        assert_eq!(s2.consult_relay(&ports, &[1, 2]).unwrap(), vec![1, 2]);
696
697        // Opted in but no relay wired → still air-gapped.
698        let no_relay = Ports::permissive();
699        assert_eq!(
700            s2.consult_relay(&no_relay, &[1]).unwrap_err(),
701            EdgeError::AirGapViolation
702        );
703    }
704
705    #[test]
706    fn first_events_are_init_then_model_loaded() {
707        let mut s = InferenceSession::new(
708            SessionId(6),
709            SessionConfig::default(),
710            NullEngine::new(0, 4),
711            permit(),
712        );
713        let evs = s.drain_events();
714        assert!(matches!(
715            evs[0].event,
716            DomainEvent::SessionInitialized { .. }
717        ));
718        assert!(matches!(evs[1].event, DomainEvent::ModelLoaded { .. }));
719    }
720
721    // ----- ADR-012 checkpointed-rollback control loop -----
722
723    use el_safety::{ChunkGuard, SafetyScore};
724
725    /// Hard-unsafe whenever the given token appears in the output.
726    struct BanToken(Token);
727    impl ChunkGuard for BanToken {
728        fn score(&self, recent: &[Token]) -> SafetyScore {
729            if recent.contains(&self.0) {
730                SafetyScore::MAX
731            } else {
732                SafetyScore::SAFE
733            }
734        }
735    }
736
737    /// Always hard-unsafe — exercises the rollback bound and fail-closed path.
738    struct AlwaysHot;
739    impl ChunkGuard for AlwaysHot {
740        fn score(&self, _recent: &[Token]) -> SafetyScore {
741            SafetyScore::MAX
742        }
743    }
744
745    fn tiny_policy(max_rollbacks: u8) -> RollbackPolicy {
746        RollbackPolicy {
747            guard_every: 1,
748            steer_window: 0,
749            soft_threshold: SafetyScore::from_milli(500),
750            hard_threshold: SafetyScore::from_milli(800),
751            max_rollbacks,
752            max_checkpoints: 8,
753        }
754    }
755
756    /// Grammar masker that disallows every token — exercises the no-legal-token
757    /// fail-closed path.
758    struct DenyAllMasker;
759    impl GrammarMasker for DenyAllMasker {
760        fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
761            vec![false; vocab]
762        }
763    }
764
765    /// Engine that always prefers token 0, then 1, 2, 3 — so banning the
766    /// chosen token forces the next-best, giving deterministic divergence.
767    fn descending_engine() -> FixedEngine {
768        FixedEngine {
769            logits: vec![5, 4, 3, 2],
770        }
771    }
772
773    /// Emits the unsafe token `0` first, then EOS — a completion shorter than a
774    /// large `guard_every`, so only the *final* mandatory guard check can catch
775    /// it. Banning token `0` forces the next-best (a safe token), then EOS.
776    struct UnsafeThenEos {
777        eos: Token,
778        vocab: usize,
779    }
780    impl InferenceEngine for UnsafeThenEos {
781        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
782            Ok(t.len() as u32)
783        }
784        fn next_logits(&mut self, ctx: &[Token]) -> Vec<i32> {
785            let mut v = vec![0i32; self.vocab];
786            if ctx.is_empty() {
787                v[0] = 10; // unsafe token 0 wins the first step
788            } else {
789                v[self.eos as usize] = 10; // then terminate with EOS
790            }
791            v
792        }
793        fn eos_token(&self) -> Token {
794            self.eos
795        }
796        fn rollback(&mut self, _keep: u32) -> Result<()> {
797            Ok(()) // stateless: logits depend only on the passed ctx
798        }
799        fn reset_cache(&mut self) -> Result<()> {
800            Ok(()) // stateless
801        }
802    }
803
804    /// `guard_every` larger than any completion here, so the *cadence* check
805    /// never fires — isolating the mandatory final guard check.
806    fn coarse_policy(max_rollbacks: u8) -> RollbackPolicy {
807        RollbackPolicy {
808            guard_every: 16,
809            steer_window: 0,
810            soft_threshold: SafetyScore::from_milli(500),
811            hard_threshold: SafetyScore::from_milli(800),
812            max_rollbacks,
813            max_checkpoints: 8,
814        }
815    }
816
817    #[test]
818    fn eos_terminated_short_completion_is_scored_not_bypassed() {
819        // Regression (P1): EOS was handled before guard evaluation, so an unsafe
820        // tail ending in EOS within < guard_every tokens escaped scoring.
821        let mut s = InferenceSession::new(
822            SessionId(26),
823            SessionConfig::default(),
824            UnsafeThenEos { eos: 5, vocab: 8 },
825            permit(),
826        );
827        let ports = guarded_ports(Box::new(BanToken(0)));
828        s.load_prompt(&ports, &[]).unwrap();
829
830        // No rollback budget → the final check must refuse, not return EOS.
831        let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
832
833        assert_eq!(stop, StopReason::Stopped);
834        assert!(s.output().is_empty()); // unsafe EOS-terminated tail not emitted
835        let evs = s.drain_events();
836        assert!(evs
837            .iter()
838            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
839    }
840
841    #[test]
842    fn max_tokens_partial_chunk_is_scored_not_bypassed() {
843        // Regression (P1): hitting max_tokens exited before flushing a partial
844        // chunk, so a completion shorter than guard_every was never scored.
845        let mut s = InferenceSession::new(
846            SessionId(27),
847            SessionConfig::default(),
848            descending_engine(), // always prefers unsafe token 0
849            permit(),
850        );
851        let ports = guarded_ports(Box::new(BanToken(0)));
852        s.load_prompt(&ports, &[]).unwrap();
853
854        // 2 tokens < guard_every (16): only the final check can catch the breach.
855        let stop = s.generate_with_policy(&ports, 2, coarse_policy(0)).unwrap();
856
857        assert_eq!(stop, StopReason::Stopped);
858        assert!(s.output().is_empty());
859        let evs = s.drain_events();
860        assert!(evs
861            .iter()
862            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
863    }
864
865    #[test]
866    fn eos_unsafe_tail_rolls_back_and_recovers() {
867        // With rollback budget, an unsafe EOS-terminated tail is rolled back to
868        // the seeded safe prefix, the offending token banned, and decoding
869        // resumes to a safe completion that then terminates cleanly.
870        let mut s = InferenceSession::new(
871            SessionId(28),
872            SessionConfig::default(),
873            UnsafeThenEos { eos: 5, vocab: 8 },
874            permit(),
875        );
876        let ports = guarded_ports(Box::new(BanToken(0)));
877        s.load_prompt(&ports, &[]).unwrap();
878
879        let stop = s.generate_with_policy(&ports, 8, coarse_policy(1)).unwrap();
880
881        assert_eq!(stop, StopReason::Eos);
882        assert!(!s.output().contains(&0)); // unsafe token banned out of the result
883        assert_eq!(s.output().last(), Some(&5)); // ends on EOS, scored safe
884        let evs = s.drain_events();
885        assert!(evs
886            .iter()
887            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
888    }
889
890    /// Mirrors `QwenEngine`'s statefulness: tracks how many committed tokens it
891    /// has "fed" into its (mock) KV cache. If the session truncated its own
892    /// output without telling the engine, `committed` would shrink below `fed` —
893    /// the desync the real engine hits as serving stale logits and never
894    /// re-feeding. Shared cells let the test observe behaviour after the engine
895    /// is moved into the session.
896    struct StatefulEngine {
897        fed: usize,
898        logits: Vec<i32>,
899        eos: Token,
900        rollbacks: std::rc::Rc<std::cell::Cell<u32>>,
901        last_keep: std::rc::Rc<std::cell::Cell<u32>>,
902        desynced: std::rc::Rc<std::cell::Cell<bool>>,
903    }
904    impl InferenceEngine for StatefulEngine {
905        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
906            self.fed = 0;
907            Ok(t.len() as u32)
908        }
909        fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
910            // The engine should never be "ahead" of the committed context; if it
911            // is, a rollback was not propagated here (the bug).
912            if self.fed > committed.len() {
913                self.desynced.set(true);
914            }
915            while self.fed < committed.len() {
916                self.fed += 1;
917            }
918            self.logits.clone()
919        }
920        fn eos_token(&self) -> Token {
921            self.eos
922        }
923        fn rollback(&mut self, keep_committed: u32) -> Result<()> {
924            self.fed = keep_committed as usize; // re-sync to the retained prefix
925            self.rollbacks.set(self.rollbacks.get() + 1);
926            self.last_keep.set(keep_committed);
927            Ok(())
928        }
929        fn reset_cache(&mut self) -> Result<()> {
930            self.fed = 0; // pristine: a fresh conversation re-feeds from scratch
931            Ok(())
932        }
933    }
934
935    #[test]
936    fn rollback_restores_engine_state_not_just_session_metadata() {
937        // Regression (P1): the loop truncated only the session's output + KV
938        // descriptors; a stateful engine kept the abandoned branch. The session
939        // must drive `InferenceEngine::rollback` on every backtrack.
940        let rollbacks = std::rc::Rc::new(std::cell::Cell::new(0u32));
941        let last_keep = std::rc::Rc::new(std::cell::Cell::new(u32::MAX));
942        let desynced = std::rc::Rc::new(std::cell::Cell::new(false));
943        let engine = StatefulEngine {
944            fed: 0,
945            logits: vec![5, 4, 3, 2], // prefers the unsafe token 0
946            eos: 9999,
947            rollbacks: rollbacks.clone(),
948            last_keep: last_keep.clone(),
949            desynced: desynced.clone(),
950        };
951        let mut s =
952            InferenceSession::new(SessionId(29), SessionConfig::default(), engine, permit());
953        let ports = guarded_ports(Box::new(BanToken(0)));
954        s.load_prompt(&ports, &[7, 8]).unwrap(); // non-empty prompt
955
956        let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
957
958        assert_eq!(stop, StopReason::MaxTokens);
959        assert_eq!(s.output(), &[1, 1, 1]); // recovered safe completion
960                                            // The engine was told to roll back — not just the session metadata...
961        assert!(
962            rollbacks.get() >= 1,
963            "session must propagate the backtrack to the engine"
964        );
965        // ...to a real prefix, and the cache never desynced from `committed`.
966        assert!(last_keep.get() < 3);
967        assert!(
968            !desynced.get(),
969            "engine cache must track the session rollback"
970        );
971    }
972
973    fn guarded_ports(guard: Box<dyn ChunkGuard>) -> Ports {
974        Ports {
975            compressor: Box::new(crate::defaults::IdentityCompressor),
976            grammar: Box::new(crate::defaults::AllowAllMasker),
977            safety: Box::new(el_safety::NoSafety),
978            guard: Some(guard),
979            ingress: None,
980            relay: None,
981        }
982    }
983
984    #[test]
985    fn hard_breach_rolls_back_kv_and_recovers() {
986        let mut s = InferenceSession::new(
987            SessionId(20),
988            SessionConfig::default(),
989            descending_engine(),
990            permit(),
991        );
992        let ports = guarded_ports(Box::new(BanToken(0)));
993        s.load_prompt(&ports, &[]).unwrap();
994
995        let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
996
997        assert_eq!(stop, StopReason::MaxTokens);
998        // Token 0 is unsafe; each occurrence is rolled back and banned, so the
999        // recovered output contains only the safe next-best token.
1000        assert_eq!(s.output(), &[1, 1, 1]);
1001        assert!(!s.output().contains(&0));
1002        // KV rewound in lock-step with the committed output (AC-5).
1003        assert_eq!(s.kv_len(), s.output().len() as u32);
1004
1005        let evs = s.drain_events();
1006        assert!(evs
1007            .iter()
1008            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1009        assert!(evs
1010            .iter()
1011            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1012    }
1013
1014    #[test]
1015    fn fail_closed_refusal_when_no_rollback_budget() {
1016        let mut s = InferenceSession::new(
1017            SessionId(21),
1018            SessionConfig::default(),
1019            descending_engine(),
1020            permit(),
1021        );
1022        let ports = guarded_ports(Box::new(BanToken(0)));
1023        s.load_prompt(&ports, &[]).unwrap();
1024
1025        // No rollback budget → first hard breach refuses deterministically.
1026        let stop = s.generate_with_policy(&ports, 5, tiny_policy(0)).unwrap();
1027
1028        assert_eq!(stop, StopReason::Stopped);
1029        assert!(s.output().is_empty());
1030        let evs = s.drain_events();
1031        assert!(evs
1032            .iter()
1033            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1034        assert!(!evs
1035            .iter()
1036            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1037    }
1038
1039    #[test]
1040    fn rollbacks_are_bounded_then_fail_closed() {
1041        let mut s = InferenceSession::new(
1042            SessionId(22),
1043            SessionConfig::default(),
1044            descending_engine(),
1045            permit(),
1046        );
1047        let ports = guarded_ports(Box::new(AlwaysHot));
1048        s.load_prompt(&ports, &[]).unwrap();
1049
1050        let stop = s.generate_with_policy(&ports, 8, tiny_policy(2)).unwrap();
1051
1052        assert_eq!(stop, StopReason::Stopped);
1053        let evs = s.drain_events();
1054        let rollbacks = evs
1055            .iter()
1056            .filter(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. }))
1057            .count();
1058        // Exactly max_rollbacks attempts, then a deterministic refusal (AC-6).
1059        assert_eq!(rollbacks, 2);
1060    }
1061
1062    #[test]
1063    fn memory_pressure_disables_checkpoints_and_fails_closed() {
1064        let cfg = SessionConfig {
1065            memory_budget_bytes: 1024, // far below MIN_CHECKPOINT_BUDGET_BYTES
1066            ..SessionConfig::default()
1067        };
1068        let mut s = InferenceSession::new(SessionId(23), cfg, descending_engine(), permit());
1069        let ports = guarded_ports(Box::new(BanToken(0)));
1070        s.load_prompt(&ports, &[]).unwrap();
1071
1072        let stop = s.generate_with_policy(&ports, 5, tiny_policy(3)).unwrap();
1073
1074        assert_eq!(stop, StopReason::Stopped); // no checkpoint to roll back to
1075        let evs = s.drain_events();
1076        assert!(evs.iter().any(|e| matches!(
1077            e.event,
1078            DomainEvent::SafetyDisabled {
1079                reason: DegradeReason::MemoryPressure
1080            }
1081        )));
1082    }
1083
1084    #[test]
1085    fn no_legal_token_fails_closed() {
1086        // Grammar disallows everything → pick() has no legal token. The loop must
1087        // fail closed, not commit token 0 (which could be EOS).
1088        let mut s = InferenceSession::new(
1089            SessionId(24),
1090            SessionConfig::default(),
1091            descending_engine(),
1092            permit(),
1093        );
1094        let ports = Ports {
1095            compressor: Box::new(crate::defaults::IdentityCompressor),
1096            grammar: Box::new(DenyAllMasker),
1097            safety: Box::new(el_safety::NoSafety),
1098            guard: None,
1099            ingress: None,
1100            relay: None,
1101        };
1102        s.load_prompt(&ports, &[]).unwrap();
1103
1104        let stop = s.generate(&ports, 4).unwrap();
1105
1106        assert_eq!(stop, StopReason::Stopped);
1107        assert!(s.output().is_empty()); // no illegal token committed
1108        let evs = s.drain_events();
1109        assert!(evs
1110            .iter()
1111            .any(|e| matches!(e.event, DomainEvent::GrammarViolationBlocked)));
1112    }
1113
1114    #[test]
1115    fn fail_closed_preserves_prefill_kv() {
1116        // Non-empty prompt → prefill KV descriptors must survive a
1117        // budget-exhausted refusal (regression: fail-closed once truncated KV to
1118        // output_len, dropping prefill).
1119        let mut s = InferenceSession::new(
1120            SessionId(25),
1121            SessionConfig::default(),
1122            descending_engine(),
1123            permit(),
1124        );
1125        let ports = guarded_ports(Box::new(AlwaysHot));
1126        s.load_prompt(&ports, &[7, 8, 9]).unwrap(); // prefill KV length = 3
1127        assert_eq!(s.kv_len(), 3);
1128
1129        let stop = s.generate_with_policy(&ports, 8, tiny_policy(1)).unwrap();
1130
1131        assert_eq!(stop, StopReason::Stopped);
1132        assert!(s.output().is_empty()); // refused back to the post-prefill prefix
1133        assert_eq!(s.kv_len(), 3); // prefill KV intact, not truncated to 0
1134    }
1135
1136    // ----- ADR-013 model-backed steering: window, ingress, mode selector -----
1137
1138    use el_core::SafetyMode;
1139    use el_safety::SafetySteerer;
1140
1141    /// Records, per step, whether the logit-aware path was taken and the output
1142    /// length at that step — so a test can prove the early-token window gating.
1143    struct RecordingSteerer {
1144        log: std::rc::Rc<std::cell::RefCell<Vec<(bool, usize)>>>,
1145    }
1146    impl SafetySteerer for RecordingSteerer {
1147        fn adjust(&self, recent: &[Token]) -> LogitAdjustment {
1148            self.log.borrow_mut().push((false, recent.len()));
1149            LogitAdjustment::none()
1150        }
1151        fn adjust_with_logits(&self, recent: &[Token], _base: &[i32]) -> LogitAdjustment {
1152            self.log.borrow_mut().push((true, recent.len()));
1153            LogitAdjustment::none()
1154        }
1155        fn mode(&self) -> SafetyMode {
1156            SafetyMode::SecDecoding
1157        }
1158    }
1159
1160    #[test]
1161    fn soft_steer_applies_only_inside_the_early_token_window() {
1162        // AC-1: adjust_with_logits runs for output positions < steer_window, and
1163        // plain token-only adjust() afterwards.
1164        let log = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1165        let mut s = InferenceSession::new(
1166            SessionId(30),
1167            SessionConfig::default(),
1168            descending_engine(), // never EOS (eos 9999)
1169            permit(),
1170        );
1171        let ports = Ports {
1172            compressor: Box::new(crate::defaults::IdentityCompressor),
1173            grammar: Box::new(crate::defaults::AllowAllMasker),
1174            safety: Box::new(RecordingSteerer { log: log.clone() }),
1175            guard: None,
1176            ingress: None,
1177            relay: None,
1178        };
1179        s.load_prompt(&ports, &[]).unwrap();
1180        let policy = RollbackPolicy {
1181            guard_every: 0,
1182            steer_window: 2,
1183            soft_threshold: SafetyScore::MAX,
1184            hard_threshold: SafetyScore::MAX,
1185            max_rollbacks: 0,
1186            max_checkpoints: 0,
1187        };
1188        s.generate_with_policy(&ports, 4, policy).unwrap();
1189
1190        let calls = log.borrow();
1191        assert_eq!(calls.len(), 4);
1192        for &(with_logits, len) in calls.iter() {
1193            assert_eq!(
1194                with_logits,
1195                len < 2,
1196                "window gate wrong at output len {len}"
1197            );
1198        }
1199    }
1200
1201    #[test]
1202    fn ingress_triage_fails_closed_before_generation() {
1203        // AC-3: a prompt scored at/above the hard threshold refuses with no decode.
1204        let mut s = InferenceSession::new(
1205            SessionId(31),
1206            SessionConfig::default(),
1207            descending_engine(),
1208            permit(),
1209        );
1210        let ports = Ports {
1211            compressor: Box::new(crate::defaults::IdentityCompressor),
1212            grammar: Box::new(crate::defaults::AllowAllMasker),
1213            safety: Box::new(el_safety::NoSafety),
1214            guard: None,
1215            ingress: Some(Box::new(AlwaysHot)), // prompt scores MAX
1216            relay: None,
1217        };
1218        s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1219
1220        let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
1221
1222        assert_eq!(stop, StopReason::Stopped);
1223        assert!(s.output().is_empty());
1224        let evs = s.drain_events();
1225        assert!(evs
1226            .iter()
1227            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1228        // Fail-closed at ingress means nothing was ever generated/committed.
1229        assert!(!evs
1230            .iter()
1231            .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })));
1232    }
1233
1234    #[test]
1235    fn generate_applies_safety_mode_selector_and_records_effective_mode() {
1236        // AC-4: SecDecoding on MidRange downgrades to Lightweight in the decode
1237        // path, and the effective mode is what gets recorded.
1238        let cfg = SessionConfig {
1239            device: el_core::DeviceTarget::MidRange,
1240            safety: SafetyMode::SecDecoding,
1241            ..SessionConfig::default()
1242        };
1243        let mut s = InferenceSession::new(SessionId(32), cfg, NullEngine::new(0, 4), permit());
1244        let ports = Ports::permissive();
1245        s.load_prompt(&ports, &[1]).unwrap();
1246        s.generate(&ports, 4).unwrap();
1247
1248        let evs = s.drain_events();
1249        assert!(evs.iter().any(|e| matches!(
1250            e.event,
1251            DomainEvent::SafetyModeSelected {
1252                mode: SafetyMode::Lightweight
1253            }
1254        )));
1255    }
1256
1257    // ----- ADR-018 persistent model / stateful session reuse -----
1258
1259    /// Counts how often it is prefilled and cache-reset, and emits EOS on the
1260    /// first decode step so generation terminates immediately. Mirrors a *resident*
1261    /// engine: one instance reused across conversations, never reconstructed.
1262    struct CountingEngine {
1263        eos: Token,
1264        vocab: usize,
1265        prefills: std::rc::Rc<std::cell::Cell<u32>>,
1266        resets: std::rc::Rc<std::cell::Cell<u32>>,
1267    }
1268    impl InferenceEngine for CountingEngine {
1269        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1270            self.prefills.set(self.prefills.get() + 1);
1271            Ok(t.len() as u32)
1272        }
1273        fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1274            let mut v = vec![0i32; self.vocab];
1275            if let Some(s) = v.get_mut(self.eos as usize) {
1276                *s = 1;
1277            }
1278            v
1279        }
1280        fn eos_token(&self) -> Token {
1281            self.eos
1282        }
1283        fn rollback(&mut self, _keep: u32) -> Result<()> {
1284            Ok(())
1285        }
1286        fn reset_cache(&mut self) -> Result<()> {
1287            self.resets.set(self.resets.get() + 1);
1288            Ok(())
1289        }
1290    }
1291
1292    fn counting_engine() -> (
1293        CountingEngine,
1294        std::rc::Rc<std::cell::Cell<u32>>,
1295        std::rc::Rc<std::cell::Cell<u32>>,
1296    ) {
1297        let prefills = std::rc::Rc::new(std::cell::Cell::new(0u32));
1298        let resets = std::rc::Rc::new(std::cell::Cell::new(0u32));
1299        let engine = CountingEngine {
1300            eos: 1,
1301            vocab: 4,
1302            prefills: prefills.clone(),
1303            resets: resets.clone(),
1304        };
1305        (engine, prefills, resets)
1306    }
1307
1308    #[test]
1309    fn reset_resets_the_engine_cache() {
1310        // AC: reset() must discard the engine's conversation cache, not just the
1311        // session's descriptors — otherwise a reused resident engine would carry a
1312        // stale KV cache into the next conversation.
1313        let (engine, _prefills, resets) = counting_engine();
1314        let mut s =
1315            InferenceSession::new(SessionId(40), SessionConfig::default(), engine, permit());
1316        assert_eq!(resets.get(), 0);
1317        s.reset().unwrap();
1318        assert_eq!(
1319            resets.get(),
1320            1,
1321            "reset() must reset the engine cache (ADR-018)"
1322        );
1323    }
1324
1325    #[test]
1326    fn one_engine_serves_multiple_conversations_via_reset() {
1327        // AC-1/AC-2: the same resident engine is reused across conversations — it
1328        // is prefilled again per turn but never reconstructed, and its cache is
1329        // reset between turns.
1330        let (engine, prefills, resets) = counting_engine();
1331        let mut s =
1332            InferenceSession::new(SessionId(41), SessionConfig::default(), engine, permit());
1333        let ports = Ports::permissive();
1334
1335        s.load_prompt(&ports, &[1, 2]).unwrap();
1336        s.generate(&ports, 8).unwrap();
1337        assert_eq!(prefills.get(), 1);
1338
1339        // Reuse for a second conversation — no new engine, no reload.
1340        s.reset().unwrap();
1341        s.load_prompt(&ports, &[3, 4, 5]).unwrap();
1342        s.generate(&ports, 8).unwrap();
1343
1344        assert_eq!(
1345            prefills.get(),
1346            2,
1347            "the one resident engine was prefilled again, not rebuilt"
1348        );
1349        assert!(
1350            resets.get() >= 1,
1351            "the engine cache was reset between conversations"
1352        );
1353    }
1354
1355    /// Counts both cache resets and drops, so a test can prove `close()` releases
1356    /// the conversation's KV (reset_cache) **without** dropping the resident engine.
1357    struct ResidentEngine {
1358        eos: Token,
1359        vocab: usize,
1360        resets: std::rc::Rc<std::cell::Cell<u32>>,
1361        dropped: std::rc::Rc<std::cell::Cell<u32>>,
1362    }
1363    impl Drop for ResidentEngine {
1364        fn drop(&mut self) {
1365            self.dropped.set(self.dropped.get() + 1);
1366        }
1367    }
1368    impl InferenceEngine for ResidentEngine {
1369        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1370            Ok(t.len() as u32)
1371        }
1372        fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1373            let mut v = vec![0i32; self.vocab];
1374            if let Some(s) = v.get_mut(self.eos as usize) {
1375                *s = 1;
1376            }
1377            v
1378        }
1379        fn eos_token(&self) -> Token {
1380            self.eos
1381        }
1382        fn rollback(&mut self, _keep: u32) -> Result<()> {
1383            Ok(())
1384        }
1385        fn reset_cache(&mut self) -> Result<()> {
1386            self.resets.set(self.resets.get() + 1);
1387            Ok(())
1388        }
1389    }
1390
1391    #[test]
1392    fn close_releases_conversation_but_keeps_engine_resident() {
1393        // AC-4 / ADR-018: `close` must release the conversation's memory (KV via
1394        // the engine's `reset_cache`) while keeping the resident weights loaded —
1395        // and the session must remain reusable for the next conversation. Dropping
1396        // the engine (and its weights) is NOT close's job; that is provider drop.
1397        let resets = std::rc::Rc::new(std::cell::Cell::new(0u32));
1398        let dropped = std::rc::Rc::new(std::cell::Cell::new(0u32));
1399        let engine = ResidentEngine {
1400            eos: 1,
1401            vocab: 4,
1402            resets: resets.clone(),
1403            dropped: dropped.clone(),
1404        };
1405        let mut s =
1406            InferenceSession::new(SessionId(42), SessionConfig::default(), engine, permit());
1407        let ports = Ports::permissive();
1408        s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1409        s.generate(&ports, 8).unwrap();
1410        assert!(s.kv_len() > 0, "conversation memory is measurable (AC-4)");
1411
1412        s.close().unwrap();
1413
1414        assert_eq!(s.kv_len(), 0, "close frees the session's KV descriptors");
1415        assert!(s.output().is_empty(), "close frees committed output");
1416        assert!(
1417            resets.get() >= 1,
1418            "close releases the engine's conversation KV (keeps weights)"
1419        );
1420        assert_eq!(
1421            dropped.get(),
1422            0,
1423            "the resident engine/weights are NOT dropped by close"
1424        );
1425
1426        // Model still resident → the session serves a new conversation, no reload.
1427        s.load_prompt(&ports, &[4, 5]).unwrap();
1428        s.generate(&ports, 8).unwrap();
1429        assert!(
1430            s.kv_len() > 0,
1431            "same engine serves a new conversation after close"
1432        );
1433        assert_eq!(dropped.get(), 0);
1434    }
1435
1436    #[test]
1437    fn reset_preserves_undrained_events() {
1438        // Generic semantics: reset() must NOT silently drop a consumer's undrained
1439        // telemetry. Turn-level isolation is the provider's job (drain between
1440        // turns), not reset()'s.
1441        let (engine, _prefills, _resets) = counting_engine();
1442        let mut s =
1443            InferenceSession::new(SessionId(44), SessionConfig::default(), engine, permit());
1444        let ports = Ports::permissive();
1445        s.load_prompt(&ports, &[1, 2]).unwrap();
1446        s.generate(&ports, 8).unwrap(); // emits events the caller has not drained
1447
1448        s.reset().unwrap();
1449
1450        let evs = s.drain_events();
1451        assert!(
1452            evs.iter()
1453                .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })),
1454            "pre-reset events are preserved for the consumer"
1455        );
1456        assert!(
1457            evs.iter()
1458                .any(|e| matches!(e.event, DomainEvent::SessionReset)),
1459            "and the reset itself is recorded"
1460        );
1461    }
1462
1463    /// Reset is fallible: this engine refuses to discard its cache.
1464    struct FailResetEngine {
1465        eos: Token,
1466        vocab: usize,
1467    }
1468    impl InferenceEngine for FailResetEngine {
1469        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1470            Ok(t.len() as u32)
1471        }
1472        fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1473            let mut v = vec![0i32; self.vocab];
1474            if let Some(s) = v.get_mut(self.eos as usize) {
1475                *s = 1;
1476            }
1477            v
1478        }
1479        fn eos_token(&self) -> Token {
1480            self.eos
1481        }
1482        fn rollback(&mut self, _keep: u32) -> Result<()> {
1483            Ok(())
1484        }
1485        fn reset_cache(&mut self) -> Result<()> {
1486            Err(EdgeError::Engine("reset_cache refused"))
1487        }
1488    }
1489
1490    #[test]
1491    fn reset_propagates_engine_cache_failure_and_leaves_state() {
1492        // A fallible engine that cannot discard its cache must NOT be reported as a
1493        // clean fresh session: the error surfaces and the session descriptors stay
1494        // untouched, so an empty session can never desync from a stale engine cache.
1495        let mut s = InferenceSession::new(
1496            SessionId(45),
1497            SessionConfig::default(),
1498            FailResetEngine { eos: 1, vocab: 4 },
1499            permit(),
1500        );
1501        let ports = Ports::permissive();
1502        s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1503        s.generate(&ports, 8).unwrap();
1504        let kv_before = s.kv_len();
1505        assert!(kv_before > 0);
1506
1507        let err = s.reset().unwrap_err();
1508        assert!(matches!(err, EdgeError::Engine(_)));
1509        assert_eq!(
1510            s.kv_len(),
1511            kv_before,
1512            "descriptors must be left intact when the engine reset fails"
1513        );
1514        assert_eq!(
1515            s.phase(),
1516            Phase::Completed,
1517            "phase is unchanged on a failed reset"
1518        );
1519    }
1520
1521    /// Marks itself dirty when it caches, fails its first `reset_cache` (a
1522    /// simulated partial eviction), then succeeds — recording `cleared` only after
1523    /// a *fully successful* attempt. Mirrors the `QwenEngine` dirty-flag contract.
1524    struct RetryClearEngine {
1525        eos: Token,
1526        vocab: usize,
1527        dirty: bool,
1528        fails_left: std::rc::Rc<std::cell::Cell<u32>>,
1529        cleared: std::rc::Rc<std::cell::Cell<bool>>,
1530    }
1531    impl InferenceEngine for RetryClearEngine {
1532        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1533            self.dirty = true;
1534            Ok(t.len() as u32)
1535        }
1536        fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1537            self.dirty = true;
1538            let mut v = vec![0i32; self.vocab];
1539            if let Some(s) = v.get_mut(self.eos as usize) {
1540                *s = 1;
1541            }
1542            v
1543        }
1544        fn eos_token(&self) -> Token {
1545            self.eos
1546        }
1547        fn rollback(&mut self, _keep: u32) -> Result<()> {
1548            Ok(())
1549        }
1550        fn reset_cache(&mut self) -> Result<()> {
1551            if self.dirty {
1552                if self.fails_left.get() > 0 {
1553                    self.fails_left.set(self.fails_left.get() - 1);
1554                    return Err(EdgeError::Engine("partial eviction")); // stays dirty
1555                }
1556                self.dirty = false;
1557                self.cleared.set(true);
1558            }
1559            Ok(())
1560        }
1561    }
1562
1563    #[test]
1564    fn reset_retries_clearing_after_a_failed_attempt() {
1565        // Regression (P1): a `reset_cache` that fails after partially clearing must
1566        // NOT be skipped on the next attempt. Dirtiness is tracked independently of
1567        // any position cursor, so the retry re-clears rather than falsely reporting
1568        // a clean cache while user K/V is still resident.
1569        let fails_left = std::rc::Rc::new(std::cell::Cell::new(1u32));
1570        let cleared = std::rc::Rc::new(std::cell::Cell::new(false));
1571        let engine = RetryClearEngine {
1572            eos: 1,
1573            vocab: 4,
1574            dirty: false,
1575            fails_left: fails_left.clone(),
1576            cleared: cleared.clone(),
1577        };
1578        let mut s =
1579            InferenceSession::new(SessionId(47), SessionConfig::default(), engine, permit());
1580        let ports = Ports::permissive();
1581        s.load_prompt(&ports, &[1, 2]).unwrap();
1582        s.generate(&ports, 8).unwrap();
1583
1584        // First reset fails (simulated partial eviction) and is surfaced.
1585        assert!(s.reset().is_err());
1586        assert!(
1587            !cleared.get(),
1588            "a failed reset must not report the cache cleared"
1589        );
1590
1591        // The retry actually re-clears — it is not skipped.
1592        s.reset().unwrap();
1593        assert!(
1594            cleared.get(),
1595            "the next reset re-clears after a failed attempt"
1596        );
1597    }
1598}