Files
Aura/crates/aura-std/src/delay.rs
T
claude bd7585b609 audit(cycle 316): drift disposition, bench seed docs + ratified re-pin, C29 scope paragraph
Cycle-close audit for the self-description cycle. Architect: drift_found
(mild), core holds -- the gate is pure string shape in core imported by
every seam (C28), the engine never evaluates description text (C17/inv
10), determinism pins untouched (C1); the store boundary is gated by
construction with the raw write private; Op::Doc is a closed typed
construct (C25). Regression: aura-bench green (exit 0) after one
cycle-caused infra fix and one ratified re-pin.

Infra fix: the bench campaign seeds (BP_SMA_A/B) predate C29 and were
refused by the store gate (exit 2, infrastructure-first rule) -- both
thread the authored sma_signal meaning line.

Ratify: cli_fixed_cost's run_line_fnv moved because bench_sma_a.json now
carries its doc -- the blueprint's content id shifts exactly as the spec
anticipates for fixtures gaining docs; campaign result fingerprints are
unchanged (descriptions stay out of execution, C29). Baselines re-pinned
via aura-bench pin (throughput baselines re-pinned in the same sweep,
all deltas within tolerance at pin time).

Drift disposition:
- [high] C29 guarantee overclaim vs emit/binding-column vocabularies ->
  contract now carries an explicit Scope paragraph naming the residue,
  tracked as #321 (also covers the [medium] non-roster schema walk gap).
- [low] Delay's meaning line cited the stale C5 for the delay register;
  corrected to C9.

refs #316, refs #321
2026-07-23 20:42:02 +02:00

175 lines
7.0 KiB
Rust

//! `Delay` — the C5 register: outputs the value its single input had `lag` fired
//! cycles ago (`prevHigh15` in the session-breakout strategy is `Delay[1]` on
//! `high15`).
//!
//! The first **state** node. The lag history lives in a node-owned ring of length
//! `lag` (a `Box<[f64]>` plus a `pos` write cursor and a `count` warm-up counter —
//! the same shape and roles as `Sma`'s `ring`/`pos`/`count`), so `lookbacks()` is
//! `1`: the input column is depth-1 and the past is held in node state, NOT read
//! back out of the input column (that would be the input-column-lookback design,
//! fork B; this is the node-owned-ring design, fork A).
//!
//! Why node-owned: the output at cycle T is then a pure function of state captured
//! BEFORE T — the slot the next push will overwrite holds the lag-ago value, read
//! out before the new sample lands. That precondition is what lets a future engine
//! exclude a delay's in-edge from the topo sort and close a feedback loop (the
//! RTL register C5 names): a delay's output never depends on the current cycle's
//! upstream, only on history.
//!
//! Warm-up is **skip-emit**: `None` until `lag` samples have passed, because the
//! lag-ago value does not exist yet — a `Delay[1]` has no previous bar on its
//! first cycle. It NEVER fabricates a sentinel past value; a downstream comparator
//! must not be handed a bar that never streamed.
use aura_core::{
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
ScalarKind,
};
/// The C5 lag-N register: emits the value its `series` input carried `lag` fired
/// cycles ago, held in a node-owned ring. `None` during warm-up (the first `lag`
/// cycles, when the lag-ago value does not yet exist).
pub struct Delay {
lag: usize,
// The lag history, node-owned (so the input column is depth-1): `ring[pos]` is
// the value `lag` cycles ago — the one this cycle emits and the next push
// evicts. Sized once at construction (C7).
ring: Box<[f64]>,
pos: usize,
// Samples seen so far — the warm-up gate (silent until `lag`, like `Sma`).
count: usize,
out: [Cell; 1],
}
impl Delay {
/// Build a lag-`lag` register (must be >= 1: a zero-lag register is the
/// identity and is forbidden — mirror `Sma::new`'s `length >= 1`).
pub fn new(lag: usize) -> Self {
assert!(lag >= 1, "Delay lag must be >= 1");
Self {
lag,
ring: vec![0.0; lag].into_boxed_slice(),
pos: 0,
count: 0,
out: [Cell::from_f64(0.0)],
}
}
/// The param-generic recipe for a blueprint primitive: declares `lag` and
/// builds through `Delay::new` (the slice is kind-checked before `build` runs,
/// so the typed read is total).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"Delay",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "series".into() }],
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "lag".into(), kind: ScalarKind::I64 }],
doc: "emits the input from `lag` cycles ago — the explicit delay register (C9)",
},
|p| Box::new(Delay::new(p[0].i64() as usize)),
)
}
}
impl Node for Delay {
// The lag history lives in node state, so only the newest sample is read each
// cycle — `lag` sizes the ring, not the input column (like `Sma`).
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None; // empty-input cycle: no sample to register
}
let x = w[0]; // index 0 = newest (financial indexing)
// `ring[pos]` is the value `lag` cycles ago — read it OUT before the new
// sample overwrites it (so the output is a pure function of pre-cycle state,
// never the cycle's own upstream — the C5 feedback-loop precondition).
let lagged = self.ring[self.pos];
self.ring[self.pos] = x;
self.pos = (self.pos + 1) % self.lag;
if self.count < self.lag {
self.count += 1;
return None; // warm-up: the lag-ago value does not exist yet (skip-emit)
}
self.out[0] = Cell::from_f64(lagged);
Some(&self.out)
}
fn label(&self) -> String {
format!("Delay({})", self.lag)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Scalar, Timestamp};
#[test]
fn delay_one_emits_the_immediately_prior_value() {
// The headline property (this is `prevHigh15` = Delay[1] on high15): each
// fire emits the value the input carried the cycle before, and the first
// cycle is None — there is no previous bar yet (warm-up skip-emit, never a
// fabricated sentinel).
let node_for_depth = Delay::new(1);
// size the input column from the node's lookback, as bootstrap will at wiring
let mut inputs =
vec![AnyColumn::with_capacity(ScalarKind::F64, node_for_depth.lookbacks()[0])];
let mut node = node_for_depth;
let feed = [10.0_f64, 20.0, 30.0];
let expect = [None, Some(10.0), Some(20.0)];
for (v, want) in feed.iter().zip(expect) {
inputs[0].push(Scalar::f64(*v)).unwrap();
let got = node.eval(Ctx::new(&inputs, Timestamp(0)));
match want {
None => assert_eq!(got, None),
Some(p) => assert_eq!(got, Some([Cell::from_f64(p)].as_slice())),
}
}
}
#[test]
fn delay_two_emits_the_value_two_cycles_ago() {
// lag-N generalisation: Delay[2] holds two cycles of history, so it is None
// for the first two cycles, then emits the value from two cycles back.
let mut node = Delay::new(2);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let feed = [10.0_f64, 20.0, 30.0, 40.0];
let expect = [None, None, Some(10.0), Some(20.0)];
for (v, want) in feed.iter().zip(expect) {
inputs[0].push(Scalar::f64(*v)).unwrap();
let got = node.eval(Ctx::new(&inputs, Timestamp(0)));
match want {
None => assert_eq!(got, None),
Some(p) => assert_eq!(got, Some([Cell::from_f64(p)].as_slice())),
}
}
}
#[test]
fn delay_is_none_on_an_empty_input_cycle() {
// a degenerate empty-input cycle filters (the universal warm-up/empty guard)
let mut node = Delay::new(1);
let inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
assert_eq!(node.eval(Ctx::new(&inputs, Timestamp(0))), None);
}
#[test]
fn lag_must_be_at_least_one() {
// a zero-lag register is the identity and is forbidden (mirror Sma::new)
let r = std::panic::catch_unwind(|| Delay::new(0));
assert!(r.is_err(), "Delay::new(0) must panic — a zero-lag register is the identity");
}
}