use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum CapHitReason {
MonotoneShrinking { trajectory: SmallVec<[u32; 4]> },
Plateau { delta: u32 },
SuspectedOscillation {
period: u8,
trajectory: SmallVec<[u32; 4]>,
},
#[default]
Unknown,
}
impl CapHitReason {
pub fn classify(deltas: &[u32]) -> CapHitReason {
if deltas.len() < 2 {
return CapHitReason::Unknown;
}
if deltas.len() >= 4 {
let n = deltas.len();
let (a0, b0, a1, b1) = (deltas[n - 4], deltas[n - 3], deltas[n - 2], deltas[n - 1]);
if a0 == a1 && b0 == b1 && a0 != b0 {
let tail = deltas
.iter()
.rev()
.take(4)
.rev()
.copied()
.collect::<SmallVec<[u32; 4]>>();
return CapHitReason::SuspectedOscillation {
period: 2,
trajectory: tail,
};
}
}
let last = deltas[deltas.len() - 1];
let prev = deltas[deltas.len() - 2];
if last == prev && last > 0 {
return CapHitReason::Plateau { delta: last };
}
let mut monotone = true;
for w in deltas.windows(2) {
if w[1] > w[0] {
monotone = false;
break;
}
}
if monotone {
let tail = deltas
.iter()
.rev()
.take(4)
.rev()
.copied()
.collect::<SmallVec<[u32; 4]>>();
return CapHitReason::MonotoneShrinking { trajectory: tail };
}
CapHitReason::Unknown
}
pub fn tag(&self) -> &'static str {
match self {
CapHitReason::MonotoneShrinking { .. } => "monotone_shrinking",
CapHitReason::Plateau { .. } => "plateau",
CapHitReason::SuspectedOscillation { .. } => "suspected_oscillation",
CapHitReason::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LossDirection {
Informational,
UnderReport,
OverReport,
Bail,
}
impl LossDirection {
pub fn combine(self, other: LossDirection) -> LossDirection {
self.max(other)
}
pub fn tag(self) -> &'static str {
match self {
LossDirection::Informational => "informational",
LossDirection::UnderReport => "under-report",
LossDirection::OverReport => "over-report",
LossDirection::Bail => "bail",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum EngineNote {
WorklistCapped { iterations: u32 },
OriginsTruncated { dropped: u32 },
InFileFixpointCapped {
iterations: u32,
#[serde(default)]
reason: CapHitReason,
},
CrossFileFixpointCapped {
iterations: u32,
#[serde(default)]
reason: CapHitReason,
},
SsaLoweringBailed { reason: String },
ParseTimeout { timeout_ms: u32 },
PredicateStateWidened,
PathEnvCapped,
InlineCacheReused,
PointsToTruncated { dropped: u32 },
}
impl EngineNote {
pub fn direction(&self) -> LossDirection {
match self {
EngineNote::WorklistCapped { .. } => LossDirection::UnderReport,
EngineNote::OriginsTruncated { .. } => LossDirection::UnderReport,
EngineNote::InFileFixpointCapped { .. } => LossDirection::UnderReport,
EngineNote::CrossFileFixpointCapped { .. } => LossDirection::UnderReport,
EngineNote::SsaLoweringBailed { .. } => LossDirection::Bail,
EngineNote::ParseTimeout { .. } => LossDirection::Bail,
EngineNote::PredicateStateWidened => LossDirection::OverReport,
EngineNote::PathEnvCapped => LossDirection::OverReport,
EngineNote::InlineCacheReused => LossDirection::Informational,
EngineNote::PointsToTruncated { .. } => LossDirection::UnderReport,
}
}
pub fn lowers_confidence(&self) -> bool {
self.direction() != LossDirection::Informational
}
}
pub fn worst_direction(notes: &[EngineNote]) -> Option<LossDirection> {
let mut worst: Option<LossDirection> = None;
for note in notes {
let dir = note.direction();
if dir == LossDirection::Informational {
continue;
}
worst = Some(match worst {
Some(w) => w.combine(dir),
None => dir,
});
}
worst
}
pub fn push_unique(notes: &mut smallvec::SmallVec<[EngineNote; 2]>, note: EngineNote) {
if !notes.iter().any(|n| n == ¬e) {
notes.push(note);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn worklist_capped_lowers_confidence() {
assert!(EngineNote::WorklistCapped { iterations: 10 }.lowers_confidence());
}
#[test]
fn inline_cache_reused_does_not_lower_confidence() {
assert!(!EngineNote::InlineCacheReused.lowers_confidence());
}
#[test]
fn serialization_uses_snake_case_tag() {
let note = EngineNote::WorklistCapped { iterations: 7 };
let s = serde_json::to_string(¬e).unwrap();
assert!(s.contains("\"kind\":\"worklist_capped\""));
assert!(s.contains("\"iterations\":7"));
}
#[test]
fn push_unique_deduplicates() {
let mut v = smallvec::SmallVec::<[EngineNote; 2]>::new();
push_unique(&mut v, EngineNote::WorklistCapped { iterations: 1 });
push_unique(&mut v, EngineNote::WorklistCapped { iterations: 1 });
push_unique(&mut v, EngineNote::OriginsTruncated { dropped: 2 });
assert_eq!(v.len(), 2);
}
#[test]
fn direction_classification_is_exhaustive() {
assert_eq!(
EngineNote::WorklistCapped { iterations: 1 }.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::OriginsTruncated { dropped: 1 }.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::InFileFixpointCapped {
iterations: 1,
reason: CapHitReason::Unknown,
}
.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::CrossFileFixpointCapped {
iterations: 1,
reason: CapHitReason::Unknown,
}
.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::PointsToTruncated { dropped: 1 }.direction(),
LossDirection::UnderReport
);
assert_eq!(
EngineNote::PredicateStateWidened.direction(),
LossDirection::OverReport
);
assert_eq!(
EngineNote::PathEnvCapped.direction(),
LossDirection::OverReport
);
assert_eq!(
EngineNote::SsaLoweringBailed { reason: "x".into() }.direction(),
LossDirection::Bail
);
assert_eq!(
EngineNote::ParseTimeout { timeout_ms: 1 }.direction(),
LossDirection::Bail
);
assert_eq!(
EngineNote::InlineCacheReused.direction(),
LossDirection::Informational
);
}
#[test]
fn loss_direction_order_is_worst_last() {
assert!(LossDirection::Bail > LossDirection::OverReport);
assert!(LossDirection::OverReport > LossDirection::UnderReport);
assert!(LossDirection::UnderReport > LossDirection::Informational);
}
#[test]
fn combine_takes_the_worse_direction() {
assert_eq!(
LossDirection::UnderReport.combine(LossDirection::OverReport),
LossDirection::OverReport
);
assert_eq!(
LossDirection::OverReport.combine(LossDirection::UnderReport),
LossDirection::OverReport
);
assert_eq!(
LossDirection::Bail.combine(LossDirection::OverReport),
LossDirection::Bail
);
assert_eq!(
LossDirection::Informational.combine(LossDirection::Informational),
LossDirection::Informational
);
}
#[test]
fn worst_direction_empty_is_none() {
let notes: Vec<EngineNote> = vec![];
assert_eq!(worst_direction(¬es), None);
}
#[test]
fn worst_direction_informational_only_is_none() {
let notes = vec![EngineNote::InlineCacheReused, EngineNote::InlineCacheReused];
assert_eq!(worst_direction(¬es), None);
}
#[test]
fn worst_direction_mixed_picks_worst() {
let notes = vec![
EngineNote::InlineCacheReused,
EngineNote::WorklistCapped { iterations: 1 },
EngineNote::PredicateStateWidened,
];
assert_eq!(worst_direction(¬es), Some(LossDirection::OverReport));
}
#[test]
fn worst_direction_bail_dominates() {
let notes = vec![
EngineNote::PredicateStateWidened,
EngineNote::ParseTimeout { timeout_ms: 100 },
];
assert_eq!(worst_direction(¬es), Some(LossDirection::Bail));
}
#[test]
fn cap_hit_reason_too_few_samples_unknown() {
assert_eq!(CapHitReason::classify(&[]), CapHitReason::Unknown);
assert_eq!(CapHitReason::classify(&[5]), CapHitReason::Unknown);
}
#[test]
fn cap_hit_reason_detects_period_2_oscillation() {
let result = CapHitReason::classify(&[3, 7, 3, 7]);
match result {
CapHitReason::SuspectedOscillation { period, .. } => assert_eq!(period, 2),
other => panic!("expected SuspectedOscillation; got {other:?}"),
}
}
#[test]
fn cap_hit_reason_detects_plateau() {
let result = CapHitReason::classify(&[10, 5, 5]);
assert_eq!(result, CapHitReason::Plateau { delta: 5 });
}
#[test]
fn cap_hit_reason_plateau_at_zero_is_not_a_plateau() {
let result = CapHitReason::classify(&[3, 0, 0]);
match result {
CapHitReason::MonotoneShrinking { .. } => {}
other => panic!("expected MonotoneShrinking; got {other:?}"),
}
}
#[test]
fn cap_hit_reason_detects_monotone_shrinking() {
let result = CapHitReason::classify(&[10, 7, 4, 2]);
match result {
CapHitReason::MonotoneShrinking { trajectory } => {
assert_eq!(trajectory.as_slice(), &[10, 7, 4, 2]);
}
other => panic!("expected MonotoneShrinking; got {other:?}"),
}
}
#[test]
fn cap_hit_reason_non_monotone_non_oscillating_is_unknown() {
let result = CapHitReason::classify(&[3, 8, 2]);
assert_eq!(result, CapHitReason::Unknown);
}
#[test]
fn cap_hit_reason_serializes_snake_case_tag() {
let r = CapHitReason::Plateau { delta: 4 };
let s = serde_json::to_string(&r).unwrap();
assert!(s.contains("\"kind\":\"plateau\""), "got {s}");
assert!(s.contains("\"delta\":4"), "got {s}");
}
#[test]
fn in_file_fixpoint_capped_serde_backcompat() {
let legacy = r#"{"kind":"in_file_fixpoint_capped","iterations":7}"#;
let parsed: EngineNote = serde_json::from_str(legacy).unwrap();
match parsed {
EngineNote::InFileFixpointCapped { iterations, reason } => {
assert_eq!(iterations, 7);
assert_eq!(reason, CapHitReason::Unknown);
}
other => panic!("expected InFileFixpointCapped; got {other:?}"),
}
}
#[test]
fn cross_file_fixpoint_capped_serde_backcompat() {
let legacy = r#"{"kind":"cross_file_fixpoint_capped","iterations":64}"#;
let parsed: EngineNote = serde_json::from_str(legacy).unwrap();
match parsed {
EngineNote::CrossFileFixpointCapped { iterations, reason } => {
assert_eq!(iterations, 64);
assert_eq!(reason, CapHitReason::Unknown);
}
other => panic!("expected CrossFileFixpointCapped; got {other:?}"),
}
}
#[test]
fn loss_direction_tag_stable() {
assert_eq!(LossDirection::UnderReport.tag(), "under-report");
assert_eq!(LossDirection::OverReport.tag(), "over-report");
assert_eq!(LossDirection::Bail.tag(), "bail");
assert_eq!(LossDirection::Informational.tag(), "informational");
}
}