fieldtest: milestone the-world param-space & sweep — green, delivers promise
Milestone-scope fieldtest (closing functional gate) for "The World —
parameter-space & sweep" (issues #30-#36). Three curated end-to-end scenarios
derived top-down from the milestone promise, driven from the public interface
only (ledger + specs + rustdoc; no crates/*/src read), built and run from HEAD
via a downstream consumer crate.
Delivery verdict: GREEN. The milestone delivers its promise.
- mw_1 single run bound by name: .with("sma_cross.fast", 2).bootstrap() binds
and runs (12 equity rows); diagnostics precise and knob-named (UnknownKnob /
AmbiguousKnob / KindMismatch).
- mw_2 named-axis sweep + compare: .axis(..).sweep(run) yields a 4-member,
ordered (odometer), disjoint, non-constant SweepFamily; ranking from public
fields. CLI (aura sweep / aura runs rank) delivers the same with no Rust.
- mw_3 structural-constant negative space (deliberate): SMA2-entry's
definitional `2` has no honest expression today -> grounds #55. Out of
milestone scope; not a blocker.
Findings: 0 bugs, 2 working, 3 friction, 1 spec_gap. Verified independently
(re-ran all three from HEAD; confirmed a plain SMA-cross emits sma_cross.length
twice and the shipped CLI sample carries the fast/slow ParamAlias overlays).
Friction (routed to the forward queue, none blocks the gate):
- the promised sma_cross.fast/.slow names are not free — they require
author-added ParamAlias overlays, doubly mandatory because the canonical
2-SMA cross also will not bootstrap without them (IndistinguishableFanIn);
- the sweep closure drops back to positional bind + manual name re-zip.
spec_gap -> #55: the wished-for structural-constant consumer code is recorded
in mw_3 as #55's acceptance evidence.
This commit is the milestone fieldtest itself; it closes no issue. Closing the
tracker milestone stays a deliberate manual act on this green gate.
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
# 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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-std"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ms-the-world-param-sweep-fieldtest"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-std",
|
||||
]
|
||||
|
||||
[[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 = "syn"
|
||||
version = "2.0.117"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
|
||||
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"
|
||||
@@ -0,0 +1,34 @@
|
||||
# Milestone fieldtest consumer crate — "The World — parameter-space & sweep".
|
||||
#
|
||||
# A standalone downstream project (the shape a C16 research project takes):
|
||||
# path-deps on the engine crates only. It drives the param-space / named-binding
|
||||
# / sweep surface from the PUBLIC interface only — no crates/*/src was read; the
|
||||
# API was discovered from the design ledger (docs/design/INDEX.md), the specs
|
||||
# (0015/0028/0030), and `cargo doc --workspace --no-deps` rustdoc.
|
||||
#
|
||||
# Empty [workspace] table: this fixture crate is its OWN workspace root, so it is
|
||||
# not pulled into the aura engine workspace.
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "ms-the-world-param-sweep-fieldtest"
|
||||
edition = "2024"
|
||||
version = "0.0.0"
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "mw_1_single_named"
|
||||
path = "mw_1_single_named.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "mw_2_sweep_compare"
|
||||
path = "mw_2_sweep_compare.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "mw_3_structural_constant"
|
||||
path = "mw_3_structural_constant.rs"
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../../crates/aura-core" }
|
||||
aura-std = { path = "../../crates/aura-std" }
|
||||
aura-engine = { path = "../../crates/aura-engine" }
|
||||
@@ -0,0 +1,239 @@
|
||||
// Milestone fieldtest — scenario 1: a single concrete run, bound BY NAME.
|
||||
//
|
||||
// Promise probed: an author declares a strategy's tunable knobs in its schema
|
||||
// (typed), inspects them via `param_space()`, and binds one concrete runnable
|
||||
// instance by NAME — `.with("sma_cross.fast", 2).with("scale", 0.5).bootstrap()`.
|
||||
//
|
||||
// Public interface only: discovered from the ledger + specs 0015/0030 + rustdoc.
|
||||
// No crates/*/src was read.
|
||||
//
|
||||
// The harness: source -> sma_cross composite { Sma, Sma, Sub } -> Exposure
|
||||
// -> SimBroker -> Recorder. The two SMA lengths live INSIDE the
|
||||
// `sma_cross` composite (so param_space() path-qualifies them as
|
||||
// `sma_cross.fast` / `sma_cross.slow`), `scale` is a root-level Exposure knob
|
||||
// (bare). The 0024 root model: the root graph IS a Composite all of whose roles
|
||||
// are source-bound.
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
BindError, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role, Target,
|
||||
};
|
||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
|
||||
/// Build the reusable inner 2/4-SMA-cross composite (value-empty recipes; the
|
||||
/// two lengths are open knobs bound at bootstrap).
|
||||
fn sma_cross() -> Composite {
|
||||
Composite::new(
|
||||
"sma_cross",
|
||||
vec![
|
||||
Sma::builder().into(),
|
||||
Sma::builder().into(),
|
||||
Sub::builder().into(),
|
||||
],
|
||||
// interior edges: fast -> Sub.slot0, slow -> Sub.slot1
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
],
|
||||
// one OPEN input role (price) fanning into both SMAs' slot 0.
|
||||
// source: None => open interior port, wired by the enclosing graph.
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 },
|
||||
Target { node: 1, slot: 0 },
|
||||
],
|
||||
// source: None => an OPEN interior port, wired by the enclosing graph
|
||||
// (0024 root model: Role.source distinguishes open vs source-bound).
|
||||
source: None,
|
||||
}],
|
||||
vec![], // no param aliases
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// The SAME composite, but with C23 ParamAlias overlays so the two interior SMA
|
||||
/// knobs surface as the DISTINCT names `fast` / `slow` the milestone promise
|
||||
/// example assumes. This is the work the author must do by hand to make
|
||||
/// `sma_cross.fast` real.
|
||||
fn sma_cross_aliased() -> Composite {
|
||||
Composite::new(
|
||||
"sma_cross",
|
||||
vec![
|
||||
Sma::builder().into(),
|
||||
Sma::builder().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,
|
||||
}],
|
||||
// ALIASES: rename Sma#0's length slot -> "fast", Sma#1's -> "slow".
|
||||
vec![
|
||||
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
|
||||
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
|
||||
],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// Harness over an arbitrary inner cross composite (plain or aliased).
|
||||
fn harness_with(
|
||||
inner: Composite,
|
||||
tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>,
|
||||
) -> Composite {
|
||||
Composite::new(
|
||||
"harness",
|
||||
vec![
|
||||
BlueprintNode::Composite(inner),
|
||||
Exposure::builder().into(),
|
||||
SimBroker::builder(1e-4).into(),
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).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 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 },
|
||||
Target { node: 2, slot: 1 },
|
||||
],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
/// The root harness over the PLAIN (un-aliased) cross — its two SMA knobs both
|
||||
/// surface as `sma_cross.length`.
|
||||
fn harness(tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>) -> Composite {
|
||||
harness_with(sma_cross(), tx)
|
||||
}
|
||||
|
||||
/// The root harness over the ALIASED cross — knobs surface as
|
||||
/// `sma_cross.fast` / `sma_cross.slow`.
|
||||
fn harness_aliased(tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>) -> Composite {
|
||||
harness_with(sma_cross_aliased(), tx)
|
||||
}
|
||||
|
||||
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
let prices = [
|
||||
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07,
|
||||
];
|
||||
prices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// --- 1. Inspect the param-space: does it tell me the knob names? ----------
|
||||
let (tx0, _rx0) = mpsc::channel();
|
||||
let space = harness(tx0).param_space();
|
||||
println!("param_space() knobs (name : kind):");
|
||||
for p in &space {
|
||||
println!(" {} : {:?}", p.name, p.kind);
|
||||
}
|
||||
|
||||
// --- 2a. FIRST ATTEMPT: bind by the milestone-promise names. --------------
|
||||
// The carrier / spec 0030 worked example says these are `sma_cross.fast` /
|
||||
// `sma_cross.slow`. Try it verbatim and record the diagnostic.
|
||||
let (txp, _rxp) = mpsc::channel();
|
||||
let promise_form: Result<_, BindError> = harness(txp)
|
||||
.with("sma_cross.fast", 2)
|
||||
.with("sma_cross.slow", 4)
|
||||
.with("scale", 0.5)
|
||||
.bootstrap();
|
||||
println!("\n[2a] promise-form (sma_cross.fast/.slow) result: {:?}", promise_form.err());
|
||||
|
||||
// --- 2b. SECOND ATTEMPT: bind by the names param_space() ACTUALLY emits. ---
|
||||
// Both interior SMA knobs surface as the IDENTICAL `sma_cross.length`.
|
||||
let (txq, _rxq) = mpsc::channel();
|
||||
let real_name_form: Result<_, BindError> = harness(txq)
|
||||
.with("sma_cross.length", 2)
|
||||
.with("scale", 0.5)
|
||||
.bootstrap();
|
||||
println!("[2b] real-emitted-name (sma_cross.length) result: {:?}", real_name_form.err());
|
||||
|
||||
// --- 2c. The FIX: add C23 ParamAlias overlays so fast/slow become real. ---
|
||||
// With the aliases, param_space() emits the DISTINCT names the promise wants.
|
||||
let (txal0, _rxal0) = mpsc::channel();
|
||||
let aliased_space = harness_aliased(txal0).param_space();
|
||||
println!("\n[2c] param_space() AFTER aliasing:");
|
||||
for p in &aliased_space {
|
||||
println!(" {} : {:?}", p.name, p.kind);
|
||||
}
|
||||
|
||||
// Now the milestone-promise named bind works AND runs.
|
||||
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
||||
let mut h = harness_aliased(tx)
|
||||
.with("sma_cross.fast", 2)
|
||||
.with("sma_cross.slow", 4)
|
||||
.with("scale", 0.5)
|
||||
.bootstrap()
|
||||
.expect("aliased harness binds by the promised names");
|
||||
|
||||
h.run(vec![synthetic_prices()]);
|
||||
drop(h);
|
||||
|
||||
let rows: Vec<(Timestamp, Vec<Scalar>)> = rx.iter().collect();
|
||||
println!("\n[2c] aliased named-bound run — recorded equity rows: {}", rows.len());
|
||||
for (ts, row) in &rows {
|
||||
if let Scalar::F64(x) = row[0] {
|
||||
println!(" ts={:>14} equity_pips={:.4}", ts.0, x);
|
||||
}
|
||||
}
|
||||
assert!(!rows.is_empty(), "named-bound harness must record a populated trace");
|
||||
|
||||
// --- 3. Probe diagnostics: a misspelled knob name (against aliased names). -
|
||||
let (txa, _rxa) = mpsc::channel();
|
||||
let wrong_name: Result<_, BindError> = harness_aliased(txa)
|
||||
.with("sma_cross.fast", 2)
|
||||
.with("sma_cross.slo", 4) // typo: "slo" not "slow"
|
||||
.with("scale", 0.5)
|
||||
.bootstrap();
|
||||
println!("\n[3] wrong-name (sma_cross.slo) result: {:?}", wrong_name.err());
|
||||
|
||||
// --- 4. Probe diagnostics: the SHORT bare name (spec says UnknownKnob). ----
|
||||
let (txb, _rxb) = mpsc::channel();
|
||||
let bare: Result<_, BindError> = harness_aliased(txb)
|
||||
.with("fast", 2) // unqualified — spec: UnknownKnob
|
||||
.with("sma_cross.slow", 4)
|
||||
.with("scale", 0.5)
|
||||
.bootstrap();
|
||||
println!("[4] bare-name (\"fast\") result: {:?}", bare.err());
|
||||
|
||||
// --- 5. Probe diagnostics: a wrong-KIND value. ----------------------------
|
||||
let (txc, _rxc) = mpsc::channel();
|
||||
let wrong_kind: Result<_, BindError> = harness_aliased(txc)
|
||||
.with("sma_cross.fast", 2)
|
||||
.with("sma_cross.slow", 4)
|
||||
.with("scale", 1) // I64 literal into an F64 slot -> KindMismatch
|
||||
.bootstrap();
|
||||
println!("[5] wrong-kind (scale=1 into F64) result: {:?}", wrong_kind.err());
|
||||
|
||||
// --- 6. Probe: binding the AMBIGUOUS un-aliased name -> AmbiguousKnob. -----
|
||||
let (txd, _rxd) = mpsc::channel();
|
||||
let ambiguous: Result<_, BindError> = harness(txd) // PLAIN harness, both = sma_cross.length
|
||||
.with("sma_cross.length", 2)
|
||||
.with("scale", 0.5)
|
||||
.bootstrap();
|
||||
println!("[6] ambiguous (plain sma_cross.length) result: {:?}", ambiguous.err());
|
||||
|
||||
println!("\nOK: param-space inspected, named bind ran (aliased), diagnostics probed.");
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
// Milestone fieldtest — scenario 2: a SWEEP over named axes + compare across
|
||||
// the resulting family.
|
||||
//
|
||||
// Promise probed: take ONE blueprint and spread it across a FAMILY of disjoint
|
||||
// instances over NAMED axes (`.axis("sma_cross.fast", [2,3]).sweep(run)`), then
|
||||
// index/compare manifests and metrics across the family members (#33's surface).
|
||||
//
|
||||
// Public interface only: ledger + specs 0028/0030 + rustdoc. No crates/*/src
|
||||
// was read. Uses the ALIASED cross so the promise-form axis names are real
|
||||
// (scenario 1 established the plain cross's two knobs collide on
|
||||
// `sma_cross.length`; aliasing is the author's fix).
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role,
|
||||
RunManifest, RunReport, SweepFamily, Target,
|
||||
};
|
||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
|
||||
/// The aliased 2-SMA cross (knobs surface as `fast` / `slow`).
|
||||
fn sma_cross_aliased() -> Composite {
|
||||
Composite::new(
|
||||
"sma_cross",
|
||||
vec![
|
||||
Sma::builder().into(),
|
||||
Sma::builder().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![
|
||||
ParamAlias { name: "fast".into(), node: 0, slot: 0 },
|
||||
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
|
||||
],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// A fresh harness Composite plus the equity + exposure receivers it records to.
|
||||
/// A fresh build per point gives each sweep member its OWN drainable channels
|
||||
/// (the #32 spec's per-point-fresh-build pattern).
|
||||
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_aliased()),
|
||||
Exposure::builder().into(),
|
||||
SimBroker::builder(1e-4).into(),
|
||||
// sink 0: broker equity (item 4)
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
||||
// sink 1: exposure stream (item 5)
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
|
||||
],
|
||||
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![],
|
||||
vec![],
|
||||
);
|
||||
(bp, rx_eq, rx_ex)
|
||||
}
|
||||
|
||||
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
let prices = [
|
||||
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07,
|
||||
];
|
||||
prices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let prices = synthetic_prices();
|
||||
|
||||
// --- The sweep: ONE blueprint, named axes, a family of disjoint runs. -----
|
||||
// 2 (fast) x 2 (slow) x 1 (scale) = 4 points.
|
||||
let family: SweepFamily = harness_with_sinks()
|
||||
.0
|
||||
.axis("sma_cross.fast", [2, 3])
|
||||
.axis("sma_cross.slow", [4, 5])
|
||||
.axis("scale", [0.5])
|
||||
.sweep(|point| {
|
||||
// Each point builds fresh (fresh channels), bootstraps by the SAME
|
||||
// positional point vector the named axes resolved to, runs, drains,
|
||||
// summarizes — author-side glue (the engine cannot know which sink
|
||||
// is equity vs exposure; #28 "closure-driven" boundary).
|
||||
let (bp, rx_eq, rx_ex) = harness_with_sinks();
|
||||
let mut h = bp
|
||||
.bootstrap_with_params(point.to_vec())
|
||||
.expect("named-resolved point is kind-checked");
|
||||
h.run(vec![prices.clone()]);
|
||||
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);
|
||||
// The author also supplies the manifest (the sweep is domain-free;
|
||||
// #29 made SweepPoint carry a full RunReport).
|
||||
RunReport {
|
||||
manifest: RunManifest {
|
||||
commit: "fieldtest".into(),
|
||||
// name the point's coords for the comparison below
|
||||
params: vec![],
|
||||
window: (Timestamp(0), Timestamp(660_000_000_000)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal".into(),
|
||||
},
|
||||
metrics,
|
||||
}
|
||||
})
|
||||
.expect("named axes resolve to a valid grid");
|
||||
|
||||
// --- Compare across the family (the #33 index/compare surface). -----------
|
||||
println!("sweep family: {} members\n", family.points.len());
|
||||
println!(
|
||||
"{:>5} {:>5} {:>6} {:>12} {:>12} {:>8}",
|
||||
"fast", "slow", "scale", "total_pips", "max_dd", "flips"
|
||||
);
|
||||
let mut best: Option<(usize, f64)> = None;
|
||||
for (i, pt) in family.points.iter().enumerate() {
|
||||
// pt.params is the point's coordinate in param_space() slot order.
|
||||
let coord: Vec<String> = pt.params.iter().map(scalar_str).collect();
|
||||
let m = &pt.report.metrics;
|
||||
println!(
|
||||
"{:>5} {:>5} {:>6} {:>12.4} {:>12.4} {:>8}",
|
||||
coord[0], coord[1], coord[2], m.total_pips, m.max_drawdown, m.exposure_sign_flips
|
||||
);
|
||||
if best.is_none() || m.total_pips > best.unwrap().1 {
|
||||
best = Some((i, m.total_pips));
|
||||
}
|
||||
}
|
||||
|
||||
let (bi, bp) = best.unwrap();
|
||||
let bc: Vec<String> = family.points[bi].params.iter().map(scalar_str).collect();
|
||||
println!(
|
||||
"\nbest by total_pips: point #{bi} (fast={}, slow={}, scale={}) = {:.4} pips",
|
||||
bc[0], bc[1], bc[2], bp
|
||||
);
|
||||
|
||||
// --- Confirm members are disjoint / independently meaningful. -------------
|
||||
// Distinct points must produce distinct metrics (the family is not constant).
|
||||
let distinct: std::collections::BTreeSet<String> = family
|
||||
.points
|
||||
.iter()
|
||||
.map(|p| format!("{:.6}", p.report.metrics.total_pips))
|
||||
.collect();
|
||||
println!(
|
||||
"\ndistinct total_pips values across {} points: {}",
|
||||
family.points.len(),
|
||||
distinct.len()
|
||||
);
|
||||
assert!(
|
||||
distinct.len() > 1,
|
||||
"a sweep over distinct lengths must not collapse to one metric"
|
||||
);
|
||||
for pt in &family.points {
|
||||
assert!(pt.report.metrics.total_pips.is_finite(), "every point produces metrics");
|
||||
}
|
||||
|
||||
// Odometer order check (last axis fastest) — pin the family is ordered.
|
||||
println!("\nfamily params, in order:");
|
||||
for (i, pt) in family.points.iter().enumerate() {
|
||||
let c: Vec<String> = pt.params.iter().map(scalar_str).collect();
|
||||
println!(" #{i}: [{}]", c.join(", "));
|
||||
}
|
||||
|
||||
println!("\nOK: named-axis sweep produced a comparable, ordered, non-constant family.");
|
||||
}
|
||||
|
||||
fn scalar_str(s: &Scalar) -> String {
|
||||
match s {
|
||||
Scalar::I64(v) => v.to_string(),
|
||||
Scalar::F64(v) => format!("{v}"),
|
||||
Scalar::Bool(v) => v.to_string(),
|
||||
Scalar::Ts(t) => t.0.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// Milestone fieldtest — scenario 3: the NEGATIVE-SPACE probe (grounds #55).
|
||||
//
|
||||
// The "SMA2-entry" strategy: "the close of the last candle is greater than the
|
||||
// high of the prior candle, with SMA(2) as the bias." The `2` is DEFINITIONAL —
|
||||
// it is bound to the two-candle construction. Sweeping it to 5 does not give
|
||||
// "the same strategy at another point"; it deforms it into a different strategy.
|
||||
//
|
||||
// This file tries to express that `2` HONESTLY as a downstream author and
|
||||
// records where the public surface forces a wrong shape. NOTHING is worked
|
||||
// around: no `.bind()` is invented, no engine is touched. The deliverable is the
|
||||
// verbatim friction plus the consumer code I WISH I could write (see the
|
||||
// `WISHED-FOR` block at the bottom — it does not compile and is commented out).
|
||||
//
|
||||
// Public interface only: ledger (C8/C19/C23, C20) + specs 0015/0030 + rustdoc.
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{
|
||||
BlueprintNode, Composite, Edge, OutField, ParamAlias, Role, Target,
|
||||
};
|
||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||
|
||||
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
||||
let prices = [
|
||||
1.00, 1.01, 1.02, 1.03, 1.05, 1.08, 1.06, 1.04, 1.02, 1.01, 1.03, 1.07,
|
||||
];
|
||||
prices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &p)| (Timestamp(60_000_000_000 * i as i64), Scalar::F64(p)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// AVENUE (a): leave the definitional length as an OPEN param_space() knob and
|
||||
// fix it to 2 in the bind vector.
|
||||
//
|
||||
// The cross uses two Smas. The "bias" Sma is DEFINITIONALLY SMA(2) — but
|
||||
// `Sma::builder()` is value-empty: its length is ALWAYS an injected param. So
|
||||
// the `2` MUST surface as an open param-space knob. There is no way to bake it.
|
||||
// ===========================================================================
|
||||
fn sma2_entry_avenue_a() -> Composite {
|
||||
Composite::new(
|
||||
"sma2_entry",
|
||||
vec![
|
||||
Sma::builder().into(), // the DEFINITIONAL SMA(2) bias — but it is an OPEN knob
|
||||
Sma::builder().into(), // a genuinely-tunable slow leg
|
||||
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,
|
||||
}],
|
||||
// ALIASES are MANDATORY here: Sub is a fan-in of two same-signature SMAs,
|
||||
// each carrying an unaliased param slot -> C9 IndistinguishableFanIn at
|
||||
// compile unless aliased. So even avenue (a) is forced to NAME the
|
||||
// definitional-2 knob `bias` as if it were tunable.
|
||||
vec![
|
||||
ParamAlias { name: "bias".into(), node: 0, slot: 0 },
|
||||
ParamAlias { name: "slow".into(), node: 1, slot: 0 },
|
||||
],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
fn harness_with(inner: Composite, tx: mpsc::Sender<(Timestamp, Vec<Scalar>)>) -> Composite {
|
||||
Composite::new(
|
||||
"harness",
|
||||
vec![
|
||||
BlueprintNode::Composite(inner),
|
||||
Exposure::builder().into(),
|
||||
SimBroker::builder(1e-4).into(),
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).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 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 2, slot: 1 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// --- AVENUE (a): the definitional 2 leaks into param_space() as a knob. ---
|
||||
let (tx0, _rx0) = mpsc::channel();
|
||||
let space = harness_with(sma2_entry_avenue_a(), tx0).param_space();
|
||||
println!("AVENUE (a) — param_space() of the SMA2-entry strategy:");
|
||||
for p in &space {
|
||||
println!(" {} : {:?}", p.name, p.kind);
|
||||
}
|
||||
println!(
|
||||
"\n >> The DEFINITIONAL SMA(2) length surfaces as `sma2_entry.bias` — an OPEN\n \
|
||||
>> knob, indistinguishable from the genuinely-tunable `sma2_entry.slow`.\n \
|
||||
>> A sweep `.axis(\"sma2_entry.bias\", [2, 5, 10])` would enumerate DEFORMED\n \
|
||||
>> strategies (SMA-5-entry, SMA-10-entry) as valid family members.\n \
|
||||
>> param_space() declares a degree of freedom the strategy's definition\n \
|
||||
>> forbids. (C20: structural axes are NOT sweep params — but nothing here\n \
|
||||
>> distinguishes the two.) NOTE: the alias `bias` was FORCED on this knob\n \
|
||||
>> just to clear C9 IndistinguishableFanIn — so the surface makes me NAME\n \
|
||||
>> the constant as if it were tunable."
|
||||
);
|
||||
|
||||
// It still runs — the point is the SHAPE, not a crash:
|
||||
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
||||
let mut h = harness_with(sma2_entry_avenue_a(), tx)
|
||||
.bootstrap_with_params(vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)])
|
||||
.expect("avenue (a) bootstraps");
|
||||
h.run(vec![synthetic_prices()]);
|
||||
drop(h);
|
||||
let rows: Vec<_> = rx.iter().collect();
|
||||
println!("\n (avenue (a) runs: {} recorded rows — it WORKS, but the 2 is fungible)", rows.len());
|
||||
|
||||
// --- AVENUE (b): imitate SimBroker::builder(pip_size) — a value captured
|
||||
// OUTSIDE param_space — for the SMA length. ---------------------------
|
||||
println!("\nAVENUE (b) — capture the 2 as a construction constant (SimBroker pattern):");
|
||||
println!(
|
||||
" SimBroker::builder(1e-4) bakes pip_size OUTSIDE param_space (it is C10/C15\n \
|
||||
metadata — the broker declares NO param). The SMA2-entry author wants the\n \
|
||||
SAME for the length: a value baked at build, never a knob.\n"
|
||||
);
|
||||
// Probe: is that pattern reachable for an INDICATOR node?
|
||||
// Sma::builder() -> takes NO args; length is ALWAYS an injected param.
|
||||
// Sma::new(2) -> exists, bakes length 2... but is it a BlueprintNode?
|
||||
//
|
||||
// `Sma::new(2)` returns an `Sma` (a built Node), NOT a `PrimitiveBuilder`.
|
||||
// A composite's interior is `Vec<BlueprintNode>` = Primitive(PrimitiveBuilder)
|
||||
// | Composite. There is NO `BlueprintNode::from(Sma)` for a built node — the
|
||||
// blueprint holds value-empty RECIPES, not built nodes. So a built `Sma::new(2)`
|
||||
// cannot be placed into a blueprint at all. The SimBroker escape hatch
|
||||
// (`builder(constant)`) exists ONLY because SimBroker::builder TAKES the
|
||||
// constant; Sma::builder() does not, and there is no `Sma::builder_with(2)`.
|
||||
//
|
||||
// The following line, if uncommented, does NOT compile — recorded as the
|
||||
// verbatim wall (no `From<Sma> for BlueprintNode`):
|
||||
//
|
||||
// let _node: BlueprintNode = Sma::new(2).into();
|
||||
//
|
||||
println!(
|
||||
" >> WALL: `Sma::builder()` takes no length arg (length is always a param),\n \
|
||||
>> and `Sma::new(2)` returns a built `Sma`, not a `PrimitiveBuilder` /\n \
|
||||
>> `BlueprintNode` — a blueprint holds value-empty RECIPES, not built nodes.\n \
|
||||
>> There is no `Sma::builder_with(2)` analogous to `SimBroker::builder(pip)`.\n \
|
||||
>> The construction-constant escape hatch exists for SimBroker (its builder\n \
|
||||
>> TAKES the constant) but is UNREACHABLE for a stock indicator node."
|
||||
);
|
||||
|
||||
// ===================================================================
|
||||
// WISHED-FOR consumer code (the acceptance evidence for #55). This does
|
||||
// NOT compile against today's surface; it is the code I reached for and
|
||||
// could not write. Left here verbatim, commented out, as the empirical
|
||||
// grounding for #55's future spec.
|
||||
// ===================================================================
|
||||
//
|
||||
// // (1) An indicator builder that BAKES a structural constant, removing it
|
||||
// // from param_space entirely (the opposite of `.with`, reserved `.bind`):
|
||||
// let bias = Sma::builder().bind("length", 2); // length is now a CONSTANT, not a knob
|
||||
//
|
||||
// // ...so param_space() of the strategy contains ONLY the genuinely-tunable
|
||||
// // knobs, and a sweep CANNOT enumerate a deformed SMA-5-entry:
|
||||
// // param_space() == ["sma2_entry.slow": I64, "scale": F64] // no `length`
|
||||
//
|
||||
// // (2) equivalently, a recipe-level constructor that takes the constant:
|
||||
// let bias = Sma::builder_with(2); // length baked at build, no param slot
|
||||
//
|
||||
// Either makes the definitional `2` a CONSTRUCTION value (C19 "params size
|
||||
// nodes but a topology change is a different blueprint"; C20 "structural axes
|
||||
// are not numeric sweep params"), distinguishing it from a tunable knob — which
|
||||
// is exactly the distinction the SMA2-entry strategy needs and today's surface
|
||||
// cannot express.
|
||||
|
||||
println!("\nOK: scenario 3 recorded both wrong avenues + the wished-for code (grounds #55).");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
param_space() knobs (name : kind):
|
||||
sma_cross.length : I64
|
||||
sma_cross.length : I64
|
||||
scale : F64
|
||||
|
||||
[2a] promise-form (sma_cross.fast/.slow) result: Some(UnknownKnob("sma_cross.fast"))
|
||||
[2b] real-emitted-name (sma_cross.length) result: Some(AmbiguousKnob("sma_cross.length"))
|
||||
|
||||
[2c] param_space() AFTER aliasing:
|
||||
sma_cross.fast : I64
|
||||
sma_cross.slow : I64
|
||||
scale : F64
|
||||
|
||||
[2c] aliased named-bound run — recorded equity rows: 12
|
||||
ts= 0 equity_pips=0.0000
|
||||
ts= 60000000000 equity_pips=0.0000
|
||||
ts= 120000000000 equity_pips=0.0000
|
||||
ts= 180000000000 equity_pips=0.0000
|
||||
ts= 240000000000 equity_pips=4.0000
|
||||
ts= 300000000000 equity_pips=11.5000
|
||||
ts= 360000000000 equity_pips=3.5000
|
||||
ts= 420000000000 equity_pips=-2.5000
|
||||
ts= 480000000000 equity_pips=0.5000
|
||||
ts= 540000000000 equity_pips=4.5000
|
||||
ts= 600000000000 equity_pips=-2.5000
|
||||
ts= 660000000000 equity_pips=-6.5000
|
||||
|
||||
[3] wrong-name (sma_cross.slo) result: Some(UnknownKnob("sma_cross.slo"))
|
||||
[4] bare-name ("fast") result: Some(UnknownKnob("fast"))
|
||||
[5] wrong-kind (scale=1 into F64) result: Some(KindMismatch { knob: "scale", expected: F64, got: I64 })
|
||||
[6] ambiguous (plain sma_cross.length) result: Some(AmbiguousKnob("sma_cross.length"))
|
||||
|
||||
OK: param-space inspected, named bind ran (aliased), diagnostics probed.
|
||||
@@ -0,0 +1,19 @@
|
||||
sweep family: 4 members
|
||||
|
||||
fast slow scale total_pips max_dd flips
|
||||
2 4 0.5 -6.5000 18.0000 2
|
||||
2 5 0.5 -24.4000 35.2000 2
|
||||
3 4 0.5 -7.8333 13.3333 2
|
||||
3 5 0.5 -23.7333 30.5333 2
|
||||
|
||||
best by total_pips: point #0 (fast=2, slow=4, scale=0.5) = -6.5000 pips
|
||||
|
||||
distinct total_pips values across 4 points: 4
|
||||
|
||||
family params, in order:
|
||||
#0: [2, 4, 0.5]
|
||||
#1: [2, 5, 0.5]
|
||||
#2: [3, 4, 0.5]
|
||||
#3: [3, 5, 0.5]
|
||||
|
||||
OK: named-axis sweep produced a comparable, ordered, non-constant family.
|
||||
@@ -0,0 +1,30 @@
|
||||
AVENUE (a) — param_space() of the SMA2-entry strategy:
|
||||
sma2_entry.bias : I64
|
||||
sma2_entry.slow : I64
|
||||
scale : F64
|
||||
|
||||
>> The DEFINITIONAL SMA(2) length surfaces as `sma2_entry.bias` — an OPEN
|
||||
>> knob, indistinguishable from the genuinely-tunable `sma2_entry.slow`.
|
||||
>> A sweep `.axis("sma2_entry.bias", [2, 5, 10])` would enumerate DEFORMED
|
||||
>> strategies (SMA-5-entry, SMA-10-entry) as valid family members.
|
||||
>> param_space() declares a degree of freedom the strategy's definition
|
||||
>> forbids. (C20: structural axes are NOT sweep params — but nothing here
|
||||
>> distinguishes the two.) NOTE: the alias `bias` was FORCED on this knob
|
||||
>> just to clear C9 IndistinguishableFanIn — so the surface makes me NAME
|
||||
>> the constant as if it were tunable.
|
||||
|
||||
(avenue (a) runs: 12 recorded rows — it WORKS, but the 2 is fungible)
|
||||
|
||||
AVENUE (b) — capture the 2 as a construction constant (SimBroker pattern):
|
||||
SimBroker::builder(1e-4) bakes pip_size OUTSIDE param_space (it is C10/C15
|
||||
metadata — the broker declares NO param). The SMA2-entry author wants the
|
||||
SAME for the length: a value baked at build, never a knob.
|
||||
|
||||
>> WALL: `Sma::builder()` takes no length arg (length is always a param),
|
||||
>> and `Sma::new(2)` returns a built `Sma`, not a `PrimitiveBuilder` /
|
||||
>> `BlueprintNode` — a blueprint holds value-empty RECIPES, not built nodes.
|
||||
>> There is no `Sma::builder_with(2)` analogous to `SimBroker::builder(pip)`.
|
||||
>> The construction-constant escape hatch exists for SimBroker (its builder
|
||||
>> TAKES the constant) but is UNREACHABLE for a stock indicator node.
|
||||
|
||||
OK: scenario 3 recorded both wrong avenues + the wished-for code (grounds #55).
|
||||
Reference in New Issue
Block a user