21c1621bd0
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:
- prose mentions -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat` -> `render_flat_graph` (historical render symbol;
keeps the `render_blueprint` / `render_flat_graph`
source-vs-product pairing)
- `intra-compilat` -> `intra-graph`
- `from_compilat` (test) -> `from_flat`
"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
489 lines
19 KiB
Markdown
489 lines
19 KiB
Markdown
# 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::<Vec<_>>(),
|
|
["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<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)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Wire the check into `SweepBinder::sweep`**
|
|
|
|
In `blueprint.rs:335-344`, insert the check after `let space`:
|
|
|
|
```rust
|
|
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))
|
|
}
|
|
```
|
|
|
|
- [ ] **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 **flat graph** 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, flat graph wired by raw index). The check
|
|
reads the boundary name projection; it does not make names load-bearing in the
|
|
flat graph.
|
|
|
|
- [ ] **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).
|