Skip to main content

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}