Files
Aura/docs/design/contracts/c08-node-contract.md
T
claude bd0c557f16 docs(ledger, glossary): tap-subscribers cycle tidy — C8/C27/C28 lifts, tap subscription vocabulary
Cycle-close audit for the #283/#77 tap-subscribers cycle
(09994b8..6e3f394). Architect review: code contract-clean; what
holds — C1/C7/C8 (empty-output caller-built consumers, (Timestamp,
Cell) zero-heap payload, lifecycle hooks inside the sequential loop,
byte-identical twin-run and streamed-vs-legacy byte-equality pins),
C28 (assembly-crate placement of the writer-holding consumer,
c28_layering guard green), C25 (registry growth = new Rust entry,
data-expressible Named form, roster-enumerating refusals). All drift
items were doc lifts, resolved in this commit:

- C8: the lifecycle is now a symmetric pair — initialize() as
  finalize's start-of-stream mirror (infallible by signature;
  acquire-in-initialize, degrade-to-inert, surface-once-at-finalize);
  history sidecar records the refinement and the #77 resolution the
  old Deferred paragraph still carried as open.
- C27: Guarantee and Current state now describe the shipped model —
  tap plan (Named{label, params} | Live) over the layered fold
  registry, BOTH entry points (run_signal_r AND run_measurement) on
  the one shared wiring pair, record streaming at constant memory.
- C28: aura-runner now defines one graph node of its own (the
  in-graph record consumer — exactly the graph-meets-persistence
  assembly position); the #297 refusal-site count grows ~20 → ~24
  (the four tap-plan refusals, already typed before the exit).
- glossary §tap: the subscription/fold-registry sense added; Avoid
  line steers probe/monitor/scope away from the observation slot.

Bench (report-only, loadavg 5.8 > 2 on the box — read with care),
all fingerprints OK, no regression:
  engine_throughput    bars_per_s 14005262   wall_s 0.714s   Δ bars_per_s +3.0% wall_s -3.0%
  ingest_throughput    bars_per_s 12970670   wall_s 0.387s   Δ bars_per_s +0.5% wall_s -0.5%
  campaign_sweep       cpu_percent 2202.0   peak_rss_mb 92.2   wall_s 1.396s   Δ cpu_percent +1.5% peak_rss_mb -6.1% wall_s -1.5%
  campaign_heavy       cpu_percent 2155.0   peak_rss_mb 94.8   wall_s 5.585s   Δ cpu_percent +1.8% peak_rss_mb -3.5% wall_s -0.4%
  cli_fixed_cost       help_ms 1.5ms   run_ms 3.6ms   Δ help_ms -1.8% run_ms -2.6%
No baseline update (report-only surface; no ratify due).

Correction on 6e3f394's body: "no Probe symbol is introduced
anywhere" — introduced is the operative word; the pre-existing
aura-core test fixture `struct Probe` stays, as the cycle's design
record specified.

Follow-ups on the tracker: #308 (legacy Recorder surface migration),
#307 (binary trace format). Cycle spec/plan working files discarded.

refs #283 refs #77
2026-07-21 23:56:14 +02:00

10 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 live / --trace / test-tap path.

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