From 22a690361a94142d504af8484e3cb3659bf6cf92 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 8 Jun 2026 15:21:51 +0200 Subject: [PATCH] spec: 0021 refine fan-in constraint to a param-aware criterion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signature collision is a fault only when at least one colliding source carries an unaliased param slot — the configuration axis the author left unnamed (sma_cross's length). Equal-signature sources with no param are genuinely interchangeable (fan_composite's Pass/Pass, add(price,price)) and stay legal; the render uses the positional letter for them. Keeps the construction blast radius to the param-bearing alias-less fixtures. Refs #44 --- docs/specs/0021-fan-in-distinguishability.md | 248 +++++++++++-------- 1 file changed, 144 insertions(+), 104 deletions(-) diff --git a/docs/specs/0021-fan-in-distinguishability.md b/docs/specs/0021-fan-in-distinguishability.md index 4a0a18b..8a8c1eb 100644 --- a/docs/specs/0021-fan-in-distinguishability.md +++ b/docs/specs/0021-fan-in-distinguishability.md @@ -16,27 +16,34 @@ from the slot index — `[Sub(#A,#B)]` (`crates/aura-cli/src/graph.rs:182-183`). 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`), +(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 malformation **illegal by construction** and lets the -render rely on the guarantee: +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** is a + 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. -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)]`, + 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. + 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. @@ -64,15 +71,24 @@ 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. +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`). +bare combinators reach into it (`SEf` vs `SEu`); genuinely interchangeable +collisions cannot be separated and use the positional fallback. ### Relationship to existing contracts @@ -80,11 +96,11 @@ bare combinators reach into it (`SEf` vs `SEu`). 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 +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 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 @@ -100,21 +116,25 @@ role kinds). The new check joins them, before the interior is lowered: 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`. +- 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 a recursive walk of interior `edges`/`input_roles` to build a -source's signature. No param *values* are required: the check is structural. +`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), so the inputs cannot be told apart. Name a distinguishing -/// param (e.g. fast/slow), or the two sources are genuinely identical (a − a). +/// 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 }, ``` @@ -128,16 +148,12 @@ shortest-unique prefix of its 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. +- 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`). @@ -182,11 +198,11 @@ The signature carries the lengths; each SMA shows its alias; the `Sub` reads `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) +### 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 is legal and renders by descending into the inputs just far enough: +outer Sub renders by descending into the inputs just far enough: ```text [Sub(#Ef,#Es)] [Sub(#Eu,#Ed)] @@ -195,19 +211,27 @@ outer Sub is legal and renders by descending into the inputs just far enough: [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. +`#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 -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`: +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`, both into one Sub: -// signature(node0) == signature(node1) == "Sp" (S + price) +// 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 }) ``` @@ -216,8 +240,8 @@ role into a `Sub`) is rejected — both SMAs have signature `Sp`: `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: +the shortest sibling-unique prefix (`#…`), with the positional letter kept for +the equal-signature (interchangeable) case: ```rust let stubs: Vec = if slots.len() > 1 { @@ -231,11 +255,10 @@ 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 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 { - let sigs = fan_in_source_signatures(node, &edges, &input_roles, &nodes); - if has_duplicate(&sigs) { + if let Some(dup) = colliding_pair_with_unaliased_param(node, &edges, &input_roles, &nodes) { return Err(CompileError::IndistinguishableFanIn { node }); } } @@ -246,44 +269,49 @@ for node in 0..item_count { - **`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. + (`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 - 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 + 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 equal sibling signatures, -`Err(IndistinguishableFanIn)`; else continue as today. +**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 → `#id`; structure only, no `eval`. +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`). -- **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. +- **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 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. + sources (roles, composites) and state/delay nodes, so it cannot loop. ## Testing strategy @@ -291,14 +319,16 @@ 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 })`. + 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 — 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)`. +- **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): @@ -308,41 +338,51 @@ Render (aura-cli): - **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). +- **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 — compilat labels via `Box::label()`, no fan-in stubs, no `#` identifier. C23 guard. -Fixture knock-on: +Fixture knock-on (the constraint-driven set): -- 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. +- 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 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). +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/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 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. + 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 a malformed blueprint (positional fallback; no - panic). +- [ ] 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