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

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.