Skip to main content

irtt_stats/
lib.rs

1//! Statistics aggregation for `irtt-client` events.
2//!
3//! [`StatsCollector`] consumes [`irtt_client::ClientEvent`] values through
4//! [`StatsCollector::process`] and produces a cumulative [`Snapshot`], plus
5//! optional count- and time-based rolling snapshots, for reporting and
6//! integration code.
7//!
8//! # Retention modes
9//!
10//! [`StatsConfig`] selects between two retention modes via [`SampleMode`].
11//! [`StatsConfig::finite`] ([`SampleMode::Exact`]) retains an exact sample per
12//! timing metric, so every metric reports an exact median once it has
13//! samples; retention grows with the number of probes processed. This is the
14//! default and suits a run with a known, bounded duration.
15//! [`StatsConfig::continuous`] ([`SampleMode::RunningOnly`]) keeps only
16//! running statistics — no exact median — and bounds its adjacent-sequence
17//! IPDV tracking, so memory stays bounded for a long-running or unbounded
18//! session. An enabled time-based rolling window has no item cap and is not
19//! hard-bounded; it expires only when new events arrive. Rolling snapshots
20//! always use running statistics regardless of this setting.
21//!
22//! See `examples/` in the repository for a runnable comparison of both modes.
23
24#![forbid(unsafe_code)]
25#![warn(missing_docs)]
26#![warn(rustdoc::missing_crate_level_docs)]
27
28/// Cargo package version of this compiled library.
29pub const VERSION: &str = env!("CARGO_PKG_VERSION");
30
31use std::time::Duration;
32
33use irtt_client::ClientEvent;
34
35mod core;
36mod ipdv;
37mod loss;
38mod normalization;
39mod retention;
40mod rolling;
41
42use core::CoreStats;
43pub use loss::LossStats;
44#[doc(inline)]
45pub use measurement_stats::TimeStats;
46use normalization::normalize_event;
47use rolling::RollingEvents;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50/// Configuration for statistics collection.
51pub struct StatsConfig {
52    /// How timing samples are retained, and so whether the cumulative snapshot
53    /// reports medians.
54    pub samples: SampleMode,
55    /// Number of recent normalized events retained for count-based rolling snapshots.
56    ///
57    /// A successful probe usually contributes two normalized events: one send event
58    /// and one unique reply event.
59    pub rolling_count: Option<usize>,
60    /// Time span of recent normalized events retained for time-based rolling snapshots.
61    ///
62    /// This window has no item cap, even in continuous mode. Its memory depends
63    /// on event volume inside the interval. Expiry occurs on insertion, anchored
64    /// at the maximum observed normalized event timestamp, not when a snapshot
65    /// is read. Events exactly at the cutoff are included. Backdated events
66    /// cannot move the anchor backwards or restore expired history. Retained
67    /// events are replayed in arrival order.
68    pub rolling_time: Option<Duration>,
69    /// Whether matched late replies contribute measurements.
70    pub late_replies: LateReplyMode,
71}
72
73impl StatsConfig {
74    /// Returns the default configuration for finite runs.
75    ///
76    /// Finite mode retains exact samples for every timing metric, so each of
77    /// them reports a median in the cumulative snapshot once it has samples,
78    /// and keeps unbounded adjacent-sequence IPDV tracking so late adjacent
79    /// replies can still complete IPDV pairs.
80    ///
81    /// # Example
82    ///
83    /// ```
84    /// use irtt_stats::{StatsCollector, StatsConfig};
85    ///
86    /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
87    /// # use std::time::{Duration, Instant, SystemTime};
88    /// # use irtt_client::{ClientEvent, ClientTimestamp};
89    /// #
90    /// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
91    /// #     let sent_at = ClientTimestamp {
92    /// #         mono: Instant::now() + Duration::from_millis(at_ms),
93    /// #         wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
94    /// #     };
95    /// #     ClientEvent::EchoSent {
96    /// #         seq,
97    /// #         remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
98    /// #         scheduled_at: Some(sent_at.mono),
99    /// #         sent_at,
100    /// #         bytes: 64,
101    /// #         send_call: Duration::from_micros(send_call_us),
102    /// #         timer_error: Some(Duration::from_micros(2)),
103    /// #     }
104    /// # }
105    /// #
106    /// let mut collector = StatsCollector::new(StatsConfig::finite());
107    /// for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
108    ///     collector.process(&sent(seq as u32, seq as u64 * 10, send_call_us));
109    /// }
110    ///
111    /// let snapshot = collector.snapshot();
112    /// // Finite mode retains exact samples, so the send-call metric reports an
113    /// // exact median once it has samples: the middle of the sorted
114    /// // 10, 20, 30 µs values, not their mean.
115    /// assert_eq!(snapshot.send_call.count, 3);
116    /// assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
117    /// ```
118    pub fn finite() -> Self {
119        Self {
120            samples: SampleMode::Exact,
121            rolling_count: None,
122            rolling_time: None,
123            late_replies: LateReplyMode::Measure,
124        }
125    }
126
127    /// Returns a configuration for long-running use.
128    ///
129    /// Continuous mode uses running statistics, retains no exact samples so no
130    /// timing metric reports a median, and bounds adjacent-sequence IPDV
131    /// tracking for long-running sessions. An explicitly enabled `rolling_time`
132    /// window is not hard-bounded; use `rolling_count` for bounded event storage.
133    ///
134    /// # Example
135    ///
136    /// ```
137    /// use irtt_stats::{StatsCollector, StatsConfig};
138    ///
139    /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
140    /// # use std::time::{Duration, Instant, SystemTime};
141    /// # use irtt_client::{ClientEvent, ClientTimestamp};
142    /// #
143    /// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
144    /// #     let sent_at = ClientTimestamp {
145    /// #         mono: Instant::now() + Duration::from_millis(at_ms),
146    /// #         wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
147    /// #     };
148    /// #     ClientEvent::EchoSent {
149    /// #         seq,
150    /// #         remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
151    /// #         scheduled_at: Some(sent_at.mono),
152    /// #         sent_at,
153    /// #         bytes: 64,
154    /// #         send_call: Duration::from_micros(send_call_us),
155    /// #         timer_error: Some(Duration::from_micros(2)),
156    /// #     }
157    /// # }
158    /// #
159    /// let mut collector = StatsCollector::new(StatsConfig::continuous());
160    /// for seq in 0..3 {
161    ///     collector.process(&sent(seq, u64::from(seq) * 10, 10));
162    /// }
163    ///
164    /// let snapshot = collector.snapshot();
165    /// // Continuous mode still tracks running statistics — count, mean, min,
166    /// // and max — but retains no exact samples, so it reports no median.
167    /// assert_eq!(snapshot.send_call.count, 3);
168    /// assert!(snapshot.send_call.mean_ns > 0.0);
169    /// assert_eq!(snapshot.send_call.median_ns, None);
170    /// ```
171    pub fn continuous() -> Self {
172        Self {
173            samples: SampleMode::RunningOnly,
174            rolling_count: None,
175            rolling_time: None,
176            late_replies: LateReplyMode::Measure,
177        }
178    }
179
180    /// Returns an approximate estimate of the bytes a [`StatsCollector`] with
181    /// this configuration retains after processing `probe_count` probes.
182    ///
183    /// This is a planning aid — it exists so callers can warn about a run
184    /// before starting it — and deliberately not allocator accounting. It is
185    /// derived from the structures this crate retains and a fixed
186    /// capacity-headroom factor for the amortized-doubling containers those
187    /// structures use, so it errs toward overestimating.
188    ///
189    /// # What is included
190    ///
191    /// - Exact timing samples, for every metric that retains them.
192    /// - Adjacent-sequence IPDV tracking state.
193    /// - Count-based rolling storage, when [`StatsConfig::rolling_count`] is
194    ///   set, assuming a probe contributes one send event and one unique
195    ///   reply event.
196    ///
197    /// # What is excluded
198    ///
199    /// - Time-based rolling storage. [`StatsConfig::rolling_time`] retains
200    ///   whatever arrives inside its window, which cannot be bounded from a
201    ///   probe count alone without assuming traffic timing, so it does not
202    ///   contribute to this estimate.
203    /// - Everything outside this crate: sockets, client session state,
204    ///   rendered output, and caller structures.
205    ///
206    /// # Sample mode
207    ///
208    /// [`SampleMode::Exact`] retains one value per timing metric per ordinary
209    /// successful probe and keeps unbounded IPDV state, so the estimate grows
210    /// with `probe_count`. [`SampleMode::RunningOnly`] retains no exact
211    /// samples and bounds its IPDV state, so the estimate reaches a ceiling
212    /// and stops growing.
213    ///
214    /// # Optional measurements
215    ///
216    /// Adjusted RTT, one-way delay, send/receive IPDV, and server processing
217    /// are only measured when the negotiated session supplies the necessary
218    /// timestamps. The estimate assumes all of them are available, which is
219    /// the upper bound.
220    ///
221    /// # Saturation
222    ///
223    /// The result is deterministic and computed with saturating arithmetic, so
224    /// an enormous `probe_count` saturates at [`u64::MAX`] rather than
225    /// wrapping or panicking.
226    ///
227    /// # Example
228    ///
229    /// ```
230    /// use irtt_stats::StatsConfig;
231    ///
232    /// // Finite retention grows with the probe count...
233    /// let finite = StatsConfig::finite();
234    /// assert!(
235    ///     finite.estimated_retained_bytes(1_000_000)
236    ///         > finite.estimated_retained_bytes(1_000)
237    /// );
238    ///
239    /// // ...while continuous retention is bounded, so a far longer run is no
240    /// // more expensive.
241    /// let continuous = StatsConfig::continuous();
242    /// assert_eq!(
243    ///     continuous.estimated_retained_bytes(1_000_000),
244    ///     continuous.estimated_retained_bytes(10_000_000),
245    /// );
246    /// assert!(
247    ///     continuous.estimated_retained_bytes(1_000_000)
248    ///         < finite.estimated_retained_bytes(1_000_000)
249    /// );
250    ///
251    /// // Enormous probe counts saturate rather than wrapping or panicking.
252    /// assert_eq!(finite.estimated_retained_bytes(u64::MAX), u64::MAX);
253    /// ```
254    pub fn estimated_retained_bytes(&self, probe_count: u64) -> u64 {
255        retention::estimated_retained_bytes(self, probe_count)
256    }
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
260/// Whether a matched late reply contributes measurements.
261///
262/// This is a measurement policy, not a classification. A late reply that the
263/// client matched to retained send state is a late unique reply in both modes,
264/// and a late reply that could not be matched remains an untracked late reply
265/// in both modes. Only what a matched late reply is allowed to measure differs,
266/// so a caller that treats late replies as diagnostics does not have to degrade
267/// the event itself to suppress its timing contribution.
268pub enum LateReplyMode {
269    /// Matched late replies contribute timing, one-way, server-processing, and
270    /// IPDV measurements like any other unique reply.
271    #[default]
272    Measure,
273    /// Matched late replies are counted but measure nothing.
274    ///
275    /// Packet, byte, late, and server-reported receive counters still update,
276    /// so the reply stays visible as a late unique reply. Timing metrics,
277    /// one-way and server-processing measurements, and IPDV do not update, and
278    /// [`EventStatsUpdate::contributed_sample`] is `false`.
279    CountOnly,
280}
281
282impl Default for StatsConfig {
283    fn default() -> Self {
284        Self::finite()
285    }
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289/// Controls whether exact timing samples are retained.
290///
291/// This governs the cumulative snapshot from [`StatsCollector::snapshot`],
292/// where the choice applies uniformly to every timing metric, so that snapshot
293/// never mixes median-capable and median-incapable timing metrics. Rolling
294/// snapshots are recomputed with running statistics whatever this is set to;
295/// see [`StatsCollector::rolling_count`].
296pub enum SampleMode {
297    /// Keep only running statistics; medians are not available for any timing
298    /// metric, and retention stays bounded for long-running sessions.
299    RunningOnly,
300    /// Retain exact samples for every timing metric, so each one reports a
301    /// median once it has samples in the cumulative snapshot. Retention grows
302    /// with the probe count.
303    Exact,
304}
305
306#[derive(Debug, Clone, PartialEq)]
307/// Stateful statistics collector for `irtt-client` events.
308///
309/// A collector maintains cumulative statistics and, when configured, rolling
310/// windows. Rolling snapshots are recomputed from retained normalized events.
311///
312/// # Example
313///
314/// Feed a short stream of `irtt-client` events to a finite-mode collector and
315/// read back a cumulative snapshot:
316///
317/// ```
318/// use irtt_stats::{StatsCollector, StatsConfig};
319///
320/// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
321/// # use std::time::{Duration, Instant, SystemTime};
322/// # use irtt_client::{ClientEvent, ClientTimestamp, RttSample, SignedDuration};
323/// #
324/// # /// A synthetic reply; a real session delivers these as `ClientEvent`s.
325/// # fn reply(seq: u32, rtt_ms: u64) -> ClientEvent {
326/// #     let remote = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112);
327/// #     let sent_at = ClientTimestamp {
328/// #         mono: Instant::now() + Duration::from_millis(u64::from(seq) * 10),
329/// #         wall: SystemTime::UNIX_EPOCH + Duration::from_millis(u64::from(seq) * 10),
330/// #     };
331/// #     let raw = Duration::from_millis(rtt_ms);
332/// #     ClientEvent::EchoReply {
333/// #         seq,
334/// #         remote,
335/// #         sent_at,
336/// #         received_at: ClientTimestamp {
337/// #             mono: sent_at.mono + raw,
338/// #             wall: sent_at.wall + raw,
339/// #         },
340/// #         rtt: RttSample {
341/// #             raw,
342/// #             adjusted: None,
343/// #             effective: SignedDuration::from_duration(raw),
344/// #         },
345/// #         server_timing: None,
346/// #         one_way: None,
347/// #         received_stats: None,
348/// #         bytes: 64,
349/// #         packet_meta: irtt_client::PacketMeta::default(),
350/// #     }
351/// # }
352/// #
353/// let mut collector = StatsCollector::new(StatsConfig::finite());
354/// for (seq, rtt_ms) in [10_u64, 30, 20].into_iter().enumerate() {
355///     collector.process(&reply(seq as u32, rtt_ms));
356/// }
357///
358/// let snapshot = collector.snapshot();
359/// assert_eq!(snapshot.events.echo_replies, 3);
360/// assert_eq!(snapshot.rtt.primary.count, 3);
361/// // Finite mode retains exact samples, so the round-trip metric reports an
362/// // exact median: the middle of the sorted 10, 20, 30 ms samples.
363/// assert_eq!(snapshot.rtt.primary.median_ns, Some(20_000_000.0));
364/// ```
365pub struct StatsCollector {
366    cumulative: CoreStats,
367    rolling: RollingEvents,
368}
369
370impl StatsCollector {
371    /// Creates a collector with the supplied configuration.
372    pub fn new(config: StatsConfig) -> Self {
373        Self {
374            cumulative: CoreStats::new(config.samples, config.late_replies),
375            rolling: RollingEvents::new(config),
376        }
377    }
378
379    /// Processes one client event and returns the per-event statistics update.
380    ///
381    /// Updates currently report whether the event contributed a unique reply
382    /// timing sample and any adjacent-sequence IPDV pairs completed by this
383    /// event.
384    pub fn process(&mut self, event: &ClientEvent) -> EventStatsUpdate {
385        let Some(stats_event) = normalize_event(event) else {
386            return EventStatsUpdate::default();
387        };
388
389        let update = self.cumulative.apply(stats_event.clone());
390        self.rolling.push(stats_event);
391        update
392    }
393
394    /// Returns a snapshot of all events processed by this collector.
395    ///
396    /// This is the snapshot [`StatsConfig::samples`] applies to, so under
397    /// [`SampleMode::Exact`] every timing metric here reports a median once it
398    /// has samples.
399    pub fn snapshot(&self) -> Snapshot {
400        self.cumulative.snapshot()
401    }
402
403    /// Returns the configured count-based rolling snapshot, if enabled.
404    ///
405    /// The snapshot is recomputed from the retained rolling events using
406    /// running statistics only, whatever [`StatsConfig::samples`] is set to,
407    /// so its timing metrics report no median. A rolling window is a bounded
408    /// recent view rather than the run's retained history.
409    ///
410    /// # Example
411    ///
412    /// ```
413    /// use irtt_stats::{StatsCollector, StatsConfig};
414    ///
415    /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
416    /// # use std::time::{Duration, Instant, SystemTime};
417    /// # use irtt_client::{ClientEvent, ClientTimestamp};
418    /// #
419    /// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
420    /// #     let sent_at = ClientTimestamp {
421    /// #         mono: Instant::now() + Duration::from_millis(at_ms),
422    /// #         wall: SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
423    /// #     };
424    /// #     ClientEvent::EchoSent {
425    /// #         seq,
426    /// #         remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
427    /// #         scheduled_at: Some(sent_at.mono),
428    /// #         sent_at,
429    /// #         bytes: 64,
430    /// #         send_call: Duration::from_micros(send_call_us),
431    /// #         timer_error: Some(Duration::from_micros(2)),
432    /// #     }
433    /// # }
434    /// #
435    /// let mut collector = StatsCollector::new(StatsConfig {
436    ///     rolling_count: Some(2),
437    ///     ..StatsConfig::finite()
438    /// });
439    /// collector.process(&sent(0, 0, 10));
440    /// collector.process(&sent(1, 10, 10));
441    /// collector.process(&sent(2, 20, 10));
442    ///
443    /// // The window keeps only the last two normalized events...
444    /// let rolling = collector.rolling_count().unwrap();
445    /// assert_eq!(rolling.packets.packets_sent, 2);
446    /// // ...and is recomputed with running statistics, so it reports no median
447    /// // even though the collector retains exact samples for its cumulative
448    /// // snapshot.
449    /// assert_eq!(rolling.send_call.median_ns, None);
450    ///
451    /// // The cumulative snapshot still sees every event.
452    /// let cumulative = collector.snapshot();
453    /// assert_eq!(cumulative.packets.packets_sent, 3);
454    /// assert_eq!(cumulative.send_call.median_ns, Some(10_000.0));
455    /// ```
456    pub fn rolling_count(&self) -> Option<Snapshot> {
457        self.rolling.count_snapshot()
458    }
459
460    /// Returns the configured time-based rolling snapshot, if enabled.
461    ///
462    /// Like [`StatsCollector::rolling_count`], this is recomputed with running
463    /// statistics only and reports no medians.
464    ///
465    /// # Example
466    ///
467    /// ```
468    /// use std::time::Duration;
469    ///
470    /// use irtt_stats::{StatsCollector, StatsConfig};
471    ///
472    /// # use std::net::{IpAddr, Ipv4Addr, SocketAddr};
473    /// # use std::time::Instant;
474    /// # use irtt_client::{ClientEvent, ClientTimestamp};
475    /// #
476    /// # fn sent(seq: u32, at_ms: u64, send_call_us: u64) -> ClientEvent {
477    /// #     let sent_at = ClientTimestamp {
478    /// #         mono: Instant::now() + Duration::from_millis(at_ms),
479    /// #         wall: std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(at_ms),
480    /// #     };
481    /// #     ClientEvent::EchoSent {
482    /// #         seq,
483    /// #         remote: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 2112),
484    /// #         scheduled_at: Some(sent_at.mono),
485    /// #         sent_at,
486    /// #         bytes: 64,
487    /// #         send_call: Duration::from_micros(send_call_us),
488    /// #         timer_error: Some(Duration::from_micros(2)),
489    /// #     }
490    /// # }
491    /// #
492    /// let mut collector = StatsCollector::new(StatsConfig {
493    ///     rolling_time: Some(Duration::from_millis(15)),
494    ///     ..StatsConfig::finite()
495    /// });
496    /// collector.process(&sent(0, 0, 10));
497    /// collector.process(&sent(1, 10, 10));
498    /// collector.process(&sent(2, 30, 10));
499    ///
500    /// // A 15 ms window anchored at the latest event (30 ms) retains only the
501    /// // events from the last 15 ms — here just the final send.
502    /// let rolling = collector.rolling_time().unwrap();
503    /// assert_eq!(rolling.packets.packets_sent, 1);
504    /// ```
505    pub fn rolling_time(&self) -> Option<Snapshot> {
506        self.rolling.time_snapshot()
507    }
508}
509
510#[derive(Debug, Clone, PartialEq, Eq, Default)]
511/// Per-event statistics produced by [`StatsCollector::process`].
512pub struct EventStatsUpdate {
513    /// Whether the processed event contributed a unique reply timing sample.
514    pub contributed_sample: bool,
515    /// Adjacent-sequence IPDV pairs completed by the processed event.
516    pub ipdv_pairs: Vec<IpdvPairUpdate>,
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq)]
520/// Adjacent-sequence IPDV pair completed by a processed event.
521pub struct IpdvPairUpdate {
522    /// Sequence number of the earlier reply in the adjacent pair.
523    pub previous_seq: u32,
524    /// Sequence number of the later reply in the adjacent pair.
525    pub current_seq: u32,
526    /// Signed round-trip delay change (current minus previous) in nanoseconds.
527    pub rtt_ipdv_ns: i128,
528    /// Signed send-side delay change in nanoseconds, when available for both replies.
529    pub send_ipdv_ns: Option<i128>,
530    /// Signed receive-side delay change in nanoseconds, when available for both replies.
531    pub receive_ipdv_ns: Option<i128>,
532    /// Absolute round-trip IPDV between the adjacent replies.
533    pub rtt_ipdv: Duration,
534    /// Absolute send-side IPDV when send one-way delay is available for both replies.
535    pub send_ipdv: Option<Duration>,
536    /// Absolute receive-side IPDV when receive one-way delay is available for both replies.
537    pub receive_ipdv: Option<Duration>,
538}
539
540#[derive(Debug, Clone, PartialEq)]
541/// Point-in-time statistics summary.
542pub struct Snapshot {
543    /// Event counters grouped by event class.
544    pub events: EventCounts,
545    /// Packet and byte counters.
546    pub packets: PacketCounts,
547    /// Packet loss, duplicate, and late-packet percentages.
548    pub loss: LossStats,
549    /// Duration of send calls, in nanoseconds.
550    pub send_call: TimeStats,
551    /// Sender scheduling error, in nanoseconds.
552    pub timer_error: TimeStats,
553    /// Round-trip timing statistics.
554    pub rtt: RttStats,
555    /// Inter-packet delay variation statistics.
556    pub ipdv: IpdvStats,
557    /// One-way delay statistics.
558    pub one_way_delay: OneWayDelayStats,
559    /// Server processing time statistics.
560    pub server_processing: ServerProcessingStats,
561}
562
563#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
564/// Counts of normalized client events.
565pub struct EventCounts {
566    /// Probe send events processed.
567    pub sent_events: u64,
568    /// On-time unique echo replies processed.
569    pub echo_replies: u64,
570    /// Late unique echo replies processed.
571    pub late_unique_replies: u64,
572    /// Duplicate echo replies processed.
573    pub duplicate_replies: u64,
574    /// Loss events processed.
575    pub loss_events: u64,
576    /// Warning events processed.
577    pub warning_events: u64,
578    /// Late replies that could not be matched to retained in-flight state.
579    ///
580    /// These replies are observed on the socket, but cannot contribute RTT,
581    /// one-way delay, server-processing, or IPDV samples.
582    pub untracked_late_replies: u64,
583}
584
585#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
586/// Packet, byte, and server-reported receive counters.
587pub struct PacketCounts {
588    /// Probe packets sent by the local client.
589    pub packets_sent: u64,
590    /// Reply packets observed by the local client.
591    ///
592    /// This includes unique measurable replies, duplicate replies, and late
593    /// replies that cannot be matched to retained in-flight state.
594    pub packets_received: u64,
595    /// Unique replies that can contribute timing measurements.
596    pub unique_replies: u64,
597    /// Duplicate replies received by the local client.
598    pub duplicates: u64,
599    /// Late reply packets received by the local client, including untracked-late replies.
600    pub late_packets: u64,
601    /// Probe bytes sent by the local client.
602    pub bytes_sent: u64,
603    /// Reply bytes observed by the local client.
604    ///
605    /// This includes bytes from unique measurable replies, duplicate replies,
606    /// and untracked-late replies. Per-category byte counts are not currently
607    /// exposed separately.
608    pub bytes_received: u64,
609    /// Highest cumulative server-reported packets received, when available.
610    ///
611    /// Rolling snapshots also preserve this raw cumulative value from retained
612    /// replies. Their directional loss uses an interval-relative increase
613    /// instead; see [`LossStats`] for baseline and availability semantics.
614    pub server_packets_received: Option<u64>,
615    /// Raw server-reported receive window from the most recently processed
616    /// reply that carried one, when available.
617    ///
618    /// The value is stored exactly as the server reported it and is not
619    /// interpreted by this crate. It is a bounded bitmap of recent server
620    /// receive history rather than a fixed 64-packet loss mask, so a value of
621    /// `0x1` means no earlier history is represented and not that the previous
622    /// 63 packets were lost. Replies are aggregated in local arrival order, so
623    /// a late reply can leave an older window here than an already-processed
624    /// reply carried.
625    pub server_received_window: Option<u64>,
626}
627
628#[derive(Debug, Clone, PartialEq)]
629/// Round-trip time statistics.
630pub struct RttStats {
631    /// Effective RTT used for primary RTT reporting and IPDV input.
632    pub primary: TimeStats,
633    /// Client-observed raw RTT.
634    pub raw: TimeStats,
635    /// RTT adjusted for server processing time when available.
636    pub adjusted: TimeStats,
637}
638
639#[derive(Debug, Clone, PartialEq)]
640/// Inter-packet delay variation statistics.
641pub struct IpdvStats {
642    /// Round-trip IPDV.
643    pub round_trip: TimeStats,
644    /// Send-side IPDV, when send one-way delay is available.
645    pub send: TimeStats,
646    /// Receive-side IPDV, when receive one-way delay is available.
647    pub receive: TimeStats,
648}
649
650#[derive(Debug, Clone, PartialEq)]
651/// One-way delay statistics.
652pub struct OneWayDelayStats {
653    /// Client-to-server delay.
654    pub send_delay: TimeStats,
655    /// Server-to-client delay.
656    pub receive_delay: TimeStats,
657}
658
659#[derive(Debug, Clone, PartialEq)]
660/// Server processing time statistics.
661pub struct ServerProcessingStats {
662    /// Time spent processing a probe at the server.
663    pub processing: TimeStats,
664}