`run_signal_r` retained one row per cycle for the equity, exposure and dense position-management series, then derived nothing from them but three summary values — and bound a fourth sink, r-equity, whose receiver it held without ever draining, so those rows accumulated for the length of the run and were dropped unread. The reduce path had used a folded delivery all along. This moves the single-run path onto it: the two f64 series land one summary row each at finalize, the dense record is gated to its closed rows plus the true final row, and the unread sink is no longer built. Retention on that path goes from O(cycles) to O(trades). Every reported number is unchanged. The replacement read is, line for line, what `run_blueprint_member` already does, so it inherits that path's proof rather than needing a new one; both equivalences it rests on were already pinned green (a folded series summary equals the post-run fold's fields; the gated record equals the full record through the R reduction, including a trade still open at the window end). The `cli_fixed_cost` fingerprint, which hashes the exec record line with only the build sha blanked, is unmoved. The delivery parameter is renamed `fold_series`. Its old name described the sweep/reduce run mode, which the single-run path is not; the new one names what the flag does to the sinks, which is the only framing true at every call site — the two param-space probes request the folded shape while consuming nothing at all. Rust has no named arguments and every site passes the flag positionally, so the rename reached no caller. Two properties that were load-bearing but unguarded are now pinned: - `crates/aura-runner/tests/folded_wrap_retention.rs` asserts that the folded wrap retains at most one record row per closed trade plus one, that each series sink retains at most its summary row, and that the r-equity sink is unbound. Both assertions are identities that hold at any trade count, so the pin depends on no property of the driven price path. Demonstrated red under retained delivery: 50000 rows for 250 trades. - `--trace` has been absent since the sugar retirement (#319) with nothing in the suite guarding its absence; this cycle rests a scope exclusion on that absence, so `exec_trace_flag_no_longer_parses` now guards it. A third pin closes a gap the change made load-bearing: the suite pinned `total_pips` and the R block on the exec path but neither `max_drawdown` nor `bias_sign_flips`, the two values whose positional read this cycle rewrote. Demonstrated red under a deliberate column slip. Prose the change stranded is corrected across production comments, test identifiers and four live ledger contracts; the superseded C08 clause moves verbatim to its history sidecar. Three of those sites still described `--trace` as reachable, one of them in the ledger. Suite 1652 green, clippy clean, all five bench fingerprints OK. closes #308
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.
Refinement (2026-07-27 — the single-run path folds too, #308). run_signal_r
moved onto the folded delivery shape run_blueprint_member already used, which
narrowed Recorder's surviving callers to the campaign trace writer and the
test-tap sites. The superseded clause read: "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." The --trace flag it named retired with the run verb (#319).