From 3f4d756ed2c2192bd777fe0f9494fcbd02133214 Mon Sep 17 00:00:00 2001 From: Brummel Date: Mon, 8 Jun 2026 15:50:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(aura):=20fan-in=20input=20distinguishabili?= =?UTF-8?q?ty=20=E2=80=94=20construction=20constraint=20+=20source-derived?= =?UTF-8?q?=20render?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/src/graph.rs | 89 ++++++++++- crates/aura-cli/src/main.rs | 96 ++++++++++-- crates/aura-cli/tests/cli_run.rs | 23 +++ crates/aura-engine/src/blueprint.rs | 226 +++++++++++++++++++++++++++- crates/aura-engine/src/lib.rs | 4 +- docs/design/INDEX.md | 9 ++ 6 files changed, 426 insertions(+), 21 deletions(-) diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs index 01be71b..92ad68c 100644 --- a/crates/aura-cli/src/graph.rs +++ b/crates/aura-cli/src/graph.rs @@ -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 = 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 { + // 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 { + if others.iter().any(|o| o.as_str() == sig) { + return None; + } + let chars: Vec = 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 `[]` diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 3f6f345..845f758 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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; diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 698ace8..c728258 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -160,3 +160,26 @@ fn help_flag_prints_usage_to_stdout_and_exits_zero() { out.status ); } + +/// Property: the `aura graph` blueprint view renders a fan-in node's inputs as +/// source-derived signature identifiers, not positional `#A`/`#B`. The sample +/// `sma_cross` Sub combines a fast and a slow SMA; the two inputs must therefore +/// render as the distinct prefixes `#Sf` / `#Ss` (SMA + alias initial), so a +/// non-commutative Sub's argument order is legible at the boundary. Driven +/// through the built binary (output piped → Plain, byte-clean) so it pins the +/// observable CLI contract, not just the in-process renderer. +#[test] +fn graph_renders_source_derived_fan_in_identifiers() { + let out = Command::new(BIN).arg("graph").output().expect("spawn aura graph"); + assert_eq!(out.status.code(), Some(0), "`aura graph` exit: {:?}", out.status); + let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); + assert!( + stdout.contains("[Sub(#Sf,#Ss)]"), + "fan-in inputs must be source-derived (#Sf/#Ss), not positional: {stdout}" + ); + // the retired positional form must not reappear. + assert!( + !stdout.contains("[Sub(#A,#B)]"), + "positional #A/#B fan-in stubs must be gone: {stdout}" + ); +} diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index cd4c92e..9cecd9d 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -139,6 +139,11 @@ pub enum CompileError { ParamKindMismatch { slot: usize, expected: ScalarKind, got: ScalarKind }, /// The injected vector's length does not equal the sum of declared params. ParamArity { expected: usize, got: usize }, + /// 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 }, } /// The root graph-as-data, before compilation: blueprint items + sources + edges, @@ -192,6 +197,12 @@ impl Blueprint { self, params: &[Scalar], ) -> Result<(Vec>, Vec, Vec), CompileError> { + // structural validation (param-value-independent, spec §"the check is + // structural"): reject an indistinguishable fan-in before the arity gate, + // so a structurally-ambiguous blueprint is rejected whether or not params + // were injected. + check_fan_in_distinguishability(&self.nodes)?; + let expected = self.param_space().len(); if params.len() != expected { return Err(CompileError::ParamArity { expected, got: params.len() }); @@ -245,6 +256,137 @@ impl Blueprint { } } +/// 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 initial, 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) { + // a role is a named source: it contributes its initial, no + // descent (matching the nested-composite branch below). + let init = r.name.chars().next().map(String::from).unwrap_or_default(); + slotted.push((t.slot, init)); + } + } + 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 +} + +/// Structural validation (param-value-independent): walk every composite in the +/// blueprint and reject an indistinguishable fan-in. Recurses into nested +/// composites so the check holds at every level. Runs before the arity gate (a +/// structural fault does not depend on injected param values, spec §structural). +fn check_fan_in_distinguishability(items: &[BlueprintNode]) -> Result<(), CompileError> { + for item in items { + if let BlueprintNode::Composite(c) = item { + check_composite_fan_in(c.nodes(), c.edges(), c.input_roles(), c.params())?; + check_fan_in_distinguishability(c.nodes())?; + } + } + Ok(()) +} + +/// The per-composite fan-in distinguishability check on the destructured pieces: +/// for each interior 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. A role contributes its name and +/// has no param of its own. +fn check_composite_fan_in( + nodes: &[BlueprintNode], + edges: &[Edge], + input_roles: &[Role], + param_aliases: &[ParamAlias], +) -> Result<(), CompileError> { + // alias-validity first (mirrors `inline_composite`): a bogus alias index is a + // `BadInteriorIndex`, ordered ahead of the fan-in fault so a bad alias surfaces + // as the index error, not as an incidental collision. + for a in param_aliases { + let ok = a.node < nodes.len() + && matches!(&nodes[a.node], BlueprintNode::Leaf(f) if a.slot < f.params().len()); + if !ok { + return Err(CompileError::BadInteriorIndex); + } + } + + 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)); + } + } + 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 }); + } + } + } + } + Ok(()) +} + +/// 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, + } +} + /// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its /// declared params under the running path prefix; a composite pushes its `name()` /// onto the path and recurses, passing its own param aliases down. A leaf param @@ -475,7 +617,7 @@ fn slot_kind(t: Target, flat_nodes: &[Box]) -> Result "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() }], + ) + } + #[test] fn single_composite_inlines_with_offset_fan_and_output() { // composite as item 0; a source into its role 0; an edge out of it to a sink. @@ -782,6 +956,44 @@ mod tests { assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex)); } + #[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"); + } + #[test] fn role_kind_mismatch_rejected() { // role 0 fans into a Pass1 f64 slot AND a SinkI64 i64 slot -> mismatch @@ -928,7 +1140,10 @@ mod tests { 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 }, + ParamAlias { name: "slow".into(), node: 1, slot: 0 }, + ], vec![OutField { node: 2, field: 0, name: "out".into() }], ) } @@ -1148,7 +1363,7 @@ mod tests { // scale (F64); Sub/SimBroker/Recorder declare none assert_eq!( space.iter().map(|p| p.name.as_str()).collect::>(), - ["sma_cross.length", "sma_cross.length", "scale"], + ["sma_cross.fast", "sma_cross.slow", "scale"], ); assert_eq!( space.iter().map(|p| p.kind).collect::>(), @@ -1329,7 +1544,10 @@ mod tests { 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 }, + ParamAlias { name: "slow".into(), node: 1, slot: 0 }, + ], vec![OutField { node: 2, field: 0, name: "out".into() }], ); // outer composite "strategy": the inner composite + a LinComb([1,-1]) diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index fa234c2..dbec361 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -35,7 +35,9 @@ mod blueprint; mod harness; mod report; -pub use blueprint::{Blueprint, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role}; +pub use blueprint::{ + signature_of, Blueprint, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role, +}; pub use harness::{BootstrapError, Edge, Harness, SourceSpec, Target}; pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport}; // #29: re-export the core scalar vocabulary a Blueprint builder needs diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 9257ef0..f808e08 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -322,6 +322,15 @@ are dropped at lowering — the compilat is wired by raw index and a dangling al rejected at compile (`BadInteriorIndex`), not silently lowered. The full composite boundary signature (named inputs, params, multi-outputs) is now legible without changing the compilat. +**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 compilat stays name-free (C23). +The graph view renders each fan-in input as the shortest sibling-unique prefix +of its source signature. ### C10 — Strategy output is an intent/exposure stream; position management is a decoupled derived layer; brokers are downstream nodes **Guarantee.** A strategy's primary, backtestable output is **not** an equity