Skip to main content

nmbrs_runtime/optimize/
settle.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-86 §"Settling via the cadence pulse" — the per-pulse settle
5//! interpreter and its [`PulseEvaluator`] adapter.
6//!
7//! For a *volatile* optimizer objective (one defined over a windowed
8//! run-produced metric such as `metric_window("...","errors")` or a
9//! `metricsql_*` reader), the objective value chases the live cadence
10//! window and at phase completion the trailing window is empty. The
11//! objective therefore cannot be read by a one-shot post-execution
12//! pull; it must be *settled* across the run and held in a register the
13//! executor reads at completion.
14//!
15//! [`SettleInterpreter`] is the settle-signal engine. It owns one
16//! persistent settle-kernel — typically the phase's objective bindings
17//! plus `(stable_value, stable) := is_stable(<objective>, …)` — and is
18//! driven once per cadence pulse:
19//!
20//! 1. `set_input` on the kernel's poke input advances the generation,
21//!    which dirties **every** non-deterministic node (engine rule, see
22//!    `kernel::engines::set_input`): the embedded volatile objective
23//!    reader re-reads the latest published window and
24//!    [`is_stable`](polydat::library::stability) re-evaluates — exactly
25//!    one new sample on its ring.
26//! 2. Pull `stable` then `stable_value`; both land on that single
27//!    evaluation (generation cache), so the multi-output node
28//!    contributes one sample per pulse, not two.
29//! 3. Publish `stable_value` into the shared register (an [`ArcSwap`],
30//!    lock-free for the executor's completion read).
31//!
32//! The interpreter does **not** decide phase stop — that is the
33//! [`SettleEvaluator`]'s job (settled ⇒ `interrupted`; timed out ⇒
34//! `failed`), driven through the general
35//! [`super::phase_pulse::PhaseStopEvaluator`] callback registered on the
36//! metrics cadence feed.
37
38use std::sync::Arc;
39use std::sync::atomic::AtomicBool;
40use std::time::{Duration, Instant};
41
42use crate::scope_kernel::ScopeKernel;
43use arc_swap::ArcSwap;
44use nmbrs_metrics::cadence_reporter::{CadenceReporter, SubscriberId};
45use nmbrs_metrics::snapshot::MetricSet;
46use polydat::Kernel;
47use polydat::ast::Value;
48use polydat::kernel::PolydatProgram;
49
50use super::phase_pulse::{PhaseStopEvaluator, PulseEvaluator, StopOutcomeCell};
51use crate::phase_outcome::Outcome;
52
53/// Convert a pulled objective wire to f64 with the same numeric
54/// coercion as `read_objective_at_completion` (F64 as-is, U64 widened,
55/// Bool 0/1); a non-numeric objective reads as 0.0 (the
56/// volatile-objective gate upstream only admits numeric readers).
57fn objective_to_f64(v: &Value) -> f64 {
58    match v {
59        Value::F64(f) => *f,
60        Value::U64(u) => *u as f64,
61        Value::Bool(b) => {
62            if *b {
63                1.0
64            } else {
65                0.0
66            }
67        }
68        _ => 0.0,
69    }
70}
71
72/// The latest reading a [`SettleInterpreter`] publishes.
73#[derive(Clone, Copy, Debug)]
74pub struct SettleReading {
75    /// The stabilized objective value — `is_stable`'s `stable_value`
76    /// output (the median of the recent window). This is what the
77    /// phase executor reads as the objective at completion.
78    pub value: f64,
79    /// Whether the signal reached steady state on the latest pulse.
80    pub stable: bool,
81    /// Count of pulses delivered so far (viability / diagnostics).
82    pub pulses: u64,
83}
84
85impl Default for SettleReading {
86    fn default() -> Self {
87        Self {
88            value: 0.0,
89            stable: false,
90            pulses: 0,
91        }
92    }
93}
94
95/// Per-pulse interpreter of objective settling. See the module docs.
96pub struct SettleInterpreter {
97    /// A standalone kernel on whatever engine the compile chose — it
98    /// takes no part in the scope tree, so it is driven through the
99    /// engine-neutral [`Kernel`] trait.
100    kernel: Box<dyn Kernel>,
101    /// Index of the kernel's `samples: vec_f64` input, the window
102    /// `is_stable` judges.
103    samples_input: usize,
104    /// The most recent samples, oldest first, at most `horizon` of them.
105    /// `is_stable` is a pure function of its window, so the interpreter
106    /// owns the history.
107    window: std::collections::VecDeque<f64>,
108    horizon: usize,
109    value_wire: String,
110    stable_wire: String,
111    register: Arc<ArcSwap<SettleReading>>,
112    pulses: u64,
113}
114
115impl SettleInterpreter {
116    /// Build an interpreter over a compiled settle kernel. `samples` is
117    /// its `vec_f64` window input; `value_wire` / `stable_wire` are the
118    /// `is_stable` multi-output wire names; `horizon` is how many recent
119    /// samples the window holds.
120    ///
121    /// # Panics
122    /// When the kernel has no `samples` input: a malformed settle kernel.
123    pub fn new(
124        kernel: Box<dyn Kernel>,
125        samples: &str,
126        value_wire: &str,
127        stable_wire: &str,
128        horizon: usize,
129    ) -> Self {
130        let samples_input = kernel
131            .input_index(samples)
132            .unwrap_or_else(|| panic!("settle kernel has no `{samples}` input"));
133        Self {
134            kernel,
135            samples_input,
136            window: std::collections::VecDeque::with_capacity(horizon),
137            horizon,
138            value_wire: value_wire.to_string(),
139            stable_wire: stable_wire.to_string(),
140            register: Arc::new(ArcSwap::from_pointee(SettleReading::default())),
141            pulses: 0,
142        }
143    }
144
145    /// The shared register handle. The executor reads the settled
146    /// objective from this at phase completion; the interpreter
147    /// publishes into it on every pulse.
148    pub fn register(&self) -> Arc<ArcSwap<SettleReading>> {
149        self.register.clone()
150    }
151
152    /// Deliver one cadence pulse: append `sample` to the window (dropping
153    /// the oldest past `horizon`), write the window to the kernel, read
154    /// `is_stable`'s verdict on it, publish the stabilized value, and
155    /// return the reading.
156    pub fn pulse(&mut self, sample: f64) -> SettleReading {
157        self.pulses += 1;
158        if self.window.len() == self.horizon {
159            self.window.pop_front();
160        }
161        self.window.push_back(sample);
162        let window: Vec<f64> = self.window.iter().copied().collect();
163        // `samples` is a `vec_f64` extern, so a `VecF64` write cannot be
164        // refused; a refusal is a malformed settle kernel.
165        self.kernel
166            .set_input_at(
167                self.samples_input,
168                Value::VecF64(polydat::ast::SliceArc::from_vec(window)),
169            )
170            .expect("settle kernel refused its vec_f64 `samples` extern");
171        let stable = self.kernel.pull(&self.stable_wire).as_u64() != 0;
172        let value = self.kernel.pull(&self.value_wire).as_f64();
173
174        let reading = SettleReading {
175            value,
176            stable,
177            pulses: self.pulses,
178        };
179        self.register.store(Arc::new(reading));
180        reading
181    }
182}
183
184/// The settle detector as a [`PulseEvaluator`]. It holds the phase's
185/// **objective kernel** (a clone of node X's kernel — already bound to
186/// this evaluation's coordinate, the same kernel
187/// `read_objective_at_completion` pulls) and a fixed `is_stable`
188/// engine. Each cadence pulse:
189///
190/// 1. positions the objective kernel at the pulse's ordinal on its
191///    coordinate input (typically `cycle`); a volatile objective reader
192///    re-reads the latest published window on every pull regardless;
193/// 2. pulls the objective wire — the fresh windowed objective value;
194/// 3. feeds it to [`SettleInterpreter`] (`is_stable`).
195///
196/// It yields a terminal [`Outcome`] when the objective settles
197/// (`interrupted` — stopped early, register trustworthy) or when the
198/// settle `timeout` elapses without settling (`failed` — SRD-86 §6
199/// step 5). `None` while the loop should hold.
200pub struct SettleEvaluator {
201    objective: ScopeKernel,
202    objective_wire: String,
203    poke: Option<usize>,
204    interp: SettleInterpreter,
205    timeout: Duration,
206    /// SRD-86 viability gate — minimum WALL-CLOCK a coordinate must run before a
207    /// stable verdict is trusted, so the windowed objective's rollup has cleared
208    /// the prior coordinate (and the leading transient). Wall-clock, not pulse
209    /// count: under concurrent scheduling cadence pulses are delivered in
210    /// bursts (many per cadence interval), so a pulse gate collapses to far less
211    /// than the window — the gate must measure real time.
212    min_viable: Duration,
213    started: Option<Instant>,
214    pulses: u64,
215}
216
217impl SettleEvaluator {
218    /// `objective` is the phase's objective kernel (node X clone);
219    /// `objective_wire` the objective output; `poke_input` the coordinate
220    /// input positioned at each pulse's ordinal (typically `cycle`);
221    /// `interp` the `is_stable` engine fed the objective value.
222    pub fn new(
223        objective: ScopeKernel,
224        objective_wire: &str,
225        poke_input: &str,
226        interp: SettleInterpreter,
227        timeout: Duration,
228        min_viable: Duration,
229    ) -> Self {
230        let poke = objective.program().find_input(poke_input);
231        Self {
232            objective,
233            objective_wire: objective_wire.to_string(),
234            poke,
235            interp,
236            timeout,
237            min_viable,
238            started: None,
239            pulses: 0,
240        }
241    }
242
243    /// The settled-value register (grab it before boxing the evaluator).
244    pub fn register(&self) -> Arc<ArcSwap<SettleReading>> {
245        self.interp.register()
246    }
247}
248
249impl PulseEvaluator for SettleEvaluator {
250    fn evaluate(&mut self, _window: &MetricSet) -> Option<Outcome> {
251        let start = *self.started.get_or_insert_with(Instant::now);
252        self.pulses += 1;
253        // Position the objective kernel at this pulse, then read it; a
254        // volatile reader in its cone re-reads the latest published
255        // window on every pull.
256        if let Some(idx) = self.poke {
257            let k = &mut self.objective;
258            let coords = k.coord_count();
259            if idx < coords {
260                // A coordinate is positioned with `set_inputs`; the pulse
261                // then invalidates every output so a volatile reader
262                // re-reads (native_scope_trees.md §3, the settle pulse).
263                let mut position: Vec<u64> = (0..coords)
264                    .map(|i| k.input_value_at(i).map_or(0, |v| v.as_u64()))
265                    .collect();
266                position[idx] = self.pulses;
267                k.set_inputs(&position);
268                k.invalidate_all();
269            } else if let Err(e) = k.set_input_at(idx, Value::U64(self.pulses)) {
270                crate::diag!(
271                    crate::observer::LogLevel::Warn,
272                    "settle: the objective's poke input refused pulse {}: {e}",
273                    self.pulses
274                );
275            }
276        }
277        let obj = objective_to_f64(&self.objective.pull(&self.objective_wire));
278        // SRD-89 — a NaN objective is a windowed metric reading **no data** (an
279        // empty `rate(...[W])` lookback — see `nodes::no_data_value`), distinct
280        // from a real 0. HOLD on it: do not feed the stability detector (a
281        // fabricated 0 would let `is_stable` settle on the empty leading reads,
282        // mis-converging the optimizer under concurrency where early windows are
283        // routinely empty) and do not advance the register. The timeout still
284        // bounds the wait, so a window that never produces data fails rather
285        // than hanging.
286        if obj.is_nan() {
287            if start.elapsed() >= self.timeout {
288                return Some(Outcome::failed());
289            }
290            return None;
291        }
292        let reading = self.interp.pulse(obj);
293        // SRD-86 viability gate — do NOT trust a stable verdict until the
294        // coordinate has run for `min_viable` of WALL-CLOCK, so the windowed
295        // objective's rollup has cleared the prior coordinate (and its own
296        // leading transient). At a coordinate's START the windowed objective is
297        // a stable run of stale data — `rate(errors_total[W])` reads ~0 before
298        // the first error registers at warmup, and at a transition it reads the
299        // PRIOR coordinate's value drifting out of the window. `is_stable`
300        // (which fires on `SETTLE_MIN_SAMPLES`, and whose relative margin admits
301        // a slow drift as "stable") would latch that stale value, mis-converging
302        // the optimizer (it keeps a saturating `concurrency` because it "saw no
303        // errors", or accepts a half-cleared transition value). The gate is
304        // wall-clock, not pulse count: under concurrent scheduling cadence
305        // pulses arrive in bursts, so a pulse gate collapses to far less than
306        // the window — only real elapsed time guarantees the window has cleared.
307        if reading.stable && start.elapsed() >= self.min_viable {
308            return Some(Outcome::interrupted());
309        }
310        if start.elapsed() >= self.timeout {
311            return Some(Outcome::failed());
312        }
313        None
314    }
315}
316
317/// Metrics-reader node names whose presence makes a phase's objective
318/// potentially volatile (read from the live cadence feed). The
319/// `metric` / `metric_window` stat-readers and the four `metricsql_*`
320/// readers. (First-push heuristic: program-wide presence, not
321/// objective-cone-precise — a non-reader objective in a phase that also
322/// reads metrics elsewhere would settle trivially on its constant.)
323const READER_NODES: &[&str] = &[
324    "metric",
325    "metric_window",
326    "metricsql",
327    "metricsql_scalar",
328    "metricsql_vector",
329    "metricsql_window",
330];
331
332/// True if `program` contains a metrics-reader node — the signal that
333/// the phase's objective may be a volatile windowed metric that the
334/// one-shot post-completion read cannot capture.
335pub fn program_reads_live_metrics(program: &PolydatProgram) -> bool {
336    (0..program.node_count()).any(|i| READER_NODES.contains(&program.node_meta(i).name.as_str()))
337}
338
339/// Node name of the session-cumulative reader (`metric(...)`), which
340/// reads `MetricsQuery::session_lifetime` — a running total over ALL
341/// coordinates. Unlike the windowed readers it has no bounded lookback,
342/// so the viability gate cannot scope it to one coordinate.
343const SESSION_CUMULATIVE_READER: &str = "metric";
344
345/// True if `program` reads the session-cumulative `metric(...)` reader —
346/// an objective aggregated across every coordinate, which the per-eval
347/// warmup gate cannot isolate (no window to clear). The settle warns
348/// rather than silently treating it as per-coordinate.
349fn program_reads_session_cumulative_metrics(program: &PolydatProgram) -> bool {
350    (0..program.node_count()).any(|i| program.node_meta(i).name == SESSION_CUMULATIVE_READER)
351}
352
353/// Warn at most once per distinct objective string that it reads a
354/// session-cumulative metric. Returns `true` the first time a given
355/// objective is seen (so the caller emits the diagnostic once, not once
356/// per coordinate across a search).
357fn warn_once_session_cumulative(objective: &str) -> bool {
358    static WARNED: std::sync::LazyLock<std::sync::Mutex<std::collections::HashSet<String>>> =
359        std::sync::LazyLock::new(Default::default);
360    WARNED
361        .lock()
362        .unwrap_or_else(|e| e.into_inner())
363        .insert(objective.to_string())
364}
365
366// First-push settle parameters (the SRD-86 §6 `settle:` YAML surface +
367// finer-cadence reconfiguration are deferred). Sized for the default
368// 1 s cadence and short phases: `is_stable`'s windowed median is
369// published every pulse regardless of the `stable` flag, so the
370// register holds a smoothed objective even before a full settle; the
371// 8-deep horizon / 4-sample warmup let a steady objective settle (and
372// stop the phase early) within a handful of pulses. The generous
373// timeout means a phase that completes first simply uses its smoothed
374// value — only a genuinely non-settling long phase trips `failed`.
375//
376// The settle is gated by a viability horizon (see `SettleEvaluator::evaluate`):
377// a stable verdict is only honored once `SETTLE_HORIZON` pulses have been
378// delivered, so the verdict is always taken over a full horizon of
379// in-coordinate samples (≈ `SETTLE_HORIZON × cadence` of wall-clock, the rollup
380// window by the usual `window = horizon × cadence` sizing). Without it, a
381// windowed objective's leading transient — `rate(...[W])` reading ~0 before the
382// coordinate's first data lands — is itself momentarily "stable", and
383// `is_stable` (firing on `SETTLE_MIN_SAMPLES`) latches that phantom value; under
384// concurrent scheduling a sub-window eval did exactly that.
385const SETTLE_MARGIN: f64 = 0.05;
386const SETTLE_MIN_SAMPLES: u64 = 4;
387const SETTLE_HORIZON: u64 = 8;
388const SETTLE_TIMEOUT: Duration = Duration::from_secs(60);
389
390/// What the executor holds while a settle detector runs: the cadence
391/// subscription to tear down, the settled-value register, and the
392/// terminal-disposition cell.
393pub struct SettleHandle {
394    pub subscriber: SubscriberId,
395    pub register: Arc<ArcSwap<SettleReading>>,
396    pub outcome: StopOutcomeCell,
397}
398
399/// Why [`start_settle`] started no detector.
400#[derive(Debug)]
401pub enum SettleSkip {
402    /// The objective reads no live metric: its one-shot read at phase
403    /// completion is the value, and there is nothing to settle.
404    NotWindowed,
405    /// The metrics cadence is disabled, so no pulse would ever arrive.
406    CadenceDisabled,
407    /// The detector could not be built.
408    Failed(String),
409}
410
411impl std::fmt::Display for SettleSkip {
412    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
413        match self {
414            SettleSkip::NotWindowed => write!(f, "it reads no live windowed metric"),
415            SettleSkip::CadenceDisabled => write!(f, "the metrics cadence is disabled"),
416            SettleSkip::Failed(reason) => {
417                write!(f, "the settle detector failed to start: {reason}")
418            }
419        }
420    }
421}
422
423/// Start a cadence-fed settle detector for `objective` on the running
424/// phase **iff** the objective reads live metrics. Builds the objective
425/// kernel as node X's program rebound to `parent` (carrying the
426/// coordinate — mirrors `read_objective_at_completion`), wraps it in a
427/// [`PhaseStopEvaluator`], and subscribes it to the smallest cadence.
428/// Starts nothing for a non-volatile objective (the one-shot read path
429/// is correct there) or when the metrics cadence is disabled, and says
430/// which; a detector that cannot be built is [`SettleSkip::Failed`].
431pub fn start_settle(
432    parent: &Arc<ScopeKernel>,
433    phase_kernel: &Arc<ScopeKernel>,
434    objective: &str,
435    reporter: &Arc<CadenceReporter>,
436    stop_flag: Arc<AtomicBool>,
437) -> Result<SettleHandle, SettleSkip> {
438    let program = phase_kernel.program();
439    if !program_reads_live_metrics(program) {
440        return Err(SettleSkip::NotWindowed);
441    }
442    // A session-cumulative `metric(...)` objective has no bounded window
443    // for the gate to scope, so it cannot isolate per-coordinate — warn
444    // once and servo the author to a windowed reader.
445    if program_reads_session_cumulative_metrics(program) && warn_once_session_cumulative(objective)
446    {
447        crate::diag!(
448            crate::observer::LogLevel::Warn,
449            "optimizer objective '{objective}' reads a session-cumulative metric \
450             (`metric(...)` → session_lifetime): it aggregates across coordinates and \
451             will not isolate per-coordinate. Use `metric_window(...)` or \
452             `metricsql_scalar(rate(...[W]))` for a per-coordinate objective."
453        );
454    }
455    let cadence = reporter.declared_cadences().smallest();
456    if cadence.is_zero() {
457        return Err(SettleSkip::CadenceDisabled);
458    }
459
460    let failed = |what: &str, e: &dyn std::fmt::Display| SettleSkip::Failed(format!("{what}: {e}"));
461    let obj_kernel = phase_kernel
462        .bind_under(parent.kernel(), &[])
463        .map_err(|e| failed("objective kernel", &e))?;
464
465    let is_stable_kernel = polydat::dsl::compile::compile_polydat(&format!(
466        "extern samples: vec_f64\n(stable_value, stable) := is_stable(samples, {SETTLE_MARGIN}, \
467         {SETTLE_MIN_SAMPLES})"
468    ))
469    .map_err(|e| failed("is_stable kernel", &e))?;
470    let interp = SettleInterpreter::new(
471        is_stable_kernel,
472        "samples",
473        "stable_value",
474        "stable",
475        SETTLE_HORIZON as usize,
476    );
477    // Viability gate = the stability horizon's worth of cadence intervals, in
478    // WALL-CLOCK. With the usual `window = SETTLE_HORIZON × cadence` sizing this
479    // is the rollup window — long enough for the objective's window to clear the
480    // prior coordinate before a stable verdict is honored.
481    let min_viable = cadence.saturating_mul(SETTLE_HORIZON as u32);
482    let eval = SettleEvaluator::new(
483        obj_kernel,
484        objective,
485        "cycle",
486        interp,
487        SETTLE_TIMEOUT,
488        min_viable,
489    );
490    let register = eval.register();
491
492    let pse = PhaseStopEvaluator::new(Box::new(eval), stop_flag);
493    let outcome = pse.outcome_cell();
494    // SRD-88 — bind this subscription to THIS execution's context so its
495    // delivery fiber pulls the objective as the owning execution (the
496    // metric read scopes to its own `exec_id`). Captured in the
497    // execution's scope here; `None` in single-run (A1).
498    let mut opts = nmbrs_metrics::cadence_reporter::SubscriptionOpts::default();
499    if let Some(ctx) = crate::execution_context::try_current() {
500        opts.context_wrap = Some(std::sync::Arc::new(move |fut| {
501            Box::pin(crate::execution_context::scope(ctx.clone(), fut))
502                as std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>>
503        }));
504    }
505    let subscriber = reporter
506        .subscribe(cadence, Box::new(pse), opts)
507        .map_err(|e| failed("cadence subscription", &e))?;
508    Ok(SettleHandle {
509        subscriber,
510        register,
511        outcome,
512    })
513}
514
515#[cfg(test)]
516mod tests {
517    use super::*;
518    use crate::phase_outcome::{Disposition, Validity};
519    use polydat::dsl::compile::{compile_polydat, compile_polydat_interpreter};
520
521    /// The fixed `is_stable` engine fed the per-pulse objective value.
522    fn settle_interp() -> SettleInterpreter {
523        let kernel = compile_polydat(
524            "extern samples: vec_f64\n(stable_value, stable) := is_stable(samples, 0.05, 4)",
525        )
526        .expect("is_stable kernel compiles");
527        SettleInterpreter::new(kernel, "samples", "stable_value", "stable", 8)
528    }
529
530    fn obj_kernel(src: &str) -> ScopeKernel {
531        crate::bindings::compile_scope_kernel(src, &Default::default())
532            .expect("objective kernel compiles")
533    }
534
535    // A constant objective: settles regardless of the poke.
536    const STEADY_OBJ: &str = "input cycle: u64\nobj := 5.0";
537    // A ramping objective = the poke value (cycle): never settles.
538    const RAMP_OBJ: &str = "input cycle: u64\nobj := cycle";
539
540    fn window() -> MetricSet {
541        MetricSet::new(Duration::from_secs(1))
542    }
543
544    #[test]
545    fn interpreter_publishes_settled_value_into_the_register() {
546        let mut i = settle_interp();
547        let mut last = SettleReading::default();
548        for _ in 0..8 {
549            last = i.pulse(5.0);
550        }
551        assert!(last.stable, "a steady value settles");
552        assert!(
553            (i.register().load().value - 5.0).abs() < 1e-9,
554            "register holds the steady level"
555        );
556    }
557
558    #[test]
559    fn evaluator_yields_interrupted_when_settled() {
560        let mut ev = SettleEvaluator::new(
561            obj_kernel(STEADY_OBJ),
562            "obj",
563            "cycle",
564            settle_interp(),
565            Duration::from_secs(60),
566            Duration::ZERO,
567        );
568        let reg = ev.register();
569        let mut verdict = None;
570        for _ in 0..16 {
571            if let Some(o) = ev.evaluate(&window()) {
572                verdict = Some(o);
573                break;
574            }
575        }
576        let o = verdict.expect("a steady objective settles within the budget");
577        assert_eq!(o.disposition, Disposition::Interrupted);
578        assert_eq!(o.validity, Validity::Succeeded);
579        assert!(
580            (reg.load().value - 5.0).abs() < 1e-9,
581            "settled register reads 5.0"
582        );
583    }
584
585    #[test]
586    fn evaluator_yields_failed_on_settle_timeout() {
587        let mut ev = SettleEvaluator::new(
588            obj_kernel(RAMP_OBJ),
589            "obj",
590            "cycle",
591            settle_interp(),
592            Duration::from_millis(40),
593            Duration::ZERO,
594        );
595        // First pulse starts the clock; the ramp never settles.
596        assert!(
597            ev.evaluate(&window()).is_none(),
598            "no verdict before timeout"
599        );
600        std::thread::sleep(Duration::from_millis(55));
601        let o = ev.evaluate(&window()).expect("timeout fires a verdict");
602        assert_eq!(o.disposition, Disposition::Interrupted);
603        assert_eq!(
604            o.validity,
605            Validity::Failed,
606            "a settle timeout is the untrustworthy quadrant"
607        );
608    }
609
610    #[test]
611    fn viability_gate_withholds_settle_until_min_viable_elapses() {
612        // A steady objective is "stable" almost immediately, but the gate
613        // withholds the settle until `min_viable` of WALL-CLOCK has elapsed —
614        // so a windowed objective's rollup has cleared the prior coordinate /
615        // warmup transient before its value is trusted (the bug that let a
616        // sub-window concurrent eval latch a phantom score).
617        let mut ev = SettleEvaluator::new(
618            obj_kernel(STEADY_OBJ),
619            "obj",
620            "cycle",
621            settle_interp(),
622            Duration::from_secs(60),
623            Duration::from_millis(60),
624        );
625        // Many pulses arrive in a burst (as under concurrent scheduling): the
626        // objective is stable, but the gate holds because no real time passed.
627        for _ in 0..32 {
628            assert!(
629                ev.evaluate(&window()).is_none(),
630                "stable-but-gated: a burst of pulses must not settle before min_viable wall-clock"
631            );
632        }
633        std::thread::sleep(Duration::from_millis(70));
634        let o = ev
635            .evaluate(&window())
636            .expect("settles once min_viable has elapsed");
637        assert_eq!(o.disposition, Disposition::Interrupted);
638        assert_eq!(o.validity, Validity::Succeeded);
639    }
640
641    #[test]
642    fn detects_session_cumulative_reader_only() {
643        // `metric(...)` reads session_lifetime (cumulative across all
644        // coordinates) → flagged as un-gateable. `metric_window(...)` is
645        // a bounded windowed reader, and a plain objective reads nothing
646        // → neither is flagged.
647        let cum = compile_polydat_interpreter(r#"obj := metric("cycles_total, phase=p", "rate")"#)
648            .expect("metric node compiles");
649        assert!(program_reads_session_cumulative_metrics(cum.program()));
650
651        let win =
652            compile_polydat_interpreter(r#"obj := metric_window("cycles_total, phase=p", "rate")"#)
653                .expect("metric_window node compiles");
654        assert!(
655            !program_reads_session_cumulative_metrics(win.program()),
656            "metric_window is windowed, not session-cumulative"
657        );
658
659        let plain = compile_polydat_interpreter("obj := 5.0").expect("plain objective compiles");
660        assert!(!program_reads_session_cumulative_metrics(plain.program()));
661    }
662}