Skip to main content

tailtriage_analyzer/
evidence.rs

1use std::collections::HashSet;
2
3use serde::Serialize;
4use tailtriage_core::Run;
5
6use super::{duplicate_completed_request_ids, orphan_event_request_ids, AnalyzeOptions};
7
8const DUPLICATE_REQUEST_ID_LIMITATION: &str =
9    "Duplicate completed request_id values make request-scoped attribution ambiguous.";
10const ORPHAN_REQUEST_SCOPED_EVENT_LIMITATION: &str =
11    "Stage or queue evidence with no matching completed request_id cannot be reliably attributed.";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
14#[serde(rename_all = "snake_case")]
15/// Overall evidence-quality level for this capture.
16pub enum EvidenceQualityLevel {
17    /// Evidence coverage is sufficient for a strong triage interpretation.
18    Strong,
19    /// Evidence coverage has important limitations.
20    Partial,
21    /// Evidence coverage is too sparse/truncated for stable interpretation.
22    Weak,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "snake_case")]
27/// Coverage status for one signal family.
28pub enum SignalCoverageStatus {
29    /// Signal family has usable data.
30    Present,
31    /// Signal family is absent.
32    Missing,
33    /// Signal family exists but has limited interpretability.
34    Partial,
35    /// Signal family had capture drops due to truncation.
36    Truncated,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize)]
40/// Structured capture-coverage and interpretation-quality summary.
41pub struct EvidenceQuality {
42    /// Number of completed request events captured.
43    pub request_count: usize,
44    /// Number of queue events captured.
45    pub queue_event_count: usize,
46    /// Number of stage events captured.
47    pub stage_event_count: usize,
48    /// Number of runtime snapshots captured.
49    pub runtime_snapshot_count: usize,
50    /// Number of in-flight snapshots captured.
51    pub inflight_snapshot_count: usize,
52    /// Coverage status for request events.
53    pub requests: SignalCoverageStatus,
54    /// Coverage status for queue events.
55    pub queues: SignalCoverageStatus,
56    /// Coverage status for stage events.
57    pub stages: SignalCoverageStatus,
58    /// Coverage status for runtime snapshots.
59    pub runtime_snapshots: SignalCoverageStatus,
60    /// Coverage status for in-flight snapshots.
61    pub inflight_snapshots: SignalCoverageStatus,
62    /// Whether any capture truncation limit was hit.
63    pub truncated: bool,
64    /// Number of dropped request events.
65    pub dropped_requests: u64,
66    /// Number of dropped stage events.
67    pub dropped_stages: u64,
68    /// Number of dropped queue events.
69    pub dropped_queues: u64,
70    /// Number of dropped in-flight snapshots.
71    pub dropped_inflight_snapshots: u64,
72    /// Number of dropped runtime snapshots.
73    pub dropped_runtime_snapshots: u64,
74    /// Overall quality level for this report's evidence coverage.
75    pub quality: EvidenceQualityLevel,
76    /// Interpretation limitations inferred from coverage/truncation.
77    pub limitations: Vec<String>,
78}
79
80pub(super) fn evidence_quality(run: &Run, options: &AnalyzeOptions) -> EvidenceQuality {
81    let requests = request_status(run, options);
82    let queues = family_status(run.queues.is_empty(), run.truncation.dropped_queues);
83    let stages = family_status(run.stages.is_empty(), run.truncation.dropped_stages);
84    let runtime_snapshots = runtime_status(run);
85    let inflight_snapshots = family_status(
86        run.inflight.is_empty(),
87        run.truncation.dropped_inflight_snapshots,
88    );
89    let limitations = evidence_limitations(run, queues, stages, runtime_snapshots, options);
90    let non_request_truncated = matches!(queues, SignalCoverageStatus::Truncated)
91        || matches!(stages, SignalCoverageStatus::Truncated)
92        || matches!(runtime_snapshots, SignalCoverageStatus::Truncated)
93        || matches!(inflight_snapshots, SignalCoverageStatus::Truncated);
94    let explanatory_present =
95        !run.queues.is_empty() || !run.stages.is_empty() || !run.runtime_snapshots.is_empty();
96    let quality = if run.requests.is_empty()
97        || run.requests.len() < options.evidence.low_completed_request_threshold
98        || run.truncation.dropped_requests > 0
99        || !explanatory_present
100    {
101        EvidenceQualityLevel::Weak
102    } else if non_request_truncated
103        || (run.queues.is_empty() && run.stages.is_empty())
104        || runtime_snapshots == SignalCoverageStatus::Partial
105    {
106        EvidenceQualityLevel::Partial
107    } else {
108        EvidenceQualityLevel::Strong
109    };
110
111    EvidenceQuality {
112        request_count: run.requests.len(),
113        queue_event_count: run.queues.len(),
114        stage_event_count: run.stages.len(),
115        runtime_snapshot_count: run.runtime_snapshots.len(),
116        inflight_snapshot_count: run.inflight.len(),
117        requests,
118        queues,
119        stages,
120        runtime_snapshots,
121        inflight_snapshots,
122        truncated: run.truncation.is_truncated() || run.truncation.limits_hit,
123        dropped_requests: run.truncation.dropped_requests,
124        dropped_stages: run.truncation.dropped_stages,
125        dropped_queues: run.truncation.dropped_queues,
126        dropped_inflight_snapshots: run.truncation.dropped_inflight_snapshots,
127        dropped_runtime_snapshots: run.truncation.dropped_runtime_snapshots,
128        quality,
129        limitations,
130    }
131}
132
133fn request_status(run: &Run, options: &AnalyzeOptions) -> SignalCoverageStatus {
134    if run.requests.is_empty() {
135        SignalCoverageStatus::Missing
136    } else if run.truncation.dropped_requests > 0 {
137        SignalCoverageStatus::Truncated
138    } else if run.requests.len() < options.evidence.low_completed_request_threshold {
139        SignalCoverageStatus::Partial
140    } else {
141        SignalCoverageStatus::Present
142    }
143}
144
145fn family_status(is_empty: bool, dropped: u64) -> SignalCoverageStatus {
146    if dropped > 0 {
147        SignalCoverageStatus::Truncated
148    } else if is_empty {
149        SignalCoverageStatus::Missing
150    } else {
151        SignalCoverageStatus::Present
152    }
153}
154
155fn runtime_status(run: &Run) -> SignalCoverageStatus {
156    if run.truncation.dropped_runtime_snapshots > 0 {
157        SignalCoverageStatus::Truncated
158    } else if run.runtime_snapshots.is_empty() {
159        SignalCoverageStatus::Missing
160    } else if run
161        .runtime_snapshots
162        .iter()
163        .all(|s| s.blocking_queue_depth.is_none())
164        || run
165            .runtime_snapshots
166            .iter()
167            .all(|s| s.local_queue_depth.is_none())
168        || run
169            .runtime_snapshots
170            .iter()
171            .all(|s| s.global_queue_depth.is_none())
172    {
173        SignalCoverageStatus::Partial
174    } else {
175        SignalCoverageStatus::Present
176    }
177}
178
179fn evidence_limitations(
180    run: &Run,
181    queues: SignalCoverageStatus,
182    stages: SignalCoverageStatus,
183    runtime_snapshots: SignalCoverageStatus,
184    options: &AnalyzeOptions,
185) -> Vec<String> {
186    let mut limitations = Vec::new();
187    if run.requests.len() < options.evidence.low_completed_request_threshold {
188        limitations
189            .push("Low completed-request count can make suspect ranking unstable.".to_string());
190    }
191    if matches!(
192        queues,
193        SignalCoverageStatus::Missing | SignalCoverageStatus::Truncated
194    ) && matches!(
195        stages,
196        SignalCoverageStatus::Missing | SignalCoverageStatus::Truncated
197    ) {
198        limitations.push("Queue and stage instrumentation are both unavailable, limiting application vs downstream interpretation.".to_string());
199    }
200    if run.runtime_snapshots.is_empty() {
201        limitations.push("Runtime snapshots are missing, limiting executor and blocking-pressure interpretation.".to_string());
202    } else if runtime_snapshots == SignalCoverageStatus::Partial {
203        limitations.push("Runtime snapshots have missing queue-depth fields, limiting executor vs blocking differentiation.".to_string());
204    }
205    if run.truncation.is_truncated() || run.truncation.limits_hit {
206        limitations.push(
207            "Capture truncation dropped evidence and can reduce diagnosis completeness."
208                .to_string(),
209        );
210    }
211    if !duplicate_completed_request_ids(run).is_empty() {
212        limitations.push(DUPLICATE_REQUEST_ID_LIMITATION.to_string());
213    }
214
215    let completed_ids = run
216        .requests
217        .iter()
218        .map(|request| request.request_id.as_str())
219        .collect::<HashSet<_>>();
220    let has_orphan_stage = !orphan_event_request_ids(&completed_ids, &run.stages, |stage| {
221        stage.request_id.as_str()
222    })
223    .is_empty();
224    let has_orphan_queue = !orphan_event_request_ids(&completed_ids, &run.queues, |queue| {
225        queue.request_id.as_str()
226    })
227    .is_empty();
228    if has_orphan_stage || has_orphan_queue {
229        limitations.push(ORPHAN_REQUEST_SCOPED_EVENT_LIMITATION.to_string());
230    }
231    limitations
232}
233
234pub(super) fn truncation_warnings(run: &Run) -> Vec<String> {
235    let mut warnings = Vec::new();
236    if run.truncation.limits_hit || run.truncation.is_truncated() {
237        warnings.push("Capture limits were hit during this run; dropped evidence can reduce diagnosis completeness and confidence.".to_string());
238    }
239    if run.truncation.dropped_requests > 0 {
240        warnings.push(format!("Capture truncated requests: dropped {} request events after reaching the configured max_requests limit. This dropped evidence can reduce diagnosis completeness and confidence.", run.truncation.dropped_requests));
241    }
242    if run.truncation.dropped_stages > 0 {
243        warnings.push(format!("Capture truncated stages: dropped {} stage events after reaching the configured max_stages limit. This dropped evidence can reduce diagnosis completeness and confidence.", run.truncation.dropped_stages));
244    }
245    if run.truncation.dropped_queues > 0 {
246        warnings.push(format!("Capture truncated queues: dropped {} queue events after reaching the configured max_queues limit. This dropped evidence can reduce diagnosis completeness and confidence.", run.truncation.dropped_queues));
247    }
248    if run.truncation.dropped_inflight_snapshots > 0 {
249        warnings.push(format!("Capture truncated in-flight snapshots: dropped {} entries after reaching max_inflight_snapshots. This dropped evidence can reduce diagnosis completeness and confidence.", run.truncation.dropped_inflight_snapshots));
250    }
251    if run.truncation.dropped_runtime_snapshots > 0 {
252        warnings.push(format!("Capture truncated runtime snapshots: dropped {} entries after reaching max_runtime_snapshots. This dropped evidence can reduce diagnosis completeness and confidence.", run.truncation.dropped_runtime_snapshots));
253    }
254    warnings
255}