# Fan-in input distinguishability — Design Spec **Date:** 2026-06-08 **Status:** Draft — awaiting user spec review **Authors:** orchestrator + Claude ## Goal A node with more than one input (a fan-in node) carries an ordered argument list whose order is load-bearing for non-commutative nodes (`Sub`: a − b). The definition view currently labels those inputs with positional stubs derived from the slot index — `[Sub(#A,#B)]` (`crates/aura-cli/src/graph.rs:182-183`). `#A`/`#B` say nothing about *which producer* feeds each slot, and two distinct `Sub` nodes in one graph render identically. The root cause is upstream of the render. `sma_cross` (`main.rs:122`) wires two `Sma` nodes into a `Sub` **without naming their length params** (empty alias list, `main.rs:134`). Run `aura graph` and the signature reads `sma_cross() -> (cross)` — no params at all — with two `[SMA]` boxes feeding `[Sub(#A,#B)]`. The two SMAs each carry an *unnamed* `length` slot that will be filled with *different* values (2 and 4): the author means two different things — fast and slow — but the rendered identity hides it. That is the defect: an input fed by a node with a configuration axis the author left unnamed, sharing its rendered identity with a sibling. The contemporaneous `macd` fixture (`main.rs:191`), authored after the graph view existed, does it right: aliased `fast`/`slow`/`signal` lengths, a full `macd(fast:i64, slow:i64, signal:i64)` signature. This cycle makes that defect **illegal by construction** and lets the render rely on the guarantee: 1. **Construction constraint (aura-engine).** A composite with a fan-in node whose two input slots are fed by sources with **identical signatures** *and* where **at least one colliding source carries an unaliased param slot** is a construction fault — a new `CompileError`, raised in the same per-composite walk that already validates aliases, output ports, and role kinds. Equal signatures with no unaliased param are genuinely interchangeable inputs (a fan-out / diamond — `add(price, price)`, two identical pass-throughs) and are allowed: there is no "which is which" to confuse. 2. **Source-derived render identifiers (aura-cli).** With the defect ruled out, the definition view replaces positional `#A/#B` with the shortest abbreviation of each input's *producing node* — `[Sub(#Sf,#Ss)]`, `[Sub(#Ef,#Es)]` — answering "where does this input come from" from the label alone. Genuinely-interchangeable inputs (equal signatures, no unaliased param) keep the positional letter — correct, since interchangeable inputs have no load-bearing order. 3. **Fixture correction.** `sma_cross` gains `fast`/`slow` aliases, becoming a well-formed composite that passes the new constraint. ### A node's signature (the recursive identity) The signature is the authoring identity of a node, built depth-first: ``` signature(node) := typeInitial(node) -- 'E' for EMA, 'S' for SMA/Sub, … ++ [ initial(alias) for alias in node's param-aliases, in declared order ] ++ [ inputSig(slot) for slot in node's wired inputs, in slot order ] inputSig(slot) := roleName(source) if the slot's source is an input role (named port — no descent) compositeName(source) if the source is a nested composite (named — no descent) signature(source) if the source is an interior leaf (recurse) ``` So `EMA(fast)` has signature `Ef`; `Sub(EMA(fast), EMA(slow))` has signature `SEfEs`; a `Sma` with no alias fed by role `price` has signature `Sp`. The recursion terminates because the dataflow is a DAG (C5); the only feedback path is an explicit delay/state node, at which the descent stops (a state node is a named type whose signature is its type + aliases, not its delayed input). Two siblings **collide** when their full signatures are equal — which happens only when their source sub-trees are structurally *and* nominally identical (or are the very same node fed twice). The injected param *value* (length 2 vs 4) is bound later and is not part of the signature; two alias-less SMAs on the same input have identical signatures regardless of the values they will receive. A collision is a **construction fault only when at least one colliding source carries an unaliased param slot** — the configuration axis that could distinguish the two but whose name is missing. The fix is always available: name the param (C: an alias initial enters the signature, separating the siblings). A collision where neither source has any param is genuinely interchangeable — `Pass`/`Pass`, `add(price, price)` — and is allowed; the render falls back to the positional letter for those slots (interchangeable inputs carry no order). The rendered identifier is the **shortest prefix** of a source's signature that is unique among the consumer node's siblings (per-node-call namespace, not composite-global; prefix-free among siblings). Most nodes resolve at the type + first-alias initials (`Ef`, `Ss`) and never reach the recursive tail; nested bare combinators reach into it (`SEf` vs `SEu`); genuinely interchangeable collisions cannot be separated and use the positional fallback. ### Relationship to existing contracts `param_space` documents (`blueprint.rs:176-177`) that "same-type siblings in one composite share a name — uniqueness is at the slot". That stays true for **param binding**: the injected vector is positional, one value per slot (C12/C19/C23 untouched). This cycle adds a *separate* authoring well-formedness rule that bites only at fan-in nodes whose colliding sources have an unnamed configuration axis. C23 is not violated — the check runs during construction, before the type-erased flat graph; runtime names stay non-load-bearing debug symbols. What changes is that a composite which was silently accepted (then rendered ambiguously) is now rejected at compile. This is a deliberate contract refinement, recorded in the ledger. ## Architecture ### Construction constraint (aura-engine) `inline_composite` (`crates/aura-engine/src/blueprint.rs:344`) destructures a composite into `{ nodes, edges, input_roles, param_aliases, output }` and runs structural checks there (alias slot validity `:361-367`, output-port range, role kinds). The new check joins them, before the interior is lowered: - Enumerate each interior node's wired input slots from `edges` (`Edge.to == i`) and `input_roles` (`Role.targets` with `node == i`). - For each node with **> 1** wired slot, compute each slot-source's full signature (recursive, per the definition above). - If two slots of one node carry equal signatures **and** at least one of those two sources is a leaf with a param slot that has no alias, raise the new `CompileError`. (Equal signatures where neither source has an unaliased param are allowed — interchangeable.) The check needs interior leaves' type labels and alias names at compile time — `factory.label()` (the value-empty type name) and the `param_aliases` already in hand — plus `factory.params()` to know whether a source has param slots, and a recursive walk of interior `edges`/`input_roles` to build a source's signature. No param *values* are required: the check is structural. New variant on `CompileError` (`blueprint.rs:127`): ```rust /// A fan-in node (>1 input) at interior index `node` has two input slots fed /// by sources with identical signatures (type + alias names + recursive input /// signatures) where at least one source has an unaliased param slot — the two /// inputs differ in configuration but share a rendered identity. Name the /// distinguishing param (e.g. fast/slow). IndistinguishableFanIn { node: usize }, ``` ### Render identifiers (aura-cli) `leaf_label` (`crates/aura-cli/src/graph.rs:160`) keeps its slot collection; the stub construction changes from "slot index → letter" to "slot's producer → shortest-unique prefix of its signature": - For each wired slot, resolve its producer and that producer's signature. - The identifier is the shortest signature prefix unique among **this node's** sibling inputs. A role passes its name through verbatim (`#price`). Each final identifier is `#`-prefixed. - Two siblings with equal full signatures (the allowed interchangeable case, or an un-compiled malformed blueprint) cannot be separated by any prefix; those slots fall back to the positional letter (`#A`). For a *valid* blueprint this is reached only by genuinely-interchangeable inputs, which is correct; the construction constraint guarantees no *configuration-distinct* pair reaches it. - No counter suffix is introduced. No edge labels are introduced; `render_flat` keeps passing `None` (`graph.rs:85`). ### Fixture correction (aura-cli) `sma_cross` (`main.rs:122`) gains the two missing aliases, mirroring `macd`: ```rust // params: was vec![] vec![ ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast SMA length ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow SMA length ], ``` ## Concrete code shapes ### Worked user-facing example (the acceptance evidence) `aura graph`, the `where:` definition of `sma_cross`. **Before** (today — the defect): `sma_cross() -> (cross)`, two indistinguishable `[SMA]`, `[Sub(#A,#B)]`. **After** the fixture correction: ```text sma_cross(fast:i64, slow:i64) -> (cross): [price] ┌────└────┐ ↓ ↓ [SMA(fast)] [SMA(slow)] └────┌────┘ ↓ [Sub(#Sf,#Ss)] │ ↓ [cross] ``` The signature carries the lengths; each SMA shows its alias; the `Sub` reads `[Sub(#Sf,#Ss)]` — "fast SMA minus slow SMA". The `macd` definition's two `Sub`s, both `[Sub(#A,#B)]` today, become `[Sub(#Ef,#Es)]` (the line) and `[Sub(#S,#Es)]` (the histogram) — distinct and self-describing. ### Nested bare combinators (the recursive case, legal) `Sub(Sub(EMA(fast),EMA(slow)), Sub(EMA(up),EMA(down)))` — two bare `Sub`s into one `Sub`. The inner Subs have signatures `SEfEs` and `SEuEd`; distinct, so the outer Sub renders by descending into the inputs just far enough: ```text [Sub(#Ef,#Es)] [Sub(#Eu,#Ed)] └──────┐ ┌──────┘ ↓ ↓ [Sub(#SEf,#SEu)] ``` `#SEf` / `#SEu` = the shortest prefixes that separate `SEfEs` from `SEuEd`. ### Interchangeable inputs (the allowed collision) `Join(Pass(price), Pass(price))` (the `fan_composite` test fixture, `blueprint.rs:581`): two param-less `Pass` leaves, both fed by role `price`, into one `Join`. Both signatures are `Pp` — equal — but **neither source has a param**, so this is interchangeable, **not** a fault: `compile()` accepts it, and the render uses the positional fallback `[Join(#A,#B)]` (the two inputs have no load-bearing order). `add(price, price)` is the same case. ### The construction fault, shown The *old* `sma_cross` shape — two `Sma` (each with an unaliased `length`) on the same role into a `Sub` — is rejected; both have signature `Sp` and carry an unaliased param: ```rust // two SMA leaves, no aliases, both fed by role `price`, into one Sub: // signature(node0) == signature(node1) == "Sp", and Sma has an unaliased // `length` param slot. // compiling a blueprint containing this composite: // => Err(CompileError::IndistinguishableFanIn { node: 2 }) ``` ### Implementation shape (secondary) `leaf_label` stub construction, today (`graph.rs:182-183`) maps slot index → `#A/#B`; after, each wired slot resolves its producer's signature and renders the shortest sibling-unique prefix (`#…`), with the positional letter kept for the equal-signature (interchangeable) case: ```rust let stubs: Vec = if slots.len() > 1 { fan_in_identifiers(c, index, &slots) // signature prefixes, slot order } else { Vec::new() }; ``` The construction check inside `inline_composite`, beside the existing validations (`blueprint.rs:361-409`): ```rust // for every interior node with >1 wired input slot: if two sources share a // signature AND at least one has an unaliased param slot, IndistinguishableFanIn. for node in 0..item_count { if let Some(dup) = colliding_pair_with_unaliased_param(node, &edges, &input_roles, &nodes) { return Err(CompileError::IndistinguishableFanIn { node }); } } ``` ## Components - **`CompileError::IndistinguishableFanIn { node }`** (new variant, `blueprint.rs:127`). - **construction check + `signature` walk** in `inline_composite` (`blueprint.rs:344`): per-node fan-in collision detection guarded by the unaliased-param predicate + the recursive signature helper (type label + alias names + input signatures; role/composite source stops the descent). Reuses `factory.label()`, `factory.params()`, and the destructured `param_aliases`/`edges`/`input_roles`. Terminates on the DAG (C5). - **`leaf_label` / `fan_in_identifiers`** (`graph.rs:160`): shortest sibling-unique signature prefix, role passthrough, positional fallback for the equal-signature case. The wired-slot collection (today inline in `leaf_label`) is the shared seam; it must not be duplicated. - **`sma_cross` fixture** (`main.rs:122`): two aliases added. The engine-local `sma_cross()`/`fast_slow` fixtures (`blueprint.rs`) that build the same param-bearing alias-less shape into a fan-in must also gain aliases (they now fail the constraint); the param-less `fan_composite` and the hand-wired flat-level fixtures are unaffected (interchangeable / not composite-compiled). - The signature notion is shared by the engine check and the CLI render. Whether the signature helper lives in aura-engine and is reused by aura-cli, or is computed each side, is a plan-level call; its *definition* must be a single source of truth. ## Data flow Construction: `compile_with_params` → `lower_items` → `inline_composite` → **new fan-in check** (before interior lowering) → on a colliding pair where one source has an unaliased param, `Err(IndistinguishableFanIn)`; else continue. Render: `render_definition` → `leaf_label(c, index, factory)` → `fan_in_identifiers(c, index, slots)` → per slot: producer → signature → shortest sibling-unique prefix (or positional fallback on an inseparable collision) → `#id`; structure only, no `eval`. ## Error handling - **`IndistinguishableFanIn`** is the new, intended construction fault — returned, never panicked; composes with the existing `CompileError` surface (`PartialEq`, testable like `BadInteriorIndex`). - **Interchangeable collision** (equal signatures, no unaliased param): allowed at compile; the render uses the positional letter for those slots. Total, no panic. - **Render of a malformed blueprint** (a constraint-violating one, rendered without compiling): the colliding slots also fall back to positional letters — the same total path, no panic, no dropped slot. - **Signature recursion**: bounded by the DAG (C5); descent stops at named sources (roles, composites) and state/delay nodes, so it cannot loop. ## Testing strategy Engine (the central RED): - **New constraint test** (`blueprint.rs` test module, beside `BadInteriorIndex` / `RoleKindMismatch` ~`:782-817`): a composite with two alias-less `Sma` (each an unaliased `length`) on the same role feeding one `Sub` compiles to `Err(IndistinguishableFanIn { node: 2 })`. - **Positive — aliased**: the same shape with distinct `fast`/`slow` aliases compiles `Ok`. - **Positive — interchangeable**: `fan_composite` (`blueprint.rs:581`, two param-less `Pass` into a `Join`) still compiles `Ok` — equal signatures, no unaliased param. This pins the param-aware criterion (the distinguishing test vs the SMA case). - **Positive — nested bare combinators**: `Sub(Sub(a,b),Sub(c,d))` with distinct interior leaves compiles `Ok` (distinct recursive signatures). Render (aura-cli): - **`macd_blueprint_renders_a_nested_composite_definition`** (`main.rs:585`): the single `[Sub(#A,#B)]` assert (`:598`) becomes `[Sub(#Ef,#Es)]` and `[Sub(#S,#Es)]`. - **Sample needle** (`main.rs:402`) and **`blueprint_view_golden`** (`:491`, line ~522): re-captured for the corrected `sma_cross` — `sma_cross(fast:i64, slow:i64) -> (cross)`, `[SMA(fast)]` / `[SMA(slow)]`, `[Sub(#Sf,#Ss)]`. - **New focused render test**: per-node-call uniqueness, role-name passthrough (`#price`), the recursive descent (`[Sub(#SEf,#SEu)]`), and the interchangeable positional fallback (`[Join(#A,#B)]`-style). - **`compiled_view_golden`** (`main.rs:533`): byte-stable — flat graph labels via `Box::label()`, no fan-in stubs, no `#` identifier. C23 guard. Fixture knock-on (the constraint-driven set): - Engine-local `sma_cross()` (`blueprint.rs:919`) and the `fast_slow` fixtures (`:1262`, `:1316`) build the param-bearing alias-less fan-in and now fail `compile()`; they gain `fast`/`slow` aliases. The eight tests on `composite_sma_cross_harness` and the `param_space` name assert (`blueprint.rs:1149-1152`) re-pin to the aliased names. `fan_composite` and the two tests on it (`:598`, `:677`) stay green (interchangeable). The hand-wired flat fixtures (`main.rs:46`, `blueprint.rs:877`) bypass `inline_composite` and stay green. - CLI sample `param_space` names gain `sma_cross.fast` / `sma_cross.slow`; `sample_point` slot order/values unchanged. The planner enumerates exact affected asserts. ## Acceptance criteria Per aura's feature-acceptance: a strategy author cannot accidentally ship a fan-in whose inputs differ in configuration but share a rendered identity — the engine rejects it at construction, where aura makes invariants structural; genuinely-interchangeable inputs stay legal; the graph view communicates input provenance correctly; no runtime/flat graph behaviour changes (C1/C12/C19/C23 intact). - [ ] A composite with a fan-in node whose two sources have equal signatures and at least one has an unaliased param fails `compile()` with `IndistinguishableFanIn { node }`. - [ ] Equal-signature sources with **no** unaliased param (`fan_composite`, `add(price,price)`) compile `Ok`; distinct aliases and distinct nested combinators compile `Ok`. - [ ] `sma_cross` (CLI and engine-local) is corrected to declare `fast`/`slow` aliases; its signature renders `sma_cross(fast:i64, slow:i64) -> (cross)`. - [ ] A fan-in leaf renders each input as a `#`-prefixed shortest-unique signature prefix (recursive into bare sources as needed), replacing positional `#A/#B`; a role-fed input uses the verbatim role name; interchangeable inputs keep the positional letter. - [ ] Abbreviations are scoped per-node-call; a valid blueprint never needs a counter suffix. - [ ] The render stays total on interchangeable and malformed blueprints (positional fallback; no panic). - [ ] The MACD render shows its two `Sub`s as distinct labels; nested bare combinators render their recursive identifiers. - [ ] `compiled_view_golden` stays byte-stable (C23 guard); affected blueprint goldens / needles / param_space asserts regenerated. - [ ] The construction-constraint contract refinement is recorded in the design ledger.