feat: node output is a record (composite stream), not a single scalar
Cycle 0005 (BLOCKER #1): a node's output generalizes from one scalar to a record of K >= 1 named base-scalar columns, with a scalar being the degenerate K = 1 case. A node keeps exactly one output port; its payload is now an ordered bundle of base columns (a composite stream, C7). This unblocks every multi-column producer the engine needs next -- OHLCV bars and, later, the C10 position-event table -- none of which could exist while a node emitted at most one column. Revises C8, sharpens C7 in the design ledger. Contract (aura-core): - `NodeSchema.output: ScalarKind` -> `Vec<FieldSpec>` (FieldSpec = { name: &'static str, kind }; the field position is what an Edge binds, the name is metadata for later sinks/playground, C18). - `Node::eval -> Option<Scalar>` -> `-> Option<&[Scalar]>`: a node fills a buffer it owns (sized once at construction) and returns a borrowed K-field row; `None` still means filter/not-warmed. This is the C7-faithful representation chosen with the user: zero per-cycle heap allocation on the forward path (rejected: Option<Vec<Scalar>> allocates per fire; an inline fixed [Scalar; N] bakes a width cap; engine-owned output columns invert the eval model). Scalar output is the degenerate 1-field record -- no separate scalar path. Field-wise binding (aura-engine): - `Edge` gains `from_field: usize` -- which producer column this edge forwards. Consuming a whole record is N edges; there is no "bind whole record" mechanism. - bootstrap kind-checks per field (`from.output.get(from_field)` -> BadIndex if out of range; `field.kind != slot.kind` -> KindMismatch). - the run loop copies a producer's returned row into one reused scratch buffer (resolving the borrow; no per-cycle alloc once warm) and scatters scratch[from_field] into each out-edge slot. `NodeBox.out_len` (set at bootstrap) backs a debug_assert on the returned row width. `run` returns `Vec<Option<Vec<Scalar>>>` (the observed node's full row per cycle -- a materialization-surface alloc, not the inter-node hot path). - the K fields of one record are co-fresh by construction (one eval, one timestamp), so C6 is untouched. aura-std: Sma/Sub migrate to the 1-field degenerate case (hold a [Scalar; 1] buffer; behaviour unchanged). Sub drops #[derive(Default)] (Scalar has no Default) for a manual Default. Proof (aura-engine tests, +5): an Ohlcv 5-field bundler fed by five timestamp-aligned barrier sources emits one complete bar per timestamp (ohlcv_bundles_five_field_record); a downstream Sub binds high(1)-low(2) field-wise, observed end-to-end + a determinism re-run (edge_binds_single_field_high_minus_low); a second consumer binds close(3)-open(0) on the same record (distinct_edges_read_distinct_fields); must-fail: bootstrap_rejects_from_field_out_of_range (BadIndex), bootstrap_rejects_per_field_kind_mismatch (a TwoField [f64,i64] producer, the i64 field into an f64 slot). All prior 0003/0004 tests adapted to from_field + the Vec return, expected vectors unchanged (scalar = 1-field record). Gates re-run by the orchestrator (not just the agent): cargo build/test --workspace --all-targets (36: aura-core 19 + aura-std 3 + aura-engine 14, 0 failed) / clippy --workspace --all-targets -D warnings clean; surface-purity grep (no dyn-Any / Rc / RefCell) clean. Ledger C8/C7 edits verified (Option<&[Scalar]> present, "one series per node" gone, cycle-0005 realization note added). The glossary composite/node record-reality pass is the audit-time follow-up. closes #1 refs walking-skeleton Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,18 +3,19 @@
|
||||
//! `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, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
|
||||
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
|
||||
|
||||
/// Simple moving average over the last `length` values of one f64 input.
|
||||
pub struct Sma {
|
||||
length: usize,
|
||||
out: [Scalar; 1],
|
||||
}
|
||||
|
||||
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 }
|
||||
Self { length, out: [Scalar::F64(0.0)] }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,11 +27,11 @@ impl Node for Sma {
|
||||
lookback: self.length,
|
||||
firing: Firing::Any,
|
||||
}],
|
||||
output: ScalarKind::F64,
|
||||
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar> {
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
||||
let w = ctx.f64_in(0);
|
||||
if w.len() < self.length {
|
||||
return None; // not yet warmed up
|
||||
@@ -39,7 +40,8 @@ impl Node for Sma {
|
||||
for k in 0..self.length {
|
||||
sum += w[k]; // index 0 = newest (financial indexing)
|
||||
}
|
||||
Some(Scalar::F64(sum / self.length as f64))
|
||||
self.out[0] = Scalar::F64(sum / self.length as f64);
|
||||
Some(&self.out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +67,11 @@ mod tests {
|
||||
|
||||
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));
|
||||
let got = sma.eval(Ctx::new(&inputs));
|
||||
match want {
|
||||
None => assert_eq!(got, None),
|
||||
Some(m) => assert_eq!(got, Some([Scalar::F64(m)].as_slice())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,9 +81,9 @@ mod tests {
|
||||
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)));
|
||||
assert_eq!(sma.eval(Ctx::new(&inputs)), Some([Scalar::F64(7.0)].as_slice()));
|
||||
|
||||
inputs[0].push(Scalar::F64(9.0)).unwrap();
|
||||
assert_eq!(sma.eval(Ctx::new(&inputs)), Some(Scalar::F64(9.0)));
|
||||
assert_eq!(sma.eval(Ctx::new(&inputs)), Some([Scalar::F64(9.0)].as_slice()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,24 @@
|
||||
//! real fan-out + join to run (two SMAs joining into one node), exercising
|
||||
//! multi-input `Ctx` access inside a running graph.
|
||||
|
||||
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
|
||||
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind};
|
||||
|
||||
/// Two-input f64 difference: input 0 minus input 1. Emits `None` until both
|
||||
/// inputs have a value.
|
||||
#[derive(Default)]
|
||||
pub struct Sub;
|
||||
pub struct Sub {
|
||||
out: [Scalar; 1],
|
||||
}
|
||||
|
||||
impl Sub {
|
||||
/// Build a `Sub` node.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
Self { out: [Scalar::F64(0.0)] }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Sub {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,17 +31,18 @@ impl Node for Sub {
|
||||
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
|
||||
InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any },
|
||||
],
|
||||
output: ScalarKind::F64,
|
||||
output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }],
|
||||
}
|
||||
}
|
||||
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<Scalar> {
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
||||
let a = ctx.f64_in(0);
|
||||
let b = ctx.f64_in(1);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Scalar::F64(a[0] - b[0]))
|
||||
self.out[0] = Scalar::F64(a[0] - b[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +65,6 @@ mod tests {
|
||||
|
||||
// both present -> a - b
|
||||
inputs[1].push(Scalar::F64(4.0)).unwrap();
|
||||
assert_eq!(sub.eval(Ctx::new(&inputs)), Some(Scalar::F64(6.0)));
|
||||
assert_eq!(sub.eval(Ctx::new(&inputs)), Some([Scalar::F64(6.0)].as_slice()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user