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