use std::collections::HashMap;
use std::time::{Duration, Instant};
#[cfg(feature = "googcc-bwe")]
use super::googcc::GoogCcEstimator;
use super::kalman::DelayEstimator;
use super::loss::LossEstimator;
const INITIAL_BITRATE_BPS: f64 = 300_000.0;
const CLIENT_HINT_MAX_AGE: Duration = Duration::from_secs(5);
#[derive(Debug, Clone, Copy)]
pub struct ClientHint {
pub bps: u64,
pub received_at: Instant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BindingTerm {
Delay,
Loss,
Native,
GoogCc,
ClientHint,
}
impl BindingTerm {
pub const ALL: [BindingTerm; 5] = [
BindingTerm::Delay,
BindingTerm::Loss,
BindingTerm::Native,
BindingTerm::GoogCc,
BindingTerm::ClientHint,
];
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
BindingTerm::Delay => "delay",
BindingTerm::Loss => "loss",
BindingTerm::Native => "native",
BindingTerm::GoogCc => "googcc",
BindingTerm::ClientHint => "client_hint",
}
}
}
fn combine_terms(
delay: f64,
loss: f64,
native: Option<f64>,
googcc: Option<f64>,
hint: Option<f64>,
) -> (f64, BindingTerm) {
fn min_update(best: &mut f64, term: &mut BindingTerm, cand: f64, cand_term: BindingTerm) {
if best.is_nan() || cand < *best {
*best = cand;
*term = cand_term;
}
}
let mut best = delay;
let mut term = BindingTerm::Delay;
min_update(&mut best, &mut term, loss, BindingTerm::Loss);
if let Some(n) = native {
min_update(&mut best, &mut term, n, BindingTerm::Native);
}
if let Some(g) = googcc {
min_update(&mut best, &mut term, g, BindingTerm::GoogCc);
}
if let Some(h) = hint {
min_update(&mut best, &mut term, h, BindingTerm::ClientHint);
}
(best.max(0.0), term)
}
#[derive(Debug)]
pub struct PerSubscriber {
pub send_times: HashMap<u64, Instant>,
pub last_arrival: Option<Instant>,
pub last_send_for_received: Option<Instant>,
pub delay: DelayEstimator,
pub loss: LossEstimator,
pub rtt: Option<Duration>,
pub native_estimate_bps: Option<f64>,
pub client_hint: Option<ClientHint>,
#[cfg(feature = "googcc-bwe")]
pub googcc: Option<GoogCcEstimator>,
}
impl PerSubscriber {
pub fn new() -> Self {
Self {
send_times: HashMap::new(),
last_arrival: None,
last_send_for_received: None,
delay: DelayEstimator::new(INITIAL_BITRATE_BPS),
loss: LossEstimator::new(INITIAL_BITRATE_BPS),
rtt: None,
native_estimate_bps: None,
client_hint: None,
#[cfg(feature = "googcc-bwe")]
googcc: None,
}
}
#[cfg(feature = "googcc-bwe")]
#[must_use]
pub fn googcc_active(&self) -> bool {
self.googcc.is_some()
}
#[must_use]
pub fn combined_bps(&self, now: Instant) -> f64 {
self.combined_bps_with_term(now).0
}
#[must_use]
pub fn combined_bps_with_term(&self, now: Instant) -> (f64, BindingTerm) {
let native = self.native_estimate_bps;
#[cfg(feature = "googcc-bwe")]
let googcc = self.googcc.as_ref().map(|gcc| gcc.current_bps() as f64);
#[cfg(not(feature = "googcc-bwe"))]
let googcc: Option<f64> = None;
let hint = match self.client_hint {
Some(h) if now.duration_since(h.received_at) < CLIENT_HINT_MAX_AGE => {
Some(h.bps as f64)
}
_ => None,
};
combine_terms(
self.delay.bitrate_bps(),
self.loss.bitrate_bps(),
native,
googcc,
hint,
)
}
}
impl Default for PerSubscriber {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Instant;
#[test]
fn combined_bps_takes_minimum_of_delay_and_loss() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(2_000_000.0);
sub.loss = LossEstimator::new(1_000_000.0);
let combined = sub.combined_bps(now);
assert!(
combined <= 1_100_000.0,
"expected approx 1Mbps (min of 2M and 1M), got {combined}"
);
}
#[test]
fn native_estimate_acts_as_ceiling() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(2_000_000.0);
sub.loss = LossEstimator::new(2_000_000.0);
sub.native_estimate_bps = Some(500_000.0);
let combined = sub.combined_bps(now);
assert!(
combined <= 500_100.0,
"native GCC ceiling not applied: {combined}"
);
}
#[test]
fn client_hint_acts_as_ceiling_when_fresh() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(2_000_000.0);
sub.loss = LossEstimator::new(2_000_000.0);
sub.client_hint = Some(ClientHint {
bps: 400_000,
received_at: now,
});
let combined = sub.combined_bps(now);
assert!(
combined <= 400_100.0,
"client hint ceiling not applied: {combined}"
);
}
#[test]
fn stale_client_hint_is_ignored() {
let past = Instant::now() - Duration::from_secs(10); let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(2_000_000.0);
sub.loss = LossEstimator::new(2_000_000.0);
sub.client_hint = Some(ClientHint {
bps: 100,
received_at: past,
}); let combined = sub.combined_bps(now);
assert!(
combined > 1_000.0,
"stale client hint should be ignored, got {combined}"
);
}
fn old_min_chain(
delay: f64,
loss: f64,
native: Option<f64>,
googcc: Option<f64>,
hint: Option<f64>,
) -> f64 {
let base = delay.min(loss);
let after_native = match native {
Some(n) => base.min(n),
None => base,
};
let after_gcc = match googcc {
Some(g) => after_native.min(g),
None => after_native,
};
let after_hint = match hint {
Some(h) => after_gcc.min(h),
None => after_gcc,
};
after_hint.max(0.0)
}
#[test]
fn combine_terms_value_matches_original_min_chain_including_nan() {
let scalars = [30_000.0_f64, 50_000.0, f64::NAN, 0.0];
let opts = [None, Some(40_000.0_f64), Some(f64::NAN), Some(0.0)];
for &delay in &scalars {
for &loss in &scalars {
for &native in &opts {
for &googcc in &opts {
for &hint in &opts {
let (got, term) = combine_terms(delay, loss, native, googcc, hint);
let want = old_min_chain(delay, loss, native, googcc, hint);
assert_eq!(
got, want,
"value drift: delay={delay} loss={loss} native={native:?} \
googcc={googcc:?} hint={hint:?} term={term:?}"
);
assert!(
!got.is_nan(),
"combine_terms must never return NaN (clamped by max(0.0))"
);
}
}
}
}
}
}
#[test]
fn binding_term_is_delay_or_loss_at_boot() {
let now = Instant::now();
let sub = PerSubscriber::new();
let (_, term) = sub.combined_bps_with_term(now);
assert_eq!(term, BindingTerm::Delay, "boot state should report delay");
}
#[test]
fn binding_term_loss_when_loss_lower() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(2_000_000.0);
sub.loss = LossEstimator::new(1_000_000.0);
let (bps, term) = sub.combined_bps_with_term(now);
assert_eq!(term, BindingTerm::Loss);
assert_eq!(bps, sub.combined_bps(now), "value must match combined_bps");
}
#[test]
fn binding_term_native_when_native_ceiling_lowest() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(2_000_000.0);
sub.loss = LossEstimator::new(2_000_000.0);
sub.native_estimate_bps = Some(500_000.0);
let (_, term) = sub.combined_bps_with_term(now);
assert_eq!(term, BindingTerm::Native);
}
#[test]
fn binding_term_client_hint_when_fresh_hint_lowest() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(2_000_000.0);
sub.loss = LossEstimator::new(2_000_000.0);
sub.native_estimate_bps = Some(800_000.0);
sub.client_hint = Some(ClientHint {
bps: 400_000,
received_at: now,
});
let (_, term) = sub.combined_bps_with_term(now);
assert_eq!(term, BindingTerm::ClientHint);
}
#[test]
fn binding_term_not_client_hint_when_stale() {
let past = Instant::now() - Duration::from_secs(10);
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(2_000_000.0);
sub.loss = LossEstimator::new(2_000_000.0);
sub.native_estimate_bps = Some(600_000.0);
sub.client_hint = Some(ClientHint {
bps: 100,
received_at: past,
});
let (_, term) = sub.combined_bps_with_term(now);
assert_eq!(
term,
BindingTerm::Native,
"stale hint must not be the binding term"
);
}
#[test]
fn binding_term_as_str_and_all_cover_every_variant() {
assert_eq!(BindingTerm::ALL.len(), 5);
let labels: Vec<&str> = BindingTerm::ALL.iter().map(BindingTerm::as_str).collect();
assert_eq!(
labels,
vec!["delay", "loss", "native", "googcc", "client_hint"]
);
}
}
#[cfg(all(test, feature = "googcc-bwe"))]
mod googcc_integration_tests {
use super::*;
use crate::bwe::googcc::GoogCcEstimator;
#[test]
fn googcc_acts_as_ceiling_when_set() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(5_000_000.0);
sub.loss = LossEstimator::new(5_000_000.0);
let mut gcc = GoogCcEstimator::new();
gcc.force_bps_for_tests(300_000);
sub.googcc = Some(gcc);
let combined = sub.combined_bps(now);
assert!(
combined <= 300_100.0,
"GoogCC ceiling not applied: {combined}"
);
}
#[test]
fn googcc_none_does_not_affect_combined() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(1_000_000.0);
sub.loss = LossEstimator::new(1_000_000.0);
let combined = sub.combined_bps(now);
assert!(
combined > 900_000.0,
"missing GoogCC should not cap estimate: {combined}"
);
}
#[test]
fn binding_term_googcc_when_googcc_ceiling_lowest() {
let now = Instant::now();
let mut sub = PerSubscriber::new();
sub.delay = DelayEstimator::new(5_000_000.0);
sub.loss = LossEstimator::new(5_000_000.0);
sub.native_estimate_bps = Some(2_000_000.0);
let mut gcc = GoogCcEstimator::new();
gcc.force_bps_for_tests(300_000);
sub.googcc = Some(gcc);
let (_, term) = sub.combined_bps_with_term(now);
assert_eq!(term, BindingTerm::GoogCc);
}
}