spec: 0021 fan-in distinguishability
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).
This commit is contained in:
@@ -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<String> = 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<dyn Node>::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.
|
||||
Reference in New Issue
Block a user