Files
Aura/fieldtests/cycle-0049-random-sweep/c0049_1_continuous_tune.rs
T
Brummel af0191884d fieldtest: cycle-0049 — 4 examples, 7 findings
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
2026-06-17 13:42:39 +02:00

212 lines
8.2 KiB
Rust

// Cycle-0049 fieldtest — example 1: the HEADLINE task.
//
// Tune a 3-param SMA-cross strategy by drawing N random points over declared
// continuous `ParamRange`s and running the SAME `sweep` a grid would use — then
// pick the best point by total_pips. A grid would explode here (fast x slow x
// scale over continuous ranges is the curse of dimensionality); random sampling
// over declared ranges is the standard tool. This is the code a researcher
// writes (axis-hint 1).
//
// PUBLIC INTERFACE ONLY: API discovered from the design ledger
// (docs/design/INDEX.md C12/C12.1), the glossary, spec 0049, and
// `cargo doc --workspace --no-deps` 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, OutField, ParamRange, RandomSpace,
Role, RunManifest, RunReport, SweepFamily, Target, VecSource,
};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
/// The reusable 2-SMA-cross composite (legs named so their knobs surface as
/// `sma_cross.fast.length` / `sma_cross.slow.length`).
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() }],
)
}
/// A fresh root harness Composite plus the equity + exposure receivers it
/// records to. A fresh build per point gives each sweep member its OWN
/// drainable channels.
///
/// param_space() = [sma_cross.fast.length: I64, sma_cross.slow.length: I64,
/// exposure.scale: F64].
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()),
Exposure::builder().into(),
SimBroker::builder(1e-4).into(),
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(), // sink 0: equity
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(), // sink 1: exposure
],
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![],
);
(bp, rx_eq, rx_ex)
}
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
// A trending-then-reverting series so different (fast, slow, scale) actually
// produce different pip outcomes.
let prices = [
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.11, 1.10, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07, 1.12,
1.15, 1.13, 1.09, 1.05, 1.02,
];
prices
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::f64(p)))
.collect()
}
/// The author's per-point closure: build fresh, bootstrap by the point's cells,
/// run, drain, summarize into a RunReport. `Fn(&[Cell]) -> RunReport`.
fn run_one(point: &[Cell], prices: &[(Timestamp, Scalar)]) -> RunReport {
let (bp, rx_eq, rx_ex) = harness_with_sinks();
let mut h = bp
.bootstrap_with_cells(point)
.expect("random point is kind-checked against the param-space");
h.run(vec![Box::new(VecSource::new(prices.to_vec()))]);
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);
RunReport {
manifest: RunManifest {
commit: "fieldtest".into(),
params: vec![],
window: (Timestamp(0), Timestamp(60_000_000_000 * 20)),
seed: 0,
broker: "sim-optimal".into(),
},
metrics,
}
}
fn main() {
let prices = synthetic_prices();
// The param-space the random ranges are declared against (positional-parallel).
let space = harness_with_sinks().0.param_space();
println!("param-space ({} slots):", space.len());
for (i, ps) in space.iter().enumerate() {
println!(" slot {i}: {} : {:?}", ps.name, ps.kind);
}
// ONE declared continuous range per slot, in param_space() order.
let ranges = vec![
ParamRange::i64(2, 8), // sma_cross.fast.length in [2, 8] (inclusive)
ParamRange::i64(10, 30), // sma_cross.slow.length in [10, 30] (inclusive)
ParamRange::f64(0.5, 4.0), // exposure.scale in [0.5, 4.0) (half-open)
];
// Draw 200 seeded points; validated against the param-space IN new().
let count = 200;
let seed = 0xC0FFEE;
let rand_space = RandomSpace::new(&space, ranges, count, seed)
.expect("ranges are well-formed for the space");
println!("\nRandomSpace: {} points, seed {:#x}", rand_space.len(), seed);
// SAME execution layer as the grid sweep — sweep is generic over impl Space.
let family: SweepFamily = sweep(&rand_space, |point: &[Cell]| run_one(point, &prices));
assert_eq!(family.points.len(), count, "one point per draw");
// Pick the best point by total_pips.
let mut best: Option<(usize, f64)> = None;
for (i, pt) in family.points.iter().enumerate() {
let p = pt.report.metrics.total_pips;
if best.is_none() || p > best.unwrap().1 {
best = Some((i, p));
}
assert!(p.is_finite(), "every drawn point produces finite metrics");
}
let (bi, bp) = best.unwrap();
// Readable named view of the winning coordinate (named_params reuses zip).
let named = family.named_params(bi);
println!("\nbest point #{bi} = {:.4} pips at:", bp);
for (name, val) in &named {
println!(" {name} = {}", scalar_str(val));
}
// Confirm the family genuinely spread: more than one distinct metric.
let distinct: std::collections::BTreeSet<String> = family
.points
.iter()
.map(|p| format!("{:.6}", p.report.metrics.total_pips))
.collect();
println!(
"\ndistinct total_pips across {} points: {}",
family.points.len(),
distinct.len()
);
assert!(distinct.len() > 1, "a random sweep over real ranges must not collapse");
// Show every drawn point landed inside its declared range (in-range draws).
let mut fast_min = i64::MAX;
let mut fast_max = i64::MIN;
let mut scale_min = f64::INFINITY;
let mut scale_max = f64::NEG_INFINITY;
for pt in &family.points {
fast_min = fast_min.min(pt.params[0].i64());
fast_max = fast_max.max(pt.params[0].i64());
scale_min = scale_min.min(pt.params[2].f64());
scale_max = scale_max.max(pt.params[2].f64());
}
println!(
"\nsampled ranges: sma_cross.fast.length in [{fast_min}, {fast_max}] (declared [2, 8]); \
exposure.scale in [{scale_min:.4}, {scale_max:.4}) (declared [0.5, 4.0))"
);
assert!((2..=8).contains(&fast_min) && (2..=8).contains(&fast_max));
assert!(scale_min >= 0.5 && scale_max < 4.0);
println!("\nOK: random continuous-range tuning produced a comparable, spread family.");
}
fn scalar_str(s: &Scalar) -> String {
match s {
Scalar::I64(v) => v.to_string(),
Scalar::F64(v) => format!("{v:.4}"),
Scalar::Bool(v) => v.to_string(),
Scalar::Timestamp(t) => t.0.to_string(),
}
}