03285d1b47
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.
204 lines
7.8 KiB
Rust
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(),
|
|
}
|
|
}
|