use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum Verdict {
Keep,
Tune,
Retire,
}
impl Verdict {
pub(crate) fn as_str(self) -> &'static str {
match self {
Verdict::Keep => "keep",
Verdict::Tune => "tune",
Verdict::Retire => "retire",
}
}
fn rank(self) -> u8 {
match self {
Verdict::Keep => 0,
Verdict::Tune => 1,
Verdict::Retire => 2,
}
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct Thresholds {
pub(crate) min_precision: f64,
pub(crate) tune_max_precision: f64,
pub(crate) retire_max_precision: f64,
pub(crate) min_volume: u64,
pub(crate) stale_window_days: u64,
pub(crate) max_fp_ratio: f64,
}
#[derive(Debug, Clone)]
pub(crate) struct VerdictInput {
pub(crate) precision_proxy: Option<f64>,
pub(crate) volume: u64,
pub(crate) live_fp_ratio: Option<f64>,
pub(crate) last_fired_age_days: Option<u64>,
pub(crate) sole_coverage: bool,
pub(crate) sole_techniques: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct Outcome {
pub(crate) verdict: Verdict,
pub(crate) reason: String,
}
pub(crate) fn decide(input: &VerdictInput, t: &Thresholds) -> Outcome {
if input.volume == 0 {
return guard_sole_coverage(
Verdict::Retire,
"no fires across the backtest corpus and the metrics window (dead rule)".to_string(),
input,
);
}
if let Some(r) = input.live_fp_ratio
&& r > t.max_fp_ratio
{
return Outcome {
verdict: Verdict::Tune,
reason: format!(
"live false-positive ratio {r:.2} exceeds the {:.2} ceiling",
t.max_fp_ratio
),
};
}
match input.precision_proxy {
Some(p) if p < t.retire_max_precision => guard_sole_coverage(
Verdict::Retire,
format!(
"precision proxy {p:.2} below the {:.2} retire floor",
t.retire_max_precision
),
input,
),
Some(p) if p < t.min_precision => {
let band = if p < t.tune_max_precision {
"review band"
} else {
"below the keep floor"
};
Outcome {
verdict: Verdict::Tune,
reason: format!("precision proxy {p:.2} in the {band}"),
}
}
Some(p) => keep_gate(p, input, t),
None => {
if input.live_fp_ratio.is_some_and(|r| r <= t.max_fp_ratio)
&& input.volume >= t.min_volume
&& !is_stale(input, t)
{
Outcome {
verdict: Verdict::Keep,
reason: "no corpus precision signal; kept on a clean live false-positive ratio"
.to_string(),
}
} else {
Outcome {
verdict: Verdict::Tune,
reason: "fires without a corpus precision signal to score; review".to_string(),
}
}
}
}
}
fn keep_gate(p: f64, input: &VerdictInput, t: &Thresholds) -> Outcome {
if input.volume < t.min_volume {
return Outcome {
verdict: Verdict::Tune,
reason: format!(
"precision proxy {p:.2} is healthy but volume {} is below the {} minimum",
input.volume, t.min_volume
),
};
}
if let Some(age) = input.last_fired_age_days
&& age > t.stale_window_days
{
return Outcome {
verdict: Verdict::Tune,
reason: format!(
"precision proxy {p:.2} is healthy but the rule last fired {age}d ago, beyond the {}d window",
t.stale_window_days
),
};
}
Outcome {
verdict: Verdict::Keep,
reason: format!(
"precision proxy {p:.2} at or above the {:.2} keep floor and firing within the window",
t.min_precision
),
}
}
fn is_stale(input: &VerdictInput, t: &Thresholds) -> bool {
input
.last_fired_age_days
.is_some_and(|age| age > t.stale_window_days)
}
fn guard_sole_coverage(verdict: Verdict, reason: String, input: &VerdictInput) -> Outcome {
if verdict == Verdict::Retire && input.sole_coverage {
let techniques = if input.sole_techniques.is_empty() {
String::new()
} else {
format!(" ({})", input.sole_techniques.join(", "))
};
return Outcome {
verdict: Verdict::Tune,
reason: format!(
"{reason}; retained as the sole ATT&CK coverage{techniques}, tune rather than retire"
),
};
}
Outcome { verdict, reason }
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FailOn {
None,
Tune,
Retire,
}
impl FailOn {
pub(crate) fn parse(s: &str) -> Option<Self> {
match s.trim().to_ascii_lowercase().as_str() {
"none" => Some(FailOn::None),
"tune" => Some(FailOn::Tune),
"retire" => Some(FailOn::Retire),
_ => None,
}
}
pub(crate) fn as_str(self) -> &'static str {
match self {
FailOn::None => "none",
FailOn::Tune => "tune",
FailOn::Retire => "retire",
}
}
pub(crate) fn triggers(self, verdict: Verdict) -> bool {
match self {
FailOn::None => false,
FailOn::Tune => verdict.rank() >= Verdict::Tune.rank(),
FailOn::Retire => verdict.rank() >= Verdict::Retire.rank(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn thresholds() -> Thresholds {
Thresholds {
min_precision: 0.80,
tune_max_precision: 0.50,
retire_max_precision: 0.10,
min_volume: 1,
stale_window_days: 30,
max_fp_ratio: 0.50,
}
}
fn input(precision: Option<f64>, volume: u64) -> VerdictInput {
VerdictInput {
precision_proxy: precision,
volume,
live_fp_ratio: None,
last_fired_age_days: None,
sole_coverage: false,
sole_techniques: Vec::new(),
}
}
#[test]
fn keep_band_clean_rule() {
let o = decide(&input(Some(1.0), 10), &thresholds());
assert_eq!(o.verdict, Verdict::Keep);
}
#[test]
fn tune_review_band() {
let o = decide(&input(Some(0.30), 10), &thresholds());
assert_eq!(o.verdict, Verdict::Tune);
assert!(o.reason.contains("review band"), "{}", o.reason);
}
#[test]
fn tune_below_keep_floor_band() {
let o = decide(&input(Some(0.65), 10), &thresholds());
assert_eq!(o.verdict, Verdict::Tune);
assert!(o.reason.contains("below the keep floor"), "{}", o.reason);
}
#[test]
fn retire_below_floor() {
let o = decide(&input(Some(0.05), 10), &thresholds());
assert_eq!(o.verdict, Verdict::Retire);
}
#[test]
fn retire_dead_rule_zero_volume() {
let o = decide(&input(Some(1.0), 0), &thresholds());
assert_eq!(o.verdict, Verdict::Retire);
assert!(o.reason.contains("dead rule"), "{}", o.reason);
}
#[test]
fn sole_coverage_downgrades_retire_to_tune() {
let mut i = input(Some(0.0), 10);
i.sole_coverage = true;
i.sole_techniques = vec!["T1059".to_string()];
let o = decide(&i, &thresholds());
assert_eq!(o.verdict, Verdict::Tune);
assert!(o.reason.contains("sole ATT&CK coverage"), "{}", o.reason);
assert!(o.reason.contains("T1059"), "{}", o.reason);
}
#[test]
fn sole_coverage_downgrades_dead_rule_too() {
let mut i = input(Some(1.0), 0);
i.sole_coverage = true;
i.sole_techniques = vec!["T1003".to_string()];
let o = decide(&i, &thresholds());
assert_eq!(o.verdict, Verdict::Tune);
assert!(o.reason.contains("sole ATT&CK coverage"), "{}", o.reason);
}
#[test]
fn live_fp_ratio_over_ceiling_forces_tune() {
let mut i = input(Some(1.0), 10);
i.live_fp_ratio = Some(0.75);
let o = decide(&i, &thresholds());
assert_eq!(o.verdict, Verdict::Tune);
assert!(
o.reason.contains("live false-positive ratio"),
"{}",
o.reason
);
}
#[test]
fn stale_rule_is_tuned_not_kept() {
let mut i = input(Some(1.0), 10);
i.last_fired_age_days = Some(120);
let o = decide(&i, &thresholds());
assert_eq!(o.verdict, Verdict::Tune);
assert!(o.reason.contains("beyond the 30d window"), "{}", o.reason);
}
#[test]
fn no_corpus_signal_with_clean_live_ratio_keeps() {
let mut i = input(None, 5);
i.live_fp_ratio = Some(0.1);
let o = decide(&i, &thresholds());
assert_eq!(o.verdict, Verdict::Keep);
}
#[test]
fn no_corpus_signal_without_triage_tunes() {
let o = decide(&input(None, 5), &thresholds());
assert_eq!(o.verdict, Verdict::Tune);
}
#[test]
fn fail_on_policy_thresholds() {
assert!(!FailOn::None.triggers(Verdict::Retire));
assert!(FailOn::Tune.triggers(Verdict::Tune));
assert!(FailOn::Tune.triggers(Verdict::Retire));
assert!(!FailOn::Tune.triggers(Verdict::Keep));
assert!(FailOn::Retire.triggers(Verdict::Retire));
assert!(!FailOn::Retire.triggers(Verdict::Tune));
}
}