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}