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;
+23
View File
@@ -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}"
);
}
+222 -4
View File
@@ -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])
+3 -1
View File
@@ -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
+9
View File
@@ -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