# 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
`<node>.<param>` 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<ParamSpec>`). 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<FlatGraph, CompileError> {
    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<FlatGraph, CompileError> {
    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<Harness, BindError> {
    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<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)
}
```

```rust
// AFTER — SweepBinder::sweep (~335), same shape before resolve_axes:
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))
}
```

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.
