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}