Skip to main content

polydat_nodes/
stability.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Signal-settling / steady-state detection nodes.
5//!
6//! SRD-86 §"Causal ordering, the freshness register, and
7//! settling". An optimizer objective declared over a run-produced
8//! metric (e.g. `score := 0 - metric("errors","rate")`) is a
9//! *volatile* wire: its per-cycle value chases the live metric
10//! window, and at phase completion the trailing window is empty so
11//! a naïve post-execution read returns the empty-window value
12//! rather than the level the phase actually produced.
13//!
14//! `is_stable` conditions that volatile wire into a stable
15//! register the phase executor can read after completion. It is a
16//! *stateful* node — its cross-cycle state (a bounded ring of the
17//! most recent samples) lives in a `Mutex<SettleState>` setup
18//! field, exactly as `fft_analyze` carries its window buffer.
19//! Because the state is an internal register and not a global,
20//! every output is a deterministic function of the input *history*
21//! — the node is fully verifiable in polydat function space by
22//! feeding a sample sequence and asserting the `(stable_value,
23//! stable)` output sequence (see this module's tests).
24//!
25//! Cross-cycle *wire* reference is not expressible in polydat (a
26//! wire cannot read its own prior value), so the register is held
27//! internally and re-published each cycle as the `stable_value`
28//! output rather than threaded back in as an input wire.
29
30use std::collections::VecDeque;
31use std::sync::Mutex;
32
33/// Cross-cycle register for [`is_stable`]: a bounded ring of the
34/// most recent objective samples. The ring is the only state; the
35/// reported `stable_value` (median) and `stable` (steady-state)
36/// outputs are derived from it each eval, so a re-evaluation after
37/// the run (e.g. the executor's completion read) that pushes one
38/// trailing empty-window sample cannot move the median off the
39/// settled level.
40struct SettleState {
41    samples: VecDeque<f64>,
42}
43
44/// Build the per-call settle register pre-sized to `horizon`. The
45/// horizon is clamped to a minimum of 1; the eval body enforces it
46/// as the ring bound each cycle.
47fn settle_register(horizon: u64) -> Mutex<SettleState> {
48    let ring: Vec<_> = polydat::derive_support::buffer_for(horizon.max(1), "is_stable horizon");
49    Mutex::new(SettleState {
50        // `VecDeque::from` keeps the reserved capacity.
51        samples: VecDeque::from(ring),
52    })
53}
54
55/// Condition a per-cycle objective signal into a settled register
56/// and a steady-state signal.
57///
58/// Signature: `is_stable(objective_value: f64, margin: f64,
59/// min_samples: u64, horizon: u64) -> (stable_value: f64, stable:
60/// u64)`
61///
62/// Each cycle pushes `objective_value` onto a bounded ring of the
63/// most recent `horizon` samples and reports two outputs:
64///
65/// - `stable_value` — the **median** of the ring, a robust
66///   central-tendency estimate of the level the phase is
67///   currently producing. This is the register the phase executor
68///   reads as the optimizer objective after completion. It is
69///   populated from the first sample and, being a median over the
70///   window, is resistant to a single trailing empty-window
71///   outlier.
72/// - `stable` — `1` when the signal has reached steady state:
73///   the ring holds at least `min_samples` samples *and* the
74///   sample standard deviation is within `margin · max(|median|,
75///   1)` (a relative band with an absolute floor so a level near
76///   zero still settles). Otherwise `0`. The executor reads this
77///   to decide when settling is complete and the phase may stop.
78///
79/// Declared `Nondeterministic`: the output depends on the sample
80/// history accumulated across calls, not on the current input
81/// alone. The eval-spanning ring is the load-bearing aspect.
82#[polydat::polydat_node(
83    category = Math,
84    purity = Nondeterministic("accumulates objective samples across cycles; outputs depend on prior history"),
85    output_names(stable_value, stable),
86)]
87fn is_stable(
88    objective_value: f64,
89    #[poly_default(0.05f64)] margin: polydat::derive_support::Const<f64>,
90    #[poly_default(8u64)] min_samples: polydat::derive_support::Const<u64>,
91    #[poly_default(32u64)] horizon: polydat::derive_support::Const<u64>,
92    #[poly_const(settle_register, from = horizon)] register: &Mutex<SettleState>,
93) -> (f64, u64) {
94    let cap = (*horizon).max(1) as usize;
95    let min_n = (*min_samples) as usize;
96
97    let mut st = register.lock().unwrap();
98    st.samples.push_back(objective_value);
99    while st.samples.len() > cap {
100        st.samples.pop_front();
101    }
102    let n = st.samples.len();
103    if n == 0 {
104        return (0.0, 0);
105    }
106
107    // Robust central tendency: median of the recent window. This
108    // is the register the executor reads as the objective — always
109    // populated, and resistant to a single trailing empty-window
110    // outlier (one sample among `horizon`).
111    let mut sorted: Vec<f64> = st.samples.iter().copied().collect();
112    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
113    let median = if n % 2 == 1 {
114        sorted[n / 2]
115    } else {
116        0.5 * (sorted[n / 2 - 1] + sorted[n / 2])
117    };
118
119    // Strict steady-state gate: enough samples AND low spread.
120    let stable = if n < min_n {
121        0u64
122    } else {
123        let mean = st.samples.iter().sum::<f64>() / n as f64;
124        let var = st
125            .samples
126            .iter()
127            .map(|x| {
128                let d = x - mean;
129                d * d
130            })
131            .sum::<f64>()
132            / n as f64;
133        let stddev = var.sqrt();
134        let threshold = (*margin) * median.abs().max(1.0);
135        if stddev <= threshold { 1 } else { 0 }
136    };
137
138    (median, stable)
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use polydat::ast::{PolydatNode, Value};
145
146    /// Regression: a declared `input x: f64` must keep its f64 type
147    /// through the DSL. It used to be typed `U64` (the assembler seeded
148    /// every input as U64 and dropped the declared type), forcing a
149    /// spurious `U64→F64` adapter at every f64 consumer that panicked
150    /// at runtime when the f64 value met the adapter's `as_u64`. Here
151    /// the f64 input flows through the heterogeneous `(f64, u64)`
152    /// `is_stable` destructure without an adapter.
153    #[test]
154    fn declared_f64_input_flows_without_a_spurious_adapter() {
155        let mut k = polydat::dsl::compile::compile_polydat_interpreter(
156            "input source: f64\n(stable_value, stable) := is_stable(source, 0.05, 4, 8)",
157        )
158        .expect("compile");
159        if let Some(idx) = k.program().find_input("source") {
160            k.state().set_input(idx, Value::F64(5.0));
161        }
162        // A wrong U64→F64 adapter would panic pulling these (f64 read
163        // as u64). The destructure types resolve: value=f64, signal=u64.
164        assert_eq!(k.pull_ref("stable").as_u64(), 0, "n=1 < min_samples");
165        assert!((k.pull_ref("stable_value").as_f64() - 5.0).abs() < 1e-9);
166    }
167
168    /// Feed a noisy ramp that ages out of an 8-deep window, then a
169    /// steady tail: the strict gate latches and the register tracks
170    /// the steady level.
171    #[test]
172    fn settles_on_a_steady_signal_and_reports_the_level() {
173        let node = IsStable::new(0.05, 4, 8);
174        let mut out = [Value::None, Value::None];
175        for x in [0.0, 1.0, 3.0, 4.5] {
176            node.eval(&[Value::F64(x)], &mut out);
177        }
178        // Eight steady samples fully evict the ramp from the ring.
179        for _ in 0..8 {
180            node.eval(&[Value::F64(5.0)], &mut out);
181        }
182        assert_eq!(out[1].as_u64(), 1, "steady tail should report stable");
183        assert!(
184            (out[0].as_f64() - 5.0).abs() < 1e-9,
185            "register should track the steady level, got {}",
186            out[0].as_f64()
187        );
188    }
189
190    /// Once settled, a single trailing empty-window outlier (the
191    /// shape of the executor's post-completion read) must not move
192    /// the register off the produced level — the median absorbs it.
193    #[test]
194    fn a_trailing_outlier_does_not_corrupt_the_register() {
195        let node = IsStable::new(0.05, 4, 8);
196        let mut out = [Value::None, Value::None];
197        for _ in 0..8 {
198            node.eval(&[Value::F64(5.0)], &mut out);
199        }
200        assert!((out[0].as_f64() - 5.0).abs() < 1e-9, "settled at 5.0");
201
202        // One trailing outlier: window becomes [5×7, 0]; median 5.
203        node.eval(&[Value::F64(0.0)], &mut out);
204        assert!(
205            (out[0].as_f64() - 5.0).abs() < 1e-9,
206            "median is robust to one outlier, got {}",
207            out[0].as_f64()
208        );
209        assert_eq!(out[1].as_u64(), 0, "one outlier breaks strict steady-state");
210    }
211
212    /// Viability floor: below `min_samples` the signal is never
213    /// stable, however clean the data.
214    #[test]
215    fn reports_unstable_until_min_samples() {
216        let node = IsStable::new(0.05, 4, 8);
217        let mut out = [Value::None, Value::None];
218        node.eval(&[Value::F64(5.0)], &mut out);
219        assert_eq!(out[1].as_u64(), 0, "1 sample < min_samples");
220        node.eval(&[Value::F64(5.0)], &mut out);
221        node.eval(&[Value::F64(5.0)], &mut out);
222        assert_eq!(out[1].as_u64(), 0, "3 samples < min_samples=4");
223        node.eval(&[Value::F64(5.0)], &mut out);
224        assert_eq!(
225            out[1].as_u64(),
226            1,
227            "4 steady samples reach min_samples with zero spread"
228        );
229    }
230
231    /// A level near zero still settles thanks to the absolute floor
232    /// in the threshold (relevant to `score = -err_rate` objectives
233    /// whose settled value sits at or near 0).
234    #[test]
235    fn a_near_zero_level_still_settles() {
236        let node = IsStable::new(0.05, 4, 8);
237        let mut out = [Value::None, Value::None];
238        for _ in 0..8 {
239            node.eval(&[Value::F64(0.0)], &mut out);
240        }
241        assert_eq!(out[1].as_u64(), 1, "steady zero is stable");
242        assert!((out[0].as_f64()).abs() < 1e-9, "register at the zero level");
243    }
244}