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
213 lines
8.0 KiB
Rust
213 lines
8.0 KiB
Rust
// Cycle-0049 fieldtest — example 3: REPRODUCIBILITY + seed-sensitivity (C1),
|
|
// plus the wide-range I64 sampler edge (axis-hint 3).
|
|
//
|
|
// The C1 promise a researcher relies on: the same (ranges, count, seed)
|
|
// reproduces the same FAMILY of runs; a different seed changes it. We compare
|
|
// whole SweepFamilies (derive(PartialEq)) end to end — manifest+metrics and all.
|
|
//
|
|
// We also probe the wide-range I64 draw directly through Space::points(): the
|
|
// spec's verbatim sampler computes `span = hi - lo + 1` and `% span`, which the
|
|
// commit body (e17d78f) says was hardened so a full [i64::MIN, i64::MAX] range
|
|
// (span == 2^64 -> wraps to 0 -> `% 0`) does NOT panic. We declare exactly that
|
|
// range and call points() to see whether the public surface upholds the claim.
|
|
//
|
|
// PUBLIC INTERFACE ONLY: ledger + glossary + spec 0049 + rustdoc. No
|
|
// crates/*/src was read.
|
|
|
|
use std::sync::mpsc;
|
|
|
|
use aura_core::{
|
|
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
|
ScalarKind, Timestamp,
|
|
};
|
|
use aura_engine::{
|
|
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, 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<aura_core::Scalar>)>, mpsc::Receiver<(Timestamp, Vec<aura_core::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, aura_core::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];
|
|
p.iter()
|
|
.enumerate()
|
|
.map(|(i, &v)| (Timestamp(60_000_000_000 * i as i64), aura_core::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 * 12)),
|
|
seed: 0,
|
|
broker: "sim-optimal".into(),
|
|
},
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
fn build_family(seed: u64) -> SweepFamily {
|
|
let space = harness_with_sink().0.param_space();
|
|
let ranges = vec![
|
|
ParamRange::i64(2, 8),
|
|
ParamRange::i64(9, 25),
|
|
ParamRange::f64(0.5, 3.0),
|
|
];
|
|
let rs = RandomSpace::new(&space, ranges, 64, seed).expect("well-formed ranges");
|
|
sweep(&rs, run_one)
|
|
}
|
|
|
|
// ---- a custom node with a single i64 knob that accepts ANY value (so a wide
|
|
// i64 draw never trips a node constructor's domain `assert`, isolating the
|
|
// SAMPLER's behaviour). -------------------------------------------------------
|
|
struct IdNode {
|
|
out: [Cell; 1],
|
|
}
|
|
impl Node for IdNode {
|
|
fn lookbacks(&self) -> Vec<usize> {
|
|
vec![1]
|
|
}
|
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
|
let w = ctx.f64_in(0);
|
|
if w.is_empty() {
|
|
return None;
|
|
}
|
|
self.out[0] = Cell::from_f64(w[0]);
|
|
Some(&self.out)
|
|
}
|
|
}
|
|
fn id_i64_space() -> Vec<ParamSpec> {
|
|
Composite::new(
|
|
"harness",
|
|
vec![
|
|
PrimitiveBuilder::new(
|
|
"idnode",
|
|
NodeSchema {
|
|
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
|
|
output: vec![FieldSpec { name: "out".into(), kind: ScalarKind::F64 }],
|
|
params: vec![ParamSpec { name: "k".into(), kind: ScalarKind::I64 }],
|
|
},
|
|
|_p: &[Cell]| Box::new(IdNode { out: [Cell::from_f64(0.0)] }) as Box<dyn Node>,
|
|
)
|
|
.into(),
|
|
Exposure::builder().into(),
|
|
],
|
|
vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }],
|
|
vec![Role {
|
|
name: "price".into(),
|
|
targets: vec![Target { node: 0, slot: 0 }],
|
|
source: Some(ScalarKind::F64),
|
|
}],
|
|
vec![OutField { node: 1, field: 0, name: "exposure".into() }],
|
|
)
|
|
.param_space()
|
|
}
|
|
|
|
fn main() {
|
|
// --- 1. Reproducibility: same seed -> bit-identical family. ----------------
|
|
let a = build_family(0xABCDEF);
|
|
let b = build_family(0xABCDEF);
|
|
println!("family A: {} points; family B (same seed): {} points", a.points.len(), b.points.len());
|
|
assert_eq!(a, b, "same (ranges, count, seed) must reproduce the SAME family (C1)");
|
|
println!("reproducible: family(seed=0xABCDEF) == family(seed=0xABCDEF) OK");
|
|
|
|
// --- 2. Seed-sensitivity: a different seed changes the draws. --------------
|
|
let c = build_family(0x123456);
|
|
assert_ne!(a, c, "a different seed must change the family");
|
|
// Show the first three coordinates differ.
|
|
println!("\nfirst 3 points, seed 0xABCDEF vs 0x123456:");
|
|
for i in 0..3 {
|
|
let pa = &a.points[i].params;
|
|
let pc = &c.points[i].params;
|
|
println!(
|
|
" #{i}: [{},{},{:.4}] vs [{},{},{:.4}]",
|
|
pa[0].i64(), pa[1].i64(), pa[2].f64(),
|
|
pc[0].i64(), pc[1].i64(), pc[2].f64(),
|
|
);
|
|
}
|
|
println!("seed-sensitive: family(0xABCDEF) != family(0x123456) OK");
|
|
|
|
// --- 3. The wide-range I64 sampler edge (commit-body hardening claim). -----
|
|
// Declare the full i64 domain. The spec's literal sampler would `% 0`.
|
|
let bspace = id_i64_space();
|
|
println!("\nwide-range probe: param-space slot 0 = {} : {:?}", bspace[0].name, bspace[0].kind);
|
|
let wide = RandomSpace::new(
|
|
&bspace,
|
|
vec![ParamRange::i64(i64::MIN, i64::MAX), ParamRange::f64(0.5, 3.0)],
|
|
8,
|
|
42,
|
|
);
|
|
match wide {
|
|
Ok(rs) => {
|
|
println!("RandomSpace::new(full i64 range) accepted -> {} points", rs.len());
|
|
// Call the public Space::points() directly: does it panic on span==2^64?
|
|
let pts = rs.points();
|
|
println!("points() over the full i64 range produced {} points (no panic):", pts.len());
|
|
for (i, p) in pts.iter().enumerate() {
|
|
println!(" #{i}: k = {}", p[0].i64());
|
|
}
|
|
println!("wide-range I64 sampler: no panic OK");
|
|
}
|
|
Err(e) => println!("RandomSpace::new(full i64 range) rejected -> Err({e:?})"),
|
|
}
|
|
|
|
println!("\nOK: reproducible + seed-sensitive; wide-range probe survived.");
|
|
}
|