feat(aura-engine): param_space() injectivity as a compile invariant

A non-injective param_space() — two slots under one path-qualified name — is
the by-name knob address space (C12/C19) being broken: no binding can select
one slot without the other. This cycle makes it a structural compile error.

One check, check_param_namespace_injective over the param_space() names,
replaces the whole fan-in machinery (signature_of, leaf_has_param,
check_fan_in_distinguishability, check_composite_fan_in) and runs from
compile_with_params AND from both binders before resolve/resolve_axes — so the
canonical by-name author sees the structural cure (CompileError::DuplicateParamPath,
carrying the path and pointing at .named(...)) instead of the AmbiguousKnob symptom
(#59). With an injective space guaranteed before resolve, no bound name can
multi-match, so BindError::AmbiguousKnob is dead and removed (both resolver arms ->
unreachable!). param_space() stays infallible; the invariant lives in the compile
step (C23).

The check is strictly the by-name-addressability property. The old fan-in predicate
(equal signature AND >=1 param) also rejected two collisions that are NOT
param_space duplicates — an asymmetric param/paramless collision and a role-vs-leg
collision — which guarded render identity, dead since the renderer was retired in
0026. Both are intentionally dropped; a future node-/wiring-name check, if wanted,
is decoupled from injectivity.

The new invariant correctly rejected a pre-existing fixture (multi_output_composite's
two unnamed Sma legs); cured in place with the .named(fast/slow) the invariant
mandates. A new E2E test drives the cured cross through the by-name binder
end-to-end (resolve + bootstrap + run to a populated exposure trace).

C9/C12/C19/C23 ledger notes amended. Verified: cargo build/test --workspace green
(0 failed), clippy --workspace --all-targets -D warnings clean.

closes #59
This commit is contained in:
2026-06-11 18:30:26 +02:00
parent 770cf7177d
commit 1b7e4ad169
3 changed files with 256 additions and 169 deletions
+42 -15
View File
@@ -264,8 +264,15 @@ only — the **search-range is the run's, not the node's** (which subset / grid
sweep covers is an experiment axis, #32/C20; the node declares the knob's existence
and type, never its search interval). (2) Identity is **positional** (the slot,
C23 "by index, not name"); the path-qualified `name` is a non-load-bearing debug
symbol (like `FieldSpec.name`) — same-type siblings in one composite share a name,
uniqueness is at the slot. A vector knob (`LinComb.weights`) expands to `N` flat
symbol (like `FieldSpec.name`) — uniqueness is at the slot. **Refinement (cycle
0032):** identity in the **compilat** stays positional (C23, unchanged), but the
`param_space()` **name projection** — the authoring / by-name address space (the
surface a sweep axis or single-run binding addresses) — must be **injective** for a
blueprint to compile (a duplicated path is unaddressable; see C9). Two layers:
positional wiring below (the compilat), an injective name address space above (the
authoring boundary). So same-type siblings in one composite no longer silently
share a name — they must be `.named(...)` apart, which is also what disambiguates a
same-type fan-in (C9). A vector knob (`LinComb.weights`) expands to `N` flat
`weights[i]` entries, `N` topology-fixed (C19). Permitted kinds are `i64`/`f64`/
`bool` (a `timestamp` knob is a structural axis, C20, never a numeric sweep param).
Binding a value to a slot landed in cycle 0016 (#31, see C19/C23); enumerating a
@@ -364,19 +371,30 @@ non-load-bearing** (C23): they live at the blueprint boundary and in render but
dropped at lowering — the compilat is wired by raw index. The full composite
boundary signature (named inputs, multi-outputs) and the per-node param path are
legible without changing the compilat.
**Refinement (fan-in distinguishability, 2026-06-08; node-name keying, cycle 0031).**
A fan-in node (>1 input) is well-formed only if its colliding sources — sources
with equal recursive **node-name** signatures (each node contributes its name —
the instance name, default = the lowercased type label — then its inputs'
signatures in slot order, stopping at a named source) — do not hide an unnamed
configuration axis: a collision is a `CompileError` (`IndistinguishableFanIn`)
when at least one colliding source carries a **param** (a param-bearing same-name
collision), resolved by giving the colliding nodes distinct names. Paramless
interchangeable same-name sources (equal signatures, no param) stay legal.
Construction-phase only; the compilat stays name-free (C23), and `signature_of`
has no render consumer (the ascii-dag fan-in render was retired with the renderer
in cycle 0026). Surfacing the disambiguating node identity in the WASM graph view
is tracked follow-up, not yet realized.
**Refinement (param-namespace injectivity, cycle 0032; supersedes the fan-in
distinguishability check).** A blueprint compiles only if its `param_space()` name
projection is **injective** — every path-qualified knob name is unique. A duplicated
path is a knob no binding can select alone (it has no distinct by-name address,
C12/C19), so it is a `CompileError::DuplicateParamPath` carrying the offending path;
the cure is to give the colliding same-type sibling nodes distinct names with
`.named(...)`, the same single act that qualifies their param paths. The
param-bearing indistinguishable fan-in is *one instance* of a duplicated path (two
default-named same-type legs share a leaf path). `signature_of`, `leaf_has_param`,
and the fan-in-specific check (`check_fan_in_distinguishability` /
`check_composite_fan_in`) are **retired** (cycle 0032), replaced by one structural
`check_param_namespace_injective` over the `param_space()` names, run before name
resolution in `compile_with_params` and both binders — so the canonical by-name
author sees the structural cure rather than a downstream `AmbiguousKnob` symptom
(that `BindError` arm is retired too: an injective space can never multi-match).
Paramless interchangeable same-name sources stay legal (no path, no duplicate).
The old signature-collision predicate had extra breadth — it also rejected an
**asymmetric param/paramless** collision and a **role-vs-leg** collision, *neither*
a path duplicate (each colliding configuration keeps a unique param path, or none).
That breadth guarded **render identity**, dead since the renderer was retired in
0026; both extra rejections are **intentionally dropped**. A future node-/wiring-name
distinguishability check, if ever wanted (e.g. for the WASM graph view's #21
thread), is decoupled from param-space injectivity. Construction-phase only; the
compilat stays name-free (C23).
### C10 — Strategy output is an intent/exposure stream; position management is a decoupled derived layer; brokers are downstream nodes
**Guarantee.** A strategy's primary, backtestable output is **not** an equity
@@ -861,6 +879,15 @@ hoisting) are **deferred**, behaviour-preserving follow-on work, each gated by a
sweep-level pass further presupposes per-node param declarations (C8 — landed in
cycle 0015 as `name`+`kind`; the swept *range* still pending #32/C20) and the sweep
orchestration itself (C12/C21).
**Amendment (cycle 0032 — param-namespace injectivity is part of the compilation).**
The bootstrap-as-compilation now includes one structural gate:
`check_param_namespace_injective` over the `param_space()` name projection (see C9 /
C12-C19), run before name resolution. It reads the **boundary** name projection — the
authoring address space — and rejects a duplicated path (`DuplicateParamPath`). Node
names stay **non-load-bearing**: they qualify the param path at construction and are
dropped at lowering (the compilat is wired by raw index, unchanged). The check makes
the by-name *authoring address space* injective; it does **not** make names
load-bearing in the compilat.
---