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>]`
|
||||
|
||||
+82
-14
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user