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

22 KiB
Raw Blame History

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:

// 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:

// 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)

/// 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

// 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 ...
}
// 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

// BEFORE
/// A fan-in node (>1 input) at interior index `node` ... share a rendered identity.
IndistinguishableFanIn { node: usize },
// 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

// 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
}
// 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

// 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)
}
// 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)
}
// 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_paramscompile_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.