1use chrono::{DateTime, Utc};
22use serde::{Deserialize, Serialize};
23
24use crate::precedence::{PolicyVerdict, VerdictKind};
25
26pub const BENCH_AUDIT_ANCHOR: &str = "arXiv:2605.26079";
29
30#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
38pub struct BenchTrustSummary {
39 pub suite_id: String,
41 pub bench_trust_score: f64,
43 pub total_tasks: u32,
45 pub flagged_task_count: u32,
47 pub audited_at: DateTime<Utc>,
49 #[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 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
76pub struct BenchGatedClaim {
77 pub decision_id: String,
80 pub suite_id: String,
83 pub delta_score: f64,
86}
87
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
94pub struct BenchAuditPolicy {
95 pub min_trust_score: f64,
97 pub allow_above: f64,
100 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 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 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 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 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 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 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 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 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 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}