"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.
14 KiB
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:
-
Declaration (
aura-core). A newParamSpectype joinsInputSpecandFieldSpecas the third schema-declaration type incrates/aura-core/src/node.rs.NodeSchemagains a third field,params. A node declares its tunable knobs inschema()exactly as it already declares inputs and output. The shippedaura-stdnodes declare their real tuning knobs; nodes with no tunable parameter declare an empty list. -
Aggregation (
aura-engine). A new read-only accessorBlueprint::param_space()walks the blueprint's graph-as-data (the sameBlueprintNodeitemsnodes()/edges()already expose), descends into every composite using the already-publicComposite::name()as a path component, and concatenates each node's declared params into one flat list in the deterministic depth-first item order thatlower_items(the inliner) already uses.compile/inline_compositeare not touched: the param-space is a separate read-only projection of the blueprint (a further C9 introspection, likenodes()), 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:
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):
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):
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 Smas) and a vector knob (LinComb):
// 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:
/// 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:
// 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:
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
aura-core::ParamSpec—{ name: String, kind: ScalarKind }. New schema-declaration type besideInputSpec/FieldSpec.NodeSchema.params: Vec<ParamSpec>— third field; everyschema()literal updated.aura-stddeclarations —Sma→[length: I64],Exposure→[scale: F64],LinComb→[weights[0..N]: F64]; empty forSub,Add,SimBroker,Recorder.Blueprint::param_space() -> Vec<ParamSpec>— the read-only aggregating accessor (the visible deliverable).Composite::schema()declaresparams: 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().paramsis two entriesweights[0]/weights[1], bothF64;Sub/Add/SimBroker/Recorderdeclareparams == []. - Headline — nested aggregation (the #30 / 4-level scenario). A
composite-in-composite blueprint with
Smasiblings and aLinCombyields 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-identicalVec(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:
- The intended audience naturally reaches for it (C17). A node author who
wants
Sma.lengthtunable adds oneparams:line to theschema()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. - 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.
- 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
Nis topology-fixed (it equals the node's input arity), declared asNfixed 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.