From 77a1d2601726c62d5d0324493c879cbdf857b222 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 3 Jun 2026 12:17:33 +0200 Subject: [PATCH] =?UTF-8?q?feat(core):=20the=20node=20contract=20=E2=80=94?= =?UTF-8?q?=20Node,=20schema/eval,=20Ctx,=20and=20a=20worked=20SMA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle 0002, the second walking-skeleton slice on top of the 0001 substrate: the interface every node forever implements (C8), proven end-to-end by a real node with no engine present. - `Node` (aura-core) — `schema() -> NodeSchema` declares inputs (kind + lookback) and the single output kind; `eval(&mut self, Ctx) -> Option` computes one cycle's output (`None` = filter / not-yet-warmed-up). `&mut self` so a node may keep its own derived state. - `Ctx<'a>` (aura-core) — a `Copy` borrow-wrapper handing `eval` zero-copy, financial-indexed `Window`s per input (`ctx.f64_in(i)[k]`, index 0 = newest). A kind mismatch panics ("engine bug") — wiring guarantees the kind, so a mismatch can only mean the wiring layer is broken; this keeps node-author code clean (`w[k]`, not `w?[k]`). - `AnyColumn::as_f64/as_i64/as_bool/as_ts` (aura-core) — the read-side mirror of the existing `as_*_mut`, the mechanism `Ctx` uses to get a typed window from a type-erased edge. Closes the read-side gap the cycle-0001 audit recorded. - `Sma` (aura-std) — the skeleton's first real block: a producer node computing the moving mean of one f64 input, emitting `None` until warmed up. Authored in a downstream crate (proving aura-core's contract is usable across the crate boundary) and driven by a hand-written test that mimics exactly what the sim loop will later generalize: push fresh input, eval, collect. Deliberately deferred (spec 0002 "Out of scope", recorded as decisions): the firing policies (C6 — InputSpec carries no firing field yet), the sim loop (C4) and freshness gating (C5), schema-level tunable params (C12/C19), and the no-output sink refinement (C8 consumer side). Gates green: cargo build/test (20: 18 aura-core + 2 aura-std)/clippy -D warnings all clean; surface-purity grep (no dyn-Any / Rc / RefCell) clean. refs walking-skeleton --- crates/aura-core/src/any.rs | 44 +++++++++++++++ crates/aura-core/src/ctx.rs | 100 +++++++++++++++++++++++++++++++++++ crates/aura-core/src/lib.rs | 19 +++++-- crates/aura-core/src/node.rs | 31 +++++++++++ crates/aura-std/src/lib.rs | 8 +-- crates/aura-std/src/sma.rs | 79 +++++++++++++++++++++++++++ 6 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 crates/aura-core/src/ctx.rs create mode 100644 crates/aura-core/src/node.rs create mode 100644 crates/aura-std/src/sma.rs diff --git a/crates/aura-core/src/any.rs b/crates/aura-core/src/any.rs index 64d0ece..84a9d70 100644 --- a/crates/aura-core/src/any.rs +++ b/crates/aura-core/src/any.rs @@ -120,6 +120,37 @@ impl AnyColumn { _ => None, } } + + /// Read-side concrete-column accessor (the symmetric partner of + /// `as_*_mut`); `Some` only for the matching kind. `Ctx` uses these to hand + /// a typed window from a type-erased edge. + pub fn as_i64(&self) -> Option<&Column> { + match self { + AnyColumn::I64(c) => Some(c), + _ => None, + } + } + + pub fn as_f64(&self) -> Option<&Column> { + match self { + AnyColumn::F64(c) => Some(c), + _ => None, + } + } + + pub fn as_bool(&self) -> Option<&Column> { + match self { + AnyColumn::Bool(c) => Some(c), + _ => None, + } + } + + pub fn as_ts(&self) -> Option<&Column> { + match self { + AnyColumn::Ts(c) => Some(c), + _ => None, + } + } } #[cfg(test)] @@ -161,6 +192,19 @@ mod tests { assert_eq!(e.run_count(), 1); } + #[test] + fn read_accessor_matches_only_its_kind() { + let f = AnyColumn::with_capacity(ScalarKind::F64, 2); + assert!(f.as_f64().is_some()); + assert!(f.as_i64().is_none()); + assert!(f.as_bool().is_none()); + assert!(f.as_ts().is_none()); + + let i = AnyColumn::with_capacity(ScalarKind::I64, 2); + assert!(i.as_i64().is_some()); + assert!(i.as_f64().is_none()); + } + #[test] fn timestamp_edge_round_trips() { let mut e = AnyColumn::with_capacity(ScalarKind::Timestamp, 2); diff --git a/crates/aura-core/src/ctx.rs b/crates/aura-core/src/ctx.rs new file mode 100644 index 0000000..aa74192 --- /dev/null +++ b/crates/aura-core/src/ctx.rs @@ -0,0 +1,100 @@ +//! The evaluation context (C8): the read-side window access a node sees in +//! `eval`. The engine sizes and types each input from the node's `schema` at +//! wiring, so the typed accessors below treat a kind mismatch as an engine bug +//! (panic), not a user-facing error. + +use crate::{AnyColumn, Timestamp, Window}; + +/// Read-only, zero-copy view of a node's inputs for one `eval`, in schema +/// order. `Copy` because it is just a borrow of the input slice. +#[derive(Clone, Copy)] +pub struct Ctx<'a> { + inputs: &'a [AnyColumn], +} + +impl<'a> Ctx<'a> { + /// Wrap the per-input columns (in schema-declared order) for one `eval`. + pub fn new(inputs: &'a [AnyColumn]) -> Self { + Self { inputs } + } + + /// Zero-copy `f64` window into input `i` (index 0 = newest). Panics if input + /// `i` is not an `f64` edge — a wiring bug, never reachable from a correctly + /// wired graph. + pub fn f64_in(&self, i: usize) -> Window<'a, f64> { + let inputs: &'a [AnyColumn] = self.inputs; + inputs[i] + .as_f64() + .expect("input kind mismatch (checked at wiring) — engine bug") + .window() + } + + /// Zero-copy `i64` window into input `i` (index 0 = newest). See `f64_in`. + pub fn i64_in(&self, i: usize) -> Window<'a, i64> { + let inputs: &'a [AnyColumn] = self.inputs; + inputs[i] + .as_i64() + .expect("input kind mismatch (checked at wiring) — engine bug") + .window() + } + + /// Zero-copy `bool` window into input `i` (index 0 = newest). See `f64_in`. + pub fn bool_in(&self, i: usize) -> Window<'a, bool> { + let inputs: &'a [AnyColumn] = self.inputs; + inputs[i] + .as_bool() + .expect("input kind mismatch (checked at wiring) — engine bug") + .window() + } + + /// Zero-copy `timestamp` window into input `i` (index 0 = newest). See + /// `f64_in`. + pub fn ts_in(&self, i: usize) -> Window<'a, Timestamp> { + let inputs: &'a [AnyColumn] = self.inputs; + inputs[i] + .as_ts() + .expect("input kind mismatch (checked at wiring) — engine bug") + .window() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Scalar, ScalarKind}; + + #[test] + fn ctx_hands_financial_indexed_windows() { + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 4)]; + for v in [10.0_f64, 20.0, 30.0] { + inputs[0].push(Scalar::F64(v)).unwrap(); + } + let ctx = Ctx::new(&inputs); + let w = ctx.f64_in(0); + assert_eq!(w.len(), 3); + assert_eq!(w[0], 30.0); // newest + assert_eq!(w[2], 10.0); // oldest + } + + #[test] + fn ctx_addresses_multiple_inputs() { + let mut inputs = vec![ + AnyColumn::with_capacity(ScalarKind::F64, 2), + AnyColumn::with_capacity(ScalarKind::I64, 2), + ]; + inputs[0].push(Scalar::F64(1.5)).unwrap(); + inputs[1].push(Scalar::I64(42)).unwrap(); + let ctx = Ctx::new(&inputs); + assert_eq!(ctx.f64_in(0)[0], 1.5); + assert_eq!(ctx.i64_in(1)[0], 42); + } + + #[test] + #[should_panic(expected = "engine bug")] + fn ctx_panics_on_kind_mismatch() { + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::I64, 2)]; + inputs[0].push(Scalar::I64(7)).unwrap(); + let ctx = Ctx::new(&inputs); + let _ = ctx.f64_in(0); // wrong kind → panic + } +} diff --git a/crates/aura-core/src/lib.rs b/crates/aura-core/src/lib.rs index f47db3e..aa3ed42 100644 --- a/crates/aura-core/src/lib.rs +++ b/crates/aura-core/src/lib.rs @@ -14,16 +14,29 @@ //! - [`AnyColumn`] — the type-erased edge over the four column kinds, with an //! edge-time kind check ([`KindMismatch`]) (C7). //! -//! Still to come (subsequent cycles): the `Node` trait and its `schema`/`eval`, -//! the evaluation context `Ctx`, the firing policies (A: fire-on-any-fresh + -//! hold; B: all-fresh barrier), and the deterministic sim loop. +//! Delivered in cycle 0002 — the node contract: +//! +//! - [`Node`] — the `schema`/`eval` contract every node implements (C8), with +//! [`NodeSchema`] / [`InputSpec`] declaring inputs (kind + lookback) and the +//! single output kind; +//! - [`Ctx`] — the per-`eval` read-side: zero-copy, financial-indexed [`Window`] +//! access into each input (closing the cycle-0001 read-side gap on +//! [`AnyColumn`]). +//! +//! Still to come (subsequent cycles): the firing policies (A: fire-on-any-fresh + +//! hold; B: all-fresh barrier), the deterministic sim loop, sources, and +//! ingestion. mod any; mod column; +mod ctx; mod error; +mod node; mod scalar; pub use any::AnyColumn; pub use column::{Column, Window}; +pub use ctx::Ctx; pub use error::KindMismatch; +pub use node::{InputSpec, Node, NodeSchema}; pub use scalar::{Scalar, ScalarKind, Timestamp}; diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs new file mode 100644 index 0000000..a3ba83d --- /dev/null +++ b/crates/aura-core/src/node.rs @@ -0,0 +1,31 @@ +//! The node contract (C8): the interface every node implements. A node declares +//! its inputs and output kind via `schema`, and computes one cycle's output via +//! `eval`. Firing policy (C6) and tunable params (C12/C19) are deliberately not +//! part of the schema yet — see spec 0002's "Out of scope". + +use crate::{Ctx, Scalar, ScalarKind}; + +/// One declared input of a node: its scalar kind and the lookback depth the +/// engine must pre-size for it (must be >= 1). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct InputSpec { + pub kind: ScalarKind, + pub lookback: usize, +} + +/// A node's declared interface: its inputs (in order) and its single output +/// kind. Built once at wiring, never on the hot path — the `Vec` is fine here. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NodeSchema { + pub inputs: Vec, + pub output: ScalarKind, +} + +/// The universal composable dataflow unit (C8): at most one output, a producer +/// or transformer. `schema` declares the interface; `eval` computes one cycle's +/// output (`None` = filter / not-yet-warmed-up). `&mut self` because a node may +/// keep its own derived state. +pub trait Node { + fn schema(&self) -> NodeSchema; + fn eval(&mut self, ctx: Ctx<'_>) -> Option; +} diff --git a/crates/aura-std/src/lib.rs b/crates/aura-std/src/lib.rs index 035712d..b8be4ea 100644 --- a/crates/aura-std/src/lib.rs +++ b/crates/aura-std/src/lib.rs @@ -12,6 +12,8 @@ //! pulled in as cargo git dependencies; //! - project-local `nodes/` — experimental, project-specific blocks. //! -//! Types are intentionally absent until the first spec needs them (the -//! walking-skeleton milestone). This crate documents intent; it does not yet -//! define API. +//! The first block lands with the walking skeleton: [`Sma`], the simple moving +//! average — a worked producer node proving the `aura-core` `Node` contract. + +mod sma; +pub use sma::Sma; diff --git a/crates/aura-std/src/sma.rs b/crates/aura-std/src/sma.rs new file mode 100644 index 0000000..c6a1355 --- /dev/null +++ b/crates/aura-std/src/sma.rs @@ -0,0 +1,79 @@ +//! `Sma` — simple moving average over the last `length` values of one f64 +//! input. The walking skeleton's first worked node: it proves the `aura-core` +//! `Node` contract is authorable from a downstream crate and evaluable with no +//! engine present (the test drives it by hand, as the sim loop later will). + +use aura_core::{Ctx, InputSpec, Node, NodeSchema, Scalar, ScalarKind}; + +/// Simple moving average over the last `length` values of one f64 input. +pub struct Sma { + length: usize, +} + +impl Sma { + /// Build an SMA of window `length` (must be >= 1). + pub fn new(length: usize) -> Self { + assert!(length >= 1, "SMA length must be >= 1"); + Self { length } + } +} + +impl Node for Sma { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: self.length }], + output: ScalarKind::F64, + } + } + + fn eval(&mut self, ctx: Ctx<'_>) -> Option { + let w = ctx.f64_in(0); + if w.len() < self.length { + return None; // not yet warmed up + } + let mut sum = 0.0; + for k in 0..self.length { + sum += w[k]; // index 0 = newest (financial indexing) + } + Some(Scalar::F64(sum / self.length as f64)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::AnyColumn; + + #[test] + fn sma_warms_up_then_tracks_the_window_mean() { + let mut sma = Sma::new(3); + let schema = sma.schema(); + + // size the input column from the schema, as the engine will at wiring + let mut inputs = vec![AnyColumn::with_capacity( + schema.inputs[0].kind, + schema.inputs[0].lookback, + )]; + + let feed = [1.0_f64, 2.0, 3.0, 4.0, 5.0]; + // means of [1,2,3], [2,3,4], [3,4,5] once warmed up + let expect = [None, None, Some(2.0), Some(3.0), Some(4.0)]; + + for (v, want) in feed.iter().zip(expect) { + inputs[0].push(Scalar::F64(*v)).unwrap(); + assert_eq!(sma.eval(Ctx::new(&inputs)), want.map(Scalar::F64)); + } + } + + #[test] + fn sma_length_one_is_identity() { + let mut sma = Sma::new(1); + let mut inputs = vec![AnyColumn::with_capacity(ScalarKind::F64, 1)]; + + inputs[0].push(Scalar::F64(7.0)).unwrap(); + assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(7.0))); + + inputs[0].push(Scalar::F64(9.0)).unwrap(); + assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(9.0))); + } +}