acd3d2c341
`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
182 lines
13 KiB
Markdown
182 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.
|
||
|
||
**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).
|