21c1621bd0
"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.
392 lines
19 KiB
Markdown
392 lines
19 KiB
Markdown
# 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.
|