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",
);