From 14d91035ecdfc5a4b6974d25e382e3c2fcbffc26 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 8 Jun 2026 14:08:59 +0200 Subject: [PATCH] spec: 0021 fan-in distinguishability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A construction-phase constraint (CompileError::IndistinguishableFanIn, raised in inline_composite) makes a fan-in node whose sources have identical recursive signatures illegal; the definition view then renders each fan-in input as the shortest unique prefix of its source's signature (replacing positional #A/#B); and the sma_cross fixture gains its missing fast/slow aliases. Refs #44 — scope extended from render-only to a construction-phase invariant during design (the positional #A was a symptom of unnamed, indistinguishable fan-in sources, which is the actual fault to forbid). --- docs/specs/0021-fan-in-distinguishability.md | 351 +++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 docs/specs/0021-fan-in-distinguishability.md diff --git a/docs/specs/0021-fan-in-distinguishability.md b/docs/specs/0021-fan-in-distinguishability.md new file mode 100644 index 0000000..4a0a18b --- /dev/null +++ b/docs/specs/0021-fan-in-distinguishability.md @@ -0,0 +1,351 @@ +# 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 indistinguishable `[SMA]` boxes feeding +`[Sub(#A,#B)]`. The two SMAs are structurally and nominally identical at the +authoring surface; nothing says which is fast and which is slow. That is a +malformed composite, and the only reason it renders is that the render is a +best-effort total function. 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 malformation **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** is a + construction fault — a new `CompileError`, raised in the same per-composite + walk that already validates aliases, output ports, and role kinds. +2. **Source-derived render identifiers (aura-cli).** With distinguishability + guaranteed, 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. +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 are identical regardless of the values they will receive. + +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`). + +### 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: their sources must have distinct +signatures. C23 is not violated — the check runs during construction, before +the type-erased compilat; 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, raise the new + `CompileError`. + +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 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), so the inputs cannot be told apart. Name a distinguishing +/// param (e.g. fast/slow), or the two sources are genuinely identical (a − a). +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. +- Because the construction constraint guarantees distinct signatures at every + fan-in node, a unique prefix always exists for a *valid* blueprint + (lengthening terminates, at worst, at the full signature, which the + constraint guarantees distinct). No counter suffix. +- The render stays total: `aura graph` renders a blueprint *without* compiling + it, so it may be handed a malformed (constraint-violating) blueprint. There + the two signatures are equal and no prefix separates them; those slots fall + back to the positional letter (`#A`). A well-formed blueprint never reaches + this fallback — it is graceful degradation for a graph `compile()` would + reject, not a supported rendering. + +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, now 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 is legal and renders by descending into the inputs just far enough: + +```text + [Sub(#Ef,#Es)] [Sub(#Eu,#Ed)] + └──────┐ ┌──────┘ + ↓ ↓ + [Sub(#SEf,#SEu)] +``` + +`#SEf` / `#SEu` = "the Sub whose first input is EMA(fast)" vs "…EMA(up)" — the +shortest prefixes that separate `SEfEs` from `SEuEd`. Deeper nesting grows the +identifier; an author who finds it unreadable *may* encapsulate the repeated +spread as a named composite, but is not forced to. + +### The construction fault, shown + +A composite shaped like the *old* `sma_cross` (two alias-less `Sma` on the same +role into a `Sub`) is rejected — both SMAs have signature `Sp`: + +```rust +// two SMA leaves, no aliases, both fed by role `price`, both into one Sub: +// signature(node0) == signature(node1) == "Sp" (S + price) +// 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 only +as the malformed-graph fallback: + +```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, its sources' signatures +// must be pairwise distinct, else IndistinguishableFanIn. +for node in 0..item_count { + let sigs = fan_in_source_signatures(node, &edges, &input_roles, &nodes); + if has_duplicate(&sigs) { + 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 distinguishability + the recursive + signature helper (type label + alias names + input signatures; role/composite + source stops the descent at the name). Reuses `factory.label()` and the + destructured `param_aliases`/`edges`/`input_roles`. Terminates on the DAG (C5); + descent stops at a state/delay node. +- **`leaf_label` / `fan_in_identifiers`** (`graph.rs:160`): shortest + sibling-unique signature prefix, role passthrough, positional fallback for the + malformed 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 signature notion is shared by the engine check and the CLI render (one is + equality-of-signatures, the other shortest-distinguishing-prefix-of-signature). + 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 equal sibling signatures, +`Err(IndistinguishableFanIn)`; else continue as today. + +Render: `render_definition` → `leaf_label(c, index, factory)` → +`fan_in_identifiers(c, index, slots)` → per slot: producer → signature → +shortest sibling-unique prefix → `#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`). +- **Render of a malformed blueprint**: stays total — equal-signature slots fall + back to positional letters; no panic, no dropped slot. A well-formed blueprint + never exercises this path. +- **Signature recursion**: bounded by the DAG (C5); descent stops at named + sources (roles, composites) and at state/delay nodes, so it cannot loop. A + prefix that reaches a source's full signature still resolves, since the + constraint guarantees the full signatures differ. + +## Testing strategy + +Engine (the central RED): + +- **New constraint test** (`blueprint.rs` test module, beside + `BadInteriorIndex` / `RoleKindMismatch` ~`:782-817`): a composite with two + alias-less same-type leaves 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 — nested bare combinators**: `Sub(Sub(a,b),Sub(c,d))` with + distinct interior leaves compiles `Ok` (distinct recursive signatures). +- **Negative — true tie**: `Sub(X, X)` (same node fed to both slots) compiles + to `Err(IndistinguishableFanIn)`. + +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 (a producer abbreviates + independently at two consumers), role-name passthrough (`#price`), and the + recursive descent (`[Sub(#SEf,#SEu)]` for nested bare Subs). +- **`compiled_view_golden`** (`main.rs:533`): byte-stable — compilat labels via + `Box::label()`, no fan-in stubs, no `#` identifier. C23 guard. + +Fixture knock-on: + +- Any test pinning `sma_cross`'s param-less form updates to the aliased form; + the sample `param_space` names gain `sma_cross.fast` / `sma_cross.slow` + (mirroring `macd_param_space_surfaces_the_three_named_aliases`, `main.rs:623`); + `sample_point` slot order/values unchanged (only names appear). The + grounding-check / planner enumerate the exact affected asserts. + +## Acceptance criteria + +Per aura's feature-acceptance: a strategy author cannot accidentally ship a +fan-in whose inputs are indistinguishable — the engine rejects it at +construction, where aura makes invariants structural; the graph view then +communicates input provenance correctly; no runtime/compilat behaviour changes +(C1/C12/C19/C23 intact). + +- [ ] A composite with a fan-in node whose two sources have equal signatures + fails `compile()` with `IndistinguishableFanIn { node }`. +- [ ] Distinct param aliases, distinct nested combinators, and role/leaf mixes + compile `Ok`; `Sub(X,X)` (true tie) fails. +- [ ] `sma_cross` 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. +- [ ] Abbreviations are scoped per-node-call; a valid blueprint never needs a + counter suffix. +- [ ] The render stays total on a malformed blueprint (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.