# Param-Namespace Injectivity — Design Spec **Date:** 2026-06-11 **Status:** Draft — awaiting user spec review **Authors:** orchestrator + Claude ## Goal Make `param_space()` injectivity a stated, enforced **compile invariant**, caught by a single structural check, and retire the two narrower mechanisms that today half-cover the same defect. `param_space()` is aura's by-name knob address space (C12/C19): the path-qualified `.` names a sweep or a single-run binding addresses. When two same-type **unnamed** sibling primitives sit under one composite, `collect_params` emits the *same* path twice (e.g. `sma_cross.sma.length` ×2). A non-injective address space is a structural blueprint defect — a bound name cannot select one slot without the other — yet today it is caught only partially and signposted wrongly: - **Partial coverage.** `check_fan_in_distinguishability` rejects the *fan-in* special case (two colliding param-bearing legs into one node) as `IndistinguishableFanIn`. A **non-fan-in** duplicate (two unnamed same-type SMAs feeding two *separate* consumers) produces the same duplicated path but compiles **green** today — it is by-name unaddressable but nothing rejects it. - **Wrong signpost on the canonical path.** The by-name binder resolves names against `param_space()` *before* compile runs its fan-in check, so an unnamed cross bound by name fails with `BindError::AmbiguousKnob` ("this knob is ambiguous") — the symptom — instead of the cure the #56 naming mechanism exists to deliver ("give the colliding nodes distinct names with `.named(...)`"). This cycle replaces both with **one** check on the fundamental statement: the `param_space()` name projection must be injective. The check is the *principled scope* — it catches exactly the case where a knob is by-name unaddressable: every fan-in collision that hides a **duplicated param path**, plus the non-fan-in duplicate that slips through today — and, run before name resolution, surfaces the right structural error with the right cure on every path. `AmbiguousKnob` becomes dead and is removed. The old fan-in check rejected a *broader* set than by-name addressability requires: it also fired on two collisions where every knob still has a **unique** path (§What is deliberately dropped). Those extra rejections guarded **render identity** — the ability to tell two same-rendered nodes apart in a graph view — a property with **no live consumer** since the ascii-dag renderer was retired in cycle 0026. They are intentionally retired with the machinery; the injectivity check keeps exactly the live property (by-name addressability) and drops the dead one. ## Architecture One structural check, `check_param_namespace_injective`, operates on the `param_space()` output (the path-qualified names) and rejects the first duplicated path. It is the **single source of duplicate detection**, called from three sites: 1. `compile_with_params` — at the top, where `check_fan_in_distinguishability` sits today. Covers the positional compile path and makes injectivity a compile invariant (C23: the check is part of bootstrap-as-compilation). 2. `Binder::bootstrap` — before `resolve`, so the canonical single-run by-name author sees the structural error, not `AmbiguousKnob`. 3. `SweepBinder::sweep` — before `resolve_axes`, same reason for the sweep author. Detection lives in **one function**; the binders only call it (they already hold the `param_space()` vector at the call site, so no recomputation is forced). The two binder call sites guarantee `param_space()` is injective before `resolve` / `resolve_axes` run, which makes the `matches.len() > 1` branch in both resolvers **unreachable** — that is what lets `AmbiguousKnob` be removed. The fan-in machinery (`check_fan_in_distinguishability`, `check_composite_fan_in`, `signature_of`, `leaf_has_param`) is removed wholesale, together with the `signature_of` re-export in `lib.rs`. This is sound because every fan-in defect that is a **by-name addressability** failure is a path duplicate the new check catches (proven by fixtures, §Testing strategy); the old check's two *additional* rejections (§What is deliberately dropped) are not addressability defects — every knob in them stays uniquely addressable — and guarded the dead render-identity property, so retiring them is intended, not a regression. `signature_of` has no other consumer (verified: its only non-test callers are `check_composite_fan_in` and itself; the `blueprint.rs:528-529` doc line claiming a "CLI render" consumer is stale — the ascii-dag fan-in render was retired with the renderer in cycle 0026, and INDEX.md already records "no render consumer"). `param_space()` **stays infallible** (`-> Vec`). The injectivity invariant lives in the compile step (C23), not in the getter; a blueprint with a duplicate still *builds* the vector — inspection stays quiet — but fails to *compile*. ### What is deliberately dropped The old fan-in predicate fires on **equal recursive node-name signature AND *at least one* colliding leg carrying a param** (`check_composite_fan_in`: the `sources[i].has_param || sources[j].has_param` term). That `||` makes it reject two collisions where `param_space()` is nonetheless **injective** — every knob has a unique path, so neither is a by-name addressability defect. Both are intentionally dropped; both guarded only render identity (the dead property above): 1. **Asymmetric param/paramless collision.** A param-bearing leg and a *paramless* leg forced to the same node name (e.g. `Sma` defaulting to `"sma"` and a paramless node `.named("sma")`), fanning into one consumer. Equal signatures + one param → the old check rejects. But `param_space()` carries only the one param-bearing leg's path (the paramless leg contributes none), so it is a **single, unique** entry — injective, the new check admits it. This case is pinned by a now-compiles fixture (§Testing strategy test 5) so the drop is explicit, not silent. 2. **Role-name-vs-param-leg signature collision.** An input role whose name equals the recursive signature of a param-bearing leg fanning into the same node. Roles are not param knobs; the leg's single path stays uniquely addressable. No covering test exists today. Both are **not** by-name addressability failures. If a node-name / wiring-name distinguishability rejection is ever wanted again (e.g. once a graph view consumes node identity), that is a separate **structural-identity** check (its own future cycle), decoupled from param-space injectivity — not this one. These drops are intentional and called out so the grounding-check and the skeptic panel see them as a decision, not an oversight. ## Concrete code shapes ### Worked author surface (the cure, end to end) The user-facing program this cycle protects. An author writes a fast/slow cross with **unnamed** same-type legs and binds it by name: ```rust // blueprint with two same-type SMA legs left unnamed let bp = sma_cross_under_root(false); // both legs default to node-name "sma" // canonical single-run by-name binding let result = bp .with("sma_cross.sma.length", 2) .bootstrap(); // BEFORE this cycle: Err(BindError::AmbiguousKnob("sma_cross.sma.length")) // — the symptom; the author is told the *binding* is ambiguous. // AFTER this cycle: Err(BindError::Compile(CompileError::DuplicateParamPath( // "sma_cross.sma.length".into()))) // — the structural defect, naming the duplicated path; the message points at // `.named(...)`. ``` The cure is the #56 mechanism, now reachable from the right error: ```rust // give the colliding legs distinct names — the param paths become injective let bp = sma_cross_under_root(true); // legs named "fast" / "slow" let harness = bp .with("sma_cross.fast.length", 2) .with("sma_cross.slow.length", 4) .bootstrap() .expect("named legs → injective param_space → compiles"); ``` ### The check (new) ```rust /// 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(()) } ``` ### `compile_with_params` head — before → after ```rust // BEFORE (~line 189): fan-in check on nodes, param_space recomputed later for arity pub fn compile_with_params(self, params: &[Scalar]) -> Result { 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() }); } // ... lower ... } ``` ```rust // AFTER: param_space computed once at the top; injectivity check replaces fan-in pub fn compile_with_params(self, params: &[Scalar]) -> Result { 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() }); } // ... lower ... (unchanged) } ``` ### `CompileError` variant — before → after ```rust // BEFORE /// A fan-in node (>1 input) at interior index `node` ... share a rendered identity. IndistinguishableFanIn { node: usize }, ``` ```rust // AFTER /// 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), ``` ### `BindError::AmbiguousKnob` removal + resolver arms — before → after ```rust // BEFORE — BindError carries AmbiguousKnob; resolve/resolve_axes Phase 1 match: match matches.as_slice() { [] => return Err(BindError::UnknownKnob(name.clone())), // a [idx] => { /* claim slot */ } _ => return Err(BindError::AmbiguousKnob(name.clone())), // b } ``` ```rust // AFTER — AmbiguousKnob variant deleted from BindError; the multi-match branch is // unreachable because the binder checked injectivity before calling the resolver: match matches.as_slice() { [] => return Err(BindError::UnknownKnob(name.clone())), // a [idx] => { /* claim slot */ } _ => unreachable!("param_space() is injective — checked before resolve"), } ``` ### Binder call sites — before → after ```rust // BEFORE — Binder::bootstrap (~311) pub fn bootstrap(self) -> Result { let space = self.bp.param_space(); let point = resolve(&space, &self.bound)?; self.bp.bootstrap_with_params(point).map_err(BindError::Compile) } ``` ```rust // AFTER — the structural check runs before name resolution, so a duplicate surfaces // as the structural error (wrapped in BindError::Compile), not AmbiguousKnob: pub fn bootstrap(self) -> Result { 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) } ``` ```rust // AFTER — SweepBinder::sweep (~335), same shape before resolve_axes: pub fn sweep(self, run_one: F) -> Result 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)) } ``` Note the deliberate double execution on the by-name path (binder checks, then `bootstrap_with_params` → `compile_with_params` checks again). This is *one* function called at two independent entry points — not duplicated detection logic. `compile_with_params` is a public entry that must self-validate regardless of caller; the binder checks early only to order the structural error ahead of name resolution. The check is cheap, deterministic, and idempotent. ## Components - **`crates/aura-engine/src/blueprint.rs`** - **Add:** `check_param_namespace_injective(&[ParamSpec]) -> Result<(), CompileError>`. - **Add:** `CompileError::DuplicateParamPath(String)`. - **Remove:** `CompileError::IndistinguishableFanIn { node }`, `check_fan_in_distinguishability`, `check_composite_fan_in`, `signature_of`, `leaf_has_param`, and the `signature_of_is_node_name_plus_recursive_inputs` test (with its `macd_like_signature_fixture` helper if it has no other user). - **Remove:** `BindError::AmbiguousKnob(String)` and both `_ => AmbiguousKnob` arms (→ `unreachable!`). - **Edit:** `compile_with_params` head; `Binder::bootstrap`; `SweepBinder::sweep`. - **`crates/aura-engine/src/lib.rs`** - **Remove:** `signature_of` from the public re-export line (it ceases to exist). - **`docs/design/INDEX.md`** — amend the C9 refinement note and the C12/C19/C23 param_space notes (§Error handling / contracts below). Engine core (the run loop, `Harness::bootstrap`, node traits) is untouched. ## Data flow `Composite::param_space()` (infallible) → `check_param_namespace_injective` (in `compile_with_params` and ahead of each binder's resolver) → on duplicate, `CompileError::DuplicateParamPath(path)`; on the by-name path, wrapped as `BindError::Compile(CompileError::DuplicateParamPath(path))`. On success, the existing resolve → lower → bootstrap flow is unchanged. ## Error handling - **Positional compile** of a non-injective blueprint → `CompileError::DuplicateParamPath(first_duplicated_path)`. - **By-name single-run / sweep** of a non-injective blueprint → `BindError::Compile(CompileError::DuplicateParamPath(...))`, raised before name resolution. - **Injective blueprint** → no behaviour change anywhere; all existing single-run, sweep, and CLI golden paths stay green (the param paths they bind are unchanged). - **`unreachable!` in the resolvers** documents the caller-guaranteed invariant; it cannot fire because both private-`resolve` callers check injectivity first. ### Contract amendments (`docs/design/INDEX.md`) - **C9 refinement (358–379).** Reframe the fan-in distinguishability refinement onto the more 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), and paramless interchangeable same-name sources stay legal (they contribute no path, so no duplicate). Note 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**, a property with no live consumer since the renderer was retired in 0026; both are intentionally dropped here. A future node-/wiring-name distinguishability check, if ever wanted, is decoupled from param-space injectivity. - **C12/C19 (255–270).** The 0015-era line "same-type siblings in one composite share a name, uniqueness is at the slot" is now refined: identity in the **flat graph** stays positional (C23, unchanged), but the `param_space()` **name projection** — the authoring/by-name address space — must be **injective** for a blueprint to compile. The two layers are distinct: positional wiring below, injective name address space above. - **C23.** The injectivity check is part of bootstrap-as-compilation; node names stay non-load-bearing (dropped at lowering, flat graph wired by raw index). The check reads the boundary name projection; it does not make names load-bearing in the flat graph. ## Testing strategy RED-first. The relationship between the new check and the retired fan-in check is proven by **fixtures**, not by argument: every fan-in case that is a by-name addressability defect is still rejected (tests 1–3), the paramless-interchangeable case stays legal (test 4), and the larger of the two intentionally-dropped render-identity collisions is pinned by a now-compiles fixture (test 5). All in `crates/aura-engine/src/blueprint.rs` tests, command `cargo test --workspace` (one positional filter per invocation). 1. **Migrate** `unnamed_same_type_param_bearing_fan_in_is_rejected` (~1255): assert `Some(CompileError::DuplicateParamPath("sma_cross.sma.length".into()))` instead of `IndistinguishableFanIn { node: 2 }`. 2. **Migrate** `indistinguishable_fan_in_rejected` (~1503): assert `DuplicateParamPath("ambig.sma.length".into())` (the duplicated path under the inner composite `ambig`) instead of `IndistinguishableFanIn { node: 2 }`. 3. **New** `non_fan_in_duplicate_path_is_rejected`: two unnamed same-type SMAs feeding **separate** consumers (no shared fan-in node) → `compile()` returns `DuplicateParamPath`. This case compiles green today; the test is RED before the change, GREEN after — it is the proof the new check catches a duplicate the old fan-in check never reached. 4. **Unchanged green** `interchangeable_fan_in_allowed` (~1531): two **param-less** `Pass1` into a `Join2` → still `compile().is_ok()`. Paramless nodes contribute no `param_space()` path, so no duplicate; the C9 interchangeability survives. 5. **New** `asymmetric_node_name_collision_compiles` — pins the intentional drop. The asymmetric collision must sit **inside a composite** for the old check to reach it (`check_fan_in_distinguishability` only descends into composites; a root-level fan-in is never checked), so it reuses test 2's nested shape: an inner composite `asym` holding a param-bearing `Sma` (default name `"sma"`), a **paramless** `Pass1` `.named("sma")`, and a `Sub`, with both leaves on role `price` fanning into the `Sub`; `asym` sits under a source-bound `root` (`Some(ScalarKind::F64)`), exactly like `sma_cross_under_root`. Equal node-name signatures + one param → **rejected today** (`IndistinguishableFanIn`); after the change `param_space()` is the single entry `["asym.sma.length"]` (injective — the paramless leg contributes no path), so `compile_with_params(&[Scalar::I64(2)])` is `Ok`. The RED-green flip is the *reverse* of test 3 — what the old check rejected, the new one admits, by design. 6. **The #59 test** `by_name_bootstrap_of_unnamed_cross_reports_duplicate_path`: `bp.with("sma_cross.sma.length", 2).bootstrap()` on an unnamed cross → `Err(BindError::Compile(CompileError::DuplicateParamPath("sma_cross.sma.length".into())))`, **not** `AmbiguousKnob`. This is the canonical-path regression the cycle exists to fix. 7. **Remove** `resolve_ambiguous_knob` (~1009): the scenario it covered (a duplicate in `space` passed straight to `resolve`) is now structurally prevented upstream; calling `resolve` on a non-injective space would hit the `unreachable!`, so the test has no valid form and its coverage moves to tests 3 and 6. 8. **Remove** `signature_of_is_node_name_plus_recursive_inputs` (~1270): the function it tests is deleted. 9. **CLI goldens** (`crates/aura-cli/tests/cli_run.rs`, `main.rs` golden tests): no change expected — the sample blueprint's legs are named (`fast`/`slow`), its `param_space()` is injective, and the bound paths are unchanged. The final workspace test run is the gate that they stayed green. Final gate: `cargo build --workspace` green, `cargo test --workspace` green, `cargo clippy --workspace --all-targets -- -D warnings` clean. ## Acceptance criteria Judged against aura's criterion (CLAUDE.md): improves correctness / removes redundancy, the audience reaches for it, reintroduces no failure class. 1. **Removes redundancy.** One structural check replaces the fan-in machinery (`check_fan_in_distinguishability`, `check_composite_fan_in`, `signature_of`, `leaf_has_param`) **and** the `AmbiguousKnob` binder branch — a net code removal, the same shape as cycle 0031. 2. **Improves correctness.** The non-fan-in duplicate that compiles green today is rejected (test 3); the canonical by-name path surfaces the structural defect with the `.named(...)` cure instead of the `AmbiguousKnob` symptom (test 6). 3. **Injectivity is a stated compile invariant** (C12/C19/C23 amended), with `param_space()` still infallible. 4. **No regression.** Paramless interchangeable fan-in stays legal (test 4); all single-run, sweep, and CLI golden paths stay green; engine core untouched. 5. **The check's scope is the live property, proven by fixtures**, not argument: every fan-in case that is a by-name addressability defect (tests 1–3) is still rejected; the two render-identity-only collisions the old check also rejected (asymmetric param/paramless, role-vs-leg) are intentionally dropped — the first pinned by test 5 (now compiles), both documented in §What is deliberately dropped — not silently lost.