From 770cf7177de0153d1393426d9a34989b7afa26da Mon Sep 17 00:00:00 2001 From: Brummel Date: Thu, 11 Jun 2026 18:20:44 +0200 Subject: [PATCH] plan: 0032 param-namespace-injectivity Three tasks for the single-iteration cycle: - Task 1 (RED): add CompileError::DuplicateParamPath + the three new tests (non-fan-in duplicate, asymmetric nested collision, by-name #59 regression), verified failing against current code. - Task 2 (GREEN): add check_param_namespace_injective, wire it into compile_with_params + both binders (before resolve), remove the fan-in machinery (signature_of/leaf_has_param/the two check fns) + IndistinguishableFanIn + AmbiguousKnob (both resolver arms -> unreachable!) + the lib.rs re-export, migrate tests 1/2, remove tests 7/8 + macd fixture. One cargo build --workspace gate over the whole enum-coupled change. - Task 3: amend the C9/C12/C19/C23 ledger notes. refs #59 --- .../plans/0032-param-namespace-injectivity.md | 488 ++++++++++++++++++ 1 file changed, 488 insertions(+) create mode 100644 docs/plans/0032-param-namespace-injectivity.md diff --git a/docs/plans/0032-param-namespace-injectivity.md b/docs/plans/0032-param-namespace-injectivity.md new file mode 100644 index 0000000..b29e0ed --- /dev/null +++ b/docs/plans/0032-param-namespace-injectivity.md @@ -0,0 +1,488 @@ +# Param-Namespace Injectivity — Implementation Plan + +> **Parent spec:** `docs/specs/0032-param-namespace-injectivity.md` +> +> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run +> this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Make `param_space()` injectivity a single enforced compile invariant +(`check_param_namespace_injective` → `CompileError::DuplicateParamPath`), retire +the fan-in machinery and `BindError::AmbiguousKnob` it subsumes, and run the check +before name resolution so the by-name author sees the structural cure. + +**Architecture:** One structural check over the `param_space()` names replaces +`check_fan_in_distinguishability` and is called from `compile_with_params` plus +both binders (before `resolve`/`resolve_axes`). The fan-in machinery +(`signature_of`, `leaf_has_param`, the two check fns) and `AmbiguousKnob` (now dead, +since an injective space can never multi-match) are removed wholesale. The +removal+rewire is enum/signature-coupled, so it lands as one `cargo build +--workspace`-gated task after the new RED tests are in place. + +**Tech Stack:** `crates/aura-engine/src/blueprint.rs` (the whole change), +`crates/aura-engine/src/lib.rs` (one re-export line), `docs/design/INDEX.md` +(C9/C12/C19/C23 amendments). Engine core untouched. + +--- + +**Files this plan creates or modifies:** + +- Modify: `crates/aura-engine/src/blueprint.rs` — the check, the variant swap, the + machinery removal, the two resolver arms, the three call sites, the test + migrations/removals/additions. +- Modify: `crates/aura-engine/src/lib.rs:41` — drop `signature_of` from the + `pub use blueprint::{…}` re-export. +- Modify: `docs/design/INDEX.md` — C9 refinement (367-379), C12/C19 note (265-268), + C23 amendment. +- Test: `crates/aura-engine/src/blueprint.rs` — add tests 3/5/6 (RED-first), + migrate tests 1/2, remove tests 7/8 + the `macd_like_signature_fixture` helper. + +--- + +### Task 1: RED — add the new variant and the three failing tests + +Additive only (nothing removed, nothing wired): the new `DuplicateParamPath` +variant so the tests compile, and the three new tests asserting the post-change +behaviour. They must FAIL against current code. + +**Files:** +- Modify: `crates/aura-engine/src/blueprint.rs:440-465` (CompileError enum — add one variant) +- Test: `crates/aura-engine/src/blueprint.rs` (tests module — add 3 tests) + +- [ ] **Step 1: Add the `DuplicateParamPath` variant to `CompileError`** + +In `crates/aura-engine/src/blueprint.rs`, inside `enum CompileError` (derives +`#[derive(Debug, PartialEq, Eq)]` at line 440), add this variant immediately after +the existing `IndistinguishableFanIn { node: usize },` (line 460). Do NOT remove +`IndistinguishableFanIn` yet (Task 2 removes it): + +```rust + /// 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), +``` + +- [ ] **Step 2: Add test 3 (`non_fan_in_duplicate_path_is_rejected`)** + +Append to the `#[cfg(test)] mod tests` block in `blueprint.rs`: + +```rust + #[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())) + ); + } +``` + +- [ ] **Step 3: Add test 5 (`asymmetric_node_name_collision_compiles`)** + +Append to the tests block: + +```rust + #[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()); + } +``` + +- [ ] **Step 4: Add test 6 (`by_name_bootstrap_of_unnamed_cross_reports_duplicate_path`)** + +Append to the tests block: + +```rust + #[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() + ))) + ); + } +``` + +- [ ] **Step 5: Run the three new tests to verify they FAIL (RED)** + +Run (one filter per invocation — multi-filter prints nothing here): + +`cargo test --workspace non_fan_in_duplicate_path_is_rejected` +Expected: FAIL — `left: None` (compiles green today), `right: Some(DuplicateParamPath("dup.sma.length"))`. + +`cargo test --workspace asymmetric_node_name_collision_compiles` +Expected: FAIL — `assertion failed: bp.compile_with_params(&[Scalar::I64(2)]).is_ok()` (the asym fan-in is rejected as `IndistinguishableFanIn` today). + +`cargo test --workspace by_name_bootstrap_of_unnamed_cross_reports_duplicate_path` +Expected: FAIL — `left: Some(AmbiguousKnob("sma_cross.sma.length"))`, `right: Some(Compile(DuplicateParamPath("sma_cross.sma.length")))`. + +(All three compile — `DuplicateParamPath` exists from Step 1; `IndistinguishableFanIn` and `AmbiguousKnob` still exist, removed in Task 2.) + +--- + +### Task 2: GREEN — add the check, wire it, remove the fan-in machinery + AmbiguousKnob, migrate/remove tests + +Enum/signature-coupled: every removal and rewire lands together under one +`cargo build --workspace` gate so the build never sees a half-removed enum. + +**Files:** +- Modify: `crates/aura-engine/src/blueprint.rs` (check fn; head ~189; binders ~311/~335; resolvers ~370/~415; CompileError ~460; BindError ~287; remove fns 524-615; migrate tests 1258/1527; remove tests 1008-1019, 1269-1298) +- Modify: `crates/aura-engine/src/lib.rs:41` + +- [ ] **Step 1: Add `check_param_namespace_injective`** + +Add this free function in `blueprint.rs` near the other module-level `check_*` / +`fn resolve` helpers (e.g. just above `fn resolve`): + +```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(()) +} +``` + +- [ ] **Step 2: Rewire the `compile_with_params` head** + +In `blueprint.rs:189-202`, replace the head (the `check_fan_in_distinguishability` +call and the later separate `param_space()` arity computation) so `param_space()` +is computed once and the injectivity check replaces the fan-in check. Change: + +```rust + // structural validation, all pre-build (no node constructed): + 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() }); + } +``` + +to: + +```rust + // structural validation, all pre-build (no node constructed): + 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() }); + } +``` + +- [ ] **Step 3: Wire the check into `Binder::bootstrap`** + +In `blueprint.rs:311-315`, insert the check after `let space`: + +```rust + 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) + } +``` + +- [ ] **Step 4: Wire the check into `SweepBinder::sweep`** + +In `blueprint.rs:335-344`, insert the check after `let space`: + +```rust + pub fn sweep(self, run_one: F) -> Result + 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)) + } +``` + +- [ ] **Step 5: Replace both `_ => AmbiguousKnob` resolver arms with `unreachable!`** + +In `resolve_axes` (the arm at `blueprint.rs:370`) and `resolve` (the arm at +`blueprint.rs:415`), replace: + +```rust + _ => return Err(BindError::AmbiguousKnob(name.clone())), // b +``` + +with (both arms, identical): + +```rust + _ => unreachable!("param_space() is injective — checked before resolve"), +``` + +- [ ] **Step 6: Swap the `CompileError` variant** + +In `blueprint.rs:456-460`, remove the `IndistinguishableFanIn` variant and its doc +comment: + +```rust + /// 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 }, +``` + +(The `DuplicateParamPath(String)` variant added in Task 1 Step 1 stays.) + +- [ ] **Step 7: Remove the `BindError::AmbiguousKnob` variant** + +In `blueprint.rs:287-288`, remove: + +```rust + /// A name matches the exact name of more than one slot. + AmbiguousKnob(String), +``` + +- [ ] **Step 8: Remove the fan-in machinery** + +Delete these four functions wholesale (with their doc comments): +`signature_of` (`blueprint.rs:524-555`), `check_fan_in_distinguishability` +(`557-569`), `check_composite_fan_in` (`571-608`), `leaf_has_param` (`610-615`). + +- [ ] **Step 9: Drop `signature_of` from the `lib.rs` re-export** + +In `crates/aura-engine/src/lib.rs:41`, remove the leading `signature_of, ` so the +line: + +```rust + signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role, +``` + +becomes: + +```rust + BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role, +``` + +- [ ] **Step 10: Migrate test 1 (`unnamed_same_type_param_bearing_fan_in_is_rejected`)** + +In `blueprint.rs:1258`, replace: + +```rust + assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 })); +``` + +with: + +```rust + assert_eq!( + bp.compile().err(), + Some(CompileError::DuplicateParamPath("sma_cross.sma.length".to_string())) + ); +``` + +- [ ] **Step 11: Migrate test 2 (`indistinguishable_fan_in_rejected`)** + +In `blueprint.rs:1527`, replace: + +```rust + assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 })); +``` + +with: + +```rust + assert_eq!( + bp.compile().err(), + Some(CompileError::DuplicateParamPath("ambig.sma.length".to_string())) + ); +``` + +- [ ] **Step 12: Remove the obsolete tests + helper** + +Delete `resolve_ambiguous_knob` (`blueprint.rs:1008-1019`), +`signature_of_is_node_name_plus_recursive_inputs` (`1269-1278`), and +`macd_like_signature_fixture` (`1280-1298` — its only user was the removed test; +verified by recon). + +- [ ] **Step 13: Build the workspace (the coupling gate)** + +Run: `cargo build --workspace` +Expected: success, 0 errors. (Every reference to the removed `IndistinguishableFanIn` +/ `AmbiguousKnob` / `signature_of` / `check_*` / `leaf_has_param` is gone in the +same task; the build sees no half-removed enum.) + +- [ ] **Step 14: Run the three Task-1 tests to verify they now PASS (GREEN)** + +`cargo test --workspace non_fan_in_duplicate_path_is_rejected` → PASS +`cargo test --workspace asymmetric_node_name_collision_compiles` → PASS +`cargo test --workspace by_name_bootstrap_of_unnamed_cross_reports_duplicate_path` → PASS + +- [ ] **Step 15: Run the migrated tests to verify they PASS** + +`cargo test --workspace unnamed_same_type_param_bearing_fan_in_is_rejected` → PASS +`cargo test --workspace indistinguishable_fan_in_rejected` → PASS +`cargo test --workspace interchangeable_fan_in_allowed` → PASS (unchanged; the +paramless interchangeable fan-in stays legal — no param path, no duplicate) + +- [ ] **Step 16: Full workspace gate** + +Run: `cargo test --workspace` +Expected: PASS, 0 failed. (CLI sweep/run goldens stay green — the sample legs are +named fast/slow, so its `param_space()` is injective and the bound paths are +unchanged.) + +Run: `cargo clippy --workspace --all-targets -- -D warnings` +Expected: clean, 0 warnings. (`check_param_namespace_injective` is now wired into +three call sites — no dead_code.) + +--- + +### Task 3: Ledger amendments (`docs/design/INDEX.md`) + +Prose-only; reframe the contracts the cycle touches per spec §Error handling / +Contract amendments. No compile gate. + +**Files:** +- Modify: `docs/design/INDEX.md` (C9 refinement 367-379; C12/C19 note 265-268; C23) + +- [ ] **Step 1: Amend the C9 fan-in refinement (367-379)** + +Reframe the "Refinement (fan-in distinguishability …)" paragraph onto the +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); paramless interchangeable +same-name sources stay legal (no path, no duplicate). State 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**, dead since the renderer was retired in +0026; both are intentionally dropped, and a future node-/wiring-name +distinguishability check, if ever wanted, is decoupled from param-space injectivity. + +- [ ] **Step 2: Refine the C12/C19 positional-identity note (265-268)** + +At the sentence "Identity is **positional** (the slot, C23 'by index, not name'); +… same-type siblings in one composite share a name, uniqueness is at the slot", +add the refinement: 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 (cycle 0032). Two layers: +positional wiring below, injective name address space above. + +- [ ] **Step 3: Note the C23 amendment** + +Record that 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. + +- [ ] **Step 4: Verify the ledger still builds as docs (sanity)** + +Run: `cargo doc --workspace --no-deps 2>&1` +Expected: success (no doctest breakage; the ledger is prose, but confirm the +workspace doc build is unaffected).