From 5f5d1501c5801e859ead9bf1223d3107bc2878b4 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sun, 7 Jun 2026 15:09:57 +0200 Subject: [PATCH] spec: 0015 node tunable-parameter declaration (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cycle A of milestone 'The World — parameter-space & sweep'. A node declares its tunable parameters in its C8 schema (ParamSpec { name, kind }, a third NodeSchema field); Blueprint::param_space() aggregates them into one flat, path-qualified param-space as a read-only projection of the graph-as-data. Param identity is positional (slot after the deterministic inline order), the name a non-load-bearing path-qualified debug symbol (C23). Vector knobs (LinComb.weights) expand to N flat indexed entries; N is topology-fixed (C19). compile/inline_composite are untouched — the compilat stays bit-identical. Scope is declaration + aggregation + inspection only; binding (#31), sweep enumeration (#32), search-range, constraint, and default-range are deferred. refs #30 --- docs/specs/0015-node-param-declaration.md | 311 ++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 docs/specs/0015-node-param-declaration.md diff --git a/docs/specs/0015-node-param-declaration.md b/docs/specs/0015-node-param-declaration.md new file mode 100644 index 0000000..69a24c9 --- /dev/null +++ b/docs/specs/0015-node-param-declaration.md @@ -0,0 +1,311 @@ +# Node Tunable-Parameter Declaration — Design Spec + +**Date:** 2026-06-07 +**Status:** Draft — awaiting user spec review +**Authors:** orchestrator + Claude + +## Goal + +Give a node a way to **declare its tunable parameters** as part of its C8 +schema, and aggregate every node's declared params into a single, flat, +inspectable **param-space** over a `Blueprint`. This is cycle A of the milestone +*The World — parameter-space & sweep* (issue #30) — the root that unlocks the +four C12 orchestration axes: without a declared, enumerable parameter surface +there is nothing for a sweep to enumerate (#32) or a param-set injection to bind +(#31). + +Today a node's parameters are hard-coded at build sites (`Sma::new(2)`, +`Exposure::new(0.5)`) and exist only as private fields. C8 already reserves the +declaration ("the node's own tunable parameters — typed, with ranges"), and C23 +names the gap explicitly: the sweep-level pass "presupposes per-node param +declarations (C8 — deliberately not in the schema yet)". This cycle fills that +gap, and **only** that gap. + +This cycle is **declaration + aggregation + inspection**. It does **not** bind a +value (that is #31), enumerate a sweep (#32), or carry a search-range, +validity-constraint, or default-range (deferred; see Out of scope). + +## Architecture + +Two layers, both resting on machinery that already exists: + +1. **Declaration (`aura-core`).** A new `ParamSpec` type joins `InputSpec` and + `FieldSpec` as the third schema-declaration type in + `crates/aura-core/src/node.rs`. `NodeSchema` gains a third field, `params`. A + node declares its tunable knobs in `schema()` exactly as it already declares + inputs and output. The shipped `aura-std` nodes declare their real tuning + knobs; nodes with no tunable parameter declare an empty list. + +2. **Aggregation (`aura-engine`).** A new read-only accessor + `Blueprint::param_space()` walks the blueprint's graph-as-data (the same + `BlueprintNode` items `nodes()`/`edges()` already expose), descends into every + composite using the **already-public** `Composite::name()` as a path + component, and concatenates each node's declared params into one flat list in + the deterministic depth-first item order that `lower_items` (the inliner) + already uses. **`compile`/`inline_composite` are not touched**: the param-space + is a separate read-only projection of the blueprint (a further C9 + introspection, like `nodes()`), not a change to the flat compilat. + +The **identity** of a param is **positional** — its slot index in the flat +param-space — exactly as the compilat wires by raw index, not by name (C23). The +name is a **non-load-bearing debug symbol**, path-qualified at aggregation time +(`strategy.combine.weights[0]`) so a reader can trace a knob back to its node. + +## Concrete code shapes + +### Author side — what a node author writes (the headline; Step-2 evidence) + +A node declares its tunable parameters in `schema()`, beside the inputs and +output it already declares. The `Sma` author adds one line: + +```rust +impl Node for Sma { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: self.length, firing: Firing::Any }], + output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }], // NEW + } + } +} +``` + +A vector-valued knob expands to **N flat indexed entries**, one per element — +`LinComb` declares its weights by mapping over the same `self.weights` whose +length already fixes its input arity (so `N` is topology-fixed, C19): + +```rust +impl Node for LinComb { + fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: self.weights.iter() + .map(|_| InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }) + .collect(), + output: vec![FieldSpec { name: "value", kind: ScalarKind::F64 }], + params: (0..self.weights.len()) + .map(|i| ParamSpec { name: format!("weights[{i}]"), kind: ScalarKind::F64 }) + .collect(), // NEW — flat, one entry per weight + } + } +} +``` + +A node with no tunable parameter declares an empty list — `Sub`, `Add`, +`Recorder` (wiring, not a knob), and `SimBroker` (whose `pip_size` is instrument +**metadata**, C10/C15 — held reference data, never a sweep knob): + +```rust +fn schema(&self) -> NodeSchema { + NodeSchema { + inputs: vec![/* ... */], + output: vec![/* ... */], + params: vec![], // no tunable parameter + } +} +``` + +### Consumer side — inspecting the aggregated param-space + +A consumer authors a nested blueprint and reads its param-space through one new +accessor. The example nests a composite inside a composite and includes both a +same-type sibling pair (two `Sma`s) and a vector knob (`LinComb`): + +```rust +// strategy { fast_slow { Sma(2), Sma(4), Sub }, LinComb([1.0, -1.0]) } +let space = blueprint.param_space(); +let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); +assert_eq!(names, [ + "strategy.fast_slow.length", // slot 0 — Sma(2) + "strategy.fast_slow.length", // slot 1 — Sma(4): SAME name, DIFFERENT slot + "strategy.weights[0]", // slot 2 — LinComb weight 0 + "strategy.weights[1]", // slot 3 — LinComb weight 1 +]); +assert_eq!(space[0].kind, ScalarKind::I64); +assert_eq!(space[2].kind, ScalarKind::F64); +``` + +This shows the two honest properties of the design: path-qualification +disambiguates **across composite boundaries** (`weights[0]` under a different +composite path would differ), but it does **not** make sibling knobs of the same +type in the same composite name-unique (slots 0 and 1 share a name). Uniqueness +is unconditional only at the **slot** — the positional identity (C23). `Sub` +contributes nothing (empty params). + +### Implementation shape — before → after (secondary) + +**`ParamSpec` (new), in `aura-core/src/node.rs`:** + +```rust +/// One declared tunable parameter of a node (C8/C12): its render name and scalar +/// kind. The name is a non-load-bearing debug symbol (path-qualified at +/// aggregation, like `FieldSpec.name`); the param's identity is its positional +/// slot in the blueprint's aggregated param-space (C23 — by index, not by name). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParamSpec { + pub name: String, // NOT &'static str: carries a runtime index (weights[0]) + inline-time path prefix + pub kind: ScalarKind, // i64 / f64 / bool only; timestamp is a structural axis (C20), never a numeric knob +} +``` + +**`NodeSchema` — third field:** + +```rust +// before +pub struct NodeSchema { pub inputs: Vec, pub output: Vec } +// after +pub struct NodeSchema { pub inputs: Vec, pub output: Vec, pub params: Vec } +``` + +Every existing `schema()` literal gains `params: vec![...]` (or `params: +vec![]`). `Composite::schema()` (the derived interface) sets `params: vec![]` — +a composite is an authoring boundary, not a node, and its interior params surface +through `param_space()`, not through its derived schema. + +**`Blueprint::param_space()` (new accessor) — a read-only depth-first walk:** + +```rust +impl Blueprint { + /// The aggregated, flat, path-qualified param-space (C12): every node's + /// declared params, concatenated in the deterministic depth-first item order + /// `lower_items` uses, so a param's slot here matches the later flat-node order + /// (#31 binds slot-by-slot). Read-only graph-as-data (C9); does not compile. + pub fn param_space(&self) -> Vec { + let mut out = Vec::new(); + collect_params(&self.nodes, "", &mut out); + out + } +} + +// recursive helper: leaves contribute their declared params under the running +// path prefix; composites push their name() onto the path and recurse. +fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec) { + for item in items { + match item { + BlueprintNode::Leaf(node) => { + for p in node.schema().params { + let name = if prefix.is_empty() { p.name } else { format!("{prefix}.{}", p.name) }; + out.push(ParamSpec { name, kind: p.kind }); + } + } + BlueprintNode::Composite(c) => { + let child = if prefix.is_empty() { c.name().to_string() } else { format!("{prefix}.{}", c.name()) }; + collect_params(c.nodes(), &child, out); + } + } + } +} +``` + +## Components + +1. **`aura-core::ParamSpec`** — `{ name: String, kind: ScalarKind }`. New + schema-declaration type beside `InputSpec`/`FieldSpec`. +2. **`NodeSchema.params: Vec`** — third field; every `schema()` literal + updated. +3. **`aura-std` declarations** — `Sma`→`[length: I64]`, `Exposure`→`[scale: F64]`, + `LinComb`→`[weights[0..N]: F64]`; empty for `Sub`, `Add`, `SimBroker`, + `Recorder`. +4. **`Blueprint::param_space() -> Vec`** — the read-only aggregating + accessor (the visible deliverable). `Composite::schema()` declares + `params: vec![]`. + +Permitted kinds: **i64 / f64 / bool** may be params; **timestamp may not** — a +timestamp-valued knob is a structural axis / data-window (C20), not a numeric +sweep param. + +## Data flow + +``` +Node::schema().params Sma:[length:I64] LinComb:[weights[0]:F64, weights[1]:F64] Sub:[] + │ (per node, declared) + ▼ +Blueprint::param_space() depth-first walk over BlueprintNode items (lower_items order), + │ composite name() prefixed to the path, leaves contribute params + ▼ +flat param-space = [ strategy.fast_slow.length, strategy.fast_slow.length, + strategy.weights[0], strategy.weights[1] ] + (flat · path-qualified names · identity = slot index) + ▼ + #30 stops here (declared + aggregated + inspectable) + ├─► #31 bind : an injected typed value-vector binds slot-by-slot + └─► #32 sweep : enumerate points over this space +``` + +The aggregation order is the same depth-first item order `lower_items` walks, so +the param-space slot order is consistent with the later flat-node order #31 binds +against. Order is deterministic (C1) — it is a pure function of the blueprint's +item structure, which the wiring already depends on. + +## Error handling + +Deliberately **thin** — #30 declares only and checks nothing against values (that +is #31's job). Because identity is positional, **name collisions are not an +error**: two same-type siblings sharing a path-qualified name is the expected, +correct case (the consumer example above), resolved by slot disambiguation. There +is no new `CompileError` variant; `param_space()` cannot fail (a pure read-only +projection — a node with no params contributes nothing, an empty blueprint yields +an empty space). The one load-bearing property is the **deterministic aggregation +order**, which is structural and free. + +`ParamSpec.kind` is declared but unverified this cycle — nothing yet binds a value +whose kind could mismatch (that check lands with the bind in #31). + +## Testing strategy + +- **Per-node declaration.** `Sma::new(3).schema().params == [ParamSpec { name: + "length".into(), kind: I64 }]`; `Exposure::new(0.5)` declares `[scale: F64]`; + `LinComb::new(vec![1.0, -1.0]).schema().params` is two entries `weights[0]` / + `weights[1]`, both `F64`; `Sub`/`Add`/`SimBroker`/`Recorder` declare + `params == []`. +- **Headline — nested aggregation (the #30 / 4-level scenario).** A + composite-in-composite blueprint with `Sma` siblings and a `LinComb` yields a + flat, path-qualified param-space whose names carry the composite path and whose + **vector knob is expanded flat**; a knob under a deeper composite + (`outer.mid.weights[0]`) is distinct from one under a shallower path + (`outer.weights[0]`), and same-composite siblings share a name but occupy + **distinct slots**. +- **Determinism (C1).** `param_space()` called twice on the same blueprint + returns a bit-identical `Vec` (same names, kinds, order). +- **Empty / no-param cases.** A blueprint of only param-less nodes yields an empty + param-space; an empty blueprint yields an empty space. + +Build/test/clippy green across the workspace; existing compile/bootstrap/run and +render tests stay green (the compilat is unchanged — `compile`/`inline_composite` +are not touched, so every existing bit-identical and golden-snapshot test is +unaffected by construction). + +## Acceptance criteria + +The project declares no explicit feature-acceptance criterion, so the default +applies, read against aura's domain invariants: + +1. **The intended audience naturally reaches for it (C17).** A node author who + wants `Sma.length` tunable adds one `params:` line to the `schema()` literal + they already write — the worked author example above is the evidence. A + downstream consumer inspects the whole knob surface of a nested strategy + through one accessor (`blueprint.param_space()`), no engine internals. +2. **It removes a named gap.** It fills exactly the "deliberately not in the + schema yet" gap C8/C23 name, and is the precondition for #31/#32 — the + param-space is now an inspectable, enumerable surface where before there was + none. +3. **It reintroduces no failure class the core constraints eliminate.** + Determinism (C1) holds — aggregation order is a pure structural function. + Topology-invariance (C19) holds — a vector knob's arity `N` is topology-fixed + (it equals the node's input arity), declared as `N` fixed entries, never a + sweep param. The compilat is untouched (C23) — params are a read-only + projection, not a change to the wired graph; every existing bit-identical test + stays green by construction. + +### Out of scope (deliberate deferrals — decisions, not gaps) + +- **Param-set binding / injection (#31)** — binding an injected typed value-vector + to the param-space slot-by-slot, with the kind-check that declaration defers. +- **Sweep enumeration (#32)** — building a family of compilats over the + param-space. +- **The run-supplied search-range (#32 / C20)** — which subset/grid a run sweeps + lives in the experiment-builder, not in the node (the param declaration carries + the knob's existence + kind, not its search interval). +- **A validity constraint** — deferred; it only duplicates the constructor + `assert!` and drives no mechanism (issue #30 refinement comment). +- **A default-range and group-label** — deferred until a C22 playground slider + consumes them.