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    /// Begin a **follow-up turn on the same conversation**, reusing the KV cache
159    /// for the unchanged prefix instead of re-prefilling the whole transcript
160    /// (ADR-018 AC-3 cross-turn incremental prefill). `full_context` is the entire
161    /// re-rendered conversation, tokenized (rendering is the provider's concern).
162    ///
163    /// Valid only from [`Phase::Completed`] — i.e. a prior turn on this resident
164    /// session finished. The engine ([`prefill_reuse`](InferenceEngine::prefill_reuse))
165    /// reuses the longest cached prefix that still matches `full_context` and feeds
166    /// only the divergent suffix; the prior turn's generated reply, now baked into
167    /// the re-rendered context, becomes part of the reused prefix. This turn's
168    /// generated `output` starts empty; the KV descriptors are rebuilt to the
169    /// engine's reported length.
170    ///
171    /// Safety is unchanged (ADR-012/ADR-013): [`generate`](Self::generate) re-scores
172    /// the full prompt at ingress and guards this turn's output as usual, and a
173    /// within-turn rollback rebuilds from `full_context` (the reuse base). KV reuse
174    /// is purely a compute optimisation — it never alters what the safety loop sees.
175    ///
176    /// Distinct from [`reset`](Self::reset) (discard the conversation, start fresh):
177    /// `continue_prompt` *keeps* the conversation and extends it. A provider that
178    /// cannot guarantee a clean prior turn (after a reset, error, or full discard)
179    /// should use [`load_prompt`](Self::load_prompt) from `Initialized` instead;
180    /// the engine's prefix check is a backstop that falls back to a full prefill on
181    /// any divergence.
182    ///
183    /// Unlike `load_prompt`, this deliberately does **not** apply prompt
184    /// compression: cross-turn reuse needs a byte-stable token prefix, and
185    /// compressing the (growing) re-rendered conversation each turn would yield a
186    /// different prefix and defeat reuse — the two are mutually exclusive. `ports`
187    /// is accepted for call-site symmetry with `load_prompt`; the grammar/safety/
188    /// ingress ports are consumed by [`generate`](Self::generate), not here.
189    pub fn continue_prompt(&mut self, _ports: &Ports, full_context: &[Token]) -> Result<()> {
190        if self.phase != Phase::Completed {
191            return Err(EdgeError::InvalidPhase {
192                expected: "Completed",
193                found: self.phase.as_str(),
194            });
195        }
196        // The whole re-rendered conversation is this turn's prompt for ADR-013
197        // ingress triage (re-scored in `generate_with_policy`).
198        self.prompt = full_context.to_vec();
199
200        self.phase = Phase::Prefilling;
201        let kv_len = self.engine.prefill_reuse(full_context)?;
202        // Rebuild the KV descriptors to match the reused-plus-extended cache.
203        self.kv = KvRegion::new();
204        for _ in 0..kv_len {
205            let off = self.kv.len() as u64;
206            self.kv.push(off);
207        }
208        // Fresh generated output for this turn; the prior reply is now in the
209        // prefilled context.
210        self.output.clear();
211        self.emit(DomainEvent::PrefillCompleted {
212            prompt_tokens: full_context.len() as u32,
213            kv_len,
214            prefill_tps: 0,
215        });
216        self.phase = Phase::Decoding;
217        Ok(())
218    }
219
220    /// Run the decode loop until EOS or `max_tokens`, deriving the rollback
221    /// policy from the session's device tier and safety mode (ADR-005/ADR-012).
222    pub fn generate(&mut self, ports: &Ports, max_tokens: u32) -> Result<StopReason> {
223        // Resolve the *effective* tier in the decode path (ADR-013): a tier the
224        // device cannot run (e.g. `SecDecoding` on `MidRange`) is downgraded
225        // here, and the effective mode — not just the requested one — is what
226        // drives the policy and is recorded for telemetry.
227        let effective = SafetyModeSelector::resolve(self.config.safety, self.config.device);
228        self.emit(DomainEvent::SafetyModeSelected { mode: effective });
229        let policy = RollbackPolicy::for_device(self.config.device, effective);
230        self.generate_with_policy(ports, max_tokens, policy)
231    }
232
233    /// The checkpointed-rollback safety control loop (ADR-012).
234    ///
235    /// Every step preserves the invariant order **grammar mask → safety adjust →
236    /// sample → commit** (ADR-005). When a [`ChunkGuard`](el_safety::ChunkGuard)
237    /// is wired and the policy enables guarding, the loop additionally captures a
238    /// checkpoint at each guard-verified-safe boundary, scores recent output
239    /// every `guard_every` tokens, and on a hard-threshold breach rolls the KV
240    /// cache *and* the committed output back to the last safe checkpoint —
241    /// banning the offending token through the grammar mask so the resumed
242    /// decode necessarily diverges. Rollbacks are bounded by `max_rollbacks`; on
243    /// exhaustion — or with no checkpoint (e.g. under memory pressure) — the loop
244    /// fails closed with a deterministic refusal (`StopReason::Stopped`).
245    ///
246    /// Termination (EOS or `max_tokens`) is gated behind a **mandatory final
247    /// guard check**: the loop scores the trailing chunk before honouring either
248    /// stop condition, so no completion is ever returned unscored — including one
249    /// shorter than `guard_every` or whose unsafe tail ends in EOS. A final check
250    /// coincident with a cadence boundary is idempotent (re-scoring identical
251    /// output yields the same verdict).
252    pub fn generate_with_policy(
253        &mut self,
254        ports: &Ports,
255        max_tokens: u32,
256        policy: RollbackPolicy,
257    ) -> Result<StopReason> {
258        if self.phase != Phase::Decoding {
259            return Err(EdgeError::InvalidPhase {
260                expected: "Decoding",
261                found: self.phase.as_str(),
262            });
263        }
264
265        // ---- ingress / prompt-risk triage (ADR-013) ----
266        // Score the prompt before generating anything. A hard breach fails
267        // closed deterministically — no unsafe trajectory is ever started. This
268        // is the heterogeneous monitor's ingress layer, distinct from the
269        // output-side chunk guard below.
270        if policy.active() {
271            if let Some(ingress) = ports.ingress.as_deref() {
272                let score = ingress.score(&self.prompt);
273                if score >= policy.hard_threshold {
274                    self.emit(DomainEvent::SafetyViolationDetected {
275                        score_milli: score.milli(),
276                        threshold_milli: policy.hard_threshold.milli(),
277                    });
278                    self.phase = Phase::Completed;
279                    self.emit(DomainEvent::GenerationCompleted {
280                        total_tokens: self.output.len() as u32,
281                        stop: StopReason::Stopped,
282                    });
283                    return Ok(StopReason::Stopped);
284                }
285            }
286        }
287
288        let eos = self.engine.eos_token();
289        let guarding = policy.guards() && ports.guard.is_some();
290
291        // Tier-aware degradation (ADR-003/ADR-012): without budget for
292        // checkpoints, run guard-only with no rollback capability.
293        let checkpoints = if guarding {
294            if self.config.memory_budget_bytes < MIN_CHECKPOINT_BUDGET_BYTES {
295                self.emit(DomainEvent::SafetyDisabled {
296                    reason: DegradeReason::MemoryPressure,
297                });
298                CheckpointManager::new(0)
299            } else {
300                CheckpointManager::new(policy.max_checkpoints)
301            }
302        } else {
303            CheckpointManager::new(0)
304        };
305        let mut state = GuardState {
306            checkpoints,
307            rollback_count: 0,
308            banned: Vec::new(),
309            // The post-prefill baseline: the safe prefix to restore to when no
310            // checkpoint exists (e.g. checkpointing disabled under memory
311            // pressure). Captured as (output, KV) so fail-closed never drops
312            // prompt prefill KV.
313            start_out: self.output.len() as u32,
314            start_kv: self.kv.len(),
315        };
316        // Seed the safe prefix at generation start (an empty continuation is safe).
317        if state.checkpoints.enabled() {
318            state.checkpoints.push(Checkpoint {
319                output_len: state.start_out,
320                kv_len: state.start_kv,
321            });
322        }
323
324        let stop = loop {
325            // A *candidate* termination for this iteration: the token cap is
326            // reached (checked before generating), or — set below — the model
327            // emitted EOS. With a guard active, neither is honoured until the
328            // final chunk passes the mandatory guard check, so a short or
329            // EOS-terminated tail cannot bypass scoring.
330            let mut terminating: Option<StopReason> = None;
331
332            if self.output.len() as u32 >= max_tokens {
333                terminating = Some(StopReason::MaxTokens);
334            } else {
335                // 2. next-token logits (drafting off by default).
336                let logits = self.engine.next_logits(&self.output);
337                let vocab = logits.len();
338
339                // 3. grammar mask (BEFORE safety). Rollback bans ride the mask so
340                //    the resumed decode cannot re-pick the off-trajectory token.
341                let mut mask = ports.grammar.mask(&self.output, vocab);
342                for &t in &state.banned {
343                    if let Some(slot) = mask.get_mut(t as usize) {
344                        *slot = false;
345                    }
346                }
347                let allowed = mask.iter().filter(|b| **b).count() as u32;
348                self.emit(DomainEvent::TokenMaskApplied { allowed });
349
350                // 4. safety adjust (AFTER mask, BEFORE sampling). Inside the
351                //    early-token soft-steering window (ADR-013) the steerer is
352                //    given the base logits so a model-backed (contrastive)
353                //    steerer can run; outside the window it is token-only (hard
354                //    bans every step). For token-only steerers the two paths are
355                //    identical (the default `adjust_with_logits` delegates).
356                let adj = if (self.output.len() as u32) < policy.steer_window {
357                    // Hide grammar-illegal tokens from the steerer so a top-K
358                    // model-backed steerer ranks only legal candidates — otherwise
359                    // the whole top-K could be illegal and legal tokens get no
360                    // adjustment. Skip the copy when the grammar allows everything.
361                    if mask.iter().any(|&legal| !legal) {
362                        let legal_logits: Vec<i32> = logits
363                            .iter()
364                            .zip(mask.iter())
365                            .map(|(&l, &legal)| if legal { l } else { i32::MIN })
366                            .collect();
367                        ports.safety.adjust_with_logits(&self.output, &legal_logits)
368                    } else {
369                        ports.safety.adjust_with_logits(&self.output, &logits)
370                    }
371                } else {
372                    ports.safety.adjust(&self.output)
373                };
374                if !adj.is_empty() {
375                    self.emit(DomainEvent::LogitsSteered {
376                        adjustment_norm_milli: adj.l1_norm_milli(),
377                    });
378                }
379
380                // 5. sample (greedy argmax over legal, steered logits). If grammar
381                //    + rollback bans leave no legal token, fail closed rather than
382                //    emit a masked/banned token.
383                let token = match pick(&logits, &mask, &adj) {
384                    Some(t) => t,
385                    None => {
386                        self.emit(DomainEvent::GrammarViolationBlocked);
387                        break StopReason::Stopped;
388                    }
389                };
390                self.emit(DomainEvent::TokenGenerated { sampled: false });
391
392                // 6. commit.
393                self.output.push(token);
394                self.kv.push(self.output.len() as u64);
395                self.step += 1;
396                self.emit(DomainEvent::TokenCommitted {
397                    kv_len: self.kv.len(),
398                });
399
400                if token == eos {
401                    terminating = Some(StopReason::Eos);
402                }
403            }
404
405            // ---- chunk guard + checkpointed rollback (ADR-012) ----
406            // Score at each `guard_every` cadence boundary AND before any
407            // termination (the mandatory final check). This closes the bypass
408            // where EOS or the token cap returned a tail shorter than
409            // `guard_every` unscored.
410            if guarding {
411                let guard = ports
412                    .guard
413                    .as_deref()
414                    .expect("guarding implies a guard is wired");
415                let at_boundary = (self.output.len() as u32).is_multiple_of(policy.guard_every);
416                if terminating.is_some() || at_boundary {
417                    match self.guard_chunk(guard, &policy, &mut state) {
418                        // Fail closed: no checkpoint, or rollback budget spent.
419                        GuardVerdict::FailClosed => break StopReason::Stopped,
420                        // Rolled back: the candidate termination (if any) was
421                        // undone with it, so resume decoding from the safe prefix.
422                        GuardVerdict::RolledBack => continue,
423                        GuardVerdict::Pass => {}
424                    }
425                }
426            }
427
428            if let Some(reason) = terminating {
429                break reason;
430            }
431        };
432
433        self.phase = Phase::Completed;
434        self.emit(DomainEvent::GenerationCompleted {
435            total_tokens: self.output.len() as u32,
436            stop,
437        });
438        Ok(stop)
439    }
440
441    /// Score the committed output and apply the ADR-012 rollback policy:
442    /// advance the safe checkpoint when verified safe, roll back (banning the
443    /// divergence token) on a hard breach within budget, or fail closed.
444    ///
445    /// Invoked both at `guard_every` cadence boundaries and as the mandatory
446    /// final check before termination, so no completion is returned unscored.
447    /// On [`GuardVerdict::RolledBack`]/[`GuardVerdict::FailClosed`] the output
448    /// **and** KV are truncated together to the safe prefix (or the post-prefill
449    /// baseline) so prompt prefill descriptors are never dropped (AC-5). A
450    /// rollback also restores the *engine's* internal state via
451    /// [`InferenceEngine::rollback`] — a stateful engine (real KV cache) that
452    /// kept the abandoned branch would otherwise serve logits from the unsafe
453    /// path and skip the replacement tokens.
454    fn guard_chunk(
455        &mut self,
456        guard: &dyn el_safety::ChunkGuard,
457        policy: &RollbackPolicy,
458        state: &mut GuardState,
459    ) -> GuardVerdict {
460        let score = guard.score(&self.output);
461        if score >= policy.hard_threshold {
462            self.emit(DomainEvent::SafetyViolationDetected {
463                score_milli: score.milli(),
464                threshold_milli: policy.hard_threshold.milli(),
465            });
466            match state.checkpoints.last() {
467                Some(cp) if state.rollback_count < policy.max_rollbacks => {
468                    // Restore the engine's internal state (real KV cache /
469                    // position) to the checkpoint too. If it cannot, fail closed
470                    // rather than resume decoding on an inconsistent cache.
471                    if self.engine.rollback(cp.output_len).is_err() {
472                        self.output.truncate(cp.output_len as usize);
473                        self.kv.truncate(cp.kv_len);
474                        return GuardVerdict::FailClosed;
475                    }
476                    // Ban the token that began the unsafe span → divergence.
477                    if let Some(&bad) = self.output.get(cp.output_len as usize) {
478                        state.banned.push(bad);
479                    }
480                    self.output.truncate(cp.output_len as usize);
481                    self.kv.truncate(cp.kv_len);
482                    state.rollback_count += 1;
483                    self.emit(DomainEvent::ClaimBacktracked {
484                        claim_index: cp.output_len,
485                    });
486                    GuardVerdict::RolledBack
487                }
488                _ => {
489                    let (safe_out, safe_kv) = state
490                        .checkpoints
491                        .last()
492                        .map_or((state.start_out, state.start_kv), |c| {
493                            (c.output_len, c.kv_len)
494                        });
495                    self.output.truncate(safe_out as usize);
496                    self.kv.truncate(safe_kv);
497                    GuardVerdict::FailClosed
498                }
499            }
500        } else if score < policy.soft_threshold {
501            // Verified safe: advance the last-safe checkpoint, drop bans.
502            if state.checkpoints.enabled() {
503                state.checkpoints.push(Checkpoint {
504                    output_len: self.output.len() as u32,
505                    kv_len: self.kv.len(),
506                });
507            }
508            state.banned.clear();
509            GuardVerdict::Pass
510        } else {
511            // soft ≤ score < hard: tolerated but not checkpointed (still risky).
512            GuardVerdict::Pass
513        }
514    }
515
516    /// Reset for a fresh conversation on the **same resident weights** — the seam
517    /// that turns a provider from "rebuild the engine every turn" into "load once,
518    /// reuse" (ADR-018). Clears the session's KV descriptors / output / prompt and
519    /// resets the engine's *logical* cache; distinct from a safety
520    /// [`rollback`](InferenceEngine::rollback) (which rewinds *within* a
521    /// generation).
522    ///
523    /// Returns the engine's `reset_cache` error rather than swallowing it: an
524    /// engine that fails to discard its cache must not be reported as a clean
525    /// fresh session. On error the session state is left **untouched** (so an empty
526    /// session can never desync from a stale engine cache) and the caller is
527    /// notified — it should drop/rebuild rather than reuse.
528    ///
529    /// Buffered events are **preserved** (generic semantics): a telemetry consumer
530    /// may still [`drain_events`](Self::drain_events) after a reset. Turn-level
531    /// event isolation is the caller's concern — a provider that reuses one session
532    /// across turns should drain between turns (see the adapter providers).
533    ///
534    /// `reset_cache` releases the *previous conversation's* KV while keeping the
535    /// resident weights loaded — that separation of conversation lifecycle from
536    /// model lifecycle is the point of ADR-018.
537    pub fn reset(&mut self) -> Result<()> {
538        self.engine.reset_cache()?;
539        self.kv = KvRegion::new();
540        self.prompt.clear();
541        self.output.clear();
542        self.step = 0;
543        self.phase = Phase::Initialized;
544        self.emit(DomainEvent::SessionReset);
545        Ok(())
546    }
547
548    /// End the current conversation: release its volatile memory — engine KV
549    /// (via [`reset_cache`](InferenceEngine::reset_cache)), KV descriptors,
550    /// committed output, retained prompt, and buffered events — while keeping the
551    /// **resident model loaded** so the session can serve a new conversation
552    /// without reloading weights (ADR-018; PRD line 131 "KV caches … cleared on
553    /// session end"; the AC-4 explicit release).
554    ///
555    /// Takes `&mut self`, not `self`: dropping the session would also drop the
556    /// (expensive) weights, which is the opposite of "load once, reuse." Instead
557    /// the engine releases the conversation's KV in place — for candle's
558    /// `quantized_qwen2`, a position-0 forward overwrites and frees the prior K/V
559    /// tensors (see its `reset_cache`). Unlike [`reset`](Self::reset), `close` also
560    /// frees the buffers' *capacity* and discards buffered events, minimizing the
561    /// idle footprint. Propagates a `reset_cache` failure (state untouched on
562    /// error). To free the weights too, drop the session/provider (ownership).
563    pub fn close(&mut self) -> Result<()> {
564        self.engine.reset_cache()?;
565        self.kv = KvRegion::new();
566        self.prompt = Vec::new();
567        self.output = Vec::new();
568        self.events = Vec::new();
569        self.step = 0;
570        self.phase = Phase::Initialized;
571        Ok(())
572    }
573
574    /// Consult the opt-in LAN relay. Hard-fails with [`EdgeError::AirGapViolation`]
575    /// unless `hybrid_mode` is enabled AND a relay is wired (ADR-004).
576    pub fn consult_relay(&mut self, ports: &Ports, query: &[Token]) -> Result<Vec<Token>> {
577        if !self.config.hybrid_mode {
578            return Err(EdgeError::AirGapViolation);
579        }
580        match &ports.relay {
581            Some(relay) => {
582                let out = relay.consult(query);
583                self.emit(DomainEvent::HybridRelayConsulted);
584                Ok(out)
585            }
586            None => Err(EdgeError::AirGapViolation),
587        }
588    }
589}
590
591/// Greedy pick over legal, safety-steered logits. Masked-out tokens are skipped
592/// entirely; the safety delta is added to surviving logits before argmax.
593/// Returns `None` when no token is legal (every token masked out or banned), so
594/// the caller fails closed instead of emitting a rejected token.
595fn pick(logits: &[i32], mask: &[bool], adj: &LogitAdjustment) -> Option<Token> {
596    let mut best: Option<Token> = None;
597    let mut best_val = i32::MIN;
598    for (i, &l) in logits.iter().enumerate() {
599        if mask.get(i).copied() == Some(false) {
600            continue;
601        }
602        let v = l.saturating_add(adj.delta_for(i as Token));
603        if v > best_val {
604            best_val = v;
605            best = Some(i as Token);
606        }
607    }
608    best
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::defaults::NullEngine;
615    use crate::ports::{GrammarMasker, Ports};
616    use el_core::{ModelFormat, ModelId, ModelVersion};
617    use el_provenance::{ModelArtifact, SignatureVerifier};
618    use el_safety::LightweightFilter;
619
620    struct OkVerifier;
621    impl SignatureVerifier for OkVerifier {
622        fn verify(&self, _b: &[u8], _s: &[u8], _k: u32) -> bool {
623            true
624        }
625    }
626
627    fn permit() -> LoadPermit {
628        let mut a = ModelArtifact::new(ModelId(1), ModelVersion::new(0, 1, 0), ModelFormat::Gguf);
629        a.verify(&OkVerifier, b"weights", b"sig", 1);
630        a.ensure_loadable().expect("verified artifact loads")
631    }
632
633    /// A deterministic engine returning fixed logits; eos out of vocab range so
634    /// it never self-terminates (used for the composition-order test).
635    struct FixedEngine {
636        logits: Vec<i32>,
637    }
638    impl InferenceEngine for FixedEngine {
639        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
640            Ok(t.len() as u32)
641        }
642        fn next_logits(&mut self, _c: &[Token]) -> Vec<i32> {
643            self.logits.clone()
644        }
645        fn eos_token(&self) -> Token {
646            9999
647        }
648        fn rollback(&mut self, _keep: u32) -> Result<()> {
649            Ok(()) // stateless
650        }
651        fn reset_cache(&mut self) -> Result<()> {
652            Ok(()) // stateless
653        }
654    }
655
656    // Grammar masker that disallows specific token ids.
657    struct DisallowMasker(Vec<Token>);
658    impl GrammarMasker for DisallowMasker {
659        fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
660            (0..vocab as Token).map(|t| !self.0.contains(&t)).collect()
661        }
662    }
663
664    #[test]
665    fn full_lifecycle_init_prefill_decode_complete_reset() {
666        let mut s = InferenceSession::new(
667            SessionId(1),
668            SessionConfig::default(),
669            NullEngine::new(3, 8),
670            permit(),
671        );
672        assert_eq!(s.phase(), Phase::Initialized);
673
674        let ports = Ports::permissive();
675        s.load_prompt(&ports, &[10, 11, 12]).unwrap();
676        assert_eq!(s.phase(), Phase::Decoding);
677
678        let stop = s.generate(&ports, 16).unwrap();
679        assert_eq!(stop, StopReason::Eos); // NullEngine emits EOS first step
680        assert_eq!(s.output(), &[3]);
681        assert_eq!(s.phase(), Phase::Completed);
682
683        s.reset().unwrap();
684        assert_eq!(s.phase(), Phase::Initialized);
685        assert!(s.output().is_empty());
686    }
687
688    #[test]
689    fn decode_applies_grammar_before_safety_before_sampling() {
690        // logits favour token 0 (10), then 1 (9), then 2 (8), then 3 (7).
691        let engine = FixedEngine {
692            logits: vec![10, 9, 8, 7],
693        };
694        let mut s = InferenceSession::new(SessionId(2), SessionConfig::default(), engine, permit());
695
696        let ports = Ports {
697            compressor: Box::new(crate::defaults::IdentityCompressor),
698            grammar: Box::new(DisallowMasker(vec![0])), // grammar removes the top token
699            safety: Box::new(LightweightFilter::new(vec![1])), // safety bans the next-best
700            guard: None,
701            ingress: None,
702            relay: None,
703        };
704        s.load_prompt(&ports, &[1]).unwrap();
705        let stop = s.generate(&ports, 1).unwrap();
706
707        assert_eq!(stop, StopReason::MaxTokens);
708        // Token 0 removed by grammar, token 1 banned by safety → token 2 wins.
709        // Proves order: mask → adjust → sample.
710        assert_eq!(s.output(), &[2]);
711    }
712
713    #[test]
714    fn generate_before_load_prompt_is_invalid_phase() {
715        let mut s = InferenceSession::new(
716            SessionId(3),
717            SessionConfig::default(),
718            NullEngine::new(0, 4),
719            permit(),
720        );
721        let ports = Ports::permissive();
722        let err = s.generate(&ports, 4).unwrap_err();
723        assert!(matches!(err, EdgeError::InvalidPhase { .. }));
724    }
725
726    #[test]
727    fn relay_is_blocked_unless_hybrid_mode_opted_in() {
728        struct EchoRelay;
729        impl crate::ports::HybridRelay for EchoRelay {
730            fn consult(&self, q: &[Token]) -> Vec<Token> {
731                q.to_vec()
732            }
733        }
734
735        // Air-gapped by default: even with a relay wired, consulting fails.
736        let mut s = InferenceSession::new(
737            SessionId(4),
738            SessionConfig::default(),
739            NullEngine::new(0, 4),
740            permit(),
741        );
742        let ports = Ports {
743            relay: Some(Box::new(EchoRelay)),
744            ..Ports::permissive()
745        };
746        assert_eq!(
747            s.consult_relay(&ports, &[1, 2]).unwrap_err(),
748            EdgeError::AirGapViolation
749        );
750
751        // Opt in → allowed.
752        let cfg = SessionConfig {
753            hybrid_mode: true,
754            ..SessionConfig::default()
755        };
756        let mut s2 = InferenceSession::new(SessionId(5), cfg, NullEngine::new(0, 4), permit());
757        assert_eq!(s2.consult_relay(&ports, &[1, 2]).unwrap(), vec![1, 2]);
758
759        // Opted in but no relay wired → still air-gapped.
760        let no_relay = Ports::permissive();
761        assert_eq!(
762            s2.consult_relay(&no_relay, &[1]).unwrap_err(),
763            EdgeError::AirGapViolation
764        );
765    }
766
767    #[test]
768    fn first_events_are_init_then_model_loaded() {
769        let mut s = InferenceSession::new(
770            SessionId(6),
771            SessionConfig::default(),
772            NullEngine::new(0, 4),
773            permit(),
774        );
775        let evs = s.drain_events();
776        assert!(matches!(
777            evs[0].event,
778            DomainEvent::SessionInitialized { .. }
779        ));
780        assert!(matches!(evs[1].event, DomainEvent::ModelLoaded { .. }));
781    }
782
783    // ----- ADR-012 checkpointed-rollback control loop -----
784
785    use el_safety::{ChunkGuard, SafetyScore};
786
787    /// Hard-unsafe whenever the given token appears in the output.
788    struct BanToken(Token);
789    impl ChunkGuard for BanToken {
790        fn score(&self, recent: &[Token]) -> SafetyScore {
791            if recent.contains(&self.0) {
792                SafetyScore::MAX
793            } else {
794                SafetyScore::SAFE
795            }
796        }
797    }
798
799    /// Always hard-unsafe — exercises the rollback bound and fail-closed path.
800    struct AlwaysHot;
801    impl ChunkGuard for AlwaysHot {
802        fn score(&self, _recent: &[Token]) -> SafetyScore {
803            SafetyScore::MAX
804        }
805    }
806
807    fn tiny_policy(max_rollbacks: u8) -> RollbackPolicy {
808        RollbackPolicy {
809            guard_every: 1,
810            steer_window: 0,
811            soft_threshold: SafetyScore::from_milli(500),
812            hard_threshold: SafetyScore::from_milli(800),
813            max_rollbacks,
814            max_checkpoints: 8,
815        }
816    }
817
818    /// Grammar masker that disallows every token — exercises the no-legal-token
819    /// fail-closed path.
820    struct DenyAllMasker;
821    impl GrammarMasker for DenyAllMasker {
822        fn mask(&self, _recent: &[Token], vocab: usize) -> Vec<bool> {
823            vec![false; vocab]
824        }
825    }
826
827    /// Engine that always prefers token 0, then 1, 2, 3 — so banning the
828    /// chosen token forces the next-best, giving deterministic divergence.
829    fn descending_engine() -> FixedEngine {
830        FixedEngine {
831            logits: vec![5, 4, 3, 2],
832        }
833    }
834
835    /// Emits the unsafe token `0` first, then EOS — a completion shorter than a
836    /// large `guard_every`, so only the *final* mandatory guard check can catch
837    /// it. Banning token `0` forces the next-best (a safe token), then EOS.
838    struct UnsafeThenEos {
839        eos: Token,
840        vocab: usize,
841    }
842    impl InferenceEngine for UnsafeThenEos {
843        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
844            Ok(t.len() as u32)
845        }
846        fn next_logits(&mut self, ctx: &[Token]) -> Vec<i32> {
847            let mut v = vec![0i32; self.vocab];
848            if ctx.is_empty() {
849                v[0] = 10; // unsafe token 0 wins the first step
850            } else {
851                v[self.eos as usize] = 10; // then terminate with EOS
852            }
853            v
854        }
855        fn eos_token(&self) -> Token {
856            self.eos
857        }
858        fn rollback(&mut self, _keep: u32) -> Result<()> {
859            Ok(()) // stateless: logits depend only on the passed ctx
860        }
861        fn reset_cache(&mut self) -> Result<()> {
862            Ok(()) // stateless
863        }
864    }
865
866    /// `guard_every` larger than any completion here, so the *cadence* check
867    /// never fires — isolating the mandatory final guard check.
868    fn coarse_policy(max_rollbacks: u8) -> RollbackPolicy {
869        RollbackPolicy {
870            guard_every: 16,
871            steer_window: 0,
872            soft_threshold: SafetyScore::from_milli(500),
873            hard_threshold: SafetyScore::from_milli(800),
874            max_rollbacks,
875            max_checkpoints: 8,
876        }
877    }
878
879    #[test]
880    fn eos_terminated_short_completion_is_scored_not_bypassed() {
881        // Regression (P1): EOS was handled before guard evaluation, so an unsafe
882        // tail ending in EOS within < guard_every tokens escaped scoring.
883        let mut s = InferenceSession::new(
884            SessionId(26),
885            SessionConfig::default(),
886            UnsafeThenEos { eos: 5, vocab: 8 },
887            permit(),
888        );
889        let ports = guarded_ports(Box::new(BanToken(0)));
890        s.load_prompt(&ports, &[]).unwrap();
891
892        // No rollback budget → the final check must refuse, not return EOS.
893        let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
894
895        assert_eq!(stop, StopReason::Stopped);
896        assert!(s.output().is_empty()); // unsafe EOS-terminated tail not emitted
897        let evs = s.drain_events();
898        assert!(evs
899            .iter()
900            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
901    }
902
903    #[test]
904    fn max_tokens_partial_chunk_is_scored_not_bypassed() {
905        // Regression (P1): hitting max_tokens exited before flushing a partial
906        // chunk, so a completion shorter than guard_every was never scored.
907        let mut s = InferenceSession::new(
908            SessionId(27),
909            SessionConfig::default(),
910            descending_engine(), // always prefers unsafe token 0
911            permit(),
912        );
913        let ports = guarded_ports(Box::new(BanToken(0)));
914        s.load_prompt(&ports, &[]).unwrap();
915
916        // 2 tokens < guard_every (16): only the final check can catch the breach.
917        let stop = s.generate_with_policy(&ports, 2, coarse_policy(0)).unwrap();
918
919        assert_eq!(stop, StopReason::Stopped);
920        assert!(s.output().is_empty());
921        let evs = s.drain_events();
922        assert!(evs
923            .iter()
924            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
925    }
926
927    #[test]
928    fn eos_unsafe_tail_rolls_back_and_recovers() {
929        // With rollback budget, an unsafe EOS-terminated tail is rolled back to
930        // the seeded safe prefix, the offending token banned, and decoding
931        // resumes to a safe completion that then terminates cleanly.
932        let mut s = InferenceSession::new(
933            SessionId(28),
934            SessionConfig::default(),
935            UnsafeThenEos { eos: 5, vocab: 8 },
936            permit(),
937        );
938        let ports = guarded_ports(Box::new(BanToken(0)));
939        s.load_prompt(&ports, &[]).unwrap();
940
941        let stop = s.generate_with_policy(&ports, 8, coarse_policy(1)).unwrap();
942
943        assert_eq!(stop, StopReason::Eos);
944        assert!(!s.output().contains(&0)); // unsafe token banned out of the result
945        assert_eq!(s.output().last(), Some(&5)); // ends on EOS, scored safe
946        let evs = s.drain_events();
947        assert!(evs
948            .iter()
949            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
950    }
951
952    /// Mirrors `QwenEngine`'s statefulness: tracks how many committed tokens it
953    /// has "fed" into its (mock) KV cache. If the session truncated its own
954    /// output without telling the engine, `committed` would shrink below `fed` —
955    /// the desync the real engine hits as serving stale logits and never
956    /// re-feeding. Shared cells let the test observe behaviour after the engine
957    /// is moved into the session.
958    struct StatefulEngine {
959        fed: usize,
960        logits: Vec<i32>,
961        eos: Token,
962        rollbacks: std::rc::Rc<std::cell::Cell<u32>>,
963        last_keep: std::rc::Rc<std::cell::Cell<u32>>,
964        desynced: std::rc::Rc<std::cell::Cell<bool>>,
965    }
966    impl InferenceEngine for StatefulEngine {
967        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
968            self.fed = 0;
969            Ok(t.len() as u32)
970        }
971        fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
972            // The engine should never be "ahead" of the committed context; if it
973            // is, a rollback was not propagated here (the bug).
974            if self.fed > committed.len() {
975                self.desynced.set(true);
976            }
977            while self.fed < committed.len() {
978                self.fed += 1;
979            }
980            self.logits.clone()
981        }
982        fn eos_token(&self) -> Token {
983            self.eos
984        }
985        fn rollback(&mut self, keep_committed: u32) -> Result<()> {
986            self.fed = keep_committed as usize; // re-sync to the retained prefix
987            self.rollbacks.set(self.rollbacks.get() + 1);
988            self.last_keep.set(keep_committed);
989            Ok(())
990        }
991        fn reset_cache(&mut self) -> Result<()> {
992            self.fed = 0; // pristine: a fresh conversation re-feeds from scratch
993            Ok(())
994        }
995    }
996
997    #[test]
998    fn rollback_restores_engine_state_not_just_session_metadata() {
999        // Regression (P1): the loop truncated only the session's output + KV
1000        // descriptors; a stateful engine kept the abandoned branch. The session
1001        // must drive `InferenceEngine::rollback` on every backtrack.
1002        let rollbacks = std::rc::Rc::new(std::cell::Cell::new(0u32));
1003        let last_keep = std::rc::Rc::new(std::cell::Cell::new(u32::MAX));
1004        let desynced = std::rc::Rc::new(std::cell::Cell::new(false));
1005        let engine = StatefulEngine {
1006            fed: 0,
1007            logits: vec![5, 4, 3, 2], // prefers the unsafe token 0
1008            eos: 9999,
1009            rollbacks: rollbacks.clone(),
1010            last_keep: last_keep.clone(),
1011            desynced: desynced.clone(),
1012        };
1013        let mut s =
1014            InferenceSession::new(SessionId(29), SessionConfig::default(), engine, permit());
1015        let ports = guarded_ports(Box::new(BanToken(0)));
1016        s.load_prompt(&ports, &[7, 8]).unwrap(); // non-empty prompt
1017
1018        let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
1019
1020        assert_eq!(stop, StopReason::MaxTokens);
1021        assert_eq!(s.output(), &[1, 1, 1]); // recovered safe completion
1022                                            // The engine was told to roll back — not just the session metadata...
1023        assert!(
1024            rollbacks.get() >= 1,
1025            "session must propagate the backtrack to the engine"
1026        );
1027        // ...to a real prefix, and the cache never desynced from `committed`.
1028        assert!(last_keep.get() < 3);
1029        assert!(
1030            !desynced.get(),
1031            "engine cache must track the session rollback"
1032        );
1033    }
1034
1035    fn guarded_ports(guard: Box<dyn ChunkGuard>) -> Ports {
1036        Ports {
1037            compressor: Box::new(crate::defaults::IdentityCompressor),
1038            grammar: Box::new(crate::defaults::AllowAllMasker),
1039            safety: Box::new(el_safety::NoSafety),
1040            guard: Some(guard),
1041            ingress: None,
1042            relay: None,
1043        }
1044    }
1045
1046    #[test]
1047    fn hard_breach_rolls_back_kv_and_recovers() {
1048        let mut s = InferenceSession::new(
1049            SessionId(20),
1050            SessionConfig::default(),
1051            descending_engine(),
1052            permit(),
1053        );
1054        let ports = guarded_ports(Box::new(BanToken(0)));
1055        s.load_prompt(&ports, &[]).unwrap();
1056
1057        let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
1058
1059        assert_eq!(stop, StopReason::MaxTokens);
1060        // Token 0 is unsafe; each occurrence is rolled back and banned, so the
1061        // recovered output contains only the safe next-best token.
1062        assert_eq!(s.output(), &[1, 1, 1]);
1063        assert!(!s.output().contains(&0));
1064        // KV rewound in lock-step with the committed output (AC-5).
1065        assert_eq!(s.kv_len(), s.output().len() as u32);
1066
1067        let evs = s.drain_events();
1068        assert!(evs
1069            .iter()
1070            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1071        assert!(evs
1072            .iter()
1073            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1074    }
1075
1076    #[test]
1077    fn fail_closed_refusal_when_no_rollback_budget() {
1078        let mut s = InferenceSession::new(
1079            SessionId(21),
1080            SessionConfig::default(),
1081            descending_engine(),
1082            permit(),
1083        );
1084        let ports = guarded_ports(Box::new(BanToken(0)));
1085        s.load_prompt(&ports, &[]).unwrap();
1086
1087        // No rollback budget → first hard breach refuses deterministically.
1088        let stop = s.generate_with_policy(&ports, 5, tiny_policy(0)).unwrap();
1089
1090        assert_eq!(stop, StopReason::Stopped);
1091        assert!(s.output().is_empty());
1092        let evs = s.drain_events();
1093        assert!(evs
1094            .iter()
1095            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1096        assert!(!evs
1097            .iter()
1098            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1099    }
1100
1101    #[test]
1102    fn rollbacks_are_bounded_then_fail_closed() {
1103        let mut s = InferenceSession::new(
1104            SessionId(22),
1105            SessionConfig::default(),
1106            descending_engine(),
1107            permit(),
1108        );
1109        let ports = guarded_ports(Box::new(AlwaysHot));
1110        s.load_prompt(&ports, &[]).unwrap();
1111
1112        let stop = s.generate_with_policy(&ports, 8, tiny_policy(2)).unwrap();
1113
1114        assert_eq!(stop, StopReason::Stopped);
1115        let evs = s.drain_events();
1116        let rollbacks = evs
1117            .iter()
1118            .filter(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. }))
1119            .count();
1120        // Exactly max_rollbacks attempts, then a deterministic refusal (AC-6).
1121        assert_eq!(rollbacks, 2);
1122    }
1123
1124    #[test]
1125    fn memory_pressure_disables_checkpoints_and_fails_closed() {
1126        let cfg = SessionConfig {
1127            memory_budget_bytes: 1024, // far below MIN_CHECKPOINT_BUDGET_BYTES
1128            ..SessionConfig::default()
1129        };
1130        let mut s = InferenceSession::new(SessionId(23), cfg, descending_engine(), permit());
1131        let ports = guarded_ports(Box::new(BanToken(0)));
1132        s.load_prompt(&ports, &[]).unwrap();
1133
1134        let stop = s.generate_with_policy(&ports, 5, tiny_policy(3)).unwrap();
1135
1136        assert_eq!(stop, StopReason::Stopped); // no checkpoint to roll back to
1137        let evs = s.drain_events();
1138        assert!(evs.iter().any(|e| matches!(
1139            e.event,
1140            DomainEvent::SafetyDisabled {
1141                reason: DegradeReason::MemoryPressure
1142            }
1143        )));
1144    }
1145
1146    #[test]
1147    fn no_legal_token_fails_closed() {
1148        // Grammar disallows everything → pick() has no legal token. The loop must
1149        // fail closed, not commit token 0 (which could be EOS).
1150        let mut s = InferenceSession::new(
1151            SessionId(24),
1152            SessionConfig::default(),
1153            descending_engine(),
1154            permit(),
1155        );
1156        let ports = Ports {
1157            compressor: Box::new(crate::defaults::IdentityCompressor),
1158            grammar: Box::new(DenyAllMasker),
1159            safety: Box::new(el_safety::NoSafety),
1160            guard: None,
1161            ingress: None,
1162            relay: None,
1163        };
1164        s.load_prompt(&ports, &[]).unwrap();
1165
1166        let stop = s.generate(&ports, 4).unwrap();
1167
1168        assert_eq!(stop, StopReason::Stopped);
1169        assert!(s.output().is_empty()); // no illegal token committed
1170        let evs = s.drain_events();
1171        assert!(evs
1172            .iter()
1173            .any(|e| matches!(e.event, DomainEvent::GrammarViolationBlocked)));
1174    }
1175
1176    #[test]
1177    fn fail_closed_preserves_prefill_kv() {
1178        // Non-empty prompt → prefill KV descriptors must survive a
1179        // budget-exhausted refusal (regression: fail-closed once truncated KV to
1180        // output_len, dropping prefill).
1181        let mut s = InferenceSession::new(
1182            SessionId(25),
1183            SessionConfig::default(),
1184            descending_engine(),
1185            permit(),
1186        );
1187        let ports = guarded_ports(Box::new(AlwaysHot));
1188        s.load_prompt(&ports, &[7, 8, 9]).unwrap(); // prefill KV length = 3
1189        assert_eq!(s.kv_len(), 3);
1190
1191        let stop = s.generate_with_policy(&ports, 8, tiny_policy(1)).unwrap();
1192
1193        assert_eq!(stop, StopReason::Stopped);
1194        assert!(s.output().is_empty()); // refused back to the post-prefill prefix
1195        assert_eq!(s.kv_len(), 3); // prefill KV intact, not truncated to 0
1196    }
1197
1198    // ----- ADR-013 model-backed steering: window, ingress, mode selector -----
1199
1200    use el_core::SafetyMode;
1201    use el_safety::SafetySteerer;
1202
1203    /// Records, per step, whether the logit-aware path was taken and the output
1204    /// length at that step — so a test can prove the early-token window gating.
1205    struct RecordingSteerer {
1206        log: std::rc::Rc<std::cell::RefCell<Vec<(bool, usize)>>>,
1207    }
1208    impl SafetySteerer for RecordingSteerer {
1209        fn adjust(&self, recent: &[Token]) -> LogitAdjustment {
1210            self.log.borrow_mut().push((false, recent.len()));
1211            LogitAdjustment::none()
1212        }
1213        fn adjust_with_logits(&self, recent: &[Token], _base: &[i32]) -> LogitAdjustment {
1214            self.log.borrow_mut().push((true, recent.len()));
1215            LogitAdjustment::none()
1216        }
1217        fn mode(&self) -> SafetyMode {
1218            SafetyMode::SecDecoding
1219        }
1220    }
1221
1222    #[test]
1223    fn soft_steer_applies_only_inside_the_early_token_window() {
1224        // AC-1: adjust_with_logits runs for output positions < steer_window, and
1225        // plain token-only adjust() afterwards.
1226        let log = std::rc::Rc::new(std::cell::RefCell::new(Vec::new()));
1227        let mut s = InferenceSession::new(
1228            SessionId(30),
1229            SessionConfig::default(),
1230            descending_engine(), // never EOS (eos 9999)
1231            permit(),
1232        );
1233        let ports = Ports {
1234            compressor: Box::new(crate::defaults::IdentityCompressor),
1235            grammar: Box::new(crate::defaults::AllowAllMasker),
1236            safety: Box::new(RecordingSteerer { log: log.clone() }),
1237            guard: None,
1238            ingress: None,
1239            relay: None,
1240        };
1241        s.load_prompt(&ports, &[]).unwrap();
1242        let policy = RollbackPolicy {
1243            guard_every: 0,
1244            steer_window: 2,
1245            soft_threshold: SafetyScore::MAX,
1246            hard_threshold: SafetyScore::MAX,
1247            max_rollbacks: 0,
1248            max_checkpoints: 0,
1249        };
1250        s.generate_with_policy(&ports, 4, policy).unwrap();
1251
1252        let calls = log.borrow();
1253        assert_eq!(calls.len(), 4);
1254        for &(with_logits, len) in calls.iter() {
1255            assert_eq!(
1256                with_logits,
1257                len < 2,
1258                "window gate wrong at output len {len}"
1259            );
1260        }
1261    }
1262
1263    #[test]
1264    fn ingress_triage_fails_closed_before_generation() {
1265        // AC-3: a prompt scored at/above the hard threshold refuses with no decode.
1266        let mut s = InferenceSession::new(
1267            SessionId(31),
1268            SessionConfig::default(),
1269            descending_engine(),
1270            permit(),
1271        );
1272        let ports = Ports {
1273            compressor: Box::new(crate::defaults::IdentityCompressor),
1274            grammar: Box::new(crate::defaults::AllowAllMasker),
1275            safety: Box::new(el_safety::NoSafety),
1276            guard: None,
1277            ingress: Some(Box::new(AlwaysHot)), // prompt scores MAX
1278            relay: None,
1279        };
1280        s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1281
1282        let stop = s.generate_with_policy(&ports, 8, coarse_policy(0)).unwrap();
1283
1284        assert_eq!(stop, StopReason::Stopped);
1285        assert!(s.output().is_empty());
1286        let evs = s.drain_events();
1287        assert!(evs
1288            .iter()
1289            .any(|e| matches!(e.event, DomainEvent::SafetyViolationDetected { .. })));
1290        // Fail-closed at ingress means nothing was ever generated/committed.
1291        assert!(!evs
1292            .iter()
1293            .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })));
1294    }
1295
1296    #[test]
1297    fn generate_applies_safety_mode_selector_and_records_effective_mode() {
1298        // AC-4: SecDecoding on MidRange downgrades to Lightweight in the decode
1299        // path, and the effective mode is what gets recorded.
1300        let cfg = SessionConfig {
1301            device: el_core::DeviceTarget::MidRange,
1302            safety: SafetyMode::SecDecoding,
1303            ..SessionConfig::default()
1304        };
1305        let mut s = InferenceSession::new(SessionId(32), cfg, NullEngine::new(0, 4), permit());
1306        let ports = Ports::permissive();
1307        s.load_prompt(&ports, &[1]).unwrap();
1308        s.generate(&ports, 4).unwrap();
1309
1310        let evs = s.drain_events();
1311        assert!(evs.iter().any(|e| matches!(
1312            e.event,
1313            DomainEvent::SafetyModeSelected {
1314                mode: SafetyMode::Lightweight
1315            }
1316        )));
1317    }
1318
1319    // ----- ADR-018 persistent model / stateful session reuse -----
1320
1321    /// Counts how often it is prefilled and cache-reset, and emits EOS on the
1322    /// first decode step so generation terminates immediately. Mirrors a *resident*
1323    /// engine: one instance reused across conversations, never reconstructed.
1324    struct CountingEngine {
1325        eos: Token,
1326        vocab: usize,
1327        prefills: std::rc::Rc<std::cell::Cell<u32>>,
1328        resets: std::rc::Rc<std::cell::Cell<u32>>,
1329    }
1330    impl InferenceEngine for CountingEngine {
1331        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1332            self.prefills.set(self.prefills.get() + 1);
1333            Ok(t.len() as u32)
1334        }
1335        fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1336            let mut v = vec![0i32; self.vocab];
1337            if let Some(s) = v.get_mut(self.eos as usize) {
1338                *s = 1;
1339            }
1340            v
1341        }
1342        fn eos_token(&self) -> Token {
1343            self.eos
1344        }
1345        fn rollback(&mut self, _keep: u32) -> Result<()> {
1346            Ok(())
1347        }
1348        fn reset_cache(&mut self) -> Result<()> {
1349            self.resets.set(self.resets.get() + 1);
1350            Ok(())
1351        }
1352    }
1353
1354    fn counting_engine() -> (
1355        CountingEngine,
1356        std::rc::Rc<std::cell::Cell<u32>>,
1357        std::rc::Rc<std::cell::Cell<u32>>,
1358    ) {
1359        let prefills = std::rc::Rc::new(std::cell::Cell::new(0u32));
1360        let resets = std::rc::Rc::new(std::cell::Cell::new(0u32));
1361        let engine = CountingEngine {
1362            eos: 1,
1363            vocab: 4,
1364            prefills: prefills.clone(),
1365            resets: resets.clone(),
1366        };
1367        (engine, prefills, resets)
1368    }
1369
1370    #[test]
1371    fn reset_resets_the_engine_cache() {
1372        // AC: reset() must discard the engine's conversation cache, not just the
1373        // session's descriptors — otherwise a reused resident engine would carry a
1374        // stale KV cache into the next conversation.
1375        let (engine, _prefills, resets) = counting_engine();
1376        let mut s =
1377            InferenceSession::new(SessionId(40), SessionConfig::default(), engine, permit());
1378        assert_eq!(resets.get(), 0);
1379        s.reset().unwrap();
1380        assert_eq!(
1381            resets.get(),
1382            1,
1383            "reset() must reset the engine cache (ADR-018)"
1384        );
1385    }
1386
1387    #[test]
1388    fn one_engine_serves_multiple_conversations_via_reset() {
1389        // AC-1/AC-2: the same resident engine is reused across conversations — it
1390        // is prefilled again per turn but never reconstructed, and its cache is
1391        // reset between turns.
1392        let (engine, prefills, resets) = counting_engine();
1393        let mut s =
1394            InferenceSession::new(SessionId(41), SessionConfig::default(), engine, permit());
1395        let ports = Ports::permissive();
1396
1397        s.load_prompt(&ports, &[1, 2]).unwrap();
1398        s.generate(&ports, 8).unwrap();
1399        assert_eq!(prefills.get(), 1);
1400
1401        // Reuse for a second conversation — no new engine, no reload.
1402        s.reset().unwrap();
1403        s.load_prompt(&ports, &[3, 4, 5]).unwrap();
1404        s.generate(&ports, 8).unwrap();
1405
1406        assert_eq!(
1407            prefills.get(),
1408            2,
1409            "the one resident engine was prefilled again, not rebuilt"
1410        );
1411        assert!(
1412            resets.get() >= 1,
1413            "the engine cache was reset between conversations"
1414        );
1415    }
1416
1417    /// Counts both cache resets and drops, so a test can prove `close()` releases
1418    /// the conversation's KV (reset_cache) **without** dropping the resident engine.
1419    struct ResidentEngine {
1420        eos: Token,
1421        vocab: usize,
1422        resets: std::rc::Rc<std::cell::Cell<u32>>,
1423        dropped: std::rc::Rc<std::cell::Cell<u32>>,
1424    }
1425    impl Drop for ResidentEngine {
1426        fn drop(&mut self) {
1427            self.dropped.set(self.dropped.get() + 1);
1428        }
1429    }
1430    impl InferenceEngine for ResidentEngine {
1431        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1432            Ok(t.len() as u32)
1433        }
1434        fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1435            let mut v = vec![0i32; self.vocab];
1436            if let Some(s) = v.get_mut(self.eos as usize) {
1437                *s = 1;
1438            }
1439            v
1440        }
1441        fn eos_token(&self) -> Token {
1442            self.eos
1443        }
1444        fn rollback(&mut self, _keep: u32) -> Result<()> {
1445            Ok(())
1446        }
1447        fn reset_cache(&mut self) -> Result<()> {
1448            self.resets.set(self.resets.get() + 1);
1449            Ok(())
1450        }
1451    }
1452
1453    #[test]
1454    fn close_releases_conversation_but_keeps_engine_resident() {
1455        // AC-4 / ADR-018: `close` must release the conversation's memory (KV via
1456        // the engine's `reset_cache`) while keeping the resident weights loaded —
1457        // and the session must remain reusable for the next conversation. Dropping
1458        // the engine (and its weights) is NOT close's job; that is provider drop.
1459        let resets = std::rc::Rc::new(std::cell::Cell::new(0u32));
1460        let dropped = std::rc::Rc::new(std::cell::Cell::new(0u32));
1461        let engine = ResidentEngine {
1462            eos: 1,
1463            vocab: 4,
1464            resets: resets.clone(),
1465            dropped: dropped.clone(),
1466        };
1467        let mut s =
1468            InferenceSession::new(SessionId(42), SessionConfig::default(), engine, permit());
1469        let ports = Ports::permissive();
1470        s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1471        s.generate(&ports, 8).unwrap();
1472        assert!(s.kv_len() > 0, "conversation memory is measurable (AC-4)");
1473
1474        s.close().unwrap();
1475
1476        assert_eq!(s.kv_len(), 0, "close frees the session's KV descriptors");
1477        assert!(s.output().is_empty(), "close frees committed output");
1478        assert!(
1479            resets.get() >= 1,
1480            "close releases the engine's conversation KV (keeps weights)"
1481        );
1482        assert_eq!(
1483            dropped.get(),
1484            0,
1485            "the resident engine/weights are NOT dropped by close"
1486        );
1487
1488        // Model still resident → the session serves a new conversation, no reload.
1489        s.load_prompt(&ports, &[4, 5]).unwrap();
1490        s.generate(&ports, 8).unwrap();
1491        assert!(
1492            s.kv_len() > 0,
1493            "same engine serves a new conversation after close"
1494        );
1495        assert_eq!(dropped.get(), 0);
1496    }
1497
1498    #[test]
1499    fn reset_preserves_undrained_events() {
1500        // Generic semantics: reset() must NOT silently drop a consumer's undrained
1501        // telemetry. Turn-level isolation is the provider's job (drain between
1502        // turns), not reset()'s.
1503        let (engine, _prefills, _resets) = counting_engine();
1504        let mut s =
1505            InferenceSession::new(SessionId(44), SessionConfig::default(), engine, permit());
1506        let ports = Ports::permissive();
1507        s.load_prompt(&ports, &[1, 2]).unwrap();
1508        s.generate(&ports, 8).unwrap(); // emits events the caller has not drained
1509
1510        s.reset().unwrap();
1511
1512        let evs = s.drain_events();
1513        assert!(
1514            evs.iter()
1515                .any(|e| matches!(e.event, DomainEvent::TokenCommitted { .. })),
1516            "pre-reset events are preserved for the consumer"
1517        );
1518        assert!(
1519            evs.iter()
1520                .any(|e| matches!(e.event, DomainEvent::SessionReset)),
1521            "and the reset itself is recorded"
1522        );
1523    }
1524
1525    /// Reset is fallible: this engine refuses to discard its cache.
1526    struct FailResetEngine {
1527        eos: Token,
1528        vocab: usize,
1529    }
1530    impl InferenceEngine for FailResetEngine {
1531        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1532            Ok(t.len() as u32)
1533        }
1534        fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1535            let mut v = vec![0i32; self.vocab];
1536            if let Some(s) = v.get_mut(self.eos as usize) {
1537                *s = 1;
1538            }
1539            v
1540        }
1541        fn eos_token(&self) -> Token {
1542            self.eos
1543        }
1544        fn rollback(&mut self, _keep: u32) -> Result<()> {
1545            Ok(())
1546        }
1547        fn reset_cache(&mut self) -> Result<()> {
1548            Err(EdgeError::Engine("reset_cache refused"))
1549        }
1550    }
1551
1552    #[test]
1553    fn reset_propagates_engine_cache_failure_and_leaves_state() {
1554        // A fallible engine that cannot discard its cache must NOT be reported as a
1555        // clean fresh session: the error surfaces and the session descriptors stay
1556        // untouched, so an empty session can never desync from a stale engine cache.
1557        let mut s = InferenceSession::new(
1558            SessionId(45),
1559            SessionConfig::default(),
1560            FailResetEngine { eos: 1, vocab: 4 },
1561            permit(),
1562        );
1563        let ports = Ports::permissive();
1564        s.load_prompt(&ports, &[1, 2, 3]).unwrap();
1565        s.generate(&ports, 8).unwrap();
1566        let kv_before = s.kv_len();
1567        assert!(kv_before > 0);
1568
1569        let err = s.reset().unwrap_err();
1570        assert!(matches!(err, EdgeError::Engine(_)));
1571        assert_eq!(
1572            s.kv_len(),
1573            kv_before,
1574            "descriptors must be left intact when the engine reset fails"
1575        );
1576        assert_eq!(
1577            s.phase(),
1578            Phase::Completed,
1579            "phase is unchanged on a failed reset"
1580        );
1581    }
1582
1583    /// Marks itself dirty when it caches, fails its first `reset_cache` (a
1584    /// simulated partial eviction), then succeeds — recording `cleared` only after
1585    /// a *fully successful* attempt. Mirrors the `QwenEngine` dirty-flag contract.
1586    struct RetryClearEngine {
1587        eos: Token,
1588        vocab: usize,
1589        dirty: bool,
1590        fails_left: std::rc::Rc<std::cell::Cell<u32>>,
1591        cleared: std::rc::Rc<std::cell::Cell<bool>>,
1592    }
1593    impl InferenceEngine for RetryClearEngine {
1594        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1595            self.dirty = true;
1596            Ok(t.len() as u32)
1597        }
1598        fn next_logits(&mut self, _committed: &[Token]) -> Vec<i32> {
1599            self.dirty = true;
1600            let mut v = vec![0i32; self.vocab];
1601            if let Some(s) = v.get_mut(self.eos as usize) {
1602                *s = 1;
1603            }
1604            v
1605        }
1606        fn eos_token(&self) -> Token {
1607            self.eos
1608        }
1609        fn rollback(&mut self, _keep: u32) -> Result<()> {
1610            Ok(())
1611        }
1612        fn reset_cache(&mut self) -> Result<()> {
1613            if self.dirty {
1614                if self.fails_left.get() > 0 {
1615                    self.fails_left.set(self.fails_left.get() - 1);
1616                    return Err(EdgeError::Engine("partial eviction")); // stays dirty
1617                }
1618                self.dirty = false;
1619                self.cleared.set(true);
1620            }
1621            Ok(())
1622        }
1623    }
1624
1625    #[test]
1626    fn reset_retries_clearing_after_a_failed_attempt() {
1627        // Regression (P1): a `reset_cache` that fails after partially clearing must
1628        // NOT be skipped on the next attempt. Dirtiness is tracked independently of
1629        // any position cursor, so the retry re-clears rather than falsely reporting
1630        // a clean cache while user K/V is still resident.
1631        let fails_left = std::rc::Rc::new(std::cell::Cell::new(1u32));
1632        let cleared = std::rc::Rc::new(std::cell::Cell::new(false));
1633        let engine = RetryClearEngine {
1634            eos: 1,
1635            vocab: 4,
1636            dirty: false,
1637            fails_left: fails_left.clone(),
1638            cleared: cleared.clone(),
1639        };
1640        let mut s =
1641            InferenceSession::new(SessionId(47), SessionConfig::default(), engine, permit());
1642        let ports = Ports::permissive();
1643        s.load_prompt(&ports, &[1, 2]).unwrap();
1644        s.generate(&ports, 8).unwrap();
1645
1646        // First reset fails (simulated partial eviction) and is surfaced.
1647        assert!(s.reset().is_err());
1648        assert!(
1649            !cleared.get(),
1650            "a failed reset must not report the cache cleared"
1651        );
1652
1653        // The retry actually re-clears — it is not skipped.
1654        s.reset().unwrap();
1655        assert!(
1656            cleared.get(),
1657            "the next reset re-clears after a failed attempt"
1658        );
1659    }
1660
1661    // ----- ADR-018 AC-3 cross-turn prefix reuse / incremental prefill -----
1662
1663    /// A stateful mock mirroring `QwenEngine`'s cache bookkeeping: it tracks the
1664    /// exact cached token sequence, counts every forward (one per fed token), and
1665    /// implements `prefill_reuse` with the same longest-common-prefix reuse — so
1666    /// the cross-turn reuse contract can be proven without a real model. Its
1667    /// logits are a deterministic hash of the cached sequence, so two engines with
1668    /// identical caches return identical logits (and divergent caches almost never
1669    /// do): the soundness probe for reuse-equals-fresh-prefill.
1670    struct ReuseEngine {
1671        eos: Token,
1672        vocab: usize,
1673        index_pos: usize,
1674        fed: usize,
1675        cached: Vec<Token>,
1676        prompt: Vec<Token>,
1677        /// Logits after the most recent forward — **stored**, like `QwenEngine`'s
1678        /// `last_logits`, so the empty/rebuild path can be checked for stale state.
1679        last_logits: Vec<i32>,
1680        forwards: std::rc::Rc<std::cell::Cell<u32>>,
1681        /// When set, the favoured token is always EOS so `generate` terminates on
1682        /// the first decode step (keeps the reuse-savings test deterministic).
1683        eos_immediately: bool,
1684    }
1685    impl ReuseEngine {
1686        fn new(eos: Token, vocab: usize, eos_immediately: bool) -> Self {
1687            Self {
1688                eos,
1689                vocab,
1690                index_pos: 0,
1691                fed: 0,
1692                cached: Vec::new(),
1693                prompt: Vec::new(),
1694                last_logits: Vec::new(),
1695                forwards: std::rc::Rc::new(std::cell::Cell::new(0)),
1696                eos_immediately,
1697            }
1698        }
1699        fn forward(&mut self, t: Token) {
1700            self.forwards.set(self.forwards.get() + 1);
1701            self.cached.push(t);
1702            self.index_pos += 1;
1703            self.last_logits = self.logits_for_cache();
1704        }
1705        fn logits_for_cache(&self) -> Vec<i32> {
1706            let mut v = vec![0i32; self.vocab];
1707            if self.eos_immediately {
1708                if let Some(s) = v.get_mut(self.eos as usize) {
1709                    *s = 1_000_000;
1710                }
1711                return v;
1712            }
1713            // Content hash of the cached sequence so identical caches ⇒ identical
1714            // logits — the probe for reuse soundness.
1715            let h = self
1716                .cached
1717                .iter()
1718                .fold(0i32, |a, &t| a.wrapping_mul(31).wrapping_add(t as i32 + 1));
1719            for (i, slot) in v.iter_mut().enumerate() {
1720                *slot = h.wrapping_add(i as i32);
1721            }
1722            v
1723        }
1724    }
1725    impl InferenceEngine for ReuseEngine {
1726        fn prefill(&mut self, tokens: &[Token]) -> Result<u32> {
1727            self.index_pos = 0;
1728            self.fed = 0;
1729            self.cached = Vec::new();
1730            self.last_logits = Vec::new();
1731            self.prompt = tokens.to_vec();
1732            for &t in tokens {
1733                self.forward(t);
1734            }
1735            Ok(tokens.len() as u32)
1736        }
1737        fn next_logits(&mut self, committed: &[Token]) -> Vec<i32> {
1738            while self.fed < committed.len() {
1739                self.forward(committed[self.fed]);
1740                self.fed += 1;
1741            }
1742            self.last_logits.clone()
1743        }
1744        fn eos_token(&self) -> Token {
1745            self.eos
1746        }
1747        fn rollback(&mut self, _keep: u32) -> Result<()> {
1748            self.index_pos = 0;
1749            self.fed = 0;
1750            self.cached = Vec::new();
1751            self.last_logits = Vec::new();
1752            let prompt = self.prompt.clone();
1753            for &t in &prompt {
1754                self.forward(t);
1755            }
1756            Ok(())
1757        }
1758        fn reset_cache(&mut self) -> Result<()> {
1759            self.index_pos = 0;
1760            self.fed = 0;
1761            self.cached = Vec::new();
1762            self.last_logits = Vec::new();
1763            self.prompt = Vec::new();
1764            Ok(())
1765        }
1766        fn prefill_reuse(&mut self, full_context: &[Token]) -> Result<u32> {
1767            let reuse = self
1768                .cached
1769                .iter()
1770                .zip(full_context)
1771                .take_while(|(a, b)| a == b)
1772                .count();
1773            if reuse == self.cached.len() && reuse == self.index_pos {
1774                for &t in &full_context[reuse..] {
1775                    self.forward(t);
1776                }
1777            } else {
1778                self.index_pos = 0;
1779                self.cached = Vec::new();
1780                self.last_logits = Vec::new(); // mirror QwenEngine: no stale logits on rebuild
1781                for &t in full_context {
1782                    self.forward(t);
1783                }
1784            }
1785            self.fed = 0;
1786            self.prompt = full_context.to_vec();
1787            Ok(self.index_pos as u32)
1788        }
1789    }
1790
1791    /// Fails every `prefill_reuse` (a transient reuse error) but resets and
1792    /// prefills fine — the dirty-phase recovery probe.
1793    struct FailReuseEngine {
1794        eos: Token,
1795        vocab: usize,
1796    }
1797    impl InferenceEngine for FailReuseEngine {
1798        fn prefill(&mut self, t: &[Token]) -> Result<u32> {
1799            Ok(t.len() as u32)
1800        }
1801        fn next_logits(&mut self, _c: &[Token]) -> Vec<i32> {
1802            let mut v = vec![0i32; self.vocab];
1803            if let Some(s) = v.get_mut(self.eos as usize) {
1804                *s = 1;
1805            }
1806            v
1807        }
1808        fn eos_token(&self) -> Token {
1809            self.eos
1810        }
1811        fn rollback(&mut self, _keep: u32) -> Result<()> {
1812            Ok(())
1813        }
1814        fn reset_cache(&mut self) -> Result<()> {
1815            Ok(())
1816        }
1817        fn prefill_reuse(&mut self, _full_context: &[Token]) -> Result<u32> {
1818            Err(EdgeError::Engine("prefill_reuse failed"))
1819        }
1820    }
1821
1822    #[test]
1823    fn continue_prompt_reuses_cached_prefix_and_feeds_only_suffix() {
1824        // AC-3a: a follow-up turn forwards ONLY the new suffix; the unchanged
1825        // prefix's KV is reused — no whole-context re-prefill, no reload.
1826        let engine = ReuseEngine::new(1, 4, true);
1827        let forwards = engine.forwards.clone();
1828        let mut s =
1829            InferenceSession::new(SessionId(50), SessionConfig::default(), engine, permit());
1830        let ports = Ports::permissive();
1831
1832        s.load_prompt(&ports, &[10, 11]).unwrap();
1833        s.generate(&ports, 8).unwrap(); // EOS immediately → output = [1]
1834        assert_eq!(forwards.get(), 2, "turn 1 prefilled the 2 prompt tokens");
1835
1836        // Turn 2: same [10,11] prefix + a 3-token suffix.
1837        s.continue_prompt(&ports, &[10, 11, 20, 21, 22]).unwrap();
1838        assert_eq!(
1839            forwards.get() - 2,
1840            3,
1841            "only the 3-token suffix was prefilled, not the whole 5-token context"
1842        );
1843        assert_eq!(s.phase(), Phase::Decoding);
1844        // KV descriptors reflect the full reused-plus-extended context.
1845        assert_eq!(s.kv_len(), 5);
1846
1847        s.generate(&ports, 8).unwrap();
1848        assert_eq!(s.output(), &[1]);
1849        assert_eq!(s.phase(), Phase::Completed);
1850    }
1851
1852    #[test]
1853    fn continue_prompt_requires_a_completed_prior_turn() {
1854        // AC-3d: continue is valid only after a finished turn; an unprefilled
1855        // (Initialized) session must use load_prompt instead.
1856        let engine = ReuseEngine::new(1, 4, true);
1857        let mut s =
1858            InferenceSession::new(SessionId(51), SessionConfig::default(), engine, permit());
1859        let ports = Ports::permissive();
1860        let err = s.continue_prompt(&ports, &[1, 2]).unwrap_err();
1861        assert!(matches!(err, EdgeError::InvalidPhase { .. }));
1862    }
1863
1864    #[test]
1865    fn prefill_reuse_leaves_same_state_as_a_fresh_prefill() {
1866        // AC-3b soundness: prefill_reuse(ctx) must leave the engine producing the
1867        // SAME logits as reset_cache()+prefill(ctx) — both for the fast (extend)
1868        // path and the divergence (rebuild) path. Reuse is only a compute
1869        // optimisation; it must never change what the cache represents.
1870        let mut fresh = ReuseEngine::new(99, 6, false);
1871        fresh.prefill(&[1, 2, 3, 4]).unwrap();
1872        let want = fresh.next_logits(&[]);
1873
1874        // Fast path: cache [1,2] then extend to [1,2,3,4].
1875        let mut extend = ReuseEngine::new(99, 6, false);
1876        extend.prefill(&[1, 2]).unwrap();
1877        extend.prefill_reuse(&[1, 2, 3, 4]).unwrap();
1878        assert_eq!(
1879            extend.next_logits(&[]),
1880            want,
1881            "extend-reuse == fresh prefill"
1882        );
1883
1884        // Divergence: cache [1,9,3] diverges from [1,2,3,4] at index 1 → rebuild.
1885        let mut rebuild = ReuseEngine::new(99, 6, false);
1886        rebuild.prefill(&[1, 9, 3]).unwrap();
1887        rebuild.prefill_reuse(&[1, 2, 3, 4]).unwrap();
1888        assert_eq!(
1889            rebuild.next_logits(&[]),
1890            want,
1891            "rebuild-on-divergence == fresh prefill"
1892        );
1893    }
1894
1895    #[test]
1896    fn default_prefill_reuse_does_a_full_recompute() {
1897        // The safe default (used by every engine that doesn't override): discard
1898        // the cache and re-prefill the whole context. NullEngine is stateless, so
1899        // this is a no-op reset + a full prefill of the given length.
1900        let mut e = NullEngine::new(1, 4);
1901        assert_eq!(e.prefill(&[1, 2]).unwrap(), 2);
1902        assert_eq!(
1903            e.prefill_reuse(&[1, 2, 3, 4, 5]).unwrap(),
1904            5,
1905            "default reuse re-prefills the full context length"
1906        );
1907    }
1908
1909    #[test]
1910    fn continued_turn_still_runs_the_safety_control_loop() {
1911        // AC-3c: a continued turn is guarded exactly like a fresh one — the
1912        // chunk-guard + checkpointed rollback still bans the unsafe token. Uses a
1913        // stateless engine (default prefill_reuse) to isolate the safety wiring.
1914        let mut s = InferenceSession::new(
1915            SessionId(52),
1916            SessionConfig::default(),
1917            descending_engine(), // always prefers the unsafe token 0
1918            permit(),
1919        );
1920        let ports = guarded_ports(Box::new(BanToken(0)));
1921
1922        s.load_prompt(&ports, &[7]).unwrap();
1923        s.generate_with_policy(&ports, 2, tiny_policy(3)).unwrap();
1924        assert_eq!(s.phase(), Phase::Completed);
1925
1926        // Follow-up turn over the re-rendered conversation.
1927        s.continue_prompt(&ports, &[7, 1, 1, 8]).unwrap();
1928        let stop = s.generate_with_policy(&ports, 3, tiny_policy(3)).unwrap();
1929
1930        assert_eq!(stop, StopReason::MaxTokens);
1931        assert!(
1932            !s.output().contains(&0),
1933            "the guard still bans the unsafe token on a continued turn"
1934        );
1935        assert_eq!(s.output(), &[1, 1, 1]);
1936        let evs = s.drain_events();
1937        assert!(evs
1938            .iter()
1939            .any(|e| matches!(e.event, DomainEvent::ClaimBacktracked { .. })));
1940    }
1941
1942    #[test]
1943    fn prefill_reuse_into_empty_context_drops_stale_logits() {
1944        // Regression (P2): rebuilding into an EMPTY context must leave no stale
1945        // distribution — same state as reset_cache()+prefill(&[]). Otherwise
1946        // continue_prompt(&[]) would report kv_len 0 yet decode from the prior
1947        // turn's logits.
1948        let mut reused = ReuseEngine::new(9, 4, false);
1949        reused.prefill(&[1, 2, 3]).unwrap();
1950        assert!(!reused.next_logits(&[]).is_empty());
1951        reused.prefill_reuse(&[]).unwrap();
1952
1953        let mut fresh = ReuseEngine::new(9, 4, false);
1954        fresh.reset_cache().unwrap();
1955        fresh.prefill(&[]).unwrap();
1956
1957        assert_eq!(
1958            reused.next_logits(&[]),
1959            fresh.next_logits(&[]),
1960            "rebuild into an empty context == a fresh empty prefill"
1961        );
1962        assert!(
1963            reused.next_logits(&[]).is_empty(),
1964            "no stale distribution may survive the rebuild"
1965        );
1966    }
1967
1968    #[test]
1969    fn failed_continue_prompt_leaves_a_recoverable_session() {
1970        // Regression (P1): a failed prefill_reuse sets Prefilling but never reaches
1971        // Decoding. The session must NOT wedge — the provider's dirty-phase
1972        // recovery (reset() then load_prompt()) must work afterwards, instead of
1973        // the next turn hitting InvalidPhase forever.
1974        let mut s = InferenceSession::new(
1975            SessionId(54),
1976            SessionConfig::default(),
1977            FailReuseEngine { eos: 1, vocab: 4 },
1978            permit(),
1979        );
1980        let ports = Ports::permissive();
1981        s.load_prompt(&ports, &[10, 11]).unwrap();
1982        s.generate(&ports, 8).unwrap();
1983        assert_eq!(s.phase(), Phase::Completed);
1984
1985        let err = s.continue_prompt(&ports, &[10, 11, 12]).unwrap_err();
1986        assert!(matches!(err, EdgeError::Engine(_)));
1987        assert_ne!(
1988            s.phase(),
1989            Phase::Completed,
1990            "a failed reuse must not masquerade as a finished turn"
1991        );
1992
1993        // The provider's recovery path must succeed regardless of the dirty phase.
1994        s.reset().unwrap();
1995        s.load_prompt(&ports, &[20, 21]).unwrap();
1996        assert_eq!(s.phase(), Phase::Decoding);
1997        assert_eq!(s.generate(&ports, 8).unwrap(), StopReason::Eos);
1998    }
1999}