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
This commit is contained in:
@@ -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<S: Space>(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<S: Space>` 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<ParamRange>` 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 |
|
||||
+131
@@ -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"
|
||||
@@ -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 <name>
|
||||
# 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"
|
||||
@@ -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<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(),
|
||||
}
|
||||
}
|
||||
@@ -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<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 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<dyn Node>,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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<ParamSpec> {
|
||||
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<ParamSpec> {
|
||||
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<RandomSpace, SweepError>) {
|
||||
match e {
|
||||
Ok(rs) => println!("{label:<48} -> Ok({} points) [UNEXPECTED]", rs.len()),
|
||||
Err(err) => println!("{label:<48} -> Err({err:?})"),
|
||||
}
|
||||
}
|
||||
@@ -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<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.");
|
||||
}
|
||||
@@ -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<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()
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user