spec: 0032 param-namespace-injectivity (boss-signed)

param_space() injectivity becomes a stated, enforced compile invariant. One
structural check (check_param_namespace_injective) replaces the fan-in machinery
(check_fan_in_distinguishability, check_composite_fan_in, signature_of,
leaf_has_param) and the AmbiguousKnob binder branch. The error is path-carrying
(DuplicateParamPath) and the binders run the check before name resolution, so the
canonical by-name author sees the structural cure (.named(...)) instead of the
AmbiguousKnob symptom. param_space() stays infallible; the invariant lives in the
compile step (C23).

Two render-identity-only fan-in rejections (asymmetric param/paramless collision,
role-vs-leg signature collision) are intentionally dropped — neither is a
param_space duplicate (every knob keeps a unique path), and the property they
guarded (render distinguishability) has had no live consumer since the renderer
was retired in 0026. The larger is pinned by a now-compiles fixture.

Boss-signed under spec_auto_sign: objective gates green (precondition clean,
self-review clean, grounding-check PASS without override) + a unanimous five-lens
spec-skeptic panel. The grounding lens first BLOCKed a false set-equality claim;
the user ratified dropping both render-identity cases, the spec was reframed onto
the injectivity scope, re-grounded PASS, and the re-panel returned unanimous SOUND.

refs #59
This commit is contained in:
2026-06-11 18:06:28 +02:00
parent 11dfff860c
commit 7f059d0d94
@@ -0,0 +1,428 @@
# 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 (358379).** 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 (255270).** The 0015-era line "same-type siblings in one composite
share a name, uniqueness is at the slot" is now refined: 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. 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, compilat wired by raw index). The
check reads the boundary name projection; it does not make names load-bearing in
the compilat.
## 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 13), 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:
a param-bearing `Sma` (default name `"sma"`) and a **paramless** `Pass1`
`.named("sma")`, both on role `price`, fanning into one `Sub`. Equal node-name
signatures + one param → **rejected today** (`IndistinguishableFanIn`); after the
change `param_space()` is a single entry `["sma.length"]` (injective), so
`compile_with_params(&[Scalar::I64(2)])` is `Ok`. 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 13) 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.