8688a60ded
The single-file ledger had grown to 2968 lines / ~42k tokens, mixing current design law with accreted history: 59 cycle-stamped realization blocks, 18 [HISTORY] passages, 22 supersession markers, and the C10 / C22 / C24 reframe sagas layered several supersessions deep. A code-grounding audit (31 agents, adversarially verified) confirmed 11 defects stated as current truth: stale crate homes from the C28 #288 roster split (cost nodes, PositionManagement, PositionEvent, Session), the renamed InputSpec->PortSpec, the pre-#241 project model in C16 and the open-threads section, a stale HarnessKind retirement deferral in C24, and three C28-internal inconsistencies. New shape, per the ailang precedent: - INDEX.md stays the sole addressable entry point: foundation, external components, a C-id-keyed contract map (one line per contract), and only the genuinely open architectural threads. - contracts/cNN-<slug>.md carries each contract's current truth only: Guarantee / Forbids / Why with ratified refinements integrated, plus a code-anchored Current state. All confirmed defects are fixed here; crate anchors were re-verified against the tree. - contracts/cNN-<slug>.history.md (18 sidecars) and INDEX.history.md preserve every superseded block verbatim, stamps and issue refs intact, under a frozen-record banner. Nothing was deleted: superseded design intent remains an addressable working-tree artifact, off the per-cycle audit walk. - Ledger discipline is now stated in INDEX.md: live files are edited in place at cycle close, superseded text moves verbatim to the sidecar, and a supersession marker in a live file is itself an audit finding. Every contract file was verified against its old text by an independent zero-loss pass (statement-by-statement) plus a code-accuracy spot check; C-ids and contract titles are unchanged, so existing C-id citations in code, tests, and issues resolve as before.
160 lines
12 KiB
Markdown
160 lines
12 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).
|