"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.
19 KiB
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:
- 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. - Source-derived render identifiers (aura-cli). With the defect ruled out,
the definition view replaces positional
#A/#Bwith 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. - Fixture correction.
sma_crossgainsfast/slowaliases, 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) andinput_roles(Role.targetswithnode == 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.
Nested bare combinators (the recursive case, legal)
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 +
signaturewalk ininline_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). Reusesfactory.label(),factory.params(), and the destructuredparam_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 inleaf_label) is the shared seam; it must not be duplicated.sma_crossfixture (main.rs:122): two aliases added. The engine-localsma_cross()/fast_slowfixtures (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-lessfan_compositeand 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
IndistinguishableFanInis the new, intended construction fault — returned, never panicked; composes with the existingCompileErrorsurface (PartialEq, testable likeBadInteriorIndex).- 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.rstest module, besideBadInteriorIndex/RoleKindMismatch~:782-817): a composite with two alias-lessSma(each an unaliasedlength) on the same role feeding oneSubcompiles toErr(IndistinguishableFanIn { node: 2 }). - Positive — aliased: the same shape with distinct
fast/slowaliases compilesOk. - Positive — interchangeable:
fan_composite(blueprint.rs:581, two param-lessPassinto aJoin) still compilesOk— 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 compilesOk(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) andblueprint_view_golden(:491, line ~522): re-captured for the correctedsma_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 viaBox<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 thefast_slowfixtures (:1262,:1316) build the param-bearing alias-less fan-in and now failcompile(); they gainfast/slowaliases. The eight tests oncomposite_sma_cross_harnessand theparam_spacename assert (blueprint.rs:1149-1152) re-pin to the aliased names.fan_compositeand the two tests on it (:598,:677) stay green (interchangeable). The hand-wired flat fixtures (main.rs:46,blueprint.rs:877) bypassinline_compositeand stay green. - CLI sample
param_spacenames gainsma_cross.fast/sma_cross.slow;sample_pointslot 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()withIndistinguishableFanIn { node }. - Equal-signature sources with no unaliased param (
fan_composite,add(price,price)) compileOk; distinct aliases and distinct nested combinators compileOk. sma_cross(CLI and engine-local) is corrected to declarefast/slowaliases; its signature renderssma_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_goldenstays byte-stable (C23 guard); affected blueprint goldens / needles / param_space asserts regenerated.- The construction-constraint contract refinement is recorded in the design ledger.