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