Files
Brummel 03285d1b47 fieldtest: milestone the-world param-space & sweep — green, delivers promise
Milestone-scope fieldtest (closing functional gate) for "The World —
parameter-space & sweep" (issues #30-#36). Three curated end-to-end scenarios
derived top-down from the milestone promise, driven from the public interface
only (ledger + specs + rustdoc; no crates/*/src read), built and run from HEAD
via a downstream consumer crate.

Delivery verdict: GREEN. The milestone delivers its promise.
- mw_1 single run bound by name: .with("sma_cross.fast", 2).bootstrap() binds
  and runs (12 equity rows); diagnostics precise and knob-named (UnknownKnob /
  AmbiguousKnob / KindMismatch).
- mw_2 named-axis sweep + compare: .axis(..).sweep(run) yields a 4-member,
  ordered (odometer), disjoint, non-constant SweepFamily; ranking from public
  fields. CLI (aura sweep / aura runs rank) delivers the same with no Rust.
- mw_3 structural-constant negative space (deliberate): SMA2-entry's
  definitional `2` has no honest expression today -> grounds #55. Out of
  milestone scope; not a blocker.

Findings: 0 bugs, 2 working, 3 friction, 1 spec_gap. Verified independently
(re-ran all three from HEAD; confirmed a plain SMA-cross emits sma_cross.length
twice and the shipped CLI sample carries the fast/slow ParamAlias overlays).

Friction (routed to the forward queue, none blocks the gate):
- the promised sma_cross.fast/.slow names are not free — they require
  author-added ParamAlias overlays, doubly mandatory because the canonical
  2-SMA cross also will not bootstrap without them (IndistinguishableFanIn);
- the sweep closure drops back to positional bind + manual name re-zip.

spec_gap -> #55: the wished-for structural-constant consumer code is recorded
in mw_3 as #55's acceptance evidence.

This commit is the milestone fieldtest itself; it closes no issue. Closing the
tracker milestone stays a deliberate manual act on this green gate.
2026-06-11 09:11:37 +02:00

204 lines
7.8 KiB
Rust

// Milestone fieldtest — scenario 2: a SWEEP over named axes + compare across
// the resulting family.
//
// Promise probed: take ONE blueprint and spread it across a FAMILY of disjoint
// instances over NAMED axes (`.axis("sma_cross.fast", [2,3]).sweep(run)`), then
// index/compare manifests and metrics across the family members (#33's surface).
//
// Public interface only: ledger + specs 0028/0030 + rustdoc. No crates/*/src
// was read. Uses the ALIASED cross so the promise-form axis names are real
// (scenario 1 established the plain cross's two knobs collide on
// `sma_cross.length`; aliasing is the author's fix).
use std::sync::mpsc;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role,
RunManifest, RunReport, SweepFamily, Target,
};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
/// The aliased 2-SMA cross (knobs surface as `fast` / `slow`).
fn sma_cross_aliased() -> Composite {
Composite::new(
"sma_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: "fast".into(), node: 0, slot: 0 },
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
],
vec![OutField { node: 2, field: 0, name: "out".into() }],
)
}
/// A fresh harness Composite plus the equity + exposure receivers it records to.
/// A fresh build per point gives each sweep member its OWN drainable channels
/// (the #32 spec's per-point-fresh-build pattern).
fn harness_with_sinks() -> (
Composite,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
) {
let (tx_eq, rx_eq) = mpsc::channel();
let (tx_ex, rx_ex) = mpsc::channel();
let bp = Composite::new(
"harness",
vec![
BlueprintNode::Composite(sma_cross_aliased()),
Exposure::builder().into(),
SimBroker::builder(1e-4).into(),
// sink 0: broker equity (item 4)
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
// sink 1: exposure stream (item 5)
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
vec![
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // cross -> Exposure
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // Exposure -> broker.exposure
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // broker -> equity sink
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // Exposure -> exposure sink
],
vec![Role {
name: "price".into(),
targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
source: Some(ScalarKind::F64),
}],
vec![],
vec![],
);
(bp, rx_eq, rx_ex)
}
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() {
let prices = synthetic_prices();
// --- The sweep: ONE blueprint, named axes, a family of disjoint runs. -----
// 2 (fast) x 2 (slow) x 1 (scale) = 4 points.
let family: SweepFamily = harness_with_sinks()
.0
.axis("sma_cross.fast", [2, 3])
.axis("sma_cross.slow", [4, 5])
.axis("scale", [0.5])
.sweep(|point| {
// Each point builds fresh (fresh channels), bootstraps by the SAME
// positional point vector the named axes resolved to, runs, drains,
// summarizes — author-side glue (the engine cannot know which sink
// is equity vs exposure; #28 "closure-driven" boundary).
let (bp, rx_eq, rx_ex) = harness_with_sinks();
let mut h = bp
.bootstrap_with_params(point.to_vec())
.expect("named-resolved point is kind-checked");
h.run(vec![prices.clone()]);
drop(h);
let eq_rows: Vec<_> = rx_eq.try_iter().collect();
let ex_rows: Vec<_> = rx_ex.try_iter().collect();
let equity = f64_field(&eq_rows, 0);
let exposure = f64_field(&ex_rows, 0);
let metrics = summarize(&equity, &exposure);
// The author also supplies the manifest (the sweep is domain-free;
// #29 made SweepPoint carry a full RunReport).
RunReport {
manifest: RunManifest {
commit: "fieldtest".into(),
// name the point's coords for the comparison below
params: vec![],
window: (Timestamp(0), Timestamp(660_000_000_000)),
seed: 0,
broker: "sim-optimal".into(),
},
metrics,
}
})
.expect("named axes resolve to a valid grid");
// --- Compare across the family (the #33 index/compare surface). -----------
println!("sweep family: {} members\n", family.points.len());
println!(
"{:>5} {:>5} {:>6} {:>12} {:>12} {:>8}",
"fast", "slow", "scale", "total_pips", "max_dd", "flips"
);
let mut best: Option<(usize, f64)> = None;
for (i, pt) in family.points.iter().enumerate() {
// pt.params is the point's coordinate in param_space() slot order.
let coord: Vec<String> = pt.params.iter().map(scalar_str).collect();
let m = &pt.report.metrics;
println!(
"{:>5} {:>5} {:>6} {:>12.4} {:>12.4} {:>8}",
coord[0], coord[1], coord[2], m.total_pips, m.max_drawdown, m.exposure_sign_flips
);
if best.is_none() || m.total_pips > best.unwrap().1 {
best = Some((i, m.total_pips));
}
}
let (bi, bp) = best.unwrap();
let bc: Vec<String> = family.points[bi].params.iter().map(scalar_str).collect();
println!(
"\nbest by total_pips: point #{bi} (fast={}, slow={}, scale={}) = {:.4} pips",
bc[0], bc[1], bc[2], bp
);
// --- Confirm members are disjoint / independently meaningful. -------------
// Distinct points must produce distinct metrics (the family is not constant).
let distinct: std::collections::BTreeSet<String> = family
.points
.iter()
.map(|p| format!("{:.6}", p.report.metrics.total_pips))
.collect();
println!(
"\ndistinct total_pips values across {} points: {}",
family.points.len(),
distinct.len()
);
assert!(
distinct.len() > 1,
"a sweep over distinct lengths must not collapse to one metric"
);
for pt in &family.points {
assert!(pt.report.metrics.total_pips.is_finite(), "every point produces metrics");
}
// Odometer order check (last axis fastest) — pin the family is ordered.
println!("\nfamily params, in order:");
for (i, pt) in family.points.iter().enumerate() {
let c: Vec<String> = pt.params.iter().map(scalar_str).collect();
println!(" #{i}: [{}]", c.join(", "));
}
println!("\nOK: named-axis sweep produced a comparable, ordered, non-constant family.");
}
fn scalar_str(s: &Scalar) -> String {
match s {
Scalar::I64(v) => v.to_string(),
Scalar::F64(v) => format!("{v}"),
Scalar::Bool(v) => v.to_string(),
Scalar::Ts(t) => t.0.to_string(),
}
}