1use crate::stats::{estimate_jitter, percentile, required_samples};
7
8const TYPICAL_LEAK_SECONDS: f64 = 1e-6;
16
17pub struct DoctorReport {
18 pub target: String,
19 pub samples: usize,
20 pub median_rtt_seconds: f64,
21 pub jitter_seconds: f64,
22 pub packet_loss_ratio: f64,
23 pub recommended_samples: u64,
24}
25
26#[derive(Debug, PartialEq, Eq)]
27pub enum JitterLevel {
28 Low,
29 Medium,
30 High,
31}
32
33impl JitterLevel {
34 fn label(&self) -> &'static str {
35 match self {
36 JitterLevel::Low => "low",
37 JitterLevel::Medium => "medium",
38 JitterLevel::High => "high",
39 }
40 }
41}
42
43fn classify_jitter(jitter_seconds: f64) -> JitterLevel {
48 if jitter_seconds < 0.001 {
49 JitterLevel::Low
50 } else if jitter_seconds < 0.010 {
51 JitterLevel::Medium
52 } else {
53 JitterLevel::High
54 }
55}
56
57#[derive(Debug, PartialEq, Eq)]
58pub enum EnvironmentQuality {
59 Good,
60 Fair,
61 Poor,
62}
63
64impl EnvironmentQuality {
65 fn label(&self) -> &'static str {
66 match self {
67 EnvironmentQuality::Good => "GOOD",
68 EnvironmentQuality::Fair => "FAIR",
69 EnvironmentQuality::Poor => "POOR",
70 }
71 }
72}
73
74impl DoctorReport {
75 pub fn from_measurements(target: String, latencies: &[f64], failures: usize) -> Self {
76 let samples = latencies.len();
77 let attempted = samples + failures;
78 let packet_loss_ratio = if attempted > 0 {
79 failures as f64 / attempted as f64
80 } else {
81 1.0
82 };
83
84 let mut sorted = latencies.to_vec();
85 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
86 let median_rtt_seconds = if sorted.is_empty() {
87 0.0
88 } else {
89 percentile(&sorted, 50.0)
90 };
91
92 let jitter_seconds = estimate_jitter(latencies);
97
98 let recommended_samples = if jitter_seconds > 0.0 {
99 required_samples(jitter_seconds, TYPICAL_LEAK_SECONDS, 0.95)
100 } else {
101 0
102 };
103
104 Self {
105 target,
106 samples,
107 median_rtt_seconds,
108 jitter_seconds,
109 packet_loss_ratio,
110 recommended_samples,
111 }
112 }
113
114 fn quality(&self) -> EnvironmentQuality {
115 let jitter = classify_jitter(self.jitter_seconds);
116 if self.packet_loss_ratio > 0.05 {
117 return EnvironmentQuality::Poor;
118 }
119 match jitter {
120 JitterLevel::Low => EnvironmentQuality::Good,
121 JitterLevel::Medium => {
122 if self.packet_loss_ratio > 0.0 {
123 EnvironmentQuality::Fair
124 } else {
125 EnvironmentQuality::Good
126 }
127 }
128 JitterLevel::High => EnvironmentQuality::Poor,
129 }
130 }
131
132 pub fn render(&self) -> String {
133 let jitter_level = classify_jitter(self.jitter_seconds);
134 let quality = self.quality();
135
136 let mut out = String::new();
137 out.push_str(&"─".repeat(48));
138 out.push_str("\nsidecheck doctor\n");
139 out.push_str(&"─".repeat(48));
140 out.push_str(&format!("\n\ntarget {}\n", self.target));
141 out.push_str(&format!("samples {}\n\n", self.samples));
142 out.push_str(&format!(
143 "median RTT: {:.1} ms\n",
144 self.median_rtt_seconds * 1000.0
145 ));
146 out.push_str(&format!(
147 "RTT jitter: {:.2} ms ({})\n",
148 self.jitter_seconds * 1000.0,
149 jitter_level.label()
150 ));
151 out.push_str(&format!(
152 "packet loss: {:.1}%\n",
153 self.packet_loss_ratio * 100.0
154 ));
155 if self.recommended_samples > 50_000_000 {
156 out.push_str("recommended samples: effectively unbounded — a ~1μs leak is not\n reliably measurable over this path\n");
157 } else {
158 out.push_str(&format!(
159 "recommended samples: ~{} (to reliably detect a ~1μs leak, the\n rough scale of a real == vs constant-time bug)\n",
160 self.recommended_samples
161 ));
162 }
163 out.push_str(&format!("environment quality: {}\n", quality.label()));
164
165 out.push_str(&"─".repeat(48));
166 out.push('\n');
167 match quality {
168 EnvironmentQuality::Good => {
169 out.push_str("this path looks suitable for timing measurement. proceed with `sidecheck check`.\n");
170 }
171 EnvironmentQuality::Fair => {
172 out.push_str(
173 "usable, but expect to need a larger sample size for small leaks. \
174 `sidecheck check` will size the run automatically based on what it finds.\n",
175 );
176 }
177 EnvironmentQuality::Poor => {
178 out.push_str(
179 "this path is too noisy/lossy for reliable timing measurement of a \
180 realistic-sized leak. this is a property of the network path, not proof \
181 the endpoint is safe. test from a lower-latency vantage point (same \
182 LAN/datacenter as the target, or from the server itself) if you can.\n",
183 );
184 }
185 }
186
187 out
188 }
189}