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 sameClientEvents 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.
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Adds
proptest-based generative tests forirtt-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.rsdrives aStatsCollectorthrough 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 plainVec<(ClientEvent, at_ms)>history that is re-filtered from scratch on each check (once by count, once by time), then replayed through a freshStatsCollectorto get a comparisonSnapshot.Because
StatsCollector::rolling_count/rolling_timeare themselves computed by replaying the retained events through a freshCoreStatson every call (rolling.rs's privatesnapshot_window), and both sides process the sameClientEvents in the same order through the same deterministic code, the comparison is exactassert_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.rsalready documents and relies on for the loss-eventat()clamp. The generator enforces that by construction: the shared clock only ever moves forward, and every event borrows itsat()from the clock's current value.Derived
rolling_count+rolling_timecomposition ruleReading
crates/irtt-stats/src/rolling.rsshowsRollingEventskeeps two separateVecDeques (count_events,time_events), pushed to together inpush()but evicted independently by their own bound, and exposed as two separate snapshots (count_snapshot()/time_snapshot(), surfaced publicly asStatsCollector::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 existingrolling_count_eviction_recomputes_from_bounded_events/rolling_time_eviction_uses_event_timestampstests intests/stats.rsalready 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
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).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 increasing0..512sequence); 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-regressionsfile were needed.Verification
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-signas a fallback rather than blocking. The human operator should sign this commit themselves if a signed history is required.