feat(aura): fan-in input distinguishability — construction constraint + source-derived render
A fan-in node (>1 input) whose colliding sources hide an unnamed configuration axis is now illegal at construction, and the definition view renders each fan-in input as a source-derived recursive-signature identifier instead of the positional, meaningless #A/#B. The root defect was sma_cross: two Sma into a Sub with unaliased length params, rendering sma_cross() -> (cross) with two indistinguishable [SMA] and [Sub(#A,#B)] — the author meant fast vs slow but the identity hid it. Now it renders sma_cross(fast:i64, slow:i64) with [SMA(fast)]/[SMA(slow)] and [Sub(#Sf,#Ss)]. Shape (single source of truth for "signature" shared by engine + CLI): - aura-engine signature_of(): a node's recursive authoring identity — type initial + alias initials + each wired input's signature (recursing into interior leaves, stopping at named roles/composites at their initial). - aura-cli leaf_label/fan_in_identifiers: shortest sibling-unique prefix of a source's signature, never below its base (type + alias initials); a role-fed slot renders its name verbatim (#price); equal-signature interchangeable inputs keep the positional letter. - aura-engine IndistinguishableFanIn: a signature collision is a fault only when a colliding source carries an unaliased param (the unnamed axis); param-less interchangeable sources (fan_composite's Pass/Pass) stay legal. Param-aware criterion (refined during design): keeps the blast radius to the param-bearing alias-less fixtures (sma_cross CLI + engine, the nested fast_slow); fan_composite and the hand-wired flat fixtures stay green. Placement decision (deviates from the plan): the constraint runs as a structural pre-pass (check_fan_in_distinguishability) at the head of compile_with_params, BEFORE the param-arity gate — not inside inline_composite as the plan drafted. Reason: the no-param compile() of a param-bearing composite would hit ParamArity first and preempt the fault; the spec mandates a structural check needing no param values, so the pre-pass is the spec-aligned home. Alias-validity ordering (BadInteriorIndex before the fan-in fault) is preserved at the pre-pass head. C23 untouched: the check is construction-phase; the compilat stays name-free (compiled_view_golden byte-stable). Ledger C9 carries the refinement. Known debt (non-gating): the alias-index validity check now exists in both inline_composite and the pre-pass (the pre-pass reaches every composite first, making inline_composite's copy effectively dead). Left for a separate tidy — a 5-line, correctness-neutral removal with its own test surface. closes #44
This commit is contained in:
@@ -16,7 +16,7 @@
|
||||
use ascii_dag::graph::{Graph, RenderMode};
|
||||
use ascii_dag::render::colors::Palette;
|
||||
use aura_core::{LeafFactory, Node, ScalarKind};
|
||||
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, SourceSpec};
|
||||
use aura_engine::{signature_of, Blueprint, BlueprintNode, Composite, Edge, SourceSpec};
|
||||
|
||||
/// Edge colouring for a rendered graph. `Plain` is monochrome — golden-stable and
|
||||
/// pipe-safe (no escape codes when redirected to a file). `Ansi` emits per-edge
|
||||
@@ -180,7 +180,7 @@ fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String {
|
||||
}
|
||||
slots.sort_unstable();
|
||||
let stubs: Vec<String> = if slots.len() > 1 {
|
||||
slots.iter().map(|s| format!("#{}", (b'A' + *s as u8) as char)).collect()
|
||||
fan_in_identifiers(c, index, &slots)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -194,6 +194,91 @@ fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String {
|
||||
format!("{}({})", factory.label(), parts.join("; "))
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
/// Render one composite's interior as a flat graph: interior leaves as
|
||||
/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded
|
||||
/// in via `leaf_label`), nested composites as opaque `[name]`, an `[<name>]`
|
||||
|
||||
Reference in New Issue
Block a user