Files
Aura/docs/specs/0032-param-namespace-injectivity.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

435 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
**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 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.
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 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.