use std::time::{Duration, Instant};
pub const DEFAULT_LOW_LOSS: f64 = 0.02;
pub const DEFAULT_HIGH_LOSS: f64 = 0.10;
pub const DEFAULT_LOSS_INTERVAL: Duration = Duration::from_millis(200);
#[derive(Debug, Clone)]
pub struct LossController {
target: f64,
min: f64,
max: f64,
low: f64,
high: f64,
interval: Duration,
average_loss: Option<f64>,
last_change: Option<Instant>,
}
impl LossController {
pub fn new(initial: f64, min: f64, max: f64) -> Self {
Self {
target: initial.clamp(min, max),
min,
max,
low: DEFAULT_LOW_LOSS,
high: DEFAULT_HIGH_LOSS,
interval: DEFAULT_LOSS_INTERVAL,
average_loss: None,
last_change: None,
}
}
pub fn target(&self) -> f64 {
self.target
}
pub fn average_loss(&self) -> Option<f64> {
self.average_loss
}
pub fn update(&mut self, now: Instant, lost: usize, total: usize) -> f64 {
if total == 0 {
return self.target;
}
let sample = lost as f64 / total as f64;
let average = match self.average_loss {
Some(previous) => 0.8 * previous + 0.2 * sample,
None => sample,
};
self.average_loss = Some(average);
if let Some(last) = self.last_change
&& now.saturating_duration_since(last) < self.interval
{
return self.target;
}
if average.max(sample) < self.low {
self.target = (self.target * 1.05).clamp(self.min, self.max);
self.last_change = Some(now);
} else if average.min(sample) > self.high {
self.target = (self.target * (1.0 - 0.5 * average)).clamp(self.min, self.max);
self.last_change = Some(now);
}
self.target
}
}
#[cfg(test)]
mod tests {
use super::*;
const MIN: f64 = 100_000.0;
const MAX: f64 = 10_000_000.0;
fn controller() -> LossController {
LossController::new(1_000_000.0, MIN, MAX)
}
#[test]
fn heavy_loss_lowers_the_target_on_its_own() {
let epoch = Instant::now();
let mut controller = controller();
let before = controller.target();
let mut at = epoch;
for _ in 0..10 {
at += DEFAULT_LOSS_INTERVAL;
controller.update(at, 20, 100);
}
assert!(
controller.target() < before,
"20% loss must lower the target: {before} → {}",
controller.target()
);
}
#[test]
fn a_clean_path_raises_the_target() {
let epoch = Instant::now();
let mut controller = controller();
let before = controller.target();
let mut at = epoch;
for _ in 0..10 {
at += DEFAULT_LOSS_INTERVAL;
controller.update(at, 0, 100);
}
assert!(
controller.target() > before,
"a lossless path should be probed: {before} → {}",
controller.target()
);
}
#[test]
fn moderate_loss_changes_nothing() {
let epoch = Instant::now();
let mut controller = controller();
let before = controller.target();
let mut at = epoch;
for _ in 0..20 {
at += DEFAULT_LOSS_INTERVAL;
controller.update(at, 5, 100);
}
assert_eq!(
before,
controller.target(),
"loss inside the band must not move the target"
);
}
#[test]
fn the_backoff_is_proportional_to_the_loss() {
let epoch = Instant::now();
let mut mild = controller();
let mut severe = controller();
let mut at = epoch;
for _ in 0..5 {
at += DEFAULT_LOSS_INTERVAL;
mild.update(at, 12, 100);
severe.update(at, 50, 100);
}
assert!(
severe.target() < mild.target(),
"50% loss should back off further than 12%: {} vs {}",
severe.target(),
mild.target()
);
}
#[test]
fn empty_feedback_changes_nothing() {
let epoch = Instant::now();
let mut controller = controller();
let before = controller.target();
assert_eq!(before, controller.update(epoch, 0, 0));
assert_eq!(None, controller.average_loss());
}
#[test]
fn the_target_stays_within_configured_bounds() {
let epoch = Instant::now();
let mut controller = LossController::new(MIN, MIN, 400_000.0);
let mut at = epoch;
for _ in 0..500 {
at += DEFAULT_LOSS_INTERVAL;
controller.update(at, 0, 100);
}
assert!(
controller.target() <= 400_000.0,
"ceiling breached: {}",
controller.target()
);
for _ in 0..500 {
at += DEFAULT_LOSS_INTERVAL;
controller.update(at, 90, 100);
}
assert!(
controller.target() >= MIN,
"floor breached: {}",
controller.target()
);
}
}