"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.
22 KiB
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_distinguishabilityrejects the fan-in special case (two colliding param-bearing legs into one node) asIndistinguishableFanIn. 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 withBindError::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:
compile_with_params— at the top, wherecheck_fan_in_distinguishabilitysits today. Covers the positional compile path and makes injectivity a compile invariant (C23: the check is part of bootstrap-as-compilation).Binder::bootstrap— beforeresolve, so the canonical single-run by-name author sees the structural error, notAmbiguousKnob.SweepBinder::sweep— beforeresolve_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):
- Asymmetric param/paramless collision. A param-bearing leg and a paramless
leg forced to the same node name (e.g.
Smadefaulting to"sma"and a paramless node.named("sma")), fanning into one consumer. Equal signatures + one param → the old check rejects. Butparam_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. - 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_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 thesignature_of_is_node_name_plus_recursive_inputstest (with itsmacd_like_signature_fixturehelper if it has no other user). - Remove:
BindError::AmbiguousKnob(String)and both_ => AmbiguousKnobarms (→unreachable!). - Edit:
compile_with_paramshead;Binder::bootstrap;SweepBinder::sweep.
- Add:
crates/aura-engine/src/lib.rs- Remove:
signature_offrom the public re-export line (it ceases to exist).
- Remove:
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-resolvecallers 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 thatsignature_of,leaf_has_param, and the fan-in-specific check are retired (cycle 0032), the error is nowDuplicateParamPath(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).
- Migrate
unnamed_same_type_param_bearing_fan_in_is_rejected(~1255): assertSome(CompileError::DuplicateParamPath("sma_cross.sma.length".into()))instead ofIndistinguishableFanIn { node: 2 }. - Migrate
indistinguishable_fan_in_rejected(~1503): assertDuplicateParamPath("ambig.sma.length".into())(the duplicated path under the inner compositeambig) instead ofIndistinguishableFanIn { node: 2 }. - New
non_fan_in_duplicate_path_is_rejected: two unnamed same-type SMAs feeding separate consumers (no shared fan-in node) →compile()returnsDuplicateParamPath. 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. - Unchanged green
interchangeable_fan_in_allowed(~1531): two param-lessPass1into aJoin2→ stillcompile().is_ok(). Paramless nodes contribute noparam_space()path, so no duplicate; the C9 interchangeability survives. - 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_distinguishabilityonly descends into composites; a root-level fan-in is never checked), so it reuses test 2's nested shape: an inner compositeasymholding a param-bearingSma(default name"sma"), a paramlessPass1.named("sma"), and aSub, with both leaves on rolepricefanning into theSub;asymsits under a source-boundroot(Some(ScalarKind::F64)), exactly likesma_cross_under_root. Equal node-name signatures + one param → rejected today (IndistinguishableFanIn); after the changeparam_space()is the single entry["asym.sma.length"](injective — the paramless leg contributes no path), socompile_with_params(&[Scalar::I64(2)])isOk. The RED-green flip is the reverse of test 3 — what the old check rejected, the new one admits, by design. - 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()))), notAmbiguousKnob. This is the canonical-path regression the cycle exists to fix. - Remove
resolve_ambiguous_knob(~1009): the scenario it covered (a duplicate inspacepassed straight toresolve) is now structurally prevented upstream; callingresolveon a non-injective space would hit theunreachable!, so the test has no valid form and its coverage moves to tests 3 and 6. - Remove
signature_of_is_node_name_plus_recursive_inputs(~1270): the function it tests is deleted. - CLI goldens (
crates/aura-cli/tests/cli_run.rs,main.rsgolden tests): no change expected — the sample blueprint's legs are named (fast/slow), itsparam_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.
- Removes redundancy. One structural check replaces the fan-in machinery
(
check_fan_in_distinguishability,check_composite_fan_in,signature_of,leaf_has_param) and theAmbiguousKnobbinder branch — a net code removal, the same shape as cycle 0031. - 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 theAmbiguousKnobsymptom (test 6). - Injectivity is a stated compile invariant (C12/C19/C23 amended), with
param_space()still infallible. - No regression. Paramless interchangeable fan-in stays legal (test 4); all single-run, sweep, and CLI golden paths stay green; engine core untouched.
- 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.