test(stats): add rolling-window and IPDV property coverage #118

Merged
Lochnair merged 1 commits from test/stats-properties into main 2026-08-20 03:09:01 +00:00
Lochnair commented 2026-08-19 18:08:59 +00:00 (Migrated from github.com)

Summary

Adds proptest-based generative tests for irtt-stats's stateful, bounded structures — the rolling windows and the IPDV adjacent-pair tracker — as a workstream-C follow-up to the existing proto codec property tests and decoder fuzzing.

Reference model

crates/irtt-stats/tests/rolling_properties.rs drives a StatsCollector through generated operation streams (advance a shared monotonic clock, send, on-time reply, matched/unmatched late reply, duplicate, loss, warning) and, after every operation, compares the production rolling snapshots against a deliberately slow reference model: a plain Vec<(ClientEvent, at_ms)> history that is re-filtered from scratch on each check (once by count, once by time), then replayed through a fresh StatsCollector to get a comparison Snapshot.

Because StatsCollector::rolling_count/rolling_time are themselves computed by replaying the retained events through a fresh CoreStats on every call (rolling.rs's private snapshot_window), and both sides process the same ClientEvents in the same order through the same deterministic code, the comparison is exact assert_eq!, not a floating-point tolerance — there's no independent numerical computation on the reference side to accumulate error against.

The reference time-window's "recompute from scratch using the latest event's cutoff" is only equivalent to the production's incremental front-eviction when timestamps are pushed in non-decreasing order — exactly what normalization.rs already documents and relies on for the loss-event at() clamp. The generator enforces that by construction: the shared clock only ever moves forward, and every event borrows its at() from the clock's current value.

Derived rolling_count + rolling_time composition rule

Reading crates/irtt-stats/src/rolling.rs shows RollingEvents keeps two separate VecDeques (count_events, time_events), pushed to together in push() but evicted independently by their own bound, and exposed as two separate snapshots (count_snapshot() / time_snapshot(), surfaced publicly as StatsCollector::rolling_count() / rolling_time()). There is no single window bounded by both limits at once — they don't compose as AND or OR on shared storage, they are simply two independent parallel windows over the same input stream. The existing rolling_count_eviction_recomputes_from_bounded_events / rolling_time_eviction_uses_event_timestamps tests in tests/stats.rs already exercise each bound alone; the new property test proves the simultaneous case generatively: with both bounds configured on the same collector, each window must behave exactly as if the other bound didn't exist, which would catch any accidental coupling immediately.

Generated operations / properties checked

  • Advance monotonic clock, send, on-time reply, late reply (matched/unmatched), duplicate, loss, warning.
  • After every operation: collector.rolling_count() == count-only reference; collector.rolling_time() == time-only reference; collector.snapshot() (cumulative) == full-history replay (proving rolling eviction never reaches cumulative accounting).
  • Separately, in crates/irtt-stats/src/ipdv.rs: IpdvTracker's bounded FIFO state (samples/sample_order/completed_pairs) stays within its configured limit under arbitrary, possibly-repeated, possibly-non-monotonic sequence streams (the existing hand test only covers a strictly increasing 0..512 sequence); and each adjacent (seq-1, seq) pair is emitted at most once in the unbounded case.

Result

No discrepancies between production and the reference model were found. All new tests pass; no regression tests or .proptest-regressions file were needed.

Verification

cargo fmt --all -- --check                                                    # pass
cargo check --workspace --all-targets --all-features --locked                 # pass
cargo test --workspace --all-targets --all-features --locked                  # pass (all crates)
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings # pass
git diff --check                                                              # pass

Note on commit signing

This commit is unsigned. The 1Password SSH-agent signing step failed in this unattended session (1Password: agent returned an error); per the workstream instructions the same commit was retried once with --no-gpg-sign as a fallback rather than blocking. The human operator should sign this commit themselves if a signed history is required.

## Summary Adds `proptest`-based generative tests for `irtt-stats`'s stateful, bounded structures — the rolling windows and the IPDV adjacent-pair tracker — as a workstream-C follow-up to the existing proto codec property tests and decoder fuzzing. ## Reference model `crates/irtt-stats/tests/rolling_properties.rs` drives a `StatsCollector` through generated operation streams (advance a shared monotonic clock, send, on-time reply, matched/unmatched late reply, duplicate, loss, warning) and, after **every** operation, compares the production rolling snapshots against a deliberately slow reference model: a plain `Vec<(ClientEvent, at_ms)>` history that is re-filtered from scratch on each check (once by count, once by time), then replayed through a fresh `StatsCollector` to get a comparison `Snapshot`. Because `StatsCollector::rolling_count`/`rolling_time` are themselves computed by replaying the retained events through a fresh `CoreStats` on every call (`rolling.rs`'s private `snapshot_window`), and both sides process the *same* `ClientEvent`s in the *same* order through the *same* deterministic code, the comparison is **exact `assert_eq!`**, not a floating-point tolerance — there's no independent numerical computation on the reference side to accumulate error against. The reference time-window's "recompute from scratch using the latest event's cutoff" is only equivalent to the production's incremental front-eviction when timestamps are pushed in non-decreasing order — exactly what `normalization.rs` already documents and relies on for the loss-event `at()` clamp. The generator enforces that by construction: the shared clock only ever moves forward, and every event borrows its `at()` from the clock's current value. ## Derived `rolling_count` + `rolling_time` composition rule Reading `crates/irtt-stats/src/rolling.rs` shows `RollingEvents` keeps **two separate `VecDeque`s** (`count_events`, `time_events`), pushed to together in `push()` but evicted independently by their own bound, and exposed as two separate snapshots (`count_snapshot()` / `time_snapshot()`, surfaced publicly as `StatsCollector::rolling_count()` / `rolling_time()`). There is no single window bounded by both limits at once — they don't compose as AND or OR on shared storage, they are simply **two independent parallel windows** over the same input stream. The existing `rolling_count_eviction_recomputes_from_bounded_events` / `rolling_time_eviction_uses_event_timestamps` tests in `tests/stats.rs` already exercise each bound alone; the new property test proves the *simultaneous* case generatively: with both bounds configured on the same collector, each window must behave exactly as if the other bound didn't exist, which would catch any accidental coupling immediately. ## Generated operations / properties checked - Advance monotonic clock, send, on-time reply, late reply (matched/unmatched), duplicate, loss, warning. - After every operation: `collector.rolling_count()` == count-only reference; `collector.rolling_time()` == time-only reference; `collector.snapshot()` (cumulative) == full-history replay (proving rolling eviction never reaches cumulative accounting). - Separately, in `crates/irtt-stats/src/ipdv.rs`: `IpdvTracker`'s bounded FIFO state (`samples`/`sample_order`/`completed_pairs`) stays within its configured limit under arbitrary, possibly-repeated, possibly-non-monotonic sequence streams (the existing hand test only covers a strictly increasing `0..512` sequence); and each adjacent `(seq-1, seq)` pair is emitted at most once in the unbounded case. ## Result No discrepancies between production and the reference model were found. All new tests pass; no regression tests or `.proptest-regressions` file were needed. ## Verification ``` cargo fmt --all -- --check # pass cargo check --workspace --all-targets --all-features --locked # pass cargo test --workspace --all-targets --all-features --locked # pass (all crates) cargo clippy --workspace --all-targets --all-features --locked -- -D warnings # pass git diff --check # pass ``` ## Note on commit signing This commit is **unsigned**. The 1Password SSH-agent signing step failed in this unattended session (`1Password: agent returned an error`); per the workstream instructions the same commit was retried once with `--no-gpg-sign` as a fallback rather than blocking. The human operator should sign this commit themselves if a signed history is required.
Sign in to join this conversation.