Files
Aura/docs/plans/0032-param-namespace-injectivity.md
T
Brummel 770cf7177d plan: 0032 param-namespace-injectivity
Three tasks for the single-iteration cycle:
- Task 1 (RED): add CompileError::DuplicateParamPath + the three new tests
  (non-fan-in duplicate, asymmetric nested collision, by-name #59 regression),
  verified failing against current code.
- Task 2 (GREEN): add check_param_namespace_injective, wire it into
  compile_with_params + both binders (before resolve), remove the fan-in
  machinery (signature_of/leaf_has_param/the two check fns) + IndistinguishableFanIn
  + AmbiguousKnob (both resolver arms -> unreachable!) + the lib.rs re-export,
  migrate tests 1/2, remove tests 7/8 + macd fixture. One cargo build --workspace
  gate over the whole enum-coupled change.
- Task 3: amend the C9/C12/C19/C23 ledger notes.

refs #59
2026-06-11 18:20:44 +02:00

19 KiB

Param-Namespace Injectivity — Implementation Plan

Parent spec: docs/specs/0032-param-namespace-injectivity.md

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

Goal: Make param_space() injectivity a single enforced compile invariant (check_param_namespace_injectiveCompileError::DuplicateParamPath), retire the fan-in machinery and BindError::AmbiguousKnob it subsumes, and run the check before name resolution so the by-name author sees the structural cure.

Architecture: One structural check over the param_space() names replaces check_fan_in_distinguishability and is called from compile_with_params plus both binders (before resolve/resolve_axes). The fan-in machinery (signature_of, leaf_has_param, the two check fns) and AmbiguousKnob (now dead, since an injective space can never multi-match) are removed wholesale. The removal+rewire is enum/signature-coupled, so it lands as one cargo build --workspace-gated task after the new RED tests are in place.

Tech Stack: crates/aura-engine/src/blueprint.rs (the whole change), crates/aura-engine/src/lib.rs (one re-export line), docs/design/INDEX.md (C9/C12/C19/C23 amendments). Engine core untouched.


Files this plan creates or modifies:

  • Modify: crates/aura-engine/src/blueprint.rs — the check, the variant swap, the machinery removal, the two resolver arms, the three call sites, the test migrations/removals/additions.
  • Modify: crates/aura-engine/src/lib.rs:41 — drop signature_of from the pub use blueprint::{…} re-export.
  • Modify: docs/design/INDEX.md — C9 refinement (367-379), C12/C19 note (265-268), C23 amendment.
  • Test: crates/aura-engine/src/blueprint.rs — add tests 3/5/6 (RED-first), migrate tests 1/2, remove tests 7/8 + the macd_like_signature_fixture helper.

Task 1: RED — add the new variant and the three failing tests

Additive only (nothing removed, nothing wired): the new DuplicateParamPath variant so the tests compile, and the three new tests asserting the post-change behaviour. They must FAIL against current code.

Files:

  • Modify: crates/aura-engine/src/blueprint.rs:440-465 (CompileError enum — add one variant)

  • Test: crates/aura-engine/src/blueprint.rs (tests module — add 3 tests)

  • Step 1: Add the DuplicateParamPath variant to CompileError

In crates/aura-engine/src/blueprint.rs, inside enum CompileError (derives #[derive(Debug, PartialEq, Eq)] at line 440), add this variant immediately after the existing IndistinguishableFanIn { node: usize }, (line 460). Do NOT remove IndistinguishableFanIn yet (Task 2 removes it):

    /// Two `param_space()` slots resolved to the same path-qualified name — the
    /// by-name knob address space (C12/C19) is not injective, so no binding can
    /// select one slot without the other. Carries the duplicated path. Cure: give
    /// the colliding same-type sibling nodes distinct names with `.named(...)`.
    DuplicateParamPath(String),
  • Step 2: Add test 3 (non_fan_in_duplicate_path_is_rejected)

Append to the #[cfg(test)] mod tests block in blueprint.rs:

    #[test]
    fn non_fan_in_duplicate_path_is_rejected() {
        use aura_std::Sma;
        // two unnamed SMAs ("sma" each), each feeding its OWN sink — no shared
        // fan-in node, so the old fan-in check never fired; but param_space() has
        // the path "dup.sma.length" twice.
        let dup = Composite::new(
            "dup",
            vec![
                Sma::builder().into(), // node 0 -> dup.sma.length
                Sma::builder().into(), // node 1 -> dup.sma.length (duplicate)
                sink_f64(),            // node 2
                sink_f64(),            // node 3
            ],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                Edge { from: 1, to: 3, slot: 0, from_field: 0 },
            ],
            vec![Role {
                name: "price".into(),
                targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
                source: None,
            }],
            vec![],
        );
        let bp = Composite::new(
            "root",
            vec![BlueprintNode::Composite(dup)],
            vec![],
            vec![Role {
                name: "src".into(),
                targets: vec![Target { node: 0, slot: 0 }],
                source: Some(ScalarKind::F64),
            }],
            vec![],
        );
        // two params declared (one per SMA); supply both so the only failure under
        // test is the duplicate path (green today, DuplicateParamPath after).
        assert_eq!(
            bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(3)]).err(),
            Some(CompileError::DuplicateParamPath("dup.sma.length".to_string()))
        );
    }
  • Step 3: Add test 5 (asymmetric_node_name_collision_compiles)

Append to the tests block:

    #[test]
    fn asymmetric_node_name_collision_compiles() {
        use aura_std::{Sma, Sub};
        // inner composite `asym`: a param-bearing Sma (default "sma") + a paramless
        // Pass1 forced to "sma", both on role price fanning into a Sub. Equal
        // node-name signatures + one param -> rejected today (IndistinguishableFanIn);
        // param_space() is the single injective entry ["asym.sma.length"] (the
        // paramless leg contributes no path) -> admitted after the change. Pass1 is
        // built inline (the pass1() helper returns an already-.into()'d node, so it
        // cannot take .named).
        let paramless_sma = PrimitiveBuilder::new(
            "Pass1",
            NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
            |_| Box::new(Pass1 { out: [Scalar::F64(0.0)] }),
        )
        .named("sma");
        let asym = Composite::new(
            "asym",
            vec![Sma::builder().into(), paramless_sma.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: "out".into() }],
        );
        let bp = Composite::new(
            "root",
            vec![BlueprintNode::Composite(asym)],
            vec![],
            vec![Role {
                name: "src".into(),
                targets: vec![Target { node: 0, slot: 0 }],
                source: Some(ScalarKind::F64),
            }],
            vec![],
        );
        assert_eq!(
            bp.param_space().into_iter().map(|p| p.name).collect::<Vec<_>>(),
            ["asym.sma.length"]
        );
        assert!(bp.compile_with_params(&[Scalar::I64(2)]).is_ok());
    }
  • Step 4: Add test 6 (by_name_bootstrap_of_unnamed_cross_reports_duplicate_path)

Append to the tests block:

    #[test]
    fn by_name_bootstrap_of_unnamed_cross_reports_duplicate_path() {
        // the canonical by-name flow on an unnamed cross: the binder runs the
        // injectivity check before name resolution, so the author sees the
        // structural DuplicateParamPath (carrying the .named(...) cure) instead of
        // AmbiguousKnob.
        let bp = sma_cross_under_root(false);
        assert_eq!(
            bp.with("sma_cross.sma.length", 2).bootstrap().err(),
            Some(BindError::Compile(CompileError::DuplicateParamPath(
                "sma_cross.sma.length".to_string()
            )))
        );
    }
  • Step 5: Run the three new tests to verify they FAIL (RED)

Run (one filter per invocation — multi-filter prints nothing here):

cargo test --workspace non_fan_in_duplicate_path_is_rejected Expected: FAIL — left: None (compiles green today), right: Some(DuplicateParamPath("dup.sma.length")).

cargo test --workspace asymmetric_node_name_collision_compiles Expected: FAIL — assertion failed: bp.compile_with_params(&[Scalar::I64(2)]).is_ok() (the asym fan-in is rejected as IndistinguishableFanIn today).

cargo test --workspace by_name_bootstrap_of_unnamed_cross_reports_duplicate_path Expected: FAIL — left: Some(AmbiguousKnob("sma_cross.sma.length")), right: Some(Compile(DuplicateParamPath("sma_cross.sma.length"))).

(All three compile — DuplicateParamPath exists from Step 1; IndistinguishableFanIn and AmbiguousKnob still exist, removed in Task 2.)


Task 2: GREEN — add the check, wire it, remove the fan-in machinery + AmbiguousKnob, migrate/remove tests

Enum/signature-coupled: every removal and rewire lands together under one cargo build --workspace gate so the build never sees a half-removed enum.

Files:

  • Modify: crates/aura-engine/src/blueprint.rs (check fn; head ~189; binders ~311/~335; resolvers ~370/~415; CompileError ~460; BindError ~287; remove fns 524-615; migrate tests 1258/1527; remove tests 1008-1019, 1269-1298)

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

  • Step 1: Add check_param_namespace_injective

Add this free function in blueprint.rs near the other module-level check_* / fn resolve helpers (e.g. just above fn resolve):

/// Structural validation (param-value-independent): the `param_space()` name
/// projection is the by-name knob address space (C12/C19) and must be injective —
/// a duplicated path is a knob no binding can select alone. The first duplicate in
/// `param_space()` order is reported (the order is deterministic). Single source of
/// duplicate detection; called from `compile_with_params` and from both binders
/// before name resolution.
fn check_param_namespace_injective(space: &[ParamSpec]) -> Result<(), CompileError> {
    let mut seen = std::collections::HashSet::new();
    for p in space {
        if !seen.insert(p.name.as_str()) {
            return Err(CompileError::DuplicateParamPath(p.name.clone()));
        }
    }
    Ok(())
}
  • Step 2: Rewire the compile_with_params head

In blueprint.rs:189-202, replace the head (the check_fan_in_distinguishability call and the later separate param_space() arity computation) so param_space() is computed once and the injectivity check replaces the fan-in check. Change:

        // structural validation, all pre-build (no node constructed):
        check_fan_in_distinguishability(&self.nodes)?;
        validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
        for (r, role) in self.input_roles.iter().enumerate() {
            if role.source.is_none() {
                return Err(CompileError::UnboundRootRole { role: r });
            }
        }

        let expected = self.param_space().len();
        if params.len() != expected {
            return Err(CompileError::ParamArity { expected, got: params.len() });
        }

to:

        // structural validation, all pre-build (no node constructed):
        let space = self.param_space();
        check_param_namespace_injective(&space)?;
        validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
        for (r, role) in self.input_roles.iter().enumerate() {
            if role.source.is_none() {
                return Err(CompileError::UnboundRootRole { role: r });
            }
        }

        if params.len() != space.len() {
            return Err(CompileError::ParamArity { expected: space.len(), got: params.len() });
        }
  • Step 3: Wire the check into Binder::bootstrap

In blueprint.rs:311-315, insert the check after let space:

    pub fn bootstrap(self) -> Result<Harness, BindError> {
        let space = self.bp.param_space();
        check_param_namespace_injective(&space).map_err(BindError::Compile)?;
        let point = resolve(&space, &self.bound)?;
        self.bp.bootstrap_with_params(point).map_err(BindError::Compile)
    }
  • Step 4: Wire the check into SweepBinder::sweep

In blueprint.rs:335-344, insert the check after let space:

    pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
    where
        F: Fn(&[Scalar]) -> RunReport + Sync,
    {
        let space = self.bp.param_space();
        check_param_namespace_injective(&space).map_err(BindError::Compile)?;
        let ordered = resolve_axes(&space, &self.axes)?;
        let grid = GridSpace::new(&space, ordered)
            .expect("named layer pre-validates arity/kind/non-empty");
        Ok(sweep(&grid, run_one))
    }
  • Step 5: Replace both _ => AmbiguousKnob resolver arms with unreachable!

In resolve_axes (the arm at blueprint.rs:370) and resolve (the arm at blueprint.rs:415), replace:

            _ => return Err(BindError::AmbiguousKnob(name.clone())), // b

with (both arms, identical):

            _ => unreachable!("param_space() is injective — checked before resolve"),
  • Step 6: Swap the CompileError variant

In blueprint.rs:456-460, remove the IndistinguishableFanIn variant and its doc comment:

    /// A fan-in node (>1 input) at interior index `node` has two input slots fed by
    /// sources with identical signatures where at least one source carries an
    /// unaliased param slot — the inputs differ in configuration but share a
    /// rendered identity. Name the distinguishing param (e.g. fast/slow).
    IndistinguishableFanIn { node: usize },

(The DuplicateParamPath(String) variant added in Task 1 Step 1 stays.)

  • Step 7: Remove the BindError::AmbiguousKnob variant

In blueprint.rs:287-288, remove:

    /// A name matches the exact name of more than one slot.
    AmbiguousKnob(String),
  • Step 8: Remove the fan-in machinery

Delete these four functions wholesale (with their doc comments): signature_of (blueprint.rs:524-555), check_fan_in_distinguishability (557-569), check_composite_fan_in (571-608), leaf_has_param (610-615).

  • Step 9: Drop signature_of from the lib.rs re-export

In crates/aura-engine/src/lib.rs:41, remove the leading signature_of, so the line:

    signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,

becomes:

    BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
  • Step 10: Migrate test 1 (unnamed_same_type_param_bearing_fan_in_is_rejected)

In blueprint.rs:1258, replace:

        assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));

with:

        assert_eq!(
            bp.compile().err(),
            Some(CompileError::DuplicateParamPath("sma_cross.sma.length".to_string()))
        );
  • Step 11: Migrate test 2 (indistinguishable_fan_in_rejected)

In blueprint.rs:1527, replace:

        assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));

with:

        assert_eq!(
            bp.compile().err(),
            Some(CompileError::DuplicateParamPath("ambig.sma.length".to_string()))
        );
  • Step 12: Remove the obsolete tests + helper

Delete resolve_ambiguous_knob (blueprint.rs:1008-1019), signature_of_is_node_name_plus_recursive_inputs (1269-1278), and macd_like_signature_fixture (1280-1298 — its only user was the removed test; verified by recon).

  • Step 13: Build the workspace (the coupling gate)

Run: cargo build --workspace Expected: success, 0 errors. (Every reference to the removed IndistinguishableFanIn / AmbiguousKnob / signature_of / check_* / leaf_has_param is gone in the same task; the build sees no half-removed enum.)

  • Step 14: Run the three Task-1 tests to verify they now PASS (GREEN)

cargo test --workspace non_fan_in_duplicate_path_is_rejected → PASS cargo test --workspace asymmetric_node_name_collision_compiles → PASS cargo test --workspace by_name_bootstrap_of_unnamed_cross_reports_duplicate_path → PASS

  • Step 15: Run the migrated tests to verify they PASS

cargo test --workspace unnamed_same_type_param_bearing_fan_in_is_rejected → PASS cargo test --workspace indistinguishable_fan_in_rejected → PASS cargo test --workspace interchangeable_fan_in_allowed → PASS (unchanged; the paramless interchangeable fan-in stays legal — no param path, no duplicate)

  • Step 16: Full workspace gate

Run: cargo test --workspace Expected: PASS, 0 failed. (CLI sweep/run goldens stay green — the sample legs are named fast/slow, so its param_space() is injective and the bound paths are unchanged.)

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: clean, 0 warnings. (check_param_namespace_injective is now wired into three call sites — no dead_code.)


Task 3: Ledger amendments (docs/design/INDEX.md)

Prose-only; reframe the contracts the cycle touches per spec §Error handling / Contract amendments. No compile gate.

Files:

  • Modify: docs/design/INDEX.md (C9 refinement 367-379; C12/C19 note 265-268; C23)

  • Step 1: Amend the C9 fan-in refinement (367-379)

Reframe the "Refinement (fan-in distinguishability …)" paragraph onto the fundamental statement: a blueprint compiles only if its param_space() name projection is injective; the param-bearing indistinguishable fan-in is one instance of a duplicated path. Record that signature_of, leaf_has_param, and the fan-in-specific check are retired (cycle 0032); the error is now DuplicateParamPath (path-carrying, not a node index); paramless interchangeable same-name sources stay legal (no path, no duplicate). State that the old signature-collision predicate's extra breadth — rejecting an asymmetric param/paramless collision and a role-vs-leg collision, neither a path duplicate — guarded render identity, dead since the renderer was retired in 0026; both are intentionally dropped, and a future node-/wiring-name distinguishability check, if ever wanted, is decoupled from param-space injectivity.

  • Step 2: Refine the C12/C19 positional-identity note (265-268)

At the sentence "Identity is positional (the slot, C23 'by index, not name'); … same-type siblings in one composite share a name, uniqueness is at the slot", add the refinement: identity in the compilat stays positional (C23, unchanged), but the param_space() name projection — the authoring / by-name address space — must be injective for a blueprint to compile (cycle 0032). Two layers: positional wiring below, injective name address space above.

  • Step 3: Note the C23 amendment

Record that the injectivity check is part of bootstrap-as-compilation; node names stay non-load-bearing (dropped at lowering, compilat wired by raw index). The check reads the boundary name projection; it does not make names load-bearing in the compilat.

  • Step 4: Verify the ledger still builds as docs (sanity)

Run: cargo doc --workspace --no-deps 2>&1 Expected: success (no doctest breakage; the ledger is prose, but confirm the workspace doc build is unaffected).