11dfff860c
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.
161 lines
6.4 KiB
Rust
161 lines
6.4 KiB
Rust
// Cycle-0031 fieldtest — scenario 2 (axis b): the forcing function.
|
|
//
|
|
// Promise probed (spec 0031 acceptance #3): author the 2-SMA cross WITHOUT
|
|
// names -> both legs default to "sma" -> they collide and the fan-in is
|
|
// indistinguishable -> `IndistinguishableFanIn { node }`. The recovery is the
|
|
// single act of naming the colliding legs. The question this fixture answers as
|
|
// a downstream consumer: is the diagnostic DISCOVERABLE and HELPFUL from the
|
|
// public surface alone — does it name the node, and does the error type/text
|
|
// point the author at "give the legs distinct names"?
|
|
//
|
|
// Public interface only (ledger C9/C23 + spec 0031 + cargo doc). No src read.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
BlueprintNode, CompileError, Composite, Edge, OutField, Role, Target,
|
|
};
|
|
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
|
|
|
/// Build the cross with the two SMA legs given (caller decides whether to name
|
|
/// them). `name_them = false` reproduces the minimal un-named authoring.
|
|
fn sma_cross(name_them: bool) -> Composite {
|
|
let (fast, slow) = if name_them {
|
|
(Sma::builder().named("fast"), Sma::builder().named("slow"))
|
|
} else {
|
|
(Sma::builder(), Sma::builder())
|
|
};
|
|
Composite::new(
|
|
"sma_cross",
|
|
vec![fast.into(), 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() }],
|
|
)
|
|
}
|
|
|
|
fn harness(
|
|
name_them: bool,
|
|
tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
|
) -> Composite {
|
|
Composite::new(
|
|
"harness",
|
|
vec![
|
|
BlueprintNode::Composite(sma_cross(name_them)),
|
|
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 main() {
|
|
// --- 0. What does param_space() emit for the UN-NAMED cross? --------------
|
|
// Both legs default to "sma" -> sma_cross.sma.length appears TWICE
|
|
// (a collision). This is what makes a by-name bind ambiguous/unknown.
|
|
let (txs, _rxs) = mpsc::channel();
|
|
let unnamed_space = harness(false, txs).param_space();
|
|
println!("[0] un-named cross param_space():");
|
|
for p in &unnamed_space {
|
|
println!(" {} : {:?}", p.name, p.kind);
|
|
}
|
|
|
|
// --- 1. The minimal un-named authoring is correctly REJECTED. -------------
|
|
let (txu, _rxu) = mpsc::channel();
|
|
let unnamed = harness(false, txu)
|
|
.with("sma_cross.length", 2) // best guess at the colliding bare name
|
|
.bootstrap();
|
|
// Harness (Ok branch) may not implement Debug; inspect the error only.
|
|
println!("[1] un-named cross bootstrap is_err: {}", unnamed.is_err());
|
|
match unnamed.err() {
|
|
// The binder may reject first (AmbiguousKnob on the colliding
|
|
// sma_cross.length), OR the compile may reject first
|
|
// (IndistinguishableFanIn). Record whichever wall the author hits.
|
|
Some(other) => println!(" -> diagnostic the author sees: {other:?}"),
|
|
None => panic!("BUG: un-named param-bearing fan-in should be rejected"),
|
|
}
|
|
|
|
// --- 1a. Bind the name param_space() ACTUALLY emits (the duplicate). ------
|
|
// sma_cross.sma.length is emitted TWICE -> binding it is AmbiguousKnob, NOT
|
|
// the spec's headline IndistinguishableFanIn. So the by-name author never
|
|
// sees "name the colliding legs"; they see a knob-name diagnostic.
|
|
let (txa, _rxa) = mpsc::channel();
|
|
let ambiguous = harness(false, txa)
|
|
.with("sma_cross.sma.length", 2)
|
|
.bootstrap();
|
|
println!(
|
|
"[1a] bind the real emitted (duplicate) name -> {:?}",
|
|
ambiguous.err()
|
|
);
|
|
|
|
// --- 1b. The bare bootstrap (no binds at all) isolates the COMPILE wall. ---
|
|
// This removes the binder from the picture so we see the fan-in error itself.
|
|
let (txc, _rxc) = mpsc::channel();
|
|
let compiled = harness(false, txc).compile_with_params(&[
|
|
Scalar::I64(2),
|
|
Scalar::I64(4),
|
|
Scalar::F64(0.5),
|
|
]);
|
|
// FlatGraph (the Ok branch) does not implement Debug, so we cannot
|
|
// `{:?}`-print the whole Result; inspect via .err() instead.
|
|
println!("\n[1b] un-named cross compile_with_params err: {:?}", compiled.as_ref().err());
|
|
match compiled.err() {
|
|
Some(CompileError::IndistinguishableFanIn { node }) => {
|
|
println!(
|
|
" -> IndistinguishableFanIn at interior node index {node} \
|
|
(the Sub that fans in both un-named SMAs)"
|
|
);
|
|
}
|
|
other => println!(
|
|
" -> NOTE: expected IndistinguishableFanIn, got {other:?}"
|
|
),
|
|
}
|
|
|
|
// --- 2. The recovery: name the legs -> the SAME topology now compiles. -----
|
|
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
|
let named = harness(true, tx)
|
|
.with("sma_cross.fast.length", 2)
|
|
.with("sma_cross.slow.length", 4)
|
|
.with("exposure.scale", 0.5)
|
|
.bootstrap();
|
|
println!("\n[2] named cross bootstrap ok: {}", named.is_ok());
|
|
let mut h = named.expect("naming the colliding legs resolves the fan-in");
|
|
let prices: Vec<(Timestamp, Scalar)> = (0..12)
|
|
.map(|i| (Timestamp(60_000_000_000 * i), Scalar::F64(1.0 + 0.01 * i as f64)))
|
|
.collect();
|
|
h.run(vec![prices]);
|
|
drop(h);
|
|
let rows: Vec<_> = rx.iter().collect();
|
|
println!("[2] named run recorded {} rows", rows.len());
|
|
assert!(!rows.is_empty(), "named cross must run and record");
|
|
|
|
println!(
|
|
"\nOK: un-named fan-in rejected, naming the legs is the single recovering act."
|
|
);
|
|
}
|