Files
Aura/docs/plans/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

25 KiB

Fan-in input distinguishability — Implementation Plan

Parent spec: docs/specs/0021-fan-in-distinguishability.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Make a fan-in node whose colliding sources hide an unnamed configuration axis illegal at construction, and render fan-in inputs as source-derived recursive-signature identifiers instead of positional #A/#B.

Architecture: A shared signature_of(&Composite, node) helper in aura-engine is the single source of truth for a node's recursive authoring identity. The CLI render (leaf_label) abbreviates it to the shortest sibling-unique prefix; the engine construction check (inline_composite) rejects a fan-in where two sources share a signature and at least one has an unaliased param. Task order keeps the whole workspace green at every boundary: helper → CLI render+fixture → engine constraint+fixtures → ledger.

Tech Stack: aura-engine (blueprint.rs), aura-cli (graph.rs, main.rs), aura-core (LeafFactory), docs/design/INDEX.md.

Files this plan creates or modifies:

  • Modify: crates/aura-engine/src/blueprint.rssignature_of helper (Task 1); CompileError::IndistinguishableFanIn + inline_composite check + engine fixture aliases (Task 3).
  • Modify: crates/aura-engine/src/lib.rs — export signature_of.
  • Modify: crates/aura-cli/src/graph.rs:160-195leaf_label / new fan_in_identifiers (Task 2).
  • Modify: crates/aura-cli/src/main.rs:122-137sma_cross aliases; render test/golden/needle updates (Task 2).
  • Modify: docs/design/INDEX.md — C9 contract refinement (Task 4).
  • Test: engine signature unit tests, constraint accept/reject tests; CLI render tests + recaptured goldens.

Task 1: Engine — shared recursive signature_of helper

Files:

  • Modify: crates/aura-engine/src/blueprint.rs
  • Modify: crates/aura-engine/src/lib.rs
  • Test: crates/aura-engine/src/blueprint.rs (test module)

Pure addition — no behaviour change, nothing breaks. Establishes the single source of truth for a node's signature used by both later tasks.

  • Step 1: Write the failing test

In the blueprint.rs #[cfg(test)] module, add (the fixtures sma_cross() at :919 and fan_composite() at :581 already exist; Ema/Sma/Sub factories are imported there):

#[test]
fn signature_of_is_type_initial_plus_aliases_plus_recursive_inputs() {
    // EMA(fast) fed by role price -> "E" + "f"(alias) + "p"(role, no descent)
    let c = macd_like_signature_fixture(); // built below
    let sig = |n| signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), n);
    // node 0 = Ema aliased "fast", fed by role "price"
    assert_eq!(sig(0), "Efp");
    // node 2 = Sub(node0, node1) where node1 = Ema aliased "slow" -> "S"+inputs
    assert_eq!(sig(2), "SEfpEsp");
}

/// A composite: two aliased EMAs (fast, slow) on role `price`, into a Sub.
fn macd_like_signature_fixture() -> Composite {
    Composite::new(
        "sig",
        vec![Ema::factory().into(), Ema::factory().into(), Sub::factory().into()],
        vec![
            Edge { from: 0, to: 2, slot: 0, from_field: 0 },
            Edge { from: 1, to: 2, slot: 1, from_field: 0 },
        ],
        vec![Role {
            name: "price".into(),
            targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
        }],
        vec![
            ParamAlias { name: "fast".into(), node: 0, slot: 0 },
            ParamAlias { name: "slow".into(), node: 1, slot: 0 },
        ],
        vec![OutField { node: 2, field: 0, name: "x".into() }],
    )
}
  • Step 2: Run test to verify it fails

Run: cargo test -p aura-engine signature_of_is_type_initial Expected: FAIL — cannot find function signature_of in this scope.

  • Step 3: Write the helper

In blueprint.rs (module-level pub fn, near param_space/collect_params):

Operate on the destructured pieces (not &Composite), so the engine check can run after inline_composite's destructure and the CLI can call it via the composite accessors. Add near param_space/collect_params:

/// The recursive authoring signature of interior node `node`: the type initial,
/// then one initial per declared param alias (declared order), then each wired
/// input's signature in slot order — recursing into interior-leaf sources,
/// stopping at a named source (role name / nested-composite name). Single source
/// of truth for the fan-in distinguishability check (collision = equal
/// signatures) and the CLI render (shortest sibling-unique prefix). Terminates:
/// the dataflow is a DAG (C5) and the descent stops at named ports.
pub fn signature_of(
    nodes: &[BlueprintNode],
    edges: &[Edge],
    roles: &[Role],
    aliases: &[ParamAlias],
    node: usize,
) -> String {
    let mut s = String::new();
    match &nodes[node] {
        BlueprintNode::Leaf(f) => {
            if let Some(ch) = f.label().chars().next() {
                s.push(ch);
            }
            for a in aliases.iter().filter(|a| a.node == node) {
                if let Some(ch) = a.name.chars().next() {
                    s.push(ch);
                }
            }
            // wired input slots in slot order; per slot, the source's signature
            // (interior edge -> recurse; role -> the role name, no descent)
            let mut slotted: Vec<(usize, String)> = Vec::new();
            for e in edges.iter().filter(|e| e.to == node) {
                slotted.push((e.slot, signature_of(nodes, edges, roles, aliases, e.from)));
            }
            for r in roles {
                for t in r.targets.iter().filter(|t| t.node == node) {
                    slotted.push((t.slot, r.name.clone()));
                }
            }
            slotted.sort_by_key(|(slot, _)| *slot);
            for (_, sig) in slotted {
                s.push_str(&sig);
            }
        }
        BlueprintNode::Composite(inner) => {
            if let Some(ch) = inner.name().chars().next() {
                s.push(ch);
            }
        }
    }
    s
}
  • Step 4: Run test to verify it passes

Run: cargo test -p aura-engine signature_of_is_type_initial Expected: PASS.

  • Step 5: Export the helper

In crates/aura-engine/src/lib.rs, add signature_of to the pub use blueprint::{...} list (alongside CompileError, Composite).

  • Step 6: Workspace still green

Run: cargo test --workspace Expected: PASS (pure addition; no existing behaviour changed).


Task 2: CLI — source-derived render identifiers + sma_cross aliases

Files:

  • Modify: crates/aura-cli/src/graph.rs:160-195
  • Modify: crates/aura-cli/src/main.rs:122-137 and render tests
  • Test: crates/aura-cli/src/main.rs

The engine has no constraint yet, so an aliased CLI sma_cross renders fine and the goldens move once. After this task the whole CLI suite is green and the workspace stays green.

  • Step 1: Add fast/slow aliases to the CLI sma_cross fixture

In main.rs:122 fn sma_cross, the empty params vec (:134) becomes:

vec![
    ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast SMA length
    ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow SMA length
],
  • Step 2: Replace the positional stub block in leaf_label

In graph.rs, replace the stub-construction block (:182-186):

let stubs: Vec<String> = if slots.len() > 1 {
    slots.iter().map(|s| format!("#{}", (b'A' + *s as u8) as char)).collect()
} else {
    Vec::new()
};

with a call to a new helper, and add the helper below leaf_label:

let stubs: Vec<String> = if slots.len() > 1 {
    fan_in_identifiers(c, index, &slots)
} else {
    Vec::new()
};
/// One `#…` identifier per wired slot of a fan-in leaf, in slot order.
/// - A role-fed slot uses the role name verbatim (`#price`), never shortened.
/// - An interior-fed slot uses its source signature, never shorter than the
///   source's **base** (type initial + alias initials), extended into the
///   recursive tail only as far as needed to be unique among the siblings.
/// - Two siblings with equal full signatures (interchangeable inputs) cannot be
///   separated — those slots fall back to the positional letter `#A`. The engine
///   constraint guarantees no configuration-distinct pair fully collides, so a
///   valid blueprint reaches the fallback only for genuinely-interchangeable
///   inputs.
fn fan_in_identifiers(c: &Composite, index: usize, slots: &[usize]) -> Vec<String> {
    // per slot: (slot, signature, base_len, is_role)
    let srcs: Vec<(usize, String, usize, bool)> = slots
        .iter()
        .map(|&slot| {
            let (sig, base, is_role) = slot_source(c, index, slot);
            (slot, sig, base, is_role)
        })
        .collect();
    srcs.iter()
        .map(|(slot, sig, base, is_role)| {
            if *is_role {
                return format!("#{sig}"); // role name verbatim
            }
            let others: Vec<&String> =
                srcs.iter().filter(|(s, _, _, _)| s != slot).map(|(_, x, _, _)| x).collect();
            match unique_prefix_from(sig, *base, &others) {
                Some(p) => format!("#{p}"),
                None => format!("#{}", (b'A' + *slot as u8) as char), // interchangeable fallback
            }
        })
        .collect()
}

/// The source feeding `(index, slot)`: `(signature, base_len, is_role)`. A role
/// returns its name verbatim with `is_role = true` (base_len unused); an interior
/// producer returns its `signature_of` and its base length (type initial + alias
/// initials).
fn slot_source(c: &Composite, index: usize, slot: usize) -> (String, usize, bool) {
    for e in c.edges() {
        if e.to == index && e.slot == slot {
            let sig = signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), e.from);
            return (sig, signature_base_len(c, e.from), false);
        }
    }
    for r in c.input_roles() {
        if r.targets.iter().any(|t| t.node == index && t.slot == slot) {
            return (r.name.clone(), 0, true);
        }
    }
    (String::new(), 0, false)
}

/// The base length of an interior node's signature: 1 (type / composite-name
/// initial) plus one per declared param alias on that node — the minimum the
/// rendered identifier never goes below.
fn signature_base_len(c: &Composite, node: usize) -> usize {
    match &c.nodes()[node] {
        BlueprintNode::Leaf(_) => 1 + c.params().iter().filter(|a| a.node == node).count(),
        BlueprintNode::Composite(_) => 1,
    }
}

/// The shortest prefix of `sig` of length ≥ `base` that no `other` starts with —
/// i.e. distinguishes `sig` from all siblings while never dropping below the
/// base. `None` when some `other` equals `sig` in full (inseparable —
/// interchangeable, caller uses the positional fallback).
fn unique_prefix_from(sig: &str, base: usize, others: &[&String]) -> Option<String> {
    if others.iter().any(|o| o.as_str() == sig) {
        return None;
    }
    let chars: Vec<char> = sig.chars().collect();
    if chars.is_empty() {
        return Some(String::new()); // degenerate (no producer); not reached for a wired slot
    }
    let start = base.max(1).min(chars.len());
    for len in start..=chars.len() {
        let prefix: String = chars[..len].iter().collect();
        if others.iter().all(|o| !o.starts_with(&prefix)) {
            return Some(prefix);
        }
    }
    Some(sig.to_string())
}

Add use aura_engine::signature_of; to the imports at the top of graph.rs (the existing use aura_engine::{...} line).

  • Step 3: Update the MACD render asserts

In main.rs macd_blueprint_renders_a_nested_composite_definition (:585), replace the single assert at :598:

assert!(out.contains("[Sub(#A,#B)]"), "Sub shows its two ordered inputs: {out}");

with two distinct-label asserts:

assert!(out.contains("[Sub(#Ef,#Es)]"), "MACD line Sub is fast-EMA minus slow-EMA: {out}");
assert!(out.contains("[Sub(#S,#Es)]"), "histogram Sub is the line minus signal-EMA: {out}");
  • Step 4: Update the sample needle test

In main.rs blueprint_view_defines_each_composite_once (:397), the needle array (:402) changes [SMA] and [Sub(#A,#B)]:

for needle in ["[SMA(fast)]", "[SMA(slow)]", "[Sub(#Sf,#Ss)]", "[price]", "[cross]"] {
  • Step 5: Re-capture blueprint_view_golden

The golden string in blueprint_view_golden (:491-530) is layout-exact and must be regenerated, not hand-edited.

Run: cargo run -q --bin aura -- graph Copy the emitted where: … sma_cross(fast:i64, slow:i64) -> (cross): … block verbatim into the golden literal, replacing the old sma_cross() / [SMA] / [Sub(#A,#B)] lines. Expected after edit: the golden shows sma_cross(fast:i64, slow:i64) -> (cross), [SMA(fast)], [SMA(slow)], [Sub(#Sf,#Ss)].

  • Step 6: Add a focused render unit test

In main.rs test module, add a test on hand-built composites covering the cases the goldens don't isolate:

#[test]
fn fan_in_identifiers_are_source_derived_and_scoped_per_node_call() {
    use aura_engine::{Blueprint, BlueprintNode};
    // role passthrough: a Sub fed by role `price` + an EMA(slow) ->
    //   #price (role name) and #Es
    let c = Composite::new(
        "roles",
        vec![Ema::factory().into(), Sub::factory().into()],
        vec![Edge { from: 0, to: 1, slot: 1, from_field: 0 }],
        vec![Role { name: "price".into(), targets: vec![Target { node: 1, slot: 0 }] }],
        vec![ParamAlias { name: "slow".into(), node: 0, slot: 0 }],
        vec![OutField { node: 1, field: 0, name: "o".into() }],
    );
    let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
    let out = graph::render_blueprint(&bp, graph::Color::Plain);
    assert!(out.contains("[Sub(#price,#Es)]"), "role name verbatim + source-derived: {out}");
}

#[test]
fn fan_in_identifiers_descend_into_bare_combinators() {
    use aura_engine::{Blueprint, BlueprintNode};
    // Sub( Sub(EMA fast, EMA slow), Sub(EMA up, EMA down) ): the two inner Subs
    // are param-less but have distinct recursive signatures (SEf… vs SEu…), so
    // the outer Sub descends just far enough -> [Sub(#SEf,#SEu)].
    let c = Composite::new(
        "nest",
        vec![
            Ema::factory().into(), // 0 fast
            Ema::factory().into(), // 1 slow
            Ema::factory().into(), // 2 up
            Ema::factory().into(), // 3 down
            Sub::factory().into(), // 4 = Sub(0,1)
            Sub::factory().into(), // 5 = Sub(2,3)
            Sub::factory().into(), // 6 = Sub(4,5)  (the outer fan-in)
        ],
        vec![
            Edge { from: 0, to: 4, slot: 0, from_field: 0 },
            Edge { from: 1, to: 4, slot: 1, from_field: 0 },
            Edge { from: 2, to: 5, slot: 0, from_field: 0 },
            Edge { from: 3, to: 5, slot: 1, from_field: 0 },
            Edge { from: 4, to: 6, slot: 0, from_field: 0 },
            Edge { from: 5, to: 6, slot: 1, from_field: 0 },
        ],
        vec![Role {
            name: "price".into(),
            targets: vec![
                Target { node: 0, slot: 0 },
                Target { node: 1, slot: 0 },
                Target { node: 2, slot: 0 },
                Target { node: 3, slot: 0 },
            ],
        }],
        vec![
            ParamAlias { name: "fast".into(), node: 0, slot: 0 },
            ParamAlias { name: "slow".into(), node: 1, slot: 0 },
            ParamAlias { name: "up".into(), node: 2, slot: 0 },
            ParamAlias { name: "down".into(), node: 3, slot: 0 },
        ],
        vec![OutField { node: 6, field: 0, name: "o".into() }],
    );
    let bp = Blueprint::new(vec![BlueprintNode::Composite(c)], vec![], vec![]);
    let out = graph::render_blueprint(&bp, graph::Color::Plain);
    assert!(out.contains("[Sub(#SEf,#SEu)]"), "outer Sub descends into inner Subs: {out}");
    assert!(out.contains("[Sub(#Ef,#Es)]"), "inner Sub uses EMA aliases: {out}");
}

(The interchangeable [Join(#A,#B)] positional fallback is pinned engine-side by interchangeable_fan_in_allowed plus a render check is unnecessary — the fallback path is the unchanged positional branch.)

  • Step 7: Confirm compiled_view_golden is byte-stable

Run: cargo test -p aura-cli compiled_view_golden Expected: PASS unchanged — the flat graph carries no aliases / # identifiers (C23 guard). If it fails, the render change leaked into the compiled view — stop and inspect; do not re-capture this golden.

  • Step 8: Full CLI + workspace green

Run: cargo test --workspace Expected: PASS. (Engine untouched this task; CLI self-consistent with aliased sma_cross + source-derived render.)


Task 3: Engine — IndistinguishableFanIn constraint + fixture aliases

Files:

  • Modify: crates/aura-engine/src/blueprint.rs (CompileError, inline_composite, engine fixtures)
  • Test: crates/aura-engine/src/blueprint.rs

The CLI sma_cross is already aliased (Task 2), so adding the constraint does not break the CLI. RED-first: the reject test before the check.

  • Step 1: Write the failing constraint test

In the blueprint.rs test module, beside bad_interior_index_rejected (~:782):

#[test]
fn indistinguishable_fan_in_rejected() {
    // two alias-less Sma (each an unaliased `length`) on role price into a Sub:
    // signatures collide ("Sp"=="Sp") and a param is unaliased -> fault.
    let c = Composite::new(
        "ambig",
        vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
        vec![
            Edge { from: 0, to: 2, slot: 0, from_field: 0 },
            Edge { from: 1, to: 2, slot: 1, from_field: 0 },
        ],
        vec![Role {
            name: "price".into(),
            targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
        }],
        vec![],
        vec![OutField { node: 2, field: 0, name: "x".into() }],
    );
    let bp = Blueprint::new(
        vec![BlueprintNode::Composite(c)],
        vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
        vec![],
    );
    assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
}

#[test]
fn interchangeable_fan_in_allowed() {
    // fan_composite: two param-less Pass into a Join, equal signatures but no
    // unaliased param -> interchangeable -> Ok.
    let bp = Blueprint::new(
        vec![BlueprintNode::Composite(fan_composite()), sink_f64()],
        vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
        vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
    );
    assert!(bp.compile().is_ok(), "param-less interchangeable fan-in must compile");
}
  • Step 2: Run to verify the reject test fails

Run: cargo test -p aura-engine indistinguishable_fan_in_rejected Expected: FAIL — no variant named IndistinguishableFanIn (does not compile yet) or, once the variant exists but no check, the composite compiles Ok so the assert fails.

  • Step 3: Add the CompileError variant

In blueprint.rs:127 enum CompileError, add:

/// A fan-in node (>1 input) at interior index `node` has two input slots fed by
/// sources with identical signatures where at least one source carries an
/// unaliased param slot — the inputs differ in configuration but share a
/// rendered identity. Name the distinguishing param (e.g. fast/slow).
IndistinguishableFanIn { node: usize },
  • Step 4: Add the check in inline_composite, after the alias-validity loop

The check must run after the existing alias-validity loop (:361-367, which raises BadInteriorIndex for a bogus alias index — out_of_range_param_alias_rejected depends on that ordering) and before lower_items (:370). It operates on the already-destructured pieces (nodes, edges, input_roles, param_aliases — bound at :356). Insert directly after the alias loop closes at :367:

// Fan-in distinguishability: for each node with >1 wired input slot, a
// collision (equal source signatures) is a fault only when at least one
// colliding source has an unaliased param slot — the unnamed configuration
// axis. Runs after alias-validity (so a bad alias is BadInteriorIndex first)
// and before lowering. signature_of/leaf_has_unaliased_param take the pieces.
for node in 0..nodes.len() {
    let mut sources: Vec<(usize, String, bool)> = Vec::new(); // (slot, sig, has_unaliased_param)
    for e in edges.iter().filter(|e| e.to == node) {
        sources.push((
            e.slot,
            signature_of(&nodes, &edges, &input_roles, &param_aliases, e.from),
            leaf_has_unaliased_param(&nodes, &param_aliases, e.from),
        ));
    }
    for r in &input_roles {
        for t in r.targets.iter().filter(|t| t.node == node) {
            sources.push((t.slot, r.name.clone(), false)); // a role has no param of its own
        }
    }
    if sources.len() < 2 {
        continue;
    }
    for i in 0..sources.len() {
        for j in (i + 1)..sources.len() {
            if sources[i].1 == sources[j].1 && (sources[i].2 || sources[j].2) {
                return Err(CompileError::IndistinguishableFanIn { node });
            }
        }
    }
}

and a helper near signature_of:

/// Whether interior leaf `node` has at least one param slot with no alias in
/// `aliases` — the unnamed configuration axis the fan-in rule keys on. A non-leaf
/// (nested composite) reports `false`.
fn leaf_has_unaliased_param(nodes: &[BlueprintNode], aliases: &[ParamAlias], node: usize) -> bool {
    match &nodes[node] {
        BlueprintNode::Leaf(f) => {
            let n_params = f.params().len();
            let aliased = aliases.iter().filter(|a| a.node == node).count();
            n_params > aliased
        }
        BlueprintNode::Composite(_) => false,
    }
}

(Note: param_alias_relabels_param_space_name_in_place and param_space_is_flat_path_qualified_and_slot_disambiguated call only param_space(), never compile(), so the check does not reach them — they stay green unchanged, including the deliberate duplicate *.length names.)

  • Step 5: Run the constraint tests

Run: cargo test -p aura-engine indistinguishable_fan_in_rejected interchangeable_fan_in_allowed Expected: both PASS.

  • Step 6: Correct the engine-local param-bearing fixtures

Two fixtures actually reach compile() with the param-bearing alias-less shape and now fail; correct exactly those:

  1. Engine-local sma_cross() (:919) — used by composite_sma_cross_harness (eight tests). Its empty params vec (:931) gains:
vec![
    ParamAlias { name: "fast".into(), node: 0, slot: 0 },
    ParamAlias { name: "slow".into(), node: 1, slot: 0 },
],

Then re-pin the name assert in param_space_mirrors_compiled_flat_node_param_order (:1149-1152): ["sma_cross.length", "sma_cross.length", "scale"]["sma_cross.fast", "sma_cross.slow", "scale"].

  1. The fast_slow fixture inside param_space_mirrors_compiled_flat_node_param_order_under_nesting (:1321) — this test calls compile_with_params (:1352). Its empty params vec (:1332) gains the same fast/slow aliases. This test asserts only per-slot kinds and order (:1355+), not names, so it stays green after the alias (aliases rename, don't reorder).

Do not touch the separate fast_slow in param_space_is_flat_path_qualified_and_slot_disambiguated (:1265): it calls only param_space(), never compile(), so the constraint never reaches it; it deliberately demonstrates two same-type slots sharing the fast_slow.length name disambiguated by slot, and its name assert (:1292-1300) must stay as-is.

  • Step 7: Full engine + workspace green

Run: cargo test --workspace Expected: PASS. fan_composite-based tests (:599, :678) stay green (interchangeable); the hand-wired flat fixtures (main.rs:46, blueprint.rs:877) bypass inline_composite and stay green.

  • Step 8: Lint clean

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: no warnings.


Task 4: Design-ledger contract refinement

Files:

  • Modify: docs/design/INDEX.md

  • Step 1: Add the refinement note

Under the C9 contract (composite well-formedness / authoring-level identity), add a dated refinement paragraph mirroring the existing **Refinement (…).** shape:

**Refinement (fan-in distinguishability, 2026-06-08).** A fan-in node (>1 input)
is well-formed only if its colliding sources — sources with equal recursive
signatures (type initial + alias initials + recursive input signatures) — do not
hide an unnamed configuration axis: a collision is a `CompileError`
(`IndistinguishableFanIn`) when at least one colliding source carries an
unaliased param slot. Genuinely-interchangeable sources (equal signatures, no
param) stay legal. Construction-phase only; the flat graph stays name-free (C23).
The graph view renders each fan-in input as the shortest sibling-unique prefix
of its source signature.
  • Step 2: Confirm no doc build breakage

Run: cargo doc --workspace --no-deps 2>&1 Expected: no new warnings (markdown-only change).