From 3aa124f815dfd4eac958f022034d673083f92771 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 8 Jun 2026 15:35:57 +0200 Subject: [PATCH] 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 --- docs/plans/0021-fan-in-distinguishability.md | 647 +++++++++++++++++++ 1 file changed, 647 insertions(+) create mode 100644 docs/plans/0021-fan-in-distinguishability.md diff --git a/docs/plans/0021-fan-in-distinguishability.md b/docs/plans/0021-fan-in-distinguishability.md new file mode 100644 index 0000000..07eccaa --- /dev/null +++ b/docs/plans/0021-fan-in-distinguishability.md @@ -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 = 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 = 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 { + // 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 { + if others.iter().any(|o| o.as_str() == sig) { + return None; + } + let chars: Vec = 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).