Skip to main content

el_safety/
lib.rs

1//! `el-safety` — on-device, tiered, decoder-time safety (ADR-005).
2//!
3//! The [`SafetyMode`] tier is budget-gated by device profile via
4//! [`SafetyModeSelector`]. The `Lightweight` anchor/blacklist filter is fully
5//! implemented here. `SecDecoding` (two ~1B models) and `Csd` (claim
6//! backtracking) require model assets and are scaffolded as follow-ups
7//! ([`SecDecodingSteerer`]). **No safety path touches the network.**
8//!
9//! ADR-012 adds the runtime-backtracking primitives consumed by the Inference
10//! Runtime's decode-time control loop: [`ChunkGuard`]/[`SafetyScore`] scoring,
11//! the tier-aware [`RollbackPolicy`] (cadence + bounds), and
12//! [`CheckpointManager`]/[`Checkpoint`] safe-prefix snapshots (offsets only —
13//! KV payload is never copied).
14
15#![forbid(unsafe_code)]
16
17use el_core::{DeviceTarget, SafetyMode, Token};
18
19/// A vector subtracted from target logits to steer away from unsafe output.
20/// Sparse and integer (milli-logits) for deterministic, allocation-light steps.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct LogitAdjustment {
23    penalties: Vec<(Token, i32)>,
24}
25
26impl LogitAdjustment {
27    pub fn none() -> Self {
28        Self::default()
29    }
30
31    pub fn with_penalties(penalties: Vec<(Token, i32)>) -> Self {
32        Self { penalties }
33    }
34
35    pub fn is_empty(&self) -> bool {
36        self.penalties.is_empty()
37    }
38
39    /// The milli-logit delta to add for `token` (0 if unaffected).
40    pub fn delta_for(&self, token: Token) -> i32 {
41        self.penalties
42            .iter()
43            .find(|(t, _)| *t == token)
44            .map(|(_, d)| *d)
45            .unwrap_or(0)
46    }
47
48    /// L1 norm in milli-units — what `LogitsSteered.adjustment_norm_milli`
49    /// reports to telemetry. Saturating, so a large steered set can never
50    /// overflow the `u32` aggregate.
51    pub fn l1_norm_milli(&self) -> u32 {
52        self.penalties
53            .iter()
54            .fold(0u32, |acc, (_, d)| acc.saturating_add(d.unsigned_abs()))
55    }
56
57    /// The `(token, milli-delta)` pairs this adjustment applies. Lets callers
58    /// compose adjustments (e.g. hard bans + contrastive steering) without
59    /// re-deriving them.
60    pub fn penalties(&self) -> &[(Token, i32)] {
61        &self.penalties
62    }
63}
64
65/// Per-step safety intervention. The runtime applies this **after** the grammar
66/// mask and **before** sampling.
67pub trait SafetySteerer {
68    fn adjust(&self, recent_tokens: &[Token]) -> LogitAdjustment;
69    fn mode(&self) -> SafetyMode;
70
71    /// Logit-aware steering (ADR-013). Given the base model's next-token logits
72    /// for this step, return the adjustment to apply (still after the grammar
73    /// mask, before sampling — the ADR-005 order is unchanged).
74    ///
75    /// The default ignores the logits and delegates to [`adjust`](Self::adjust),
76    /// so token-only steerers (hard bans, heuristics) need no change. Model-backed
77    /// steerers (e.g. [`ContrastiveSteerer`]) override this to use the base
78    /// distribution. The runtime calls this **inside** the early-token
79    /// soft-steering window and plain [`adjust`](Self::adjust) outside it, so a
80    /// model-backed adjustment costs nothing once the window closes.
81    fn adjust_with_logits(&self, recent_tokens: &[Token], _base_logits: &[i32]) -> LogitAdjustment {
82        self.adjust(recent_tokens)
83    }
84}
85
86/// Chooses the affordable mode for the device (ADR-005).
87pub struct SafetyModeSelector;
88
89impl SafetyModeSelector {
90    /// `SecDecoding` (two ~1B models) is rejected on `MidRange` and downgraded
91    /// to `Lightweight`; everything else passes through.
92    pub fn resolve(requested: SafetyMode, device: DeviceTarget) -> SafetyMode {
93        match (requested, device) {
94            (SafetyMode::SecDecoding, DeviceTarget::MidRange) => SafetyMode::Lightweight,
95            (m, _) => m,
96        }
97    }
98}
99
100/// `SafetyMode::Off` — a no-op steerer.
101pub struct NoSafety;
102
103impl SafetySteerer for NoSafety {
104    fn adjust(&self, _recent: &[Token]) -> LogitAdjustment {
105        LogitAdjustment::none()
106    }
107    fn mode(&self) -> SafetyMode {
108        SafetyMode::Off
109    }
110}
111
112/// `SafetyMode::Lightweight` — a training-free blacklist filter (real). Banned
113/// tokens receive a very large negative logit so they cannot be sampled.
114pub struct LightweightFilter {
115    banned: Vec<Token>,
116}
117
118impl LightweightFilter {
119    pub const HARD_BAN: i32 = -1_000_000;
120
121    pub fn new(banned: Vec<Token>) -> Self {
122        Self { banned }
123    }
124}
125
126impl SafetySteerer for LightweightFilter {
127    fn adjust(&self, _recent: &[Token]) -> LogitAdjustment {
128        LogitAdjustment::with_penalties(self.banned.iter().map(|&t| (t, Self::HARD_BAN)).collect())
129    }
130    fn mode(&self) -> SafetyMode {
131        SafetyMode::Lightweight
132    }
133}
134
135/// `SafetyMode::SecDecoding` — base-vs-safety-model logit steering.
136///
137/// FOLLOW-UP (ADR-005): requires two ~1B models run on Candle. Until model
138/// assets are wired, this returns no adjustment and reports its intended mode,
139/// so callers can select it without it silently mis-steering.
140pub struct SecDecodingSteerer {
141    _private: (),
142}
143
144impl SecDecodingSteerer {
145    pub fn placeholder() -> Self {
146        Self { _private: () }
147    }
148}
149
150impl SafetySteerer for SecDecodingSteerer {
151    fn adjust(&self, _recent: &[Token]) -> LogitAdjustment {
152        // TODO(adr-005): run base + safety models on Candle, derive adjustment
153        // from their divergence. See [`ContrastiveSteerer`] (ADR-013) for the
154        // real mechanism, wired once an expert logit source is supplied.
155        LogitAdjustment::none()
156    }
157    fn mode(&self) -> SafetyMode {
158        SafetyMode::SecDecoding
159    }
160}
161
162// ---------------------------------------------------------------------------
163// ADR-013 — model-backed (contrastive) steering
164// ---------------------------------------------------------------------------
165
166/// Safety-tuned next-token logits for the committed context, in integer
167/// milli-logits over the **same vocabulary and tokenizer** as the base
168/// generator (the ADR-012 shared-tokenizer invariant — the contrastive direction
169/// is only meaningful when base and expert share a token set). The expert weights
170/// are integrity-gated on load (ADR-006) by the adapter that constructs the
171/// implementor; like every safety path this is deterministic and **never touches
172/// the network** (ADR-004).
173pub trait ExpertLogits {
174    /// Expert next-token milli-logits given the committed (generated) context.
175    fn logits(&self, committed: &[Token]) -> Vec<i32>;
176}
177
178/// SafeDecoding-style **contrastive adjustment** (ADR-013): steer toward the
179/// safety expert and away from the base — `final = base + α·(expert − base)` —
180/// returned as additive milli-logit penalties consumed after the grammar mask,
181/// before sampling (the ADR-005 order is unchanged).
182///
183/// `alpha_milli` is the steering strength ×1000 (`1000` = 1.0×). Only the
184/// `top_k` highest-base-logit tokens are steered (`0` = all): SafeDecoding
185/// restricts contrast to the head of the distribution so long-tail noise is not
186/// amplified. Deterministic and integer (ADR-008).
187///
188/// Safety/robustness invariants: a **negative** `alpha_milli` is clamped to `0`
189/// — contrastive steering must never push *toward* the base/unsafe direction —
190/// and the delta math is **saturating**, so an extreme strength can neither
191/// overflow `i64` nor wrap on the `i32` cast. Plus the usual fail-safes: an
192/// **empty** result when `base`/`expert` lengths differ (or are empty), and a
193/// natural **no-op** when `expert == base` (every delta is zero).
194pub fn contrastive_adjustment(
195    base: &[i32],
196    expert: &[i32],
197    alpha_milli: i32,
198    top_k: usize,
199) -> LogitAdjustment {
200    if base.is_empty() || base.len() != expert.len() {
201        return LogitAdjustment::none();
202    }
203    // Never steer toward the base/unsafe direction (clamp negative to zero).
204    let alpha = i64::from(alpha_milli.max(0));
205    // Tokens to steer: the top_k by base logit (deterministic tie-break on
206    // index), or all of them when top_k is 0 or covers the whole vocab.
207    let mut idx: Vec<usize> = (0..base.len()).collect();
208    if top_k > 0 && top_k < base.len() {
209        idx.sort_unstable_by(|&a, &b| base[b].cmp(&base[a]).then(a.cmp(&b)));
210        idx.truncate(top_k);
211    }
212    let mut penalties: Vec<(Token, i32)> = Vec::new();
213    for i in idx {
214        let diff = i64::from(expert[i]) - i64::from(base[i]);
215        // Saturating throughout: extreme alpha saturates rather than wrapping.
216        let delta = (diff.saturating_mul(alpha) / 1000)
217            .clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
218        if delta != 0 {
219            penalties.push((i as Token, delta));
220        }
221    }
222    LogitAdjustment::with_penalties(penalties)
223}
224
225/// `SafetyMode::SecDecoding` (or model-backed `Lightweight`) steerer (ADR-013): a
226/// hard-ban layer applied **every step** plus **contrastive soft steering** from
227/// an [`ExpertLogits`] source applied only when the runtime supplies base logits
228/// (i.e. inside the early-token window). With no expert divergence it reduces to
229/// the hard-ban behaviour of [`LightweightFilter`]; with no bans and a flat
230/// expert it is a no-op.
231pub struct ContrastiveSteerer<E: ExpertLogits> {
232    expert: E,
233    banned: Vec<Token>,
234    alpha_milli: i32,
235    top_k: usize,
236    mode: SafetyMode,
237}
238
239impl<E: ExpertLogits> ContrastiveSteerer<E> {
240    /// `mode` is the tier label reported to telemetry (`Lightweight` for a LoRA
241    /// expert, `SecDecoding` for a contrastive base+expert pair).
242    pub fn new(
243        expert: E,
244        banned: Vec<Token>,
245        alpha_milli: i32,
246        top_k: usize,
247        mode: SafetyMode,
248    ) -> Self {
249        Self {
250            expert,
251            banned,
252            alpha_milli,
253            top_k,
254            mode,
255        }
256    }
257
258    fn bans(&self) -> Vec<(Token, i32)> {
259        self.banned
260            .iter()
261            .map(|&t| (t, LightweightFilter::HARD_BAN))
262            .collect()
263    }
264}
265
266impl<E: ExpertLogits> SafetySteerer for ContrastiveSteerer<E> {
267    /// Out-of-window / token-only path: hard bans only (no expert forward).
268    fn adjust(&self, _recent: &[Token]) -> LogitAdjustment {
269        LogitAdjustment::with_penalties(self.bans())
270    }
271
272    /// In-window path: hard bans **plus** contrastive soft steering. Bans are
273    /// listed first, so a token that is both banned and steered keeps the hard
274    /// ban (`delta_for` returns the first match).
275    fn adjust_with_logits(&self, recent: &[Token], base_logits: &[i32]) -> LogitAdjustment {
276        let mut penalties = self.bans();
277        let expert = self.expert.logits(recent);
278        let contrast = contrastive_adjustment(base_logits, &expert, self.alpha_milli, self.top_k);
279        penalties.extend_from_slice(contrast.penalties());
280        LogitAdjustment::with_penalties(penalties)
281    }
282
283    fn mode(&self) -> SafetyMode {
284        self.mode
285    }
286}
287
288// ---------------------------------------------------------------------------
289// ADR-012 — checkpointed-rollback control-loop primitives
290// ---------------------------------------------------------------------------
291
292/// Risk score in milli-units, `0` (safe) ..= `1000` (max). Integer for
293/// deterministic, float-free safety decisions (ADR-008).
294#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
295pub struct SafetyScore(u16);
296
297impl SafetyScore {
298    pub const SAFE: SafetyScore = SafetyScore(0);
299    pub const MAX: SafetyScore = SafetyScore(1000);
300
301    /// Clamp a milli-unit value into `[0, 1000]`.
302    pub fn from_milli(milli: u16) -> Self {
303        Self(milli.min(1000))
304    }
305
306    /// The score in milli-units, as reported on `SafetyViolationDetected`.
307    pub fn milli(self) -> u16 {
308        self.0
309    }
310}
311
312/// Scores recent generated output for risk (ADR-012 chunk guard). Reuses the
313/// active tier's safety model; like every safety path it is deterministic and
314/// **never touches the network** (ADR-004).
315pub trait ChunkGuard {
316    /// Risk of the recent output window. Higher is riskier.
317    fn score(&self, recent: &[Token]) -> SafetyScore;
318}
319
320/// `SafetyMode::Lightweight` chunk guard — training-free **token-anchor
321/// heuristics** (no weights), the [`ChunkGuard`] counterpart of
322/// [`LightweightFilter`]. Each *pattern* is an unsafe token-id n-gram; the guard
323/// adds `per_hit` milli-units for every pattern that occurs as a contiguous
324/// subslice of the scored window, saturating at [`SafetyScore::MAX`].
325///
326/// Matching whole token sequences (not loose single ids) is deliberate: a
327/// multi-token word like "explosive" matches exactly, so a stray subword shared
328/// with benign text does not false-positive. A length-1 pattern is a plain
329/// single-token anchor.
330///
331/// Patterns are token ids, so the caller resolves them from its own tokenizer
332/// (the adapter that owns one) — this keeps `el-safety` free of any tokenizer or
333/// float dependency, and the guard fully deterministic (ADR-008) and offline
334/// (ADR-004).
335#[derive(Debug, Clone, Default)]
336pub struct AnchorGuard {
337    patterns: Vec<Vec<Token>>,
338    per_hit: u16,
339}
340
341impl AnchorGuard {
342    /// A guard adding `per_hit_milli` risk per matched pattern. Use
343    /// `per_hit_milli >= hard_threshold` so a single match breaches.
344    pub fn new(patterns: Vec<Vec<Token>>, per_hit_milli: u16) -> Self {
345        Self {
346            patterns,
347            per_hit: per_hit_milli,
348        }
349    }
350
351    /// A guard where any single pattern match saturates the score — every
352    /// flagged sequence is treated as a hard breach.
353    pub fn hard(patterns: Vec<Vec<Token>>) -> Self {
354        Self::new(patterns, SafetyScore::MAX.milli())
355    }
356
357    /// Whether the guard carries any non-empty pattern to match.
358    pub fn is_empty(&self) -> bool {
359        self.patterns.iter().all(Vec::is_empty)
360    }
361}
362
363impl ChunkGuard for AnchorGuard {
364    fn score(&self, recent: &[Token]) -> SafetyScore {
365        let mut milli: u32 = 0;
366        for pat in &self.patterns {
367            if pat.is_empty() || pat.len() > recent.len() {
368                continue;
369            }
370            if recent.windows(pat.len()).any(|w| w == pat.as_slice()) {
371                milli = milli.saturating_add(u32::from(self.per_hit));
372            }
373        }
374        SafetyScore::from_milli(milli.min(u32::from(u16::MAX)) as u16)
375    }
376}
377
378/// Cadence and bounds for the checkpointed-rollback control loop (ADR-012),
379/// chosen per device tier so cost scales with the hardware budget (ADR-003).
380///
381/// `steer_window` is the ADR-012 stage-1 **early-token soft-steering window**
382/// (ADR-013): model-backed steering applies for the first `steer_window` output
383/// tokens, then decode falls back to hard-bans-only unless the guard
384/// re-escalates. Hard bans still apply every step regardless. Checkpoint spacing
385/// is `guard_every` — checkpoints are only taken at guard-verified-safe
386/// boundaries, so there is no separate checkpoint cadence to misconfigure.
387#[derive(Debug, Clone, Copy, PartialEq, Eq)]
388pub struct RollbackPolicy {
389    /// Score the output every `guard_every` tokens (`0` = never); also the
390    /// spacing of safe-prefix checkpoints.
391    pub guard_every: u32,
392    /// Apply model-backed (soft) steering for the first `steer_window` output
393    /// tokens (`0` = never — token-only/hard-ban steering at every step).
394    pub steer_window: u32,
395    /// At/above this score, escalate; do not advance the safe checkpoint.
396    pub soft_threshold: SafetyScore,
397    /// At/above this score, roll back (or fail closed).
398    pub hard_threshold: SafetyScore,
399    /// Hard cap on rollbacks before a deterministic refusal (DoS bound).
400    pub max_rollbacks: u8,
401    /// Bounded checkpoint ring size — fixed memory (ADR-003).
402    pub max_checkpoints: u8,
403}
404
405impl RollbackPolicy {
406    /// Tier-aware policy. `SafetyMode::Off` disables the loop entirely.
407    pub fn for_device(device: DeviceTarget, mode: SafetyMode) -> Self {
408        if matches!(mode, SafetyMode::Off) {
409            return Self::disabled();
410        }
411        match device {
412            DeviceTarget::MidRange => Self {
413                guard_every: 16,
414                steer_window: 8,
415                soft_threshold: SafetyScore(600),
416                hard_threshold: SafetyScore(800),
417                max_rollbacks: 2,
418                max_checkpoints: 4,
419            },
420            DeviceTarget::HighEnd | DeviceTarget::Auto => Self {
421                guard_every: 4,
422                steer_window: 16,
423                soft_threshold: SafetyScore(500),
424                hard_threshold: SafetyScore(750),
425                max_rollbacks: 4,
426                max_checkpoints: 8,
427            },
428        }
429    }
430
431    /// A policy that performs no steering, checkpointing, or guarding.
432    pub fn disabled() -> Self {
433        Self {
434            guard_every: 0,
435            steer_window: 0,
436            soft_threshold: SafetyScore::MAX,
437            hard_threshold: SafetyScore::MAX,
438            max_rollbacks: 0,
439            max_checkpoints: 0,
440        }
441    }
442
443    /// Whether the guard/rollback machinery is active under this policy.
444    pub fn guards(&self) -> bool {
445        self.guard_every > 0
446    }
447
448    /// Whether any safety stage (guard/rollback or the soft-steering window) is
449    /// active — used to gate ingress triage (ADR-013).
450    pub fn active(&self) -> bool {
451        self.guard_every > 0 || self.steer_window > 0
452    }
453}
454
455/// A safe-prefix snapshot for rollback (ADR-012). Stores only indices: rollback
456/// truncates KV descriptors (`KvRegion::truncate`) and never replays prefill,
457/// and the KV payload is never copied (ADR-002/ADR-003).
458#[derive(Debug, Clone, Copy, PartialEq, Eq)]
459pub struct Checkpoint {
460    /// Committed-token count at the checkpoint.
461    pub output_len: u32,
462    /// KV-cache length to restore to.
463    pub kv_len: u32,
464}
465
466/// A bounded ring of guard-verified safe-prefix checkpoints. Fixed memory: the
467/// oldest checkpoint is dropped once `cap` is reached (ADR-003). A `cap` of `0`
468/// (or [`disable`](Self::disable)) retains nothing — the loop then has no
469/// rollback target and fails closed on a breach.
470#[derive(Debug, Default)]
471pub struct CheckpointManager {
472    ring: Vec<Checkpoint>,
473    cap: usize,
474    enabled: bool,
475}
476
477impl CheckpointManager {
478    pub fn new(cap: u8) -> Self {
479        Self {
480            ring: Vec::new(),
481            cap: cap as usize,
482            enabled: cap > 0,
483        }
484    }
485
486    pub fn enabled(&self) -> bool {
487        self.enabled
488    }
489
490    /// Drop all checkpoints and stop retaining new ones (memory-pressure
491    /// degradation — ADR-012/ADR-003).
492    pub fn disable(&mut self) {
493        self.enabled = false;
494        self.ring.clear();
495    }
496
497    /// Record a safe prefix; evicts the oldest if the ring is full. No-op when
498    /// disabled.
499    pub fn push(&mut self, checkpoint: Checkpoint) {
500        if !self.enabled {
501            return;
502        }
503        if self.ring.len() == self.cap {
504            self.ring.remove(0);
505        }
506        self.ring.push(checkpoint);
507    }
508
509    /// The most recent safe prefix, if any.
510    pub fn last(&self) -> Option<Checkpoint> {
511        self.ring.last().copied()
512    }
513
514    pub fn len(&self) -> usize {
515        self.ring.len()
516    }
517
518    pub fn is_empty(&self) -> bool {
519        self.ring.is_empty()
520    }
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    #[test]
528    fn secdecoding_downgrades_on_midrange() {
529        assert_eq!(
530            SafetyModeSelector::resolve(SafetyMode::SecDecoding, DeviceTarget::MidRange),
531            SafetyMode::Lightweight
532        );
533        assert_eq!(
534            SafetyModeSelector::resolve(SafetyMode::SecDecoding, DeviceTarget::HighEnd),
535            SafetyMode::SecDecoding
536        );
537    }
538
539    #[test]
540    fn lightweight_bans_tokens() {
541        let f = LightweightFilter::new(vec![42, 99]);
542        let adj = f.adjust(&[]);
543        assert_eq!(adj.delta_for(42), LightweightFilter::HARD_BAN);
544        assert_eq!(adj.delta_for(7), 0);
545        assert!(adj.l1_norm_milli() > 0);
546    }
547
548    // ----- ADR-013 model-backed (contrastive) steering -----
549
550    /// Synthetic expert: returns fixed logits, ignoring context — deterministic.
551    struct FixedExpert(Vec<i32>);
552    impl ExpertLogits for FixedExpert {
553        fn logits(&self, _committed: &[Token]) -> Vec<i32> {
554            self.0.clone()
555        }
556    }
557
558    #[test]
559    fn contrastive_pushes_toward_expert_and_is_noop_when_equal() {
560        let base = [100, 200, 300];
561        let expert = [600, 200, 100]; // prefers t0, dislikes t2
562        let adj = contrastive_adjustment(&base, &expert, 1000, 0); // alpha 1.0, all
563        assert_eq!(adj.delta_for(0), 500); // +500 toward expert
564        assert_eq!(adj.delta_for(1), 0); // unchanged → dropped
565        assert_eq!(adj.delta_for(2), -200); // away from base preference
566
567        // Half strength halves the deltas.
568        assert_eq!(
569            contrastive_adjustment(&base, &expert, 500, 0).delta_for(0),
570            250
571        );
572        // expert == base → no-op.
573        assert!(contrastive_adjustment(&base, &base, 1000, 0).is_empty());
574    }
575
576    #[test]
577    fn contrastive_top_k_restricts_to_distribution_head() {
578        let base = [10, 50, 40, 5]; // top-2 by base: t1, t2
579        let expert = [999, 999, 999, 999];
580        let adj = contrastive_adjustment(&base, &expert, 1000, 2);
581        assert_ne!(adj.delta_for(1), 0);
582        assert_ne!(adj.delta_for(2), 0);
583        assert_eq!(adj.delta_for(0), 0); // outside the head → untouched
584        assert_eq!(adj.delta_for(3), 0);
585    }
586
587    #[test]
588    fn contrastive_empty_on_length_mismatch_or_empty() {
589        assert!(contrastive_adjustment(&[1, 2, 3], &[1, 2], 1000, 0).is_empty());
590        assert!(contrastive_adjustment(&[], &[], 1000, 0).is_empty());
591    }
592
593    #[test]
594    fn contrastive_clamps_negative_and_saturates_extreme_alpha() {
595        let base = [100, 200];
596        let expert = [900, 0];
597        // Negative strength must NOT reverse the safety direction → clamped to a
598        // no-op.
599        assert!(contrastive_adjustment(&base, &expert, -5000, 0).is_empty());
600        // Extreme strength saturates instead of wrapping (no overflow/panic) and
601        // still steers toward the expert-preferred token.
602        let adj = contrastive_adjustment(&base, &expert, i32::MAX, 0);
603        assert!(adj.delta_for(0) > 0);
604        assert!(adj.delta_for(1) < 0);
605        let _ = adj.l1_norm_milli(); // saturating; must not overflow/panic
606    }
607
608    #[test]
609    fn contrastive_steerer_layers_bans_and_contrast() {
610        let base = [100, 100, 100];
611        let expert = FixedExpert(vec![100, 400, 100]); // prefers token 1
612        let steerer = ContrastiveSteerer::new(expert, vec![0], 1000, 0, SafetyMode::SecDecoding);
613
614        // Token-only path (out of window): hard ban only, no contrast.
615        let banned_only = steerer.adjust(&[]);
616        assert_eq!(banned_only.delta_for(0), LightweightFilter::HARD_BAN);
617        assert_eq!(banned_only.delta_for(1), 0);
618
619        // In-window path: ban (token 0) + contrastive steer toward token 1.
620        let full = steerer.adjust_with_logits(&[], &base);
621        assert_eq!(full.delta_for(0), LightweightFilter::HARD_BAN); // ban dominates
622        assert_eq!(full.delta_for(1), 300); // 400 - 100
623        assert_eq!(steerer.mode(), SafetyMode::SecDecoding);
624    }
625
626    #[test]
627    fn policy_has_tier_aware_steer_window() {
628        let mid = RollbackPolicy::for_device(DeviceTarget::MidRange, SafetyMode::Lightweight);
629        let high = RollbackPolicy::for_device(DeviceTarget::HighEnd, SafetyMode::SecDecoding);
630        assert!(mid.steer_window > 0 && high.steer_window > 0);
631        assert!(high.steer_window >= mid.steer_window);
632        let off = RollbackPolicy::for_device(DeviceTarget::Auto, SafetyMode::Off);
633        assert_eq!(off.steer_window, 0);
634        assert!(!off.active());
635    }
636
637    #[test]
638    fn anchor_guard_matches_token_ngrams_exactly() {
639        // Two patterns: a single-token anchor (9) and a 2-token n-gram (40,41).
640        let guard = AnchorGuard::hard(vec![vec![9], vec![40, 41]]);
641
642        // No anchor present → safe.
643        assert_eq!(guard.score(&[1, 2, 3]), SafetyScore::SAFE);
644        // The single-token anchor anywhere in the window → hard breach.
645        assert_eq!(guard.score(&[1, 9, 2]), SafetyScore::MAX);
646        // The full 2-gram present (contiguous) → match.
647        assert_eq!(guard.score(&[7, 40, 41, 8]), SafetyScore::MAX);
648        // The 2-gram's tokens present but NOT contiguous → no false positive.
649        assert_eq!(guard.score(&[40, 99, 41]), SafetyScore::SAFE);
650    }
651
652    #[test]
653    fn anchor_guard_accumulates_below_max_and_is_empty_safe() {
654        // per-hit below MAX: one hit is soft, two hits saturate.
655        let guard = AnchorGuard::new(vec![vec![1], vec![2]], 600);
656        assert_eq!(guard.score(&[1, 5]).milli(), 600);
657        assert_eq!(guard.score(&[1, 2]), SafetyScore::MAX); // 1200 clamps to 1000
658
659        // No patterns (or only empty ones) → always safe, never matches.
660        assert!(AnchorGuard::hard(vec![]).is_empty());
661        assert_eq!(
662            AnchorGuard::hard(vec![]).score(&[1, 2, 3]),
663            SafetyScore::SAFE
664        );
665        assert_eq!(
666            AnchorGuard::hard(vec![vec![]]).score(&[1]),
667            SafetyScore::SAFE
668        );
669    }
670
671    #[test]
672    fn safety_score_clamps_and_orders() {
673        assert_eq!(SafetyScore::from_milli(5000), SafetyScore::MAX);
674        assert!(SafetyScore::SAFE < SafetyScore::MAX);
675        assert_eq!(SafetyScore::from_milli(750).milli(), 750);
676    }
677
678    #[test]
679    fn policy_off_is_disabled() {
680        let p = RollbackPolicy::for_device(DeviceTarget::HighEnd, SafetyMode::Off);
681        assert!(!p.guards());
682        assert_eq!(p.max_rollbacks, 0);
683    }
684
685    #[test]
686    fn policy_is_tier_aware() {
687        let mid = RollbackPolicy::for_device(DeviceTarget::MidRange, SafetyMode::Lightweight);
688        let high = RollbackPolicy::for_device(DeviceTarget::HighEnd, SafetyMode::SecDecoding);
689        assert!(mid.guards() && high.guards());
690        // Stronger hardware guards more often and tolerates more rollbacks.
691        assert!(high.guard_every < mid.guard_every);
692        assert!(high.max_rollbacks >= mid.max_rollbacks);
693    }
694
695    #[test]
696    fn checkpoint_ring_is_bounded_and_disablable() {
697        let mut m = CheckpointManager::new(2);
698        m.push(Checkpoint {
699            output_len: 1,
700            kv_len: 1,
701        });
702        m.push(Checkpoint {
703            output_len: 2,
704            kv_len: 2,
705        });
706        m.push(Checkpoint {
707            output_len: 3,
708            kv_len: 3,
709        }); // evicts the oldest
710        assert_eq!(m.len(), 2);
711        assert_eq!(
712            m.last(),
713            Some(Checkpoint {
714                output_len: 3,
715                kv_len: 3,
716            })
717        );
718        m.disable();
719        assert!(!m.enabled() && m.is_empty());
720        m.push(Checkpoint {
721            output_len: 9,
722            kv_len: 9,
723        }); // no-op when disabled
724        assert!(m.last().is_none());
725    }
726}