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
13 KiB
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.
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.