1use serde::{Deserialize, Serialize};
10use smallvec::SmallVec;
11
12#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
16#[serde(tag = "kind", rename_all = "snake_case")]
17pub enum CapHitReason {
18 MonotoneShrinking { trajectory: SmallVec<[u32; 4]> },
21 Plateau { delta: u32 },
24 SuspectedOscillation {
27 period: u8,
28 trajectory: SmallVec<[u32; 4]>,
29 },
30 #[default]
32 Unknown,
33}
34
35impl CapHitReason {
36 pub fn classify(deltas: &[u32]) -> CapHitReason {
42 if deltas.len() < 2 {
43 return CapHitReason::Unknown;
44 }
45
46 if deltas.len() >= 4 {
48 let n = deltas.len();
49 let (a0, b0, a1, b1) = (deltas[n - 4], deltas[n - 3], deltas[n - 2], deltas[n - 1]);
50 if a0 == a1 && b0 == b1 && a0 != b0 {
51 let tail = deltas
52 .iter()
53 .rev()
54 .take(4)
55 .rev()
56 .copied()
57 .collect::<SmallVec<[u32; 4]>>();
58 return CapHitReason::SuspectedOscillation {
59 period: 2,
60 trajectory: tail,
61 };
62 }
63 }
64
65 let last = deltas[deltas.len() - 1];
66 let prev = deltas[deltas.len() - 2];
67
68 if last == prev && last > 0 {
70 return CapHitReason::Plateau { delta: last };
71 }
72
73 let mut monotone = true;
77 for w in deltas.windows(2) {
78 if w[1] > w[0] {
79 monotone = false;
80 break;
81 }
82 }
83 if monotone {
84 let tail = deltas
85 .iter()
86 .rev()
87 .take(4)
88 .rev()
89 .copied()
90 .collect::<SmallVec<[u32; 4]>>();
91 return CapHitReason::MonotoneShrinking { trajectory: tail };
92 }
93
94 CapHitReason::Unknown
95 }
96
97 pub fn tag(&self) -> &'static str {
99 match self {
100 CapHitReason::MonotoneShrinking { .. } => "monotone_shrinking",
101 CapHitReason::Plateau { .. } => "plateau",
102 CapHitReason::SuspectedOscillation { .. } => "suspected_oscillation",
103 CapHitReason::Unknown => "unknown",
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
112#[serde(rename_all = "snake_case")]
113pub enum LossDirection {
114 Informational,
116 UnderReport,
119 OverReport,
122 Bail,
125}
126
127impl LossDirection {
128 pub fn combine(self, other: LossDirection) -> LossDirection {
130 self.max(other)
131 }
132
133 pub fn tag(self) -> &'static str {
135 match self {
136 LossDirection::Informational => "informational",
137 LossDirection::UnderReport => "under-report",
138 LossDirection::OverReport => "over-report",
139 LossDirection::Bail => "bail",
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
146#[serde(tag = "kind", rename_all = "snake_case")]
147pub enum EngineNote {
148 WorklistCapped { iterations: u32 },
150 OriginsTruncated { dropped: u32 },
154 InFileFixpointCapped {
156 iterations: u32,
157 #[serde(default)]
158 reason: CapHitReason,
159 },
160 CrossFileFixpointCapped {
162 iterations: u32,
163 #[serde(default)]
164 reason: CapHitReason,
165 },
166 SsaLoweringBailed { reason: String },
168 ParseTimeout { timeout_ms: u32 },
170 PredicateStateWidened,
173 PathEnvCapped,
175 InlineCacheReused,
177 PointsToTruncated { dropped: u32 },
180}
181
182impl EngineNote {
183 pub fn direction(&self) -> LossDirection {
186 match self {
187 EngineNote::WorklistCapped { .. } => LossDirection::UnderReport,
188 EngineNote::OriginsTruncated { .. } => LossDirection::UnderReport,
189 EngineNote::InFileFixpointCapped { .. } => LossDirection::UnderReport,
190 EngineNote::CrossFileFixpointCapped { .. } => LossDirection::UnderReport,
191 EngineNote::SsaLoweringBailed { .. } => LossDirection::Bail,
192 EngineNote::ParseTimeout { .. } => LossDirection::Bail,
193 EngineNote::PredicateStateWidened => LossDirection::OverReport,
194 EngineNote::PathEnvCapped => LossDirection::OverReport,
195 EngineNote::InlineCacheReused => LossDirection::Informational,
196 EngineNote::PointsToTruncated { .. } => LossDirection::UnderReport,
197 }
198 }
199
200 pub fn lowers_confidence(&self) -> bool {
203 self.direction() != LossDirection::Informational
204 }
205}
206
207pub fn worst_direction(notes: &[EngineNote]) -> Option<LossDirection> {
210 let mut worst: Option<LossDirection> = None;
211 for note in notes {
212 let dir = note.direction();
213 if dir == LossDirection::Informational {
214 continue;
215 }
216 worst = Some(match worst {
217 Some(w) => w.combine(dir),
218 None => dir,
219 });
220 }
221 worst
222}
223
224pub fn push_unique(notes: &mut smallvec::SmallVec<[EngineNote; 2]>, note: EngineNote) {
226 if !notes.iter().any(|n| n == ¬e) {
227 notes.push(note);
228 }
229}
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn worklist_capped_lowers_confidence() {
237 assert!(EngineNote::WorklistCapped { iterations: 10 }.lowers_confidence());
238 }
239
240 #[test]
241 fn inline_cache_reused_does_not_lower_confidence() {
242 assert!(!EngineNote::InlineCacheReused.lowers_confidence());
243 }
244
245 #[test]
246 fn serialization_uses_snake_case_tag() {
247 let note = EngineNote::WorklistCapped { iterations: 7 };
248 let s = serde_json::to_string(¬e).unwrap();
249 assert!(s.contains("\"kind\":\"worklist_capped\""));
250 assert!(s.contains("\"iterations\":7"));
251 }
252
253 #[test]
254 fn push_unique_deduplicates() {
255 let mut v = smallvec::SmallVec::<[EngineNote; 2]>::new();
256 push_unique(&mut v, EngineNote::WorklistCapped { iterations: 1 });
257 push_unique(&mut v, EngineNote::WorklistCapped { iterations: 1 });
258 push_unique(&mut v, EngineNote::OriginsTruncated { dropped: 2 });
259 assert_eq!(v.len(), 2);
260 }
261
262 #[test]
263 fn direction_classification_is_exhaustive() {
264 assert_eq!(
266 EngineNote::WorklistCapped { iterations: 1 }.direction(),
267 LossDirection::UnderReport
268 );
269 assert_eq!(
270 EngineNote::OriginsTruncated { dropped: 1 }.direction(),
271 LossDirection::UnderReport
272 );
273 assert_eq!(
274 EngineNote::InFileFixpointCapped {
275 iterations: 1,
276 reason: CapHitReason::Unknown,
277 }
278 .direction(),
279 LossDirection::UnderReport
280 );
281 assert_eq!(
282 EngineNote::CrossFileFixpointCapped {
283 iterations: 1,
284 reason: CapHitReason::Unknown,
285 }
286 .direction(),
287 LossDirection::UnderReport
288 );
289 assert_eq!(
290 EngineNote::PointsToTruncated { dropped: 1 }.direction(),
291 LossDirection::UnderReport
292 );
293
294 assert_eq!(
296 EngineNote::PredicateStateWidened.direction(),
297 LossDirection::OverReport
298 );
299 assert_eq!(
300 EngineNote::PathEnvCapped.direction(),
301 LossDirection::OverReport
302 );
303
304 assert_eq!(
306 EngineNote::SsaLoweringBailed { reason: "x".into() }.direction(),
307 LossDirection::Bail
308 );
309 assert_eq!(
310 EngineNote::ParseTimeout { timeout_ms: 1 }.direction(),
311 LossDirection::Bail
312 );
313
314 assert_eq!(
316 EngineNote::InlineCacheReused.direction(),
317 LossDirection::Informational
318 );
319 }
320
321 #[test]
322 fn loss_direction_order_is_worst_last() {
323 assert!(LossDirection::Bail > LossDirection::OverReport);
326 assert!(LossDirection::OverReport > LossDirection::UnderReport);
327 assert!(LossDirection::UnderReport > LossDirection::Informational);
328 }
329
330 #[test]
331 fn combine_takes_the_worse_direction() {
332 assert_eq!(
333 LossDirection::UnderReport.combine(LossDirection::OverReport),
334 LossDirection::OverReport
335 );
336 assert_eq!(
337 LossDirection::OverReport.combine(LossDirection::UnderReport),
338 LossDirection::OverReport
339 );
340 assert_eq!(
341 LossDirection::Bail.combine(LossDirection::OverReport),
342 LossDirection::Bail
343 );
344 assert_eq!(
345 LossDirection::Informational.combine(LossDirection::Informational),
346 LossDirection::Informational
347 );
348 }
349
350 #[test]
351 fn worst_direction_empty_is_none() {
352 let notes: Vec<EngineNote> = vec![];
353 assert_eq!(worst_direction(¬es), None);
354 }
355
356 #[test]
357 fn worst_direction_informational_only_is_none() {
358 let notes = vec![EngineNote::InlineCacheReused, EngineNote::InlineCacheReused];
359 assert_eq!(worst_direction(¬es), None);
360 }
361
362 #[test]
363 fn worst_direction_mixed_picks_worst() {
364 let notes = vec![
365 EngineNote::InlineCacheReused,
366 EngineNote::WorklistCapped { iterations: 1 },
367 EngineNote::PredicateStateWidened,
368 ];
369 assert_eq!(worst_direction(¬es), Some(LossDirection::OverReport));
370 }
371
372 #[test]
373 fn worst_direction_bail_dominates() {
374 let notes = vec![
375 EngineNote::PredicateStateWidened,
376 EngineNote::ParseTimeout { timeout_ms: 100 },
377 ];
378 assert_eq!(worst_direction(¬es), Some(LossDirection::Bail));
379 }
380
381 #[test]
382 fn cap_hit_reason_too_few_samples_unknown() {
383 assert_eq!(CapHitReason::classify(&[]), CapHitReason::Unknown);
384 assert_eq!(CapHitReason::classify(&[5]), CapHitReason::Unknown);
385 }
386
387 #[test]
388 fn cap_hit_reason_detects_period_2_oscillation() {
389 let result = CapHitReason::classify(&[3, 7, 3, 7]);
390 match result {
391 CapHitReason::SuspectedOscillation { period, .. } => assert_eq!(period, 2),
392 other => panic!("expected SuspectedOscillation; got {other:?}"),
393 }
394 }
395
396 #[test]
397 fn cap_hit_reason_detects_plateau() {
398 let result = CapHitReason::classify(&[10, 5, 5]);
399 assert_eq!(result, CapHitReason::Plateau { delta: 5 });
400 }
401
402 #[test]
403 fn cap_hit_reason_plateau_at_zero_is_not_a_plateau() {
404 let result = CapHitReason::classify(&[3, 0, 0]);
406 match result {
408 CapHitReason::MonotoneShrinking { .. } => {}
409 other => panic!("expected MonotoneShrinking; got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn cap_hit_reason_detects_monotone_shrinking() {
415 let result = CapHitReason::classify(&[10, 7, 4, 2]);
416 match result {
417 CapHitReason::MonotoneShrinking { trajectory } => {
418 assert_eq!(trajectory.as_slice(), &[10, 7, 4, 2]);
419 }
420 other => panic!("expected MonotoneShrinking; got {other:?}"),
421 }
422 }
423
424 #[test]
425 fn cap_hit_reason_non_monotone_non_oscillating_is_unknown() {
426 let result = CapHitReason::classify(&[3, 8, 2]);
428 assert_eq!(result, CapHitReason::Unknown);
429 }
430
431 #[test]
432 fn cap_hit_reason_serializes_snake_case_tag() {
433 let r = CapHitReason::Plateau { delta: 4 };
434 let s = serde_json::to_string(&r).unwrap();
435 assert!(s.contains("\"kind\":\"plateau\""), "got {s}");
436 assert!(s.contains("\"delta\":4"), "got {s}");
437 }
438
439 #[test]
440 fn in_file_fixpoint_capped_serde_backcompat() {
441 let legacy = r#"{"kind":"in_file_fixpoint_capped","iterations":7}"#;
444 let parsed: EngineNote = serde_json::from_str(legacy).unwrap();
445 match parsed {
446 EngineNote::InFileFixpointCapped { iterations, reason } => {
447 assert_eq!(iterations, 7);
448 assert_eq!(reason, CapHitReason::Unknown);
449 }
450 other => panic!("expected InFileFixpointCapped; got {other:?}"),
451 }
452 }
453
454 #[test]
455 fn cross_file_fixpoint_capped_serde_backcompat() {
456 let legacy = r#"{"kind":"cross_file_fixpoint_capped","iterations":64}"#;
457 let parsed: EngineNote = serde_json::from_str(legacy).unwrap();
458 match parsed {
459 EngineNote::CrossFileFixpointCapped { iterations, reason } => {
460 assert_eq!(iterations, 64);
461 assert_eq!(reason, CapHitReason::Unknown);
462 }
463 other => panic!("expected CrossFileFixpointCapped; got {other:?}"),
464 }
465 }
466
467 #[test]
468 fn loss_direction_tag_stable() {
469 assert_eq!(LossDirection::UnderReport.tag(), "under-report");
470 assert_eq!(LossDirection::OverReport.tag(), "over-report");
471 assert_eq!(LossDirection::Bail.tag(), "bail");
472 assert_eq!(LossDirection::Informational.tag(), "informational");
473 }
474}