From 1b7e4ad1699b18b083f576fd967c073e3721a9cb Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 11 Jun 2026 18:30:26 +0200 Subject: [PATCH] feat(aura-engine): param_space() injectivity as a compile invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-engine/src/blueprint.rs | 366 ++++++++++++++++------------ crates/aura-engine/src/lib.rs | 2 +- docs/design/INDEX.md | 57 +++-- 3 files changed, 256 insertions(+), 169 deletions(-) diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index f24cc2f..b31baf1 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -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 { // 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> = 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 { 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)]) -> Result 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)]) -> Result 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` in `param_space()` slot /// order. (Body filled in Step 3.) fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result, BindError> { @@ -412,7 +428,7 @@ fn resolve(space: &[ParamSpec], bound: &[(String, Scalar)]) -> Result 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 `..` (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::>(), + ["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::>(); + assert!(!ex.is_empty(), "the cured, by-name-bound cross must run to a populated exposure trace"); + } } diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index e78b52f..d8c825c 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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; diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 70d100d..68ff404 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -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. ---