diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 3b89497..7ef756d 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -1,7 +1,8 @@ //! The node contract (C8): the interface every node implements. A node declares //! its inputs (each with its scalar kind, lookback depth, and firing policy, C6) -//! and its output record (1..K base columns, C7) via `schema`, and computes one -//! cycle's row via `eval`. +//! and its output record (0..K base columns, C7; an empty output declares a +//! pure consumer / sink role, C8) via `schema`, and computes one cycle's row +//! via `eval`. //! Tunable params (C12/C19) are deliberately not part of the schema yet — see //! spec 0002's "Out of scope". @@ -42,18 +43,25 @@ pub struct FieldSpec { /// A node's declared interface: its inputs (in order) and its output record — an /// ordered list of named base columns; length 1 is a scalar (the degenerate -/// case). Built once at wiring, never on the hot path — the `Vec`s are fine here. +/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer** +/// (sink role, C8) — a node with no output port. Built once at wiring, never on +/// the hot path — the `Vec`s are fine here. #[derive(Clone, Debug, PartialEq, Eq)] pub struct NodeSchema { pub inputs: Vec, pub output: Vec, } -/// The universal composable dataflow unit (C8): one output port carrying a record -/// of 1..K base columns, a producer or transformer. `schema` declares the +/// The universal composable dataflow unit (C8): a **producer, consumer, or both**. +/// A producer/transformer exposes one output port carrying a record of 1..K base +/// columns; a **pure consumer (sink)** declares `output: vec![]` and records via +/// an out-of-graph side effect in `eval` (cycle 0006). `schema` declares the /// interface; `eval` computes one cycle's row, returning a borrowed slice into a -/// buffer the node owns (`None` = filter / not-yet-warmed-up). `&mut self` because -/// a node may keep its own derived state (and its output buffer). +/// buffer the node owns. `None` = filter / not-yet-warmed-up / pure sink; a +/// returned `Some(row)` must satisfy `row.len() == schema().output.len()` (so a +/// pure consumer returns `None` or an empty `Some(&[])`) — the engine debug-asserts +/// the width and forwards the row field-wise along the node's out-edges. `&mut self` +/// because a node may keep its own derived state (and its output buffer). pub trait Node { fn schema(&self) -> NodeSchema; fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>; diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index c74cec4..c2f9f36 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -215,7 +215,15 @@ the record to a destination it holds as a field (a channel, a chart handle) — **out-of-graph side effect**. There is no `Sink` type, trait, or engine flag: a node that only records returns `None` (pure consumer), and a node may record **and** return an output the engine forwards in the same `eval` (the "both" -case). In-graph routing stays engine-owned data (the edge table); the escape out +case). **Encoding & return contract.** A pure consumer declares `output: vec![]` +— the empty record *is* the sink declaration; there is no separate type, trait, +or marker. Its `eval` returns `None` or a zero-width `Some(&[])`, and the run +loop debug-asserts the returned row's width equals the declared output width +(`row.len() == schema.output.len()`). Field-wise wiring resolves +`Edge::from_field` against the producer's `output` at bootstrap, so no edge can +bind a field of a zero-output node — it fails with `BadIndex` — making a sink +structurally unwireable as an in-graph producer; its only output is the +out-of-graph side effect. In-graph routing stays engine-owned data (the edge table); the escape out of the graph is the node's own side effect — and that boundary is the determinism / graph-as-data boundary (C1/C7).