bd0c557f16
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
155 lines
10 KiB
Markdown
155 lines
10 KiB
Markdown
# 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 `OutField`s, 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](c01-determinism.md) — the deterministic, non-concurrent event loop; `finalize` runs once after it; the determinism / graph-as-data boundary.
|
|
- [C6](c06-firing-policy.md) — the co-freshness of a record's K columns.
|
|
- [C7](c07-scalar-soa.md) — the four base scalar types, the tag-free `Cell` carrier, records as bundles of base columns, zero per-cycle allocation.
|
|
- [C9](c09-fractal-composition.md) — graph-as-data; `param_space` as a read-only projection; same-type fan-in disambiguation by name.
|
|
- [C12](c12-atomic-sim-unit.md) — the aggregated param-space the optimizer sweeps.
|
|
- [C18](c18-registry.md) — `FieldSpec.name` as metadata for sinks and the playground.
|
|
- [C19](c19-bootstrap.md) — the value-empty recipe, the pre-build signature, and inline order.
|
|
- [C20](c20-strategy-harness.md) — the sweep axis; the search-range is the run's; a `timestamp` axis is structural.
|
|
- [C22](c22-playground-traces.md) — read-only surfacing of the composite `doc` and the blueprint view.
|
|
- [C23](c23-graph-compilation.md) — names as non-load-bearing debug symbols; positional (by-index) identity.
|
|
|
|
> History: [c08-node-contract.history.md](c08-node-contract.history.md)
|