`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
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_injective → CompileError::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;
finalizeruns 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
Cellcarrier, records as bundles of base columns, zero per-cycle allocation. - C9 — graph-as-data;
param_spaceas a read-only projection; same-type fan-in disambiguation by name. - C12 — the aggregated param-space the optimizer sweeps.
- C18 —
FieldSpec.nameas 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
timestampaxis is structural. - C22 — read-only surfacing of the composite
docand the blueprint view. - C23 — names as non-load-bearing debug symbols; positional (by-index) identity.
History: c08-node-contract.history.md