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
+213 -153
View File
@@ -188,7 +188,8 @@ impl Composite {
/// inline composites, rewrite edges, lower bound roles to flat sources).
pub fn compile_with_params(self, params: &[Scalar]) -> Result<FlatGraph, CompileError> {
// structural validation, all pre-build (no node constructed):
check_fan_in_distinguishability(&self.nodes)?;
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() {
@@ -196,9 +197,8 @@ impl Composite {
}
}
let expected = self.param_space().len();
if params.len() != expected {
return Err(CompileError::ParamArity { expected, got: params.len() });
if params.len() != space.len() {
return Err(CompileError::ParamArity { expected: space.len(), got: params.len() });
}
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
@@ -284,8 +284,6 @@ pub enum BindError {
KindMismatch { knob: String, expected: ScalarKind, got: ScalarKind },
/// The same name was bound twice.
DuplicateBinding(String),
/// A name matches the exact name of more than one slot.
AmbiguousKnob(String),
/// (sweep, iteration 2) An axis was given zero values.
EmptyAxis(String),
/// The resolved point passed name resolution but failed downstream bootstrap.
@@ -310,6 +308,7 @@ impl Binder {
/// Resolve the accumulated bindings against `param_space()` and bootstrap.
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)
}
@@ -337,6 +336,7 @@ impl SweepBinder {
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");
@@ -367,7 +367,7 @@ fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<V
}
claimed[*idx] = Some(values.clone());
}
_ => return Err(BindError::AmbiguousKnob(name.clone())), // b
_ => unreachable!("param_space() is injective — checked before resolve"),
}
}
// Phase 2 — slot walk in param_space() order: completeness (e) + per-element kind (f).
@@ -392,6 +392,22 @@ fn resolve_axes(space: &[ParamSpec], axes: &[(String, Vec<Scalar>)]) -> Result<V
Ok(ordered)
}
/// 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(())
}
/// Resolve named bindings to a positional `Vec<Scalar>` in `param_space()` slot
/// order. (Body filled in Step 3.)
fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar>, BindError> {
@@ -412,7 +428,7 @@ fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result<Vec<Scalar
}
claimed[*idx] = Some(*value);
}
_ => return Err(BindError::AmbiguousKnob(name.clone())), // b
_ => unreachable!("param_space() is injective — checked before resolve"),
}
}
// Phase 2 — slot walk in param_space() order (e, f).
@@ -453,11 +469,11 @@ pub enum CompileError {
ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind },
/// The injected vector's length does not equal the sum of declared params.
ParamArity { expected: usize, got: usize },
/// A fan-in node (>1 input) at interior index `node` has two input slots fed by
/// sources with identical signatures where at least one source carries an
/// unaliased param slot — the inputs differ in configuration but share a
/// rendered identity. Name the distinguishing param (e.g. fast/slow).
IndistinguishableFanIn { node: usize },
/// 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),
/// A root input role `role` has no bound source (`source: None`) — an open port
/// at the root, which has no enclosing graph to wire it. Only a fully source-
/// bound composite is runnable.
@@ -521,99 +537,6 @@ fn validate_wiring(
Ok(())
}
/// The recursive authoring signature of interior node `node`: the node name (the
/// instance name, default = the lowercased type label), then each wired input's
/// signature in slot order — recursing into interior-leaf sources, stopping at a
/// named source (role name / nested-composite name). Single source of truth for
/// the fan-in distinguishability check (collision = equal signatures) and the CLI
/// render (shortest sibling-unique prefix). Terminates: the dataflow is a DAG (C5)
/// and the descent stops at named ports.
pub fn signature_of(nodes: &[BlueprintNode], edges: &[Edge], roles: &[Role], node: usize) -> String {
match &nodes[node] {
// a nested composite is a named source: its name, no descent.
BlueprintNode::Composite(inner) => inner.name().to_string(),
BlueprintNode::Primitive(f) => {
let mut s = f.node_name();
// wired input slots in slot order; per slot, the source's signature
// (interior edge -> recurse; role -> the role name, no descent)
let mut slotted: Vec<(usize, String)> = Vec::new();
for e in edges.iter().filter(|e| e.to == node) {
slotted.push((e.slot, signature_of(nodes, edges, roles, e.from)));
}
for r in roles {
for t in r.targets.iter().filter(|t| t.node == node) {
slotted.push((t.slot, r.name.clone()));
}
}
slotted.sort_by_key(|(slot, _)| *slot);
for (_, sig) in slotted {
s.push_str(&sig);
}
s
}
}
}
/// Structural validation (param-value-independent): walk every composite in the
/// blueprint and reject an indistinguishable fan-in. Recurses into nested
/// composites so the check holds at every level. Runs before the arity gate (a
/// structural fault does not depend on injected param values, spec §structural).
fn check_fan_in_distinguishability(items: &[BlueprintNode]) -> Result<(), CompileError> {
for item in items {
if let BlueprintNode::Composite(c) = item {
check_composite_fan_in(c.nodes(), c.edges(), c.input_roles())?;
check_fan_in_distinguishability(c.nodes())?;
}
}
Ok(())
}
/// The per-composite fan-in distinguishability check on the destructured pieces:
/// for each interior node with >1 wired input slot, a collision (equal source
/// signatures, now keyed on the node name) is a fault only when at least one
/// colliding source carries a param — the unnamed configuration axis resolved by
/// giving the colliding nodes distinct names. A role contributes its name and has
/// no param of its own.
fn check_composite_fan_in(
nodes: &[BlueprintNode],
edges: &[Edge],
input_roles: &[Role],
) -> Result<(), CompileError> {
for node in 0..nodes.len() {
let mut sources: Vec<(usize, String, bool)> = Vec::new(); // (slot, sig, has_param)
for e in edges.iter().filter(|e| e.to == node) {
sources.push((
e.slot,
signature_of(nodes, edges, input_roles, e.from),
leaf_has_param(nodes, e.from),
));
}
for r in input_roles {
for t in r.targets.iter().filter(|t| t.node == node) {
sources.push((t.slot, r.name.clone(), false));
}
}
if sources.len() < 2 {
continue;
}
for i in 0..sources.len() {
for j in (i + 1)..sources.len() {
if sources[i].1 == sources[j].1 && (sources[i].2 || sources[j].2) {
return Err(CompileError::IndistinguishableFanIn { node });
}
}
}
}
Ok(())
}
/// Whether interior leaf `node` has at least one param slot — the configuration
/// axis the fan-in rule keys on once the node name no longer distinguishes the
/// colliding sources. A non-leaf (nested composite) reports `false`.
fn leaf_has_param(nodes: &[BlueprintNode], node: usize) -> bool {
matches!(&nodes[node], BlueprintNode::Primitive(b) if !b.params().is_empty())
}
/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its
/// declared params under `<prefix>.<node-name>.<param>` (the node name is the
/// instance name, default = the lowercased type label); a composite pushes its
@@ -1005,19 +928,6 @@ mod tests {
);
}
#[test]
fn resolve_ambiguous_knob() {
// two slots with the identical exact name
let space = vec![
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
];
assert_eq!(
resolve(&space, &[("dup".to_string(), Scalar::I64(1))]),
Err(BindError::AmbiguousKnob("dup".to_string())),
);
}
#[test]
fn resolve_precedence_unknown_before_kind_mismatch() {
// Phase-1 (unknown) wins over Phase-2 (kind mismatch)
@@ -1255,7 +1165,10 @@ mod tests {
fn unnamed_same_type_param_bearing_fan_in_is_rejected() {
// both SMAs default to "sma" -> collide -> fan-in indistinguishable
let bp = sma_cross_under_root(false);
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
assert_eq!(
bp.compile().err(),
Some(CompileError::DuplicateParamPath("sma_cross.sma.length".to_string()))
);
}
#[test]
@@ -1266,37 +1179,6 @@ mod tests {
assert!(bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(4)]).is_ok());
}
#[test]
fn signature_of_is_node_name_plus_recursive_inputs() {
// EMA named "fast" fed by role price -> "fast" + "price" (role, no descent)
let c = macd_like_signature_fixture(); // built below
let sig = |n| signature_of(c.nodes(), c.edges(), c.input_roles(), n);
// node 0 = EMA named "fast", fed by role "price"
assert_eq!(sig(0), "fastprice");
// node 2 = Sub (default name "sub") over node0/node1; node1 = EMA "slow"
assert_eq!(sig(2), "subfastpriceslowprice");
}
/// A composite: two named EMAs (fast, slow) on role `price`, into a Sub.
fn macd_like_signature_fixture() -> Composite {
Composite::new(
"sig",
vec![
Ema::builder().named("fast").into(),
Ema::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![OutField { node: 2, field: 0, name: "x".into() }],
)
}
#[test]
fn single_composite_inlines_with_offset_fan_and_output() {
// composite as item 0; a source into its role 0; an edge out of it to a sink.
@@ -1524,7 +1406,10 @@ mod tests {
],
vec![], // output
);
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
assert_eq!(
bp.compile().err(),
Some(CompileError::DuplicateParamPath("ambig.sma.length".to_string()))
);
}
#[test]
@@ -1805,7 +1690,7 @@ mod tests {
// fields by from_field.
let c = Composite::new(
"two_sma",
vec![Sma::builder().into(), Sma::builder().into()],
vec![Sma::builder().named("fast").into(), Sma::builder().named("slow").into()],
vec![],
vec![
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
@@ -2218,4 +2103,179 @@ mod tests {
assert_eq!(Sma::new(3).lookbacks().len(), Sma::builder().schema().inputs.len());
assert_eq!(Add::new().lookbacks().len(), Add::builder().schema().inputs.len());
}
#[test]
fn non_fan_in_duplicate_path_is_rejected() {
use aura_std::Sma;
// two unnamed SMAs ("sma" each), each feeding its OWN sink — no shared
// fan-in node, so the old fan-in check never fired; but param_space() has
// the path "dup.sma.length" twice.
let dup = Composite::new(
"dup",
vec![
Sma::builder().into(), // node 0 -> dup.sma.length
Sma::builder().into(), // node 1 -> dup.sma.length (duplicate)
sink_f64(), // node 2
sink_f64(), // node 3
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 3, slot: 0, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(dup)],
vec![],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
// two params declared (one per SMA); supply both so the only failure under
// test is the duplicate path (green today, DuplicateParamPath after).
assert_eq!(
bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(3)]).err(),
Some(CompileError::DuplicateParamPath("dup.sma.length".to_string()))
);
}
#[test]
fn asymmetric_node_name_collision_compiles() {
use aura_std::{Sma, Sub};
// inner composite `asym`: a param-bearing Sma (default "sma") + a paramless
// Pass1 forced to "sma", both on role price fanning into a Sub. Equal
// node-name signatures + one param -> rejected today (IndistinguishableFanIn);
// param_space() is the single injective entry ["asym.sma.length"] (the
// paramless leg contributes no path) -> admitted after the change. Pass1 is
// built inline (the pass1() helper returns an already-.into()'d node, so it
// cannot take .named).
let paramless_sma = PrimitiveBuilder::new(
"Pass1",
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|_| Box::new(Pass1 { out: [Scalar::F64(0.0)] }),
)
.named("sma");
let asym = Composite::new(
"asym",
vec![Sma::builder().into(), paramless_sma.into(), Sub::builder().into()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(asym)],
vec![],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
assert_eq!(
bp.param_space().into_iter().map(|p| p.name).collect::<Vec<_>>(),
["asym.sma.length"]
);
assert!(bp.compile_with_params(&[Scalar::I64(2)]).is_ok());
}
#[test]
fn by_name_bootstrap_of_unnamed_cross_reports_duplicate_path() {
// the canonical by-name flow on an unnamed cross: the binder runs the
// injectivity check before name resolution, so the author sees the
// structural DuplicateParamPath (carrying the .named(...) cure) instead of
// AmbiguousKnob.
let bp = sma_cross_under_root(false);
assert_eq!(
bp.with("sma_cross.sma.length", 2).bootstrap().err(),
Some(BindError::Compile(CompileError::DuplicateParamPath(
"sma_cross.sma.length".to_string()
)))
);
}
/// E2E (cycle 0032): the whole collision → cure → run arc on the canonical
/// by-name flow. The same un-named SMA cross that `bootstrap` rejects as
/// `DuplicateParamPath` becomes runnable the moment its two legs are `.named()`
/// apart — the cure the error itself prescribes. Naming makes `param_space()`
/// injective, the by-name `.with(...).bootstrap()` terminal resolves the two
/// now-distinct paths, and the harness runs to a populated, non-degenerate
/// exposure trace. A regression that made injectivity gate the run (rather than
/// only the duplicate) — or that broke the by-name resolution of the cured
/// space — would surface here as a bootstrap error or an empty trace, not just a
/// bad `compile()` Err.
#[test]
fn named_cross_resolves_by_name_and_runs_to_a_trace() {
// root { sma_cross[ fast/slow SMA -> Sub ] -> Exposure -> SimBroker -> rec },
// plus an exposure tap, mirroring composite_sma_cross_harness but driven
// through the by-name binder so the injective cured space is exercised
// end-to-end (resolve + bootstrap + run), not just at compile().
let prices = synthetic_prices();
let (tx_ex, rx_ex) = mpsc::channel();
let cross = Composite::new(
"sma_cross",
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![
BlueprintNode::Composite(cross),
Exposure::builder().into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // cross out -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> recorder
],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
);
// the un-named twin of this blueprint is rejected (see
// by_name_bootstrap_of_unnamed_cross_reports_duplicate_path); the named one
// resolves its two distinct paths by name and bootstraps.
let mut h = bp
.with("sma_cross.fast.length", 2)
.with("sma_cross.slow.length", 4)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("named (injective) cross resolves by name and bootstraps");
h.run(vec![prices]);
let ex = rx_ex.try_iter().collect::<Vec<_>>();
assert!(!ex.is_empty(), "the cured, by-name-bound cross must run to a populated exposure trace");
}
}
+1 -1
View File
@@ -38,7 +38,7 @@ mod report;
mod sweep;
pub use blueprint::{
signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
SweepBinder,
};
pub use graph_model::model_to_json;
+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.
---