refactor(stats): retain exact finite timing samples

`SampleMode::Exact` retained exact samples for eight of the eleven public
timing metrics. Send call, timer error, and server processing always built
their `TimeMetric` with retention disabled, so a finite run reported no
median for them even though they are the same public `TimeStats` type and
appear in the same summary table with a Median column.

Derive one `retain_exact` decision from the sample mode in `CoreStats::new`
and apply it to every timing metric, so exact mode means the same thing for
all of them. Running-only mode is unchanged and still retains none of them.

The retention estimator counted eight exact sample streams per measurable
reply. Rename it to the eleven exact samples an ordinary successful probe
can retain and document the wider optional-measurement upper bound, which
raises the finite estimate on this target from 668 to 764 bytes per probe.
A test counts the metrics a fully measured probe actually retains and
compares it against that constant, so retention and its estimate cannot
drift apart unnoticed.

No new median-capability type was needed: `TimeStats::median_ns` already
distinguishes a retained median from an absent one, and an optional
measurement the server never supplied still produces no samples at all.
This commit is contained in:
Nils Andreas Svee committed 2026-08-15 10:41:17 +07:00
1 parent 76dc904da1
commit db9bd515bb
8 files changed
+491 -44

No files matched your search

+44
View File
@@ -100,6 +100,50 @@ pub fn reply_with_received_stats(
event
}
/// Builds a send event with the supplied send-call duration and timer error.
pub fn sent_with_timings(
seq: u32,
sent_at: ClientTimestamp,
send_call_us: u64,
timer_error_us: u64,
) -> ClientEvent {
let mut event = sent(seq, sent_at);
let ClientEvent::EchoSent {
send_call,
timer_error,
..
} = &mut event
else {
unreachable!();
};
*send_call = Duration::from_micros(send_call_us);
*timer_error = Duration::from_micros(timer_error_us);
event
}
/// Builds a normal reply reporting exactly the supplied server processing time.
pub fn reply_with_processing(seq: u32, raw_ms: u64, processing_ms: u64) -> ClientEvent {
let mut event = unadjusted_reply(seq, raw_ms);
let ClientEvent::EchoReply { server_timing, .. } = &mut event else {
unreachable!();
};
if let Some(timing) = server_timing.as_mut() {
timing.processing = Some(Duration::from_millis(processing_ms));
}
event
}
/// Builds a normal reply from a session that supplied no server timestamps, so
/// no server processing measurement exists.
pub fn reply_without_server_timing(seq: u32, raw_ms: u64) -> ClientEvent {
let mut event = unadjusted_reply(seq, raw_ms);
let ClientEvent::EchoReply { server_timing, .. } = &mut event else {
unreachable!();
};
*server_timing = None;
event
}
pub fn unadjusted_late_reply(seq: u32, raw_ms: u64) -> ClientEvent {
late_reply_from(unadjusted_reply(seq, raw_ms))
}
+19
View File
@@ -35,6 +35,25 @@ fn exact_mode_scales_linearly_with_probe_count() {
assert_eq!(config.estimated_retained_bytes(1_000), single * 1_000);
}
/// Exact mode retains eleven `i128` timing streams per probe alongside its
/// IPDV tracker state, so the timing samples alone are at least that much.
///
/// This checks the estimate against the storage it claims to model. It is an
/// implementation test, not a stable public constant: the figure is free to
/// move whenever the retention model does.
#[test]
fn exact_estimate_covers_every_retained_timing_sample() {
const EXACT_TIMING_STREAMS: u64 = 11;
let per_probe = StatsConfig::finite().estimated_retained_bytes(1);
let timing_sample_bytes = EXACT_TIMING_STREAMS * std::mem::size_of::<i128>() as u64;
assert!(
per_probe > timing_sample_bytes,
"the estimate should cover {EXACT_TIMING_STREAMS} retained timing samples plus \
IPDV tracker state, but a probe estimated only {per_probe} bytes"
);
}
#[test]
fn enormous_probe_counts_saturate_instead_of_wrapping() {
for config in [StatsConfig::finite(), StatsConfig::continuous()] {
+143 -1
View File
@@ -3,7 +3,8 @@ mod common;
use std::time::{Duration, Instant, UNIX_EPOCH};
use common::{
adjusted_reply, reply_with_received_stats, sent, ts, unadjusted_late_reply, unadjusted_reply,
adjusted_reply, reply_with_processing, reply_with_received_stats, reply_without_server_timing,
sent, sent_with_timings, ts, unadjusted_late_reply, unadjusted_reply,
};
use irtt_client::{
ClientEvent, ClientTimestamp, OneWayDelaySample, PacketMeta, RttSample, SignedDuration,
@@ -12,6 +13,144 @@ use irtt_stats::{
EventStatsUpdate, IpdvPairUpdate, LateReplyMode, SampleMode, StatsCollector, StatsConfig,
};
#[test]
fn exact_mode_reports_a_send_call_median() {
let mut collector = StatsCollector::new(StatsConfig::finite());
for (seq, send_call_us) in [10_u64, 30, 20].into_iter().enumerate() {
collector.process(&sent_with_timings(
seq as u32,
ts(seq as u64 * 10),
send_call_us,
2,
));
}
let snapshot = collector.snapshot();
assert_eq!(snapshot.send_call.count, 3);
assert_eq!(snapshot.send_call.min_ns, Some(10_000));
assert_eq!(snapshot.send_call.max_ns, Some(30_000));
// Sorted 10, 20, 30 us: the median is the middle sample, not the mean.
assert_eq!(snapshot.send_call.median_ns, Some(20_000.0));
}
#[test]
fn exact_mode_reports_a_timer_error_median() {
let mut collector = StatsCollector::new(StatsConfig::finite());
for (seq, timer_error_us) in [2_u64, 8, 4, 6].into_iter().enumerate() {
collector.process(&sent_with_timings(
seq as u32,
ts(seq as u64 * 10),
10,
timer_error_us,
));
}
let snapshot = collector.snapshot();
assert_eq!(snapshot.timer_error.count, 4);
// Sorted 2, 4, 6, 8 us: an even count averages the two middle samples.
assert_eq!(snapshot.timer_error.median_ns, Some(5_000.0));
}
#[test]
fn exact_mode_reports_a_server_processing_median() {
let mut collector = StatsCollector::new(StatsConfig::finite());
for (seq, processing_ms) in [1_u64, 5, 3].into_iter().enumerate() {
collector.process(&reply_with_processing(seq as u32, 10, processing_ms));
}
let snapshot = collector.snapshot();
assert_eq!(snapshot.server_processing.processing.count, 3);
assert_eq!(
snapshot.server_processing.processing.median_ns,
Some(3_000_000.0)
);
}
#[test]
fn absent_server_processing_measurements_retain_no_samples() {
let mut collector = StatsCollector::new(StatsConfig::finite());
for seq in 0..3 {
collector.process(&reply_without_server_timing(seq, 10));
}
let snapshot = collector.snapshot();
// The replies measured RTT, so the run is not simply empty.
assert_eq!(snapshot.rtt.primary.count, 3);
assert!(snapshot.rtt.primary.median_ns.is_some());
// ...but a metric the server never supplied stays empty rather than
// gaining zero-valued samples.
assert_eq!(snapshot.server_processing.processing.count, 0);
assert_eq!(snapshot.server_processing.processing.median_ns, None);
assert_eq!(snapshot.server_processing.processing.total_ns, 0);
}
#[test]
fn exact_mode_reports_medians_for_every_timing_metric_with_samples() {
let mut collector = StatsCollector::new(StatsConfig::finite());
for seq in 0..3 {
collector.process(&sent(seq, ts(u64::from(seq) * 10)));
collector.process(&adjusted_reply(
seq,
10 + u64::from(seq),
9 + i128::from(seq),
));
}
let snapshot = collector.snapshot();
for (label, stats) in [
("send call", &snapshot.send_call),
("timer error", &snapshot.timer_error),
("primary RTT", &snapshot.rtt.primary),
("raw RTT", &snapshot.rtt.raw),
("adjusted RTT", &snapshot.rtt.adjusted),
("round-trip IPDV", &snapshot.ipdv.round_trip),
("send IPDV", &snapshot.ipdv.send),
("receive IPDV", &snapshot.ipdv.receive),
("send delay", &snapshot.one_way_delay.send_delay),
("receive delay", &snapshot.one_way_delay.receive_delay),
("server processing", &snapshot.server_processing.processing),
] {
assert!(stats.count > 0, "{label} should have samples");
assert!(
stats.median_ns.is_some(),
"{label} has samples, so exact mode should report its median"
);
}
}
#[test]
fn running_only_tracks_send_side_and_processing_without_medians() {
let mut collector = StatsCollector::new(StatsConfig::continuous());
for (seq, value) in [10_u64, 30, 20].into_iter().enumerate() {
let seq = seq as u32;
collector.process(&sent_with_timings(
seq,
ts(u64::from(seq) * 10),
value,
value,
));
collector.process(&reply_with_processing(seq, 10, 1 + u64::from(seq)));
}
let snapshot = collector.snapshot();
for (label, stats) in [
("send call", &snapshot.send_call),
("timer error", &snapshot.timer_error),
("server processing", &snapshot.server_processing.processing),
] {
assert_eq!(stats.count, 3, "{label} should still count its samples");
assert!(
stats.min_ns.is_some(),
"{label} should still track a minimum"
);
assert!(stats.mean_ns > 0.0, "{label} should still track a mean");
assert_eq!(
stats.median_ns, None,
"{label} retains no exact samples in running-only mode"
);
}
}
#[test]
fn running_only_samples_avoid_finite_retention() {
let mut collector = StatsCollector::new(StatsConfig::continuous());
@@ -492,6 +631,9 @@ fn matched_late_reply_counts_without_measuring_under_count_only() {
assert_eq!(snapshot.one_way_delay.send_delay.count, 0);
assert_eq!(snapshot.one_way_delay.receive_delay.count, 0);
assert_eq!(snapshot.ipdv.round_trip.count, 0);
// The reply carried a processing time, but the policy withheld it, so
// exact retention has nothing to report a median from either.
assert_eq!(snapshot.server_processing.processing.median_ns, None);
}
#[test]