use std::collections::VecDeque;
use std::sync::Mutex;
struct SettleState {
samples: VecDeque<f64>,
}
impl crate::derive_support::PolydatSetup for Mutex<SettleState> {}
fn settle_register(horizon: u64) -> Mutex<SettleState> {
let cap = horizon.max(1) as usize;
Mutex::new(SettleState {
samples: VecDeque::with_capacity(cap),
})
}
#[crate::polydat_node(
category = Math,
purity = Nondeterministic("accumulates objective samples across cycles; outputs depend on prior history"),
output_names(stable_value, stable),
)]
fn is_stable(
objective_value: f64,
#[poly_default(0.05f64)] margin: crate::derive_support::Const<f64>,
#[poly_default(8u64)] min_samples: crate::derive_support::Const<u64>,
#[poly_default(32u64)] horizon: crate::derive_support::Const<u64>,
#[poly_const(settle_register, from = horizon)] register: &Mutex<SettleState>,
) -> (f64, u64) {
let cap = (*horizon).max(1) as usize;
let min_n = (*min_samples) as usize;
let mut st = register.lock().unwrap();
st.samples.push_back(objective_value);
while st.samples.len() > cap {
st.samples.pop_front();
}
let n = st.samples.len();
if n == 0 {
return (0.0, 0);
}
let mut sorted: Vec<f64> = st.samples.iter().copied().collect();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let median = if n % 2 == 1 {
sorted[n / 2]
} else {
0.5 * (sorted[n / 2 - 1] + sorted[n / 2])
};
let stable = if n < min_n {
0u64
} else {
let mean = st.samples.iter().sum::<f64>() / n as f64;
let var = st
.samples
.iter()
.map(|x| {
let d = x - mean;
d * d
})
.sum::<f64>()
/ n as f64;
let stddev = var.sqrt();
let threshold = (*margin) * median.abs().max(1.0);
if stddev <= threshold { 1 } else { 0 }
};
(median, stable)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ast::{PolydatNode, Value};
#[test]
fn declared_f64_input_flows_without_a_spurious_adapter() {
let mut k = crate::dsl::compile::compile_polydat(
"input source: f64\n(stable_value, stable) := is_stable(source, 0.05, 4, 8)",
)
.expect("compile");
if let Some(idx) = k.program().find_input("source") {
k.state().set_input(idx, Value::F64(5.0));
}
assert_eq!(k.pull("stable").as_u64(), 0, "n=1 < min_samples");
assert!((k.pull("stable_value").as_f64() - 5.0).abs() < 1e-9);
}
#[test]
fn settles_on_a_steady_signal_and_reports_the_level() {
let node = IsStable::new(0.05, 4, 8);
let mut out = [Value::None, Value::None];
for x in [0.0, 1.0, 3.0, 4.5] {
node.eval(&[Value::F64(x)], &mut out);
}
for _ in 0..8 {
node.eval(&[Value::F64(5.0)], &mut out);
}
assert_eq!(out[1].as_u64(), 1, "steady tail should report stable");
assert!(
(out[0].as_f64() - 5.0).abs() < 1e-9,
"register should track the steady level, got {}",
out[0].as_f64()
);
}
#[test]
fn a_trailing_outlier_does_not_corrupt_the_register() {
let node = IsStable::new(0.05, 4, 8);
let mut out = [Value::None, Value::None];
for _ in 0..8 {
node.eval(&[Value::F64(5.0)], &mut out);
}
assert!((out[0].as_f64() - 5.0).abs() < 1e-9, "settled at 5.0");
node.eval(&[Value::F64(0.0)], &mut out);
assert!(
(out[0].as_f64() - 5.0).abs() < 1e-9,
"median is robust to one outlier, got {}",
out[0].as_f64()
);
assert_eq!(
out[1].as_u64(),
0,
"one outlier breaks strict steady-state"
);
}
#[test]
fn reports_unstable_until_min_samples() {
let node = IsStable::new(0.05, 4, 8);
let mut out = [Value::None, Value::None];
node.eval(&[Value::F64(5.0)], &mut out);
assert_eq!(out[1].as_u64(), 0, "1 sample < min_samples");
node.eval(&[Value::F64(5.0)], &mut out);
node.eval(&[Value::F64(5.0)], &mut out);
assert_eq!(out[1].as_u64(), 0, "3 samples < min_samples=4");
node.eval(&[Value::F64(5.0)], &mut out);
assert_eq!(
out[1].as_u64(),
1,
"4 steady samples reach min_samples with zero spread"
);
}
#[test]
fn a_near_zero_level_still_settles() {
let node = IsStable::new(0.05, 4, 8);
let mut out = [Value::None, Value::None];
for _ in 0..8 {
node.eval(&[Value::F64(0.0)], &mut out);
}
assert_eq!(out[1].as_u64(), 1, "steady zero is stable");
assert!((out[0].as_f64()).abs() < 1e-9, "register at the zero level");
}
}