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_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{ use aura_engine::{
f64_field, summarize, BlueprintNode, Composite, Edge, FlatGraph, Harness, 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_registry::{rank_by, Registry};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub}; use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
@@ -136,7 +136,11 @@ fn run_sample() -> RunReport {
fn sma_cross(name: &str) -> Composite { fn sma_cross(name: &str) -> Composite {
Composite::new( Composite::new(
name, 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![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, 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 }], targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None, 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() }], vec![OutField { node: 2, field: 0, name: "cross".into() }],
) )
} }
@@ -190,7 +190,6 @@ fn sample_blueprint_with_sinks() -> (
], ],
source: Some(ScalarKind::F64), source: Some(ScalarKind::F64),
}], }],
vec![], // params: the interior sma_cross carries the aliases
vec![], // output: the root ends in sinks, no re-export vec![], // output: the root ends in sinks, no re-export
); );
(bp, rx_eq, rx_ex) (bp, rx_eq, rx_ex)
@@ -226,9 +225,9 @@ fn scalar_as_param_f64(s: &Scalar) -> f64 {
fn sweep_family() -> SweepFamily { fn sweep_family() -> SweepFamily {
let bp = sample_blueprint_with_sinks().0; let bp = sample_blueprint_with_sinks().0;
let space = bp.param_space(); let space = bp.param_space();
bp.axis("sma_cross.fast", [2, 3]) bp.axis("sma_cross.fast.length", [2, 3])
.axis("sma_cross.slow", [4, 5]) .axis("sma_cross.slow.length", [4, 5])
.axis("scale", [0.5]) .axis("exposure.scale", [0.5])
.sweep(|point| { .sweep(|point| {
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(); let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp let mut h = bp
@@ -338,11 +337,11 @@ fn macd(name: &str) -> Composite {
Composite::new( Composite::new(
name, name,
vec![ vec![
Ema::builder().into(), // 0 fast EMA Ema::builder().named("fast").into(), // 0 fast EMA
Ema::builder().into(), // 1 slow EMA Ema::builder().named("slow").into(), // 1 slow EMA
Sub::builder().into(), // 2 MACD line = fast slow Sub::builder().into(), // 2 MACD line = fast slow
Ema::builder().into(), // 3 signal EMA of the MACD line Ema::builder().named("signal").into(), // 3 signal EMA of the MACD line
Sub::builder().into(), // 4 histogram = MACD line signal Sub::builder().into(), // 4 histogram = MACD line signal
], ],
vec![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast → line[0] Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // fast → line[0]
@@ -359,11 +358,6 @@ fn macd(name: &str) -> Composite {
], ],
source: None, 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![ vec![
OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line OutField { node: 2, field: 0, name: "macd".into() }, // the MACD line
OutField { node: 3, field: 0, name: "signal".into() }, // the signal line OutField { node: 3, field: 0, name: "signal".into() }, // the signal line
@@ -402,7 +396,6 @@ fn macd_strategy_blueprint(
], ],
source: Some(ScalarKind::F64), source: Some(ScalarKind::F64),
}], }],
vec![], // params: the interior macd carries the aliases
vec![], // output: the root ends in sinks, no re-export 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. // so a caller can bootstrap one point, run it, and drain both sinks.
let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(); let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
let mut h = bp let mut h = bp
.with("sma_cross.fast", 2) .with("sma_cross.fast.length", 2)
.with("sma_cross.slow", 4) .with("sma_cross.slow.length", 4)
.with("scale", 0.5) .with("exposure.scale", 0.5)
.bootstrap() .bootstrap()
.expect("sample blueprint compiles under a valid point"); .expect("sample blueprint compiles under a valid point");
h.run(vec![synthetic_prices()]); h.run(vec![synthetic_prices()]);
@@ -534,10 +527,10 @@ mod tests {
for line in &lines { for line in &lines {
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}"); 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[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":2,"sma_cross.slow":5,"scale":0.5}"#), "line1: {}", lines[1]); 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":3,"sma_cross.slow":4,"scale":0.5}"#), "line2: {}", lines[2]); 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":3,"sma_cross.slow":5,"scale":0.5}"#), "line3: {}", lines[3]); 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 { for line in &lines {
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}"); assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {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 /// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy
/// blueprint's swept param surface relabels the three otherwise-indistinguishable /// blueprint's swept param surface qualifies the three otherwise-indistinguishable
/// EMA `length` slots to `macd.fast` / `macd.slow` / `macd.signal` — the named /// EMA `length` slots by node name to `macd.fast.length` / `macd.slow.length` /
/// composite boundary visible end-to-end through `param_space()`, with the slot /// `macd.signal.length` — the named composite boundary visible end-to-end through
/// count and order unchanged (C23 — pure naming overlay, not curation: every /// `param_space()`, with the slot count and order unchanged (C23 — node names are
/// interior slot stays sweepable, the `scale` knob is unaffected). /// non-load-bearing: every interior slot stays sweepable, the `exposure.scale`
/// knob is unaffected).
#[test] #[test]
fn macd_param_space_surfaces_the_three_named_aliases() { fn macd_param_space_surfaces_the_three_named_legs() {
let names: Vec<String> = let names: Vec<String> =
macd_blueprint().param_space().into_iter().map(|p| p.name).collect(); macd_blueprint().param_space().into_iter().map(|p| p.name).collect();
// three aliased composite slots, in declared (fast, slow, signal) order, // three named composite-interior slots, in declared (fast, slow, signal)
// then the strategy-level Exposure `scale` (outside the composite, unaliased). // order, then the strategy-level Exposure `scale` (a root-level leaf).
assert_eq!( assert_eq!(
names, names,
vec![ vec![
"macd.fast".to_string(), "macd.fast.length".to_string(),
"macd.slow".to_string(), "macd.slow.length".to_string(),
"macd.signal".to_string(), "macd.signal.length".to_string(),
"scale".to_string(), "exposure.scale".to_string(),
], ],
"MACD param surface must expose the three named EMA lengths + scale", "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. // pin the first odometer point's manifest params, not the commit value.
assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}"); assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}");
assert!( 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}" "got: {stdout}"
); );
let _ = std::fs::remove_dir_all(&cwd); let _ = std::fs::remove_dir_all(&cwd);
+36 -2
View File
@@ -76,6 +76,7 @@ pub struct ParamSpec {
/// pre-build (#43). /// pre-build (#43).
pub struct PrimitiveBuilder { pub struct PrimitiveBuilder {
name: &'static str, name: &'static str,
instance_name: Option<String>,
schema: NodeSchema, schema: NodeSchema,
// The build closure's type is exactly the recipe contract (a param slice in, a // 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. // boxed node out); a type alias would not clarify it.
@@ -92,7 +93,20 @@ impl PrimitiveBuilder {
schema: NodeSchema, schema: NodeSchema,
build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static, build: impl Fn(&[Scalar]) -> Box<dyn Node> + 'static,
) -> Self { ) -> 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`, /// The full declared signature (read pre-build by `Composite::param_space`,
/// `BlueprintNode::signature`, and the renderer). /// `BlueprintNode::signature`, and the renderer).
@@ -120,7 +134,7 @@ impl PrimitiveBuilder {
/// case), and an **empty** `output` (`vec![]`) declares a **pure consumer** /// 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 /// (sink role, C8) — a node with no output port. Built once at wiring, never on
/// the hot path — the `Vec`s are fine here. /// the hot path — the `Vec`s are fine here.
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct NodeSchema { pub struct NodeSchema {
pub inputs: Vec<PortSpec>, pub inputs: Vec<PortSpec>,
pub output: Vec<FieldSpec>, pub output: Vec<FieldSpec>,
@@ -215,6 +229,26 @@ mod tests {
assert_eq!(none.label(), "Sub"); 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] #[test]
fn primitive_builder_build_runs_the_closure() { fn primitive_builder_build_runs_the_closure() {
let f = PrimitiveBuilder::new( let f = PrimitiveBuilder::new(
+186 -369
View File
@@ -87,7 +87,7 @@ fn derive_signature(c: &Composite) -> NodeSchema {
}) })
.collect(); .collect();
let mut params = Vec::new(); let mut params = Vec::new();
collect_params(c.nodes(), "", c.params(), &mut params); collect_params(c.nodes(), "", &mut params);
NodeSchema { inputs, output, params } NodeSchema { inputs, output, params }
} }
@@ -118,17 +118,6 @@ pub struct Role {
pub source: Option<ScalarKind>, 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 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), /// 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 /// interior edges (local indices), input roles (role `r` fans into the interior
@@ -139,7 +128,6 @@ pub struct Composite {
nodes: Vec<BlueprintNode>, nodes: Vec<BlueprintNode>,
edges: Vec<Edge>, edges: Vec<Edge>,
input_roles: Vec<Role>, input_roles: Vec<Role>,
params: Vec<ParamAlias>,
output: Vec<OutField>, output: Vec<OutField>,
} }
@@ -153,10 +141,9 @@ impl Composite {
nodes: Vec<BlueprintNode>, nodes: Vec<BlueprintNode>,
edges: Vec<Edge>, edges: Vec<Edge>,
input_roles: Vec<Role>, input_roles: Vec<Role>,
params: Vec<ParamAlias>,
output: Vec<OutField>, output: Vec<OutField>,
) -> Self { ) -> 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. /// The authored render name (cluster title, #13). Non-load-bearing.
@@ -177,11 +164,6 @@ impl Composite {
pub fn input_roles(&self) -> &[Role] { pub fn input_roles(&self) -> &[Role] {
&self.input_roles &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 /// The exposed output record: each entry re-exports one interior
/// `(node, output-field)` under a boundary name (C8 — one port, K columns). /// `(node, output-field)` under a boundary name (C8 — one port, K columns).
pub fn output(&self) -> &[OutField] { pub fn output(&self) -> &[OutField] {
@@ -189,12 +171,14 @@ impl Composite {
} }
/// The aggregated, flat, path-qualified param-space (C12): every node's declared /// The aggregated, flat, path-qualified param-space (C12): every node's declared
/// params, concatenated in lowering order. The ROOT uses an empty path prefix /// params, concatenated in lowering order. Each param is `<node>.<param>` (the
/// (its own name does not prefix — preserving the pre-refactor param names); /// node name = its instance name, default = lowercased type label), with the
/// interior composite names prefix via the recursion in `collect_params`. /// 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> { pub fn param_space(&self) -> Vec<ParamSpec> {
let mut out = Vec::new(); let mut out = Vec::new();
collect_params(&self.nodes, "", &self.params, &mut out); collect_params(&self.nodes, "", &mut out);
out out
} }
@@ -269,8 +253,9 @@ impl Composite {
/// Begin binding this blueprint's knobs **by name** for a single run (the /// Begin binding this blueprint's knobs **by name** for a single run (the
/// fluent alternative to a positional `bootstrap_with_params` vector). The /// fluent alternative to a positional `bootstrap_with_params` vector). The
/// bound name is the exact `param_space()` name — path-qualified for a knob /// bound name is the exact `param_space()` name — `<node>.<param>` at every
/// inside a composite (`sma_cross.fast`), bare for a root-level knob (`scale`). /// 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 { pub fn with(self, name: &str, v: impl Into<Scalar>) -> Binder {
Binder { bp: self, bound: vec![(name.to_string(), v.into())] } Binder { bp: self, bound: vec![(name.to_string(), v.into())] }
} }
@@ -536,81 +521,37 @@ fn validate_wiring(
Ok(()) Ok(())
} }
/// The recursive authoring signature of interior node `node`: the type initial, /// The recursive authoring signature of interior node `node`: the node name (the
/// then one initial per declared param alias (declared order), then each wired /// instance name, default = the lowercased type label), then each wired input's
/// input's signature in slot order — recursing into interior-leaf sources, /// signature in slot order — recursing into interior-leaf sources, stopping at a
/// stopping at a named source (role name / nested-composite name). Single source /// named source (role name / nested-composite name). Single source of truth for
/// of truth for the fan-in distinguishability check (collision = equal /// the fan-in distinguishability check (collision = equal signatures) and the CLI
/// signatures) and the CLI render (shortest sibling-unique prefix). Terminates: /// render (shortest sibling-unique prefix). Terminates: the dataflow is a DAG (C5)
/// the dataflow is a DAG (C5) and the descent stops at named ports. /// and the descent stops at named ports.
pub fn signature_of( pub fn signature_of(nodes: &[BlueprintNode], edges: &[Edge], roles: &[Role], node: usize) -> String {
nodes: &[BlueprintNode],
edges: &[Edge],
roles: &[Role],
aliases: &[ParamAlias],
node: usize,
) -> String {
let mut s = String::new();
match &nodes[node] { match &nodes[node] {
// a nested composite is a named source: its name, no descent.
BlueprintNode::Composite(inner) => inner.name().to_string(),
BlueprintNode::Primitive(f) => { BlueprintNode::Primitive(f) => {
if let Some(ch) = f.label().chars().next() { let mut s = f.node_name();
s.push(ch);
}
for a in aliases_on(aliases, node) {
if let Some(ch) = a.name.chars().next() {
s.push(ch);
}
}
// wired input slots in slot order; per slot, the source's signature // 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(); let mut slotted: Vec<(usize, String)> = Vec::new();
for e in edges.iter().filter(|e| e.to == node) { 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 r in roles {
for t in r.targets.iter().filter(|t| t.node == node) { for t in r.targets.iter().filter(|t| t.node == node) {
// a role is a named source: it contributes its initial, no slotted.push((t.slot, r.name.clone()));
// 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); slotted.sort_by_key(|(slot, _)| *slot);
for (_, sig) in slotted { for (_, sig) in slotted {
s.push_str(&sig); s.push_str(&sig);
} }
} s
BlueprintNode::Composite(inner) => {
if let Some(ch) = inner.name().chars().next() {
s.push(ch);
}
} }
} }
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 /// 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> { fn check_fan_in_distinguishability(items: &[BlueprintNode]) -> Result<(), CompileError> {
for item in items { for item in items {
if let BlueprintNode::Composite(c) = item { 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())?; 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: /// The per-composite fan-in distinguishability check on the destructured pieces:
/// for each interior node with >1 wired input slot, a collision (equal source /// 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 /// signatures, now keyed on the node name) is a fault only when at least one
/// param slot — the unnamed configuration axis. A role contributes its name and /// colliding source carries a param — the unnamed configuration axis resolved by
/// has no param of its own. /// giving the colliding nodes distinct names. A role contributes its name and has
/// no param of its own.
fn check_composite_fan_in( fn check_composite_fan_in(
nodes: &[BlueprintNode], nodes: &[BlueprintNode],
edges: &[Edge], edges: &[Edge],
input_roles: &[Role], input_roles: &[Role],
param_aliases: &[ParamAlias],
) -> Result<(), CompileError> { ) -> 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() { 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) { for e in edges.iter().filter(|e| e.to == node) {
sources.push(( sources.push((
e.slot, e.slot,
signature_of(nodes, edges, input_roles, param_aliases, e.from), signature_of(nodes, edges, input_roles, e.from),
leaf_has_unaliased_param(nodes, param_aliases, e.from), leaf_has_param(nodes, e.from),
)); ));
} }
for r in input_roles { for r in input_roles {
@@ -671,50 +607,30 @@ fn check_composite_fan_in(
Ok(()) Ok(())
} }
/// Whether interior leaf `node` has at least one param slot with no alias in /// Whether interior leaf `node` has at least one param slot — the configuration
/// `aliases` — the unnamed configuration axis the fan-in rule keys on. A non-leaf /// axis the fan-in rule keys on once the node name no longer distinguishes the
/// (nested composite) reports `false`. /// colliding sources. A non-leaf (nested composite) reports `false`.
fn leaf_has_unaliased_param(nodes: &[BlueprintNode], aliases: &[ParamAlias], node: usize) -> bool { fn leaf_has_param(nodes: &[BlueprintNode], node: usize) -> bool {
match &nodes[node] { matches!(&nodes[node], BlueprintNode::Primitive(b) if !b.params().is_empty())
BlueprintNode::Primitive(f) => {
let n_params = f.params().len();
let aliased = aliases_on(aliases, node).count();
n_params > aliased
}
BlueprintNode::Composite(_) => false,
}
} }
/// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its /// Recursive read-only walk for `Blueprint::param_space`: a leaf contributes its
/// declared params under the running path prefix; a composite pushes its `name()` /// declared params under `<prefix>.<node-name>.<param>` (the node name is the
/// onto the path and recurses, passing its own param aliases down. A leaf param /// instance name, default = the lowercased type label); a composite pushes its
/// slot matched by an `(node, slot)` alias is relabelled in place (C23 — pure /// `name()` onto the path and recurses. Order mirrors `lower_items` (items in
/// naming overlay; the slot stays, order is untouched). Order mirrors `lower_items` /// declared order, composites depth-first) so a param's slot matches the later
/// (items in declared order, composites depth-first) so a param's slot matches the /// flat-node order.
/// later flat-node order. fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>) {
fn collect_params( for item in items {
items: &[BlueprintNode],
prefix: &str,
aliases: &[ParamAlias],
out: &mut Vec<ParamSpec>,
) {
for (i, item) in items.iter().enumerate() {
match item { match item {
BlueprintNode::Primitive(builder) => { BlueprintNode::Primitive(b) => {
for (s, p) in builder.params().iter().enumerate() { let node = if prefix.is_empty() {
// an alias for this exact (node, slot) relabels in place; b.node_name()
// otherwise the factory param name, as today. } else {
let local = aliases format!("{prefix}.{}", b.node_name())
.iter() };
.find(|a| a.node == i && a.slot == s) for p in b.params() {
.map(|a| a.name.as_str()) out.push(ParamSpec { name: format!("{node}.{}", p.name), kind: p.kind });
.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::Composite(c) => { BlueprintNode::Composite(c) => {
@@ -723,7 +639,7 @@ fn collect_params(
} else { } else {
format!("{prefix}.{}", c.name()) 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> { ) -> Result<ItemLowering, CompileError> {
// `name` is the non-load-bearing render symbol (#13); it dissolves at inline // `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. // (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 // Node names join the same non-load-bearing debug-symbol class: they qualify the
// by the pre-pass and unused in lowering — the injected scalar `params: &[Scalar]` // param-space path at construction but are dropped at lowering — the injected
// arg drives `lower_items` below — so it is dropped here. // scalar `params: &[Scalar]` arg drives `lower_items` below; the compilat stays
let Composite { name: _, nodes, edges, input_roles, params: _, output } = c; // wired by raw index.
let Composite { name: _, nodes, edges, input_roles, output } = c;
let item_count = nodes.len(); 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 // recursively lower interior items, then rewrite interior edges through them
let interior = lower_items(nodes, params, cursor, flat_nodes, flat_signatures, flat_edges)?; let interior = lower_items(nodes, params, cursor, flat_nodes, flat_signatures, flat_edges)?;
for e in &edges { for e in &edges {
@@ -989,14 +902,14 @@ mod tests {
// must not be invoked. // must not be invoked.
let (bp, _eq, _ex) = composite_sma_cross_harness(); let (bp, _eq, _ex) = composite_sma_cross_harness();
let result = bp let result = bp
.axis("sma_cross.fast", [2.0, 3.0]) // F64 values for the I64 slot .axis("sma_cross.fast.length", [2.0, 3.0]) // F64 values for the I64 slot
.axis("sma_cross.slow", [4]) .axis("sma_cross.slow.length", [4])
.axis("scale", [0.5]) .axis("exposure.scale", [0.5])
.sweep(|_: &[Scalar]| -> RunReport { panic!("axis pre-validation must reject before running") }); .sweep(|_: &[Scalar]| -> RunReport { panic!("axis pre-validation must reject before running") });
assert_eq!( assert_eq!(
result, result,
Err(BindError::KindMismatch { Err(BindError::KindMismatch {
knob: "sma_cross.fast".to_string(), knob: "sma_cross.fast.length".to_string(),
expected: ScalarKind::I64, expected: ScalarKind::I64,
got: ScalarKind::F64, got: ScalarKind::F64,
}), }),
@@ -1011,9 +924,9 @@ mod tests {
let named = resolve_axes( let named = resolve_axes(
&space, &space,
&[ &[
("sma_cross.fast".to_string(), vec![Scalar::I64(2), Scalar::I64(3)]), ("sma_cross.fast.length".to_string(), vec![Scalar::I64(2), Scalar::I64(3)]),
("sma_cross.slow".to_string(), vec![Scalar::I64(4), Scalar::I64(5)]), ("sma_cross.slow.length".to_string(), vec![Scalar::I64(4), Scalar::I64(5)]),
("scale".to_string(), vec![Scalar::F64(0.5)]), ("exposure.scale".to_string(), vec![Scalar::F64(0.5)]),
], ],
) )
.expect("named axes resolve"); .expect("named axes resolve");
@@ -1133,9 +1046,9 @@ mod tests {
// the positional vector, over the sample composite harness. // the positional vector, over the sample composite harness.
let (bp, comp_eq, comp_ex) = composite_sma_cross_harness(); let (bp, comp_eq, comp_ex) = composite_sma_cross_harness();
let mut named = bp let mut named = bp
.with("sma_cross.fast", 2) .with("sma_cross.fast.length", 2)
.with("sma_cross.slow", 4) .with("sma_cross.slow.length", 4)
.with("scale", 0.5) .with("exposure.scale", 0.5)
.bootstrap() .bootstrap()
.expect("named binding resolves and bootstraps"); .expect("named binding resolves and bootstraps");
named.run(vec![synthetic_prices()]); named.run(vec![synthetic_prices()]);
@@ -1280,27 +1193,99 @@ mod tests {
vec![Role { vec![Role {
name: "price".into(), name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![],
vec![OutField { node: 2, field: 0, name: "out".into() }], vec![OutField { node: 2, field: 0, name: "out".into() }],
) )
} }
#[test] /// The SMA-cross signal as a composite under the boundary name `sma_cross`,
fn signature_of_is_type_initial_plus_aliases_plus_recursive_inputs() { /// with its two SMA legs given `name` (or left default when `named` is false).
// EMA(fast) fed by role price -> "E" + "f"(alias) + "p"(role, no descent) /// Nested under a root so the composite name qualifies the param-space path and
let c = macd_like_signature_fixture(); // built below /// the fan-in check (which inspects nested composites) reaches the Sub fan-in.
let sig = |n| signature_of(c.nodes(), c.edges(), c.input_roles(), c.params(), n); fn sma_cross_under_root(named: bool) -> Composite {
// node 0 = Ema aliased "fast", fed by role "price" use aura_std::{Sma, Sub};
assert_eq!(sig(0), "Efp"); let (a, b) = if named {
// node 2 = Sub(node0, node1) where node1 = Ema aliased "slow" -> "S"+inputs (Sma::builder().named("fast"), Sma::builder().named("slow"))
assert_eq!(sig(2), "SEfpEsp"); } 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 { fn macd_like_signature_fixture() -> Composite {
Composite::new( Composite::new(
"sig", "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![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -1308,10 +1293,6 @@ mod tests {
vec![Role { vec![Role {
name: "price".into(), name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], 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() }], vec![OutField { node: 2, field: 0, name: "x".into() }],
) )
} }
@@ -1326,7 +1307,6 @@ mod tests {
vec![ vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
let flat = bp.compile().expect("valid composite"); 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: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None }, Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
], ],
vec![],
vec![ vec![
OutField { node: 0, field: 0, name: "a".into() }, OutField { node: 0, field: 0, name: "a".into() },
OutField { node: 1, field: 0, name: "b".into() }, OutField { node: 1, field: 0, name: "b".into() },
@@ -1382,7 +1361,6 @@ mod tests {
vec![ vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) }, Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
let flat = bp.compile().expect("valid multi-output composite"); let flat = bp.compile().expect("valid multi-output composite");
@@ -1416,7 +1394,6 @@ mod tests {
vec![BlueprintNode::Composite(inner)], vec![BlueprintNode::Composite(inner)],
vec![], vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }], vec![OutField { node: 0, field: 0, name: "out".into() }],
); );
let bp = Composite::new( let bp = Composite::new(
@@ -1426,7 +1403,6 @@ mod tests {
vec![ vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
let flat = bp.compile().expect("valid nested composite"); 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: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None }, Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
], ],
vec![],
vec![ vec![
OutField { node: 0, field: 0, name: "a".into() }, OutField { node: 0, field: 0, name: "a".into() },
OutField { node: 1, field: 0, name: "b".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: "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 Role { name: "price2".into(), targets: vec![Target { node: 0, slot: 1 }], source: None }, // outer role 1 -> inner role 1
], ],
vec![],
vec![ vec![
OutField { node: 0, field: 0, name: "x".into() }, // inner field 0 OutField { node: 0, field: 0, name: "x".into() }, // inner field 0
OutField { node: 0, field: 1, name: "y".into() }, // inner field 1 (nested arm) OutField { node: 0, field: 1, name: "y".into() }, // inner field 1 (nested arm)
@@ -1490,7 +1464,6 @@ mod tests {
vec![ vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) }, Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
let flat = bp.compile().expect("valid nested multi-output"); let flat = bp.compile().expect("valid nested multi-output");
@@ -1513,7 +1486,6 @@ mod tests {
vec![pass1()], vec![pass1()],
vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }], 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![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }], vec![OutField { node: 0, field: 0, name: "out".into() }],
); );
let bp = Composite::new( let bp = Composite::new(
@@ -1521,7 +1493,6 @@ mod tests {
vec![BlueprintNode::Composite(c)], vec![BlueprintNode::Composite(c)],
vec![], vec![],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm. // the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
@@ -1530,8 +1501,8 @@ mod tests {
#[test] #[test]
fn indistinguishable_fan_in_rejected() { fn indistinguishable_fan_in_rejected() {
// two alias-less Sma (each an unaliased `length`) on role price into a Sub: // two default-named Sma (both "sma", each a `length` param) on role price
// signatures collide ("Sp"=="Sp") and a param is unaliased -> fault. // into a Sub: node-name signatures collide and a param is present -> fault.
let c = Composite::new( let c = Composite::new(
"ambig", "ambig",
vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()], vec![Sma::builder().into(), Sma::builder().into(), Sub::builder().into()],
@@ -1542,7 +1513,6 @@ mod tests {
vec![Role { vec![Role {
name: "price".into(), name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![],
vec![OutField { node: 2, field: 0, name: "x".into() }], vec![OutField { node: 2, field: 0, name: "x".into() }],
); );
let bp = Composite::new( let bp = Composite::new(
@@ -1552,7 +1522,6 @@ mod tests {
vec![ vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 })); assert_eq!(bp.compile().err(), Some(CompileError::IndistinguishableFanIn { node: 2 }));
@@ -1569,7 +1538,6 @@ mod tests {
vec![ vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
assert!(bp.compile().is_ok(), "param-less interchangeable fan-in must compile"); assert!(bp.compile().is_ok(), "param-less interchangeable fan-in must compile");
@@ -1585,7 +1553,6 @@ mod tests {
vec![Role { vec![Role {
name: "price".into(), name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }], vec![OutField { node: 0, field: 0, name: "out".into() }],
); );
let bp = Composite::new( let bp = Composite::new(
@@ -1593,7 +1560,6 @@ mod tests {
vec![BlueprintNode::Composite(c)], vec![BlueprintNode::Composite(c)],
vec![], vec![],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm. // the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
@@ -1608,7 +1574,6 @@ mod tests {
vec![pass1()], vec![pass1()],
vec![], vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 5, name: "out".into() }], vec![OutField { node: 0, field: 5, name: "out".into() }],
); );
let bp = Composite::new( let bp = Composite::new(
@@ -1616,7 +1581,6 @@ mod tests {
vec![BlueprintNode::Composite(c)], vec![BlueprintNode::Composite(c)],
vec![], vec![],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
// the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm. // the Ok arm holds Box<dyn Node> (not Debug), so assert via the Err arm.
@@ -1632,7 +1596,6 @@ mod tests {
vec![pass1()], vec![pass1()],
vec![], vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "a".into() }], vec![OutField { node: 0, field: 0, name: "a".into() }],
); );
let bp = Composite::new( let bp = Composite::new(
@@ -1642,7 +1605,6 @@ mod tests {
vec![ vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }, Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex)); assert_eq!(bp.compile().err(), Some(CompileError::BadInteriorIndex));
@@ -1657,7 +1619,6 @@ mod tests {
vec![pass1(), sink_i64()], vec![pass1(), sink_i64()],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
match bp.bootstrap().unwrap_err() { match bp.bootstrap().unwrap_err() {
@@ -1747,7 +1708,11 @@ mod tests {
fn sma_cross() -> Composite { fn sma_cross() -> Composite {
Composite::new( Composite::new(
"sma_cross", "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![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -1755,10 +1720,6 @@ mod tests {
vec![Role { vec![Role {
name: "price".into(), name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], 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() }], vec![OutField { node: 2, field: 0, name: "out".into() }],
) )
} }
@@ -1793,7 +1754,6 @@ mod tests {
Target { node: 2, slot: 1 }, // price -> SimBroker price slot Target { node: 2, slot: 1 }, // price -> SimBroker price slot
], source: Some(ScalarKind::F64) }, ], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
(bp, rx_eq, rx_ex) (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: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None }, Role { name: "price2".into(), targets: vec![Target { node: 1, slot: 0 }], source: None },
], ],
vec![],
vec![ vec![
OutField { node: 0, field: 0, name: "a".into() }, OutField { node: 0, field: 0, name: "a".into() },
OutField { node: 1, field: 0, name: "b".into() }, OutField { node: 1, field: 0, name: "b".into() },
@@ -1871,7 +1830,6 @@ mod tests {
vec![ vec![
Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) }, Role { name: "src".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }], source: Some(ScalarKind::F64) },
], ],
vec![], // params
vec![], // output vec![], // output
); );
let mut h = bp let mut h = bp
@@ -1980,7 +1938,7 @@ mod tests {
// scale (F64); Sub/SimBroker/Recorder declare none // scale (F64); Sub/SimBroker/Recorder declare none
assert_eq!( assert_eq!(
space.iter().map(|p| p.name.as_str()).collect::<Vec<_>>(), 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!( assert_eq!(
space.iter().map(|p| p.kind).collect::<Vec<_>>(), 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] #[test]
fn param_space_is_flat_path_qualified_and_slot_disambiguated() { fn param_space_is_flat_path_qualified_and_slot_disambiguated() {
use aura_std::{LinComb, Sma, Sub}; 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( let fast_slow = Composite::new(
"fast_slow", "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![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -2126,7 +1964,6 @@ mod tests {
vec![Role { vec![Role {
name: "price".into(), name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }],
vec![],
vec![OutField { node: 2, field: 0, name: "out".into() }], vec![OutField { node: 2, field: 0, name: "out".into() }],
); );
// outer composite "strategy": the inner composite + a LinComb([1,-1]) // 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![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![], vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }], vec![OutField { node: 0, field: 0, name: "out".into() }],
); );
let bp = Composite::new( let bp = Composite::new(
@@ -2143,7 +1979,6 @@ mod tests {
vec![BlueprintNode::Composite(strategy)], vec![BlueprintNode::Composite(strategy)],
vec![], vec![],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
@@ -2152,10 +1987,10 @@ mod tests {
assert_eq!( assert_eq!(
names, names,
[ [
"strategy.fast_slow.length", // slot 0 — Sma(2) "strategy.fast_slow.fast.length", // slot 0 — Sma "fast"
"strategy.fast_slow.length", // slot 1 — Sma(4): same name, distinct slot "strategy.fast_slow.slow.length", // slot 1 — Sma "slow"
"strategy.weights[0]", // slot 2 — LinComb weight 0 "strategy.lincomb.weights[0]", // slot 2 — LinComb weight 0
"strategy.weights[1]", // slot 3 — LinComb weight 1 "strategy.lincomb.weights[1]", // slot 3 — LinComb weight 1
] ]
); );
assert_eq!(space[0].kind, ScalarKind::I64); assert_eq!(space[0].kind, ScalarKind::I64);
@@ -2180,7 +2015,11 @@ mod tests {
// isolated path-qualification test above) // isolated path-qualification test above)
let fast_slow = Composite::new( let fast_slow = Composite::new(
"fast_slow", "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![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 },
@@ -2188,10 +2027,6 @@ mod tests {
vec![Role { vec![Role {
name: "price".into(), name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], 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() }], vec![OutField { node: 2, field: 0, name: "out".into() }],
); );
// outer composite "strategy": the inner composite + a LinComb([1,-1]) // 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![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
vec![], vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }], vec![OutField { node: 0, field: 0, name: "out".into() }],
); );
let bp = Composite::new( let bp = Composite::new(
@@ -2208,7 +2042,6 @@ mod tests {
vec![BlueprintNode::Composite(strategy)], vec![BlueprintNode::Composite(strategy)],
vec![], vec![],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
@@ -2240,19 +2073,13 @@ mod tests {
} }
#[test] #[test]
fn top_level_leaf_params_are_unqualified() { fn top_level_leaf_params_carry_the_node_segment() {
use aura_std::Sma; use aura_std::Sma;
let bp = Composite::new( let bp = Composite::new("root", vec![Sma::builder().into()], vec![], vec![], vec![]);
"root",
vec![Sma::builder().into()],
vec![],
vec![],
vec![], // params
vec![], // output
);
let space = bp.param_space(); let space = bp.param_space();
assert_eq!(space.len(), 1); 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] #[test]
@@ -2263,7 +2090,6 @@ mod tests {
vec![Sma::builder().into(), LinComb::builder(2).into()], vec![Sma::builder().into(), LinComb::builder(2).into()],
vec![], vec![],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
assert_eq!(bp.param_space(), bp.param_space()); // pure structural function (C1) 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![Sub::builder().into(), Add::builder().into()],
vec![], vec![],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
assert!(only_paramless.param_space().is_empty()); assert!(only_paramless.param_space().is_empty());
@@ -2287,7 +2112,6 @@ mod tests {
vec![], vec![],
vec![], vec![],
vec![], vec![],
vec![], // params
vec![], // output vec![], // output
); );
assert!(empty.param_space().is_empty()); assert!(empty.param_space().is_empty());
@@ -2301,11 +2125,11 @@ mod tests {
Composite::new( Composite::new(
"macd", "macd",
vec![ vec![
Ema::builder().into(), // 0 fast EMA Ema::builder().named("fast").into(), // 0 fast EMA
Ema::builder().into(), // 1 slow EMA Ema::builder().named("slow").into(), // 1 slow EMA
Sub::builder().into(), // 2 macd = fast - slow Sub::builder().into(), // 2 macd = fast - slow
Ema::builder().into(), // 3 signal EMA of macd Ema::builder().named("signal").into(), // 3 signal EMA of macd
Sub::builder().into(), // 4 histogram = macd - signal Sub::builder().into(), // 4 histogram = macd - signal
], ],
vec![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, 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 }, 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![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![ vec![
OutField { node: 2, field: 0, name: "macd".into() }, OutField { node: 2, field: 0, name: "macd".into() },
OutField { node: 3, field: 0, name: "signal".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![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![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64) }],
vec![], vec![],
vec![],
); );
let err = root.compile_with_params(&[Scalar::I64(3)]); let err = root.compile_with_params(&[Scalar::I64(3)]);
// kind fault caught pre-build (no panic) — same variant bootstrap would give // kind fault caught pre-build (no panic) — same variant bootstrap would give
@@ -2384,7 +2202,6 @@ mod tests {
vec![], vec![],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
vec![], vec![],
vec![],
); );
// the Ok arm holds a FlatGraph (not Debug), so assert via the Err arm. // the Ok arm holds a FlatGraph (not Debug), so assert via the Err arm.
assert_eq!( assert_eq!(
+6 -9
View File
@@ -232,7 +232,7 @@ pub fn model_to_json(root: &Composite) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::{Edge, OutField, ParamAlias, Role, Target}; use crate::{Edge, OutField, Role, Target};
use aura_core::{ use aura_core::{
FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
}; };
@@ -273,7 +273,11 @@ mod tests {
fn sma_cross() -> Composite { fn sma_cross() -> Composite {
Composite::new( Composite::new(
"sma_cross", "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![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, 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 }], targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None, 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() }], 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 targets: vec![Target { node: 0, slot: 0 }], // price -> sma_cross role 0
source: Some(ScalarKind::F64), source: Some(ScalarKind::F64),
}], }],
vec![], // params: the interior sma_cross carries the aliases
vec![], // output: the root ends in a sink, no re-export 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: "a".into(), targets: vec![Target { node: 0, slot: 0 }], source: None },
Role { name: "b".into(), targets: vec![Target { node: 1, 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() }], vec![OutField { node: 0, field: 0, name: "v".into() }],
) )
} }
@@ -358,7 +356,6 @@ mod tests {
source: Some(ScalarKind::F64), source: Some(ScalarKind::F64),
}], }],
vec![], vec![],
vec![],
) )
} }
+2 -2
View File
@@ -38,8 +38,8 @@ mod report;
mod sweep; mod sweep;
pub use blueprint::{ pub use blueprint::{
aliases_on, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, signature_of, BindError, Binder, BlueprintNode, CompileError, Composite, OutField, Role,
ParamAlias, Role, SweepBinder, SweepBinder,
}; };
pub use graph_model::model_to_json; pub use graph_model::model_to_json;
pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target}; pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
+6 -8
View File
@@ -170,8 +170,7 @@ where
mod tests { mod tests {
use super::*; use super::*;
use crate::{ use crate::{
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role, f64_field, summarize, BlueprintNode, Composite, Edge, OutField, Role, RunManifest, Target,
RunManifest, Target,
}; };
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp}; use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
@@ -275,7 +274,11 @@ mod tests {
fn sma_cross() -> Composite { fn sma_cross() -> Composite {
Composite::new( Composite::new(
"sma_cross", "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![ vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, 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 }], targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
source: None, 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() }], 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 }], targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
source: Some(ScalarKind::F64), source: Some(ScalarKind::F64),
}], }],
vec![], // params: the interior sma_cross + Exposure carry the knobs
vec![], // output: the root ends in sinks vec![], // output: the root ends in sinks
); );
(bp, rx_eq, rx_ex) (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, but are dropped at lowering — `ItemLowering::Composite.output` is `Vec<(usize,
usize)>`, raw index pairs only, so the compilat is name-free (verified: the usize)>`, raw index pairs only, so the compilat is name-free (verified: the
compiled-view render stayed bit-identical across this change). compiled-view render stayed bit-identical across this change).
**Realization (cycle 0019 — name the composite boundary, #41).** The same **Realization (cycle 0019 — name the composite boundary, #41; param-overlay retired,
named-projection shape now covers the other two boundary edge-kinds, so **all cycle 0031).** The named-projection shape covers the surviving boundary edge-kinds:
three** are uniform: `input_roles` is a `Vec<Role { name, targets }>` (was a bare `input_roles` is a `Vec<Role { name, targets }>` (was a bare `Vec<Vec<Target>>`),
`Vec<Vec<Target>>`) and a composite carries `params: Vec<ParamAlias { name, node, alongside `output: Vec<OutField>`. Each is an ordered, positionally-indexed
slot }>`, alongside `output: Vec<OutField>`. Each is an ordered, positionally-indexed **named projection** of interior handles. **Param projection is no longer a
**named projection** of interior handles. Param aliasing is a **pure naming overlay**, composite overlay** (the index-addressed `ParamAlias` was retired in cycle 0031):
not curation: `param_space()` relabels an aliased slot's surface name **in place** a node's surface param name flows from its own **instance name** — every node
slot order, arity, and kinds are unchanged, so the sweep surface is identical and the carries a name (default = its lowercased type label, override via `.named()`), and
`param_space_mirrors_compiled_flat_node_param_order` anchor stays green. Like the `param_space()` is uniformly `<node>.<param>` at every level including the root. A
output names, role and param **names** are **non-load-bearing** (C23): they live at same-type fan-in is distinguished by naming the colliding legs, the same single act
the blueprint boundary and in render (`[in:<name>]` / `[param:<name>]` markers) but that qualifies their param paths. Like the output and role names, **node names are
are dropped at lowering — the compilat is wired by raw index and a dangling alias is non-load-bearing** (C23): they live at the blueprint boundary and in render but are
rejected at compile (`BadInteriorIndex`), not silently lowered. The full composite dropped at lowering — the compilat is wired by raw index. The full composite
boundary signature (named inputs, params, multi-outputs) is now legible without boundary signature (named inputs, multi-outputs) and the per-node param path are
changing the compilat. legible without changing the compilat.
**Refinement (fan-in distinguishability, 2026-06-08).** A fan-in node (>1 input) **Refinement (fan-in distinguishability, 2026-06-08; node-name keying, cycle 0031).**
is well-formed only if its colliding sources — sources with equal recursive A fan-in node (>1 input) is well-formed only if its colliding sources — sources
signatures (type initial + alias initials + recursive input signatures) — do not with equal recursive **node-name** signatures (each node contributes its name —
hide an unnamed configuration axis: a collision is a `CompileError` the instance name, default = the lowercased type label — then its inputs'
(`IndistinguishableFanIn`) when at least one colliding source carries an signatures in slot order, stopping at a named source) — do not hide an unnamed
unaliased param slot. Genuinely-interchangeable sources (equal signatures, no configuration axis: a collision is a `CompileError` (`IndistinguishableFanIn`)
param) stay legal. Construction-phase only; the compilat stays name-free (C23). when at least one colliding source carries a **param** (a param-bearing same-name
The graph view renders each fan-in input as the shortest sibling-unique prefix collision), resolved by giving the colliding nodes distinct names. Paramless
of its source signature. 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 ### 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 **Guarantee.** A strategy's primary, backtestable output is **not** an equity