mnm_core/injection/report.rs
1//! Shared detector-result wire types.
2//!
3//! These are pure data shapes returned by the server's ingest-time scan and its
4//! `/v1/admin/injection/...` endpoints, and embedded in rejected-document
5//! [`crate::ingest::UploadConflict`] detail. They live in `mnm-core` (not the
6//! server) so the CLI can deserialize the admin/ingest responses against the
7//! same contract the server serializes.
8
9use serde::{Deserialize, Serialize};
10
11use crate::injection::pattern::PatternResult;
12
13/// One ≤512-token window the model detector flagged, with its malicious
14/// probability and span (in original input bytes).
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct FlaggedWindow {
17 /// `[start, end)` byte span in the original input.
18 pub span: [usize; 2],
19 /// Model-assigned malicious probability for this window, `0.0..=1.0`.
20 pub score: f64,
21}
22
23/// Result of the hosted model detector for one document.
24///
25/// `available = false` means the model endpoint was unreachable and this leg was
26/// skipped (fail-open) — `score` is then `0.0` and `flagged_windows` empty.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
28pub struct ModelReport {
29 /// Whether the model detector actually ran (false ⇒ skipped/unreachable).
30 pub available: bool,
31 /// Max malicious probability across windows, `0.0..=1.0` (0 when unavailable).
32 pub score: f64,
33 /// Per-window detail for windows whose score met the model threshold.
34 #[serde(default)]
35 pub flagged_windows: Vec<FlaggedWindow>,
36}
37
38/// Final accept/reject verdict for a scanned document.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "lowercase")]
41pub enum Verdict {
42 /// Below the reject threshold — content is ingested.
43 Accept,
44 /// At or above the reject threshold — content is rejected.
45 Reject,
46}
47
48/// Full breakdown of one document's scan.
49///
50/// Carries both detector legs, the blended score, the threshold in force, and the
51/// verdict. Returned verbatim by the admin `score` endpoint and used to assemble
52/// the ingest rejection detail.
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct ScanReport {
55 /// Which detectors actually ran (`"pattern"`, `"model"`).
56 pub detectors_run: Vec<String>,
57 /// Local pattern-detector leg.
58 pub pattern: PatternResult,
59 /// Hosted model-detector leg (absent when the model was not requested).
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub model: Option<ModelReport>,
62 /// Weighted blend of the two legs, `0.0..=1.0`.
63 pub blended_score: f64,
64 /// Reject threshold the blend was compared against.
65 pub reject_threshold: f64,
66 /// Final verdict.
67 pub verdict: Verdict,
68}
69
70#[cfg(test)]
71mod tests {
72 use super::*;
73
74 #[test]
75 fn model_report_default_is_unavailable() {
76 let m = ModelReport::default();
77 assert!(!m.available);
78 assert!((m.score - 0.0).abs() < 1e-12);
79 assert!(m.flagged_windows.is_empty());
80 }
81
82 #[test]
83 fn scan_report_round_trips_through_json() {
84 let r = ScanReport {
85 detectors_run: vec!["pattern".into(), "model".into()],
86 pattern: PatternResult::default(),
87 model: Some(ModelReport {
88 available: true,
89 score: 0.91,
90 flagged_windows: vec![FlaggedWindow { span: [0, 512], score: 0.91 }],
91 }),
92 blended_score: 0.9,
93 reject_threshold: 0.85,
94 verdict: Verdict::Reject,
95 };
96 let s = serde_json::to_string(&r).unwrap();
97 let back: ScanReport = serde_json::from_str(&s).unwrap();
98 assert_eq!(r, back);
99 // Verdict renders lowercase on the wire.
100 assert!(s.contains("\"verdict\":\"reject\""));
101 }
102
103 #[test]
104 fn model_omitted_when_none() {
105 let r = ScanReport {
106 detectors_run: vec!["pattern".into()],
107 pattern: PatternResult::default(),
108 model: None,
109 blended_score: 0.0,
110 reject_threshold: 0.85,
111 verdict: Verdict::Accept,
112 };
113 let s = serde_json::to_string(&r).unwrap();
114 assert!(!s.contains("\"model\""), "model must be omitted when None: {s}");
115 }
116}