feat(aura-engine): name the composite boundary — input roles + param aliases

Brings the other two composite-boundary edge-kinds to the named-projection
shape #40 gave outputs. input_roles changes from a bare Vec<Vec<Target>>
to Vec<Role { name, targets }> (rendered [in:<name>]); a composite gains
params: Vec<ParamAlias { name, node, slot }> that relabels an interior
leaf param slot's surface name in param_space() (rendered [param:<name>]).

Param aliasing is a PURE NAMING OVERLAY, not curation (the load-bearing
decision, per spec 0019): every interior param slot stays in param_space()
and sweepable; the alias only relabels in place, never reorders or hides.
Proven empirically — the MACD run is byte-identical (total_pips
0.1637945563898923, 3 sign flips), only the param labels improved:
param_space() now surfaces [macd.fast, macd.slow, macd.signal] instead of
three indistinguishable macd.length, and the manifest reads ema_fast/
ema_slow/ema_signal.

C23 honoured: role/param/output names are non-load-bearing debug symbols
dropped at lowering; identity is positional (role index, param slot,
output field). compiled_view_golden is byte-identical (verified: the
golden region is untouched in the diff). An out-of-range alias (missing/
non-leaf node or slot past the leaf's param count) is rejected at
compile_with_params as BadInteriorIndex, mirroring the output range-check
(no new variant). Orthogonal to #36 — purely additive at the composite
level.

Aliasing is demonstrated on the CLI MACD site only (the spec's worked
example + a new E2E test macd_param_space_surfaces_the_three_named_aliases);
sma_cross, the engine test fixtures, and the construction-layer fieldtests
get the forced role-name + empty params, so the param_space C23 anchor
goldens (param_space_mirrors_compiled_flat_node_param_order + siblings)
stay byte-identical.

Verification (orchestrator-run, not trusted from the agent report):
cargo build/test/clippy --workspace -D warnings all green (engine 66,
cli 12); the separate-workspace construction-layer fieldtest crate builds
(guards the #42 latent-drift recurrence); compiled_view_golden + MACD
determinism unchanged.

Two faithful repairs to the plan's literal test/code bodies, no semantic
change: the out-of-range test uses .err()/Some(BadInteriorIndex) (the Ok
arm Vec<Box<dyn Node>> is not Debug, so .unwrap_err() would not compile),
and the inline_composite destructure binds params as `param_aliases` to
avoid shadowing the injected `params: &[Scalar]` arg.

closes #41
This commit is contained in:
2026-06-08 02:21:30 +02:00
parent c9115e3e0f
commit 41cbb5506f
8 changed files with 323 additions and 66 deletions
+11 -5
View File
@@ -99,8 +99,9 @@ fn collect_distinct_composites(bp: &Blueprint) -> Vec<&Composite> {
}
/// Render one composite's interior as a flat graph: interior leaves as `[type]`,
/// nested composites as opaque `[name]`, plus an `[in:k]` entry marker per input
/// role (wired to its interior targets) and an `[out:<name>]` marker per
/// nested composites as opaque `[name]`, plus an `[in:<name>]` entry marker per
/// input role (wired to its interior targets), a `[param:<name>]` marker per param
/// alias (wired to the leaf it relabels), and an `[out:<name>]` marker per
/// re-exported output field (wired from its producer). Prefixed `"<name>:\n"`.
fn render_definition(c: &Composite) -> String {
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
@@ -114,13 +115,18 @@ fn render_definition(c: &Composite) -> String {
for e in c.edges() {
edges.push((e.from, e.to));
}
for (role, targets) in c.input_roles().iter().enumerate() {
for role in c.input_roles() {
let in_id = labels.len();
labels.push(format!("in:{role}"));
for t in targets {
labels.push(format!("in:{}", role.name));
for t in &role.targets {
edges.push((in_id, t.node));
}
}
for a in c.params() {
let p_id = labels.len();
labels.push(format!("param:{}", a.name));
edges.push((p_id, a.node));
}
for of in c.output() {
let out_id = labels.len();
labels.push(format!("out:{}", of.name));
+52 -10
View File
@@ -11,7 +11,7 @@ mod graph;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutField,
RunManifest, RunReport, SourceSpec, Target,
ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target,
};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
@@ -126,7 +126,11 @@ fn sma_cross(name: &str) -> Composite {
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
}],
vec![],
vec![OutField { node: 2, field: 0, name: "cross".into() }],
)
}
@@ -200,10 +204,18 @@ fn macd(name: &str) -> Composite {
Edge { from: 2, to: 4, slot: 0, from_field: 0 }, // line → histogram[0]
Edge { from: 3, to: 4, slot: 1, from_field: 0 }, // signal → histogram[1]
],
vec![vec![
Target { node: 0, slot: 0 }, // price → fast EMA
Target { node: 1, slot: 0 }, // price → slow EMA
]],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 }, // price → fast EMA
Target { node: 1, slot: 0 }, // price → slow EMA
],
}],
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
@@ -379,7 +391,7 @@ mod tests {
let out = graph::render_blueprint(&sample_blueprint());
// the sma_cross body is defined exactly once, with its interior + ports
assert_eq!(out.matches("sma_cross:").count(), 1, "definition not rendered once:\n{out}");
for needle in ["[SMA]", "[Sub]", "[in:0]", "[out:cross]"] {
for needle in ["[SMA]", "[Sub]", "[in:price]", "[out:cross]"] {
assert!(out.contains(needle), "missing {needle} in definition:\n{out}");
}
}
@@ -392,14 +404,16 @@ mod tests {
"inner",
vec![Sma::factory().into()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }] }],
vec![],
vec![OutField { node: 0, field: 0, name: "out".into() }],
);
let outer = Composite::new(
"outer",
vec![BlueprintNode::Composite(inner), Sub::factory().into()],
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
vec![vec![Target { node: 0, slot: 0 }]],
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }] }],
vec![],
vec![OutField { node: 1, field: 0, name: "out".into() }],
);
let bp = Blueprint::new(
@@ -490,7 +504,7 @@ mod tests {
where:
sma_cross:
[in:0]
[in:price]
┌───└───┐
↓ ↓
[SMA] [SMA]
@@ -566,6 +580,34 @@ sma_cross:
assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}");
assert_eq!(out.matches("macd:").count(), 1, "macd defined once:\n{out}");
assert!(out.contains("[EMA]"), "macd interior must show EMA leaves:\n{out}");
assert!(out.contains("[in:price]"), "named MACD input role: {out}");
assert!(out.contains("[param:fast]"), "aliased fast length: {out}");
assert!(out.contains("[param:slow]"), "aliased slow length: {out}");
assert!(out.contains("[param:signal]"), "aliased signal length: {out}");
}
/// 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).
#[test]
fn macd_param_space_surfaces_the_three_named_aliases() {
let names: Vec<String> =
macd_blueprint().param_space().into_iter().map(|p| p.name).collect();
// three aliased composite slots, in declared (fast, slow, signal) order,
// then the strategy-level Exposure `scale` (outside the composite, unaliased).
assert_eq!(
names,
vec![
"macd.fast".to_string(),
"macd.slow".to_string(),
"macd.signal".to_string(),
"scale".to_string(),
],
"MACD param surface must expose the three named EMA lengths + scale",
);
}
#[test]