Files
Aura/fieldtests/milestone-construction-layer/mc_1_composite_build_run.rs
T
Brummel 41cbb5506f 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
2026-06-08 02:21:30 +02:00

121 lines
5.2 KiB
Rust

// Milestone fieldtest — axis (a): author a small signal as a reusable NAMED
// Composite, build a Blueprint that uses it, compile() + bootstrap() + run(),
// and confirm the recorded trace is non-empty.
//
// Public interface only: Blueprint / Composite / BlueprintNode / Edge / Target /
// OutField / SourceSpec from aura-engine; Sma / Sub / Exposure / SimBroker /
// Recorder from aura-std; ScalarKind / Scalar / Firing / Timestamp from
// aura-core. No crates/*/src was read.
//
// The downstream task: package an SMA(2)/SMA(4)-cross into a reusable
// `sma_cross` composite exposing one output (the cross spread), then wire that
// composite into a harness blueprint (source -> composite -> Exposure ->
// SimBroker -> Recorder), bootstrap it, and run it over a tiny synthetic price
// ramp. This is the milestone's headline authoring story: build a graph
// fractally from a Rust builder via a named composite.
use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, OutField, Role, SourceSpec, Target};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
fn main() {
// --- The reusable composite: a 2/4 SMA cross. ----------------------------
// Interior layout (local indices):
// 0: Sma(2) (fast) 1: Sma(4) (slow) 2: Sub (fast - slow)
// One input role (the price) fans into BOTH SMAs' slot 0.
// The exposed output port is Sub's single output field.
// Value-empty recipes (factories); the two SMA lengths are injected at
// bootstrap (param vector below), not baked here.
let sma_cross = Composite::new(
"sma_cross",
vec![
Sma::factory().into(),
Sma::factory().into(),
Sub::factory().into(),
],
// interior edges (local indices): fast -> Sub.slot0, slow -> Sub.slot1
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
],
// input role 0 (price) fans into Sma(2).slot0 AND Sma(4).slot0
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 1, slot: 0 },
],
}],
// no param aliases
vec![],
// single exposed output: Sub's output field 0
vec![OutField { node: 2, field: 0, name: "out".into() }],
);
// --- The harness blueprint that USES the composite. ----------------------
// Top-level items:
// 0: sma_cross (Composite) 1: Exposure(0.5) 2: SimBroker(1e-4)
// 3: Recorder over [F64] (taps the broker equity)
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
let blueprint = Blueprint::new(
vec![
BlueprintNode::Composite(sma_cross),
Exposure::factory().into(),
SimBroker::factory(1e-4).into(),
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
// one f64 price source: feeds the composite (item 0, role 0) AND the
// broker's price leg (item 2, slot 1).
vec![SourceSpec {
kind: ScalarKind::F64,
targets: vec![
Target { node: 0, slot: 0 }, // composite price role
Target { node: 2, slot: 1 }, // broker price leg
],
}],
// top-level edges: composite-out -> Exposure -> broker.exposure(slot0)
// -> Recorder
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // sma_cross -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // Exposure -> SimBroker.exposure
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // SimBroker -> Recorder
],
);
// --- compile() + bootstrap() + run() -------------------------------------
// Param vector (depth-first item order, C12): sma_cross's two SMA lengths
// (I64), then Exposure's scale (F64); Sub/SimBroker/Recorder declare none.
let mut harness = blueprint
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
.expect("composite-authored blueprint should bootstrap");
// a synthetic upward price ramp then a dip, enough to warm SMA(4) and flip
let prices: Vec<f64> = vec![
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07,
];
let stream: Vec<(Timestamp, Scalar)> = prices
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p)))
.collect();
harness.run(vec![stream]);
drop(harness); // drop the recorder's tx clone held inside
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.iter().collect();
println!("recorded equity rows: {}", rows.len());
for (ts, row) in &rows {
let v = match row[0] {
Scalar::F64(x) => x,
other => panic!("expected f64 equity, got {other:?}"),
};
println!(" ts={:>14} equity_pips={:.4}", ts.0, v);
}
assert!(
!rows.is_empty(),
"the composite-authored harness must record a populated equity trace"
);
println!("OK: composite authored, compiled, bootstrapped, ran, recorded.");
}