Files
Aura/crates/aura-std/src/ema.rs
T
Brummel 1b3909316e feat(aura): node signature lives in the blueprint; collapse Blueprint into Composite
Consolidate the node data structure so every node's signature (NodeSchema:
inputs/output/params) is declared once and exists in the blueprint pre-build,
and dissolve the special "root graph" type. Behaviour-preserving (C1).

Signature vs sizing
- NodeSchema is now the static signature only: InputSpec -> PortSpec{kind,firing},
  with lookback removed. The signature is fully static per blueprint (input
  kinds/firing, output fields, params); LinComb's variable arity is a builder arg,
  not an injected param.
- The one param-dependent quantity, an input's buffer lookback (e.g. Sma's window =
  its injected length), moves out of the signature to Node::lookbacks() -> Vec<usize>,
  read only by bootstrap for sizing. Node::schema() is removed.
- LeafFactory -> PrimitiveBuilder, which carries the full NodeSchema. The built node
  no longer re-declares it: closes the params-declared-twice drift (#36, the 8
  per-node factory_params_match_built_node_schema lockstep tests are deleted — their
  subject is now structurally impossible) and a value-empty recipe exposes its full
  I/O interface pre-build (#43).

Root is just a bound composite
- struct Blueprint is deleted; its compile/bootstrap/param_space methods move onto
  Composite. Role gains source: Option<ScalarKind> (None = open interior port,
  Some = bound ingestion feed). A composite is runnable iff every root role is bound;
  the "main graph" is no longer a category, only the fully-source-bound composite.
  New error CompileError::UnboundRootRole for an open root role.
- BlueprintNode::signature() answers uniformly for both arms: Primitive returns the
  builder's declared schema, Composite derives it from the interior (role kinds in,
  OutField kinds out, aggregated params), pre-build, no build.

compile -> FlatGraph -> bootstrap
- compile validates structure pre-build via signature() (validate_wiring: range +
  kind, returning the same variants as before, so an edge kind fault is now caught
  before any build closure fires) and emits FlatGraph{nodes,signatures,sources,edges}.
- bootstrap consumes the FlatGraph: kinds/firing/output from the carried signatures,
  buffer depth from node.lookbacks(). SourceSpec survives as the flat descriptor.

Renames: BlueprintNode::Leaf -> Primitive, LeafFactory -> PrimitiveBuilder.

Render (aura-cli/src/graph.rs) is migrated compile-only: it takes &Composite, maps
bound roles to the same source-entry shape, so both render goldens reproduce
byte-identical output (no re-capture needed). Render-fidelity tuning is the next cycle.

Verification (orchestrator-run, not agent-reported): cargo build --workspace green;
cargo test --workspace 150 passed / 0 failed; cargo clippy --workspace --all-targets
-D warnings clean. All pinned determinism/run-output tests pass with values unchanged;
no behavioural assertion was altered to go green. 5 new tests assert the signature is
pre-build and uniform, that compile rejects a kind mismatch without building (via a
panicking builder), UnboundRootRole, and lookbacks()/signature arity agreement.

Deferred to cycle-close audit (per plan): docs/design/INDEX.md and some aura-std
module docs still name the old Node::schema()/LeafFactory/BlueprintNode::Leaf/
Blueprint::param_space contracts; prose reconciliation is the architect's at audit.

closes #43 #36
2026-06-09 12:49:22 +02:00

165 lines
6.3 KiB
Rust

//! `Ema` — exponential moving average of one f64 input, smoothing constant
//! `alpha = 2 / (length + 1)` (the conventional length↔alpha identity, so an
//! `Ema` and an `Sma` of the same `length` are roughly comparable).
//!
//! Two design points worth stating:
//!
//! - **Seeding (ta-lib convention).** Like `Sma`, the EMA stays silent (`None`)
//! until it has seen `length` samples, then seeds itself with the *SMA* of those
//! first `length` values and runs the recurrence from there. This keeps warm-up
//! semantics uniform across the moving-average nodes, matches the EMA traders
//! know (ta-lib's MACD seeds its EMAs this way), and avoids claiming an average
//! from a single observation. (A first-value seed — emit from sample 1 — is the
//! other common convention; rejected here for the consistency/honesty reasons
//! above.)
//! - **Recursive, so `lookback = 1`.** The running average lives in internal
//! state, so the node reads only the newest sample each cycle — `length` sizes
//! `alpha`, not the input window. `eval` is O(1) time, O(1) state, and allocates
//! nothing on the hot path (the output buffer is reused).
use aura_core::{
Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
ScalarKind,
};
/// Exponential moving average of one f64 input, smoothing `alpha = 2/(length+1)`,
/// seeded with the SMA of the first `length` samples.
pub struct Ema {
alpha: f64,
length: usize,
// Warm-up accumulator: until `length` samples have arrived the node is silent
// and sums them; on the `length`-th sample it locks in the SMA seed (`seeded`)
// and `ema` carries the running value from then on.
seeded: bool,
warmup_sum: f64,
count: usize,
ema: f64,
out: [Scalar; 1],
}
impl Ema {
/// Build an EMA of window `length` (must be >= 1); `alpha = 2/(length+1)`
/// (`length == 1` gives `alpha == 1`, the identity).
pub fn new(length: usize) -> Self {
assert!(length >= 1, "EMA length must be >= 1");
Self {
alpha: 2.0 / (length as f64 + 1.0),
length,
seeded: false,
warmup_sum: 0.0,
count: 0,
ema: 0.0,
out: [Scalar::F64(0.0)],
}
}
/// The param-generic recipe for a blueprint primitive: declares `length` and builds
/// through `Ema::new` (the single sizing/validation gate; the slice is
/// kind-checked before `build` runs, so the typed read is total).
pub fn builder() -> PrimitiveBuilder {
PrimitiveBuilder::new(
"EMA",
NodeSchema {
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any }],
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
},
|p| Box::new(Ema::new(p[0].as_i64().expect("length slot is I64") as usize)),
)
}
}
impl Node for Ema {
// recursive: the running average lives in internal state, so only the newest
// sample is read — `length` sizes alpha, not the window.
fn lookbacks(&self) -> Vec<usize> {
vec![1]
}
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
let w = ctx.f64_in(0);
if w.is_empty() {
return None; // no sample yet
}
let x = w[0]; // index 0 = newest (financial indexing)
if !self.seeded {
// warm up over `length` samples, then seed with their SMA (ta-lib
// convention) — silent until ready, exactly like `Sma`.
self.warmup_sum += x;
self.count += 1;
if self.count < self.length {
return None;
}
self.ema = self.warmup_sum / self.length as f64;
self.seeded = true;
} else {
// O(1) recurrence: one sub, one mul, one add.
self.ema += self.alpha * (x - self.ema);
}
self.out[0] = Scalar::F64(self.ema);
Some(&self.out)
}
fn label(&self) -> String {
format!("EMA({})", self.length)
}
}
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{AnyColumn, Timestamp};
#[test]
fn ema_warms_up_like_sma_then_smooths() {
// length 3 -> alpha = 0.5: silent for the first 2 samples, seeds with the
// SMA of the first 3, then runs the recurrence. The jump to 10 separates the
// EMA from a plain SMA (EMA = 7.0 here, SMA(4,6,10) would be 6.667); all
// magnitudes are exact in f64.
let mut ema = Ema::new(3);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
let feed = [2.0_f64, 4.0, 6.0, 10.0];
// None, None, SMA(2,4,6) = 4.0, then 4.0 + 0.5*(10 - 4.0) = 7.0
let expect = [None, None, Some(4.0), Some(7.0)];
for (v, want) in feed.iter().zip(expect) {
inputs[0].push(Scalar::F64(*v)).unwrap();
let got = ema.eval(Ctx::new(&inputs, Timestamp(0)));
match want {
None => assert_eq!(got, None),
Some(m) => assert_eq!(got, Some([Scalar::F64(m)].as_slice())),
}
}
}
#[test]
fn ema_length_one_is_identity() {
// alpha = 2/2 = 1.0: the seed locks in on the first sample (length 1) and
// the recurrence reduces to ema = x.
let mut ema = Ema::new(1);
let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)];
inputs[0].push(Scalar::F64(7.0)).unwrap();
assert_eq!(ema.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(7.0)].as_slice()));
inputs[0].push(Scalar::F64(9.0)).unwrap();
assert_eq!(ema.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(9.0)].as_slice()));
}
#[test]
fn label_carries_the_window() {
assert_eq!(Ema::new(12).label(), "EMA(12)");
assert_eq!(Ema::new(26).label(), "EMA(26)");
}
#[test]
fn builder_declares_the_length_knob() {
// the param-generic recipe carries the declared I64 `length` knob
let b = Ema::builder();
assert_eq!(b.params(), b.schema().params.as_slice());
assert_eq!(b.schema().params[0].name, "length");
assert_eq!(b.schema().params[0].kind, ScalarKind::I64);
}
}