feat(aura-core,aura-engine,aura-cli): node-instance naming retires ParamAlias

Every blueprint node now carries a name. A primitive builder gains an optional
instance name (Sma::builder().named("fast")); when omitted it defaults to the
node's type label, ASCII-lowercased verbatim ("SMA"->"sma", "SimBroker"->
"simbroker"). param_space() is now uniformly <node>.<param> at every level
including the root (sma_cross.fast.length, exposure.scale). Fan-in
distinguishability (C9) and signature_of re-key onto the node name: two same-type
siblings both defaulting to "sma" collide and IndistinguishableFanIn fires, so
one act -- naming the legs "fast"/"slow" -- fixes both the naming collision and
the fan-in check. The index-addressed ParamAlias overlay, Composite.params, the
Composite::new 5th argument, aliases_on, and check_alias_indices are removed
(Role.name and OutField.name untouched). Ledger C9 and C23 amended.

This is why the change is correct, not merely convenient: blueprints are
value-empty (C11), so the thing that would otherwise distinguish two SMAs --
their length -- is not present at construction; identity must come from a name,
not a deferred value. Closes the #56 friction surfaced by the param-space &
sweep milestone fieldtest (a freshly-authored 2-SMA cross was unbindable and
would not bootstrap without two hand-counted ParamAlias overlays).

Two plan defects were corrected during implementation and verified:
- the three new fan-in/path tests authored sma_cross at root level, which would
  qualify to "fast.length" (root prefix is empty) and is not the nested case the
  fan-in check inspects; nesting sma_cross under a root (a shared
  sma_cross_under_root helper) restores the asserted "sma_cross.fast.length" and
  IndistinguishableFanIn{node:2};
- three cycle-0030 named-binder tests bound the real harness by the old names
  (sma_cross.fast / scale) and were migrated to the new path strings, surfaced by
  the workspace test gate.

Verified by the orchestrator: cargo build/test --workspace green (198 tests,
0 red), clippy --all-targets -D warnings clean. model_to_json emits the type
label + factory param names (node-name-independent), so sample-model.json and the
graph_model golden are byte-identical (untouched). NodeSchema gained a Default
derive (the builder default-name test constructs an empty schema). fieldtests/
are frozen non-workspace records, not migrated.

closes #56
This commit is contained in:
2026-06-11 11:51:30 +02:00
parent d8900900b5
commit ffed8cc612
8 changed files with 298 additions and 455 deletions
+34 -40
View File
@@ -11,7 +11,7 @@ mod render;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, BlueprintNode, Composite, Edge, FlatGraph, Harness,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
OutField, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target,
};
use aura_registry::{rank_by, Registry};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
@@ -136,7 +136,11 @@ fn run_sample() -> RunReport {
fn sma_cross(name: &str) -> Composite {
Composite::new(
name,
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Sma::builder().named("fast").into(), // fast SMA leg
Sma::builder().named("slow").into(), // slow SMA leg
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -146,10 +150,6 @@ fn sma_cross(name: &str) -> Composite {
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
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() }],
)
}
@@ -190,7 +190,6 @@ fn sample_blueprint_with_sinks() -> (
],
source: Some(ScalarKind::F64),
}],
vec![], // params: the interior sma_cross carries the aliases
vec![], // output: the root ends in sinks, no re-export
);
(bp, rx_eq, rx_ex)
@@ -226,9 +225,9 @@ fn scalar_as_param_f64(s: &Scalar) -> f64 {
fn sweep_family() -> SweepFamily {
let bp = sample_blueprint_with_sinks().0;
let space = bp.param_space();
bp.axis("sma_cross.fast", [2, 3])
.axis("sma_cross.slow", [4, 5])
.axis("scale", [0.5])
bp.axis("sma_cross.fast.length", [2, 3])
.axis("sma_cross.slow.length", [4, 5])
.axis("exposure.scale", [0.5])
.sweep(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
@@ -338,11 +337,11 @@ fn macd(name: &str) -> Composite {
Composite::new(
name,
vec![
Ema::builder().into(), // 0 fast EMA
Ema::builder().into(), // 1 slow EMA
Sub::builder().into(), // 2 MACD line = fast slow
Ema::builder().into(), // 3 signal EMA of the MACD line
Sub::builder().into(), // 4 histogram = MACD line signal
Ema::builder().named("fast").into(), // 0 fast EMA
Ema::builder().named("slow").into(), // 1 slow EMA
Sub::builder().into(), // 2 MACD line = fast slow
Ema::builder().named("signal").into(), // 3 signal EMA of the MACD line
Sub::builder().into(), // 4 histogram = MACD line signal
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast → line[0]
@@ -359,11 +358,6 @@ fn macd(name: &str) -> Composite {
],
source: None,
}],
vec![
ParamAlias { name: "fast".into(), node: 0, slot: 0 }, // fast EMA length
ParamAlias { name: "slow".into(), node: 1, slot: 0 }, // slow EMA length
ParamAlias { name: "signal".into(), node: 3, slot: 0 }, // signal EMA length
],
vec![
OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line
OutField { node: 3, field: 0, name: "signal".into() }, // the signal line
@@ -402,7 +396,6 @@ fn macd_strategy_blueprint(
],
source: Some(ScalarKind::F64),
}],
vec![], // params: the interior macd carries the aliases
vec![], // output: the root ends in sinks, no re-export
)
}
@@ -513,9 +506,9 @@ mod tests {
// so a caller can bootstrap one point, run it, and drain both sinks.
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp
.with("sma_cross.fast", 2)
.with("sma_cross.slow", 4)
.with("scale", 0.5)
.with("sma_cross.fast.length", 2)
.with("sma_cross.slow.length", 4)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("sample blueprint compiles under a valid point");
h.run(vec![synthetic_prices()]);
@@ -534,10 +527,10 @@ mod tests {
for line in &lines {
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}");
}
assert!(lines[0].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}"#), "line0: {}", lines[0]);
assert!(lines[1].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5}"#), "line1: {}", lines[1]);
assert!(lines[2].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5}"#), "line2: {}", lines[2]);
assert!(lines[3].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5}"#), "line3: {}", lines[3]);
assert!(lines[0].contains(r#""params":{"sma_cross.fast.length":2,"sma_cross.slow.length":4,"exposure.scale":0.5}"#), "line0: {}", lines[0]);
assert!(lines[1].contains(r#""params":{"sma_cross.fast.length":2,"sma_cross.slow.length":5,"exposure.scale":0.5}"#), "line1: {}", lines[1]);
assert!(lines[2].contains(r#""params":{"sma_cross.fast.length":3,"sma_cross.slow.length":4,"exposure.scale":0.5}"#), "line2: {}", lines[2]);
assert!(lines[3].contains(r#""params":{"sma_cross.fast.length":3,"sma_cross.slow.length":5,"exposure.scale":0.5}"#), "line3: {}", lines[3]);
for line in &lines {
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");
@@ -577,24 +570,25 @@ mod tests {
}
/// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy
/// blueprint's swept param surface relabels the three otherwise-indistinguishable
/// EMA `length` slots to `macd.fast` / `macd.slow` / `macd.signal` — the named
/// composite boundary visible end-to-end through `param_space()`, with the slot
/// count and order unchanged (C23 — pure naming overlay, not curation: every
/// interior slot stays sweepable, the `scale` knob is unaffected).
/// blueprint's swept param surface qualifies the three otherwise-indistinguishable
/// EMA `length` slots by node name to `macd.fast.length` / `macd.slow.length` /
/// `macd.signal.length` — the named composite boundary visible end-to-end through
/// `param_space()`, with the slot count and order unchanged (C23 — node names are
/// non-load-bearing: every interior slot stays sweepable, the `exposure.scale`
/// knob is unaffected).
#[test]
fn macd_param_space_surfaces_the_three_named_aliases() {
fn macd_param_space_surfaces_the_three_named_legs() {
let names: Vec<String> =
macd_blueprint().param_space().into_iter().map(|p| p.name).collect();
// three aliased composite slots, in declared (fast, slow, signal) order,
// then the strategy-level Exposure `scale` (outside the composite, unaliased).
// three named composite-interior slots, in declared (fast, slow, signal)
// order, then the strategy-level Exposure `scale` (a root-level leaf).
assert_eq!(
names,
vec![
"macd.fast".to_string(),
"macd.slow".to_string(),
"macd.signal".to_string(),
"scale".to_string(),
"macd.fast.length".to_string(),
"macd.slow.length".to_string(),
"macd.signal.length".to_string(),
"exposure.scale".to_string(),
],
"MACD param surface must expose the three named EMA lengths + scale",
);
+1 -1
View File
@@ -211,7 +211,7 @@ fn sweep_prints_four_json_lines_and_exits_zero() {
// pin the first odometer point's manifest params, not the commit value.
assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}");
assert!(
first.contains("\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5}"),
first.contains("\"params\":{\"sma_cross.fast.length\":2,\"sma_cross.slow.length\":4,\"exposure.scale\":0.5}"),
"got: {stdout}"
);
let _ = std::fs::remove_dir_all(&cwd);
+36 -2
View File
@@ -76,6 +76,7 @@ pub struct ParamSpec {
/// pre-build (#43).
pub struct PrimitiveBuilder {
name: &'static str,
instance_name: Option<String>,
schema: NodeSchema,
// The build closure's type is exactly the recipe contract (a param slice in, a
// boxed node out); a type alias would not clarify it.
@@ -92,7 +93,20 @@ impl PrimitiveBuilder {
schema: NodeSchema,
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
) -> Self {
Self { name, schema, build: Box::new(build) }
Self { name, instance_name: None, schema, build: Box::new(build) }
}
/// Set this node instance's name. Must be non-empty.
pub fn named(mut self, name: &str) -> Self {
debug_assert!(!name.is_empty(), "node name must be non-empty");
self.instance_name = Some(name.to_string());
self
}
/// The resolved node name: the explicit instance name if set, else the
/// type label ASCII-lowercased verbatim (e.g. "SimBroker" -> "simbroker").
pub fn node_name(&self) -> String {
self.instance_name
.clone()
.unwrap_or_else(|| self.name.to_ascii_lowercase())
}
/// The full declared signature (read pre-build by `Composite::param_space`,
/// `BlueprintNode::signature`, and the renderer).
@@ -120,7 +134,7 @@ impl PrimitiveBuilder {
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer**
/// (sink role, C8) — a node with no output port. Built once at wiring, never on
/// the hot path — the `Vec`s are fine here.
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NodeSchema {
pub inputs: Vec<PortSpec>,
pub output: Vec<FieldSpec>,
@@ -215,6 +229,26 @@ mod tests {
assert_eq!(none.label(), "Sub");
}
#[test]
fn node_name_defaults_to_lowercased_type_label() {
// unnamed → the type label, ASCII-lowercased verbatim (no snake_case)
let unnamed = PrimitiveBuilder::new(
"SimBroker",
NodeSchema::default(),
|_| panic!("not built in this test"),
);
assert_eq!(unnamed.node_name(), "simbroker");
// explicit name overrides
let named = PrimitiveBuilder::new(
"SMA",
NodeSchema::default(),
|_| panic!("not built in this test"),
)
.named("fast");
assert_eq!(named.node_name(), "fast");
}
#[test]
fn primitive_builder_build_runs_the_closure() {
let f = PrimitiveBuilder::new(
+186 -369
View File
@@ -87,7 +87,7 @@ fn derive_signature(c: &Composite) -> NodeSchema {
})
.collect();
let mut params = Vec::new();
collect_params(c.nodes(), "", c.params(), &mut params);
collect_params(c.nodes(), "", &mut params);
NodeSchema { inputs, output, params }
}
@@ -118,17 +118,6 @@ pub struct Role {
pub source: Option<ScalarKind>,
}
/// A composite-level alias relabelling one interior leaf param slot's surface
/// name in `param_space()`. `node` is the interior item index, `slot` the param
/// slot within that leaf. Pure legibility: the alias relabels in place and never
/// reorders, adds, or removes a slot (C23 — identity stays the slot).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ParamAlias {
pub name: String,
pub node: usize,
pub slot: usize,
}
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
/// interior edges (local indices), input roles (role `r` fans into the interior
@@ -139,7 +128,6 @@ pub struct Composite {
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Role>,
params: Vec<ParamAlias>,
output: Vec<OutField>,
}
@@ -153,10 +141,9 @@ impl Composite {
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Role>,
params: Vec<ParamAlias>,
output: Vec<OutField>,
) -> Self {
Self { name: name.into(), nodes, edges, input_roles, params, output }
Self { name: name.into(), nodes, edges, input_roles, output }
}
/// The authored render name (cluster title, #13). Non-load-bearing.
@@ -177,11 +164,6 @@ impl Composite {
pub fn input_roles(&self) -> &[Role] {
&self.input_roles
}
/// The param aliases: each relabels one interior leaf param slot's surface
/// name in `param_space()` (pure naming overlay; identity stays the slot, C23).
pub fn params(&self) -> &[ParamAlias] {
&self.params
}
/// The exposed output record: each entry re-exports one interior
/// `(node, output-field)` under a boundary name (C8 — one port, K columns).
pub fn output(&self) -> &[OutField] {
@@ -189,12 +171,14 @@ impl Composite {
}
/// The aggregated, flat, path-qualified param-space (C12): every node's declared
/// params, concatenated in lowering order. The ROOT uses an empty path prefix
/// (its own name does not prefix — preserving the pre-refactor param names);
/// interior composite names prefix via the recursion in `collect_params`.
/// params, concatenated in lowering order. Each param is `<node>.<param>` (the
/// node name = its instance name, default = lowercased type label), with the
/// composite path prefixed at every level including the root — so a top-level
/// leaf carries its own node segment (e.g. `sma.length`). Interior composite
/// names prefix via the recursion in `collect_params`.
pub fn param_space(&self) -> Vec<ParamSpec> {
let mut out = Vec::new();
collect_params(&self.nodes, "", &self.params, &mut out);
collect_params(&self.nodes, "", &mut out);
out
}
@@ -269,8 +253,9 @@ impl Composite {
/// Begin binding this blueprint's knobs **by name** for a single run (the
/// fluent alternative to a positional `bootstrap_with_params` vector). The
/// bound name is the exact `param_space()` name — path-qualified for a knob
/// inside a composite (`sma_cross.fast`), bare for a root-level knob (`scale`).
/// bound name is the exact `param_space()` name — `<node>.<param>` at every
/// level, e.g. `sma_cross.fast.length` for a composite-interior knob and
/// `exposure.scale` for a root-level knob.
pub fn with(self, name: &str, v: impl Into<Scalar>) -> Binder {
Binder { bp: self, bound: vec![(name.to_string(), v.into())] }
}
@@ -536,81 +521,37 @@ fn validate_wiring(
Ok(())
}
/// 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();
/// The recursive authoring signature of interior node `node`: the node name (the
/// instance name, default = the lowercased type label), 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], node: usize) -> String {
match &nodes[node] {
// a nested composite is a named source: its name, no descent.
BlueprintNode::Composite(inner) => inner.name().to_string(),
BlueprintNode::Primitive(f) => {
if let Some(ch) = f.label().chars().next() {
s.push(ch);
}
for a in aliases_on(aliases, node) {
if let Some(ch) = a.name.chars().next() {
s.push(ch);
}
}
let mut s = f.node_name();
// wired input slots in slot order; per slot, the source's signature
// (interior edge -> recurse; role -> the role initial, no descent)
// (interior edge -> recurse; role -> the role name, 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)));
slotted.push((e.slot, signature_of(nodes, edges, roles, 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.push((t.slot, r.name.clone()));
}
}
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
}
}
s
}
/// The param aliases declared on interior node `node`, in declared order. The single
/// anchor for the `a.node == node` predicate shared by the signature base, the
/// unaliased-param test, and the CLI's render base-length (issue #45) — change the
/// predicate here, not in three places.
pub fn aliases_on(aliases: &[ParamAlias], node: usize) -> impl Iterator<Item = &ParamAlias> {
aliases.iter().filter(move |a| a.node == node)
}
/// Validate that every param alias names a real interior leaf param slot: a dangling
/// `(node, slot)` is a `BadInteriorIndex` (C23 — names are cosmetic, but a dangling
/// handle is an author error). Sole owner of the alias-index check: the structural
/// pre-pass recurses every composite that lowering reaches, so `inline_composite`
/// trusts this has already run rather than re-checking (issue #45).
fn check_alias_indices(nodes: &[BlueprintNode], aliases: &[ParamAlias]) -> Result<(), CompileError> {
for a in aliases {
let ok = a.node < nodes.len()
&& matches!(&nodes[a.node], BlueprintNode::Primitive(f) if a.slot < f.params().len());
if !ok {
return Err(CompileError::BadInteriorIndex);
}
}
Ok(())
}
/// Structural validation (param-value-independent): walk every composite in the
@@ -620,7 +561,7 @@ fn check_alias_indices(nodes: &[BlueprintNode], aliases: &[ParamAlias]) -> Resul
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_composite_fan_in(c.nodes(), c.edges(), c.input_roles())?;
check_fan_in_distinguishability(c.nodes())?;
}
}
@@ -629,27 +570,22 @@ fn check_fan_in_distinguishability(items: &[BlueprintNode]) -> Result<(), Compil
/// 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.
/// signatures, now keyed on the node name) is a fault only when at least one
/// colliding source carries a param — the unnamed configuration axis resolved by
/// giving the colliding nodes distinct names. 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: 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.
check_alias_indices(nodes, param_aliases)?;
for node in 0..nodes.len() {
let mut sources: Vec<(usize, String, bool)> = Vec::new(); // (slot, sig, has_unaliased_param)
let mut sources: Vec<(usize, String, bool)> = Vec::new(); // (slot, sig, has_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),
signature_of(nodes, edges, input_roles, e.from),
leaf_has_param(nodes, e.from),
));
}
for r in input_roles {
@@ -671,50 +607,30 @@ fn check_composite_fan_in(
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::Primitive(f) => {
let n_params = f.params().len();
let aliased = aliases_on(aliases, node).count();
n_params > aliased
}
BlueprintNode::Composite(_) => false,
}
/// Whether interior leaf `node` has at least one param slot — the configuration
/// axis the fan-in rule keys on once the node name no longer distinguishes the
/// colliding sources. A non-leaf (nested composite) reports `false`.
fn leaf_has_param(nodes: &[BlueprintNode], node: usize) -> bool {
matches!(&nodes[node], BlueprintNode::Primitive(b) if !b.params().is_empty())
}
/// 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
/// slot matched by an `(node, slot)` alias is relabelled in place (C23 — pure
/// naming overlay; the slot stays, order is untouched). Order mirrors `lower_items`
/// (items in declared order, composites depth-first) so a param's slot matches the
/// later flat-node order.
fn collect_params(
items: &[BlueprintNode],
prefix: &str,
aliases: &[ParamAlias],
out: &mut Vec<ParamSpec>,
) {
for (i, item) in items.iter().enumerate() {
/// declared params under `<prefix>.<node-name>.<param>` (the node name is the
/// instance name, default = the lowercased type label); a composite pushes its
/// `name()` onto the path and recurses. Order mirrors `lower_items` (items in
/// declared order, composites depth-first) so a param's slot matches the later
/// flat-node order.
fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>) {
for item in items {
match item {
BlueprintNode::Primitive(builder) => {
for (s, p) in builder.params().iter().enumerate() {
// an alias for this exact (node, slot) relabels in place;
// otherwise the factory param name, as today.
let local = aliases
.iter()
.find(|a| a.node == i && a.slot == s)
.map(|a| a.name.as_str())
.unwrap_or(p.name.as_str());
let name = if prefix.is_empty() {
local.to_string()
} else {
format!("{prefix}.{local}")
};
out.push(ParamSpec { name, kind: p.kind });
BlueprintNode::Primitive(b) => {
let node = if prefix.is_empty() {
b.node_name()
} else {
format!("{prefix}.{}", b.node_name())
};
for p in b.params() {
out.push(ParamSpec { name: format!("{node}.{}", p.name), kind: p.kind });
}
}
BlueprintNode::Composite(c) => {
@@ -723,7 +639,7 @@ fn collect_params(
} else {
format!("{prefix}.{}", c.name())
};
collect_params(c.nodes(), &child, c.params(), out);
collect_params(c.nodes(), &child, out);
}
}
}
@@ -800,16 +716,13 @@ fn inline_composite(
) -> Result<ItemLowering, CompileError> {
// `name` is the non-load-bearing render symbol (#13); it dissolves at inline
// (C23 — the boundary does not reach the compilat), so it is not destructured.
// `params` (the composite's ParamAlias overlay) is a pure naming layer validated
// by the pre-pass and unused in lowering — the injected scalar `params: &[Scalar]`
// arg drives `lower_items` below — so it is dropped here.
let Composite { name: _, nodes, edges, input_roles, params: _, output } = c;
// Node names join the same non-load-bearing debug-symbol class: they qualify the
// param-space path at construction but are dropped at lowering — the injected
// scalar `params: &[Scalar]` arg drives `lower_items` below; the compilat stays
// wired by raw index.
let Composite { name: _, nodes, edges, input_roles, output } = c;
let item_count = nodes.len();
// alias-validity (a dangling `(node, slot)` is a `BadInteriorIndex`) is owned by
// the structural pre-pass `check_alias_indices`, which `compile_with_params` runs
// over every composite before any lowering — so it has already fired here (#45).
// recursively lower interior items, then rewrite interior edges through them
let interior = lower_items(nodes, params, cursor, flat_nodes, flat_signatures, flat_edges)?;
for e in &edges {
@@ -989,14 +902,14 @@ mod tests {
// must not be invoked.
let (bp, _eq, _ex) = composite_sma_cross_harness();
let result = bp
.axis("sma_cross.fast", [2.0, 3.0]) // F64 values for the I64 slot
.axis("sma_cross.slow", [4])
.axis("scale", [0.5])
.axis("sma_cross.fast.length", [2.0, 3.0]) // F64 values for the I64 slot
.axis("sma_cross.slow.length", [4])
.axis("exposure.scale", [0.5])
.sweep(|_: &[Scalar]| -> RunReport { panic!("axis pre-validation must reject before running") });
assert_eq!(
result,
Err(BindError::KindMismatch {
knob: "sma_cross.fast".to_string(),
knob: "sma_cross.fast.length".to_string(),
expected: ScalarKind::I64,
got: ScalarKind::F64,
}),
@@ -1011,9 +924,9 @@ mod tests {
let named = resolve_axes(
&space,
&[
("sma_cross.fast".to_string(), vec![Scalar::I64(2), Scalar::I64(3)]),
("sma_cross.slow".to_string(), vec![Scalar::I64(4), Scalar::I64(5)]),
("scale".to_string(), vec![Scalar::F64(0.5)]),
("sma_cross.fast.length".to_string(), vec![Scalar::I64(2), Scalar::I64(3)]),
("sma_cross.slow.length".to_string(), vec![Scalar::I64(4), Scalar::I64(5)]),
("exposure.scale".to_string(), vec![Scalar::F64(0.5)]),
],
)
.expect("named axes resolve");
@@ -1133,9 +1046,9 @@ mod tests {
// the positional vector, over the sample composite harness.
let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
let mut named = bp
.with("sma_cross.fast", 2)
.with("sma_cross.slow", 4)
.with("scale", 0.5)
.with("sma_cross.fast.length", 2)
.with("sma_cross.slow.length", 4)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("named binding resolves and bootstraps");
named.run(vec![synthetic_prices()]);
@@ -1280,27 +1193,99 @@ mod tests {
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
#[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");
/// The SMA-cross signal as a composite under the boundary name `sma_cross`,
/// with its two SMA legs given `name` (or left default when `named` is false).
/// Nested under a root so the composite name qualifies the param-space path and
/// the fan-in check (which inspects nested composites) reaches the Sub fan-in.
fn sma_cross_under_root(named: bool) -> Composite {
use aura_std::{Sma, Sub};
let (a, b) = if named {
(Sma::builder().named("fast"), Sma::builder().named("slow"))
} else {
(Sma::builder(), Sma::builder())
};
let cross = Composite::new(
"sma_cross",
vec![a.into(), b.into(), Sub::builder().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 }],
source: None,
}],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
);
Composite::new(
"root",
vec![BlueprintNode::Composite(cross)],
vec![],
vec![Role {
name: "src".into(),
targets: vec![Target { node: 0, slot: 0 }],
source: Some(ScalarKind::F64),
}],
vec![],
)
}
/// A composite: two aliased EMAs (fast, slow) on role `price`, into a Sub.
#[test]
fn named_siblings_path_qualify_with_node_segment() {
// two SMAs named fast/slow under composite "sma_cross" -> the node segment
// qualifies each leaf's param path under the composite name.
let bp = sma_cross_under_root(true);
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, ["sma_cross.fast.length", "sma_cross.slow.length"]);
}
#[test]
fn unnamed_single_primitive_uses_lowercased_type_label_segment() {
use aura_std::Sma;
let bp = Composite::new("root", vec![Sma::builder().into()], vec![], vec![], vec![]);
assert_eq!(bp.param_space()[0].name, "sma.length");
}
#[test]
fn unnamed_same_type_param_bearing_fan_in_is_rejected() {
// both SMAs default to "sma" -> collide -> fan-in indistinguishable
let bp = sma_cross_under_root(false);
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
}
#[test]
fn named_param_bearing_fan_in_bootstraps() {
// distinct node names -> fan-in distinguishable -> compiles (the two SMA
// length params are supplied so the only thing under test is the fan-in)
let bp = sma_cross_under_root(true);
assert!(bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(4)]).is_ok());
}
#[test]
fn signature_of_is_node_name_plus_recursive_inputs() {
// EMA named "fast" fed by role price -> "fast" + "price" (role, no descent)
let c = macd_like_signature_fixture(); // built below
let sig = |n| signature_of(c.nodes(), c.edges(), c.input_roles(), n);
// node 0 = EMA named "fast", fed by role "price"
assert_eq!(sig(0), "fastprice");
// node 2 = Sub (default name "sub") over node0/node1; node1 = EMA "slow"
assert_eq!(sig(2), "subfastpriceslowprice");
}
/// A composite: two named EMAs (fast, slow) on role `price`, into a Sub.
fn macd_like_signature_fixture() -> Composite {
Composite::new(
"sig",
vec![Ema::builder().into(), Ema::builder().into(), Sub::builder().into()],
vec![
Ema::builder().named("fast").into(),
Ema::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -1308,10 +1293,6 @@ mod tests {
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
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() }],
)
}
@@ -1326,7 +1307,6 @@ mod tests {
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
let flat = bp.compile().expect("valid composite");
@@ -1364,7 +1344,6 @@ mod tests {
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
],
vec![],
vec![
OutField { node: 0, field: 0, name: "a".into() },
OutField { node: 1, field: 0, name: "b".into() },
@@ -1382,7 +1361,6 @@ mod tests {
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
let flat = bp.compile().expect("valid multi-output composite");
@@ -1416,7 +1394,6 @@ mod tests {
vec![BlueprintNode::Composite(inner)],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
@@ -1426,7 +1403,6 @@ mod tests {
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
let flat = bp.compile().expect("valid nested composite");
@@ -1460,7 +1436,6 @@ mod tests {
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
],
vec![],
vec![
OutField { node: 0, field: 0, name: "a".into() },
OutField { node: 1, field: 0, name: "b".into() },
@@ -1474,7 +1449,6 @@ mod tests {
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }, // outer role 0 -> inner role 0
Role { name: "price2".into(), targets: vec![Target { node: 0, slot: 1 }], source: None }, // outer role 1 -> inner role 1
],
vec![],
vec![
OutField { node: 0, field: 0, name: "x".into() }, // inner field 0
OutField { node: 0, field: 1, name: "y".into() }, // inner field 1 (nested arm)
@@ -1490,7 +1464,6 @@ mod tests {
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
let flat = bp.compile().expect("valid nested multi-output");
@@ -1513,7 +1486,6 @@ mod tests {
vec![pass1()],
vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
@@ -1521,7 +1493,6 @@ mod tests {
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // params
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
@@ -1530,8 +1501,8 @@ mod tests {
#[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.
// two default-named Sma (both "sma", each a `length` param) on role price
// into a Sub: node-name signatures collide and a param is present -> fault.
let c = Composite::new(
"ambig",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
@@ -1542,7 +1513,6 @@ mod tests {
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![],
vec![OutField { node: 2, field: 0, name: "x".into() }],
);
let bp = Composite::new(
@@ -1552,7 +1522,6 @@ mod tests {
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
@@ -1569,7 +1538,6 @@ mod tests {
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
assert!(bp.compile().is_ok(), "param-less interchangeable fan-in must compile");
@@ -1585,7 +1553,6 @@ mod tests {
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
@@ -1593,7 +1560,6 @@ mod tests {
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // params
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
@@ -1608,7 +1574,6 @@ mod tests {
vec![pass1()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 5, name: "out".into() }],
);
let bp = Composite::new(
@@ -1616,7 +1581,6 @@ mod tests {
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // params
vec![], // output
);
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
@@ -1632,7 +1596,6 @@ mod tests {
vec![pass1()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "a".into() }],
);
let bp = Composite::new(
@@ -1642,7 +1605,6 @@ mod tests {
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex));
@@ -1657,7 +1619,6 @@ mod tests {
vec![pass1(), sink_i64()],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![],
vec![], // params
vec![], // output
);
match bp.bootstrap().unwrap_err() {
@@ -1747,7 +1708,11 @@ mod tests {
fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -1755,10 +1720,6 @@ mod tests {
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
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() }],
)
}
@@ -1793,7 +1754,6 @@ mod tests {
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
(bp, rx_eq, rx_ex)
@@ -1851,7 +1811,6 @@ mod tests {
Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
],
vec![],
vec![
OutField { node: 0, field: 0, name: "a".into() },
OutField { node: 1, field: 0, name: "b".into() },
@@ -1871,7 +1830,6 @@ mod tests {
vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
let mut h = bp
@@ -1980,7 +1938,7 @@ mod tests {
// scale (F64); Sub/SimBroker/Recorder declare none
assert_eq!(
space.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(),
["sma_cross.fast", "sma_cross.slow", "scale"],
["sma_cross.fast.length", "sma_cross.slow.length", "exposure.scale"],
);
assert_eq!(
space.iter().map(|p| p.kind).collect::<Vec<_>>(),
@@ -1988,137 +1946,17 @@ mod tests {
);
}
#[test]
fn param_alias_relabels_param_space_name_in_place() {
// two Sma leaves (each one `length` param) under a composite that aliases
// slot 0 of node 0 -> "shortLen" and slot 0 of node 1 -> "longLen".
let c = Composite::new(
"cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().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 }], source: None, }],
vec![
ParamAlias { name: "shortLen".into(), node: 0, slot: 0 },
ParamAlias { name: "longLen".into(), node: 1, slot: 0 },
],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // params
vec![], // output
);
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
// aliased in place: names are the aliases, NOT two duplicate "cross.length".
assert_eq!(names, vec!["cross.shortLen".to_string(), "cross.longLen".to_string()]);
}
#[test]
fn out_of_range_param_alias_rejected() {
// alias names node 9 (no such interior item) -> caught at compile.
let c = Composite::new(
"cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().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 }], source: None, }],
vec![ParamAlias { name: "bogus".into(), node: 9, slot: 0 }],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![
Role { name: "src".into(), targets: vec![], source: Some(ScalarKind::F64) },
],
vec![], // params
vec![], // output
);
// two Sma leaves => two i64 length slots; supply a matching vector so the
// ONLY error is the bad alias, not arity. (The Ok arm holds Box<dyn Node>,
// not Debug, so assert via the Err arm — as the other reject tests do.)
assert_eq!(
bp.compile_with_params(&[Scalar::I64(2), Scalar::I64(4)]).err(),
Some(CompileError::BadInteriorIndex),
);
}
#[test]
fn unaliased_params_keep_factory_names() {
// no aliases => param_space identical to the pre-#41 path-qualified names.
let c = Composite::new(
"cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().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 }], source: None, }],
vec![],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // params
vec![], // output
);
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, vec!["cross.length".to_string(), "cross.length".to_string()]);
}
#[test]
fn partial_aliasing_relabels_only_the_named_slot() {
// alias node 0 only; node 1 keeps its factory name; order intact.
let c = Composite::new(
"cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().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 }], source: None, }],
vec![ParamAlias { name: "shortLen".into(), node: 0, slot: 0 }],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
let bp = Composite::new(
"root",
vec![BlueprintNode::Composite(c)],
vec![],
vec![],
vec![], // params
vec![], // output
);
let names: Vec<String> = bp.param_space().into_iter().map(|p| p.name).collect();
assert_eq!(names, vec!["cross.shortLen".to_string(), "cross.length".to_string()]);
}
#[test]
fn param_space_is_flat_path_qualified_and_slot_disambiguated() {
use aura_std::{LinComb, Sma, Sub};
// inner composite "fast_slow": two SMAs (same type → same param name) + a Sub
// inner composite "fast_slow": two named SMAs (fast/slow) + a Sub
let fast_slow = Composite::new(
"fast_slow",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -2126,7 +1964,6 @@ mod tests {
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![],
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
// outer composite "strategy": the inner composite + a LinComb([1,-1])
@@ -2135,7 +1972,6 @@ mod tests {
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
@@ -2143,7 +1979,6 @@ mod tests {
vec![BlueprintNode::Composite(strategy)],
vec![],
vec![],
vec![], // params
vec![], // output
);
@@ -2152,10 +1987,10 @@ mod tests {
assert_eq!(
names,
[
"strategy.fast_slow.length", // slot 0 — Sma(2)
"strategy.fast_slow.length", // slot 1 — Sma(4): same name, distinct slot
"strategy.weights[0]", // slot 2 — LinComb weight 0
"strategy.weights[1]", // slot 3 — LinComb weight 1
"strategy.fast_slow.fast.length", // slot 0 — Sma "fast"
"strategy.fast_slow.slow.length", // slot 1 — Sma "slow"
"strategy.lincomb.weights[0]", // slot 2 — LinComb weight 0
"strategy.lincomb.weights[1]", // slot 3 — LinComb weight 1
]
);
assert_eq!(space[0].kind, ScalarKind::I64);
@@ -2180,7 +2015,11 @@ mod tests {
// isolated path-qualification test above)
let fast_slow = Composite::new(
"fast_slow",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -2188,10 +2027,6 @@ mod tests {
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
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])
@@ -2200,7 +2035,6 @@ mod tests {
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let bp = Composite::new(
@@ -2208,7 +2042,6 @@ mod tests {
vec![BlueprintNode::Composite(strategy)],
vec![],
vec![],
vec![], // params
vec![], // output
);
@@ -2240,19 +2073,13 @@ mod tests {
}
#[test]
fn top_level_leaf_params_are_unqualified() {
fn top_level_leaf_params_carry_the_node_segment() {
use aura_std::Sma;
let bp = Composite::new(
"root",
vec![Sma::builder().into()],
vec![],
vec![],
vec![], // params
vec![], // output
);
let bp = Composite::new("root", vec![Sma::builder().into()], vec![], vec![], vec![]);
let space = bp.param_space();
assert_eq!(space.len(), 1);
assert_eq!(space[0].name, "length"); // no path prefix at the top level
// the root node now carries its own node-name segment (default "sma")
assert_eq!(space[0].name, "sma.length");
}
#[test]
@@ -2263,7 +2090,6 @@ mod tests {
vec![Sma::builder().into(), LinComb::builder(2).into()],
vec![],
vec![],
vec![], // params
vec![], // output
);
assert_eq!(bp.param_space(), bp.param_space()); // pure structural function (C1)
@@ -2278,7 +2104,6 @@ mod tests {
vec![Sub::builder().into(), Add::builder().into()],
vec![],
vec![],
vec![], // params
vec![], // output
);
assert!(only_paramless.param_space().is_empty());
@@ -2287,7 +2112,6 @@ mod tests {
vec![],
vec![],
vec![],
vec![], // params
vec![], // output
);
assert!(empty.param_space().is_empty());
@@ -2301,11 +2125,11 @@ mod tests {
Composite::new(
"macd",
vec![
Ema::builder().into(), // 0 fast EMA
Ema::builder().into(), // 1 slow EMA
Sub::builder().into(), // 2 macd = fast - slow
Ema::builder().into(), // 3 signal EMA of macd
Sub::builder().into(), // 4 histogram = macd - signal
Ema::builder().named("fast").into(), // 0 fast EMA
Ema::builder().named("slow").into(), // 1 slow EMA
Sub::builder().into(), // 2 macd = fast - slow
Ema::builder().named("signal").into(), // 3 signal EMA of macd
Sub::builder().into(), // 4 histogram = macd - signal
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
@@ -2315,11 +2139,6 @@ mod tests {
Edge { from: 3, to: 4, slot: 1, from_field: 0 },
],
vec![root_role("price", 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 },
ParamAlias { name: "signal".into(), node: 3, slot: 0 },
],
vec![
OutField { node: 2, field: 0, name: "macd".into() },
OutField { node: 3, field: 0, name: "signal".into() },
@@ -2366,7 +2185,6 @@ mod tests {
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], // f64 -> i64 slot
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![],
vec![],
);
let err = root.compile_with_params(&[Scalar::I64(3)]);
// kind fault caught pre-build (no panic) — same variant bootstrap would give
@@ -2384,7 +2202,6 @@ mod tests {
vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![],
);
// the Ok arm holds a FlatGraph (not Debug), so assert via the Err arm.
assert_eq!(
+6 -9
View File
@@ -232,7 +232,7 @@ pub fn model_to_json(root: &Composite) -> String {
#[cfg(test)]
mod tests {
use super::*;
use crate::{Edge, OutField, ParamAlias, Role, Target};
use crate::{Edge, OutField, Role, Target};
use aura_core::{
FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
};
@@ -273,7 +273,11 @@ mod tests {
fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -283,10 +287,6 @@ mod tests {
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
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() }],
)
}
@@ -315,7 +315,6 @@ mod tests {
targets: vec![Target { node: 0, slot: 0 }], // price -> sma_cross role 0
source: Some(ScalarKind::F64),
}],
vec![], // params: the interior sma_cross carries the aliases
vec![], // output: the root ends in a sink, no re-export
)
}
@@ -331,7 +330,6 @@ mod tests {
Role { name: "a".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "b".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
],
vec![],
vec![OutField { node: 0, field: 0, name: "v".into() }],
)
}
@@ -358,7 +356,6 @@ mod tests {
source: Some(ScalarKind::F64),
}],
vec![],
vec![],
)
}
+2 -2
View File
@@ -38,8 +38,8 @@ mod report;
mod sweep;
pub use blueprint::{
aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField,
ParamAlias, Role, SweepBinder,
signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
SweepBinder,
};
pub use graph_model::model_to_json;
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
+6 -8
View File
@@ -170,8 +170,7 @@ where
mod tests {
use super::*;
use crate::{
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role,
RunManifest, Target,
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, Role, RunManifest, Target,
};
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
@@ -275,7 +274,11 @@ mod tests {
fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
vec![
Sma::builder().named("fast").into(),
Sma::builder().named("slow").into(),
Sub::builder().into(),
],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -285,10 +288,6 @@ mod tests {
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None,
}],
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() }],
)
}
@@ -323,7 +322,6 @@ mod tests {
targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![], // params: the interior sma_cross + Exposure carry the knobs
vec![], // output: the root ends in sinks
);
(bp, rx_eq, rx_ex)
+27 -24
View File
@@ -349,30 +349,33 @@ its producing node as a `name := …` binding; originally `[out:<name>]` markers
but are dropped at lowering — `ItemLowering::Composite.output` is `Vec<(usize,
usize)>`, raw index pairs only, so the compilat is name-free (verified: the
compiled-view render stayed bit-identical across this change).
**Realization (cycle 0019 — name the composite boundary, #41).** The same
named-projection shape now covers the other two boundary edge-kinds, so **all
three** are uniform: `input_roles` is a `Vec<Role { name, targets }>` (was a bare
`Vec<Vec<Target>>`) and a composite carries `params: Vec<ParamAlias { name, node,
slot }>`, alongside `output: Vec<OutField>`. Each is an ordered, positionally-indexed
**named projection** of interior handles. Param aliasing is a **pure naming overlay**,
not curation: `param_space()` relabels an aliased slot's surface name **in place**
slot order, arity, and kinds are unchanged, so the sweep surface is identical and the
`param_space_mirrors_compiled_flat_node_param_order` anchor stays green. Like the
output names, role and param **names** are **non-load-bearing** (C23): they live at
the blueprint boundary and in render (`[in:<name>]` / `[param:<name>]` markers) but
are dropped at lowering — the compilat is wired by raw index and a dangling alias is
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.
**Realization (cycle 0019 — name the composite boundary, #41; param-overlay retired,
cycle 0031).** The named-projection shape covers the surviving boundary edge-kinds:
`input_roles` is a `Vec<Role { name, targets }>` (was a bare `Vec<Vec<Target>>`),
alongside `output: Vec<OutField>`. Each is an ordered, positionally-indexed
**named projection** of interior handles. **Param projection is no longer a
composite overlay** (the index-addressed `ParamAlias` was retired in cycle 0031):
a node's surface param name flows from its own **instance name** — every node
carries a name (default = its lowercased type label, override via `.named()`), and
`param_space()` is uniformly `<node>.<param>` at every level including the root. A
same-type fan-in is distinguished by naming the colliding legs, the same single act
that qualifies their param paths. Like the output and role names, **node names are
non-load-bearing** (C23): they live at the blueprint boundary and in render but are
dropped at lowering — the compilat is wired by raw index. The full composite
boundary signature (named inputs, multi-outputs) and the per-node param path are
legible without changing the compilat.
**Refinement (fan-in distinguishability, 2026-06-08; node-name keying, cycle 0031).**
A fan-in node (>1 input) is well-formed only if its colliding sources — sources
with equal recursive **node-name** signatures (each node contributes its name —
the instance name, default = the lowercased type label — then its inputs'
signatures in slot order, stopping at a named source) — do not hide an unnamed
configuration axis: a collision is a `CompileError` (`IndistinguishableFanIn`)
when at least one colliding source carries a **param** (a param-bearing same-name
collision), resolved by giving the colliding nodes distinct names. Paramless
interchangeable same-name 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