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:
2026-06-03 15:55:20 +02:00
parent f95fe0f4e4
commit 5228542bfd
6 changed files with 384 additions and 82 deletions
+14 -8
View File
@@ -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()));
}
}
+16 -8
View File
@@ -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()));
}
}