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
174 lines
13 KiB
Markdown
174 lines
13 KiB
Markdown
# C8 — The node contract: history
|
||
|
||
> FROZEN HISTORICAL RECORD. Each block below was true as of its cycle/date stamp and may be superseded; this file is NOT current truth and NOT a grounding surface. Current contract: [c08-node-contract.md](c08-node-contract.md).
|
||
|
||
**Realization (cycle 0005).** `NodeSchema.output` is a `Vec<FieldSpec>` (named base
|
||
columns; length 1 = scalar). Binding is **field-wise only**: `Edge::from_field`
|
||
selects one producer column per edge; consuming a whole record is N edges (no
|
||
"bind whole record" mechanism). The K fields of one record are **co-fresh by
|
||
construction** (one `eval`, one timestamp), so C6 is untouched. `eval` returns
|
||
`Option<&[Cell]>` — a borrowed row into a node-owned buffer — so the forward
|
||
path allocates nothing per cycle (C7) (the carrier is now a tag-free `Cell` — see
|
||
the C7 carrier note).
|
||
|
||
**Realization (cycle 0006).** The pure-consumer (sink) half of this contract is
|
||
now realized at the substrate: **recording is a node role, not a type.** A
|
||
recording node reads its typed input windows + `ctx.now()` in `eval` and pushes
|
||
the record to a destination it holds as a field (a channel, a chart handle) — an
|
||
**out-of-graph side effect**. There is no `Sink` type, trait, or engine flag: a
|
||
node that only records returns `None` (pure consumer), and a node may record
|
||
**and** return an output the engine forwards in the same `eval` (the "both"
|
||
case). **Encoding & return contract.** A pure consumer declares `output: vec![]`
|
||
— the empty record *is* the sink declaration; there is no separate type, trait,
|
||
or marker. Its `eval` returns `None` or a zero-width `Some(&[])`, and the run
|
||
loop debug-asserts the returned row's width equals the declared output width
|
||
(`row.len() == schema.output.len()`). Field-wise wiring resolves
|
||
`Edge::from_field` against the producer's `output` at bootstrap, so no edge can
|
||
bind a field of a zero-output node — it fails with `BadIndex` — making a sink
|
||
structurally unwireable as an in-graph producer; its only output is the
|
||
out-of-graph side effect. In-graph routing stays engine-owned data (the edge table); the escape out
|
||
of the graph is the node's own side effect — and that boundary is the
|
||
determinism / graph-as-data boundary (C1/C7).
|
||
|
||
**Realization (cycle 0015 — param declaration).** The tunable-parameter half of
|
||
this contract is now realized: a node declares its knobs in `schema()` as
|
||
`params: Vec<ParamSpec>` (`ParamSpec { name: String, kind: ScalarKind }`), and
|
||
`Composite::param_space()` (cycle 0024; was `Blueprint::param_space()`) aggregates
|
||
them into one flat, path-qualified list — a
|
||
read-only projection of the graph-as-data (C9), mirroring the inline order
|
||
(C19/C23) so a param's slot matches the later flat-node order. Two refinements to
|
||
the guarantee's "typed, with ranges": (1) the declaration carries `name` + `kind`
|
||
only — the **search-range is the run's, not the node's** (which subset / grid a
|
||
sweep covers is an experiment axis, #32/C20; the node declares the knob's existence
|
||
and type, never its search interval). (2) Identity is **positional** (the slot,
|
||
C23 "by index, not name"); the path-qualified `name` is a non-load-bearing debug
|
||
symbol (like `FieldSpec.name`) — uniqueness is at the slot. **Refinement (cycle
|
||
0032):** identity in the **flat graph** stays positional (C23, unchanged), but the
|
||
`param_space()` **name projection** — the authoring / by-name address space (the
|
||
surface a sweep axis or single-run binding addresses) — must be **injective** for a
|
||
blueprint to compile (a duplicated path is unaddressable; see C9). Two layers:
|
||
positional wiring below (the flat graph), an injective name address space above (the
|
||
authoring boundary). So same-type siblings in one composite no longer silently
|
||
share a name — they must be `.named(...)` apart, which is 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). Permitted kinds are `i64`/`f64`/
|
||
`bool` (a `timestamp` knob is a structural axis, C20, never a numeric sweep param).
|
||
Binding a value to a slot landed in cycle 0016 (#31, see C19/C23); enumerating a
|
||
sweep (#32) is the deferred next layer. The 0016 binding also moved the param
|
||
declaration's *authoring* home into the value-empty recipe, whose `params()` is read
|
||
pre-build. In 0016–0023 the built node's `schema().params` still reported the same
|
||
slots, kept in lockstep by a per-node test (a duplication filed as debt, #36); **cycle
|
||
0024 dissolved this** — the signature is declared *once* on the recipe and the built
|
||
node no longer carries `schema()` (#36 closed). See the C8 cycle-0024 realization.
|
||
|
||
**Realization (cycle 0024 — the signature lives in the blueprint, #43/#36).** The
|
||
node's whole declared interface — its `NodeSchema` (input scalar types + firing,
|
||
output record, params) — now lives **once**, on the value-empty recipe
|
||
`PrimitiveBuilder` (ex-`LeafFactory`), read pre-build by `param_space()`, the
|
||
compile-time structural validation, and the render. This closes two debts: **#43**
|
||
(a value-empty recipe used to declare *no* input/output interface pre-build — only
|
||
params) and **#36** (params declared twice, recipe vs built node, kept in lockstep
|
||
by a per-node test — those 8 tests are deleted, the duplication structurally gone).
|
||
The split that makes this work: a node's signature is **fully static** per blueprint
|
||
(input kinds/firing, output fields, params — verified across the roster; a variable-
|
||
arity node like `LinComb` takes its arity as a *recipe argument*, not an injected
|
||
param), so it can be declared without building; the **one** param-dependent quantity,
|
||
an input's buffer **lookback** depth, is no longer in the signature but answered by
|
||
`Node::lookbacks() -> Vec<usize>`, read only by bootstrap to size the windows.
|
||
`Node::schema()` is therefore **removed** — the signature is pre-build data, not a
|
||
built-node method. `BlueprintNode::signature()` answers uniformly for both arms (a
|
||
primitive returns its recipe's schema; a composite *derives* it from the interior:
|
||
role kinds in, re-exported field kinds out, aggregated params), so "every node has a
|
||
signature in the blueprint" holds for composites too.
|
||
|
||
**Realization (cycle 0027 — name input ports, refs #21/#51).** `PortSpec` now
|
||
carries a `name: String` (it drops `Copy`, like `ParamSpec`), so an input port is
|
||
named just as `FieldSpec.name` (output) and `ParamSpec.name` (param) already are.
|
||
The name is **non-load-bearing** (C23): wiring stays positional by slot, bootstrap
|
||
and the run loop never read it; it exists for tracing / graph rendering (#13). Leaf
|
||
primitives declare their slot names (`SimBroker`'s `exposure`/`price`, etc.);
|
||
`derive_signature` carries a composite's `Role.name` into the derived input port,
|
||
so the graph model (`model_to_json`) is homogeneously named across inputs, outputs,
|
||
and params and across both graph levels. This does **not** close #21 (a swapped
|
||
same-kind wiring is still only kind-checked) — it makes those slots
|
||
self-documenting; a name-consuming validation is its own future cycle.
|
||
|
||
**Realization (cycle 0040 — wiring totality: every input slot connected exactly
|
||
once, #65).** The node contract's "the engine provides a window into each input"
|
||
presupposes each input is actually fed; until now an unwired interior input slot was
|
||
accepted and bootstrapped to a silent empty column (`harness.rs`), and two producers
|
||
into one slot — ill-formed, since a slot holds one column — was likewise
|
||
uncompiled-against. Cycle 0040 makes a valid graph **total and single-valued** over
|
||
its interior input slots: `check_ports_connected` (in `validate_wiring`, run 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 }`, edges and role targets 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 already guarded by `UnboundRootRole`) —
|
||
never consumers, so only interior input slots are subject to the rule. There is **no
|
||
optional-input concept**: every declared input port is required (no shipped node runs
|
||
meaningfully without one; an unwarmed mode-A input is *wired-but-not-yet-valued*, not
|
||
unwired). The check is **index-based and name-free** — it touches no name machinery
|
||
and emits nothing into the flat graph, so C23 is untouched (it proves the existing
|
||
raw-index wiring is total and single-valued). Inherited identically by the raw
|
||
`Composite::new` path and the `GraphBuilder::build()` path (both compile via
|
||
`compile_with_params`).
|
||
|
||
**Refinement (Construction-layer milestone — render labels, 2026-06-05).** A node
|
||
additionally exposes `label() -> String`, a **single-line, non-load-bearing**
|
||
render symbol: a default trait method the run loop never calls (wiring is by
|
||
index, C23). Overrides carry the node's identifying params (`SMA(2)` vs `SMA(4)`)
|
||
so a graph render (C9 graph-as-data, #13) disambiguates identical node types and
|
||
surfaces a mis-wiring. Like `FieldSpec.name`, it is an informative debug symbol,
|
||
not part of the C8 dataflow contract — adding it changes no run behaviour.
|
||
|
||
**Refinement (cycle 0070 — end-of-stream `finalize` lifecycle, 2026-06-25).** The
|
||
node contract gains a second lifecycle phase beside `eval`: `finalize(&mut self)`,
|
||
a **default-no-op** trait method the engine calls **once per node, in topological
|
||
order, after the source loop drains** (the `Harness::run` epilogue). It lets a sink
|
||
**fold online** — accumulate per `eval` into O(trades)/O(1) owned state and flush
|
||
one compact summary at stream end — instead of pushing a record every fired cycle
|
||
into an unbounded channel that buffers O(cycles) rows until the run ends (the
|
||
retention BLOCKER #138 hit: a full-history Stage-1 sweep held ~2 GiB/member over
|
||
~5.5M one-minute bars). C1/C7/C8 are preserved: `finalize` runs once *after* the
|
||
deterministic event loop, adding no within-sim concurrency (C1); a folding sink
|
||
holds only owned accumulator state, no interior mutability (C7); it still declares
|
||
`output: vec![]` and its flush is the same out-of-graph side effect (C8) — one
|
||
summary row, not a per-cycle stream. Realized by `GatedRecorder` (emits only gated
|
||
rows + the genuine final row) and `SeriesReducer` (folds one column, emits one
|
||
summary row), aura-std siblings of the per-cycle `Recorder`, which survives for the
|
||
live / `--trace` / test-tap path. The recording sink's *own* per-cycle allocation
|
||
(the `Recorder`→`Probe` rename + its accumulate-vs-stream choice, #77) stays open;
|
||
`finalize` is the "non-channel exit from the graph" that issue's
|
||
accumulate-then-read option named as missing.
|
||
|
||
**Realization (2026-07-11 — composite `doc`, #125).** `Composite` carries an
|
||
optional authored rationale `doc: Option<String>` — the prose twin of its `name`
|
||
and the same C23 category: a non-load-bearing debug symbol. Authored via
|
||
`Composite::with_doc` / `GraphBuilder::doc`; persisted as a Tier-1
|
||
additive-optional `CompositeData` field (no format-version bump; absent-field
|
||
documents keep their exact bytes, so existing content ids are untouched);
|
||
**blanked in the identity projection** (`strip_debug_symbols`) while staying
|
||
canonical-byte-bearing; dissolved at inline like the name (never reaches
|
||
`FlatGraph`). Surfaced read-only (C22): the graph model emits an optional
|
||
trailing `"doc"` fragment per scope (`json_str` hardened for multi-line free
|
||
text — `\n`/`\t`/`\r` named, other control chars as `\u00XX`), the viewer shows
|
||
it in both composite view states (collapsed tooltip, expanded cluster frame) and
|
||
the root's as a muted header line. The construction op-script vocabulary
|
||
deliberately has no doc-carrying surface yet (scope cut recorded on #125).
|
||
|
||
**Refinement (2026-07-21 — start-of-stream `initialize` lifecycle; #77 resolved,
|
||
#283).** The contract gains `finalize`'s mirror: `initialize()`, default no-op,
|
||
called once per node in topological order before the first source value (a
|
||
`Harness::run` prologue with its own once-per-run mirror test). Consumers acquire
|
||
run resources there — the in-graph record consumer (`TapRecorder`, `aura-runner`)
|
||
opens its streaming trace writer in `initialize`, appends per fired warm cycle,
|
||
and finishes/reports exactly one terminal outcome at `finalize`; the hook is
|
||
infallible by signature, so an acquisition failure degrades the node to inert and
|
||
surfaces once, terminally, at `finalize`. This closes the #77 line the cycle-0070
|
||
entry left open: declared-tap consumers now move `(Timestamp, Cell)` (zero
|
||
per-cycle heap), and the `Recorder`→`Probe` rename is retired — `Recorder` keeps
|
||
its name on the legacy live/`--trace`/test-tap surface, whose migration onto the
|
||
subscriber shape is tracked as #308.
|