Files
Aura/docs/specs/0021-fan-in-distinguishability.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

392 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<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: 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<dyn Node>::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.