use pav_regression::{IsotonicRegression, Point};
use std::collections::VecDeque;
const MIN_POINTS_FOR_PRIOR: usize = 5;
const MAX_PRIOR_POINTS: usize = 500;
pub(crate) const NEUTRAL_DEMAND: f64 = 1.0;
pub(crate) struct ProximityPrior {
regression: IsotonicRegression<f64>,
raw_points: VecDeque<Point<f64>>,
}
impl ProximityPrior {
pub(crate) fn new() -> Self {
let empty: [Point<f64>; 0] = [];
let regression = IsotonicRegression::new_descending(&empty)
.expect("empty descending isotonic regression is always constructible");
Self {
regression,
raw_points: VecDeque::new(),
}
}
pub(crate) fn observe(&mut self, distance: f64, read_rate: f64) {
if !distance.is_finite() || !read_rate.is_finite() || distance < 0.0 || read_rate < 0.0 {
return;
}
let point = Point::new(distance, read_rate);
self.regression.add_points(&[point]);
self.raw_points.push_back(point);
if self.raw_points.len() > MAX_PRIOR_POINTS {
if let Some(oldest) = self.raw_points.pop_front() {
self.regression.remove_points(&[oldest]);
}
}
}
pub(crate) fn predict(&self, distance: f64) -> f64 {
if self.raw_points.len() < MIN_POINTS_FOR_PRIOR || !distance.is_finite() {
return NEUTRAL_DEMAND;
}
match self.regression.interpolate(distance) {
Some(rate) if rate.is_finite() => rate.max(0.0),
_ => NEUTRAL_DEMAND,
}
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.raw_points.len()
}
}
impl Default for ProximityPrior {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_prior_is_neutral_everywhere() {
let prior = ProximityPrior::new();
assert_eq!(prior.len(), 0);
assert_eq!(prior.predict(0.0), NEUTRAL_DEMAND);
assert_eq!(prior.predict(0.25), NEUTRAL_DEMAND);
assert_eq!(prior.predict(0.5), NEUTRAL_DEMAND);
}
#[test]
fn prior_stays_neutral_below_min_points() {
let mut prior = ProximityPrior::new();
for i in 0..(MIN_POINTS_FOR_PRIOR - 1) {
prior.observe(0.1 * i as f64, 10.0 - i as f64);
}
assert!(prior.len() < MIN_POINTS_FOR_PRIOR);
assert_eq!(prior.predict(0.0), NEUTRAL_DEMAND);
}
#[test]
fn fitted_prior_is_monotone_non_increasing() {
let mut prior = ProximityPrior::new();
let samples = [
(0.02, 20.0),
(0.05, 18.0),
(0.05, 22.0), (0.10, 12.0),
(0.15, 9.0),
(0.20, 7.0),
(0.30, 3.0),
(0.40, 2.0),
(0.45, 1.0),
(0.48, 0.5),
];
for (d, r) in samples {
prior.observe(d, r);
}
assert!(prior.len() >= MIN_POINTS_FOR_PRIOR);
let mut prev = f64::INFINITY;
let mut x = 0.05;
while x <= 0.45 + 1e-9 {
let y = prior.predict(x);
assert!(
y <= prev + 1e-9,
"prior must be non-increasing in distance: g({x}) = {y} > previous {prev}"
);
prev = y;
x += 0.02;
}
assert!(
prior.predict(0.05) > prior.predict(0.45),
"near-key demand must exceed far-key demand: near={}, far={}",
prior.predict(0.05),
prior.predict(0.45),
);
}
#[test]
fn rolling_window_is_bounded() {
let mut prior = ProximityPrior::new();
for i in 0..(MAX_PRIOR_POINTS + 100) {
let d = (i % 50) as f64 / 100.0; prior.observe(d, (50 - (i % 50)) as f64);
}
assert!(
prior.len() <= MAX_PRIOR_POINTS,
"raw points must be bounded, got {}",
prior.len()
);
let y = prior.predict(0.1);
assert!(y.is_finite() && y >= 0.0, "prediction must stay valid: {y}");
}
#[test]
fn invalid_observations_are_ignored() {
let mut prior = ProximityPrior::new();
prior.observe(f64::NAN, 5.0);
prior.observe(0.1, f64::INFINITY);
prior.observe(-0.1, 5.0);
prior.observe(0.1, -5.0);
assert_eq!(prior.len(), 0, "no invalid point should be retained");
assert_eq!(prior.predict(0.1), NEUTRAL_DEMAND);
}
#[test]
fn prediction_is_non_negative_out_of_range() {
let mut prior = ProximityPrior::new();
for i in 0..10 {
prior.observe(0.01 * i as f64, 5.0 - 0.4 * i as f64);
}
assert!(prior.predict(0.5) >= 0.0);
assert!(prior.predict(0.49) >= 0.0);
}
}