Files
Aura/docs/design/contracts/c08-node-contract.md
T
claude acd3d2c341 feat(aura-runner, aura-cli): the single-run path folds what it only aggregates
`run_signal_r` retained one row per cycle for the equity, exposure and dense
position-management series, then derived nothing from them but three summary
values — and bound a fourth sink, r-equity, whose receiver it held without ever
draining, so those rows accumulated for the length of the run and were dropped
unread. The reduce path had used a folded delivery all along. This moves the
single-run path onto it: the two f64 series land one summary row each at
finalize, the dense record is gated to its closed rows plus the true final row,
and the unread sink is no longer built. Retention on that path goes from
O(cycles) to O(trades).

Every reported number is unchanged. The replacement read is, line for line, what
`run_blueprint_member` already does, so it inherits that path's proof rather than
needing a new one; both equivalences it rests on were already pinned green
(a folded series summary equals the post-run fold's fields; the gated record
equals the full record through the R reduction, including a trade still open at
the window end). The `cli_fixed_cost` fingerprint, which hashes the exec record
line with only the build sha blanked, is unmoved.

The delivery parameter is renamed `fold_series`. Its old name described the
sweep/reduce run mode, which the single-run path is not; the new one names what
the flag does to the sinks, which is the only framing true at every call site —
the two param-space probes request the folded shape while consuming nothing at
all. Rust has no named arguments and every site passes the flag positionally, so
the rename reached no caller.

Two properties that were load-bearing but unguarded are now pinned:

- `crates/aura-runner/tests/folded_wrap_retention.rs` asserts that the folded
  wrap retains at most one record row per closed trade plus one, that each
  series sink retains at most its summary row, and that the r-equity sink is
  unbound. Both assertions are identities that hold at any trade count, so the
  pin depends on no property of the driven price path. Demonstrated red under
  retained delivery: 50000 rows for 250 trades.
- `--trace` has been absent since the sugar retirement (#319) with nothing in
  the suite guarding its absence; this cycle rests a scope exclusion on that
  absence, so `exec_trace_flag_no_longer_parses` now guards it.

A third pin closes a gap the change made load-bearing: the suite pinned
`total_pips` and the R block on the exec path but neither `max_drawdown` nor
`bias_sign_flips`, the two values whose positional read this cycle rewrote.
Demonstrated red under a deliberate column slip.

Prose the change stranded is corrected across production comments, test
identifiers and four live ledger contracts; the superseded C08 clause moves
verbatim to its history sidecar. Three of those sites still described `--trace`
as reachable, one of them in the ledger.

Suite 1652 green, clippy clean, all five bench fingerprints OK.

closes #308
2026-07-27 23:10:45 +02:00

11 KiB

C8 — The node contract

Guarantee. A node is the universal composable dataflow unit — a producer, a consumer, or both. It has a signature, its NodeSchema: each input port (scalar kind + firing group), its output record, and its own tunable parameters — each a typed knob (name + scalar kind). The node declares a knob's existence and type, never its search interval: which subset/grid a sweep covers is the run's concern (an experiment axis, C20), not the node's. The knobs aggregate into the blueprint's flat, path-qualified param-space the optimizer sweeps (C12/C19/C20). The whole signature is declared once, pre-build, on the value-empty recipe (C19), so the entire interface is legible without building; the built node carries no schema(). The one param-dependent quantity — an input's buffer lookback depth (e.g. an Sma's window = its length) — is not in the signature but answered by lookbacks(), read once at bootstrap to size the windows. The built node implements eval(ctx) -> Option<&[Cell]>: the engine provides read-only, zero-copy windows into each input's SoA ring buffer (ctx.f64_in(i)[k], sized at wiring), and eval returns a borrowed row — one Cell per declared output column — into a node-owned buffer, so the forward path allocates nothing per cycle (C7). A node may additionally keep its own mutable series for derived/intermediate state. Binding is field-wise: one edge selects one producer column, so consuming a whole record is N edges — there is no "bind whole record" mechanism. The K columns of one record are co-fresh by construction (one eval, one timestamp), so C6 is untouched.

A producer/transformer exposes one output port, whose payload is a record of 1..K base-scalar columns (a scalar is the degenerate K=1 record); a pure consumer (sink) — chart, equity, logger — declares no output and records via an out-of-graph side effect. Recording is a node role, not a type: a node declaring output: vec![] is a sink (the empty record is the whole declaration — no Sink type, trait, or engine flag), its eval returns None or a zero-width Some(&[]), and it pushes its record to a destination it holds as a field (a channel, a chart handle). A node may record and return a forwarded output in the same eval (the "both" case). None = filter / not-yet-warmed-up / pure sink. Sources are pure producers; sinks are pure consumers. Beside eval, the contract has a symmetric lifecycle pair — default-no-op hooks the engine calls once per node, in topological order: initialize() before the first source value, letting a consumer acquire its run resources (e.g. the file a recording sink streams into) at stream start, and finalize() after the source loop drains, letting a sink fold online and flush one compact summary at stream end instead of streaming a row every fired cycle. A node also exposes label(), a single-line, non-load-bearing render symbol (C23).

Forbids. A node sizing/growing its input lookback at runtime; more than one output port per node; a fifth scalar type or a heterogeneous output payload (a record is a bundle of base columns, C7); copy-on-read of input history; a Sink type/trait/engine flag or a "bind whole record" mechanism (both are structurally absent by design).

Why. Engine-provided windows mean LLM-authored code cannot mis-manage lookback bookkeeping, and history passes through zero-copy. Fixed, pre-sized buffers suit deterministic, pre-dimensioned sims (no realloc in the hot loop). Declaring the signature once, pre-build on the recipe, keeps the whole interface legible without building and dissolves the drift of declaring params twice (recipe vs built node). The empty-output sink keeps in-graph routing engine-owned data (the edge table) and pushes every escape out of the graph onto the node's own side effect — that boundary is the determinism / graph-as-data boundary (C1/C7). finalize gives a folding sink O(trades)/O(1) owned accumulator state and one summary row, instead of an unbounded channel that buffers O(cycles) rows until the run ends; it runs once after the deterministic event loop, adding no within-sim concurrency (C1), and holds only owned state, no interior mutability (C7). initialize mirrors it at stream start (#283): resource acquisition happens inside the same deterministic sequence, and the hook is infallible by signature — a consumer that fails to acquire stores the error, degrades to inert, and surfaces the failure once, terminally, at finalize (the run completes; refusal-worthy faults are caught pre-run, at construction/bind time).

Current state

The signature types and the Node trait live in aura-core/src/node.rs. NodeSchema { inputs: Vec<PortSpec>, output: Vec<FieldSpec>, params: Vec<ParamSpec> }; PortSpec { kind, firing, name }, FieldSpec { name, kind }, and ParamSpec { name, kind } each carry a name that is a non-load-bearing debug symbol (C23) — wiring is positional by slot; bootstrap and the run loop never read it. Permitted param kinds are i64/f64/bool (a timestamp knob is a structural axis, C20, never a numeric sweep param).

trait Node declares lookbacks() -> Vec<usize>, eval(&mut self, ctx) -> Option<&[Cell]>, label() -> String (default a placeholder every shipped node overrides; overrides carry identifying params so SMA(2) vs SMA(4) disambiguate in a graph render, #13), and the lifecycle pair initialize(&mut self) / finalize(&mut self) (both default no-op). There is no Node::schema() — the signature is pre-build data, not a built-node method. The signature is declared once on the value-empty recipe PrimitiveBuilder (aura-core/src/node.rs), whose schema() / params() are read pre-build by the param-space aggregation, the structural validation, and the render; named(...) sets a node instance's authoring name and bind(slot, value) fixes a knob to a structural constant, removing it from the param surface.

Field-wise binding is Edge::from_field (aura-engine/src/blueprint.rs), resolved against the producer's output at bootstrap. A sink's empty output therefore makes it structurally unwireable as an in-graph producer — no edge can bind a field of a zero-output node. The run loop (aura-engine/src/harness.rs) debug-asserts row.len() equals the declared output width, and that lookbacks() arity equals signature().inputs.len(). The lifecycle pair is realized in Harness::run (aura-engine/src/harness.rs): an initialize() prologue once per node in topological order before the source loop, and the finalize() epilogue after it drains — each pinned by its own once-per-run mirror test; GatedRecorder and SeriesReducer (aura-std) are the folding siblings of the per-cycle Recorder (aura-std/src/recorder.rs), which survives for the callers that read a per-cycle series back: the campaign trace writer (aura-runner::runner::persist_campaign_traces, whose retained-delivery re-run builds the trace columns) and the test-tap sites.

BlueprintNode::signature() (aura-engine/src/blueprint.rs) answers uniformly for both arms: a primitive returns its recipe's schema, a composite derives it via derive_signature (input-port kinds from interior target slots, output-field kinds from re-exported OutFields, aggregated params). Composite::param_space() (same file) aggregates every node's declared params into one flat, path-qualified list mirroring the inline order (C19/C23) — a read-only projection of the graph-as-data (C9). Identity in the flat graph is positional; the param_space() name projection (the by-name authoring/sweep address space) must be injective for a blueprint to compile, enforced by check_param_namespace_injectiveCompileError::DuplicateParamPath, so same-type siblings in one composite must be .named(...) apart (also what disambiguates a same-type fan-in, C9). A vector knob (LinComb.weights) expands to N flat weights[i] entries, N topology-fixed (C19).

Wiring is total and single-valued over interior input slots: check_ports_connected, run inside validate_wiring at every nesting level, requires every interior node's every declared input slot to be covered by exactly one wiring act (one interior Edge { to, slot } or one role Target { node, slot }, counted uniformly). Zero coverage is CompileError::UnconnectedPort, more than one is CompileError::DoubleWiredPort. A composite's own input roles (source: None) are coverage providers (the wired-by-enclosing boundary, the root case guarded by UnboundRootRole), never consumers. There is no optional-input concept — every declared input port is required. The check is index-based and name-free, so C23 is untouched.

A Composite carries an optional authored rationale doc: Option<String> (aura-engine/src/builder.rs: GraphBuilder::doc / Composite::with_doc) — the prose twin of its name, the same C23 non-load-bearing category. It is persisted as an additive-optional CompositeData field (aura-engine/src/blueprint_serde.rs; absent-field documents keep their exact bytes, so existing content ids are untouched), blanked in the identity projection by strip_debug_symbols, dissolved at inline like the name, and surfaced read-only in the graph model and viewer (C22).

Deferred: Naming input ports (PortSpec.name) makes swap-prone same-kind slots self-documenting but does not add a name-consuming wiring validation; a swapped same-kind wiring is still only kind-checked (#21). The construction op-script vocabulary has no doc-carrying surface yet (scope cut on #125).

See also

  • C1 — the deterministic, non-concurrent event loop; finalize runs once after it; the determinism / graph-as-data boundary.
  • C6 — the co-freshness of a record's K columns.
  • C7 — the four base scalar types, the tag-free Cell carrier, records as bundles of base columns, zero per-cycle allocation.
  • C9 — graph-as-data; param_space as a read-only projection; same-type fan-in disambiguation by name.
  • C12 — the aggregated param-space the optimizer sweeps.
  • C18FieldSpec.name as metadata for sinks and the playground.
  • C19 — the value-empty recipe, the pre-build signature, and inline order.
  • C20 — the sweep axis; the search-range is the run's; a timestamp axis is structural.
  • C22 — read-only surfacing of the composite doc and the blueprint view.
  • C23 — names as non-load-bearing debug symbols; positional (by-index) identity.

History: c08-node-contract.history.md