21c1621bd0
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:
- prose mentions -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat` -> `render_flat_graph` (historical render symbol;
keeps the `render_blueprint` / `render_flat_graph`
source-vs-product pairing)
- `intra-compilat` -> `intra-graph`
- `from_compilat` (test) -> `from_flat`
"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
312 lines
14 KiB
Markdown
312 lines
14 KiB
Markdown
# 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 graph.
|
|
|
|
The **identity** of a param is **positional** — its slot index in the flat
|
|
param-space — exactly as the flat graph 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<InputSpec>, pub output: Vec<FieldSpec> }
|
|
// after
|
|
pub struct NodeSchema { pub inputs: Vec<InputSpec>, pub output: Vec<FieldSpec>, pub params: Vec<ParamSpec> }
|
|
```
|
|
|
|
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<ParamSpec> {
|
|
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<ParamSpec>) {
|
|
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<ParamSpec>`** — 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<ParamSpec>`** — 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 flat graph 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 flat graph 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 flat graphs 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.
|