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")]
15pub enum EvidenceQualityLevel {
17 Strong,
19 Partial,
21 Weak,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub enum SignalCoverageStatus {
29 Present,
31 Missing,
33 Partial,
35 Truncated,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize)]
40pub struct EvidenceQuality {
42 pub request_count: usize,
44 pub queue_event_count: usize,
46 pub stage_event_count: usize,
48 pub runtime_snapshot_count: usize,
50 pub inflight_snapshot_count: usize,
52 pub requests: SignalCoverageStatus,
54 pub queues: SignalCoverageStatus,
56 pub stages: SignalCoverageStatus,
58 pub runtime_snapshots: SignalCoverageStatus,
60 pub inflight_snapshots: SignalCoverageStatus,
62 pub truncated: bool,
64 pub dropped_requests: u64,
66 pub dropped_stages: u64,
68 pub dropped_queues: u64,
70 pub dropped_inflight_snapshots: u64,
72 pub dropped_runtime_snapshots: u64,
74 pub quality: EvidenceQualityLevel,
76 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}