Files
Aura/docs/design/contracts/c08-node-contract.history.md
T
claude bd0c557f16 docs(ledger, glossary): tap-subscribers cycle tidy — C8/C27/C28 lifts, tap subscription vocabulary
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
2026-07-21 23:56:14 +02:00

174 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 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 `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.