Files
Aura/docs/design/contracts/c08-node-contract.history.md
T
claude acd3d2c341 feat(aura-runner, aura-cli): the single-run path folds what it only aggregates
`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
2026-07-27 23:10:45 +02:00

13 KiB
Raw Blame History

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 00160023 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 RecorderProbe 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 RecorderProbe 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).