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}