c8dc57e2aa
Second half of the #281 clock-enable set. CumSum is the generic run-length accumulator with O(1) Neumaier-compensated state — the branching Neumaier variant, not Sma's plain Kahan, so it stays exact when a large running total absorbs a tiny sample (the standalone accumulator has no windowed-magnitude guarantee; module doc explains why the helper is not reused). When is the clock-enable driver: forwards value iff gate, else a quiet cycle (eval->None), so every stateful reducer composes gated unmodified — SMA(When(x,g),N) is the gated SMA. Rosters CumSum and When (both count pins 31->33) and ships two engine E2E fixtures: When forwarding/quiet-downstream through the real GraphBuilder->compile->run seam, and a quiet When leg suppressing a whole Resample Barrier(0) group (the deliberate stall semantics). refs #281
134 lines
6.8 KiB
Rust
134 lines
6.8 KiB
Rust
//! End-to-end coverage for the `When` standard cell (#281): the clock-enable
|
|
//! `f64 x bool -> f64` driver.
|
|
//!
|
|
//! The in-module tests in `aura-std/src/when.rs` drive `eval` directly — they
|
|
//! never go through a `PrimitiveBuilder` schema, a `GraphBuilder`-resolved
|
|
//! wiring, or a compiled+bootstrapped `FlatGraph`. That is the real seam a
|
|
//! data-authored op-script or a Rust builder actually exercises (C24); this
|
|
//! file proves `When` is reachable from the public `aura_engine`/`aura_std`
|
|
//! construction surface, wires cleanly by declared port name/kind (`value:
|
|
//! F64`, `gate: Bool`), and — the load-bearing property — that a quiet
|
|
//! (gate-false) cycle never reaches a downstream sink: the recorder's own
|
|
//! input only advances on a fresh push from `When`, so a quiet cycle leaves
|
|
//! it un-fired rather than recording a held/zero value.
|
|
//!
|
|
//! One `price` source feeds both `When`'s `value` leg and, via
|
|
//! `Gt(|price|, price)`, its `gate` leg (true exactly when `price < 0`) — the
|
|
//! same single-source-per-cycle shape `select_sign_e2e.rs` uses, so `gate`
|
|
//! and `value` are always computed in the same engine cycle (no cross-source
|
|
//! phase skew between two independently-clocked inputs).
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{GraphBuilder, VecSource};
|
|
use aura_std::{Abs, Gt, Recorder, Resample, When};
|
|
|
|
/// A deterministic `f64` tick stream, no timestamps/randomness beyond a fixed
|
|
/// literal sequence.
|
|
fn ticks(values: &[f64]) -> Vec<(Timestamp, Scalar)> {
|
|
values.iter().enumerate().map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v))).collect()
|
|
}
|
|
|
|
/// Property: **`When` correctly registers its `PrimitiveBuilder` schema
|
|
/// (`value: F64`, `gate: Bool` in, one `f64` out) and dispatches through a
|
|
/// compiled+bootstrapped `FlatGraph`, forwarding `value` on gate-true cycles
|
|
/// and going quiet — not merely holding or zeroing — on gate-false cycles,
|
|
/// all the way to a downstream sink.** A schema/wiring regression (wrong port
|
|
/// kind, wrong output arity) would fail at `GraphBuilder::build` or
|
|
/// `compile_with_params`, before a single cycle runs; a dispatch regression
|
|
/// would show up in the recorded trace below — either the wrong values or,
|
|
/// were quiet cycles wrongly forwarding a held/zero value, extra rows.
|
|
#[test]
|
|
fn when_dispatches_through_a_compiled_graph_and_is_quiet_downstream_on_gate_false() {
|
|
let (tx, rx) = mpsc::channel();
|
|
let mut g = GraphBuilder::new("root");
|
|
let price = g.source_role("price", ScalarKind::F64);
|
|
let abs = g.add(Abs::builder());
|
|
let gt = g.add(Gt::builder());
|
|
let when = g.add(When::builder());
|
|
let rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx));
|
|
|
|
g.feed(price, [abs.input("value"), gt.input("b"), when.input("value")]);
|
|
g.connect(abs.output("value"), gt.input("a"));
|
|
g.connect(gt.output("value"), when.input("gate"));
|
|
g.connect(when.output("value"), rec.input("col[0]"));
|
|
|
|
let mut h = g.build().expect("resolves by name").bootstrap_with_params(vec![]).expect("bootstraps");
|
|
h.run(vec![Box::new(VecSource::new(ticks(&[-3.0, 2.5, -1.0, 4.0])))]);
|
|
let trace: Vec<f64> = rx.try_iter().map(|(_, row)| row[0].as_f64()).collect();
|
|
|
|
assert_eq!(
|
|
trace,
|
|
vec![-3.0, -1.0],
|
|
"only the two negative-price (gate-true) rows reach the sink; the positive-price rows are quiet, not recorded as held/zero"
|
|
);
|
|
}
|
|
|
|
/// Property: **a quiet `When` (gate false) driving one leg of a `Firing::Barrier(N)`
|
|
/// group suppresses the WHOLE group that cycle — not merely its own leg.** This is
|
|
/// the composition the `when.rs` module doc calls out directly ("a quiet `When`
|
|
/// feeding a `Firing::Barrier(N)` group means the group does not fire that cycle").
|
|
/// `Resample`'s four OHLC inputs are `aura-std`'s only `Barrier(0)` consumer; here
|
|
/// `When` drives `close` while `open`/`high`/`low` are wired straight from the same
|
|
/// source (fresh every cycle, gate or no gate). On a gate-false tick the other three
|
|
/// legs still receive a fresh push, but `close` does not — since `Barrier(0)`
|
|
/// requires all four co-fresh, the node does not fire at all that cycle, so the
|
|
/// gate-false tick's price never enters the OHLC accumulator (not even via the
|
|
/// ungated legs), and a bucket rollover due exactly on a gate-false tick is
|
|
/// deferred to the next gate-true tick rather than firing early or partially.
|
|
#[test]
|
|
fn quiet_when_leg_suppresses_the_whole_barrier_group() {
|
|
let (tx, rx) = mpsc::channel();
|
|
let mut g = GraphBuilder::new("root");
|
|
let price = g.source_role("price", ScalarKind::F64);
|
|
let abs = g.add(Abs::builder());
|
|
let gt = g.add(Gt::builder());
|
|
let when = g.add(When::builder());
|
|
let resample = g.add(Resample::builder());
|
|
let rec = g.add(Recorder::builder(vec![ScalarKind::F64; 4], Firing::Any, tx));
|
|
|
|
g.feed(price, [
|
|
abs.input("value"),
|
|
gt.input("b"),
|
|
when.input("value"),
|
|
resample.input("open"),
|
|
resample.input("high"),
|
|
resample.input("low"),
|
|
]);
|
|
g.connect(abs.output("value"), gt.input("a"));
|
|
g.connect(gt.output("value"), when.input("gate"));
|
|
g.connect(when.output("value"), resample.input("close"));
|
|
g.connect(resample.output("open"), rec.input("col[0]"));
|
|
g.connect(resample.output("high"), rec.input("col[1]"));
|
|
g.connect(resample.output("low"), rec.input("col[2]"));
|
|
g.connect(resample.output("close"), rec.input("col[3]"));
|
|
|
|
let mut h = g
|
|
.build()
|
|
.expect("resolves by name")
|
|
.bootstrap_with_params(vec![Scalar::i64(1)]) // Resample's sole open param: period_minutes=1
|
|
.expect("bootstraps");
|
|
|
|
// gate = price < 0 (Gt(|price|, price)); 1-minute buckets (period_ns = 60e9).
|
|
let feed: [(i64, f64); 5] = [
|
|
(0, -1.0), // gate true: bucket 0 opens (o=h=l=c=-1.0)
|
|
(10_000_000_000, 2.0), // gate false: When quiet -> Resample does not fire at all
|
|
(20_000_000_000, -3.0), // gate true, still bucket 0: high stays -1.0, low->-3.0, close->-3.0
|
|
(60_000_000_000, 4.0), // gate false exactly on the bucket-1 boundary: rollover DEFERRED
|
|
(70_000_000_000, -6.0), // gate true, bucket 1: fires -> rolls over the completed bucket-0 bar
|
|
];
|
|
let ticks: Vec<(Timestamp, Scalar)> =
|
|
feed.iter().map(|&(t, v)| (Timestamp(t), Scalar::f64(v))).collect();
|
|
h.run(vec![Box::new(VecSource::new(ticks))]);
|
|
let trace: Vec<Vec<f64>> =
|
|
rx.try_iter().map(|(_, row)| row.iter().map(|c| c.as_f64()).collect()).collect();
|
|
|
|
assert_eq!(
|
|
trace,
|
|
vec![vec![-1.0, -1.0, -3.0, -3.0]],
|
|
"the two gate-false ticks (price 2.0, 4.0) must never enter the OHLC accumulator: \
|
|
a quiet When leg blocks the whole Barrier(0) group, not just its own field, and the \
|
|
bucket-1 rollover due at t=60e9 is deferred to the next gate-true tick (t=70e9)"
|
|
);
|
|
}
|