Skip to main content

el_runtime/
ports.rs

1//! Port traits the Inference Runtime depends on (the collaborator contexts).
2
3use el_core::{Result, Token};
4use el_safety::{ChunkGuard, SafetySteerer};
5
6/// The inference engine adapter (`RuntimeAcl`). Implemented for real by Candle
7/// in the excluded adapter `el-engine-candle` (ADR-002).
8///
9/// Logits are integer milli-logits to keep the orchestrator deterministic and
10/// float-free; a real engine quantises its float logits at the ACL boundary.
11pub trait InferenceEngine {
12    /// Encode the (compressed) prompt; returns the resulting KV length.
13    fn prefill(&mut self, tokens: &[Token]) -> Result<u32>;
14    /// Produce next-token logits given the committed context.
15    fn next_logits(&mut self, committed: &[Token]) -> Vec<i32>;
16    /// The end-of-sequence token id.
17    fn eos_token(&self) -> Token;
18
19    /// Roll the engine's internal state back so its context is exactly the
20    /// prompt plus `keep_committed` generated tokens.
21    ///
22    /// The ADR-012 control loop truncates the session's committed output and KV
23    /// descriptors on a safety backtrack. A **stateful** engine (one holding a
24    /// real KV cache and position counters, e.g. a transformer) must mirror that
25    /// truncation here — otherwise it keeps serving logits from the abandoned
26    /// (unsafe) branch and never re-feeds the replacement tokens, so the rollback
27    /// is silently a no-op at the engine level.
28    ///
29    /// After `Ok(())`, the next [`next_logits`](Self::next_logits) call — passed a
30    /// `committed` slice of length `keep_committed` — must produce logits
31    /// consistent with that prefix. Returning `Err` makes the loop fail closed
32    /// rather than resume on an inconsistent cache.
33    ///
34    /// This method is **required, with no default**, deliberately: a default
35    /// no-op would let a stateful adapter that forgot to override silently resume
36    /// on a stale KV cache — a safety bug that fails *open*. Every engine must
37    /// make the choice explicit. A stateless engine whose `next_logits`
38    /// recomputes purely from `committed` implements it as `Ok(())`.
39    fn rollback(&mut self, keep_committed: u32) -> Result<()>;
40
41    /// Return the engine to its **pristine, pre-prefill** state so the same
42    /// loaded weights can serve a *new conversation* without being reloaded
43    /// (ADR-018). After `Ok(())`, the next [`prefill`](Self::prefill) must build a
44    /// KV cache from scratch as if the engine had just been constructed.
45    ///
46    /// This is distinct from [`rollback`](Self::rollback): `rollback(keep)` rewinds
47    /// *within* a generation to a safe prefix of length `keep` (ADR-012);
48    /// `reset_cache` discards the whole conversation. It is what lets a provider
49    /// hold one resident model and reuse it across turns instead of re-reading the
50    /// weights from disk every call.
51    ///
52    /// Like `rollback`, it is **required with no default**: a stateful adapter that
53    /// forgot to override would otherwise carry a stale cache into the next
54    /// conversation. A stateless engine whose `next_logits` recomputes purely from
55    /// `committed` implements it as `Ok(())`.
56    fn reset_cache(&mut self) -> Result<()>;
57
58    /// Prefill `full_context`, **reusing the KV already cached for its longest
59    /// matching prefix** and feeding only the divergent suffix — cross-turn
60    /// incremental prefill (ADR-018 AC-3). Returns the resulting KV length.
61    ///
62    /// This is the engine half of [`InferenceSession::continue_prompt`]: on a
63    /// follow-up turn the whole conversation is re-rendered and re-tokenized, but a
64    /// stateful engine that still holds the prior turn's KV can skip re-encoding the
65    /// unchanged prefix. The token-level prefix match against the live cache **is**
66    /// the tokenizer-round-trip guard — if the re-tokenized context diverges from
67    /// what was cached (decode→encode is not always identity), reuse stops at the
68    /// divergence and the suffix is fed fresh.
69    ///
70    /// **Soundness contract.** After `Ok`, the engine MUST be in the exact state a
71    /// `reset_cache()` + `prefill(full_context)` would have left it — identical
72    /// logits for any subsequent [`next_logits`](Self::next_logits). Reuse is purely
73    /// a compute optimisation; it must never change *what* the cache represents, so
74    /// the runtime's safety checks (which re-run over `full_context` every turn) see
75    /// identical data.
76    ///
77    /// Unlike [`rollback`](Self::rollback)/[`reset_cache`](Self::reset_cache), a wrong
78    /// implementation here is a correctness/perf regression, not a safety
79    /// fail-*open* — so this has a **safe default**: discard the cache and re-prefill
80    /// the whole context (no reuse). Stateful engines override it for the fast path;
81    /// stateless engines (whose `next_logits` recomputes from `committed`) inherit
82    /// the default unchanged.
83    fn prefill_reuse(&mut self, full_context: &[Token]) -> Result<u32> {
84        self.reset_cache()?;
85        self.prefill(full_context)
86    }
87}
88
89/// Prompt Compression port (LLMLingua-2 — context 2).
90pub trait PromptCompressor {
91    fn compress(&self, tokens: &[Token]) -> Vec<Token>;
92}
93
94/// Grammar Constraint port (llguidance — context 4). Returns a per-token allow
95/// mask of length `vocab`; `true` = legal this step.
96pub trait GrammarMasker {
97    fn mask(&self, recent: &[Token], vocab: usize) -> Vec<bool>;
98}
99
100/// Opt-in LAN relay (ADR-004 HybridMode). Implementations MUST stay on the local
101/// network — there is no cloud variant.
102pub trait HybridRelay {
103    fn consult(&self, query_tokens: &[Token]) -> Vec<Token>;
104}
105
106/// The collaborator ports bound to a session. `relay` is `None` by default —
107/// air-gapped.
108pub struct Ports {
109    pub compressor: Box<dyn PromptCompressor>,
110    pub grammar: Box<dyn GrammarMasker>,
111    pub safety: Box<dyn SafetySteerer>,
112    /// Optional chunk guard for the checkpointed-rollback control loop
113    /// (ADR-012). `None` runs the plain single-pass decode.
114    pub guard: Option<Box<dyn ChunkGuard>>,
115    /// Optional **ingress / prompt-risk triage** (ADR-013): scores the prompt
116    /// before generation. Reuses the [`ChunkGuard`] contract — it scores a token
117    /// window for risk — applied to the prompt rather than the output. `None`
118    /// runs no ingress check.
119    pub ingress: Option<Box<dyn ChunkGuard>>,
120    pub relay: Option<Box<dyn HybridRelay>>,
121}
122
123impl Ports {
124    /// Defaults: identity compression, all-tokens-allowed grammar, no safety
125    /// steering, no guard, no ingress, no relay (air-gapped).
126    pub fn permissive() -> Self {
127        Self {
128            compressor: Box::new(super::defaults::IdentityCompressor),
129            grammar: Box::new(super::defaults::AllowAllMasker),
130            safety: Box::new(el_safety::NoSafety),
131            guard: None,
132            ingress: None,
133            relay: None,
134        }
135    }
136}