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

19 KiB
Raw Blame History

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):

/// 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:

// 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:

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 Subs, both [Sub(#A,#B)] today, become [Sub(#Ef,#Es)] (the line) and [Sub(#S,#Es)] (the histogram) — distinct and self-describing.

Sub(Sub(EMA(fast),EMA(slow)), Sub(EMA(up),EMA(down))) — two bare Subs 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:

       [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:

// 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:

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):

// 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_paramslower_itemsinline_compositenew fan-in check (before interior lowering) → on a colliding pair where one source has an unaliased param, Err(IndistinguishableFanIn); else continue.

Render: render_definitionleaf_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_crosssma_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 Subs 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.