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
@@ -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<PrimitiveBuilder> for BlueprintNode` (confirmed from rustdoc; there
is **no** `From<Sma>`). 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.
+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).