Skip to main content

fd_policy/
bench_audit.rs

1//! Benchmark-audit policy gate.
2//!
3//! Before an external benchmark delta is allowed to gate a routing /
4//! model-swap decision, the eval plane produces a
5//! [`BenchTrustSummary`](crate::bench_audit::BenchTrustSummary) — the
6//! `bench_trust_score` and the list of flagged task ids derived from the ABA
7//! hygiene audit ([arXiv:2605.26079](https://arxiv.org/abs/2605.26079)). This
8//! module turns that summary into [`PolicyVerdict`]s that flow through the
9//! existing [`crate::precedence::resolve_conflicts`] resolver — so this is a
10//! new *rule source*, not a parallel engine. The deny-by-default invariant
11//! is preserved at the caller.
12//!
13//! Verdict sources emitted here:
14//! - `bench_audit:low_trust_score` — trust below `min_trust_score`. Deny.
15//! - `bench_audit:hitl_band`       — trust in the HITL band. RequiresApproval.
16//! - `bench_audit:within_flagged_margin` — the cited delta is within the
17//!   noise margin implied by the flagged-task ratio. Deny.
18//! - `bench_audit:high_trust_score` — trust at or above `allow_above` and the
19//!   delta is outside the flagged margin. Allow.
20
21use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23
24use crate::precedence::{PolicyVerdict, VerdictKind};
25
26/// Stable arXiv anchor for the audit methodology. Surfaced on the wire so
27/// audit consumers can cite the paper without re-reading docstrings.
28pub const BENCH_AUDIT_ANCHOR: &str = "arXiv:2605.26079";
29
30/// Summary of a bench audit produced by the Python eval plane.
31///
32/// Field semantics mirror `fd_evals.bench_audit.BenchAuditReport` — only the
33/// subset the Rust policy plane needs to decide is included here. The full
34/// per-task flag list lives in the eval-plane report; this struct intentionally
35/// stays narrow so it can be denormalised onto a `runs` / `eval_runs` column
36/// without bloating the row.
37#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct BenchTrustSummary {
39    /// Identifier of the eval suite that was audited.
40    pub suite_id: String,
41    /// Aggregate hygiene score in `[0.0, 1.0]`. Higher = more trustworthy.
42    pub bench_trust_score: f64,
43    /// Total tasks the auditor scanned in the suite.
44    pub total_tasks: u32,
45    /// Number of distinct tasks with at least one hygiene flag.
46    pub flagged_task_count: u32,
47    /// When the audit was produced.
48    pub audited_at: DateTime<Utc>,
49    /// arXiv anchor of the audit methodology (defaults to
50    /// [`BENCH_AUDIT_ANCHOR`]).
51    #[serde(default = "default_anchor")]
52    pub anchor: String,
53}
54
55fn default_anchor() -> String {
56    BENCH_AUDIT_ANCHOR.to_string()
57}
58
59impl BenchTrustSummary {
60    /// Share of suite tasks with at least one hygiene flag. The policy plane
61    /// uses this as the noise margin around a benchmark delta — if the cited
62    /// improvement is within `flagged_task_ratio`, it's not distinguishable
63    /// from grading noise and the gate must deny.
64    pub fn flagged_task_ratio(&self) -> f64 {
65        if self.total_tasks == 0 {
66            return 0.0;
67        }
68        f64::from(self.flagged_task_count) / f64::from(self.total_tasks)
69    }
70}
71
72/// A routing / model-swap claim that cites an external benchmark delta. The
73/// policy plane compares this against the [`BenchTrustSummary`] for the suite
74/// the delta came from.
75#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct BenchGatedClaim {
77    /// Stable id of the routing decision the operator wants to take —
78    /// surfaces in the audit trail.
79    pub decision_id: String,
80    /// Eval suite the delta is sourced from. Must match
81    /// [`BenchTrustSummary::suite_id`] or this gate will deny on mismatch.
82    pub suite_id: String,
83    /// Score delta the operator is using to justify the routing change. In
84    /// the same `[0, 1]` units as `bench_trust_score`.
85    pub delta_score: f64,
86}
87
88/// Configuration for [`BenchAuditPolicy::evaluate`].
89///
90/// Defaults are deliberately conservative: a clean suite (`bench_trust_score >=
91/// 0.85`) backed by a delta larger than the flagged-task noise floor will
92/// produce an `Allow` verdict; otherwise the gate denies or asks for HITL.
93#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct BenchAuditPolicy {
95    /// Trust below this is an unconditional deny.
96    pub min_trust_score: f64,
97    /// Trust at or above this — and a delta outside the flagged margin — is
98    /// the only path to an explicit Allow.
99    pub allow_above: f64,
100    /// Multiplicative slack applied to the flagged-task ratio when computing
101    /// the noise margin. `delta_score <= flagged_task_ratio * margin_slack`
102    /// denies as "within flagged margin". `1.0` means use the ratio as-is.
103    pub margin_slack: f64,
104}
105
106impl Default for BenchAuditPolicy {
107    fn default() -> Self {
108        Self {
109            min_trust_score: 0.70,
110            allow_above: 0.85,
111            margin_slack: 1.0,
112        }
113    }
114}
115
116impl BenchAuditPolicy {
117    /// Evaluate a single bench-gated claim against the audit summary.
118    ///
119    /// Returns a bag of [`PolicyVerdict`]s ready to be fed into
120    /// [`crate::precedence::resolve_conflicts`]. The caller wires the
121    /// resolution into a [`crate::decision::PolicyDecision`] with the standard
122    /// [`crate::trace::DecisionTrace`] — see
123    /// [`crate::engine::PolicyEngine::evaluate_bench_gated_decision`].
124    ///
125    /// An empty result vector means "the audit plane has nothing to say" —
126    /// the caller's deny-by-default fallback is what then applies.
127    pub fn evaluate(
128        &self,
129        claim: &BenchGatedClaim,
130        summary: &BenchTrustSummary,
131    ) -> Vec<PolicyVerdict> {
132        let mut verdicts = Vec::new();
133
134        if claim.suite_id != summary.suite_id {
135            verdicts.push(PolicyVerdict::new(
136                VerdictKind::Deny,
137                "bench_audit:suite_mismatch",
138                format!(
139                    "claim references suite '{}' but audit summary is for '{}'",
140                    claim.suite_id, summary.suite_id
141                ),
142            ));
143            return verdicts;
144        }
145
146        if summary.bench_trust_score < self.min_trust_score {
147            verdicts.push(PolicyVerdict::new(
148                VerdictKind::Deny,
149                "bench_audit:low_trust_score",
150                format!(
151                    "bench_trust_score {:.4} below min_trust_score {:.4} ({})",
152                    summary.bench_trust_score, self.min_trust_score, summary.anchor
153                ),
154            ));
155        }
156
157        // HITL band: between min_trust_score and allow_above. Operators get a
158        // RequiresApproval rather than an Allow so a borderline suite can
159        // still drive a routing change with explicit human sign-off.
160        if summary.bench_trust_score >= self.min_trust_score
161            && summary.bench_trust_score < self.allow_above
162        {
163            verdicts.push(PolicyVerdict::new(
164                VerdictKind::RequiresApproval,
165                "bench_audit:hitl_band",
166                format!(
167                    "bench_trust_score {:.4} in HITL band [{:.4}, {:.4}); routing requires approval",
168                    summary.bench_trust_score, self.min_trust_score, self.allow_above
169                ),
170            ));
171        }
172
173        // Even a trusted suite can't justify a routing change whose claimed
174        // delta is inside the flagged-task noise floor. We deny that case
175        // explicitly so the trace records *why* — operators see
176        // `bench_audit:within_flagged_margin` instead of a vague deny.
177        let margin = summary.flagged_task_ratio() * self.margin_slack;
178        if claim.delta_score.abs() <= margin && summary.flagged_task_count > 0 {
179            verdicts.push(PolicyVerdict::new(
180                VerdictKind::Deny,
181                "bench_audit:within_flagged_margin",
182                format!(
183                    "delta {:+.4} within flagged-task margin {:.4} ({} flagged / {} total)",
184                    claim.delta_score, margin, summary.flagged_task_count, summary.total_tasks
185                ),
186            ));
187        }
188
189        // Explicit Allow verdict — emitted whenever the suite passes the
190        // `allow_above` bar, regardless of the margin check. We deliberately
191        // record both verdicts so the [`crate::trace::DecisionTrace`] shows
192        // the high-trust Allow as overridden by the margin Deny when both
193        // fire. Operators reading the audit log see "the suite was clean BUT
194        // the delta was inside the noise floor" rather than just a bare deny.
195        if summary.bench_trust_score >= self.allow_above {
196            verdicts.push(PolicyVerdict::new(
197                VerdictKind::Allow,
198                "bench_audit:high_trust_score",
199                format!(
200                    "bench_trust_score {:.4} ≥ allow_above {:.4} (delta {:+.4}, margin {:.4})",
201                    summary.bench_trust_score, self.allow_above, claim.delta_score, margin
202                ),
203            ));
204        }
205
206        verdicts
207    }
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213    use crate::precedence::resolve_conflicts;
214
215    fn ts(secs: i64) -> DateTime<Utc> {
216        DateTime::<Utc>::from_timestamp(secs, 0).expect("valid timestamp")
217    }
218
219    fn clean_summary() -> BenchTrustSummary {
220        BenchTrustSummary {
221            suite_id: "smoke".into(),
222            bench_trust_score: 0.92,
223            total_tasks: 20,
224            flagged_task_count: 0,
225            audited_at: ts(1_700_000_000),
226            anchor: BENCH_AUDIT_ANCHOR.into(),
227        }
228    }
229
230    fn dirty_summary() -> BenchTrustSummary {
231        BenchTrustSummary {
232            suite_id: "smoke".into(),
233            bench_trust_score: 0.40,
234            total_tasks: 20,
235            flagged_task_count: 12,
236            audited_at: ts(1_700_000_000),
237            anchor: BENCH_AUDIT_ANCHOR.into(),
238        }
239    }
240
241    fn claim(delta: f64) -> BenchGatedClaim {
242        BenchGatedClaim {
243            decision_id: "dec_001".into(),
244            suite_id: "smoke".into(),
245            delta_score: delta,
246        }
247    }
248
249    #[test]
250    fn clean_suite_with_strong_delta_emits_allow_only() {
251        let policy = BenchAuditPolicy::default();
252        let verdicts = policy.evaluate(&claim(0.05), &clean_summary());
253
254        assert_eq!(verdicts.len(), 1);
255        assert_eq!(verdicts[0].kind, VerdictKind::Allow);
256        assert_eq!(verdicts[0].source, "bench_audit:high_trust_score");
257    }
258
259    #[test]
260    fn low_trust_emits_low_trust_score_deny() {
261        let policy = BenchAuditPolicy::default();
262        let verdicts = policy.evaluate(&claim(0.20), &dirty_summary());
263
264        // Low-trust deny + within-margin deny + (no Allow because trust is low).
265        let kinds: Vec<VerdictKind> = verdicts.iter().map(|v| v.kind).collect();
266        assert!(kinds.contains(&VerdictKind::Deny));
267        assert!(
268            verdicts
269                .iter()
270                .any(|v| v.source == "bench_audit:low_trust_score"),
271            "expected a bench_audit:low_trust_score verdict, got {verdicts:?}"
272        );
273        let resolved = resolve_conflicts(verdicts);
274        assert_eq!(
275            resolved.winning.expect("verdict survives precedence").kind,
276            VerdictKind::Deny,
277        );
278    }
279
280    #[test]
281    fn mid_band_trust_emits_hitl_band_approval() {
282        let policy = BenchAuditPolicy::default();
283        let mid_summary = BenchTrustSummary {
284            bench_trust_score: 0.75,
285            ..clean_summary()
286        };
287        let verdicts = policy.evaluate(&claim(0.10), &mid_summary);
288
289        assert!(
290            verdicts
291                .iter()
292                .any(|v| v.kind == VerdictKind::RequiresApproval
293                    && v.source == "bench_audit:hitl_band"),
294            "expected hitl_band approval verdict, got {verdicts:?}"
295        );
296        // Mid-band must not also emit an explicit Allow — that's the whole
297        // point of the HITL guard.
298        assert!(
299            !verdicts.iter().any(|v| v.kind == VerdictKind::Allow),
300            "mid-band must not also emit an Allow"
301        );
302    }
303
304    #[test]
305    fn within_flagged_margin_denies_even_for_clean_suite() {
306        let policy = BenchAuditPolicy::default();
307        // 5/20 flagged → 0.25 noise floor. A 0.20 delta sits inside it.
308        let summary = BenchTrustSummary {
309            flagged_task_count: 5,
310            ..clean_summary()
311        };
312        let verdicts = policy.evaluate(&claim(0.20), &summary);
313
314        assert!(
315            verdicts
316                .iter()
317                .any(|v| v.kind == VerdictKind::Deny
318                    && v.source == "bench_audit:within_flagged_margin"),
319            "expected within_flagged_margin deny, got {verdicts:?}"
320        );
321        // A clean suite *also* emits the high-trust Allow so the trace shows
322        // the override. Precedence then picks the Deny.
323        assert!(
324            verdicts
325                .iter()
326                .any(|v| v.kind == VerdictKind::Allow
327                    && v.source == "bench_audit:high_trust_score"),
328            "clean suite must still emit the high_trust_score Allow for trace fidelity"
329        );
330        let resolved = resolve_conflicts(verdicts);
331        assert_eq!(
332            resolved.winning.expect("verdict survives precedence").kind,
333            VerdictKind::Deny,
334            "Deny must win precedence over high_trust_score Allow",
335        );
336    }
337
338    #[test]
339    fn suite_id_mismatch_short_circuits_to_deny() {
340        let policy = BenchAuditPolicy::default();
341        let summary = clean_summary();
342        let mismatched = BenchGatedClaim {
343            decision_id: "dec_001".into(),
344            suite_id: "regression".into(),
345            delta_score: 0.30,
346        };
347        let verdicts = policy.evaluate(&mismatched, &summary);
348
349        assert_eq!(verdicts.len(), 1);
350        assert_eq!(verdicts[0].kind, VerdictKind::Deny);
351        assert_eq!(verdicts[0].source, "bench_audit:suite_mismatch");
352    }
353
354    #[test]
355    fn no_flagged_tasks_skips_margin_check() {
356        let policy = BenchAuditPolicy::default();
357        // Tiny delta but zero flagged tasks → margin check should NOT fire.
358        let summary = BenchTrustSummary {
359            flagged_task_count: 0,
360            ..clean_summary()
361        };
362        let verdicts = policy.evaluate(&claim(0.001), &summary);
363
364        assert!(
365            !verdicts
366                .iter()
367                .any(|v| v.source == "bench_audit:within_flagged_margin"),
368            "margin check must skip when nothing is flagged"
369        );
370        assert!(verdicts.iter().any(|v| v.kind == VerdictKind::Allow));
371    }
372
373    #[test]
374    fn flagged_task_ratio_is_zero_for_empty_suite() {
375        let summary = BenchTrustSummary {
376            total_tasks: 0,
377            flagged_task_count: 0,
378            ..clean_summary()
379        };
380        assert_eq!(summary.flagged_task_ratio(), 0.0);
381    }
382
383    #[test]
384    fn anchor_round_trips_through_serde() {
385        let summary = clean_summary();
386        let json = serde_json::to_string(&summary).expect("serialise");
387        assert!(json.contains("\"anchor\":\"arXiv:2605.26079\""));
388        let parsed: BenchTrustSummary = serde_json::from_str(&json).expect("deserialise");
389        assert_eq!(parsed.anchor, BENCH_AUDIT_ANCHOR);
390    }
391}