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}