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;
|
||||
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<Box<dyn Node>>, Vec<SourceSpec>, Vec<Edge>), 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<dyn Node>]) -> Result<ScalarKind, Comp
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_core::{Ctx, FieldSpec, Firing, InputSpec, NodeSchema, Timestamp};
|
||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// A 2-input f64 node, one f64 output. Test-local fixture (C9: examples for the
|
||||
@@ -595,6 +737,38 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[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() }],
|
||||
)
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>(),
|
||||
["sma_cross.length", "sma_cross.length", "scale"],
|
||||
["sma_cross.fast", "sma_cross.slow", "scale"],
|
||||
);
|
||||
assert_eq!(
|
||||
space.iter().map(|p| p.kind).collect::<Vec<_>>(),
|
||||
@@ -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])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user