plan: 0021 fan-in distinguishability
Four tasks: shared recursive signature_of helper (aura-engine); CLI source-derived fan-in identifiers + sma_cross aliases + render goldens (aura-cli); the IndistinguishableFanIn construction constraint + engine fixture aliases (aura-engine); the C9 ledger refinement. Ordered so the workspace test suite stays green at every task boundary. Refs #44
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
# Fan-in input distinguishability — Implementation Plan
|
||||
|
||||
> **Parent spec:** `docs/specs/0021-fan-in-distinguishability.md`
|
||||
>
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run
|
||||
> this plan. Steps use `- [ ]` checkboxes for tracking.
|
||||
|
||||
**Goal:** Make a fan-in node whose colliding sources hide an unnamed
|
||||
configuration axis illegal at construction, and render fan-in inputs as
|
||||
source-derived recursive-signature identifiers instead of positional `#A/#B`.
|
||||
|
||||
**Architecture:** A shared `signature_of(&Composite, node)` helper in
|
||||
aura-engine is the single source of truth for a node's recursive authoring
|
||||
identity. The CLI render (`leaf_label`) abbreviates it to the shortest
|
||||
sibling-unique prefix; the engine construction check (`inline_composite`)
|
||||
rejects a fan-in where two sources share a signature and at least one has an
|
||||
unaliased param. Task order keeps the whole workspace green at every boundary:
|
||||
helper → CLI render+fixture → engine constraint+fixtures → ledger.
|
||||
|
||||
**Tech Stack:** aura-engine (`blueprint.rs`), aura-cli (`graph.rs`, `main.rs`),
|
||||
aura-core (`LeafFactory`), `docs/design/INDEX.md`.
|
||||
|
||||
**Files this plan creates or modifies:**
|
||||
|
||||
- Modify: `crates/aura-engine/src/blueprint.rs` — `signature_of` helper (Task 1);
|
||||
`CompileError::IndistinguishableFanIn` + `inline_composite` check + engine
|
||||
fixture aliases (Task 3).
|
||||
- Modify: `crates/aura-engine/src/lib.rs` — export `signature_of`.
|
||||
- Modify: `crates/aura-cli/src/graph.rs:160-195` — `leaf_label` / new
|
||||
`fan_in_identifiers` (Task 2).
|
||||
- Modify: `crates/aura-cli/src/main.rs:122-137` — `sma_cross` aliases; render
|
||||
test/golden/needle updates (Task 2).
|
||||
- Modify: `docs/design/INDEX.md` — C9 contract refinement (Task 4).
|
||||
- Test: engine signature unit tests, constraint accept/reject tests; CLI render
|
||||
tests + recaptured goldens.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Engine — shared recursive `signature_of` helper
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-engine/src/blueprint.rs`
|
||||
- Modify: `crates/aura-engine/src/lib.rs`
|
||||
- Test: `crates/aura-engine/src/blueprint.rs` (test module)
|
||||
|
||||
Pure addition — no behaviour change, nothing breaks. Establishes the single
|
||||
source of truth for a node's signature used by both later tasks.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
In the `blueprint.rs` `#[cfg(test)]` module, add (the fixtures `sma_cross()` at
|
||||
`:919` and `fan_composite()` at `:581` already exist; `Ema`/`Sma`/`Sub`
|
||||
factories are imported there):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn signature_of_is_type_initial_plus_aliases_plus_recursive_inputs() {
|
||||
// EMA(fast) fed by role price -> "E" + "f"(alias) + "p"(role, no descent)
|
||||
let c = macd_like_signature_fixture(); // built below
|
||||
let sig = |n| signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), n);
|
||||
// node 0 = Ema aliased "fast", fed by role "price"
|
||||
assert_eq!(sig(0), "Efp");
|
||||
// node 2 = Sub(node0, node1) where node1 = Ema aliased "slow" -> "S"+inputs
|
||||
assert_eq!(sig(2), "SEfpEsp");
|
||||
}
|
||||
|
||||
/// A composite: two aliased EMAs (fast, slow) on role `price`, into a Sub.
|
||||
fn macd_like_signature_fixture() -> Composite {
|
||||
Composite::new(
|
||||
"sig",
|
||||
vec![Ema::factory().into(), Ema::factory().into(), Sub::factory().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 }],
|
||||
}],
|
||||
vec![
|
||||
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
|
||||
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
|
||||
],
|
||||
vec![OutField { node: 2, field: 0, name: "x".into() }],
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `cargo test -p aura-engine signature_of_is_type_initial`
|
||||
Expected: FAIL — `cannot find function signature_of in this scope`.
|
||||
|
||||
- [ ] **Step 3: Write the helper**
|
||||
|
||||
In `blueprint.rs` (module-level `pub fn`, near `param_space`/`collect_params`):
|
||||
|
||||
Operate on the destructured pieces (not `&Composite`), so the engine check can
|
||||
run after `inline_composite`'s destructure and the CLI can call it via the
|
||||
composite accessors. Add near `param_space`/`collect_params`:
|
||||
|
||||
```rust
|
||||
/// The recursive authoring signature of interior node `node`: the type initial,
|
||||
/// then one initial per declared param alias (declared order), 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],
|
||||
aliases: &[ParamAlias],
|
||||
node: usize,
|
||||
) -> String {
|
||||
let mut s = String::new();
|
||||
match &nodes[node] {
|
||||
BlueprintNode::Leaf(f) => {
|
||||
if let Some(ch) = f.label().chars().next() {
|
||||
s.push(ch);
|
||||
}
|
||||
for a in aliases.iter().filter(|a| a.node == node) {
|
||||
if let Some(ch) = a.name.chars().next() {
|
||||
s.push(ch);
|
||||
}
|
||||
}
|
||||
// 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, aliases, 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);
|
||||
}
|
||||
}
|
||||
BlueprintNode::Composite(inner) => {
|
||||
if let Some(ch) = inner.name().chars().next() {
|
||||
s.push(ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `cargo test -p aura-engine signature_of_is_type_initial`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Export the helper**
|
||||
|
||||
In `crates/aura-engine/src/lib.rs`, add `signature_of` to the `pub use
|
||||
blueprint::{...}` list (alongside `CompileError`, `Composite`).
|
||||
|
||||
- [ ] **Step 6: Workspace still green**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: PASS (pure addition; no existing behaviour changed).
|
||||
|
||||
---
|
||||
|
||||
## Task 2: CLI — source-derived render identifiers + `sma_cross` aliases
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-cli/src/graph.rs:160-195`
|
||||
- Modify: `crates/aura-cli/src/main.rs:122-137` and render tests
|
||||
- Test: `crates/aura-cli/src/main.rs`
|
||||
|
||||
The engine has no constraint yet, so an aliased CLI `sma_cross` renders fine and
|
||||
the goldens move once. After this task the whole CLI suite is green and the
|
||||
workspace stays green.
|
||||
|
||||
- [ ] **Step 1: Add `fast`/`slow` aliases to the CLI `sma_cross` fixture**
|
||||
|
||||
In `main.rs:122` `fn sma_cross`, the empty params vec (`:134`) becomes:
|
||||
|
||||
```rust
|
||||
vec![
|
||||
ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast SMA length
|
||||
ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow SMA length
|
||||
],
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the positional stub block in `leaf_label`**
|
||||
|
||||
In `graph.rs`, replace the stub-construction block (`:182-186`):
|
||||
|
||||
```rust
|
||||
let stubs: Vec<String> = if slots.len() > 1 {
|
||||
slots.iter().map(|s| format!("#{}", (b'A' + *s as u8) as char)).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
```
|
||||
|
||||
with a call to a new helper, and add the helper below `leaf_label`:
|
||||
|
||||
```rust
|
||||
let stubs: Vec<String> = if slots.len() > 1 {
|
||||
fan_in_identifiers(c, index, &slots)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
```
|
||||
|
||||
```rust
|
||||
/// One `#…` identifier per wired slot of a fan-in leaf, in slot order.
|
||||
/// - A role-fed slot uses the role name verbatim (`#price`), never shortened.
|
||||
/// - An interior-fed slot uses its source signature, never shorter than the
|
||||
/// source's **base** (type initial + alias initials), extended into the
|
||||
/// recursive tail only as far as needed to be unique among the siblings.
|
||||
/// - Two siblings with equal full signatures (interchangeable inputs) cannot be
|
||||
/// separated — those slots fall back to the positional letter `#A`. The engine
|
||||
/// constraint guarantees no configuration-distinct pair fully collides, so a
|
||||
/// valid blueprint reaches the fallback only for genuinely-interchangeable
|
||||
/// inputs.
|
||||
fn fan_in_identifiers(c: &Composite, index: usize, slots: &[usize]) -> Vec<String> {
|
||||
// per slot: (slot, signature, base_len, is_role)
|
||||
let srcs: Vec<(usize, String, usize, bool)> = slots
|
||||
.iter()
|
||||
.map(|&slot| {
|
||||
let (sig, base, is_role) = slot_source(c, index, slot);
|
||||
(slot, sig, base, is_role)
|
||||
})
|
||||
.collect();
|
||||
srcs.iter()
|
||||
.map(|(slot, sig, base, is_role)| {
|
||||
if *is_role {
|
||||
return format!("#{sig}"); // role name verbatim
|
||||
}
|
||||
let others: Vec<&String> =
|
||||
srcs.iter().filter(|(s, _, _, _)| s != slot).map(|(_, x, _, _)| x).collect();
|
||||
match unique_prefix_from(sig, *base, &others) {
|
||||
Some(p) => format!("#{p}"),
|
||||
None => format!("#{}", (b'A' + *slot as u8) as char), // interchangeable fallback
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The source feeding `(index, slot)`: `(signature, base_len, is_role)`. A role
|
||||
/// returns its name verbatim with `is_role = true` (base_len unused); an interior
|
||||
/// producer returns its `signature_of` and its base length (type initial + alias
|
||||
/// initials).
|
||||
fn slot_source(c: &Composite, index: usize, slot: usize) -> (String, usize, bool) {
|
||||
for e in c.edges() {
|
||||
if e.to == index && e.slot == slot {
|
||||
let sig = signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), e.from);
|
||||
return (sig, signature_base_len(c, e.from), false);
|
||||
}
|
||||
}
|
||||
for r in c.input_roles() {
|
||||
if r.targets.iter().any(|t| t.node == index && t.slot == slot) {
|
||||
return (r.name.clone(), 0, true);
|
||||
}
|
||||
}
|
||||
(String::new(), 0, false)
|
||||
}
|
||||
|
||||
/// The base length of an interior node's signature: 1 (type / composite-name
|
||||
/// initial) plus one per declared param alias on that node — the minimum the
|
||||
/// rendered identifier never goes below.
|
||||
fn signature_base_len(c: &Composite, node: usize) -> usize {
|
||||
match &c.nodes()[node] {
|
||||
BlueprintNode::Leaf(_) => 1 + c.params().iter().filter(|a| a.node == node).count(),
|
||||
BlueprintNode::Composite(_) => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// The shortest prefix of `sig` of length ≥ `base` that no `other` starts with —
|
||||
/// i.e. distinguishes `sig` from all siblings while never dropping below the
|
||||
/// base. `None` when some `other` equals `sig` in full (inseparable —
|
||||
/// interchangeable, caller uses the positional fallback).
|
||||
fn unique_prefix_from(sig: &str, base: usize, others: &[&String]) -> Option<String> {
|
||||
if others.iter().any(|o| o.as_str() == sig) {
|
||||
return None;
|
||||
}
|
||||
let chars: Vec<char> = sig.chars().collect();
|
||||
if chars.is_empty() {
|
||||
return Some(String::new()); // degenerate (no producer); not reached for a wired slot
|
||||
}
|
||||
let start = base.max(1).min(chars.len());
|
||||
for len in start..=chars.len() {
|
||||
let prefix: String = chars[..len].iter().collect();
|
||||
if others.iter().all(|o| !o.starts_with(&prefix)) {
|
||||
return Some(prefix);
|
||||
}
|
||||
}
|
||||
Some(sig.to_string())
|
||||
}
|
||||
```
|
||||
|
||||
Add `use aura_engine::signature_of;` to the imports at the top of `graph.rs`
|
||||
(the existing `use aura_engine::{...}` line).
|
||||
|
||||
- [ ] **Step 3: Update the MACD render asserts**
|
||||
|
||||
In `main.rs` `macd_blueprint_renders_a_nested_composite_definition` (`:585`),
|
||||
replace the single assert at `:598`:
|
||||
|
||||
```rust
|
||||
assert!(out.contains("[Sub(#A,#B)]"), "Sub shows its two ordered inputs: {out}");
|
||||
```
|
||||
|
||||
with two distinct-label asserts:
|
||||
|
||||
```rust
|
||||
assert!(out.contains("[Sub(#Ef,#Es)]"), "MACD line Sub is fast-EMA minus slow-EMA: {out}");
|
||||
assert!(out.contains("[Sub(#S,#Es)]"), "histogram Sub is the line minus signal-EMA: {out}");
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update the sample needle test**
|
||||
|
||||
In `main.rs` `blueprint_view_defines_each_composite_once` (`:397`), the needle
|
||||
array (`:402`) changes `[SMA]` and `[Sub(#A,#B)]`:
|
||||
|
||||
```rust
|
||||
for needle in ["[SMA(fast)]", "[SMA(slow)]", "[Sub(#Sf,#Ss)]", "[price]", "[cross]"] {
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Re-capture `blueprint_view_golden`**
|
||||
|
||||
The golden string in `blueprint_view_golden` (`:491-530`) is layout-exact and
|
||||
must be regenerated, not hand-edited.
|
||||
|
||||
Run: `cargo run -q --bin aura -- graph`
|
||||
Copy the emitted `where: … sma_cross(fast:i64, slow:i64) -> (cross): …` block
|
||||
verbatim into the golden literal, replacing the old `sma_cross()` / `[SMA]` /
|
||||
`[Sub(#A,#B)]` lines.
|
||||
Expected after edit: the golden shows `sma_cross(fast:i64, slow:i64) -> (cross)`,
|
||||
`[SMA(fast)]`, `[SMA(slow)]`, `[Sub(#Sf,#Ss)]`.
|
||||
|
||||
- [ ] **Step 6: Add a focused render unit test**
|
||||
|
||||
In `main.rs` test module, add a test on hand-built composites covering the cases
|
||||
the goldens don't isolate:
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn fan_in_identifiers_are_source_derived_and_scoped_per_node_call() {
|
||||
use aura_engine::{Blueprint, BlueprintNode};
|
||||
// role passthrough: a Sub fed by role `price` + an EMA(slow) ->
|
||||
// #price (role name) and #Es
|
||||
let c = Composite::new(
|
||||
"roles",
|
||||
vec![Ema::factory().into(), Sub::factory().into()],
|
||||
vec![Edge { from: 0, to: 1, slot: 1, from_field: 0 }],
|
||||
vec![Role { name: "price".into(), targets: vec![Target { node: 1, slot: 0 }] }],
|
||||
vec![ParamAlias { name: "slow".into(), node: 0, slot: 0 }],
|
||||
vec![OutField { node: 1, field: 0, name: "o".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
|
||||
let out = graph::render_blueprint(&bp, graph::Color::Plain);
|
||||
assert!(out.contains("[Sub(#price,#Es)]"), "role name verbatim + source-derived: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fan_in_identifiers_descend_into_bare_combinators() {
|
||||
use aura_engine::{Blueprint, BlueprintNode};
|
||||
// Sub( Sub(EMA fast, EMA slow), Sub(EMA up, EMA down) ): the two inner Subs
|
||||
// are param-less but have distinct recursive signatures (SEf… vs SEu…), so
|
||||
// the outer Sub descends just far enough -> [Sub(#SEf,#SEu)].
|
||||
let c = Composite::new(
|
||||
"nest",
|
||||
vec![
|
||||
Ema::factory().into(), // 0 fast
|
||||
Ema::factory().into(), // 1 slow
|
||||
Ema::factory().into(), // 2 up
|
||||
Ema::factory().into(), // 3 down
|
||||
Sub::factory().into(), // 4 = Sub(0,1)
|
||||
Sub::factory().into(), // 5 = Sub(2,3)
|
||||
Sub::factory().into(), // 6 = Sub(4,5) (the outer fan-in)
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 4, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 4, slot: 1, from_field: 0 },
|
||||
Edge { from: 2, to: 5, slot: 0, from_field: 0 },
|
||||
Edge { from: 3, to: 5, slot: 1, from_field: 0 },
|
||||
Edge { from: 4, to: 6, slot: 0, from_field: 0 },
|
||||
Edge { from: 5, to: 6, slot: 1, from_field: 0 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 },
|
||||
Target { node: 1, slot: 0 },
|
||||
Target { node: 2, slot: 0 },
|
||||
Target { node: 3, slot: 0 },
|
||||
],
|
||||
}],
|
||||
vec![
|
||||
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
|
||||
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
|
||||
ParamAlias { name: "up".into(), node: 2, slot: 0 },
|
||||
ParamAlias { name: "down".into(), node: 3, slot: 0 },
|
||||
],
|
||||
vec![OutField { node: 6, field: 0, name: "o".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
|
||||
let out = graph::render_blueprint(&bp, graph::Color::Plain);
|
||||
assert!(out.contains("[Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs: {out}");
|
||||
assert!(out.contains("[Sub(#Ef,#Es)]"), "inner Sub uses EMA aliases: {out}");
|
||||
}
|
||||
```
|
||||
|
||||
(The interchangeable `[Join(#A,#B)]` positional fallback is pinned engine-side
|
||||
by `interchangeable_fan_in_allowed` plus a render check is unnecessary — the
|
||||
fallback path is the unchanged positional branch.)
|
||||
|
||||
- [ ] **Step 7: Confirm `compiled_view_golden` is byte-stable**
|
||||
|
||||
Run: `cargo test -p aura-cli compiled_view_golden`
|
||||
Expected: PASS unchanged — the compilat carries no aliases / `#` identifiers
|
||||
(C23 guard). If it fails, the render change leaked into the compiled view — stop
|
||||
and inspect; do not re-capture this golden.
|
||||
|
||||
- [ ] **Step 8: Full CLI + workspace green**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: PASS. (Engine untouched this task; CLI self-consistent with aliased
|
||||
`sma_cross` + source-derived render.)
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Engine — `IndistinguishableFanIn` constraint + fixture aliases
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/aura-engine/src/blueprint.rs` (`CompileError`, `inline_composite`, engine fixtures)
|
||||
- Test: `crates/aura-engine/src/blueprint.rs`
|
||||
|
||||
The CLI `sma_cross` is already aliased (Task 2), so adding the constraint does
|
||||
not break the CLI. RED-first: the reject test before the check.
|
||||
|
||||
- [ ] **Step 1: Write the failing constraint test**
|
||||
|
||||
In the `blueprint.rs` test module, beside `bad_interior_index_rejected`
|
||||
(`~:782`):
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn indistinguishable_fan_in_rejected() {
|
||||
// two alias-less Sma (each an unaliased `length`) on role price into a Sub:
|
||||
// signatures collide ("Sp"=="Sp") and a param is unaliased -> fault.
|
||||
let c = Composite::new(
|
||||
"ambig",
|
||||
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().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 }],
|
||||
}],
|
||||
vec![],
|
||||
vec![OutField { node: 2, field: 0, name: "x".into() }],
|
||||
);
|
||||
let bp = Blueprint::new(
|
||||
vec![BlueprintNode::Composite(c)],
|
||||
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interchangeable_fan_in_allowed() {
|
||||
// fan_composite: two param-less Pass into a Join, equal signatures but no
|
||||
// unaliased param -> interchangeable -> Ok.
|
||||
let bp = Blueprint::new(
|
||||
vec![BlueprintNode::Composite(fan_composite()), sink_f64()],
|
||||
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
|
||||
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
||||
);
|
||||
assert!(bp.compile().is_ok(), "param-less interchangeable fan-in must compile");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify the reject test fails**
|
||||
|
||||
Run: `cargo test -p aura-engine indistinguishable_fan_in_rejected`
|
||||
Expected: FAIL — `no variant named IndistinguishableFanIn` (does not compile yet)
|
||||
or, once the variant exists but no check, the composite compiles `Ok` so the
|
||||
assert fails.
|
||||
|
||||
- [ ] **Step 3: Add the `CompileError` variant**
|
||||
|
||||
In `blueprint.rs:127` `enum CompileError`, add:
|
||||
|
||||
```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 },
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the check in `inline_composite`, after the alias-validity loop**
|
||||
|
||||
The check must run **after** the existing alias-validity loop (`:361-367`, which
|
||||
raises `BadInteriorIndex` for a bogus alias index — `out_of_range_param_alias_rejected`
|
||||
depends on that ordering) and **before** `lower_items` (`:370`). It operates on
|
||||
the already-destructured pieces (`nodes`, `edges`, `input_roles`, `param_aliases`
|
||||
— bound at `:356`). Insert directly after the alias loop closes at `:367`:
|
||||
|
||||
```rust
|
||||
// Fan-in distinguishability: for each node with >1 wired input slot, a
|
||||
// collision (equal source signatures) is a fault only when at least one
|
||||
// colliding source has an unaliased param slot — the unnamed configuration
|
||||
// axis. Runs after alias-validity (so a bad alias is BadInteriorIndex first)
|
||||
// and before lowering. signature_of/leaf_has_unaliased_param take the pieces.
|
||||
for node in 0..nodes.len() {
|
||||
let mut sources: Vec<(usize, String, bool)> = Vec::new(); // (slot, sig, has_unaliased_param)
|
||||
for e in edges.iter().filter(|e| e.to == node) {
|
||||
sources.push((
|
||||
e.slot,
|
||||
signature_of(&nodes, &edges, &input_roles, ¶m_aliases, e.from),
|
||||
leaf_has_unaliased_param(&nodes, ¶m_aliases, 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)); // a role has no param of its own
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
and a helper near `signature_of`:
|
||||
|
||||
```rust
|
||||
/// Whether interior leaf `node` has at least one param slot with no alias in
|
||||
/// `aliases` — the unnamed configuration axis the fan-in rule keys on. A non-leaf
|
||||
/// (nested composite) reports `false`.
|
||||
fn leaf_has_unaliased_param(nodes: &[BlueprintNode], aliases: &[ParamAlias], node: usize) -> bool {
|
||||
match &nodes[node] {
|
||||
BlueprintNode::Leaf(f) => {
|
||||
let n_params = f.params().len();
|
||||
let aliased = aliases.iter().filter(|a| a.node == node).count();
|
||||
n_params > aliased
|
||||
}
|
||||
BlueprintNode::Composite(_) => false,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(Note: `param_alias_relabels_param_space_name_in_place` and
|
||||
`param_space_is_flat_path_qualified_and_slot_disambiguated` call only
|
||||
`param_space()`, never `compile()`, so the check does not reach them — they stay
|
||||
green unchanged, including the deliberate duplicate `*.length` names.)
|
||||
|
||||
- [ ] **Step 5: Run the constraint tests**
|
||||
|
||||
Run: `cargo test -p aura-engine indistinguishable_fan_in_rejected interchangeable_fan_in_allowed`
|
||||
Expected: both PASS.
|
||||
|
||||
- [ ] **Step 6: Correct the engine-local param-bearing fixtures**
|
||||
|
||||
Two fixtures actually reach `compile()` with the param-bearing alias-less shape
|
||||
and now fail; correct exactly those:
|
||||
|
||||
1. **Engine-local `sma_cross()` (`:919`)** — used by `composite_sma_cross_harness`
|
||||
(eight tests). Its empty params vec (`:931`) gains:
|
||||
|
||||
```rust
|
||||
vec![
|
||||
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
|
||||
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
|
||||
],
|
||||
```
|
||||
|
||||
Then re-pin the name assert in `param_space_mirrors_compiled_flat_node_param_order`
|
||||
(`:1149-1152`): `["sma_cross.length", "sma_cross.length", "scale"]` →
|
||||
`["sma_cross.fast", "sma_cross.slow", "scale"]`.
|
||||
|
||||
2. **The `fast_slow` fixture inside `param_space_mirrors_compiled_flat_node_param_order_under_nesting`
|
||||
(`:1321`)** — this test calls `compile_with_params` (`:1352`). Its empty params
|
||||
vec (`:1332`) gains the same `fast`/`slow` aliases. This test asserts only
|
||||
per-slot *kinds* and *order* (`:1355+`), not names, so it stays green after the
|
||||
alias (aliases rename, don't reorder).
|
||||
|
||||
Do **not** touch the separate `fast_slow` in `param_space_is_flat_path_qualified_and_slot_disambiguated`
|
||||
(`:1265`): it calls only `param_space()`, never `compile()`, so the constraint
|
||||
never reaches it; it deliberately demonstrates two same-type slots sharing the
|
||||
`fast_slow.length` name disambiguated by slot, and its name assert (`:1292-1300`)
|
||||
must stay as-is.
|
||||
|
||||
- [ ] **Step 7: Full engine + workspace green**
|
||||
|
||||
Run: `cargo test --workspace`
|
||||
Expected: PASS. `fan_composite`-based tests (`:599`, `:678`) stay green
|
||||
(interchangeable); the hand-wired flat fixtures (`main.rs:46`, `blueprint.rs:877`)
|
||||
bypass `inline_composite` and stay green.
|
||||
|
||||
- [ ] **Step 8: Lint clean**
|
||||
|
||||
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
Expected: no warnings.
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Design-ledger contract refinement
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/design/INDEX.md`
|
||||
|
||||
- [ ] **Step 1: Add the refinement note**
|
||||
|
||||
Under the C9 contract (composite well-formedness / authoring-level identity),
|
||||
add a dated refinement paragraph mirroring the existing `**Refinement (…).**`
|
||||
shape:
|
||||
|
||||
```markdown
|
||||
**Refinement (fan-in distinguishability, 2026-06-08).** A fan-in node (>1 input)
|
||||
is well-formed only if its colliding sources — sources with equal recursive
|
||||
signatures (type initial + alias initials + recursive input signatures) — do not
|
||||
hide an unnamed configuration axis: a collision is a `CompileError`
|
||||
(`IndistinguishableFanIn`) when at least one colliding source carries an
|
||||
unaliased param slot. Genuinely-interchangeable sources (equal signatures, no
|
||||
param) stay legal. Construction-phase only; the compilat stays name-free (C23).
|
||||
The graph view renders each fan-in input as the shortest sibling-unique prefix
|
||||
of its source signature.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Confirm no doc build breakage**
|
||||
|
||||
Run: `cargo doc --workspace --no-deps 2>&1`
|
||||
Expected: no new warnings (markdown-only change).
|
||||
Reference in New Issue
Block a user