Files
Aura/fieldtests/cycle-0031-node-naming/c0031_1_named_single_run.rs
T
Brummel 11dfff860c fieldtest: cycle 0031 node-naming — naming cure under-signposted on the by-name flow
Per-cycle fieldtest of node-instance naming (#56), driven as a downstream
consumer from the public interface only (ledger + spec + rustdoc + CLI; no
crates/*/src read), three fixtures built and run from HEAD.

Verdict: the core 0031 promise holds first-try. A consumer authors
Sma::builder().named("fast"), inspects param_space() (sma_cross.fast.length),
binds by name and runs; the default-name case (sma.length / exposure.scale,
verbatim lowercase) and paramless-interchangeable-stays-legal both hold.

0 bugs, 3 working, 2 friction, 1 spec_gap. The one real gap (verified by the
orchestrator against the fixture output): the spec's headline forcing function
IndistinguishableFanIn does NOT reach an author on the canonical
.with(...).bootstrap() flow. An un-named 2-SMA cross emits a literal DUPLICATE
knob (sma_cross.sma.length x2); the binder resolves names before the compile
fan-in check, so the author hits UnknownKnob / AmbiguousKnob("sma_cross.sma.length")
— which point at the knob, not at the cure "name your nodes". IndistinguishableFanIn
only surfaces via the positional compile_with_params path. Rejection still
happens (no invalid blueprint runs), so it is an ergonomic/signposting gap, not
a correctness bug. Routed to the backlog (relates to #58); 0031 stays
audit-closed. Minor: FlatGraph/Harness lack Debug, so a bootstrap Result can't
be {:?}-printed.
2026-06-11 12:18:29 +02:00

148 lines
5.5 KiB
Rust

// Cycle-0031 fieldtest — scenario 1 (axis a): the named single-run authoring path.
//
// Promise probed (spec 0031 acceptance #1, #2, #4): a downstream author writes
// `Sma::builder().named("fast")`, inspects `param_space()` (now uniformly
// `<node>.<param>` at every level, including the root `exposure.scale`), binds
// `sma_cross.fast.length` BY NAME with NO ParamAlias and NO index counting, and
// runs. This is the exact pain the milestone-0030 fixture (mw_1) had to work
// around with two hand-counted ParamAlias overlays.
//
// Public interface only: API discovered from the ledger (C9/C23), spec 0031,
// and `cargo doc`. No crates/*/src was read.
//
// The harness: source -> sma_cross composite { Sma("fast"), Sma("slow"), Sub }
// -> Exposure -> SimBroker -> Recorder.
use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{BlueprintNode, Composite, Edge, OutField, Role, Target};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
/// The reusable inner 2/4-SMA-cross composite. The two SMA legs are now named
/// inline via `.named()` — the single act that (a) distinguishes the fan-in and
/// (b) qualifies the two param paths. No ParamAlias argument exists anymore
/// (Composite::new is 4-arg).
fn sma_cross() -> Composite {
Composite::new(
"sma_cross",
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 },
],
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: "out".into() }],
)
}
/// The root harness over the named cross. The root-level Exposure node now also
/// carries a name (default "exposure"), so its knob surfaces as `exposure.scale`.
fn harness(tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>) -> Composite {
Composite::new(
"harness",
vec![
BlueprintNode::Composite(sma_cross()),
Exposure::builder().into(),
SimBroker::builder(1e-4).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 },
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
],
vec![Role {
name: "price".into(),
targets: vec![
Target { node: 0, slot: 0 },
Target { node: 2, slot: 1 },
],
source: Some(ScalarKind::F64),
}],
vec![OutField { node: 0, field: 0, name: "exposure".into() }],
)
}
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
let prices = [
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07,
];
prices
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p)))
.collect()
}
fn main() {
// --- 1. Inspect the param-space: are the names what the spec promises? -----
let (tx0, _rx0) = mpsc::channel();
let space = harness(tx0).param_space();
println!("param_space() knobs (name : kind):");
for p in &space {
println!(" {} : {:?}", p.name, p.kind);
}
let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect();
// spec 0031 acceptance #1/#4: uniform <node>.<param>, distinct named legs.
assert!(
names.contains(&"sma_cross.fast.length"),
"expected sma_cross.fast.length, got {names:?}"
);
assert!(
names.contains(&"sma_cross.slow.length"),
"expected sma_cross.slow.length, got {names:?}"
);
assert!(
names.contains(&"exposure.scale"),
"expected root-level exposure.scale, got {names:?}"
);
// --- 2. Bind BY NAME, with no ParamAlias and no index counting. ------------
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
let mut h = harness(tx)
.with("sma_cross.fast.length", 2)
.with("sma_cross.slow.length", 4)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("named harness binds by the uniform <node>.<param> names and bootstraps");
h.run(vec![synthetic_prices()]);
drop(h);
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.iter().collect();
println!("\nnamed-bound run — recorded rows: {}", rows.len());
for (ts, row) in &rows {
if let Scalar::F64(x) = row[0] {
println!(" ts={:>14} exposure={:+.4}", ts.0, x);
}
}
assert!(!rows.is_empty(), "named-bound harness must record a populated trace");
// --- 3. Determinism (C1): a disjoint second run is bit-identical. ----------
let (tx2, rx2) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
let mut h2 = harness(tx2)
.with("sma_cross.fast.length", 2)
.with("sma_cross.slow.length", 4)
.with("exposure.scale", 0.5)
.bootstrap()
.expect("second disjoint bootstrap");
h2.run(vec![synthetic_prices()]);
drop(h2);
let rows2: Vec<(Timestamp, Vec<Scalar>)> = rx2.iter().collect();
assert_eq!(rows, rows2, "two disjoint named runs must be bit-identical (C1)");
println!("\nOK: param-space uniform <node>.<param>, named bind ran, deterministic.");
}