The first walking-skeleton cycle: the aura-core data substrate everything streams through — the four scalar base types (Scalar/ScalarKind/Timestamp, C7), the fixed-capacity financial-indexed SoA Column with its zero-copy Window (C8), the type-erased AnyColumn edge with an edge-time kind check (C7), and the per-column run_count freshness primitive (C5). Sharpens the RustAst reference: no boxed Value, no VecDeque, no RefCell/Rc, no per-event allocation. Node trait, Ctx, firing policies, and the sim loop are deferred to subsequent cycles. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
Core Streaming Substrate — Design Spec
Date: 2026-06-03 Status: Draft — awaiting user spec review Authors: orchestrator + Claude
Goal
Deliver the foundation everything in aura streams through: the four scalar base
types, the bounded-lookback Structure-of-Arrays column with financial-style
indexing, the zero-copy read window a node sees, the type-erased edge carrier,
and the per-column run-count that the freshness model is built on. This is the
first cycle of the walking-skeleton milestone (ingest → one signal →
deterministic backtest → position table → sim-optimal broker → pip-equity); it
is the substrate the subsequent Node/Ctx/engine-loop cycles build on.
The reference is RustAst's src/ast/rtl/series and streams — a working
implementation of this substrate. aura keeps its shape (newest-at-0 indexing,
a per-series push counter for freshness, SoA composites) and sharpens it:
no boxed Value, no VecDeque, no RefCell/Rc, no per-event allocation.
This cycle implements only the data substrate. Out of scope (subsequent
cycles): the Node trait, the evaluation Ctx, firing policies A/B, the
deterministic sim loop / freshness-gated recompute, the ingestion boundary /
k-way merge, and the named OHLCV bundle. Those are named here only to fix this
cycle's boundary; this cycle delivers the primitives they will consume.
Architecture
aura-core gains four small modules, no dependencies added:
scalar— the closed four-type scalar set: theTimestampnewtype, theScalarCopy-POD carrier, and theScalarKindtag (C7).column—Column<T>: a fixed-capacity ring buffer, pre-sized to a lookback limit at construction, financial-indexed (index 0 = newest), carrying a monotonicrun_count(C5/C8).Window<'_, T>is its zero-copy read view.any—AnyColumn: the type-erased edge carrier, an enum over the fourColumn<T>kinds. The kind check is paid at the edge (wiring time); the hot loop pushes into the concreteColumn<T>monomorphically (C7).error—KindMismatch, the wiring-time guard error returned when aScalarof the wrong kind is pushed into anAnyColumn.
The whole substrate is interior-mutability-free. A sim is single-threaded
and non-concurrent (C1), so the engine owns columns by &mut for the push side
and hands out &Column / Window (shared borrow) on the read side — RustAst's
RefCell-on-every-series disappears.
Concrete code shapes
North-star slice (the consumer this substrate exists to serve)
A future aura-std SMA node — not built this cycle — reads its input as an
indexed, newest-at-0 window. This is the minimal honest slice proving the
substrate's read API fits the node use case it exists for:
// Illustrative future consumer (NOT delivered this cycle): the read shape an
// indicator node wants. `length` is the node's pre-sized lookback (C8).
fn sma(window: &Window<'_, f64>, length: usize) -> Option<f64> {
if window.len() < length {
return None; // not yet warmed up (C8: None = not-yet-warmed-up)
}
let sum: f64 = (0..length).map(|k| window[k]).sum(); // [0] newest, [length-1] oldest
Some(sum / length as f64)
}
The point of the cycle is that Window makes this code possible with no
lookback bookkeeping in the node: the engine pre-sized the buffer, the node only
reads by index.
Delivered code — the scalar set (scalar.rs)
/// Canonical engine time: epoch-nanoseconds UTC. Newtype over i64 (C7).
/// `Default` (epoch 0) is required so `Column<Timestamp>` can pre-size its ring.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Timestamp(pub i64);
/// The four scalar base types, type-erased for an edge. Copy POD — no `dyn Any`,
/// no heap, no Rc (C7 forbids all three on the hot path).
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Scalar {
I64(i64),
F64(f64),
Bool(bool),
Ts(Timestamp),
}
/// The kind tag of a scalar / column, used for the edge-time type check.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum ScalarKind {
I64,
F64,
Bool,
Timestamp,
}
impl Scalar {
pub fn kind(self) -> ScalarKind { /* match */ }
}
// Total, infallible widenings into the carrier:
impl From<i64> for Scalar { /* ... */ }
impl From<f64> for Scalar { /* ... */ }
impl From<bool> for Scalar { /* ... */ }
impl From<Timestamp> for Scalar { /* ... */ }
Delivered code — the column and its window (column.rs)
/// A fixed-capacity, bounded-lookback SoA column. Pre-sized at construction
/// (C8: no realloc in the hot loop); financial-indexed (index 0 = newest);
/// carries a monotonic push counter for the freshness model (C5).
pub struct Column<T> {
buf: Box<[T]>, // capacity == lookback; never reallocated
head: usize, // ring write cursor (next slot)
len: usize, // valid elements, saturates at capacity
run_count: u64, // monotonic total pushes — RustAst's total_count (C5)
}
impl<T: Copy + Default> Column<T> {
/// `lookback` is the window depth the engine sized for this edge (C8).
pub fn with_capacity(lookback: usize) -> Self { /* Box<[T]> of len lookback */ }
/// Append the newest value. O(1), no allocation. Bumps `run_count`.
/// On overflow the oldest value is overwritten (true ring).
pub fn push(&mut self, v: T) { /* buf[head]=v; head=(head+1)%cap; len=min(len+1,cap); run_count+=1 */ }
/// Financial index: 0 = newest, `len()-1` = oldest. None if `k >= len`
/// (C8: None == not-yet-warmed-up / out of range). Zero-copy.
pub fn get(&self, k: usize) -> Option<T> { /* buf[(head - 1 - k) mod cap] */ }
pub fn len(&self) -> usize { self.len }
pub fn capacity(&self) -> usize { self.buf.len() }
pub fn run_count(&self) -> u64 { self.run_count }
/// A zero-copy read view handed to a reading node.
pub fn window(&self) -> Window<'_, T> { Window { col: self } }
}
/// Read-only, zero-copy view into a `Column` — what a node sees (C8).
pub struct Window<'a, T> {
col: &'a Column<T>,
}
impl<'a, T: Copy + Default> Window<'a, T> {
pub fn get(&self, k: usize) -> Option<T> { self.col.get(k) }
pub fn len(&self) -> usize { self.col.len() }
pub fn run_count(&self) -> u64 { self.col.run_count() }
}
/// `window[k]` — newest-at-0 indexed access; panics on out-of-range, matching
/// slice `Index` semantics. Nodes that may be cold use `get` instead.
impl<'a, T: Copy + Default> core::ops::Index<usize> for Window<'a, T> {
type Output = T;
fn index(&self, k: usize) -> &T { /* &buf[(head-1-k) mod cap], bounds-checked */ }
}
Delivered code — the type-erased edge (any.rs)
/// A type-erased edge: the four concrete columns behind one enum (C7). The kind
/// check happens here, at wiring time; the hot loop grabs the concrete column
/// once (`as_f64_mut`) and pushes monomorphically — no per-event dispatch.
pub enum AnyColumn {
I64(Column<i64>),
F64(Column<f64>),
Bool(Column<bool>),
Ts(Column<Timestamp>),
}
impl AnyColumn {
pub fn with_capacity(kind: ScalarKind, lookback: usize) -> Self { /* match kind */ }
pub fn kind(&self) -> ScalarKind { /* match self */ }
pub fn len(&self) -> usize { /* match self */ }
pub fn run_count(&self) -> u64 { /* match self */ }
/// Edge-time typed push. Returns `KindMismatch` if `v.kind() != self.kind()`
/// — the C7 type-erasure guard. The hot path avoids this by taking the
/// concrete `Column<T>` once via `as_*_mut`.
pub fn push(&mut self, v: Scalar) -> Result<(), KindMismatch> { /* match (self, v) */ }
/// Read one type-erased value (0 = newest). None if cold / out of range.
pub fn get(&self, k: usize) -> Option<Scalar> { /* match self -> Scalar::… */ }
/// Concrete-column accessors for the monomorphic hot path.
pub fn as_f64_mut(&mut self) -> Option<&mut Column<f64>> { /* … */ }
pub fn as_i64_mut(&mut self) -> Option<&mut Column<i64>> { /* … */ }
pub fn as_bool_mut(&mut self) -> Option<&mut Column<bool>> { /* … */ }
pub fn as_ts_mut(&mut self) -> Option<&mut Column<Timestamp>> { /* … */ }
}
Delivered code — the must-fail guard fixture (error.rs + a test)
The C7 type-erasure contract is load-bearing: a wrong-kind push must be rejected, never silently coerced. The test whose correct behaviour is rejection:
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct KindMismatch {
pub expected: ScalarKind,
pub got: ScalarKind,
}
#[test]
fn wrong_kind_push_is_rejected() {
let mut edge = AnyColumn::with_capacity(ScalarKind::I64, 8);
let err = edge.push(Scalar::F64(1.5)).unwrap_err(); // pushing f64 into an i64 edge
assert_eq!(err, KindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64 });
assert_eq!(edge.len(), 0); // nothing was stored
assert_eq!(edge.run_count(), 0); // and the counter did not move
}
Implementation shape (secondary — before → after)
aura-core/src/lib.rs today is doc-comment-only (no pub items). After this
cycle it declares the four modules and re-exports the surface:
// before: lib.rs is documentation only, defines no types.
// after:
mod scalar;
mod column;
mod any;
mod error;
pub use scalar::{Scalar, ScalarKind, Timestamp};
pub use column::{Column, Window};
pub use any::AnyColumn;
pub use error::KindMismatch;
Components
Timestamp—i64epoch-ns newtype, fully ordered (the ingestion boundary will normalize source-native units into it in a later cycle; here it is just the type).Scalar/ScalarKind— the closed carrier + tag.ScalarisCopy;Fromimpls give ergonomic widening;kind()projects to the tag.Column<T>— the ring. Owns a pre-sizedBox<[T]>;pushis O(1) and allocation-free;get/Indeximplement newest-at-0;run_countis the freshness primitive.Window<'_, T>— the borrow a reader gets; carries no storage, only a reference;Indexfor the warm path,getfor the possibly-cold path.AnyColumn— the edge; type check at the boundary, concrete-column accessors for the hot loop.KindMismatch— the guard error.
Data flow
- The engine (later cycle) sizes an edge:
AnyColumn::with_capacity(kind, lookback)— buffer allocated once, never again (C8). - A producer pushes its newest output for the cycle. Wiring-time / dynamic
path:
edge.push(scalar)?(kind-checked). Hot path: the engine tookedge.as_f64_mut()once at sim start and callscol.push(x)monomorphically each cycle — no per-event dispatch, no allocation (C7). run_countincrements on every push; the freshness model (later cycle) compares an input'srun_countagainst the value it last saw to decide fire-vs-hold (C5). This cycle delivers the counter, not the comparison loop.- A reading node receives
col.window()and reads by index, newest at 0 (C8).
There is no merge, no clock, and no recompute logic in this cycle — those are the engine's, downstream. The substrate is pure data structures + their invariants.
Error handling
- Wrong-kind push →
Err(KindMismatch { expected, got }); the column is left untouched (len and run_count unchanged). This is the only fallible operation in the substrate. - Cold / out-of-range read →
getreturnsNone(C8: not-yet-warmed-up);Indexpanics like a slice (the warm-path ergonomic, used only when the node has already checkedlen). - No panics on the push/normal-read path; no allocation after construction; no interior mutability to poison.
Testing strategy
Unit tests live beside each module; all run under cargo test --workspace.
Columnring semantics: push thenget(0)is newest;get(len-1)is oldest; pushing more than capacity keeps exactly the newestcapacityvalues and drops the oldest (wraparound correctness);lensaturates at capacity;get(k>=len) == None.run_countis the freshness primitive: monotonic across pushes (including after the ring wraps — it counts pushes, not stored len); never moved by a read.Windowis a zero-copy view: values read through the window equal the column's; taking a window does not change the column's capacity (no realloc);Indexandgetagree on the warm range.Scalar/ScalarKind:kind()is correct for each variant;Fromwidenings round-trip throughgeton anAnyColumn.AnyColumntype erasure: same-kind push stores and is read back as the right-kindScalar;run_count/lendelegate; the concreteas_*_mutaccessor returnsSomeonly for the matching kind.- The must-fail guard (
wrong_kind_push_is_rejected, shown above): a wrong-kind push is rejected and leaves the column untouched.
Acceptance criteria
Applying aura's feature-acceptance lens (CLAUDE.md):
- The intended audience naturally reaches for it. The node author (LLM via
builder APIs, later cycles) reads inputs as
window[k]newest-at-0 — the north-star slice above is exactly the SMA an author writes, with zero lookback bookkeeping. The substrate is the thing every node, source, and sink stands on. - It measurably improves correctness / removes redundancy. Engine-sized,
pre-allocated windows mean node code cannot mis-manage lookback (C8
rationale); the closed
Scalarset +AnyColumnremoves RustAst's boxedValueand per-seriesRefCellentirely. - It reintroduces no failure class the core constraints exist to kill. No
dyn Any, no per-event heap allocation, no interior mutability, fixed pre-sized buffers (C1/C7/C8 all upheld); the wrong-kind push is structurally rejected, not coerced.
Concretely, the cycle ships when:
cargo build --workspace,cargo test --workspace, andcargo clippy --workspace --all-targets -- -D warningsare all green.- Every test in the Testing-strategy list exists and passes, including the
wrong_kind_push_is_rejectedmust-fail guard. aura-coreexportsScalar,ScalarKind,Timestamp,Column,Window,AnyColumn,KindMismatchand nothing leaksRefCell/Rc/dyn Anyon the substrate surface.