Skip to main content

srt_runtime/
filecc.rs

1//! SRT File Transfer Congestion Control (FileCC) — window-based congestion
2//! control, `draft-sharabayko-srt-01` §5.2 (curated at
3//! `specs/rules/srt-congestion.md`).
4//!
5//! This is the **file/bulk-transfer mode** sibling of [`crate::livecc::LiveCC`]
6//! (§5.1, the **live/streaming mode**'s pacing-only model). Unlike LiveCC,
7//! which only paces `PKT_SND_PERIOD` from a configured `MAX_BW`, FileCC is "a
8//! hybrid Additive Increase Multiplicative Decrease (AIMD) algorithm"
9//! (`specs/rules/srt-congestion.md` L3294-3295) that also grows/shrinks a
10//! congestion window (`CWND_SIZE`), in two strictly-sequential phases: Slow
11//! Start (§5.2.1.1), then Congestion Avoidance (§5.2.1.2) — see [`Phase`].
12//! `PKT_SND_PERIOD` and `CWND_SIZE` are computed independently of and do not
13//! touch LiveCC's state — the two controllers are additive alternatives, not
14//! layered.
15//!
16//! ## Usage
17//!
18//! ```rust
19//! use srt_runtime::filecc::{FileCc, Phase};
20//! use core::time::Duration;
21//!
22//! let mut cc = FileCc::new(0);
23//! assert_eq!(cc.phase(), Phase::SlowStart);
24//!
25//! // On each full ACK, feed the receiver-reported rate/RTT samples:
26//! cc.on_ack(Duration::from_millis(10), 16, 5_000, 1_000, Duration::from_millis(50));
27//!
28//! // A NAK (loss report) ends slow start on the first loss (rule 4/9):
29//! cc.on_loss(8, 16, 0.5);
30//! assert_eq!(cc.phase(), Phase::CongestionAvoidance);
31//! ```
32//!
33//! ## Sans-IO contract
34//!
35//! Like every other engine in this crate, [`FileCc`] never reads a wall
36//! clock: [`FileCc::on_ack`] takes an explicit `now: Duration`; [`FileCc::on_loss`]
37//! and [`FileCc::on_timeout`] are driven purely by caller-reported events.
38//! [`FileCc::tick`] is provided for forward compatibility (mirrors
39//! [`crate::livecc::LiveCC::tick`]) but this section defines no periodic,
40//! time-only state transition beyond the three events already covered.
41//!
42//! ## Shared state, not redefined here
43//!
44//! Per `specs/rules/srt-congestion.md`'s header note, the following are
45//! cross-referenced, not redefined by this module:
46//! - `RC_INTERVAL` / `SYN` = 10 ms — reused from [`crate::arq::FULL_ACK_PERIOD`]
47//!   (`specs/rules/srt-arq.md` rule 11), which is the same 10 ms value
48//!   (L3421-3425).
49//! - The initial `RTT` estimate (100 ms) — reused from
50//!   [`crate::arq::rtt::INITIAL_RTT`] (`specs/rules/srt-arq.md` rule 31)
51//!   before the first ACK sample is fed.
52//!
53//! ## Two implementation-defined gaps, flagged (not fabricated)
54//!
55//! `specs/rules/srt-congestion.md` explicitly flags two points the draft
56//! text leaves unspecified. Both are resolved here with a documented choice:
57//!
58//! 1. **EWMA weight for `RECEIVING_RATE`/`EST_LINK_CAPACITY` smoothing**
59//!    (gap, L3678-3681 / doc's "Gaps" section). Chosen: **this engine does
60//!    not smooth them itself** — [`FileCc::on_ack`] takes `receiving_rate_pps`
61//!    and `est_link_capacity_pps` as already-current values and stores them
62//!    verbatim, exactly the same treatment this section already gives `RTT`
63//!    (cross-ref rule/L3409-3411: "receiver-reported and sender-smoothed",
64//!    with the smoothing defined *elsewhere*, not here). This keeps the
65//!    engine's formulas directly hand-computable from fed inputs (no hidden
66//!    internal averaging to reverse-engineer), and leaves the actual
67//!    smoothing decision to the caller/sender loop — consistent with how RTT
68//!    is already layered in this crate ([`crate::arq::rtt::RttEstimator`] is
69//!    a separate, explicit component, not folded into the ARQ sender).
70//! 2. **Packet-pairs probing mechanics** (gap, L3686-3689). Out of scope:
71//!    this module consumes `RECEIVING_RATE`/`EST_LINK_CAPACITY` as inputs: it
72//!    does not implement the receiver-side inter-arrival-time measurement or
73//!    packet-pairs probing described in §5.2.1.3 (rules 30-37) that would
74//!    *produce* those inputs on a real receiver. That measurement pipeline is
75//!    receiver-side and is not part of this sender-side congestion-control
76//!    state machine.
77//!
78//! A third, smaller implementation choice not flagged as a spec gap: the
79//! `DecRandom` *distribution* (uniform in `[1, AvgNAKNum]`, clamped to 1) is
80//! given (rule 23), but no source of randomness is specified. This module
81//! uses a small internal, deterministic xorshift PRNG instead of pulling in
82//! a `rand` dependency for a `no_std` crate — true entropy is not required
83//! for correctness here, `DecRandom` only staggers repeat-decrease timing
84//! across congestion periods (rule 24). `DecRandom` is rounded to the
85//! nearest whole number (`FileCc::next_dec_random`, internal): Step 4's gate
86//! (`NAKCount == DecCount * DecRandom`) compares two integer counters, so a
87//! fractional draw makes that equality unsatisfiable after the first check.
88//!
89//! ## A quirk of the draft's own Step 4 pseudocode (verified, not a bug)
90//!
91//! `NAKCount`/`DecCount` reset to 1 at the start of a congestion period and
92//! are ONLY incremented again *inside* Step 4's own conditional (rule 28) —
93//! never unconditionally per NAK. So once the immediate post-reset check
94//! (`1 == 1*DecRandom`) fails — i.e. whenever the drawn `DecRandom != 1` —
95//! neither counter ever moves again for the rest of the period, and Step 4
96//! goes silent until the next congestion period redraws `DecRandom`. This is
97//! a property of the draft text as transcribed (`specs/rules/srt-congestion.md`,
98//! confirmed against the actual reference implementation, `libsrt`
99//! `congctl.cpp`, which uses a different formulation — `NAKCount % DecRandom
100//! == 0` with both counters incrementing on every same-period NAK
101//! regardless of outcome — that does not share this one-shot property).
102//! Adopting libsrt's formulation would be a spec-posture departure this
103//! crate is not designated for; this implementation stays literal to the
104//! curated draft text. See `filecc::tests::repeat_decrease_is_a_one_shot_per_period_once_dec_random_exceeds_one`.
105
106use core::time::Duration;
107
108use crate::arq::FULL_ACK_PERIOD;
109use crate::arq::rtt::INITIAL_RTT;
110use crate::arq::seq::{seq_diff, seq_gt};
111
112/// `RC_INTERVAL` = `SYN` = 10 ms (`specs/rules/srt-congestion.md` L3421-3425),
113/// reused from [`crate::arq::FULL_ACK_PERIOD`] (same value, `srt-arq.md` rule
114/// 11) rather than redefined here.
115const RC_INTERVAL: Duration = FULL_ACK_PERIOD;
116
117/// `S` — "the SRT packet size (in terms of IP payload) in bytes. SRT treats
118/// 1500 bytes as a standard packet size." (`specs/rules/srt-congestion.md`
119/// L3539-3540, verbatim). A different quantity from LiveCC's EWMA-derived
120/// `PktSize` — see the module doc's header note.
121const S_BYTES: f64 = 1500.0;
122
123/// Slow start's fixed `PKT_SND_PERIOD` — "1 microsecond ... in order to send
124/// packets as fast as possible, but not at an infinite rate"
125/// (`specs/rules/srt-congestion.md` L3338-3340, verbatim; rule 6).
126const SLOW_START_PKT_SND_PERIOD_US: f64 = 1.0;
127
128/// Slow start's initial `CWND_SIZE` — 16 packets (`specs/rules/srt-congestion.md`
129/// L3340-3341, rule 7).
130const INITIAL_CWND_SIZE: f64 = 16.0;
131
132/// `LastDecPeriod`'s initial value — 1 microsecond (`specs/rules/srt-congestion.md`
133/// L3521-3524).
134const INITIAL_LAST_DEC_PERIOD_US: f64 = 1.0;
135
136/// Default `MAX_CWND_SIZE` — the spec suggests "the maximum receiver buffer
137/// size (12 MB)" as the threshold (`specs/rules/srt-congestion.md` L3344-3345,
138/// rule 8), worded as settable/recommended, not a hardwired constant.
139/// Expressed here in packets at the standard packet size `S` (1500 bytes):
140/// `12_000_000 / 1500 = 8000` packets. Override with [`FileCc::set_max_cwnd_size`].
141const DEFAULT_MAX_CWND_SIZE: f64 = 8_000.0;
142
143/// The NAK-tolerance loss-ratio threshold — "less than 2%"
144/// (`specs/rules/srt-congestion.md` L3572-3579, rule 16).
145const LOSS_RATIO_TOLERANCE: f64 = 0.02;
146
147/// The repeat-decrease rate-backoff multiplier — `1.03`
148/// (`specs/rules/srt-congestion.md` L3602-3604 / L3652-3654, rules 19 & 27).
149const RATE_BACKOFF_FACTOR: f64 = 1.03;
150
151/// The `AvgNAKNum` EWMA weights — `0.97`/`0.03`
152/// (`specs/rules/srt-congestion.md` L3606-3608, rule 20, verbatim).
153const AVG_NAK_NUM_OLD_WEIGHT: f64 = 0.97;
154const AVG_NAK_NUM_NEW_WEIGHT: f64 = 0.03;
155
156/// The repeat-decrease `DecCount` ceiling — decreases stop once `DecCount`
157/// exceeds 5 within a congestion period (`specs/rules/srt-congestion.md`
158/// L3652-3658, rule "Step 4").
159const MAX_DEC_COUNT: u32 = 5;
160
161/// `inc`'s multiplier in the Step 5 rate-increase formula (`specs/rules/srt-congestion.md`
162/// L3498-3517, verbatim: `0.0000015`).
163const INC_SCALE: f64 = 0.0000015;
164
165/// Microseconds per second — the units conversion every `PKT_SND_PERIOD`/rate
166/// formula in this module divides or multiplies by (`specs/rules/srt-congestion.md`,
167/// recurring throughout §5.2.1.2's Steps 3/5/6, sourced from the same
168/// L3498-3517 code block as [`INC_SCALE`]).
169const US_PER_SEC: f64 = 1_000_000.0;
170
171/// `lossBandwidth`'s doubling factor in the Step 5 rate-increase formula
172/// (`specs/rules/srt-congestion.md` L3498-3517 code block, verbatim:
173/// `lossBandwidth = 2 * (1000000 / LastDecPeriod)`).
174const LOSS_BANDWIDTH_FACTOR: f64 = 2.0;
175
176/// The `linkCapacity / 9` clamp divisor in the Step 5 rate-increase formula
177/// (`specs/rules/srt-congestion.md` L3498-3517 code block, verbatim:
178/// `if (... && (linkCapacity / 9) < B) B = linkCapacity / 9;`) — a
179/// spec-mandated tuning constant, not independently derived.
180const LINK_CAPACITY_CLAMP_DIVISOR: f64 = 9.0;
181
182/// Bits per byte, used to convert `S` (packet size in bytes) to bits in the
183/// Step 5 rate-increase formula (`specs/rules/srt-congestion.md` L3498-3517
184/// code block, verbatim: `inc = pow(10.0, ceil(log10(B * S * 8))) * 0.0000015 / S`).
185const BITS_PER_BYTE: f64 = 8.0;
186
187/// FileCC algorithm phase (`specs/rules/srt-congestion.md` rules 4-5). Slow
188/// Start (§5.2.1.1) runs exactly once at the start of a connection; it
189/// transitions to Congestion Avoidance (§5.2.1.2) on the first loss,
190/// `CWND_SIZE` exceeding its maximum, or a timeout — and never transitions
191/// back (L3323-3326).
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193#[non_exhaustive]
194pub enum Phase {
195    /// §5.2.1.1 — probes for available bandwidth; runs exactly once.
196    SlowStart,
197    /// §5.2.1.2 — entered once slow start ends; the steady-state AIMD phase.
198    CongestionAvoidance,
199}
200
201impl Phase {
202    /// The §5.2 phase name.
203    pub fn name(&self) -> &'static str {
204        match self {
205            Phase::SlowStart => "SlowStart",
206            Phase::CongestionAvoidance => "CongestionAvoidance",
207        }
208    }
209}
210
211broadcast_common::impl_spec_display!(Phase);
212
213/// A minimal, seedable xorshift64* PRNG — see the module doc's third
214/// implementation-choice note (`DecRandom`'s source of randomness is
215/// unspecified by the draft; only its distribution is given, rule 23).
216#[derive(Debug, Clone, Copy)]
217struct XorShift64(u64);
218
219impl XorShift64 {
220    /// A fixed, non-zero default seed. Deterministic on purpose: reproducible
221    /// test/debug behavior, and true entropy is not required for correctness
222    /// (see the module doc).
223    const DEFAULT_SEED: u64 = 0x9E37_79B9_7F4A_7C15;
224
225    fn new(seed: u64) -> Self {
226        XorShift64(if seed == 0 { Self::DEFAULT_SEED } else { seed })
227    }
228
229    /// Next pseudo-random value, uniform in `[0.0, 1.0)`.
230    fn next_unit(&mut self) -> f64 {
231        let mut x = self.0;
232        x ^= x << 13;
233        x ^= x >> 7;
234        x ^= x << 17;
235        self.0 = x;
236        let r = x.wrapping_mul(0x2545_F491_4F6C_DD1D);
237        // Top 53 bits as a double in [0, 1).
238        (r >> 11) as f64 * (1.0 / (1u64 << 53) as f64)
239    }
240}
241
242/// `min(a, b)` for `f64`, without relying on `f64::min` (kept as a plain
243/// comparison so this module has no dependency on any transcendental-math
244/// support — see [`next_power_of_10`]'s doc for why that matters for the
245/// `no_std` build).
246fn fmin(a: f64, b: f64) -> f64 {
247    if a < b { a } else { b }
248}
249
250/// `max(a, b)` for `f64` — see [`fmin`].
251fn fmax(a: f64, b: f64) -> f64 {
252    if a > b { a } else { b }
253}
254
255/// Round-half-away-from-zero for `f64`, matching `f64::round`'s semantics —
256/// without calling it, since `f64::round` is a `std`-only method (`core`
257/// float types don't expose it; see [`fmin`]'s doc for why this module avoids
258/// any transcendental/libm dependency). `as i64` float-to-int casts are
259/// saturating in Rust, so this is safe for any finite input in `i64` range —
260/// `DecRandom` values are always small and positive.
261fn fround(x: f64) -> f64 {
262    let truncated = x as i64 as f64;
263    let diff = x - truncated;
264    if diff >= 0.5 {
265        truncated + 1.0
266    } else if diff <= -0.5 {
267        truncated - 1.0
268    } else {
269        truncated
270    }
271}
272
273/// `pow(10.0, ceil(log10(x)))` for `x > 0` — the smallest power of 10 that is
274/// `>= x` (`specs/rules/srt-congestion.md` Step 5, L3506, verbatim source:
275/// `pow(10.0, ceil(log10(B * S * 8))) * 0.0000015 / S`).
276///
277/// Implemented via repeated multiplication/division instead of calling
278/// `log10`/`powf` directly: those are `libm` functions not available in
279/// `core` (this crate builds `no_std`, including on `thumbv7em-none-eabi`
280/// with no libm), whereas this loop only uses basic arithmetic and
281/// comparisons. Algebraically identical to the spec's formula for `x > 0`.
282fn next_power_of_10(x: f64) -> f64 {
283    debug_assert!(x > 0.0, "next_power_of_10 is only defined for x > 0");
284    let mut p = 1.0_f64;
285    while p < x {
286        p *= 10.0;
287    }
288    while p / 10.0 >= x {
289        p /= 10.0;
290    }
291    p
292}
293
294/// SRT File Transfer Congestion Control — sender-side window + pacing state
295/// (`draft-sharabayko-srt-01` §5.2). See the module doc for the full formula
296/// mapping, the sans-IO contract, and the two flagged spec gaps.
297#[derive(Debug, Clone)]
298pub struct FileCc {
299    phase: Phase,
300    /// `CWND_SIZE`, in packets (rule 7; Step 3 of both phases).
301    cwnd_size: f64,
302    /// `MAX_CWND_SIZE` (rule 8) — slow start ends once `cwnd_size` exceeds
303    /// this.
304    max_cwnd_size: f64,
305    /// `PKT_SND_PERIOD`, in microseconds.
306    pkt_snd_period_us: f64,
307    /// `LastRCTime` — `None` until the first ACK (rate-control gate always
308    /// passes on the very first call; the draft does not give an initial
309    /// value, this is the implementation-defined resolution).
310    last_rc_time: Option<Duration>,
311    /// `LAST_ACK_SEQNO` — initialized to the caller-supplied `initial_seqno`
312    /// (representing "no data packet acknowledged yet"; the draft does not
313    /// give LAST_ACK_SEQNO's initial value either).
314    last_ack_seqno: u32,
315    /// `bLoss` — initial value `False` (rule 12).
316    b_loss: bool,
317    /// `LastDecPeriod`, in microseconds — initial value 1 microsecond
318    /// (item under Step 5).
319    last_dec_period_us: f64,
320    /// `LastDecSeq` — `None` until the first Congestion-Avoidance-phase NAK
321    /// (so the very first CA-phase loss always starts a new congestion
322    /// period, rule 25).
323    last_dec_seq: Option<u32>,
324    /// `AvgNAKNum` — initial value 0 (rule "State variables", verbatim def).
325    avg_nak_num: f64,
326    /// `NAKCount` — initial value 0.
327    nak_count: u32,
328    /// `DecCount` — initial value 0.
329    dec_count: u32,
330    /// `DecRandom` — computed per congestion period (rule 23).
331    dec_random: f64,
332    /// `RECEIVING_RATE`, packets/sec — see the module doc's gap-1 resolution
333    /// (stored verbatim from [`FileCc::on_ack`], not smoothed here).
334    receiving_rate_pps: u64,
335    /// `EST_LINK_CAPACITY`, packets/sec — see the module doc's gap-1
336    /// resolution.
337    est_link_capacity_pps: u64,
338    /// `RTT`, sender-smoothed elsewhere (`crate::arq::rtt::RttEstimator`) and
339    /// fed in via [`FileCc::on_ack`]. Initialized to
340    /// [`crate::arq::rtt::INITIAL_RTT`] before the first ACK.
341    rtt: Duration,
342    /// `MAX_BW`, bytes/sec — Step 6 clamp (rule 15: file transfer only uses
343    /// `MAXBW_SET`; `None` means unbounded, the default for file transfer).
344    max_bw_bytes_per_sec: Option<u64>,
345    rng: XorShift64,
346}
347
348impl FileCc {
349    /// A fresh FileCC engine in Slow Start (rule 4: "runs exactly once at
350    /// the beginning of a connection").
351    ///
352    /// `initial_seqno` seeds `LAST_ACK_SEQNO` (see the field doc) — pass the
353    /// connection's initial sequence number (ISN) minus one, or the ISN
354    /// itself if no data has been sent yet; either way the first ACK's
355    /// `CWND_SIZE` growth (Step 3) reflects exactly the packets actually
356    /// acknowledged since then.
357    pub fn new(initial_seqno: u32) -> Self {
358        FileCc {
359            phase: Phase::SlowStart,
360            cwnd_size: INITIAL_CWND_SIZE,
361            max_cwnd_size: DEFAULT_MAX_CWND_SIZE,
362            pkt_snd_period_us: SLOW_START_PKT_SND_PERIOD_US,
363            last_rc_time: None,
364            last_ack_seqno: initial_seqno,
365            b_loss: false,
366            last_dec_period_us: INITIAL_LAST_DEC_PERIOD_US,
367            last_dec_seq: None,
368            avg_nak_num: 0.0,
369            nak_count: 0,
370            dec_count: 0,
371            dec_random: 1.0,
372            receiving_rate_pps: 0,
373            est_link_capacity_pps: 0,
374            rtt: INITIAL_RTT,
375            max_bw_bytes_per_sec: None,
376            rng: XorShift64::new(XorShift64::DEFAULT_SEED),
377        }
378    }
379
380    /// The current algorithm phase.
381    pub fn phase(&self) -> Phase {
382        self.phase
383    }
384
385    /// The current `CWND_SIZE`, in packets.
386    pub fn cwnd_size(&self) -> f64 {
387        self.cwnd_size
388    }
389
390    /// The current `PKT_SND_PERIOD`, in microseconds (as an `f64` — the
391    /// Congestion Avoidance formulas are inherently fractional).
392    pub fn pkt_snd_period_us(&self) -> f64 {
393        self.pkt_snd_period_us
394    }
395
396    /// The current `PKT_SND_PERIOD` as a [`Duration`], for a sender to
397    /// consult before transmitting the next packet (same role as
398    /// [`crate::livecc::LiveCC::on_ack_received`]'s return value).
399    ///
400    /// Truncates the microsecond value toward zero (consistent with this
401    /// crate's existing integer-truncation convention, e.g. LiveCC's
402    /// `PKT_SND_PERIOD`).
403    pub fn pkt_snd_period(&self) -> Duration {
404        Duration::from_micros(self.pkt_snd_period_us as u64)
405    }
406
407    /// The current `MAX_CWND_SIZE` (rule 8), in packets.
408    pub fn max_cwnd_size(&self) -> f64 {
409        self.max_cwnd_size
410    }
411
412    /// Reconfigure `MAX_CWND_SIZE` — rule 8 calls the 12 MB-derived default
413    /// a settable/recommended value, not a hardwired constant.
414    pub fn set_max_cwnd_size(&mut self, packets: f64) {
415        self.max_cwnd_size = packets;
416    }
417
418    /// The current `MAX_BW` clamp, in bytes/sec (`None` = unbounded).
419    pub fn max_bw_bytes_per_sec(&self) -> Option<u64> {
420        self.max_bw_bytes_per_sec
421    }
422
423    /// Reconfigure `MAX_BW` (rule 15: `MAXBW_SET` mode only applies to file
424    /// transfer; there is no default, `None` is unbounded).
425    pub fn set_max_bw_bytes_per_sec(&mut self, max_bw: Option<u64>) {
426        self.max_bw_bytes_per_sec = max_bw;
427    }
428
429    /// The last `RECEIVING_RATE` fed via [`FileCc::on_ack`], packets/sec.
430    pub fn receiving_rate_pps(&self) -> u64 {
431        self.receiving_rate_pps
432    }
433
434    /// The last `EST_LINK_CAPACITY` fed via [`FileCc::on_ack`], packets/sec.
435    pub fn est_link_capacity_pps(&self) -> u64 {
436        self.est_link_capacity_pps
437    }
438
439    /// The last `RTT` fed via [`FileCc::on_ack`] (or the initial 100 ms
440    /// default before the first ACK).
441    pub fn rtt(&self) -> Duration {
442        self.rtt
443    }
444
445    /// `bLoss` — `true` if a loss has been reported since the last rate
446    /// increase (rule 11-12).
447    pub fn b_loss(&self) -> bool {
448        self.b_loss
449    }
450
451    /// `AvgNAKNum` — the average number of NAKs per congestion period
452    /// (rule 20's EWMA).
453    pub fn avg_nak_num(&self) -> f64 {
454        self.avg_nak_num
455    }
456
457    /// `NAKCount` — NAKs received so far in the current congestion period.
458    pub fn nak_count(&self) -> u32 {
459        self.nak_count
460    }
461
462    /// `DecCount` — rate decreases applied so far in the current congestion
463    /// period.
464    pub fn dec_count(&self) -> u32 {
465        self.dec_count
466    }
467
468    /// `LastDecSeq` — the largest sent sequence number at the last rate
469    /// decrease / congestion-period boundary, or `None` if no
470    /// Congestion-Avoidance-phase loss has occurred yet.
471    pub fn last_dec_seq(&self) -> Option<u32> {
472        self.last_dec_seq
473    }
474
475    /// `LastDecPeriod`, in microseconds (item under Step 5; initial value 1
476    /// microsecond).
477    pub fn last_dec_period_us(&self) -> f64 {
478        self.last_dec_period_us
479    }
480
481    /// On full-ACK packet reception (`specs/rules/srt-congestion.md`,
482    /// §5.2.1.1 "(1) On ACK packet reception" / §5.2.1.2 "(1) On ACK packet
483    /// reception"). Only full ACKs trigger a rate increase (rule 3,
484    /// L3313-3314) — a light ACK must not be passed to this method.
485    ///
486    /// `now` — the current time (Step 1's `currTime`).
487    /// `ack_seqno` — the ACK's acknowledged sequence number (`ACK_SEQNO`).
488    /// `receiving_rate_pps` / `est_link_capacity_pps` — the ACK-carried,
489    /// receiver-reported rate estimates (§5.2.1.3); see the module doc's
490    /// gap-1 resolution for why these are stored verbatim, not smoothed
491    /// here.
492    /// `rtt` — the current (already-smoothed elsewhere) RTT estimate.
493    pub fn on_ack(
494        &mut self,
495        now: Duration,
496        ack_seqno: u32,
497        receiving_rate_pps: u64,
498        est_link_capacity_pps: u64,
499        rtt: Duration,
500    ) {
501        self.receiving_rate_pps = receiving_rate_pps;
502        self.est_link_capacity_pps = est_link_capacity_pps;
503        self.rtt = rtt;
504
505        // Step 1 (identical gate in both phases, L3365-3371 / L3455-3461):
506        // if (currTime - LastRCTime < RC_INTERVAL) { keep; stop; }
507        if let Some(last) = self.last_rc_time
508            && now.saturating_sub(last) < RC_INTERVAL
509        {
510            return;
511        }
512        // Step 2: LastRCTime = currTime.
513        self.last_rc_time = Some(now);
514
515        match self.phase {
516            Phase::SlowStart => self.on_ack_slow_start(ack_seqno),
517            Phase::CongestionAvoidance => self.on_ack_congestion_avoidance(),
518        }
519    }
520
521    /// Slow start's ACK handling, Steps 3-5 (L3381-3401).
522    fn on_ack_slow_start(&mut self, ack_seqno: u32) {
523        // Step 3: CWND_SIZE += ACK_SEQNO - LAST_ACK_SEQNO (wrap-safe delta —
524        // reusing arq::seq, not redefined here, per the crate's existing
525        // sequence-arithmetic convention).
526        let delta = seq_diff(ack_seqno, self.last_ack_seqno);
527        self.cwnd_size += f64::from(delta);
528        // Step 4: LAST_ACK_SEQNO = ACK_SEQNO.
529        self.last_ack_seqno = ack_seqno;
530
531        // Step 5: CWND_SIZE exceeding MAX_CWND_SIZE ends slow start.
532        if self.cwnd_size > self.max_cwnd_size {
533            self.end_slow_start();
534        }
535    }
536
537    /// Congestion Avoidance's ACK handling, Steps 3-6 (L3479-3559).
538    fn on_ack_congestion_avoidance(&mut self) {
539        // Step 3: CWND_SIZE = RECEIVING_RATE*(RTT+RC_INTERVAL)/1000000 + 16
540        // (recomputed directly, not incrementally, unlike slow start).
541        self.cwnd_size = self.receiving_rate_pps as f64 * self.rtt_plus_rc_interval_us()
542            / US_PER_SEC
543            + INITIAL_CWND_SIZE;
544
545        // Step 4: loss-in-flight guard.
546        if self.b_loss {
547            self.b_loss = false;
548            return;
549        }
550
551        // Step 5: rate-increase formula (L3498-3517, verbatim).
552        let loss_bandwidth = LOSS_BANDWIDTH_FACTOR * (US_PER_SEC / self.last_dec_period_us);
553        let link_capacity = fmin(loss_bandwidth, self.est_link_capacity_pps as f64);
554        let mut b = link_capacity - US_PER_SEC / self.pkt_snd_period_us;
555        if self.pkt_snd_period_us > self.last_dec_period_us
556            && (link_capacity / LINK_CAPACITY_CLAMP_DIVISOR) < b
557        {
558            b = link_capacity / LINK_CAPACITY_CLAMP_DIVISOR;
559        }
560        let inc = if b <= 0.0 {
561            1.0 / S_BYTES
562        } else {
563            let raw = next_power_of_10(b * S_BYTES * BITS_PER_BYTE) * INC_SCALE / S_BYTES;
564            fmax(raw, 1.0 / S_BYTES)
565        };
566        let rc_interval_us = RC_INTERVAL.as_micros() as f64;
567        self.pkt_snd_period_us = (self.pkt_snd_period_us * rc_interval_us)
568            / (self.pkt_snd_period_us * inc + rc_interval_us);
569
570        // Step 6: MAX_BW clamp, if configured (rule 15).
571        if let Some(max_bw) = self.max_bw_bytes_per_sec {
572            let min_period_us = US_PER_SEC / (max_bw as f64 / S_BYTES);
573            if self.pkt_snd_period_us < min_period_us {
574                self.pkt_snd_period_us = min_period_us;
575            }
576        }
577    }
578
579    /// `RTT + RC_INTERVAL`, in microseconds — shared by the CWND_SIZE
580    /// formula (Step 3, both event handlers that use it).
581    fn rtt_plus_rc_interval_us(&self) -> f64 {
582        self.rtt.as_micros() as f64 + RC_INTERVAL.as_micros() as f64
583    }
584
585    /// Ends slow start, computing `PKT_SND_PERIOD` per the shared Step 5
586    /// formula (L3392-3401, reused verbatim by rules 9 and 10 — NAK and RTO
587    /// during slow start).
588    fn end_slow_start(&mut self) {
589        self.phase = Phase::CongestionAvoidance;
590        self.pkt_snd_period_us = if self.receiving_rate_pps > 0 {
591            US_PER_SEC / self.receiving_rate_pps as f64
592        } else {
593            self.cwnd_size / self.rtt_plus_rc_interval_us()
594        };
595    }
596
597    /// On a loss report (NAK) packet reception.
598    ///
599    /// - During Slow Start (rule 9): ends slow start; `PKT_SND_PERIOD` is
600    ///   set exactly as in the ACK Step 5 formula. `lost_seqno`,
601    ///   `largest_sent_seqno`, and `loss_ratio` are not consulted by this
602    ///   phase's handling (the draft's rule 9 gives no further formula).
603    /// - During Congestion Avoidance (§5.2.1.2 "(2)", L3568-3658): runs the
604    ///   full bLoss/loss-ratio-tolerance/congestion-period/repeat-decrease
605    ///   state machine.
606    ///
607    /// `lost_seqno` — the sequence number reported lost by this NAK.
608    /// `largest_sent_seqno` — the largest sequence number sent so far
609    /// (recorded as the new `LastDecSeq` on a decrease, rules 22/29).
610    /// `loss_ratio` — the sender's current estimated loss ratio (rule 16's
611    /// "less than 2%" tolerance check), e.g. lost/sent over a recent window.
612    pub fn on_loss(&mut self, lost_seqno: u32, largest_sent_seqno: u32, loss_ratio: f64) {
613        match self.phase {
614            Phase::SlowStart => self.end_slow_start(),
615            Phase::CongestionAvoidance => {
616                self.on_loss_congestion_avoidance(lost_seqno, largest_sent_seqno, loss_ratio)
617            }
618        }
619    }
620
621    /// Congestion Avoidance's NAK handling (L3568-3658).
622    fn on_loss_congestion_avoidance(
623        &mut self,
624        lost_seqno: u32,
625        largest_sent_seqno: u32,
626        loss_ratio: f64,
627    ) {
628        // Step 1: bLoss = True.
629        self.b_loss = true;
630
631        // Step 2: loss-ratio tolerance (rule 16-17).
632        if loss_ratio < LOSS_RATIO_TOLERANCE {
633            self.last_dec_period_us = self.pkt_snd_period_us;
634            return;
635        }
636
637        // Step 3: new congestion period? (rule 25: the lost seq is greater
638        // than LastDecSeq).
639        let is_new_period = match self.last_dec_seq {
640            None => true,
641            Some(last) => seq_gt(lost_seqno, last),
642        };
643
644        if is_new_period {
645            self.last_dec_period_us = self.pkt_snd_period_us;
646            self.pkt_snd_period_us *= RATE_BACKOFF_FACTOR;
647            // rule 20: AvgNAKNum = 0.97*AvgNAKNum + 0.03*NAKCount (using the
648            // just-finished period's NAKCount, before it is reset below).
649            self.avg_nak_num = AVG_NAK_NUM_OLD_WEIGHT * self.avg_nak_num
650                + AVG_NAK_NUM_NEW_WEIGHT * self.nak_count as f64;
651            self.nak_count = 1;
652            self.dec_count = 1;
653            self.last_dec_seq = Some(largest_sent_seqno);
654            self.dec_random = self.next_dec_random();
655            return;
656        }
657
658        // Step 4: repeat decrease within the same congestion period
659        // (rule "Step 4": DecCount<=5 && NAKCount==DecCount*DecRandom).
660        if self.dec_count <= MAX_DEC_COUNT
661            && self.nak_count as f64 == self.dec_count as f64 * self.dec_random
662        {
663            self.pkt_snd_period_us *= RATE_BACKOFF_FACTOR;
664            self.dec_count += 1;
665            self.nak_count += 1;
666            self.last_dec_seq = Some(largest_sent_seqno);
667        }
668    }
669
670    /// `DecRandom` — "a random number between 1 and the average number of
671    /// NAKs per congestion period (AvgNAKNum)", clamped to 1 if the draw is
672    /// below 1 (rule 23). See the module doc for the PRNG source note.
673    ///
674    /// Rounded to the nearest whole number: Step 4's repeat-decrease gate
675    /// (`NAKCount == DecCount * DecRandom`) compares against the integer
676    /// counters `NAKCount`/`DecCount`, so it only means "every `DecRandom`-th
677    /// NAK" — and is satisfiable — when `DecRandom` is itself integer-valued.
678    /// A raw fractional draw (`AvgNAKNum > 1`) makes that equality a
679    /// measure-zero float comparison that (almost) never holds again after
680    /// the congestion period's first decrease, silently disabling repeat
681    /// decrease for the rest of the period.
682    fn next_dec_random(&mut self) -> f64 {
683        let hi = fmax(self.avg_nak_num, 1.0);
684        let r = self.rng.next_unit();
685        let val = fround(1.0 + r * (hi - 1.0));
686        fmax(val, 1.0)
687    }
688
689    /// On a retransmission timeout (RTO) event.
690    ///
691    /// During Slow Start (rule 10): ends slow start, `PKT_SND_PERIOD` set
692    /// exactly as in the ACK Step 5 formula (same as [`FileCc::on_loss`]'s
693    /// slow-start handling). Once in Congestion Avoidance, this section does
694    /// not describe an RTO-specific FileCC reaction (RTO-driven
695    /// retransmission itself is the ARQ engine's concern, `srt-arq.md`) —
696    /// calling this while already in Congestion Avoidance is a no-op.
697    pub fn on_timeout(&mut self) {
698        if self.phase == Phase::SlowStart {
699            self.end_slow_start();
700        }
701    }
702
703    /// Time-driven tick — currently a no-op (mirrors
704    /// [`crate::livecc::LiveCC::tick`]). Provided for forward compatibility:
705    /// this section's algorithm is driven entirely by the three named
706    /// events (send / ACK / timeout, rule 5); there is no additional
707    /// periodic-only state transition to run here.
708    pub fn tick(&mut self, _now: Duration) {}
709}
710
711impl Default for FileCc {
712    /// A fresh engine with `initial_seqno = 0` (see [`FileCc::new`]).
713    fn default() -> Self {
714        FileCc::new(0)
715    }
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721
722    #[test]
723    fn starts_in_slow_start_with_spec_initial_values() {
724        let cc = FileCc::new(0);
725        assert_eq!(cc.phase(), Phase::SlowStart);
726        assert_eq!(cc.cwnd_size(), 16.0);
727        assert_eq!(cc.pkt_snd_period_us(), 1.0);
728        assert_eq!(cc.max_cwnd_size(), 8_000.0);
729        assert!(!cc.b_loss());
730        assert_eq!(cc.avg_nak_num(), 0.0);
731        assert_eq!(cc.nak_count(), 0);
732        assert_eq!(cc.dec_count(), 0);
733        assert_eq!(cc.last_dec_seq(), None);
734        assert_eq!(cc.last_dec_period_us(), 1.0);
735        assert_eq!(cc.rtt(), Duration::from_millis(100));
736        assert_eq!(cc.max_bw_bytes_per_sec(), None);
737    }
738
739    #[test]
740    fn slow_start_cwnd_grows_by_ack_seqno_delta() {
741        let mut cc = FileCc::new(0);
742        cc.on_ack(
743            Duration::from_millis(10),
744            5,
745            0,
746            0,
747            Duration::from_millis(100),
748        );
749        assert_eq!(cc.cwnd_size(), 21.0); // 16 + (5 - 0)
750        assert_eq!(
751            cc.pkt_snd_period_us(),
752            1.0,
753            "fixed at 1us during slow start"
754        );
755    }
756
757    #[test]
758    fn rate_control_gate_blocks_updates_within_rc_interval() {
759        let mut cc = FileCc::new(0);
760        cc.on_ack(
761            Duration::from_millis(10),
762            5,
763            0,
764            0,
765            Duration::from_millis(100),
766        );
767        assert_eq!(cc.cwnd_size(), 21.0);
768        // Only 1ms later (< RC_INTERVAL = 10ms): must be a no-op.
769        cc.on_ack(
770            Duration::from_millis(11),
771            999,
772            0,
773            0,
774            Duration::from_millis(100),
775        );
776        assert_eq!(
777            cc.cwnd_size(),
778            21.0,
779            "gate must block an ACK inside RC_INTERVAL"
780        );
781    }
782
783    #[test]
784    fn loss_during_slow_start_transitions_to_congestion_avoidance() {
785        let mut cc = FileCc::new(0);
786        assert_eq!(cc.phase(), Phase::SlowStart);
787        cc.on_loss(10, 10, 0.9);
788        assert_eq!(cc.phase(), Phase::CongestionAvoidance);
789    }
790
791    #[test]
792    fn timeout_during_slow_start_transitions_to_congestion_avoidance() {
793        let mut cc = FileCc::new(0);
794        cc.on_timeout();
795        assert_eq!(cc.phase(), Phase::CongestionAvoidance);
796    }
797
798    #[test]
799    fn timeout_during_congestion_avoidance_is_a_no_op() {
800        let mut cc = FileCc::new(0);
801        cc.on_loss(10, 10, 0.9);
802        assert_eq!(cc.phase(), Phase::CongestionAvoidance);
803        let period = cc.pkt_snd_period_us();
804        cc.on_timeout();
805        assert_eq!(cc.pkt_snd_period_us(), period);
806    }
807
808    #[test]
809    fn cwnd_exceeding_max_ends_slow_start() {
810        let mut cc = FileCc::new(0);
811        cc.set_max_cwnd_size(20.0);
812        cc.on_ack(
813            Duration::from_millis(10),
814            10,
815            0,
816            0,
817            Duration::from_millis(100),
818        );
819        // CWND = 16 + 10 = 26 > 20 -> slow start ends.
820        assert_eq!(cc.phase(), Phase::CongestionAvoidance);
821    }
822
823    #[test]
824    fn max_bw_clamp_floors_pkt_snd_period() {
825        let mut cc = FileCc::new(0);
826        cc.on_loss(1, 1, 0.9); // -> Congestion Avoidance
827        cc.set_max_bw_bytes_per_sec(Some(1)); // absurdly low MAX_BW
828        cc.on_ack(
829            Duration::from_millis(10),
830            1,
831            1_000,
832            1_000,
833            Duration::from_millis(50),
834        );
835        // MIN_PERIOD = 1_000_000 / (1 / 1500) = 1_500_000_000 us.
836        assert!(cc.pkt_snd_period_us() >= 1_500_000_000.0);
837    }
838
839    #[test]
840    fn next_power_of_10_matches_known_values() {
841        assert_eq!(next_power_of_10(1.0), 1.0);
842        assert_eq!(next_power_of_10(9.9), 10.0);
843        assert_eq!(next_power_of_10(10.0), 10.0);
844        assert_eq!(next_power_of_10(10.1), 100.0);
845        assert_eq!(next_power_of_10(100.0), 100.0);
846        assert_eq!(next_power_of_10(0.05), 0.1);
847    }
848
849    #[test]
850    fn xorshift_produces_values_in_unit_range() {
851        let mut rng = XorShift64::new(1);
852        for _ in 0..100 {
853            let v = rng.next_unit();
854            assert!((0.0..1.0).contains(&v), "value out of range: {v}");
855        }
856    }
857
858    #[test]
859    fn dec_random_is_one_when_avg_nak_num_is_zero() {
860        let mut cc = FileCc::new(0);
861        assert_eq!(cc.avg_nak_num(), 0.0);
862        assert_eq!(cc.next_dec_random(), 1.0);
863    }
864
865    /// `DecRandom` must be integer-valued once `AvgNAKNum > 1` (a fractional
866    /// draw makes Step 4's `NAKCount == DecCount * DecRandom` gate a
867    /// measure-zero float comparison that — after the congestion period's
868    /// first decrease — essentially never holds again, silently disabling
869    /// repeat decrease for the rest of the period). This is the bug a
870    /// pre-tag audit found: the un-rounded draw only ever produced an
871    /// integer in the degenerate `AvgNAKNum <= 1` case, which is the only
872    /// case the pre-existing test suite exercised.
873    #[test]
874    fn dec_random_is_integer_valued_once_avg_nak_num_exceeds_one() {
875        let mut cc = FileCc::new(0);
876        cc.avg_nak_num = 6.0;
877        for _ in 0..200 {
878            let v = cc.next_dec_random();
879            assert_eq!(
880                v,
881                v.round(),
882                "DecRandom must be a whole number (got {v}) so Step 4's \
883                 NAKCount == DecCount * DecRandom gate is actually satisfiable"
884            );
885            assert!((1.0..=6.0).contains(&v), "DecRandom out of range: {v}");
886        }
887    }
888
889    /// Documents a genuine quirk of the draft's own literal Step 4
890    /// pseudocode (verified against `specs/rules/srt-congestion.md`, not
891    /// assumed): `NAKCount`/`DecCount` are reset to 1 on a new congestion
892    /// period and are ONLY ever touched again *inside* Step 4's own
893    /// `NAKCount == DecCount * DecRandom` conditional (rule 28: "Increase
894    /// DecCount and NAKCount each by 1" — inside the `if`, not unconditional
895    /// per-NAK). So the very next same-period NAK checks `1 == 1*DecRandom`;
896    /// if `DecRandom != 1` that fails, and since neither counter has moved,
897    /// the SAME false check repeats for every subsequent NAK in the period —
898    /// Step 4 cannot fire again until the next congestion period redraws
899    /// `DecRandom`. This is a property of the literal spec text as
900    /// transcribed, not a Rust-side bug: the reference implementation
901    /// (libsrt `congctl.cpp`) uses a different formulation (`NAKCount %
902    /// DecRandom == 0`, incrementing both counters on every same-period NAK
903    /// regardless of the check's outcome) that does NOT have this one-shot
904    /// property — but adopting that would be a spec-posture departure this
905    /// crate isn't designated for (see `CLAUDE.md`), so this implementation
906    /// stays literal to the curated draft text. Confirmed here with a fixed
907    /// `dec_random` (bypassing the RNG) so the assertion is deterministic.
908    #[test]
909    fn repeat_decrease_is_a_one_shot_per_period_once_dec_random_exceeds_one() {
910        let mut cc = FileCc::new(0);
911        cc.on_loss(10, 10, 0.9); // end slow start
912        cc.on_loss(20, 20, 0.9); // new congestion period, big decrease
913        assert_eq!(cc.dec_count(), 1);
914
915        // Force a specific integer DecRandom > 1 for this period (bypassing
916        // the RNG so the check below is deterministic, not seed-dependent).
917        cc.dec_random = 3.0;
918        let after_first = cc.pkt_snd_period_us();
919
920        // Many same-period NAKs: per the literal spec pseudocode, NONE of
921        // them can fire Step 4 again, because NAKCount/DecCount are frozen
922        // at (1, 1) and 1 == 1*3 is false — and stays false forever, since
923        // nothing outside Step 4's own conditional advances either counter.
924        for sent in 21..=60u32 {
925            cc.on_loss(15, sent, 0.9);
926        }
927
928        assert_eq!(
929            cc.dec_count(),
930            1,
931            "with the literal spec's Step 4 pseudocode, DecCount must stay \
932             frozen at 1 for the rest of the period once the immediate \
933             post-reset check (NAKCount==DecCount*DecRandom, i.e. 1==1*3) \
934             fails — this is the draft's own one-shot property, not a bug"
935        );
936        assert_eq!(
937            cc.pkt_snd_period_us(),
938            after_first,
939            "PKT_SND_PERIOD must not change further once Step 4 has gone \
940             silent for the rest of the period"
941        );
942    }
943
944    /// The complementary, working case: when the drawn integer `DecRandom`
945    /// rounds to exactly `1`, the immediate post-reset check `1 == 1*1`
946    /// holds, so Step 4 fires on every subsequent same-period NAK (this is
947    /// the case the pre-existing integration test
948    /// `repeated_decrease_backs_off_by_1_03_bounded_by_dec_count` already
949    /// covers end-to-end with `AvgNAKNum == 0`; this unit test additionally
950    /// proves it holds even when `AvgNAKNum` is nonzero but rounds to 1).
951    #[test]
952    fn repeat_decrease_fires_repeatedly_when_dec_random_rounds_to_one() {
953        let mut cc = FileCc::new(0);
954        cc.avg_nak_num = 1.2; // rounds to 1 via next_dec_random's `.round()`
955        cc.on_loss(10, 10, 0.9);
956        cc.on_loss(20, 20, 0.9);
957        assert_eq!(cc.dec_random, 1.0);
958        assert_eq!(cc.dec_count(), 1);
959
960        for sent in 21..=25u32 {
961            cc.on_loss(15, sent, 0.9);
962        }
963        assert_eq!(
964            cc.dec_count(),
965            6,
966            "DecRandom==1 must let Step 4 fire on every same-period NAK"
967        );
968    }
969
970    #[test]
971    fn default_matches_new_zero() {
972        let a = FileCc::default();
973        let b = FileCc::new(0);
974        assert_eq!(a.cwnd_size(), b.cwnd_size());
975        assert_eq!(a.phase(), b.phase());
976    }
977}