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 cap = horizon.max(1) as usize;
49    Mutex::new(SettleState {
50        samples: VecDeque::with_capacity(cap),
51    })
52}
53
54/// Condition a per-cycle objective signal into a settled register
55/// and a steady-state signal.
56///
57/// Signature: `is_stable(objective_value: f64, margin: f64,
58/// min_samples: u64, horizon: u64) -> (stable_value: f64, stable:
59/// u64)`
60///
61/// Each cycle pushes `objective_value` onto a bounded ring of the
62/// most recent `horizon` samples and reports two outputs:
63///
64/// - `stable_value` — the **median** of the ring, a robust
65///   central-tendency estimate of the level the phase is
66///   currently producing. This is the register the phase executor
67///   reads as the optimizer objective after completion. It is
68///   populated from the first sample and, being a median over the
69///   window, is resistant to a single trailing empty-window
70///   outlier.
71/// - `stable` — `1` when the signal has reached steady state:
72///   the ring holds at least `min_samples` samples *and* the
73///   sample standard deviation is within `margin · max(|median|,
74///   1)` (a relative band with an absolute floor so a level near
75///   zero still settles). Otherwise `0`. The executor reads this
76///   to decide when settling is complete and the phase may stop.
77///
78/// Declared `Nondeterministic`: the output depends on the sample
79/// history accumulated across calls, not on the current input
80/// alone. The eval-spanning ring is the load-bearing aspect.
81#[polydat::polydat_node(
82    category = Math,
83    purity = Nondeterministic("accumulates objective samples across cycles; outputs depend on prior history"),
84    output_names(stable_value, stable),
85)]
86fn is_stable(
87    objective_value: f64,
88    #[poly_default(0.05f64)] margin: polydat::derive_support::Const<f64>,
89    #[poly_default(8u64)] min_samples: polydat::derive_support::Const<u64>,
90    #[poly_default(32u64)] horizon: polydat::derive_support::Const<u64>,
91    #[poly_const(settle_register, from = horizon)] register: &Mutex<SettleState>,
92) -> (f64, u64) {
93    let cap = (*horizon).max(1) as usize;
94    let min_n = (*min_samples) as usize;
95
96    let mut st = register.lock().unwrap();
97    st.samples.push_back(objective_value);
98    while st.samples.len() > cap {
99        st.samples.pop_front();
100    }
101    let n = st.samples.len();
102    if n == 0 {
103        return (0.0, 0);
104    }
105
106    // Robust central tendency: median of the recent window. This
107    // is the register the executor reads as the objective — always
108    // populated, and resistant to a single trailing empty-window
109    // outlier (one sample among `horizon`).
110    let mut sorted: Vec<f64> = st.samples.iter().copied().collect();
111    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
112    let median = if n % 2 == 1 {
113        sorted[n / 2]
114    } else {
115        0.5 * (sorted[n / 2 - 1] + sorted[n / 2])
116    };
117
118    // Strict steady-state gate: enough samples AND low spread.
119    let stable = if n < min_n {
120        0u64
121    } else {
122        let mean = st.samples.iter().sum::<f64>() / n as f64;
123        let var = st
124            .samples
125            .iter()
126            .map(|x| {
127                let d = x - mean;
128                d * d
129            })
130            .sum::<f64>()
131            / n as f64;
132        let stddev = var.sqrt();
133        let threshold = (*margin) * median.abs().max(1.0);
134        if stddev <= threshold { 1 } else { 0 }
135    };
136
137    (median, stable)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use polydat::ast::{PolydatNode, Value};
144
145    /// Regression: a declared `input x: f64` must keep its f64 type
146    /// through the DSL. It used to be typed `U64` (the assembler seeded
147    /// every input as U64 and dropped the declared type), forcing a
148    /// spurious `U64→F64` adapter at every f64 consumer that panicked
149    /// at runtime when the f64 value met the adapter's `as_u64`. Here
150    /// the f64 input flows through the heterogeneous `(f64, u64)`
151    /// `is_stable` destructure without an adapter.
152    #[test]
153    fn declared_f64_input_flows_without_a_spurious_adapter() {
154        let mut k = polydat::dsl::compile::compile_polydat(
155            "input source: f64\n(stable_value, stable) := is_stable(source, 0.05, 4, 8)",
156        )
157        .expect("compile");
158        if let Some(idx) = k.program().find_input("source") {
159            k.state().set_input(idx, Value::F64(5.0));
160        }
161        // A wrong U64→F64 adapter would panic pulling these (f64 read
162        // as u64). The destructure types resolve: value=f64, signal=u64.
163        assert_eq!(k.pull("stable").as_u64(), 0, "n=1 < min_samples");
164        assert!((k.pull("stable_value").as_f64() - 5.0).abs() < 1e-9);
165    }
166
167    /// Feed a noisy ramp that ages out of an 8-deep window, then a
168    /// steady tail: the strict gate latches and the register tracks
169    /// the steady level.
170    #[test]
171    fn settles_on_a_steady_signal_and_reports_the_level() {
172        let node = IsStable::new(0.05, 4, 8);
173        let mut out = [Value::None, Value::None];
174        for x in [0.0, 1.0, 3.0, 4.5] {
175            node.eval(&[Value::F64(x)], &mut out);
176        }
177        // Eight steady samples fully evict the ramp from the ring.
178        for _ in 0..8 {
179            node.eval(&[Value::F64(5.0)], &mut out);
180        }
181        assert_eq!(out[1].as_u64(), 1, "steady tail should report stable");
182        assert!(
183            (out[0].as_f64() - 5.0).abs() < 1e-9,
184            "register should track the steady level, got {}",
185            out[0].as_f64()
186        );
187    }
188
189    /// Once settled, a single trailing empty-window outlier (the
190    /// shape of the executor's post-completion read) must not move
191    /// the register off the produced level — the median absorbs it.
192    #[test]
193    fn a_trailing_outlier_does_not_corrupt_the_register() {
194        let node = IsStable::new(0.05, 4, 8);
195        let mut out = [Value::None, Value::None];
196        for _ in 0..8 {
197            node.eval(&[Value::F64(5.0)], &mut out);
198        }
199        assert!((out[0].as_f64() - 5.0).abs() < 1e-9, "settled at 5.0");
200
201        // One trailing outlier: window becomes [5×7, 0]; median 5.
202        node.eval(&[Value::F64(0.0)], &mut out);
203        assert!(
204            (out[0].as_f64() - 5.0).abs() < 1e-9,
205            "median is robust to one outlier, got {}",
206            out[0].as_f64()
207        );
208        assert_eq!(out[1].as_u64(), 0, "one outlier breaks strict steady-state");
209    }
210
211    /// Viability floor: below `min_samples` the signal is never
212    /// stable, however clean the data.
213    #[test]
214    fn reports_unstable_until_min_samples() {
215        let node = IsStable::new(0.05, 4, 8);
216        let mut out = [Value::None, Value::None];
217        node.eval(&[Value::F64(5.0)], &mut out);
218        assert_eq!(out[1].as_u64(), 0, "1 sample < min_samples");
219        node.eval(&[Value::F64(5.0)], &mut out);
220        node.eval(&[Value::F64(5.0)], &mut out);
221        assert_eq!(out[1].as_u64(), 0, "3 samples < min_samples=4");
222        node.eval(&[Value::F64(5.0)], &mut out);
223        assert_eq!(
224            out[1].as_u64(),
225            1,
226            "4 steady samples reach min_samples with zero spread"
227        );
228    }
229
230    /// A level near zero still settles thanks to the absolute floor
231    /// in the threshold (relevant to `score = -err_rate` objectives
232    /// whose settled value sits at or near 0).
233    #[test]
234    fn a_near_zero_level_still_settles() {
235        let node = IsStable::new(0.05, 4, 8);
236        let mut out = [Value::None, Value::None];
237        for _ in 0..8 {
238            node.eval(&[Value::F64(0.0)], &mut out);
239        }
240        assert_eq!(out[1].as_u64(), 1, "steady zero is stable");
241        assert!((out[0].as_f64()).abs() < 1e-9, "register at the zero level");
242    }
243}