The single-file ledger had grown to 2968 lines / ~42k tokens, mixing current design law with accreted history: 59 cycle-stamped realization blocks, 18 [HISTORY] passages, 22 supersession markers, and the C10 / C22 / C24 reframe sagas layered several supersessions deep. A code-grounding audit (31 agents, adversarially verified) confirmed 11 defects stated as current truth: stale crate homes from the C28 #288 roster split (cost nodes, PositionManagement, PositionEvent, Session), the renamed InputSpec->PortSpec, the pre-#241 project model in C16 and the open-threads section, a stale HarnessKind retirement deferral in C24, and three C28-internal inconsistencies. New shape, per the ailang precedent: - INDEX.md stays the sole addressable entry point: foundation, external components, a C-id-keyed contract map (one line per contract), and only the genuinely open architectural threads. - contracts/cNN-<slug>.md carries each contract's current truth only: Guarantee / Forbids / Why with ratified refinements integrated, plus a code-anchored Current state. All confirmed defects are fixed here; crate anchors were re-verified against the tree. - contracts/cNN-<slug>.history.md (18 sidecars) and INDEX.history.md preserve every superseded block verbatim, stamps and issue refs intact, under a frozen-record banner. Nothing was deleted: superseded design intent remains an addressable working-tree artifact, off the per-cycle audit walk. - Ledger discipline is now stated in INDEX.md: live files are edited in place at cycle close, superseded text moves verbatim to the sidecar, and a supersession marker in a live file is itself an audit finding. Every contract file was verified against its old text by an independent zero-loss pass (statement-by-statement) plus a code-accuracy spot check; C-ids and contract titles are unchanged, so existing C-id citations in code, tests, and issues resolve as before.
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 second lifecycle phase, finalize() — a default-no-op hook the
engine calls once per node, in topological order, 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).
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 finalize(&mut self) (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 end-of-stream finalize epilogue is realized in
Harness::run (aura-engine/src/harness.rs), which calls finalize() once per
node in topological order after the source loop drains; 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_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: the recording sink's own per-cycle allocation — the Recorder→Probe
rename and its accumulate-vs-stream choice — stays open (#77). 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