Skip to main content

ruvector_sona/
darwin_guard.rs

1//! Reward-hacking defenses for evolutionary harness/config search (ADR-271).
2//!
3//! Borrowed from Ornith-1.0's three-layer defense ("Self-Scaffolding LLMs for
4//! Agentic Coding", DeepReinforce 2026). When an evolutionary loop is allowed to
5//! evolve its own harness/config, candidates can "win" by gaming the fitness
6//! rather than improving — so the search must be screened:
7//!
8//!   1. **Immutable boundary** — the verifier (the fitness/eval) is frozen and
9//!      lives outside what evolves; the genome can only change the *inner* policy.
10//!      Modelled here by keeping [`screen`] a pure function of verifier output the
11//!      candidate cannot fabricate.
12//!   2. **Deterministic monitor** — non-finite metrics, out-of-bounds genes, or a
13//!      degenerate/collapsed "win" are flagged and the candidate is **excluded
14//!      from the selection statistics** (Pareto front / advantage), NOT merely
15//!      zero-scored. A zero-scored hack can still bias selection; an excluded one
16//!      cannot. See [`best_accepted`].
17//!   3. **Frozen judge veto** — an [`IntentJudge`] (e.g. a frozen LLM) may VETO
18//!      intent-level gaming inside the allowed surface, but never *sets* the
19//!      reward — it is a veto on top of the verifier, not the reward itself.
20
21/// Outcome of screening one candidate. `Rejected` candidates are dropped from the
22/// selection statistics entirely (the "exclude from advantage" rule).
23#[derive(Clone, Copy, Debug, PartialEq)]
24pub enum Verdict {
25    /// Passed all layers; carries the verifier fitness.
26    Accepted(f32),
27    /// Rejected; excluded from Pareto/advantage with a reason.
28    Rejected(Reject),
29}
30
31/// Why a candidate was rejected (telemetry + auditability).
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum Reject {
34    /// A metric or the fitness was NaN/Inf.
35    NonFinite,
36    /// A gene was outside its declared bounds.
37    OutOfBounds,
38    /// "Won" via a collapsed/trivial path (caller-defined degeneracy check).
39    Degenerate,
40    /// The frozen intent-judge vetoed it.
41    JudgeVeto,
42}
43
44/// Layer 3: a frozen judge that may only VETO a candidate, never set its reward.
45pub trait IntentJudge {
46    /// Return `true` to veto (reject) the candidate.
47    fn veto(&self, fitness: f32) -> bool;
48}
49
50/// Deterministic-only screening (no judge).
51#[derive(Clone, Copy, Debug, Default)]
52pub struct NoJudge;
53impl IntentJudge for NoJudge {
54    fn veto(&self, _fitness: f32) -> bool {
55        false
56    }
57}
58
59/// The reward-hacking guard.
60#[derive(Clone, Copy, Debug)]
61pub struct Guard<J: IntentJudge = NoJudge> {
62    judge: J,
63}
64
65impl Guard<NoJudge> {
66    /// Deterministic-monitor-only guard (layers 1–2).
67    #[must_use]
68    pub fn deterministic() -> Self {
69        Self { judge: NoJudge }
70    }
71}
72
73impl<J: IntentJudge> Guard<J> {
74    /// Guard with a layer-3 intent judge.
75    pub fn with_judge(judge: J) -> Self {
76        Self { judge }
77    }
78
79    /// Screen one candidate. `fitness`/`finite_metrics` come from the IMMUTABLE
80    /// verifier (the candidate cannot fabricate them); `in_bounds`/`degenerate`
81    /// are caller-supplied deterministic checks over the genome + its metrics.
82    pub fn screen(
83        &self,
84        fitness: f32,
85        finite_metrics: bool,
86        in_bounds: bool,
87        degenerate: bool,
88    ) -> Verdict {
89        if !finite_metrics || !fitness.is_finite() {
90            return Verdict::Rejected(Reject::NonFinite);
91        }
92        if !in_bounds {
93            return Verdict::Rejected(Reject::OutOfBounds);
94        }
95        if degenerate {
96            return Verdict::Rejected(Reject::Degenerate);
97        }
98        if self.judge.veto(fitness) {
99            return Verdict::Rejected(Reject::JudgeVeto);
100        }
101        Verdict::Accepted(fitness)
102    }
103}
104
105/// Best ACCEPTED candidate, EXCLUDING every rejected one from the comparison
106/// (the Ornith "exclude from advantage" rule). `None` if all were rejected.
107/// NaN-safe: rejected non-finite candidates never reach the comparator.
108#[must_use]
109pub fn best_accepted(verdicts: &[Verdict]) -> Option<(usize, f32)> {
110    verdicts
111        .iter()
112        .enumerate()
113        .filter_map(|(i, v)| match v {
114            Verdict::Accepted(f) => Some((i, *f)),
115            Verdict::Rejected(_) => None,
116        })
117        .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
118}
119
120/// Rejection counts by reason: `[non_finite, out_of_bounds, degenerate, judge_veto]`.
121#[must_use]
122pub fn reject_summary(verdicts: &[Verdict]) -> [usize; 4] {
123    let mut c = [0usize; 4];
124    for v in verdicts {
125        if let Verdict::Rejected(r) = v {
126            c[match r {
127                Reject::NonFinite => 0,
128                Reject::OutOfBounds => 1,
129                Reject::Degenerate => 2,
130                Reject::JudgeVeto => 3,
131            }] += 1;
132        }
133    }
134    c
135}
136
137// ---------------------------------------------------------------------------
138// Contamination guard (weight-eft / ADR-198 borrow). The training-data analog of
139// the reward-hacking monitor: training or selecting on instances that appear in
140// the eval holdout is *fake lift*. Enforce strict train/eval instance-ID
141// disjointness — and surface what was excluded, never silently.
142// ---------------------------------------------------------------------------
143
144use std::collections::HashSet;
145
146/// Train IDs that illegally appear in the eval holdout (the contamination set).
147#[must_use]
148pub fn contamination<'a>(
149    train_ids: impl IntoIterator<Item = &'a str>,
150    eval_holdout: &[&str],
151) -> Vec<String> {
152    let holdout: HashSet<&str> = eval_holdout.iter().copied().collect();
153    let mut bad: Vec<String> = train_ids
154        .into_iter()
155        .filter(|id| holdout.contains(id))
156        .map(str::to_string)
157        .collect();
158    bad.sort();
159    bad.dedup();
160    bad
161}
162
163/// `assertTrainEvalDisjoint` analog: `Err(overlapping_ids)` if any training
164/// instance is in the eval holdout, else `Ok(())`. Callers should treat `Err` as
165/// fatal — a contaminated training set produces fake held-out lift.
166///
167/// # Errors
168/// Returns the sorted, de-duplicated overlapping instance IDs.
169pub fn assert_train_eval_disjoint(
170    train_ids: &[&str],
171    eval_holdout: &[&str],
172) -> Result<(), Vec<String>> {
173    let bad = contamination(train_ids.iter().copied(), eval_holdout);
174    if bad.is_empty() {
175        Ok(())
176    } else {
177        Err(bad)
178    }
179}
180
181/// Exporter-style contamination filter: split `items` into
182/// `(kept, excluded_by_holdout)` by their instance id, so the training set is
183/// disjoint from the eval holdout by construction. Pair with the export report
184/// (`excluded.len()`), never drop silently.
185pub fn filter_holdout<T>(
186    items: Vec<T>,
187    id_of: impl Fn(&T) -> &str,
188    eval_holdout: &[&str],
189) -> (Vec<T>, Vec<T>) {
190    let holdout: HashSet<&str> = eval_holdout.iter().copied().collect();
191    let mut kept = Vec::new();
192    let mut excluded = Vec::new();
193    for it in items {
194        if holdout.contains(id_of(&it)) {
195            excluded.push(it);
196        } else {
197            kept.push(it);
198        }
199    }
200    (kept, excluded)
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn non_finite_is_excluded_not_zeroed() {
209        let g = Guard::deterministic();
210        // A NaN-producing candidate must be REJECTED (excluded), not scored 0 —
211        // a 0 could still win if all real candidates score negative.
212        assert_eq!(
213            g.screen(f32::NAN, true, true, false),
214            Verdict::Rejected(Reject::NonFinite)
215        );
216        assert_eq!(
217            g.screen(1.0, false, true, false),
218            Verdict::Rejected(Reject::NonFinite)
219        );
220    }
221
222    #[test]
223    fn out_of_bounds_and_degenerate_rejected() {
224        let g = Guard::deterministic();
225        assert_eq!(
226            g.screen(5.0, true, false, false),
227            Verdict::Rejected(Reject::OutOfBounds)
228        );
229        assert_eq!(
230            g.screen(5.0, true, true, true),
231            Verdict::Rejected(Reject::Degenerate)
232        );
233    }
234
235    #[test]
236    fn best_accepted_excludes_rejects_and_is_nan_safe() {
237        // The hacked candidate (NonFinite) must NOT win even though its raw value
238        // would sort highest; only accepted candidates are compared.
239        let vs = [
240            Verdict::Accepted(-0.5),
241            Verdict::Rejected(Reject::NonFinite),
242            Verdict::Accepted(-0.2),
243            Verdict::Rejected(Reject::Degenerate),
244        ];
245        assert_eq!(best_accepted(&vs), Some((2, -0.2)));
246        assert_eq!(reject_summary(&vs), [1, 0, 1, 0]);
247        // All rejected → no selection (caller must handle, not crash).
248        assert_eq!(
249            best_accepted(&[Verdict::Rejected(Reject::OutOfBounds)]),
250            None
251        );
252    }
253
254    #[test]
255    fn judge_vetoes_but_does_not_set_reward() {
256        struct VetoHigh;
257        impl IntentJudge for VetoHigh {
258            fn veto(&self, fitness: f32) -> bool {
259                fitness > 100.0 // an implausibly-good score smells like gaming
260            }
261        }
262        let g = Guard::with_judge(VetoHigh);
263        assert_eq!(
264            g.screen(999.0, true, true, false),
265            Verdict::Rejected(Reject::JudgeVeto)
266        );
267        assert_eq!(g.screen(1.0, true, true, false), Verdict::Accepted(1.0));
268    }
269
270    #[test]
271    fn disjoint_train_eval_ok_and_contamination_detected() {
272        let eval = ["i-3", "i-9"];
273        assert_eq!(assert_train_eval_disjoint(&["i-1", "i-2"], &eval), Ok(()));
274        // Overlap is fatal and reports the contaminated ids (sorted, deduped).
275        assert_eq!(
276            assert_train_eval_disjoint(&["i-1", "i-9", "i-3", "i-9"], &eval),
277            Err(vec!["i-3".to_string(), "i-9".to_string()])
278        );
279    }
280
281    #[test]
282    fn filter_holdout_partitions_by_id() {
283        let items = vec![("i-1", 10), ("i-3", 20), ("i-5", 30)];
284        let (kept, excluded) = filter_holdout(items, |x| x.0, &["i-3"]);
285        assert_eq!(
286            kept.iter().map(|x| x.0).collect::<Vec<_>>(),
287            vec!["i-1", "i-5"]
288        );
289        assert_eq!(
290            excluded.iter().map(|x| x.0).collect::<Vec<_>>(),
291            vec!["i-3"]
292        );
293        // The kept set is now disjoint from the holdout by construction.
294        let kept_ids: Vec<&str> = kept.iter().map(|x| x.0).collect();
295        assert!(assert_train_eval_disjoint(&kept_ids, &["i-3"]).is_ok());
296    }
297}