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:
2026-06-08 15:50:19 +02:00
parent 3aa124f815
commit 3f4d756ed2
6 changed files with 426 additions and 21 deletions
+87 -2
View File
@@ -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>]`
+82 -14
View File
@@ -131,7 +131,10 @@ fn sma_cross(name: &str) -> Composite {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![],
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast SMA length
ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow SMA length
],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
)
}
@@ -399,7 +402,7 @@ mod tests {
let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain);
// the sma_cross body is defined exactly once, with its interior + ports
assert_eq!(out.matches("sma_cross(").count(), 1, "definition not rendered once:\n{out}");
for needle in ["[SMA]", "[Sub(#A,#B)]", "[price]", "[cross]"] {
for needle in ["[SMA(fast)]", "[SMA(slow)]", "[Sub(#Sf,#Ss)]", "[price]", "[cross]"] {
assert!(out.contains(needle), "missing {needle} in definition:\n{out}");
}
}
@@ -511,18 +514,18 @@ mod tests {
where:
sma_cross() -> (cross):
sma_cross(fast:i64, slow:i64) -> (cross):
[price]
┌───└───┐
[SMA] [SMA]
└───┌───┘
[Sub(#A,#B)]
[cross]
[price]
┌──────└──────┐
[SMA(fast)] [SMA(slow)]
└──────┌──────┘
[Sub(#Sf,#Ss)]
[cross]
"#;
@@ -595,12 +598,77 @@ sma_cross() -> (cross):
assert!(out.contains("[EMA(fast)]"), "fast EMA folds its param: {out}");
assert!(out.contains("[EMA(slow)]"), "slow EMA folds its param: {out}");
assert!(out.contains("[EMA(signal)]"), "signal EMA folds its param: {out}");
assert!(out.contains("[Sub(#A,#B)]"), "Sub shows its two ordered inputs: {out}");
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}");
assert!(out.contains("[price]"), "named MACD input role: {out}");
assert!(!out.contains("[param:"), "param marker nodes removed: {out}");
assert!(!out.contains("[out:"), "output prefix dropped: {out}");
}
#[test]
fn fan_in_identifiers_are_source_derived_and_scoped_per_node_call() {
// 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() {
// 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}");
}
#[test]
fn ansi_colour_emits_escapes_only_when_requested() {
// `Color::Ansi` adds per-edge ANSI escapes for an interactive terminal;