Files
Aura/docs/plans/0031-node-instance-naming.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

23 KiB

Node-instance naming — Implementation Plan

Parent spec: docs/specs/0031-node-instance-naming.md

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

Goal: Give every blueprint node a name (default = its lowercased type label), make param_space() uniformly <node>.<param>, re-key fan-in distinguishability onto the node name, and remove the index-addressed ParamAlias overlay.

Architecture: PrimitiveBuilder (aura-core) gains an instance-name slot (name: Option<String>, .named(), node_name()). blueprint.rs (aura-engine) inserts the node-name segment in collect_params, re-keys signature_of and the fan-in predicate onto the node name, and drops ParamAlias / Composite.params / the Composite::new 5th argument / aliases_on / check_alias_indices. Removing the argument is a compile-coupled change, so every in-workspace Composite::new caller and ParamAlias reference migrates in the same task; the cargo build --workspace gate enumerates completeness.

Tech Stack: Rust. crates/aura-core/src/node.rs, crates/aura-engine/src/{blueprint.rs, lib.rs, graph_model.rs, sweep.rs}, crates/aura-cli/src/main.rs, crates/aura-cli/tests/cli_run.rs, docs/design/INDEX.md.


Files this plan creates or modifies:

  • Modify: crates/aura-core/src/node.rs:77-116PrimitiveBuilder instance-name mechanism
  • Test: crates/aura-core/src/node.rs (mod tests, ~:190) — node_name() default + override
  • Modify: crates/aura-engine/src/blueprint.rscollect_params (:688-730), signature_of (:546-590), leaf_has_unaliased_paramleaf_has_param (:674-686), check_composite_fan_in (:635-672), check_fan_in_distinguishability (:616-628), Composite struct (:137-144) + Composite::new (:151-160) + params() (:180-184) + param_space() call (:197); REMOVE ParamAlias (:121-130), aliases_on (:592-598), check_alias_indices (:600-614)
  • Test: crates/aura-engine/src/blueprint.rs (mod tests) — new path-shape / default-name / forcing-function / paramless tests; update the mirror + top_level + signature_of tests; remove the alias-semantics tests
  • Modify: crates/aura-engine/src/lib.rs:41-42 — drop the ParamAlias / aliases_on / signature_of re-export of the retired overlay
  • Modify: crates/aura-engine/src/graph_model.rsParamAlias import (:235) + Composite::new fixtures (:274,:302,:326,:344) + alias literals (:287-288): structural migration only
  • Modify: crates/aura-engine/src/sweep.rsParamAlias import (:173) + Composite::new fixtures (:276,:306) + alias literals (:289-290): structural migration only
  • Modify: crates/aura-cli/src/main.rssma_cross sample (:137-155), root harness (:170), EMA/MACD composite (:338,:382), sweep goldens (:537-540), .axis() block (:229-231), single-run .with() (:516-518), MACD param assertion (:597-599), ParamAlias import (:14)
  • Modify: crates/aura-cli/tests/cli_run.rs:214 — the sweep-golden twin (lockstep with main.rs:537-540)
  • Modify: docs/design/INDEX.md — amend C9 (:367-375) and C23 (:359-366, :813-851)

Out of scope (do NOT touch): crates/aura-cli/tests/fixtures/sample-model.json and the model_to_json golden at graph_model.rs:430model_to_json emits the type label (b.label()"SMA") and factory param names ("length"), which are alias-independent and node-name-independent; this cycle does not change the JSON model surface, so those goldens stay byte-identical. The fieldtests/ crates are frozen non-workspace records — not built or gated by cargo --workspace.


Task 1: PrimitiveBuilder instance-name mechanism (aura-core)

Additive — no existing call site breaks (PrimitiveBuilder::new keeps its signature; the new field defaults internally).

Files:

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

  • Test: crates/aura-core/src/node.rs (mod tests)

  • Step 1: Write the failing test

Add to the mod tests block in crates/aura-core/src/node.rs (near the existing primitive_builder_label_is_the_bare_type at ~:196):

    #[test]
    fn node_name_defaults_to_lowercased_type_label() {
        // unnamed → the type label, ASCII-lowercased verbatim (no snake_case)
        let unnamed = PrimitiveBuilder::new(
            "SimBroker",
            NodeSchema::default(),
            |_| panic!("not built in this test"),
        );
        assert_eq!(unnamed.node_name(), "simbroker");

        // explicit name overrides
        let named = PrimitiveBuilder::new(
            "SMA",
            NodeSchema::default(),
            |_| panic!("not built in this test"),
        )
        .named("fast");
        assert_eq!(named.node_name(), "fast");
    }
  • Step 2: Run test to verify it fails

Run: cargo test -p aura-core node_name_defaults_to_lowercased_type_label Expected: compile error / FAIL — no method node_name / named on PrimitiveBuilder.

  • Step 3: Write minimal implementation

In crates/aura-core/src/node.rs, add a name: Option<String> field to PrimitiveBuilder (struct at :77-84), initialise it in new (:90-96), and add the two methods. Concretely:

Struct — add the field:

pub struct PrimitiveBuilder {
    name: &'static str,        // existing: the type label
    instance_name: Option<String>, // NEW: the optional instance name
    schema: NodeSchema,
    build: Box<dyn Fn(&[Scalar]) -> Box<dyn Node>>,
}

new — initialise the new field (instance_name: None):

    pub fn new(
        name: &'static str,
        schema: NodeSchema,
        build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
    ) -> Self {
        Self { name, instance_name: None, schema, build: Box::new(build) }
    }

Add the two methods (near label() at :113):

    /// Set this node instance's name. Must be non-empty.
    pub fn named(mut self, name: &str) -> Self {
        debug_assert!(!name.is_empty(), "node name must be non-empty");
        self.instance_name = Some(name.to_string());
        self
    }

    /// The resolved node name: the explicit instance name if set, else the
    /// type label ASCII-lowercased verbatim (e.g. "SimBroker" -> "simbroker").
    pub fn node_name(&self) -> String {
        self.instance_name
            .clone()
            .unwrap_or_else(|| self.name.to_ascii_lowercase())
    }
  • Step 4: Run test to verify it passes

Run: cargo test -p aura-core node_name_defaults_to_lowercased_type_label Expected: PASS.

  • Step 5: Build the crate (additive, no caller breakage)

Run: cargo build -p aura-core Expected: 0 errors (the new field is internal; new still takes 3 args).


Task 2: Engine mechanism, overlay removal, and the full compile-coupled migration (aura-engine + aura-cli)

This is one task by necessity: dropping the Composite::new 5th argument and the ParamAlias struct breaks every caller at once, so all callers + the struct removal + the dependent test-string updates land together under one cargo build/test --workspace gate (planner self-review #7).

Files: crates/aura-engine/src/blueprint.rs, lib.rs, graph_model.rs, sweep.rs; crates/aura-cli/src/main.rs, crates/aura-cli/tests/cli_run.rs.

  • Step 1: Write/replace the executable-spec tests (the RED side)

In crates/aura-engine/src/blueprint.rs mod tests, add the new behaviour tests. These assert the post-change strings, so they fail (or do not compile) until the logic + signature land in Steps 2-7. Use named SMAs and the new 5-arg Composite::new (no params arg):

    #[test]
    fn named_siblings_path_qualify_with_node_segment() {
        use aura_std::{Sma, Sub};
        // two SMAs, named fast/slow, feeding a Sub, under composite "sma_cross"
        let cross = Composite::new(
            "sma_cross",
            vec![
                Sma::builder().named("fast").into(),
                Sma::builder().named("slow").into(),
                Sub::builder().into(),
            ],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                Edge { from: 1, to: 2, slot: 1, from_field: 0 },
            ],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                source: None,
            }],
            vec![OutField { node: 2, field: 0, name: "cross".into() }],
        );
        let names: Vec<String> = cross.param_space().into_iter().map(|p| p.name).collect();
        assert_eq!(names, ["sma_cross.fast.length", "sma_cross.slow.length"]);
    }

    #[test]
    fn unnamed_single_primitive_uses_lowercased_type_label_segment() {
        use aura_std::Sma;
        let bp = Composite::new(
            "root",
            vec![Sma::builder().into()],
            vec![],
            vec![],
            vec![],
        );
        assert_eq!(bp.param_space()[0].name, "sma.length");
    }

    #[test]
    fn unnamed_same_type_param_bearing_fan_in_is_rejected() {
        use aura_std::{Sma, Sub};
        let bp = Composite::new(
            "sma_cross",
            vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                Edge { from: 1, to: 2, slot: 1, from_field: 0 },
            ],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                source: None,
            }],
            vec![OutField { node: 2, field: 0, name: "cross".into() }],
        );
        // both SMAs default to "sma" → collide → fan-in indistinguishable
        assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
    }

    #[test]
    fn named_param_bearing_fan_in_bootstraps() {
        use aura_std::{Sma, Sub};
        let bp = Composite::new(
            "sma_cross",
            vec![
                Sma::builder().named("fast").into(),
                Sma::builder().named("slow").into(),
                Sub::builder().into(),
            ],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                Edge { from: 1, to: 2, slot: 1, from_field: 0 },
            ],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                source: None,
            }],
            vec![OutField { node: 2, field: 0, name: "cross".into() }],
        );
        // distinct node names → fan-in distinguishable → compiles
        assert!(bp.compile().is_ok());
    }

Keep interchangeable_fan_in_allowed (:1561) — two paramless same-type siblings stay legal; after migration its fixture's Composite::new loses the 5th arg but the assertion (compiles OK) is unchanged.

  • Step 2: Run the new tests to verify they fail

Run: cargo test -p aura-engine named_siblings_path_qualify_with_node_segment Expected: FAIL — either a compile error (5-arg Composite::new not yet in effect) or an assertion mismatch (sma_cross.length vs sma_cross.fast.length).

  • Step 3: Rewrite collect_params to insert the node segment uniformly

Replace collect_params (:688-730) with the node-segment form (drop the aliases parameter; a primitive contributes <prefix>.<node-name>.<param>):

fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>) {
    for item in items {
        match item {
            BlueprintNode::Primitive(b) => {
                let node = if prefix.is_empty() {
                    b.node_name()
                } else {
                    format!("{prefix}.{}", b.node_name())
                };
                for p in b.params() {
                    out.push(ParamSpec { name: format!("{node}.{}", p.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);
            }
        }
    }
}

Update the param_space() call site (:197) to drop &self.params:

        collect_params(&self.nodes, "", &mut out);
  • Step 4: Re-key signature_of and the fan-in predicate onto the node name

signature_of (:546-590): drop the aliases: &[ParamAlias] parameter and the aliases_on loop (:559-563); the per-node component becomes the full node name (so two "sma" collide, "fast"/"slow" differ). Keep the -> String return. Replace the primitive arm's initial-building with the node name:

pub fn signature_of(nodes: &[BlueprintNode], edges: &[Edge], roles: &[Role], node: usize) -> String {
    let mut sig = match &nodes[node] {
        BlueprintNode::Primitive(b) => b.node_name(),        // was: type initial + alias initials
        BlueprintNode::Composite(c) => c.name().to_string(),
    };
    // recurse over inputs exactly as today (edges feeding `node`), appending each
    // input's signature_of(...) in slot order:
    for e in inputs_of(edges, node) {            // same input-gathering as today
        sig.push_str(&signature_of(nodes, edges, roles, e.from));
    }
    sig
}

(Match the existing recursion shape at :559-590; only the per-node head and the removed aliases param change.)

leaf_has_unaliased_param (:674-686) → leaf_has_param: drop the aliases arg and the aliased subtraction (:681-682); it is now true iff the leaf node has ≥1 param:

fn leaf_has_param(nodes: &[BlueprintNode], node: usize) -> bool {
    matches!(&nodes[node], BlueprintNode::Primitive(b) if !b.params().is_empty())
}

check_composite_fan_in (:635-672): drop the param_aliases parameter; remove the check_alias_indices call (:644); update the two helper calls (:651-652):

                signature_of(nodes, edges, input_roles, e.from),
                leaf_has_param(nodes, e.from),

check_fan_in_distinguishability (:616-628): drop c.params() from the check_composite_fan_in(...) call (:623).

The IndistinguishableFanIn { node } construction (:666) is unchanged.

  • Step 5: Remove the overlay (struct, field, arg, helpers, alias tests)

  • Delete pub struct ParamAlias { … } (:121-130).

  • Delete the params: Vec<ParamAlias> field from Composite (:142) and the pub fn params(&self) -> &[ParamAlias] accessor (:180-184).

  • Change Composite::new (:151-160) to a 5-arg signature (drop params):

    pub fn new(
        name: impl Into<String>,
        nodes: Vec<BlueprintNode>,
        edges: Vec<Edge>,
        input_roles: Vec<Role>,
        output: Vec<OutField>,
    ) -> Self {
        Self { name: name.into(), nodes, edges, input_roles, output }
    }
  • Delete aliases_on (:592-598) and check_alias_indices (:600-614). Keep the CompileError::BadInteriorIndex variant (:458) — it is still used by the other index checks (:498-515, :872-914); only the dangling-alias path that produced it is gone.

  • Delete the alias-semantics tests: param_alias_relabels_param_space_name_in_place (:1991-2022), partial_aliasing_relabels_only_the_named_slot (:2087-2113), out_of_range_param_alias_rejected (:2024-2057), unaliased_params_keep_factory_names (:2059-…). Their behaviour no longer exists.

  • Amend the alias-overlay comment at :803-810 to describe node-name qualification.

  • Step 6: Update the engine lib.rs re-export

In crates/aura-engine/src/lib.rs:41-42, remove ParamAlias (and aliases_on if re-exported) from the pub use blueprint::{…} list. Keep signature_of, Composite, Role, OutField, BindError, etc. (Quote: drop exactly the ParamAlias/aliases_on identifiers from the brace list.)

  • Step 7: Mechanically migrate every in-workspace Composite::new call site + ParamAlias reference

Drop the 5th (params) argument from every Composite::new(...) call, and remove every ParamAlias { … } literal / import, across the workspace. The recon hand-list (advisory; the compile gate is authoritative):

  • crates/aura-engine/src/blueprint.rs tests: the ~46 Composite::new call sites enumerated by recon (e.g. :1273, :1301, …, :2363) lose their 5th arg; the alias literals at :1312-1313, :1759-1760, :2006-2007, :2037, :2100, :2192-2193, :2319-2321 are removed (or replaced with .named(...) on the relevant builder where a same-type param-bearing fan-in fixture would otherwise become IndistinguishableFanIn).

  • crates/aura-engine/src/graph_model.rs: import (:235), Composite::new fixtures (:274,:302,:326,:344), alias literals (:287-288) — drop the arg; where a fixture fans in two same-type param-bearing leaves, add .named("a")/.named("b") so it still compiles. Do not change the expected JSON golden at :430 (it pins "type":"SMA" + ["length","i64"], which model_to_json still emits).

  • crates/aura-engine/src/sweep.rs: import (:173), fixtures (:276,:306), alias literals (:289-290) — same treatment.

  • crates/aura-cli/src/main.rs: import (:14) removed; sma_cross (:137-155) — the two ParamAlias{name:"fast"/"slow"} (:150-151) become .named("fast") / .named("slow") on the two Sma::builder() (:140-ish) and the params arg is dropped; root harness (:170) drops its vec![] params arg; EMA/MACD composite (:338,:382) — its EMA-leg aliases (:363-365) become .named(...) on the EMA builders, params arg dropped.

  • Step 8: Update the path-string assertions in the engine tests

  • param_space_mirrors_compiled_flat_node_param_order (:1958, assertion :1981-1984): the fixture's two SMAs become .named("fast")/.named("slow"), the Exposure node stays default-named; the expected vector changes from ["sma_cross.fast", "sma_cross.slow", "scale"] to ["sma_cross.fast.length", "sma_cross.slow.length", "exposure.scale"]. The kind-by-slot part of the assertion (the load-bearing arity/order invariant) is unchanged.

  • param_space_mirrors_compiled_flat_node_param_order_under_nesting (:2177, assertion :2236-2239): name the fixture's SMAs; the kind-only assertion stays; any old alias literals (:2192-2193) are removed.

  • param_space_is_flat_path_qualified_and_slot_disambiguated (:2116, assertion :2152-2160): name the two SMAs; the expected strings gain the node segment (e.g. strategy.fast_slow.fast.length / …slow.length per the chosen fixture names).

  • top_level_leaf_params_are_unqualified (:2243-2256): invert the pin — the assertion at :2255 becomes assert_eq!(space[0].name, "sma.length") (root node now carries its name segment). Rename the test to top_level_leaf_params_carry_the_node_segment to reflect the inverted intent.

  • signature_of_is_type_initial_plus_aliases_plus_recursive_inputs (:1289-1297)

    • macd_like_signature_fixture (:1299-1317): rename to signature_of_is_node_name_plus_recursive_inputs; the fixture's EMA builders are default-named ("ema") or .named(...); the expected signature strings (:1294, :1296) are recomputed from the node-name head (no longer "Efp" / "SEfpEsp"). Compute the new expected strings from the rewritten signature_of and pin them exactly.
  • indistinguishable_fan_in_rejected (:1531-1559): stays green (IndistinguishableFanIn { node: 2 }); update the fixture comment (:1533-1534) from "unaliased" to "both default to sma". (This test overlaps unnamed_same_type_param_bearing_fan_in_is_rejected from Step 1 — keep both or fold into one; do not leave a duplicate name.)

  • Step 9: Migrate the CLI sweep goldens (both files, lockstep)

  • crates/aura-cli/src/main.rs: the .axis(...) block (:229-231) → .axis("sma_cross.fast.length", …).axis("sma_cross.slow.length", …).axis("exposure.scale", …); the single-run .with(...) block (:516-518) → the same three names; the sweep golden assertions (:537-540) → "params":{"sma_cross.fast.length":N,"sma_cross.slow.length":N,"exposure.scale":0.5}; the MACD param assertion (:597-599) gains the node segments for its EMA knobs + exposure.scale.

  • crates/aura-cli/tests/cli_run.rs:214: the twin golden — replace "params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5} with "params":{"sma_cross.fast.length":2,"sma_cross.slow.length":4,"exposure.scale":0.5}. Keep the substring contiguous on one line (self-review #6).

  • Step 10: Workspace build + test + clippy gate (the completeness enumerator)

Run: cargo build --workspace Expected: 0 errors. (Any remaining un-migrated Composite::new caller or ParamAlias reference surfaces here — fix it, re-run, until 0 errors. This gate is the authoritative completeness check for the compile-coupled migration.)

Run: cargo test --workspace Expected: PASS — all new tests green, the mirror/top_level/signature tests green with their updated strings, the CLI goldens green with the migrated names, the model_to_json goldens green unchanged.

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: 0 warnings.


Task 3: Amend the design ledger (C9, C23)

Doc-only; no compile coupling.

Files: docs/design/INDEX.md

  • Step 1: Amend C9 (fan-in distinguishability)

In docs/design/INDEX.md, the C9 fan-in refinement paragraph (:367-375): replace the rationale that keys distinguishability on "equal recursive signatures (type initial + alias initials + …)" and fires "when at least one colliding source carries an unaliased param slot" with the node-name keying: colliding sources are those with equal recursive node-name signatures; the collision is IndistinguishableFanIn when at least one carries a param (a param-bearing same-name collision), resolved by giving the colliding nodes distinct names. Paramless interchangeable same-name sources stay legal.

  • Step 2: Amend C23 (named boundary projections)

In the C23-related prose at :359-366 and :813-851: strike the ParamAlias param-overlay from the named-projection set (the param projection is retired; Role.name input roles and OutField.name outputs remain). Note that node names join the same non-load-bearing debug-symbol class (construction-time identity, dropped at lowering; the flat graph stays wired by raw index). Remove the sentence referencing the dangling-alias BadInteriorIndex compile check (:362-364) — that path is gone with the overlay.

  • Step 3: Verify the ledger references no retired symbol

Run: grep -nE "ParamAlias|unaliased|aliases_on|check_alias_indices" docs/design/INDEX.md Expected: no remaining references to the retired overlay (or only historical "was retired in cycle 0031" notes if added deliberately).