From 7f059d0d944ef8b10166ef9c796b943ee5dd1edb Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 11 Jun 2026 18:06:28 +0200 Subject: [PATCH] spec: 0032 param-namespace-injectivity (boss-signed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../specs/0032-param-namespace-injectivity.md | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/specs/0032-param-namespace-injectivity.md diff --git a/docs/specs/0032-param-namespace-injectivity.md b/docs/specs/0032-param-namespace-injectivity.md new file mode 100644 index 0000000..02e00d7 --- /dev/null +++ b/docs/specs/0032-param-namespace-injectivity.md @@ -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 +`.` 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 + **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 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: + 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 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.