Files
Aura/docs/plans/0015-node-param-declaration.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"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.
2026-06-14 17:02:15 +02:00

20 KiB

Node Tunable-Parameter Declaration — Implementation Plan

Parent spec: docs/specs/0015-node-param-declaration.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: A node declares its tunable parameters in its C8 schema (ParamSpec, a third NodeSchema field), and Blueprint::param_space() aggregates every node's params into one flat, path-qualified, inspectable param-space.

Architecture: Two layers on existing machinery. aura-core gains the ParamSpec type and the NodeSchema.params field; the shipped aura-std nodes declare their tunable knobs. aura-engine gains a read-only Blueprint::param_space() that walks the graph-as-data (using the already-public Composite::name() as path prefix) in the same deterministic depth-first order lower_items uses — a parallel projection that leaves compile/inline_composite (and the flat graph) untouched.

Tech Stack: Rust workspace — aura-core (node contract), aura-std (7 nodes), aura-engine (blueprint/compile). Param identity is positional (slot), name a non-load-bearing path-qualified debug symbol (C23).

Sequencing note (compile-gate ordering): adding the non-Default params field makes every NodeSchema { .. } literal a compile error until updated. The 19 workspace literals span three crates; each crate's literals are repaired inside the crate's own task, and the per-crate build gate is -p <crate> (a partial build) until Task 4 finishes the workspace-wide build. crates/aura-cli and crates/aura-ingest construct zero NodeSchema literals (recon-verified), so they need no change and compile clean once the others do.


Files this plan creates or modifies

  • Modify: crates/aura-core/src/node.rs:6-7 — stale module doc-comment update
  • Modify: crates/aura-core/src/node.rs:38-53 — add ParamSpec, add NodeSchema.params
  • Modify: crates/aura-core/src/node.rs:99-100Bare test fixture literal gains params
  • Modify: crates/aura-core/src/lib.rs:42 — re-export ParamSpec
  • Modify: crates/aura-std/src/{sma,exposure,lincomb}.rs — declare params (+ ParamSpec import)
  • Modify: crates/aura-std/src/{sub,add,sim_broker,recorder}.rsparams: vec![]
  • Test: crates/aura-std/src/sma.rs — per-node declaration test (Sma/Exposure/LinComb + empty nodes)
  • Modify: crates/aura-engine/src/blueprint.rs:113Composite::schema() adds params: vec![]
  • Modify: crates/aura-engine/src/blueprint.rs:{373,419,438,453} — 4 test fixtures gain params: vec![]
  • Modify: crates/aura-engine/src/harness.rs:{358,384,406,436,474,498} — 6 test fixtures gain params: vec![]
  • Modify: crates/aura-engine/src/blueprint.rs:140-158 — add Blueprint::param_space() + collect_params helper
  • Modify: crates/aura-engine/src/lib.rs:44 — re-export ParamSpec
  • Test: crates/aura-engine/src/blueprint.rs — nested-aggregation, top-level-unqualified, determinism, empty

Task 1: aura-coreParamSpec type + NodeSchema.params field

Files:

  • Modify: crates/aura-core/src/node.rs

  • Modify: crates/aura-core/src/lib.rs:42

  • Step 1: Add the ParamSpec type

In crates/aura-core/src/node.rs, immediately after the FieldSpec struct (ends at line 42), insert:

/// 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, as `FieldSpec.name` already is); a param's identity is its
/// positional slot in the blueprint's aggregated param-space (C23 — by index, not
/// by name). Unlike `FieldSpec.name` (`&'static str`), the name is a `String`: a
/// vector knob carries a runtime index (`weights[0]`) and aggregation prefixes the
/// composite path (`strategy.weights[0]`). Permitted kinds: `I64`/`F64`/`Bool`;
/// `Timestamp` is a structural axis (C20), never a numeric knob.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParamSpec {
    pub name: String,
    pub kind: ScalarKind,
}
  • Step 2: Add the params field to NodeSchema

In the same file, the NodeSchema struct (lines 49-53) becomes:

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct NodeSchema {
    pub inputs: Vec<InputSpec>,
    pub output: Vec<FieldSpec>,
    pub params: Vec<ParamSpec>,
}
  • Step 3: Update the stale module doc-comment

In the same file, replace lines 6-7:

//! Tunable params (C12/C19) are deliberately not part of the schema yet — see
//! spec 0002's "Out of scope".

with:

//! Tunable params (C12/C19) are declared via `params` (cycle 0015): each node's
//! typed knobs, which `Blueprint::param_space` aggregates into the sweep's flat,
//! path-qualified param-space (C8/C23). Identity is positional (slot); the name is
//! a non-load-bearing debug symbol.
  • Step 4: Repair the Bare test fixture literal

In the same file, the Bare fixture in mod tests (line ~99-101) constructs a NodeSchema. Add the field so it reads:

            fn schema(&self) -> NodeSchema {
                NodeSchema { inputs: vec![], output: vec![], params: vec![] }
            }
  • Step 5: Re-export ParamSpec

In crates/aura-core/src/lib.rs:42, the re-export line becomes:

pub use node::{FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec};
  • Step 6: Add a declaration test pinning the new field

In crates/aura-core/src/node.rs mod tests, add:

    #[test]
    fn schema_carries_declared_params() {
        let s = NodeSchema {
            inputs: vec![],
            output: vec![],
            params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
        };
        assert_eq!(s.params.len(), 1);
        assert_eq!(s.params[0].name, "length");
        assert_eq!(s.params[0].kind, ScalarKind::I64);
    }
  • Step 7: Build + test aura-core (partial build gate)

Run: cargo test -p aura-core Expected: PASS — aura-core compiles (its one fixture repaired) and all its tests incl. schema_carries_declared_params pass. aura-std/aura-engine do not compile yet (their literals are repaired in Tasks 2-3); that is expected — this gate is scoped to -p aura-core.


Task 2: aura-std — declare params in the 7 node schemas

Files:

  • Modify: crates/aura-std/src/sma.rs:6,24

  • Modify: crates/aura-std/src/exposure.rs:25

  • Modify: crates/aura-std/src/lincomb.rs:44

  • Modify: crates/aura-std/src/{sub.rs:29,add.rs:38,sim_broker.rs:61,recorder.rs:33}

  • Test: crates/aura-std/src/sma.rs

  • Step 1: Sma declares length

In crates/aura-std/src/sma.rs, add ParamSpec to the import (line 6):

use aura_core::{Ctx, FieldSpec, Firing, InputSpec, Node, NodeSchema, ParamSpec, Scalar, ScalarKind};

and the schema() literal (line 23-32) gains the field:

    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 }],
        }
    }
  • Step 2: Exposure declares scale

In crates/aura-std/src/exposure.rs, add ParamSpec to the aura_core import, and the schema() literal (lines 24-27) gains:

        NodeSchema {
            inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
            output: vec![FieldSpec { name: "exposure", kind: ScalarKind::F64 }],
            params: vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
        }
  • Step 3: LinComb declares weights[0..N] (flat expansion)

In crates/aura-std/src/lincomb.rs, add ParamSpec to the aura_core import, and the schema() literal (lines 43-49) gains the mapped params:

        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(),
        }
  • Step 4: The four param-less nodes declare params: vec![]

Add params: vec![] as the final field of the NodeSchema literal in each of:

  • crates/aura-std/src/sub.rs (Sub::schema, ~line 29)
  • crates/aura-std/src/add.rs (Add::schema, ~line 38)
  • crates/aura-std/src/sim_broker.rs (SimBroker::schema, ~line 61 — pip_size is metadata, C10/C15, not a knob)
  • crates/aura-std/src/recorder.rs (Recorder::schema, ~line 33 — wiring, not a knob)

No ParamSpec import is needed in these four (they use only vec![]).

  • Step 5: Add the per-node declaration test

In crates/aura-std/src/sma.rs mod tests (it already imports the sibling nodes in labels_carry_identifying_params), add:

    #[test]
    fn nodes_declare_expected_params() {
        use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
        use aura_core::{Firing, ParamSpec, ScalarKind};
        // single scalar knobs
        assert_eq!(
            Sma::new(3).schema().params,
            vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
        );
        assert_eq!(
            Exposure::new(0.5).schema().params,
            vec![ParamSpec { name: "scale".into(), kind: ScalarKind::F64 }],
        );
        // vector knob expands flat to N indexed F64 entries
        let lc = LinComb::new(vec![1.0, -1.0]).schema().params;
        assert_eq!(lc.len(), 2);
        assert_eq!(lc[0].name, "weights[0]");
        assert_eq!(lc[1].name, "weights[1]");
        assert!(lc.iter().all(|p| p.kind == ScalarKind::F64));
        // param-less nodes declare empty
        assert!(Sub::new().schema().params.is_empty());
        assert!(Add::new().schema().params.is_empty());
        assert!(SimBroker::new(0.0001).schema().params.is_empty());
        let (tx, _rx) = std::sync::mpsc::channel();
        assert!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).schema().params.is_empty());
    }
  • Step 6: Build + test aura-std (partial build gate)

Run: cargo test -p aura-std Expected: PASS — aura-std compiles against the new aura-core and all tests incl. nodes_declare_expected_params pass. aura-engine still does not compile (Task 3).


Task 3: aura-engine — repair Composite::schema + 10 test fixtures

Files:

  • Modify: crates/aura-engine/src/blueprint.rs:113
  • Modify: crates/aura-engine/src/blueprint.rs:{373,419,438,453}
  • Modify: crates/aura-engine/src/harness.rs:{358,384,406,436,474,498}

This task is pure compile-repair (mechanical) — every NodeSchema literal in aura-engine gains params: vec![] so the crate compiles again against the new field. No new behaviour, no new test. None of these are tunable-knob nodes.

  • Step 1: Composite::schema() declares empty params

In crates/aura-engine/src/blueprint.rs, the Composite::schema() literal (line 112-113) becomes:

        let out_field = self.nodes[self.output.node].schema().output[self.output.field];
        NodeSchema { inputs, output: vec![out_field], params: vec![] }

A composite is an authoring boundary, not a node; its interior params surface through param_space() (Task 4), not its derived schema.

  • Step 2: Repair the 4 blueprint.rs test fixtures

In crates/aura-engine/src/blueprint.rs mod tests, append params: vec![] as the final field of the NodeSchema { .. } literal in each fixture: Join2 (~line 373), Pass1 (~419), SinkF64 (~438), SinkI64 (~453).

  • Step 3: Repair the 6 harness.rs test fixtures

In crates/aura-engine/src/harness.rs mod tests, append params: vec![] as the final field of the NodeSchema { .. } literal in each fixture: AsOfSum (~line 358), BarrierSum (~384), MixedSum (~406), Ohlcv (~436), TwoField (~474), TapForward (~498).

  • Step 4: Build aura-engine incl. tests (compile-repair gate)

Run: cargo build -p aura-engine --all-targets Expected: PASS — aura-engine lib + test targets compile (every literal repaired). If the build names any remaining NodeSchema literal missing params, add params: vec![] to it and re-run; the build error enumerates each one verbatim.

  • Step 5: Existing aura-engine tests stay green

Run: cargo test -p aura-engine Expected: PASS — all pre-existing tests (incl. the bit-identical composite_sma_cross_runs_bit_identical_to_hand_wired and the golden render tests) stay green: the flat graph is unchanged, only schema literals gained an empty field.


Task 4: aura-engineBlueprint::param_space() aggregation + tests

Files:

  • Modify: crates/aura-engine/src/blueprint.rs:140-158 (Blueprint impl) + module scope (helper)

  • Modify: crates/aura-engine/src/lib.rs:44

  • Test: crates/aura-engine/src/blueprint.rs

  • Step 1: Add the param_space() accessor

In crates/aura-engine/src/blueprint.rs, inside impl Blueprint (after edges(), which ends at line 158), add:

    /// 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. Names are
    /// non-load-bearing: a composite's `name()` is prefixed at each level, but
    /// same-type siblings in one composite share a name — uniqueness is at the slot.
    pub fn param_space(&self) -> Vec<ParamSpec> {
        let mut out = Vec::new();
        collect_params(&self.nodes, "", &mut out);
        out
    }
  • Step 2: Add the collect_params helper at module scope

In the same file, at module scope (next to lower_items, e.g. after the Blueprint impl block), add:

/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its
/// declared params under the running path prefix; a composite pushes its `name()`
/// onto the path and recurses. Order mirrors `lower_items` (items in declared order,
/// composites depth-first) so a param's slot matches the later flat-node order.
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);
            }
        }
    }
}
  • Step 3: Import ParamSpec into blueprint.rs

In crates/aura-engine/src/blueprint.rs, the aura_core import (line 14) gains ParamSpec:

use aura_core::{Node, NodeSchema, ParamSpec, ScalarKind};
  • Step 4: Re-export ParamSpec from aura-engine

In crates/aura-engine/src/lib.rs:44, add ParamSpec to the aura_core re-export (consumer of param_space() needs one import surface, per the #29 tidy precedent). The line is currently:

pub use aura_core::{Firing, Scalar, ScalarKind, Timestamp};

and becomes:

pub use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
  • Step 5: Add the headline nested-aggregation test

In crates/aura-engine/src/blueprint.rs mod tests, add (the test imports the sibling aura_std nodes, already a dev-dependency):

    #[test]
    fn param_space_is_flat_path_qualified_and_slot_disambiguated() {
        use aura_std::{LinComb, Sma, Sub};
        // inner composite "fast_slow": two SMAs (same type → same param name) + a Sub
        let fast_slow = Composite::new(
            "fast_slow",
            vec![Sma::new(2).into(), Sma::new(4).into(), Sub::new().into()],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                Edge { from: 1, to: 2, slot: 1, from_field: 0 },
            ],
            vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
            OutPort { node: 2, field: 0 },
        );
        // outer composite "strategy": the inner composite + a LinComb([1,-1])
        let strategy = Composite::new(
            "strategy",
            vec![BlueprintNode::Composite(fast_slow), LinComb::new(vec![1.0, -1.0]).into()],
            vec![],
            vec![vec![Target { node: 0, slot: 0 }]],
            OutPort { node: 0, field: 0 },
        );
        let bp = Blueprint::new(vec![BlueprintNode::Composite(strategy)], vec![], vec![]);

        let space = bp.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, distinct 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);
    }
  • Step 6: Add top-level-unqualified, determinism, and empty tests

In the same mod tests, add:

    #[test]
    fn top_level_leaf_params_are_unqualified() {
        use aura_std::Sma;
        let bp = Blueprint::new(vec![Sma::new(3).into()], vec![], vec![]);
        let space = bp.param_space();
        assert_eq!(space.len(), 1);
        assert_eq!(space[0].name, "length"); // no path prefix at the top level
    }

    #[test]
    fn param_space_is_deterministic() {
        use aura_std::{LinComb, Sma};
        let bp = Blueprint::new(
            vec![Sma::new(2).into(), LinComb::new(vec![1.0, -1.0]).into()],
            vec![],
            vec![],
        );
        assert_eq!(bp.param_space(), bp.param_space()); // pure structural function (C1)
    }

    #[test]
    fn param_space_empty_for_paramless_and_empty_blueprints() {
        use aura_std::{Add, Sub};
        let only_paramless =
            Blueprint::new(vec![Sub::new().into(), Add::new().into()], vec![], vec![]);
        assert!(only_paramless.param_space().is_empty());
        let empty = Blueprint::new(vec![], vec![], vec![]);
        assert!(empty.param_space().is_empty());
    }
  • Step 7: Test aura-engine + full workspace build

Run: cargo test -p aura-engine Expected: PASS — the four new tests pass; all existing tests stay green.

Run: cargo build --workspace && cargo test --workspace Expected: PASS — every crate compiles (aura-cli/aura-ingest unchanged, zero NodeSchema literals) and the whole suite is green.

  • Step 8: Clippy gate

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: PASS — no warnings (the format!/collect idioms in collect_params and LinComb::schema are clippy-clean).


Notes for the implementer

  • Do not touch compile, inline_composite, lower_items, or any edge/wiring logic. param_space() is a parallel read-only projection that mirrors the inliner's traversal order; it must not share or alter it. The spec's correctness rests on the flat graph staying bit-identical (every existing golden / bit-identical test stays green by construction).
  • fieldtests/ are out of scope — they are excluded crates (own Cargo.toml), not in --workspace, and are frozen cycle-archive snapshots. They construct the old 2-field NodeSchema but never compile in this build, so they do not break the gate and are deliberately left unchanged.
  • Param identity is positional — name collisions (two same-type siblings sharing a path-qualified name) are the expected, correct case, never an error.