af0191884d
Public-API field test of the random param-sweep surface, from a standalone downstream-consumer crate (path-deps only; the public interface = ledger + glossary + spec 0049 + cargo doc rustdoc; no crates/*/src read). Four bins, each built from HEAD and run: continuous tuning (200-point random tune ranked by total_pips), the typed validation gate (all five reachable SweepError variants pre-run), reproducibility + seed-sensitivity + the full i64::MIN..=MAX sampler edge, and Space-trait interchangeability (one tune_and_rank<S: Space> over both GridSpace and RandomSpace). Findings: 0 bugs, 4 working, 2 spec_gap, 1 friction. The four working findings confirm the cycle's acceptance criterion empirically — the headline tune reads as the code a researcher would write, the gate is precise and fires before any run, the C1 reproducibility promise is checkable in one line, and the Space trait delivers one-consumer/both-enumerations. Triage of the actionable findings: - friction (no named-axis builder for RandomSpace — positional Vec<ParamRange> must align with param_space() by hand, and a same-kind transposition passes validation silently): filed as a feature for a future cycle, refs #79 (a RandomBinder sibling to the grid's SweepBinder). - spec_gap (SweepError rustdoc summary named only GridSpace): fixed inline in a follow-up doc commit. - spec_gap (NonNumericRange / Bool-slot ranges unreachable with the shipped aura-std node roster — no node declares a Bool/Timestamp knob): RATIFIED as intentional. The variant is a forward-looking structural guard for the C16 "author your own node" path (a Bool/Timestamp param-slot a custom node may declare); its current untriggerability with the standard roster is expected, not drift. refs #79
192 lines
6.8 KiB
Rust
192 lines
6.8 KiB
Rust
// Cycle-0049 fieldtest — example 4: the Space-trait PAYOFF (axis-hint 4).
|
|
//
|
|
// Swap a GridSpace sweep for a RandomSpace sweep behind the `Space` trait with
|
|
// the SAME downstream consumer code. A research helper that takes `&impl Space`
|
|
// and runs the sweep + ranks the family is written ONCE and called with both
|
|
// enumerations — the abstraction's whole point.
|
|
//
|
|
// PUBLIC INTERFACE ONLY: ledger (C12.1 + the Space trait) + glossary + spec
|
|
// 0049 + rustdoc. No crates/*/src was read.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{Cell, Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, GridSpace, OutField, ParamRange,
|
|
RandomSpace, Role, RunManifest, RunReport, Space, SweepFamily, Target, VecSource,
|
|
};
|
|
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
|
|
|
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() }],
|
|
)
|
|
}
|
|
|
|
fn harness_with_sink() -> (
|
|
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()),
|
|
Exposure::builder().into(),
|
|
SimBroker::builder(1e-4).into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
|
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).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 },
|
|
Edge { from: 1, to: 4, 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![],
|
|
);
|
|
(bp, rx_eq, rx_ex)
|
|
}
|
|
|
|
fn prices() -> Vec<(Timestamp, Scalar)> {
|
|
let p = [
|
|
1.00, 1.02, 1.05, 1.04, 1.06, 1.10, 1.08, 1.05, 1.03, 1.07, 1.12, 1.09, 1.11, 1.14, 1.10,
|
|
];
|
|
p.iter()
|
|
.enumerate()
|
|
.map(|(i, &v)| (Timestamp(60_000_000_000 * i as i64), Scalar::f64(v)))
|
|
.collect()
|
|
}
|
|
|
|
fn run_one(point: &[Cell]) -> RunReport {
|
|
let (bp, rx_eq, rx_ex) = harness_with_sink();
|
|
let mut h = bp.bootstrap_with_cells(point).expect("kind-checked point");
|
|
h.run(vec![Box::new(VecSource::new(prices()))]);
|
|
drop(h);
|
|
let eq: Vec<_> = rx_eq.try_iter().collect();
|
|
let ex: Vec<_> = rx_ex.try_iter().collect();
|
|
let metrics = summarize(&f64_field(&eq, 0), &f64_field(&ex, 0));
|
|
RunReport {
|
|
manifest: RunManifest {
|
|
commit: "fieldtest".into(),
|
|
params: vec![],
|
|
window: (Timestamp(0), Timestamp(60_000_000_000 * 15)),
|
|
seed: 0,
|
|
broker: "sim-optimal".into(),
|
|
},
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
/// The ONE downstream consumer, generic over the enumeration. It does not know
|
|
/// (or care) whether the points came from a cartesian product or seeded draws —
|
|
/// it just runs the sweep and returns the best point by total_pips. This is the
|
|
/// code that previously could only take &GridSpace.
|
|
fn tune_and_rank<S: Space>(label: &str, space: &S) -> (SweepFamily, usize) {
|
|
let family = sweep(space, run_one);
|
|
let mut best = 0usize;
|
|
for (i, pt) in family.points.iter().enumerate() {
|
|
if pt.report.metrics.total_pips > family.points[best].report.metrics.total_pips {
|
|
best = i;
|
|
}
|
|
}
|
|
println!(
|
|
"[{label}] {} points; best #{best} = {:.4} pips",
|
|
family.points.len(),
|
|
family.points[best].report.metrics.total_pips
|
|
);
|
|
(family, best)
|
|
}
|
|
|
|
fn main() {
|
|
let space = harness_with_sink().0.param_space();
|
|
|
|
// GridSpace: an explicit discrete lattice over the same three slots.
|
|
let grid = GridSpace::new(
|
|
&space,
|
|
vec![
|
|
vec![Scalar::i64(2), Scalar::i64(4), Scalar::i64(6)],
|
|
vec![Scalar::i64(10), Scalar::i64(20)],
|
|
vec![Scalar::f64(0.5), Scalar::f64(1.5)],
|
|
],
|
|
)
|
|
.expect("well-formed grid");
|
|
println!("GridSpace declares {} points (3 x 2 x 2)", grid.len());
|
|
|
|
// RandomSpace: seeded draws over continuous ranges spanning the same slots.
|
|
let rand = RandomSpace::new(
|
|
&space,
|
|
vec![ParamRange::i64(2, 6), ParamRange::i64(10, 20), ParamRange::f64(0.5, 1.5)],
|
|
12,
|
|
0xBEEF,
|
|
)
|
|
.expect("well-formed ranges");
|
|
println!("RandomSpace draws {} points\n", rand.len());
|
|
|
|
// THE PAYOFF: the exact same `tune_and_rank` over both, no per-enumeration
|
|
// branching in the consumer.
|
|
let (gfam, gbest) = tune_and_rank("grid ", &grid);
|
|
let (rfam, rbest) = tune_and_rank("random", &rand);
|
|
|
|
// Both carry the same param-space schema (names + kinds) for the named view.
|
|
assert_eq!(gfam.space, rfam.space, "both Spaces carry the same param-space");
|
|
println!("\nshared param-space schema across both families:");
|
|
for ps in &gfam.space {
|
|
println!(" {} : {:?}", ps.name, ps.kind);
|
|
}
|
|
|
|
// The named view works identically off either family.
|
|
println!("\ngrid best coords: {:?}", named(&gfam, gbest));
|
|
println!("random best coords: {:?}", named(&rfam, rbest));
|
|
|
|
// Both families are non-degenerate (the sweep ran real disjoint sims).
|
|
for (lbl, f) in [("grid", &gfam), ("random", &rfam)] {
|
|
let distinct: std::collections::BTreeSet<String> = f
|
|
.points
|
|
.iter()
|
|
.map(|p| format!("{:.6}", p.report.metrics.total_pips))
|
|
.collect();
|
|
println!("{lbl}: {} distinct total_pips over {} points", distinct.len(), f.points.len());
|
|
assert!(distinct.len() > 1, "{lbl} family must not collapse");
|
|
}
|
|
|
|
println!("\nOK: one `&impl Space` consumer drove both Grid and Random sweeps unchanged.");
|
|
}
|
|
|
|
fn named(f: &SweepFamily, i: usize) -> Vec<String> {
|
|
f.named_params(i)
|
|
.into_iter()
|
|
.map(|(n, v)| {
|
|
let s = match v {
|
|
Scalar::I64(x) => x.to_string(),
|
|
Scalar::F64(x) => format!("{x:.4}"),
|
|
Scalar::Bool(x) => x.to_string(),
|
|
Scalar::Timestamp(t) => t.0.to_string(),
|
|
};
|
|
format!("{n}={s}")
|
|
})
|
|
.collect()
|
|
}
|