diff --git a/docs/specs/fieldtest-milestone-the-world-param-sweep.md b/docs/specs/fieldtest-milestone-the-world-param-sweep.md new file mode 100644 index 0000000..338e8a1 --- /dev/null +++ b/docs/specs/fieldtest-milestone-the-world-param-sweep.md @@ -0,0 +1,262 @@ +# Fieldtest — milestone "The World — parameter-space & sweep" — 2026-06-11 + +**Status:** Draft — awaiting orchestrator triage +**Author:** fieldtester (dispatched by fieldtest skill, milestone-scope variant) +**Scope mode:** MILESTONE (closing functional gate), not per-cycle. + +## Milestone delivery roll-up + +The milestone promises a native-Rust strategy author can **declare** typed knobs, +**bind** a value-empty blueprint into one runnable instance **by name**, **spread** +it across a **family** over named axes, and **index/compare** the family +(issues #30–#36). Per the three curated end-to-end scenarios: + +| Scenario | Promise clause | Delivered? | +|---|---|---| +| 1 — single run, bound **by name** | declare + bind by name (`.with(...).bootstrap()`) | 🟢 GREEN — **with one caveat**: the promised names `sma_cross.fast/.slow` are not free; they require author-added C23 `ParamAlias` overlays. Once aliased, `.with("sma_cross.fast", 2).bootstrap()` binds and runs; diagnostics are clean. | +| 2 — **sweep** over named axes + **compare** the family | spread + index/compare (`.axis(...).sweep(run)`, #32/#33) | 🟢 GREEN — `.axis("sma_cross.fast", [2,3]).sweep(run)` yields a 4-member, ordered, disjoint, non-constant `SweepFamily`; comparison/ranking assembled from the public `SweepPoint`/`RunReport`/`RunMetrics` fields. The `aura sweep` + `aura runs rank` CLI delivers the same payoff with no Rust. | +| 3 — structural-constant negative space | (deliberately **out of milestone scope**; grounds #55) | 🔴 RED **by design** — the definitional `2` of SMA2-entry can only be expressed as an open `param_space()` knob (avenue a) or via an escape hatch reachable only for `SimBroker`, not indicators (avenue b). This is the forward-pointer to #55, **not** a milestone-blocking defect. | + +**Verdict (one line):** the milestone's promise is **empirically delivered** — +scenarios 1 and 2 are green end-to-end (the by-name caveat is a friction, not a +gap in the capability) — with scenario 3 logged as the empirical grounding for +#55 (structural-constant binding), which the milestone never promised. + +## Scope + +The milestone spans cycles 0015→0030 (param declaration #30, binding #31, sweep +#32, run registry #33, named binding #35). It promises that an author can declare +a node's tunable knobs (typed), aggregate them into a flat, path-qualified +`param_space()`, bind one runnable instance **by name** (`.with()/.bootstrap()`), +spread a family over **named axes** (`.axis()/.sweep()`), and index/compare the +resulting `SweepFamily`. The deeper promise (C11/C12/C19): a blueprint is +param-generic; binding sizes nodes but never changes topology; family members are +C1-disjoint; `param_space()` is the single ground truth both single-run binding +and sweep enumeration address. + +This fieldtest derives three end-to-end scenarios top-down from that promise and +drives them from the **public interface only**: the design ledger +(`docs/design/INDEX.md`), the specs (0015/0028/0030), and the crates' published +rustdoc (`cargo doc --workspace --no-deps`). **No `crates/*/src` was read** — the +current API (incl. the 0024 deletion of `struct Blueprint`, the root-is-a-Composite +model, the `Sma::factory` → `Sma::builder` rename, and the `Role.source` field) +was discovered entirely from the ledger + rustdoc + the compiler's own +diagnostics. The artefact under test was built from HEAD (`cargo build +--workspace`; the consumer crate path-deps the engine crates; fixtures run via +`cargo run`, so HEAD is always what executed). + +## Examples + +### fieldtests/milestone-the-world-param-sweep/mw_1_single_named.rs — single concrete run, bound by name +- Authors the SMA-cross harness as a root `Composite` (0024 model: a Composite + all of whose roles are source-bound), inspects `param_space()`, and tries to + bind by the milestone-promise names `sma_cross.fast/.slow`. Records the + diagnostic, then the actual-emitted name, then the aliased fix, then five + diagnostic probes. +- Fits the milestone: this is the headline by-name binding story (#30/#31/#35) — + declare, inspect, bind one instance by name, run it. +- Outcome: built? **yes.** ran? yes. matched expected? **partially** — the + promise-form bind **fails** (`UnknownKnob("sma_cross.fast")`); the real names + collide (`AmbiguousKnob("sma_cross.length")`); after adding `ParamAlias` + overlays the promise-form bind **works** and runs (12 non-degenerate equity + rows). Diagnostics clean. + +### fieldtests/milestone-the-world-param-sweep/mw_2_sweep_compare.rs — sweep over named axes + compare the family +- Takes the (aliased) harness, spreads it over `.axis("sma_cross.fast", [2,3]) + .axis("sma_cross.slow", [4,5]).axis("scale", [0.5]).sweep(run)`, then builds a + comparison table + best-by-`total_pips` ranking from the public `SweepFamily` + fields, asserts the family is ordered (odometer), disjoint, and non-constant. +- Fits the milestone: the "N instead of 1" core (#32) + the index/compare surface + (#33). +- Outcome: built? yes. ran? yes. matched expected? **yes** — 4 members, 4 + distinct `total_pips`, odometer order `[2,4]→[2,5]→[3,4]→[3,5]`, ranking works. + +### fieldtests/milestone-the-world-param-sweep/mw_3_structural_constant.rs — negative-space probe (grounds #55) +- Authors the "SMA2-entry" strategy and tries to express the definitional `2` + honestly via both avenues the carrier names: (a) leave it as an open + `param_space()` knob fixed to 2; (b) imitate `SimBroker::builder(constant)`. + Records both as wrong, plus the wished-for `.bind()`/`builder_with()` consumer + code (commented out — it does not compile). +- Fits the milestone: the deliberate negative space — what the param-space layer + **cannot** express, the acceptance evidence for #55. +- Outcome: built? yes. ran? yes (avenue a runs; avenue b is a documented compile + wall). matched expected? **yes** — both avenues are recordably wrong; the `2` + is fungible (a) or the escape hatch is unreachable for an indicator (b). + +### Run artefacts +- `out_mw_1.txt` / `out_mw_2.txt` / `out_mw_3.txt` — verbatim stdout of each + fixture. Plus the `aura sweep` + `aura runs rank total_pips` CLI output + (recorded inline in findings), the no-Rust face of the same family payoff. + +## Findings + +### [working] The named-axis sweep delivers a comparable, ordered, disjoint family from the public surface +- Example: mw_2; corroborated by `aura sweep` / `aura runs rank`. +- What happened: `.axis(name, vals)*.sweep(run)` resolved the named axes to a + 4-point grid, ran each disjointly, and returned a `SweepFamily` whose + `points[i].params` (slot-order coordinate) and `points[i].report.metrics` + (`total_pips`/`max_drawdown`/`exposure_sign_flips`) let a consumer build a + comparison table and a best-by-metric ranking with **no scaffolding beyond a + for-loop**. Order is odometer (last axis fastest), the 4 metrics are distinct + (not a constant family), every `total_pips` finite. The `aura sweep` CLI emits + the same family as JSON lines and `aura runs rank total_pips` orders them + best-first across invocations. +- Why working: this is the milestone's core "N instead of 1" + index/compare + promise (#32/#33), delivered end-to-end from the public interface. +- Recommended action: carry-on. + +### [working] Single-run binding by name + clean, knob-named diagnostics (once knobs have distinct names) +- Example: mw_1 (aliased path) + probes 3/4/5/6. +- What happened: `.with("sma_cross.fast", 2).with("sma_cross.slow", 4) + .with("scale", 0.5).bootstrap()` binds and runs (12-row trace). The diagnostics + are precise and name the offending knob: a typo → + `UnknownKnob("sma_cross.slo")`; an unqualified short name → + `UnknownKnob("fast")`; a wrong kind → `KindMismatch { knob: "scale", expected: + F64, got: I64 }`; the un-aliased collision → `AmbiguousKnob("sma_cross.length")`. + Raw literals (`2`, `0.5`) lower without suffixes. +- Why working: spec 0030's by-name binding + the total error order land exactly + as designed; the error vocabulary is a strict win over a positional vector. +- Recommended action: carry-on. + +### [friction] The promised knob names `sma_cross.fast/.slow` do NOT exist out of the box — they require hand-added `ParamAlias` overlays +- Examples: mw_1 [2a]/[2b]/[2c]; the engine's own `aura sweep` sample (whose + manifest shows `sma_cross.fast`/`.slow`, so even the shipped sample had to add + the aliases). +- What happened: the milestone promise and the spec 0030 worked example both + bind `.with("sma_cross.fast", 2)`. Run verbatim against a plain SMA-cross + composite, `param_space()` emits **`sma_cross.length` twice** (the raw `Sma` + factory param name, path-qualified only by the composite name), so + `.with("sma_cross.fast", 2)` → `UnknownKnob("sma_cross.fast")` and binding the + real name `.with("sma_cross.length", 2)` → `AmbiguousKnob("sma_cross.length")` + (two identical names). To get the promised distinct names the author must + author two `ParamAlias { name: "fast"/"slow", node, slot }` overlays on the + composite. Only then does `param_space()` emit `sma_cross.fast`/`sma_cross.slow` + and the promise-form bind succeed. +- Why friction (not bug): the binding layer is correct — it faithfully matches + the exact `param_space()` name, and spec 0030 §Architecture explicitly says + short/distinct names are an authoring choice resolved by `ParamAlias`. But the + milestone's headline example reads as if `fast`/`slow` are free, and a new + author following it verbatim hits two consecutive errors before discovering the + required alias step. The acceptance evidence (the code a user writes) is + longer than advertised. The task **completes**, but only after the author + supplies the naming overlay the example silently assumed. +- Recommended action: plan (tidy / docs) — either (i) make the worked example in + the ledger/spec show the `ParamAlias` step explicitly (so `sma_cross.fast` is + visibly *earned*), or (ii) give `Sma::builder()` a name argument / a + per-instance recipe label so two sibling SMAs carry distinct factory names + without a separate alias. Low-to-medium; it is the first thing a by-name author + trips on. + +### [friction] A fan-in of two same-signature un-aliased knobs cannot bootstrap — aliasing is mandatory, not optional, for the canonical SMA-cross +- Examples: mw_3 (initial bootstrap → `IndistinguishableFanIn { node: 2 }`); + mw_1's plain harness is only ever *inspected*, never bootstrapped, for the same + reason. +- What happened: `Sub` fanning in two `Sma`s with equal recursive signatures — + each carrying an unaliased param slot — is a compile-time + `CompileError`/`BootstrapError::...IndistinguishableFanIn` (the C9 fan-in + distinguishability refinement). So the **most canonical** strategy in the whole + project (a 2-leg SMA cross) does not bootstrap until the author aliases its two + length knobs. The same `ParamAlias` overlays demanded by the friction above are + thus doubly mandatory: once to bind by distinct names, once to clear the fan-in + check. +- Why friction (not bug): the `IndistinguishableFanIn` rejection is a designed, + documented C9 guarantee (it catches a hidden unnamed configuration axis), and + the diagnostic names the node. But it converts "author a 2-SMA cross and bind + it" — the project's running example — into a four-step dance (build, alias to + satisfy fan-in, alias to get distinct names — the same two aliases, then bind). + The friction is that the milestone's own canonical example cannot be run in its + minimal form. +- Recommended action: plan — pair with the friction above; both are dissolved by + per-instance SMA naming at construction (a recipe label), which would satisfy + fan-in distinguishability AND yield distinct param-space names in one step. + +### [friction] The sweep closure must re-bind positionally and re-zip param names the author already declared +- Example: mw_2 (the `.sweep(|point| { ... bootstrap_with_params(point.to_vec()) + ... })` closure; the empty `manifest.params`). +- What happened: `.axis(...).sweep(run)` resolves named axes to a positional + point, but the per-point closure receives a bare `&[Scalar]` and must call + `bootstrap_with_params(point.to_vec())` (positional) and rebuild the blueprint + by hand. To put readable knob names into the per-point `RunManifest.params`, + the author must re-zip `param_space()` names onto the point themselves — the + named layer that resolved the axes does not thread the names through to the + closure. (The `aura sweep` CLI does this zip by hand internally.) +- Why friction: the named-binding ergonomics stop at the grid boundary; inside + the sweep the author is back to positional vectors and manual name-zipping, + re-deriving information `param_space()` already holds. The comparison in mw_2 + works, but only because the consumer re-zips names onto `pt.params` for display. +- Recommended action: plan — consider passing the closure a named view of the + point (e.g. `&[(/* name */ &str, Scalar)]` or a small `Point` with `get(name)`), + or have `SweepPoint` carry the param names so a consumer need not re-zip from + `param_space()`. Low-to-medium. + +### [spec_gap] The definitional structural constant (SMA2-entry's `2`) has no honest expression — grounds #55 +- Example: mw_3 avenues (a) and (b). +- What happened: the SMA2-entry strategy's `2` is bound to the two-candle + construction (sweeping it deforms the strategy). Today only two avenues exist, + both wrong: + - **(a)** leave the length open: `param_space()` emits `sma2_entry.bias : I64` + (forced to alias it just to clear `IndistinguishableFanIn`), **indistinguishable + from the genuinely-tunable `sma2_entry.slow`**. A sweep + `.axis("sma2_entry.bias", [2,5,10])` would enumerate **deformed** strategies + as valid family members. `param_space()` declares a degree of freedom the + strategy's definition forbids (C20 says structural axes are not sweep params — + but nothing here distinguishes the two). + - **(b)** imitate `SimBroker::builder(1e-4)` (a constant captured **outside** + `param_space`). Verbatim wall: `Sma::builder()` takes **no** length arg + (length is always an injected param), and `Sma::new(2)` returns a **built** + `Sma`, not a `PrimitiveBuilder`/`BlueprintNode` — and the only value-conversion + is `From for BlueprintNode` (confirmed from rustdoc; there + is **no** `From`). A blueprint holds value-empty recipes, never built + nodes. The construction-constant escape hatch exists for `SimBroker` only + because its `builder(pip_size)` **takes** the constant; there is no + `Sma::builder_with(2)`. So the escape hatch is **structurally unreachable** + for a stock indicator node. +- Why spec_gap (not a milestone bug): the milestone **never promised** + structural-constant binding — the spec 0030 §Goal explicitly puts it out of + scope ("structural blueprint constants … is the opposite operation and lives in + #55"). This finding is the **empirical grounding** for #55: the wished-for + consumer code (a `Sma::builder().bind("length", 2)` that removes the knob from + `param_space`, or a `Sma::builder_with(2)` recipe constructor) is recorded + verbatim in mw_3 as the acceptance evidence a future #55 spec needs. +- Recommended action: ratify the deferral as correct (the milestone gate is not + blocked) **and** route the recorded wished-for code to #55 as its acceptance + evidence. The `.bind()` name is already reserved for exactly this (spec 0030). + +## Recommendation summary + +| Finding | Class | Action | Routing | +|---|---|---|---| +| Named-axis sweep yields a comparable/ordered/disjoint family | working | carry-on | — | +| Single-run by-name binding + clean knob-named diagnostics | working | carry-on | — | +| Promised `sma_cross.fast/.slow` need hand-added `ParamAlias` | friction | plan (tidy/docs) | #30/#35 follow-up or doc-fix | +| Canonical SMA-cross can't bootstrap without alias (`IndistinguishableFanIn`) | friction | plan | pairs with the alias-naming friction | +| Sweep closure re-binds positionally + re-zips names | friction | plan | #32/#33 ergonomics follow-up | +| SMA2-entry's definitional `2` has no honest expression | spec_gap | ratify deferral + route evidence | **#55** | + +## Milestone roll-up + +**The milestone "The World — parameter-space & sweep" DELIVERS its promise.** A +native-Rust author can declare typed knobs, inspect them via a flat +path-qualified `param_space()`, bind one runnable instance **by name** +(`.with(...).bootstrap()`) with a precise, knob-named error vocabulary, spread the +blueprint across a **named-axis family** (`.axis(...).sweep(run)`), and +index/compare the disjoint, ordered, non-constant `SweepFamily` — all from the +public interface, with the `aura sweep` + `aura runs rank` CLI as the no-Rust +face of the same payoff. Scenarios 1 and 2 (the actual close-gate) are green +end-to-end. + +The frictions are about **the naming ergonomics on the way in**, not the +capability: the milestone's headline by-name example (`sma_cross.fast/.slow`) is +not free — those distinct names require author-added `ParamAlias` overlays, which +are **doubly mandatory** because the canonical 2-SMA cross also will not bootstrap +without them (`IndistinguishableFanIn`). A by-name author following the worked +example verbatim hits two consecutive errors before discovering the alias step. +All are tidy follow-ups, none block the gate. + +Scenario 3 is the deliberate negative space: the definitional structural constant +of SMA2-entry has no honest expression today (it leaks into `param_space()` as a +fungible knob, and the `SimBroker` construction-constant escape hatch is +unreachable for an indicator). This is **exactly** the gap #55 is filed for — the +milestone never promised it — and the wished-for consumer code is now recorded as +#55's empirical acceptance evidence. **No bugs found.** Two `working`, three +`friction`, one `spec_gap` (→ #55) — none blocking the milestone gate. diff --git a/fieldtests/milestone-the-world-param-sweep/Cargo.lock b/fieldtests/milestone-the-world-param-sweep/Cargo.lock new file mode 100644 index 0000000..9ba02b5 --- /dev/null +++ b/fieldtests/milestone-the-world-param-sweep/Cargo.lock @@ -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" diff --git a/fieldtests/milestone-the-world-param-sweep/Cargo.toml b/fieldtests/milestone-the-world-param-sweep/Cargo.toml new file mode 100644 index 0000000..068f431 --- /dev/null +++ b/fieldtests/milestone-the-world-param-sweep/Cargo.toml @@ -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" } diff --git a/fieldtests/milestone-the-world-param-sweep/mw_1_single_named.rs b/fieldtests/milestone-the-world-param-sweep/mw_1_single_named.rs new file mode 100644 index 0000000..0fae6b3 --- /dev/null +++ b/fieldtests/milestone-the-world-param-sweep/mw_1_single_named.rs @@ -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)>, +) -> 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)>) -> 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)>) -> 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)>(); + 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)> = 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."); +} diff --git a/fieldtests/milestone-the-world-param-sweep/mw_2_sweep_compare.rs b/fieldtests/milestone-the-world-param-sweep/mw_2_sweep_compare.rs new file mode 100644 index 0000000..f7ccc64 --- /dev/null +++ b/fieldtests/milestone-the-world-param-sweep/mw_2_sweep_compare.rs @@ -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)>, + mpsc::Receiver<(Timestamp, Vec)>, +) { + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let bp = Composite::new( + "harness", + vec![ + BlueprintNode::Composite(sma_cross_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 = 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 = 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 = 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 = 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(), + } +} diff --git a/fieldtests/milestone-the-world-param-sweep/mw_3_structural_constant.rs b/fieldtests/milestone-the-world-param-sweep/mw_3_structural_constant.rs new file mode 100644 index 0000000..84ec709 --- /dev/null +++ b/fieldtests/milestone-the-world-param-sweep/mw_3_structural_constant.rs @@ -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)>) -> 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)>(); + 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` = 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 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)."); +} diff --git a/fieldtests/milestone-the-world-param-sweep/out_mw_1.txt b/fieldtests/milestone-the-world-param-sweep/out_mw_1.txt new file mode 100644 index 0000000..40dea56 --- /dev/null +++ b/fieldtests/milestone-the-world-param-sweep/out_mw_1.txt @@ -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. diff --git a/fieldtests/milestone-the-world-param-sweep/out_mw_2.txt b/fieldtests/milestone-the-world-param-sweep/out_mw_2.txt new file mode 100644 index 0000000..d6b4c85 --- /dev/null +++ b/fieldtests/milestone-the-world-param-sweep/out_mw_2.txt @@ -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. diff --git a/fieldtests/milestone-the-world-param-sweep/out_mw_3.txt b/fieldtests/milestone-the-world-param-sweep/out_mw_3.txt new file mode 100644 index 0000000..daf4adb --- /dev/null +++ b/fieldtests/milestone-the-world-param-sweep/out_mw_3.txt @@ -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).