diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index cb320c5..69ac68f 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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 = 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", ); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 5f72a51..54ccf65 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -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); diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 6caae98..b53e814 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -76,6 +76,7 @@ pub struct ParamSpec { /// pre-build (#43). pub struct PrimitiveBuilder { name: &'static str, + instance_name: Option, 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 + '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, pub output: Vec, @@ -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( diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index a34fccc..f24cc2f 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -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, } -/// 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, edges: Vec, input_roles: Vec, - params: Vec, output: Vec, } @@ -153,10 +141,9 @@ impl Composite { nodes: Vec, edges: Vec, input_roles: Vec, - params: Vec, output: Vec, ) -> 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 `.` (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 { 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 — `.` 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) -> 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 { - 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, -) { - for (i, item) in items.iter().enumerate() { +/// declared params under `..` (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) { + 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 { // `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 = 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 (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 (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 (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::>(), - ["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::>(), @@ -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 = 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, - // 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 = 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 = 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!( diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs index 0c9541c..7770d17 100644 --- a/crates/aura-engine/src/graph_model.rs +++ b/crates/aura-engine/src/graph_model.rs @@ -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![], ) } diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index aa21628..e78b52f 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -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}; diff --git a/crates/aura-engine/src/sweep.rs b/crates/aura-engine/src/sweep.rs index 4c69bdc..9739d15 100644 --- a/crates/aura-engine/src/sweep.rs +++ b/crates/aura-engine/src/sweep.rs @@ -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) diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 247299f..bc50328 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -349,30 +349,33 @@ its producing node as a `name := …` binding; originally `[out:]` 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` (was a bare -`Vec>`) and a composite carries `params: Vec`, alongside `output: Vec`. 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:]` / `[param:]` 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` (was a bare `Vec>`), +alongside `output: Vec`. 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 `.` 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