diff --git a/docs/specs/fieldtest-0049-random-sweep.md b/docs/specs/fieldtest-0049-random-sweep.md new file mode 100644 index 0000000..d538ca3 --- /dev/null +++ b/docs/specs/fieldtest-0049-random-sweep.md @@ -0,0 +1,183 @@ +# Fieldtest — cycle-0049 (random param-sweep) — 2026-06-17 + +**Status:** Draft — awaiting orchestrator triage +**Author:** fieldtester (dispatched by fieldtest skill) + +## Scope + +Cycle 0049 shipped the **random** half of the C12.1 param-sweep axis in +`aura_engine`. New public surface: `RandomSpace` (draws `N` seeded uniform +points over per-slot continuous ranges, validated against a blueprint's +param-space before any run), a typed `ParamRange` (`{lo, hi}` with +`ParamRange::i64` / `ParamRange::f64` constructors and `kind()`), a `Space` +trait (`points()` / `param_specs()`) that both `GridSpace` and `RandomSpace` +implement so `sweep` / `sweep_with_threads` are generic over `impl Space`, and +three new `SweepError` variants (`NonNumericRange`, `RangeKindMismatch`, +`EmptyRange`). The per-point run closure is unchanged (`Fn(&[Cell]) -> +RunReport`). This field test exercised the surface from a standalone downstream +consumer crate (path-deps only; public interface = ledger + glossary + spec +0049 + `cargo doc` rustdoc; no `crates/*/src` read). Binary exercised: each bin +rebuilt from HEAD via `cargo run --manifest-path …` after a clean +`cargo build --workspace`. + +## Examples + +### fieldtests/cycle-0049-random-sweep/c0049_1_continuous_tune.rs — headline continuous tuning +- Builds a 3-param SMA-cross harness (`sma_cross.fast.length: I64`, + `sma_cross.slow.length: I64`, `exposure.scale: F64`), declares one + `ParamRange` per slot, draws 200 seeded points via `RandomSpace::new`, runs + `sweep`, and picks the best point by `total_pips`. +- Fits the cycle scope: this is the named "audience reaches for it" task — a + grid over continuous ranges explodes; random draw is the standard tool. +- Outcome: built (first try after a self-inflicted port-name slip), ran, + matched expected. 200 in-range points, 92 distinct metrics, best #121 = + 6.3216 pips at fast=8/slow=12/scale=1.3182. + +### fieldtests/cycle-0049-random-sweep/c0049_2_validation_gate.rs — typed validation gate +- Declares invalid ranges and records the typed `SweepError` from + `RandomSpace::new` before any run: `Arity`, `RangeKindMismatch` (F64 range on + I64 slot), `EmptyRange` (I64 `lo>hi`), `EmptyRange` (F64 `lo>=hi`), the valid + I64 `lo==hi` single point (accepted), and `NonNumericRange` (range on a Bool + slot). The Bool slot required authoring a tiny custom node (`GateNode`), + because no shipped `aura-std` node declares a Bool/Timestamp param. +- Fits the cycle scope: directly probes axis-hint 2 (the typed gate). +- Outcome: built, ran, matched expected. Every fault caught pre-run with exact + slot + kind; `NonNumericRange` wins over `RangeKindMismatch` on the Bool slot. + +### fieldtests/cycle-0049-random-sweep/c0049_3_reproducibility.rs — reproducibility + seed-sensitivity + wide-range edge +- Compares whole `SweepFamily`s (`derive(PartialEq)`): same `(ranges, count, + seed)` reproduces an identical family; a different seed changes it. Then + probes the full `ParamRange::i64(i64::MIN, i64::MAX)` range through + `Space::points()` to check the commit-body hardening claim (the literal + sampler would `% 0`). +- Fits the cycle scope: axis-hint 3 (the C1 promise) plus the documented + sampler deviation. +- Outcome: built, ran, matched expected. Same-seed family identical; + different-seed family differs; the full i64-domain range was accepted and + `points()` produced 8 values spanning the signed domain with no panic. + +### fieldtests/cycle-0049-random-sweep/c0049_4_space_interchange.rs — Space-trait interchangeability +- One downstream `tune_and_rank(space: &S)` consumer, written once and + called with both a `GridSpace` and a `RandomSpace` over the same param-space, + same `run_one` closure, asserting both produce comparable, non-degenerate + families carrying the same schema. +- Fits the cycle scope: axis-hint 4 (the abstraction's payoff). +- Outcome: built, ran, matched expected. Both 12-point families ranked through + the identical generic consumer; shared `family.space`; named view identical. + +## Findings + +### [working] Headline continuous-range tuning is the code a researcher would write +- Example: c0049_1. +- The whole flow — `param_space()` → one `ParamRange` per slot → + `RandomSpace::new(&space, ranges, count, seed)?` → `sweep(&rand_space, + run_one)` → rank by metric → `family.named_params(best)` for a readable + coordinate — read naturally and compiled/ran on the first real attempt. All + 200 draws landed inside their declared ranges (I64 inclusive, F64 half-open), + and `bootstrap_with_cells(point)` paired cleanly with the `Fn(&[Cell]) -> + RunReport` closure (no `Scalar`/`Cell` conversion dance in the hot loop). The + family spread (92 distinct metrics over 200 points) and a single argmax pass + picked the best. This is the cycle's primary acceptance criterion met + empirically. +- Recommended downstream action: carry-on. + +### [working] The typed validation gate is precise and fires before any run +- Example: c0049_2. +- Every reachable fault surfaced from `RandomSpace::new` with an exact, + self-describing variant: `Arity { expected: 3, got: 2 }`, `RangeKindMismatch + { slot: 0, expected: I64, got: F64 }`, `EmptyRange { slot: 0 }` (I64 lo>hi) + and `{ slot: 2 }` (F64 lo>=hi), and `NonNumericRange { slot: 0, kind: Bool }`. + The valid I64 `lo==hi` single point was accepted (7 points). The ordering is + sensible: a non-numeric slot is reported as `NonNumericRange` even when the + supplied range's kind also mismatches, so the author sees the structural cause + (wrong slot kind) rather than a downstream symptom. +- Recommended downstream action: carry-on. + +### [working] Reproducibility, seed-sensitivity, and the wide-range i64 hardening all hold +- Example: c0049_3. +- `SweepFamily`'s `derive(PartialEq)` made the C1 promise checkable in one line: + `family(seed) == family(seed)` held end to end (manifest + metrics included), + and `family(seed_a) != family(seed_b)`. The full `[i64::MIN, i64::MAX]` range + — the case the commit body (e17d78f) flags as a spec deviation hardened + against `% 0` / signed-overflow — was accepted by `new` and produced 8 finite + values across the signed domain through the public `Space::points()`, no + panic. The documented deviation is observably upheld at the public surface. +- Recommended downstream action: carry-on. + +### [working] The Space trait delivers its payoff: one consumer, both enumerations +- Example: c0049_4. +- A single generic `tune_and_rank` drove a `GridSpace` and a + `RandomSpace` with zero per-enumeration branching, the same `run_one`, and a + shared `family.space` schema for the named view. The grid path stayed + behaviour-preserving (the existing `GridSpace::new` / `points()` / + `param_specs()` inherent methods are still there alongside the trait impl). +- Recommended downstream action: carry-on. + +### [spec_gap] `NonNumericRange` / Bool-slot ranges are unreachable with the shipped node roster +- Example: c0049_2 (the GateNode workaround). +- Every shipped `aura-std` node declares only `I64` (`length`) or `F64` + (`scale`) params; none has a `Bool` or `Timestamp` knob. So a downstream + consumer using only the standard roster can never hit the `NonNumericRange` + gate (nor a Bool `RangeKindMismatch`) — I had to author a custom + `PrimitiveBuilder`-based node with a Bool param purely to construct a Bool + slot. The variant is correct and the C16 "author your own node" path makes it + reachable, but the cycle ships a validation arm that no shipped surface can + trigger. The ledger/spec do not say whether a Bool knob is an expected + near-term authoring case or a purely defensive guard. Plausible alternate + reading: `NonNumericRange` is dead-until-a-Bool-knob-node-exists and could + have been deferred with the rest of the random axis. I picked the reading "it + is a defensive structural guard for future Bool/Timestamp knobs" and the + example demonstrates it works under that reading. +- Recommended downstream action: ratify (record in the ledger that the + Bool/Timestamp param-slot is a forward-looking authoring case the gate guards, + so the otherwise-untriggerable variant is intentional, not drift). + +### [friction] `RandomSpace` has no named-axis builder; positional `ranges` must line up with `param_space()` by hand +- Examples: c0049_1, c0049_3, c0049_4. +- `GridSpace` has a fluent, by-name sibling: `Composite::axis("sma_cross.fast", + …).sweep(run)` (the `SweepBinder`), which resolves axes against + `param_space()` by name so a mis-ordered or mis-counted axis is impossible. + `RandomSpace` has no counterpart: the author must build `Vec` in + exactly `param_space()` slot order and pass it positionally to + `RandomSpace::new(&space, ranges, …)`. The `Arity` / `RangeKindMismatch` + checks catch a wrong count or a kind-swapped slot, but a same-kind transposition + (e.g. swapping the two I64 ranges for `fast.length` and `slow.length`) passes + validation silently and tunes the wrong knob over the wrong interval — the + exact failure class the named `SweepBinder` was built to remove for the grid + axis (cf. node memory: the mw_1 ParamAlias pain, fixed by naming). The task + completes, but the author re-takes on the positional-alignment burden that the + grid path already retired. +- Recommended downstream action: plan (a `RandomSpace` named-axis builder — a + `RandomBinder` sibling to `SweepBinder` mapping `name -> ParamRange` against + `param_space()`, so the by-name resolution that protects the grid sweep also + protects the random sweep). + +### [spec_gap] `SweepError`'s rustdoc summary names only GridSpace though it now gates both spaces +- Examples: c0049_2 (the error type the consumer reads). +- The `SweepError` enum's top-level rustdoc (the public doc a consumer reads to + understand the type) still reads: "A structural fault constructing a + **`GridSpace`** — the typed gate before any run." The enum now also carries + the three RandomSpace-only variants (`NonNumericRange`, `RangeKindMismatch`, + `EmptyRange`), each documented individually as RandomSpace faults, but the + type-level summary is stale: a consumer scanning the type doc would not learn + that this same error type gates `RandomSpace::new`. The audit commit (3fca781) + noted refreshing the *module* doc to name `RandomSpace` + the `Space` trait, + but the `SweepError` type doc itself was not updated. Another equally-plausible + reading is that `SweepError` is deliberately the single shared sweep-error type + and the summary should say so; I cannot tell which from the public surface. +- Recommended downstream action: tighten the design ledger / docs (one-line + doc-debt fix: broaden the `SweepError` summary to "constructing a `GridSpace` + *or* `RandomSpace`" — doc-only, behaviour-preserving, the same low-grade + doc-debt class the audit already fixed inline for the module doc). + +## Recommendation summary + +| Finding | Class | Action | +|---|---|---| +| Headline continuous tuning works | working | carry-on | +| Typed validation gate is precise | working | carry-on | +| Reproducibility + seed-sensitivity + wide-i64 hold | working | carry-on | +| Space-trait interchangeability payoff | working | carry-on | +| NonNumericRange unreachable with shipped roster | spec_gap | ratify | +| No named-axis builder for RandomSpace | friction | plan | +| SweepError doc summary names only GridSpace | spec_gap | tighten the design ledger / docs | diff --git a/fieldtests/cycle-0049-random-sweep/Cargo.lock b/fieldtests/cycle-0049-random-sweep/Cargo.lock new file mode 100644 index 0000000..5e5b9e6 --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/Cargo.lock @@ -0,0 +1,131 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aura-core" +version = "0.1.0" +dependencies = [ + "serde", +] + +[[package]] +name = "aura-engine" +version = "0.1.0" +dependencies = [ + "aura-core", + "serde", + "serde_json", +] + +[[package]] +name = "aura-std" +version = "0.1.0" +dependencies = [ + "aura-core", +] + +[[package]] +name = "c0049-fieldtest" +version = "0.0.0" +dependencies = [ + "aura-core", + "aura-engine", + "aura-std", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/fieldtests/cycle-0049-random-sweep/Cargo.toml b/fieldtests/cycle-0049-random-sweep/Cargo.toml new file mode 100644 index 0000000..aabedde --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/Cargo.toml @@ -0,0 +1,41 @@ +# Standalone downstream-consumer crate for the cycle-0049 fieldtest +# (RandomSpace + the Space trait + typed ParamRange — the random half of the +# C12.1 param-sweep axis). +# +# Like the cycle-0007..0031 fixtures, this is NOT a member of the aura +# workspace — it path-deps the engine crates exactly as a real C16 research +# project would, then drives the new random-sweep surface from the PUBLIC +# interface only (design ledger + glossary + specs + `cargo doc` rustdoc; no +# crates/*/src was read). Built/run via +# cargo run --manifest-path fieldtests/cycle-0049-random-sweep/Cargo.toml --bin +# so HEAD source is always what runs. +# +# Empty [workspace] table: marks this fixture crate as its OWN workspace root. +[workspace] + +[package] +name = "c0049-fieldtest" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +aura-core = { path = "../../crates/aura-core" } +aura-engine = { path = "../../crates/aura-engine" } +aura-std = { path = "../../crates/aura-std" } + +[[bin]] +name = "c0049_1_continuous_tune" +path = "c0049_1_continuous_tune.rs" + +[[bin]] +name = "c0049_2_validation_gate" +path = "c0049_2_validation_gate.rs" + +[[bin]] +name = "c0049_3_reproducibility" +path = "c0049_3_reproducibility.rs" + +[[bin]] +name = "c0049_4_space_interchange" +path = "c0049_4_space_interchange.rs" diff --git a/fieldtests/cycle-0049-random-sweep/c0049_1_continuous_tune.rs b/fieldtests/cycle-0049-random-sweep/c0049_1_continuous_tune.rs new file mode 100644 index 0000000..5751310 --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/c0049_1_continuous_tune.rs @@ -0,0 +1,211 @@ +// 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)>, + mpsc::Receiver<(Timestamp, Vec)>, +) { + 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 = 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(), + } +} diff --git a/fieldtests/cycle-0049-random-sweep/c0049_2_validation_gate.rs b/fieldtests/cycle-0049-random-sweep/c0049_2_validation_gate.rs new file mode 100644 index 0000000..098dddd --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/c0049_2_validation_gate.rs @@ -0,0 +1,200 @@ +// Cycle-0049 fieldtest — example 2: the typed VALIDATION GATE. +// +// Declare invalid ranges and observe the typed `SweepError` returned by +// `RandomSpace::new` BEFORE any run (axis-hint 2). Exercises every reachable +// SweepError construction fault: +// - Arity (wrong number of ranges vs. param-space slots) +// - RangeKindMismatch (F64 range on an I64 slot) +// - EmptyRange (I64) (lo > hi) +// - EmptyRange (F64) (lo >= hi — the half-open lo == hi case) +// - the valid I64 lo == hi single-point range (accepted, NOT an error) +// - NonNumericRange (a range on a Bool slot) +// +// The NonNumericRange case needs a node with a Bool param. No shipped aura-std +// node has one (all knobs are I64 `length` / F64 `scale`), so — exactly as a +// C16 research project would (it authors its own nodes in Rust) — this fixture +// authors a tiny custom pass-through node `GateNode` whose single knob is a Bool. +// +// PUBLIC INTERFACE ONLY: ledger + glossary + spec 0049 + rustdoc. No +// crates/*/src was read. + +use aura_core::{ + Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, + ScalarKind, +}; +use aura_engine::{ + BlueprintNode, Composite, Edge, OutField, ParamRange, RandomSpace, Role, SweepError, Target, +}; +use aura_std::{Exposure, Sma, Sub}; + +// ---- A minimal custom node with a BOOL param (what a real project would write +// to need the NonNumericRange gate). It passes its f64 input through unchanged; +// the `enabled: bool` knob is declared purely so a Bool slot exists in the +// param-space. ----------------------------------------------------------------- +struct GateNode { + out: [Cell; 1], +} +impl Node for GateNode { + fn lookbacks(&self) -> Vec { + 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 gate_builder() -> PrimitiveBuilder { + PrimitiveBuilder::new( + "gate", + 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: "enabled".into(), kind: ScalarKind::Bool }], + }, + |_params: &[Cell]| Box::new(GateNode { out: [Cell::from_f64(0.0)] }) as Box, + ) +} + +/// SMA-cross: param-space = [sma_cross.fast.length: I64, sma_cross.slow.length: +/// I64, exposure.scale: F64]. Used for the numeric error variants. +fn numeric_space() -> Vec { + let cross = 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() }], + ); + Composite::new( + "harness", + vec![BlueprintNode::Composite(cross), 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() +} + +/// A param-space with a Bool slot at index 0 (the GateNode's `enabled` knob), +/// followed by an F64 slot (exposure.scale). +fn bool_space() -> Vec { + Composite::new( + "harness", + vec![gate_builder().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() { + let space = numeric_space(); + println!("numeric param-space:"); + for (i, p) in space.iter().enumerate() { + println!(" slot {i}: {} : {:?}", p.name, p.kind); + } + println!(); + + // 1. Arity — two ranges for a three-slot space. + let e = RandomSpace::new(&space, vec![ParamRange::i64(2, 8), ParamRange::i64(10, 30)], 50, 1); + report("Arity (2 ranges, 3 slots)", &e); + assert!(matches!(e, Err(SweepError::Arity { expected: 3, got: 2 }))); + + // 2. RangeKindMismatch — an F64 range on the I64 fast.length slot (slot 0). + let e = RandomSpace::new( + &space, + vec![ParamRange::f64(2.0, 8.0), ParamRange::i64(10, 30), ParamRange::f64(0.5, 4.0)], + 50, + 1, + ); + report("RangeKindMismatch (F64 range on I64 slot 0)", &e); + assert!(matches!( + e, + Err(SweepError::RangeKindMismatch { slot: 0, expected: ScalarKind::I64, got: ScalarKind::F64 }) + )); + + // 3. EmptyRange (I64) — lo > hi on slot 0. + let e = RandomSpace::new( + &space, + vec![ParamRange::i64(8, 2), ParamRange::i64(10, 30), ParamRange::f64(0.5, 4.0)], + 50, + 1, + ); + report("EmptyRange I64 (lo=8 > hi=2 on slot 0)", &e); + assert!(matches!(e, Err(SweepError::EmptyRange { slot: 0 }))); + + // 4. EmptyRange (F64) — half-open [lo, hi) with lo == hi is empty (slot 2). + let e = RandomSpace::new( + &space, + vec![ParamRange::i64(2, 8), ParamRange::i64(10, 30), ParamRange::f64(1.5, 1.5)], + 50, + 1, + ); + report("EmptyRange F64 (lo == hi == 1.5 on slot 2, half-open)", &e); + assert!(matches!(e, Err(SweepError::EmptyRange { slot: 2 }))); + + // 5. The VALID I64 lo == hi single-point range (NOT an error — inclusive). + let ok = RandomSpace::new( + &space, + vec![ParamRange::i64(5, 5), ParamRange::i64(10, 30), ParamRange::f64(0.5, 4.0)], + 7, + 1, + ); + match &ok { + Ok(rs) => println!( + "\nVALID | I64 lo == hi == 5 single point accepted -> {} points", + rs.len() + ), + Err(err) => panic!("I64 lo == hi must be a valid single point, got {err:?}"), + } + + // 6. NonNumericRange — any range on the Bool slot 0 of the custom node. + let bspace = bool_space(); + println!("\nbool param-space:"); + for (i, p) in bspace.iter().enumerate() { + println!(" slot {i}: {} : {:?}", p.name, p.kind); + } + // We must still pass *some* range per slot to even reach the per-slot check; + // an i64 range on the Bool slot. The kind-check ordering decides which error + // wins (NonNumericRange vs RangeKindMismatch) — record whichever surfaces. + let e = RandomSpace::new( + &bspace, + vec![ParamRange::i64(0, 1), ParamRange::f64(0.5, 4.0)], + 50, + 1, + ); + report("range on Bool slot 0", &e); + + println!("\nOK: every fault was caught by RandomSpace::new before any run."); +} + +fn report(label: &str, e: &Result) { + match e { + Ok(rs) => println!("{label:<48} -> Ok({} points) [UNEXPECTED]", rs.len()), + Err(err) => println!("{label:<48} -> Err({err:?})"), + } +} diff --git a/fieldtests/cycle-0049-random-sweep/c0049_3_reproducibility.rs b/fieldtests/cycle-0049-random-sweep/c0049_3_reproducibility.rs new file mode 100644 index 0000000..72bc68a --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/c0049_3_reproducibility.rs @@ -0,0 +1,212 @@ +// 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)>, mpsc::Receiver<(Timestamp, Vec)>) { + 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 { + 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 { + 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, + ) + .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."); +} diff --git a/fieldtests/cycle-0049-random-sweep/c0049_4_space_interchange.rs b/fieldtests/cycle-0049-random-sweep/c0049_4_space_interchange.rs new file mode 100644 index 0000000..e60c4a6 --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/c0049_4_space_interchange.rs @@ -0,0 +1,191 @@ +// 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)>, + mpsc::Receiver<(Timestamp, Vec)>, +) { + 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(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 = 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 { + 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() +} diff --git a/fieldtests/cycle-0049-random-sweep/out_1_continuous_tune.txt b/fieldtests/cycle-0049-random-sweep/out_1_continuous_tune.txt new file mode 100644 index 0000000..b11eef3 --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/out_1_continuous_tune.txt @@ -0,0 +1,17 @@ +param-space (3 slots): + slot 0: sma_cross.fast.length : I64 + slot 1: sma_cross.slow.length : I64 + slot 2: exposure.scale : F64 + +RandomSpace: 200 points, seed 0xc0ffee + +best point #121 = 6.3216 pips at: + sma_cross.fast.length = 8 + sma_cross.slow.length = 12 + exposure.scale = 1.3182 + +distinct total_pips across 200 points: 92 + +sampled ranges: sma_cross.fast.length in [2, 8] (declared [2, 8]); exposure.scale in [0.5131, 3.9784) (declared [0.5, 4.0)) + +OK: random continuous-range tuning produced a comparable, spread family. diff --git a/fieldtests/cycle-0049-random-sweep/out_2_validation_gate.txt b/fieldtests/cycle-0049-random-sweep/out_2_validation_gate.txt new file mode 100644 index 0000000..f5fb4b5 --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/out_2_validation_gate.txt @@ -0,0 +1,18 @@ +numeric param-space: + slot 0: sma_cross.fast.length : I64 + slot 1: sma_cross.slow.length : I64 + slot 2: exposure.scale : F64 + +Arity (2 ranges, 3 slots) -> Err(Arity { expected: 3, got: 2 }) +RangeKindMismatch (F64 range on I64 slot 0) -> Err(RangeKindMismatch { slot: 0, expected: I64, got: F64 }) +EmptyRange I64 (lo=8 > hi=2 on slot 0) -> Err(EmptyRange { slot: 0 }) +EmptyRange F64 (lo == hi == 1.5 on slot 2, half-open) -> Err(EmptyRange { slot: 2 }) + +VALID | I64 lo == hi == 5 single point accepted -> 7 points + +bool param-space: + slot 0: gate.enabled : Bool + slot 1: exposure.scale : F64 +range on Bool slot 0 -> Err(NonNumericRange { slot: 0, kind: Bool }) + +OK: every fault was caught by RandomSpace::new before any run. diff --git a/fieldtests/cycle-0049-random-sweep/out_3_reproducibility.txt b/fieldtests/cycle-0049-random-sweep/out_3_reproducibility.txt new file mode 100644 index 0000000..2b107c4 --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/out_3_reproducibility.txt @@ -0,0 +1,23 @@ +family A: 64 points; family B (same seed): 64 points +reproducible: family(seed=0xABCDEF) == family(seed=0xABCDEF) OK + +first 3 points, seed 0xABCDEF vs 0x123456: + #0: [6,21,0.9149] vs [2,10,2.8858] + #1: [5,16,2.0410] vs [4,20,1.5472] + #2: [3,10,2.7001] vs [8,22,2.4252] +seed-sensitive: family(0xABCDEF) != family(0x123456) OK + +wide-range probe: param-space slot 0 = idnode.k : I64 +RandomSpace::new(full i64 range) accepted -> 8 points +points() over the full i64 range produced 8 points (no panic): + #0: k = 4456085495900499605 + #1: k = -4084088288392011950 + #2: k = -8521839250712812558 + #3: k = -5194507324077150883 + #4: k = -2952751159242293803 + #5: k = -5443600385428481601 + #6: k = 247114729376335590 + #7: k = 3046653382386749148 +wide-range I64 sampler: no panic OK + +OK: reproducible + seed-sensitive; wide-range probe survived. diff --git a/fieldtests/cycle-0049-random-sweep/out_4_space_interchange.txt b/fieldtests/cycle-0049-random-sweep/out_4_space_interchange.txt new file mode 100644 index 0000000..98bc781 --- /dev/null +++ b/fieldtests/cycle-0049-random-sweep/out_4_space_interchange.txt @@ -0,0 +1,17 @@ +GridSpace declares 12 points (3 x 2 x 2) +RandomSpace draws 12 points + +[grid ] 12 points; best #8 = 4.2667 pips +[random] 12 points; best #9 = 1.6469 pips + +shared param-space schema across both families: + sma_cross.fast.length : I64 + sma_cross.slow.length : I64 + exposure.scale : F64 + +grid best coords: ["sma_cross.fast.length=6", "sma_cross.slow.length=10", "exposure.scale=0.5000"] +random best coords: ["sma_cross.fast.length=6", "sma_cross.slow.length=10", "exposure.scale=1.2954"] +grid: 7 distinct total_pips over 12 points +random: 9 distinct total_pips over 12 points + +OK: one `&impl Space` consumer drove both Grid and Random sweeps unchanged.