use pav_regression::{IsotonicRegression, Point};
use std::collections::VecDeque;
const MAX_PRIOR_POINTS: usize = 500;
pub(crate) const NEUTRAL_DEMAND: f64 = 1.0;
const DISTANCE_PRIOR_DECAY: f64 = 4.0;
const PRIOR_PSEUDO_COUNT: f64 = 5.0;
fn distance_prior(distance: f64) -> f64 {
let d = distance.clamp(0.0, 0.5);
NEUTRAL_DEMAND * (-DISTANCE_PRIOR_DECAY * d).exp()
}
fn blend_weight(n: usize) -> f64 {
PRIOR_PSEUDO_COUNT / (PRIOR_PSEUDO_COUNT + n as f64)
}
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 !distance.is_finite() {
return NEUTRAL_DEMAND;
}
let g0 = distance_prior(distance);
let w = blend_weight(self.raw_points.len());
match self.fit(distance) {
Some(fit) => w * g0 + (1.0 - w) * fit,
None => g0,
}
}
fn fit(&self, distance: f64) -> Option<f64> {
if self.raw_points.is_empty() {
return None;
}
match self.regression.interpolate(distance) {
Some(rate) if rate.is_finite() => Some(rate.max(0.0)),
_ => None,
}
}
#[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 cold_prior_is_monotone_decreasing_in_distance() {
let prior = ProximityPrior::new();
assert_eq!(prior.len(), 0);
let mut prev = f64::INFINITY;
let mut x = 0.0;
while x <= 0.5 + 1e-9 {
let y = prior.predict(x);
assert!(y > 0.0, "cold prior must stay positive: g0({x}) = {y}");
assert!(
y <= prev + 1e-12,
"cold prior must be non-increasing in distance: g0({x}) = {y} > previous {prev}"
);
prev = y;
x += 0.05;
}
assert!(
prior.predict(0.02) > prior.predict(0.45),
"near-key cold demand must exceed far-key: near={}, far={}",
prior.predict(0.02),
prior.predict(0.45),
);
}
#[test]
fn warm_observations_wash_out_the_sloped_cold_prior() {
const FLAT_RATE: f64 = 5.0;
let cold = ProximityPrior::new();
let cold_spread = cold.predict(0.02) - cold.predict(0.45);
assert!(
cold_spread > 0.3,
"cold prior must carry a near>far slope, got spread {cold_spread}"
);
let mut warm = ProximityPrior::new();
for i in 0..200 {
let d = 0.01 + (i % 49) as f64 / 100.0; warm.observe(d, FLAT_RATE);
}
let near = warm.predict(0.02);
let far = warm.predict(0.45);
let warm_spread = (near - far).abs();
assert!(
warm_spread < 0.25,
"warm flat-rate fit must wash out the prior slope: near={near}, far={far}, spread={warm_spread}"
);
assert!(
near > 3.0 && far > 3.0,
"warm predictions must track the observed rate {FLAT_RATE}: near={near}, far={far}"
);
}
#[test]
fn prediction_transitions_smoothly_from_prior_to_fit() {
const FLAT_RATE: f64 = 5.0;
let mut prior = ProximityPrior::new();
let p0 = prior.predict(0.45); prior.observe(0.20, FLAT_RATE);
let p1 = prior.predict(0.45);
prior.observe(0.25, FLAT_RATE);
let p2 = prior.predict(0.45);
prior.observe(0.30, FLAT_RATE);
let p3 = prior.predict(0.45);
assert!(
p0 < p1 && p1 < p2 && p2 < p3,
"far-distance prediction must climb monotonically toward the fit as \
samples accrue: p0={p0}, p1={p1}, p2={p2}, p3={p3}"
);
assert!(
p3 < FLAT_RATE,
"still short of the full fit after only 3 samples (prior still weighted): p3={p3}"
);
}
#[test]
fn blend_weight_decays_with_sample_count() {
assert_eq!(blend_weight(0), 1.0, "prior owns the estimate at n = 0");
let mut prev = f64::INFINITY;
for n in [0usize, 1, 2, 5, 10, 25, 50, 100, 250, 500] {
let w = blend_weight(n);
assert!(w > 0.0 && w <= 1.0, "weight out of (0, 1]: w({n}) = {w}");
assert!(
w < prev,
"weight must strictly decrease: w({n}) = {w} >= {prev}"
);
prev = w;
}
assert!(
(blend_weight(PRIOR_PSEUDO_COUNT as usize) - 0.5).abs() < 1e-9,
"prior and fit weigh equally at n = PRIOR_PSEUDO_COUNT"
);
assert!(
blend_weight(MAX_PRIOR_POINTS) < 0.02,
"prior weight must be negligible once the sample window is full: {}",
blend_weight(MAX_PRIOR_POINTS)
);
}
#[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_eq!(prior.len(), samples.len());
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");
let cold = prior.predict(0.1);
assert!(
cold.is_finite() && cold > 0.0,
"empty prior must return a finite positive estimate, got {cold}"
);
}
#[test]
fn predict_guards_non_finite_distance() {
let cold = ProximityPrior::new();
assert_eq!(cold.predict(f64::NAN), NEUTRAL_DEMAND);
assert_eq!(cold.predict(f64::INFINITY), NEUTRAL_DEMAND);
assert_eq!(cold.predict(f64::NEG_INFINITY), NEUTRAL_DEMAND);
let mut warm = ProximityPrior::new();
for i in 0..20 {
warm.observe(0.01 * i as f64, 5.0);
}
assert_eq!(warm.predict(f64::NAN), NEUTRAL_DEMAND);
assert_eq!(warm.predict(f64::INFINITY), NEUTRAL_DEMAND);
assert_eq!(warm.predict(f64::NEG_INFINITY), 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);
}
}