7d04cb313a
Test 5's asymmetric-collision fixture asserted a root-level param_space
["sma.length"] while also being "test 2's fixture with one leg swapped" (nested)
— a contradiction: check_fan_in_distinguishability only descends into composites,
so a root-level collision is not rejected today (not RED-first). Caught at plan
time while writing the verbatim fixture body.
Corrected: test 5 nests the asymmetric collision in an inner composite `asym`
(param-bearing Sma "sma" + paramless Pass1 .named("sma") + Sub, both leaves on
role price fanning into the Sub) under a source-bound root, asserting
param_space() == ["asym.sma.length"] (the single injective entry — the paramless
leg contributes no path) and that compile_with_params(&[I64(2)]) is Ok after.
Re-grounded PASS, re-panel unanimous SOUND. Forward correction on top of 7f059d0
(main is forward-only — no rewind of the boss-signed spec).
refs #59
435 lines
22 KiB
Markdown
435 lines
22 KiB
Markdown
# 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 (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.
|
||
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 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.
|