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:
2026-06-11 09:11:37 +02:00
parent acaa6b0df8
commit 03285d1b47
9 changed files with 1104 additions and 0 deletions
+99
View File
@@ -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).