feat(core): the node contract — Node, schema/eval, Ctx, and a worked SMA

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<Scalar>`
  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
This commit is contained in:
2026-06-03 12:17:33 +02:00
parent e77060fdd4
commit 77a1d26017
6 changed files with 275 additions and 6 deletions
+44
View File
@@ -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<i64>> {
match self {
AnyColumn::I64(c) => Some(c),
_ => None,
}
}
pub fn as_f64(&self) -> Option<&Column<f64>> {
match self {
AnyColumn::F64(c) => Some(c),
_ => None,
}
}
pub fn as_bool(&self) -> Option<&Column<bool>> {
match self {
AnyColumn::Bool(c) => Some(c),
_ => None,
}
}
pub fn as_ts(&self) -> Option<&Column<Timestamp>> {
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);
+100
View File
@@ -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
}
}
+16 -3
View File
@@ -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};
+31
View File
@@ -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<InputSpec>,
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<Scalar>;
}