Compare commits
140 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fb7caf929 | |||
| 48979193b9 | |||
| a20b6f6003 | |||
| 59f547e313 | |||
| 51096a3212 | |||
| bd6ddf9efe | |||
| 7e1dd25a76 | |||
| cc683fb065 | |||
| 913d18755e | |||
| 1102d776df | |||
| 34987be389 | |||
| cfc2d9f5b1 | |||
| b04b56148d | |||
| 96e79184e0 | |||
| 2d988fdf3d | |||
| bd7585b609 | |||
| 9c7f60b269 | |||
| 7126886b81 | |||
| 593e233e66 | |||
| 750ee93180 | |||
| d6694d0641 | |||
| ab3f16879b | |||
| d8c6938027 | |||
| a32dc38d18 | |||
| df3c2f06bd | |||
| 8c19260e8d | |||
| bd0c557f16 | |||
| 6e3f394b48 | |||
| d8ef9de5c2 | |||
| 92e281e4ec | |||
| b8ba324a41 | |||
| 4ae2297a35 | |||
| 09994b83ed | |||
| 8688a60ded | |||
| fb28b831f3 | |||
| 6b994cead3 | |||
| a5ca0e7481 | |||
| edb8c37916 | |||
| 0a70f67711 | |||
| 27ace153f2 | |||
| da37f596f2 | |||
| 803c9704cc | |||
| 63711a1d3a | |||
| a12617bb80 | |||
| 757e3ac1bd | |||
| 4ed6455b64 | |||
| 78e68e6002 | |||
| 9df217d868 | |||
| 5006766579 | |||
| 170c6c82dc | |||
| 5c2ac982bc | |||
| 2cf4574e33 | |||
| 0651e16146 | |||
| 6744f670b1 | |||
| a9d36ddd70 | |||
| a56ab7859d | |||
| 94aaa4cde8 | |||
| b39fd63396 | |||
| 34ff539143 | |||
| 1c49d5dce2 | |||
| 592750b122 | |||
| a5e02ff541 | |||
| 43a1cc44d3 | |||
| cb6211c888 | |||
| e768072ed0 | |||
| 9005fd53a2 | |||
| ba46350da9 | |||
| cf670caf46 | |||
| 7a4e5eb99d | |||
| 2e8d74903c | |||
| 93d0ec45f2 | |||
| f3daded514 | |||
| 17e125a90a | |||
| 7239106f4e | |||
| b11bcb6202 | |||
| c302c5d5a0 | |||
| c8dc57e2aa | |||
| 40e962db89 | |||
| b5c82f5d16 | |||
| 8db2824552 | |||
| 16ca4e103e | |||
| 13d8500402 | |||
| 400105e2e2 | |||
| 3a25a4ccba | |||
| e015317183 | |||
| 0b116e4105 | |||
| 07c94433f3 | |||
| aa94f51d8a | |||
| 35865bb934 | |||
| 88a1c28954 | |||
| bfb8648925 | |||
| 9add426a93 | |||
| 69bb2fc978 | |||
| cf94377f30 | |||
| 7fa3ef4e26 | |||
| 98ddcd3595 | |||
| 883401309a | |||
| 494b7885c7 | |||
| b048923f1c | |||
| 88667a65fd | |||
| 9e30805fcc | |||
| 0b620e1f26 | |||
| ad4249f9e6 | |||
| f0aadb54f8 | |||
| d3b1a1aead | |||
| ea4e79d73f | |||
| a55e4cfd10 | |||
| d1b3a3dd31 | |||
| 528e8f33d6 | |||
| e00e264252 | |||
| ca4a89864c | |||
| 89d703dd99 | |||
| 75bfb9326f | |||
| bdafbdef59 | |||
| 3d422341eb | |||
| e8b6878d3b | |||
| e12b4a730b | |||
| 414448bd3f | |||
| ce12e7f976 | |||
| be9f1cef8c | |||
| 5a1696891a | |||
| 5b70bd60a5 | |||
| a7d465d2b5 | |||
| 529a8af0d6 | |||
| 28984ba73c | |||
| bb0b0aeac2 | |||
| 1ccce9ad32 | |||
| 05565ee8a2 | |||
| 84e1075176 | |||
| a895891ab1 | |||
| 479c6620e2 | |||
| a04fda3a4c | |||
| c31e946bb7 | |||
| 25b8354959 | |||
| 00cc2fe927 | |||
| 038e1702b3 | |||
| 1ebb94c1b8 | |||
| 4e6d5d4688 | |||
| 487431d97d | |||
| 6d3da424f4 |
@@ -159,6 +159,16 @@ design decision, not a refactor, and belongs in the ledger.
|
||||
harness and is an execution viewer / trace explorer (structure before, live
|
||||
streams during, recorded traces after) — never a scene editor; topology is
|
||||
grown in Rust + hot-reload, runtime params are UI-tunable.
|
||||
13. **Self-description: every surface explains itself.** Every closed-
|
||||
vocabulary entry the binary exposes (nodes, metrics, tap slots, folds,
|
||||
blocks) carries a one-line meaning, enforced at its entry seam —
|
||||
compile/unit for engine-shipped entries, load for native node crates,
|
||||
register for the content-addressed store (documents: an optional gated
|
||||
`description`). The gate is deterministic string shape (`doc_gate`),
|
||||
never content judgement: the engine never evaluates description text
|
||||
(no freetext logic hole, invariant 10), descriptions never influence
|
||||
execution, determinism (C1), or identity ids, and registered artifacts
|
||||
are never retroactively invalidated. (C29)
|
||||
|
||||
## HTML surfaces
|
||||
|
||||
@@ -178,6 +188,8 @@ Everything else is a fixed convention (see `~/dev/skills/docs/conventions.md`).
|
||||
- **Test** — `cargo test --workspace`
|
||||
- **Lint** — `cargo clippy --workspace --all-targets -- -D warnings`
|
||||
- **Doc build** — `cargo doc --workspace --no-deps 2>&1`
|
||||
- **Bench** — `cargo run --release -p aura-bench -- run` (cycle close;
|
||||
report-only — see `crates/aura-bench/README.md`)
|
||||
- **Design ledger** — `docs/design/INDEX.md`
|
||||
- **Glossary** — `docs/glossary.md`
|
||||
- **Issue tracker** — Gitea, repo `Brummel/Aura`:
|
||||
|
||||
Generated
+164
-3
@@ -100,20 +100,51 @@ dependencies = [
|
||||
name = "aura-analysis"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-backtest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-analysis",
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-registry",
|
||||
"aura-std",
|
||||
"clap",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-campaign"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-analysis",
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -122,31 +153,38 @@ dependencies = [
|
||||
name = "aura-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-analysis",
|
||||
"aura-backtest",
|
||||
"aura-campaign",
|
||||
"aura-composites",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-measurement",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"aura-runner",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"clap",
|
||||
"data-server",
|
||||
"libc",
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-composites"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -163,10 +201,15 @@ name = "aura-engine"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-analysis",
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-market",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"rayon",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -175,22 +218,50 @@ dependencies = [
|
||||
name = "aura-ingest"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-market",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"data-server",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-market"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-core",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-measurement"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-analysis",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-registry",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-registry"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-analysis",
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-research",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -205,15 +276,60 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-runner"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-campaign",
|
||||
"aura-composites",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"data-server",
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-std"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-strategy",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-strategy"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-vocabulary"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-market",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
@@ -402,6 +518,25 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
@@ -477,6 +612,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -801,6 +942,26 @@ version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.12.3"
|
||||
|
||||
+16
@@ -11,6 +11,11 @@ resolver = "3"
|
||||
members = [
|
||||
"crates/aura-core",
|
||||
"crates/aura-std",
|
||||
"crates/aura-vocabulary",
|
||||
"crates/aura-market",
|
||||
"crates/aura-measurement",
|
||||
"crates/aura-strategy",
|
||||
"crates/aura-backtest",
|
||||
"crates/aura-engine",
|
||||
"crates/aura-analysis",
|
||||
"crates/aura-composites",
|
||||
@@ -18,7 +23,9 @@ members = [
|
||||
"crates/aura-ingest",
|
||||
"crates/aura-registry",
|
||||
"crates/aura-research",
|
||||
"crates/aura-runner",
|
||||
"crates/aura-campaign",
|
||||
"crates/aura-bench",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -36,3 +43,12 @@ serde = { version = "1", features = ["derive"] }
|
||||
# parsed back. Exercised (with a 1-ULP canary value) by aura-cli's
|
||||
# `f64_blueprint_param_survives_store_round_trip_bit_identically`.
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
|
||||
# Dependencies at opt-level 2, workspace crates at the dev default (0): the E2E
|
||||
# suite spawns the debug `aura` binary whose wall-clock is dominated by dependency
|
||||
# code (zip inflate, sha2 dylib hashing, serde) — optimizing deps is multiplicative
|
||||
# there, while workspace crates stay fast-to-rebuild and debuggable. Float pins are
|
||||
# unaffected: every pinned float is computed by workspace code (IEEE semantics are
|
||||
# opt-level-independent in Rust regardless).
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 2
|
||||
|
||||
@@ -118,8 +118,10 @@ aura graph build < crossover.ops.json > crossover.bp.json
|
||||
aura sweep crossover.bp.json --list-axes
|
||||
```
|
||||
|
||||
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, and
|
||||
`gang`. See
|
||||
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `tap`,
|
||||
`gang`, and `doc` (`tap` declares a recorded measurement point on an interior
|
||||
wire; `doc` declares the composite's one-line meaning, required at register —
|
||||
C29; see the authoring guide). See
|
||||
`aura graph introspect --node <T>` for a type's exact ports and the op-script
|
||||
grammar in the design ledger for the full semantics.
|
||||
|
||||
|
||||
@@ -6,15 +6,13 @@ license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../aura-core" }
|
||||
# serde is admitted under the amended C16 per-case dependency policy (INDEX.md):
|
||||
# it gives the run-report types a typed (de)serialization path for the run
|
||||
# registry (cycle 0029). serde's output is deterministic (C1-safe).
|
||||
# serde gives the selection-provenance types (`FamilySelection`/`SelectionMode`,
|
||||
# embedded by the engine's `RunManifest.selection`) their typed
|
||||
# (de)serialization path for the run registry (C18). Deterministic output
|
||||
# (C1-safe). The backtest metric types moved to aura-backtest (#291).
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# serde_json is used only by this crate's round-trip tests (the production types
|
||||
# derive serde, but JSON rendering lives in aura-engine's RunReport::to_json).
|
||||
# Test-only -> dev-dependencies, so it never enters the frozen artifact's library
|
||||
# link (C16 frozen-artifact scrutiny).
|
||||
# serde_json is used only by the moved MetricStats round-trip test (#291 Task
|
||||
# 1); production JSON rendering lives at the engine/backtest boundary.
|
||||
serde_json = { workspace = true }
|
||||
|
||||
+271
-996
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "aura-backtest"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../aura-core" }
|
||||
# aura-engine is the metric-generic engine this crate instantiates with
|
||||
# `M = RunMetrics` (`RunReport`/`SweepFamily`/etc. aliases), whose
|
||||
# `run_indexed` the moved MC assembly drives, and whose re-exported
|
||||
# `MetricStats`/`resample_block`/`SplitMix64` (foundation-grade, from
|
||||
# aura-analysis) the moved `mc.rs` reduces through — no direct aura-analysis
|
||||
# edge, keeping the C28 ladder one-hop (#292, C28 phase 2). Outer-rung
|
||||
# dependency — the engine itself only dev-depends back on this crate.
|
||||
aura-engine = { path = "../aura-engine" }
|
||||
# serde gives the backtest metric types (`RunMetrics`/`RMetrics`/`PositionEvent`)
|
||||
# their typed (de)serialization path for the run registry (C18; moved with the
|
||||
# types from aura-analysis, #291). Deterministic output (C1-safe).
|
||||
serde = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# serde_json is used only by the moved metric round-trip tests (JSON rendering
|
||||
# for production lives in aura-engine's RunReport::to_json). Test-only, so it
|
||||
# never enters the frozen artifact's library link (C16).
|
||||
serde_json = { workspace = true }
|
||||
# aura-std / aura-strategy back the moved `mc.rs`/`metrics.rs` test fixtures
|
||||
# (Sma/Sub/Recorder, Bias) — the crate-private `aura-engine::test_fixtures`
|
||||
# harness is unreachable from here, so the fixture is reconstructed through
|
||||
# the public node vocabulary instead (test-only, mirrors the aura-engine
|
||||
# integration tests' own pattern).
|
||||
aura-std = { path = "../aura-std" }
|
||||
aura-strategy = { path = "../aura-strategy" }
|
||||
# rayon backs the thread-count-explicit `monte_carlo_with_threads` test
|
||||
# wrapper (moved verbatim from aura-engine's mc.rs, #291) — test-only.
|
||||
rayon = "1"
|
||||
@@ -0,0 +1,40 @@
|
||||
//! `aura-backtest` — the backtest layer (C28): simulated execution without
|
||||
//! money (the `SimBroker` pip yardstick, position management), measured in R,
|
||||
//! plus the backtest metric reductions over the recorded streams
|
||||
//! (`metrics`, moved from `aura-analysis` by #291) and the Monte-Carlo
|
||||
//! assembly (`mc`, moved from `aura-engine` by #291/#292, C28 phase 2). The
|
||||
//! outer rung of the metric-generic engine (C28): `RunReport` here is the
|
||||
//! concrete `M = RunMetrics` instantiation of `aura_engine::RunReport<M>`.
|
||||
|
||||
mod mc;
|
||||
mod metrics;
|
||||
mod position_management;
|
||||
pub mod scaffold;
|
||||
mod sim_broker;
|
||||
|
||||
// `assemble_mc` stays module-private (it was never `pub` in the pre-#291 engine
|
||||
// lib.rs re-export either — only its two callers, `monte_carlo` and the
|
||||
// `#[cfg(test)]` thread-count wrapper, are public/test surface).
|
||||
pub use mc::{monte_carlo, r_bootstrap, McAggregate, McDraw, McFamily, RBootstrap};
|
||||
pub use metrics::{
|
||||
derive_position_events, r_metrics_from_rs, summarize, summarize_r, PositionAction,
|
||||
PositionEvent, RMetrics, RunMetricKey, RunMetrics, RANKABLE_METRICS, R_BASED_METRICS,
|
||||
};
|
||||
pub use position_management::{
|
||||
ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement,
|
||||
RECORD_KINDS as PM_RECORD_KINDS, WIDTH as PM_WIDTH,
|
||||
};
|
||||
pub use scaffold::{
|
||||
fit_wf_ms_sizes, intersect_shared_window, point_from_params, wf_ms_sizes, WF_REAL_IS_NS,
|
||||
WF_REAL_OOS_NS, WF_REAL_STEP_NS,
|
||||
};
|
||||
pub use sim_broker::SimBroker;
|
||||
|
||||
/// The trading instantiation of the engine's metric-generic run record (C28
|
||||
/// phase 2, #292): source-compatible concrete names for every consumer that
|
||||
/// deals in backtest metrics.
|
||||
pub type RunReport = aura_engine::RunReport<RunMetrics>;
|
||||
pub type SweepPoint = aura_engine::SweepPoint<RunMetrics>;
|
||||
pub type SweepFamily = aura_engine::SweepFamily<RunMetrics>;
|
||||
pub type WindowRun = aura_engine::WindowRun<RunMetrics>;
|
||||
pub type WalkForwardResult = aura_engine::WalkForwardResult<RunMetrics>;
|
||||
@@ -3,18 +3,25 @@
|
||||
//! a seed set — each seed a disjoint C1 realization — and collects an
|
||||
//! [`McFamily`]: the per-seed [`McDraw`]s in seed-input order plus a stored
|
||||
//! [`McAggregate`] (mean + quantiles of all three run metrics across the
|
||||
//! realizations). It reuses the disjoint-parallel [`run_indexed`](crate::sweep)
|
||||
//! realizations). It reuses the disjoint-parallel [`run_indexed`](aura_engine::run_indexed)
|
||||
//! core `sweep` drives — the varying dimension is the *seed*, not a tuning param.
|
||||
//! Eager-agnostic (C12/#71): the API takes seeds + a per-draw closure, never a
|
||||
//! materialized stream `Vec`; the seed -> `Source` construction is a closure-body
|
||||
//! concern.
|
||||
//! concern. Moved verbatim from `aura-engine::mc` (#291, C28 phase 2): the
|
||||
//! engine stays metric-agnostic, so the concrete `RunMetrics` instantiation of
|
||||
//! `RunReport` lives here, the outer C28 rung.
|
||||
|
||||
use crate::sweep::run_indexed;
|
||||
use crate::{RunMetrics, RunReport, Scalar};
|
||||
use crate::harness::SplitMix64;
|
||||
// MetricStats/resample_block/SplitMix64 are foundation-grade (aura-analysis),
|
||||
// but reached here through aura-engine's re-export rather than a direct
|
||||
// aura-analysis dependency — the C28 ladder (c28_layering) permits
|
||||
// aura-backtest -> {aura-core, aura-engine} only, not a same-rung-skipping
|
||||
// direct edge to the foundation crate.
|
||||
use aura_core::Scalar;
|
||||
use aura_engine::{run_indexed, resample_block, MetricStats, SplitMix64};
|
||||
use crate::{RunMetrics, RunReport};
|
||||
|
||||
/// One Monte-Carlo realization: the seed that drove it and the full `RunReport`.
|
||||
/// Self-describing, analog to [`SweepPoint`](crate::SweepPoint).
|
||||
/// Self-describing, analog to [`SweepPoint`](aura_engine::SweepPoint).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct McDraw {
|
||||
pub seed: u64,
|
||||
@@ -23,7 +30,7 @@ pub struct McDraw {
|
||||
|
||||
/// The result family of a Monte-Carlo run — one [`McDraw`] per seed, in
|
||||
/// seed-**input** order (independent of thread completion), plus the stored
|
||||
/// [`McAggregate`]. Analog to [`SweepFamily`](crate::SweepFamily).
|
||||
/// [`McAggregate`]. Analog to [`SweepFamily`](aura_engine::SweepFamily).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct McFamily {
|
||||
pub draws: Vec<McDraw>,
|
||||
@@ -41,38 +48,6 @@ pub struct McAggregate {
|
||||
pub bias_sign_flips: MetricStats,
|
||||
}
|
||||
|
||||
/// Mean + a fixed quantile set of one metric across the realizations. `p50` is
|
||||
/// the median (the two coincide by definition), so "mean/median/quantiles" is
|
||||
/// `mean` + `p50` + the surrounding quantiles, with no redundant field.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MetricStats {
|
||||
pub mean: f64,
|
||||
pub p5: f64,
|
||||
pub p25: f64,
|
||||
pub p50: f64, // == median
|
||||
pub p75: f64,
|
||||
pub p95: f64,
|
||||
}
|
||||
|
||||
impl MetricStats {
|
||||
/// Mean + the fixed type-7 quantile set over a value set. Sorts a copy;
|
||||
/// `values` must be finite and non-empty. The shared reduction behind both the
|
||||
/// MC aggregate (a metric across draws) and the walk-forward param-stability
|
||||
/// summary (a param across windows, [`crate::param_stability`]).
|
||||
pub fn from_values(values: &[f64]) -> MetricStats {
|
||||
let mut xs = values.to_vec();
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).expect("values are finite"));
|
||||
MetricStats {
|
||||
mean: xs.iter().sum::<f64>() / xs.len() as f64,
|
||||
p5: quantile(&xs, 0.05),
|
||||
p25: quantile(&xs, 0.25),
|
||||
p50: quantile(&xs, 0.50),
|
||||
p75: quantile(&xs, 0.75),
|
||||
p95: quantile(&xs, 0.95),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl McAggregate {
|
||||
/// Pure reduction over the realizations — recomputable from `draws` alone (it
|
||||
/// is exactly what [`monte_carlo`] stored). `draws` must be non-empty (a
|
||||
@@ -91,23 +66,24 @@ impl McAggregate {
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear-interpolation quantile (the numpy/Excel "type 7" default) over a
|
||||
/// pre-sorted, non-empty slice. `p` in `[0, 1]`. `rank = p * (n - 1)`;
|
||||
/// interpolate between the two bracketing order statistics. `n == 1` returns the
|
||||
/// sole value.
|
||||
fn quantile(sorted: &[f64], p: f64) -> f64 {
|
||||
let n = sorted.len();
|
||||
if n == 1 {
|
||||
return sorted[0];
|
||||
}
|
||||
let rank = p * (n - 1) as f64;
|
||||
let lo = rank.floor() as usize;
|
||||
let frac = rank - lo as f64;
|
||||
if lo + 1 < n {
|
||||
sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
|
||||
} else {
|
||||
sorted[n - 1]
|
||||
}
|
||||
/// The single assembly path behind both [`monte_carlo`] and the thread-count-
|
||||
/// explicit test wrapper: run `run_one` over every seed via `run_indexed` (on
|
||||
/// whichever pool is ambient when called), build the per-seed draws in
|
||||
/// seed-input order, and reduce them into the aggregate. One source of truth so
|
||||
/// the two callers can never drift on assembly logic while their pool selection
|
||||
/// differs.
|
||||
fn assemble_mc<F>(base_point: &[Scalar], seeds: &[u64], run_one: &F) -> McFamily
|
||||
where
|
||||
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
|
||||
{
|
||||
let reports = run_indexed(seeds.len(), |i| run_one(seeds[i], base_point));
|
||||
let draws: Vec<McDraw> = seeds
|
||||
.iter()
|
||||
.zip(reports)
|
||||
.map(|(&seed, report)| McDraw { seed, report })
|
||||
.collect();
|
||||
let aggregate = McAggregate::from_draws(&draws);
|
||||
McFamily { draws, aggregate }
|
||||
}
|
||||
|
||||
/// Run `run_one(seed, base_point)` over every seed, disjointly in parallel (C1),
|
||||
@@ -119,20 +95,23 @@ fn quantile(sorted: &[f64], p: f64) -> f64 {
|
||||
///
|
||||
/// Precondition: `seeds` is non-empty (a Monte-Carlo over zero realizations has
|
||||
/// no defined aggregate). A `debug_assert!` guards it; the `-> McFamily`
|
||||
/// signature is preserved (no `Result`), matching [`sweep`](crate::sweep).
|
||||
/// signature is preserved (no `Result`), matching [`sweep`](aura_engine::sweep).
|
||||
pub fn monte_carlo<F>(base_point: &[Scalar], seeds: &[u64], run_one: F) -> McFamily
|
||||
where
|
||||
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
|
||||
{
|
||||
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
monte_carlo_with_threads(base_point, seeds, nthreads, run_one)
|
||||
debug_assert!(!seeds.is_empty(), "monte_carlo requires a non-empty seed set");
|
||||
assemble_mc(base_point, seeds, &run_one)
|
||||
}
|
||||
|
||||
/// The thread-count-explicit core of [`monte_carlo`]. Module-private: the public
|
||||
/// `monte_carlo` derives the count, while the tests drive it at 1 and at N to pin
|
||||
/// determinism under concurrency (C1). A thin adapter over
|
||||
/// [`run_indexed`](crate::sweep): it runs each seed disjointly and zips the
|
||||
/// reports back onto their seeds in seed-input order.
|
||||
/// The thread-count-explicit wrapper of [`monte_carlo`]. Module-private: the
|
||||
/// public `monte_carlo` runs on the ambient pool; the tests drive this at 1 and
|
||||
/// at N to pin determinism (C1). `assemble_mc` (run_indexed + draw assembly +
|
||||
/// aggregate) runs inside a local rayon pool of `nthreads` workers, sharing the
|
||||
/// exact assembly path `monte_carlo` uses — no second copy to drift. Bounds
|
||||
/// match `monte_carlo` (`F: Sync`): the install closure only borrows `run_one`,
|
||||
/// never moves it.
|
||||
#[cfg(test)]
|
||||
fn monte_carlo_with_threads<F>(
|
||||
base_point: &[Scalar],
|
||||
seeds: &[u64],
|
||||
@@ -143,14 +122,11 @@ where
|
||||
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
|
||||
{
|
||||
debug_assert!(!seeds.is_empty(), "monte_carlo requires a non-empty seed set");
|
||||
let reports = run_indexed(seeds.len(), nthreads, |i| run_one(seeds[i], base_point));
|
||||
let draws: Vec<McDraw> = seeds
|
||||
.iter()
|
||||
.zip(reports)
|
||||
.map(|(&seed, report)| McDraw { seed, report })
|
||||
.collect();
|
||||
let aggregate = McAggregate::from_draws(&draws);
|
||||
McFamily { draws, aggregate }
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(nthreads)
|
||||
.build()
|
||||
.expect("rayon thread pool");
|
||||
pool.install(|| assemble_mc(base_point, seeds, &run_one))
|
||||
}
|
||||
|
||||
/// Distribution of `E[R]` under a moving-block bootstrap of an OOS per-trade R series
|
||||
@@ -166,26 +142,6 @@ pub struct RBootstrap {
|
||||
pub n_resamples: usize,
|
||||
}
|
||||
|
||||
/// One moving-block resample of `rs` to length `rs.len()` (non-circular; the final
|
||||
/// block is truncated so the resample has exactly `n` values). `block_len` must be
|
||||
/// pre-clamped to `[1, rs.len()]` by the caller. Pure given the `rng` state — the
|
||||
/// shared kernel behind `r_bootstrap` and the trials-deflation reality-check (C1).
|
||||
pub fn resample_block(rs: &[f64], block_len: usize, rng: &mut SplitMix64) -> Vec<f64> {
|
||||
let n = rs.len();
|
||||
debug_assert!(
|
||||
(1..=n).contains(&block_len),
|
||||
"resample_block requires block_len in [1, rs.len()] (caller pre-clamps); \
|
||||
got block_len={block_len}, rs.len()={n}",
|
||||
);
|
||||
let mut sample: Vec<f64> = Vec::with_capacity(n);
|
||||
while sample.len() < n {
|
||||
let start = (rng.next_u64() % (n - block_len + 1) as u64) as usize;
|
||||
let take = block_len.min(n - sample.len());
|
||||
sample.extend_from_slice(&rs[start..start + take]);
|
||||
}
|
||||
sample
|
||||
}
|
||||
|
||||
/// Moving-block bootstrap of `rs` (non-circular; the final block of each resample is
|
||||
/// truncated so the resample has exactly `n` values). `block_len` is clamped to
|
||||
/// `[1, n]`. Empty `rs` or zero resamples -> an all-zero `RBootstrap`. Pure given
|
||||
@@ -216,9 +172,83 @@ pub fn r_bootstrap(rs: &[f64], n_resamples: usize, block_len: usize, seed: u64)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_fixtures::composite_sma_cross_harness;
|
||||
use crate::{f64_field, summarize, RunManifest, SyntheticSpec, Timestamp};
|
||||
use aura_core::Scalar;
|
||||
use aura_engine::{
|
||||
f64_field, BlueprintNode, Composite, Edge, OutField, Role, RunManifest, SyntheticSpec,
|
||||
Target, Timestamp,
|
||||
};
|
||||
use aura_core::{Firing, Scalar, ScalarKind};
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use crate::{summarize, SimBroker};
|
||||
use std::sync::mpsc;
|
||||
|
||||
// The SMA-cross signal-quality harness fixture, reconstructed here through
|
||||
// the PUBLIC aura-engine/aura-std/aura-strategy/aura-backtest surface —
|
||||
// `aura-engine::test_fixtures` is `#[cfg(test)]`-private to the engine crate
|
||||
// itself and unreachable from here (the same reason `random_sweep_e2e.rs` /
|
||||
// `list_sweep_e2e.rs` carry their own copy). Bodies verbatim from
|
||||
// `aura-engine/src/test_fixtures.rs`'s `sma_cross`/`composite_sma_cross_harness`.
|
||||
|
||||
/// The SMA-cross signal as a reusable value-empty composite: one input role
|
||||
/// (price), one output (fast-minus-slow spread). Lengths injected at compile.
|
||||
fn sma_cross() -> Composite {
|
||||
Composite::new(
|
||||
"sma_cross",
|
||||
vec![
|
||||
Sma::builder().named("fast").into(),
|
||||
Sma::builder().named("slow").into(),
|
||||
Sub::builder().into(),
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
],
|
||||
vec![Role {
|
||||
name: "price".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }],
|
||||
source: None,
|
||||
}],
|
||||
vec![OutField { node: 2, field: 0, name: "out".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// The signal-quality harness as a value-empty composite blueprint with two
|
||||
/// recording sinks (equity, exposure).
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn composite_sma_cross_harness() -> (
|
||||
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(
|
||||
"root",
|
||||
vec![
|
||||
BlueprintNode::Composite(sma_cross()),
|
||||
Bias::builder().named("bias").into(),
|
||||
SimBroker::builder(0.0001).into(),
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
||||
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
|
||||
],
|
||||
vec![
|
||||
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // composite out -> Bias
|
||||
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
|
||||
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
|
||||
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
|
||||
],
|
||||
vec![Role {
|
||||
name: "src".into(),
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
|
||||
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
|
||||
],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![], // output: the root ends in sinks
|
||||
);
|
||||
(bp, rx_eq, rx_ex)
|
||||
}
|
||||
|
||||
/// Build + bootstrap + run + drain + summarize one (seed, point) into a
|
||||
/// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `monte_carlo`
|
||||
@@ -239,6 +269,7 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "test".to_string(),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window,
|
||||
seed,
|
||||
broker: "test".to_string(),
|
||||
@@ -266,6 +297,7 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "t".to_string(),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(0), Timestamp(0)),
|
||||
seed: i as u64,
|
||||
broker: "t".to_string(),
|
||||
@@ -349,75 +381,6 @@ mod tests {
|
||||
assert!((agg.total_pips.p95 - 3.8).abs() < 1e-9, "p95 = {}", agg.total_pips.p95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantile_endpoints_and_singleton() {
|
||||
// singleton returns the sole value; p=0 -> min, p=1 -> max.
|
||||
assert_eq!(quantile(&[42.0], 0.0), 42.0);
|
||||
assert_eq!(quantile(&[42.0], 0.5), 42.0);
|
||||
assert_eq!(quantile(&[42.0], 1.0), 42.0);
|
||||
let xs = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||||
assert_eq!(quantile(&xs, 0.0), 1.0);
|
||||
assert_eq!(quantile(&xs, 1.0), 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_stats_from_values_matches_known_fixture() {
|
||||
// type-7 quantile + mean over [0,1,2,3,4], directly on the
|
||||
// extracted reduction: mean=2.0, p50=2.0, p5≈0.2, p95≈3.8 (same numbers the
|
||||
// aggregate fixture pins, now on from_values).
|
||||
let s = MetricStats::from_values(&[0.0, 1.0, 2.0, 3.0, 4.0]);
|
||||
assert_eq!(s.mean, 2.0);
|
||||
assert_eq!(s.p50, 2.0);
|
||||
assert!((s.p5 - 0.2).abs() < 1e-9, "p5 = {}", s.p5);
|
||||
assert!((s.p95 - 3.8).abs() < 1e-9, "p95 = {}", s.p95);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_stats_serde_round_trips() {
|
||||
// MetricStats gains serde (consistent with the
|
||||
// report types) so the CLI summary renders it and #70 lineage can persist.
|
||||
let s = MetricStats::from_values(&[1.0, 2.0, 3.0]);
|
||||
let json = serde_json::to_string(&s).expect("serialize MetricStats");
|
||||
let back: MetricStats = serde_json::from_str(&json).expect("deserialize MetricStats");
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resample_block_returns_n_values_from_contiguous_runs() {
|
||||
// The extracted kernel's standalone contract (now a public entry point):
|
||||
// given a pre-clamped block_len in [1, n], it yields exactly `n` values, each
|
||||
// appearing in `rs`, arranged as contiguous runs of `rs` (the final run
|
||||
// truncated to reach exactly n). Distinct powers of ten make membership a sum
|
||||
// check. n=5, block_len=2 -> runs of length 2, 2, 1.
|
||||
let rs = [1.0, 10.0, 100.0, 1000.0, 10000.0];
|
||||
let n = rs.len();
|
||||
let block_len = 2;
|
||||
let mut rng = SplitMix64::new(7);
|
||||
let sample = resample_block(&rs, block_len, &mut rng);
|
||||
assert_eq!(sample.len(), n, "resample has exactly n values");
|
||||
assert!(
|
||||
sample.iter().all(|v| rs.contains(v)),
|
||||
"every resampled value is drawn from rs",
|
||||
);
|
||||
// Reconstruct the contiguous-run structure: walk the sample in blocks of
|
||||
// `block_len` (last truncated) and assert each block is a contiguous slice of rs.
|
||||
let mut filled = 0usize;
|
||||
while filled < n {
|
||||
let take = block_len.min(n - filled);
|
||||
let run = &sample[filled..filled + take];
|
||||
let start = rs
|
||||
.iter()
|
||||
.position(|v| v == &run[0])
|
||||
.expect("run head is in rs");
|
||||
assert_eq!(
|
||||
run,
|
||||
&rs[start..start + take],
|
||||
"each run is a contiguous slice of rs",
|
||||
);
|
||||
filled += take;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r_bootstrap_empty_series_is_all_zero() {
|
||||
let b = r_bootstrap(&[], 100, 1, 7);
|
||||
File diff suppressed because it is too large
Load Diff
+6
-1
@@ -87,7 +87,12 @@ impl PositionManagement {
|
||||
.collect();
|
||||
PrimitiveBuilder::new(
|
||||
"PositionManagement",
|
||||
NodeSchema { inputs, output, params: vec![] },
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output,
|
||||
params: vec![],
|
||||
doc: "turns bias plus protective stop into a managed position in R",
|
||||
},
|
||||
|_| Box::new(PositionManagement::new()),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The pure per-run scaffold: bootstrap-adjacent arithmetic that has no
|
||||
//! dependency on the CLI shell — reconstructing a bootstrap point from
|
||||
//! recorded params, sizing the real walk-forward roller, and intersecting
|
||||
//! per-symbol data windows. Relocated verbatim from `aura-cli::main` (#295)
|
||||
//! so it is reachable (and unit-testable) without the shell.
|
||||
|
||||
use aura_core::{Cell, ParamSpec, Scalar};
|
||||
|
||||
/// Reconstruct a member's bootstrap point from its recorded named params — the inverse
|
||||
/// of `zip_params(space, point)`. Walks the reloaded signal's `param_space` in order
|
||||
/// (deterministic for the same blueprint) and reads each knob's value from the manifest.
|
||||
///
|
||||
/// `Err` carries the exact refusal message a manifest missing a param the reloaded
|
||||
/// space expects has always produced (corrupted-on-disk data): this crate has no
|
||||
/// process-exit register of its own (#295 — a pure library function reports a
|
||||
/// refusal, it does not end the process), so the caller is the one that turns this
|
||||
/// into `aura:` + exit 1, byte-identical to before.
|
||||
pub fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Result<Vec<Cell>, String> {
|
||||
space
|
||||
.iter()
|
||||
.map(|ps| {
|
||||
params
|
||||
.iter()
|
||||
.find(|(n, _)| n == &ps.name)
|
||||
.map(|(_, s)| s.cell())
|
||||
.ok_or_else(|| format!("manifest is missing param {}", ps.name))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Real walk-forward roller sizes (Fork D/F). `WindowRoller` takes sizes in the
|
||||
/// stream's epoch-unit; for real M1 that is nanoseconds. A classic 3-month
|
||||
/// in-sample / 1-month out-of-sample / 1-month step (contiguous OOS tiling).
|
||||
const WF_DAY_NS: i64 = 86_400_000_000_000;
|
||||
pub const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
|
||||
pub const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
|
||||
pub const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
|
||||
|
||||
/// The real walk-forward roller sizes in ms (the campaign wf stage works in ms:
|
||||
/// span `cell.window_ms`, sizes the `StageBlock` `_ms` fields). `WF_REAL_*_NS` are
|
||||
/// nanoseconds; divide by 1e6. The ms sizes over the doc window reproduce the same
|
||||
/// calendar windows the inline ns roller produces (anchor-gated).
|
||||
pub fn wf_ms_sizes() -> (u64, u64, u64) {
|
||||
(
|
||||
(WF_REAL_IS_NS / 1_000_000) as u64,
|
||||
(WF_REAL_OOS_NS / 1_000_000) as u64,
|
||||
(WF_REAL_STEP_NS / 1_000_000) as u64,
|
||||
)
|
||||
}
|
||||
|
||||
/// Fit the fixed real-archive walk-forward roller (`wf_ms_sizes`, 90/30/30 days)
|
||||
/// to a resolved campaign window `(from_ms, to_ms)` (#239). When the fixed
|
||||
/// IS+OOS span fits inside the window, the sizes come back byte-identical (every
|
||||
/// existing anchor/e2e over a year-plus window pins this branch). When the window
|
||||
/// is shorter than IS+OOS, the roller scales DOWN to the window, preserving the
|
||||
/// fixed 3:1 IS:OOS ratio, with `step_ms == oos_ms` (a window of exactly IS+OOS
|
||||
/// then yields exactly one roll — never zero). Pure and unit-testable; both
|
||||
/// `dispatch_walkforward`/`dispatch_mc` consume it in place of the unconditional
|
||||
/// `wf_ms_sizes()` stamp. The executor's own fit check (`campaign_run.rs`) stays
|
||||
/// untouched — it still validates an AUTHORED document's declared window as-is.
|
||||
pub fn fit_wf_ms_sizes(from_ms: i64, to_ms: i64) -> (u64, u64, u64) {
|
||||
let (is_ms, oos_ms, step_ms) = wf_ms_sizes();
|
||||
let span_ms = to_ms.saturating_sub(from_ms).max(0) as u64;
|
||||
if is_ms + oos_ms <= span_ms {
|
||||
return (is_ms, oos_ms, step_ms);
|
||||
}
|
||||
// Preserve the fixed IS:OOS ratio derived from the constants themselves (not a
|
||||
// bare literal): OOS = span/(ratio+1) (floor), IS = ratio*OOS, so
|
||||
// IS+OOS = (ratio+1)*OOS <= span always holds, and step == OOS (one roll over
|
||||
// the fit window, matching the fixed-size branch's `step_ms == oos_ms`).
|
||||
let ratio = is_ms / oos_ms;
|
||||
let oos_fit = span_ms / (ratio + 1);
|
||||
let is_fit = oos_fit * ratio;
|
||||
(is_fit, oos_fit, oos_fit)
|
||||
}
|
||||
|
||||
type SymbolSpans<'a> = Vec<(&'a str, (i64, i64))>;
|
||||
|
||||
/// The pure decision `generalize`'s no-explicit-window fallback reduces to once
|
||||
/// every listed symbol's full window is resolved (#213): the shared window is
|
||||
/// the intersection (latest start, earliest end); `Err` carries each symbol
|
||||
/// paired with its own resolved window when the intersection is empty (the
|
||||
/// archives never overlap), for the caller's eprintln-then-exit(1) refusal.
|
||||
/// Kept separate from `dispatch_generalize` so the intersect-or-refuse
|
||||
/// arithmetic is unit-testable on synthetic windows — real archives on this
|
||||
/// host never disjoint (every symbol's tail reaches the live present), so the
|
||||
/// refusal branch has no reachable e2e fixture.
|
||||
pub fn intersect_shared_window<'a>(
|
||||
symbols: &'a [String],
|
||||
windows: &[(i64, i64)],
|
||||
) -> Result<(i64, i64), SymbolSpans<'a>> {
|
||||
let shared_from = windows.iter().map(|w| w.0).max().expect("caller passes at least one window");
|
||||
let shared_to = windows.iter().map(|w| w.1).min().expect("caller passes at least one window");
|
||||
if shared_from > shared_to {
|
||||
Err(symbols.iter().map(String::as_str).zip(windows.iter().copied()).collect())
|
||||
} else {
|
||||
Ok((shared_from, shared_to))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A single listed symbol degenerates to its own full window unchanged
|
||||
/// (#213) — the intersection of one window with itself is that window.
|
||||
#[test]
|
||||
fn intersect_shared_window_of_a_single_symbol_is_its_own_window() {
|
||||
let symbols = vec!["GER40".to_string()];
|
||||
let windows = vec![(100, 200)];
|
||||
assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200)));
|
||||
}
|
||||
|
||||
/// Overlapping multi-symbol windows resolve to the intersection — the
|
||||
/// latest start, earliest end (#213) — never `symbols[0]`'s own window.
|
||||
#[test]
|
||||
fn intersect_shared_window_of_overlapping_windows_is_latest_start_earliest_end() {
|
||||
let symbols = vec!["AAPL.US".to_string(), "GER40".to_string()];
|
||||
let windows = vec![(0, 300), (100, 200)];
|
||||
assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200)));
|
||||
}
|
||||
|
||||
/// Disjoint archives (no shared instant across all listed symbols) refuse
|
||||
/// rather than silently pooling a floor over different periods per
|
||||
/// instrument (#213); the error names each symbol next to its own span,
|
||||
/// which the caller eprintln's before exiting 1 — the boundary this
|
||||
/// covers is otherwise unreachable in an e2e fixture on this host, since
|
||||
/// every archived symbol's tail reaches the live present (no two windows
|
||||
/// are ever genuinely disjoint here).
|
||||
#[test]
|
||||
fn intersect_shared_window_of_disjoint_windows_refuses_with_every_span() {
|
||||
let symbols = vec!["A".to_string(), "B".to_string()];
|
||||
let windows = vec![(0, 100), (200, 300)];
|
||||
assert_eq!(
|
||||
intersect_shared_window(&symbols, &windows),
|
||||
Err(vec![("A", (0, 100)), ("B", (200, 300))]),
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: a window that already fits the fixed 90/30/30-day roller passes
|
||||
/// it through byte-identical (the year-plus anchor/e2e grade pins rely on this
|
||||
/// branch never perturbing the fixed sizes).
|
||||
#[test]
|
||||
fn fit_wf_ms_sizes_passes_through_when_the_window_already_fits() {
|
||||
let day_ms: i64 = 24 * 60 * 60 * 1_000;
|
||||
let from_ms = 0;
|
||||
let to_ms = 121 * day_ms; // > 90 + 30 days
|
||||
assert_eq!(fit_wf_ms_sizes(from_ms, to_ms), wf_ms_sizes());
|
||||
}
|
||||
|
||||
/// Property: a window shorter than IS+OOS scales the roller DOWN to the
|
||||
/// window, preserving the fixed 3:1 IS:OOS ratio, with `step_ms == oos_ms`
|
||||
/// (one roll over the fit window) and `is_ms + oos_ms <= span_ms` always.
|
||||
#[test]
|
||||
fn fit_wf_ms_sizes_scales_down_to_a_short_window_at_3_to_1() {
|
||||
let day_ms: i64 = 24 * 60 * 60 * 1_000;
|
||||
let from_ms = 0;
|
||||
let to_ms = 30 * day_ms; // far shorter than the fixed 90+30-day roller
|
||||
let span_ms = (to_ms - from_ms) as u64;
|
||||
let (is_ms, oos_ms, step_ms) = fit_wf_ms_sizes(from_ms, to_ms);
|
||||
assert_eq!(is_ms, oos_ms * 3, "the fit preserves the fixed 3:1 IS:OOS ratio");
|
||||
assert_eq!(step_ms, oos_ms, "one roll over the fit window: step == oos");
|
||||
assert!(is_ms + oos_ms <= span_ms, "the fit roller must fit inside the window");
|
||||
assert!(oos_ms > 0, "a 30-day window must yield a non-degenerate fit");
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,7 @@ impl SimBroker {
|
||||
],
|
||||
output: vec![FieldSpec { name: "equity".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "frictionless sim-optimal broker: integrates held exposure times price return into cumulative pip equity",
|
||||
},
|
||||
move |_| Box::new(SimBroker::new(pip_size)),
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "aura-bench"
|
||||
edition.workspace = true
|
||||
version.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "aura-bench"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Dev-only measurement tool (issue #251): benches the engine / ingest / campaign
|
||||
# surfaces against committed baselines under baselines/. No production crate
|
||||
# depends on this crate; nothing here reaches a frozen deploy artifact (C13).
|
||||
[dependencies]
|
||||
aura-core = { path = "../aura-core" }
|
||||
aura-engine = { path = "../aura-engine" }
|
||||
aura-std = { path = "../aura-std" }
|
||||
aura-ingest = { path = "../aura-ingest" }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
# clap: same CLI surface convention as aura-cli (usage errors exit 2).
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
# zip: writes the synthetic 48-byte M1 archives the ingest/campaign surfaces
|
||||
# measure — the same on-disk format the test suites hand-pack as a dev-dep
|
||||
# (aura-cli/aura-ingest). First runtime (non-dev) use in the workspace, admitted
|
||||
# under the C16 per-case policy: aura-bench is a dev tool outside every deploy
|
||||
# path, and zip is already in the build graph via data-server.
|
||||
zip = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
# Lockstep guard for the campaign fingerprint's stdout-schema coupling: the
|
||||
# winner_fingerprint test constructs a REAL CampaignRunRecord (aura-registry)
|
||||
# with a real FamilySelection (aura-analysis) and serializes it, so a field
|
||||
# rename in those crates fails this crate's tests instead of silently eroding
|
||||
# fingerprint coverage.
|
||||
aura-registry = { path = "../aura-registry" }
|
||||
aura-analysis = { path = "../aura-analysis" }
|
||||
# the winner_fingerprint lockstep guard's RBootstrap literal (moved from
|
||||
# aura-engine, #291/#292) — test-only, same coupling-guard rationale as the
|
||||
# other three dev-deps above.
|
||||
aura-backtest = { path = "../aura-backtest" }
|
||||
@@ -0,0 +1,47 @@
|
||||
# aura-bench
|
||||
|
||||
Wall-clock benchmarks with committed baselines (issue #251, phase 1). Catches
|
||||
hot-path regressions the E2E suite structurally cannot see: every surface pins
|
||||
its inputs and asserts a determinism fingerprint, so a fast-but-wrong binary
|
||||
fails the bench instead of pinning a bogus baseline.
|
||||
|
||||
## Running
|
||||
|
||||
```console
|
||||
cargo run --release -p aura-bench -- run # measure + compare, report-only
|
||||
cargo run --release -p aura-bench -- run --surface engine_throughput
|
||||
cargo run --release -p aura-bench -- pin # rewrite committed baselines
|
||||
```
|
||||
|
||||
Debug builds refuse to measure (exit 2) — wall-clock numbers from a debug
|
||||
build measure the profile, not the code.
|
||||
|
||||
## Surfaces
|
||||
|
||||
| surface | workload | metrics |
|
||||
|---|---|---|
|
||||
| `engine_throughput` | 10M synthetic bars through an SMA-cross harness (library) | `wall_s`, `bars_per_s` |
|
||||
| `ingest_throughput` | 24 synthetic archive months x 20 fresh drains via the ingest window API (library) | `wall_s`, `bars_per_s` |
|
||||
| `campaign_sweep` | 8-cell sweep-only campaign, release `aura` binary on a scratch project | `wall_s`, `cpu_percent`, `peak_rss_mb` |
|
||||
| `campaign_heavy` | same cells; sweep → gate → walk-forward → 1000-resample bootstrap | `wall_s`, `cpu_percent`, `peak_rss_mb` |
|
||||
| `cli_fixed_cost` | spawn floor (`aura --help`) + minimal data-only project run | `help_ms`, `run_ms` |
|
||||
|
||||
## Discipline
|
||||
|
||||
- Quiet box: the driver warns at 1-min loadavg > 2.0. Close heavy jobs first.
|
||||
- 1 discarded warmup + 3 measured reps (median). Reps must agree on the
|
||||
fingerprint — cross-rep drift is reported as nondeterminism, not averaged.
|
||||
- Baselines are per-host context (`host` block inside each JSON): comparison on
|
||||
a different host prints an informational banner and stays report-only.
|
||||
- Timing drift NEVER fails the run (report-only phase, #251 cadence decision);
|
||||
a NOTICE line flags drift >= 10%. A fingerprint mismatch exits 1.
|
||||
- Re-pin (`pin`) deliberately, after an accepted behaviour or performance
|
||||
change; the baseline diff is reviewed like any other commit.
|
||||
|
||||
## Deferred (follow-up on the tracker)
|
||||
|
||||
- Instruction-count backend (iai-callgrind) once valgrind is installed on the
|
||||
measuring host — slots in as a sibling backend without reshaping baselines.
|
||||
- Native-crate project-load sub-cost (dylib SHA-256) — needs a build step in
|
||||
the scratch project.
|
||||
- Thresholded failure, once recorded variance data justifies thresholds.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"surface": "campaign_heavy",
|
||||
"metrics": {
|
||||
"cpu_percent": 2152.0,
|
||||
"peak_rss_mb": 101.3828125,
|
||||
"wall_s": 5.572580056
|
||||
},
|
||||
"fingerprint": "cells=8 c0s0w17 c0s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c0s3b2883 c1s0w17 c1s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c1s3b2883 c2s0w17 c2s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c2s3b2883 c3s0w17 c3s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c3s3b2883 c4s0w17 c4s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c4s3b2883 c5s0w17 c5s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c5s3b2883 c6s0w17 c6s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c6s3b2883 c7s0w17 c7s1v[0.1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24] c7s3b2883",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"surface": "campaign_sweep",
|
||||
"metrics": {
|
||||
"cpu_percent": 2190.0,
|
||||
"peak_rss_mb": 92.203125,
|
||||
"wall_s": 1.394470036
|
||||
},
|
||||
"fingerprint": "cells=8 c0s0w17 c1s0w17 c2s0w17 c3s0w17 c4s0w17 c5s0w17 c6s0w17 c7s0w17",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"surface": "cli_fixed_cost",
|
||||
"metrics": {
|
||||
"help_ms": 1.5046899999999999,
|
||||
"run_ms": 3.63305
|
||||
},
|
||||
"fingerprint": "run_line_fnv=6bb0d796f760d140",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"surface": "engine_throughput",
|
||||
"metrics": {
|
||||
"bars_per_s": 14005588.039151624,
|
||||
"wall_s": 0.714000724
|
||||
},
|
||||
"fingerprint": "last=0.417351 max_dd=8.274577 flips=265308 n=10000000",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"surface": "ingest_throughput",
|
||||
"metrics": {
|
||||
"bars_per_s": 13000457.745164338,
|
||||
"wall_s": 0.386201786
|
||||
},
|
||||
"fingerprint": "n=251040 xor=0019885debd3bd5e",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "9c7f60b",
|
||||
"date": "2026-07-23"
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Baseline documents and the report-only comparison core. Pure functions —
|
||||
//! everything here is unit-tested without running a single benchmark.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One committed baseline: `baselines/<surface>.json`.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct BaselineDoc {
|
||||
pub surface: String,
|
||||
/// Flat metric map, e.g. `bars_per_s`, `wall_s`, `peak_rss_mb`.
|
||||
pub metrics: BTreeMap<String, f64>,
|
||||
/// Opaque determinism fingerprint; compared for byte equality.
|
||||
pub fingerprint: String,
|
||||
pub reps: u32,
|
||||
pub host: HostInfo,
|
||||
pub profile: String,
|
||||
pub commit: String,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct HostInfo {
|
||||
pub hostname: String,
|
||||
pub nproc: u32,
|
||||
}
|
||||
|
||||
/// One measurement result, produced by a surface run.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Measured {
|
||||
pub surface: String,
|
||||
pub metrics: BTreeMap<String, f64>,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
/// Timing drift at or beyond this fraction is reported as a NOTICE line.
|
||||
pub const NOTICE_THRESHOLD: f64 = 0.10;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Compared {
|
||||
/// Per-metric relative delta, `(name, (measured - baseline) / baseline)`.
|
||||
/// Metrics missing on either side are skipped.
|
||||
pub deltas: Vec<(String, f64)>,
|
||||
/// Metric names whose |delta| >= NOTICE_THRESHOLD.
|
||||
pub notices: Vec<String>,
|
||||
pub fingerprint_changed: bool,
|
||||
pub host_mismatch: bool,
|
||||
}
|
||||
|
||||
pub fn compare(baseline: &BaselineDoc, measured: &Measured, host: &HostInfo) -> Compared {
|
||||
let mut deltas = Vec::new();
|
||||
let mut notices = Vec::new();
|
||||
for (name, base) in &baseline.metrics {
|
||||
let Some(now) = measured.metrics.get(name) else { continue };
|
||||
if *base == 0.0 {
|
||||
continue; // no meaningful relative delta
|
||||
}
|
||||
let d = (now - base) / base;
|
||||
if d.abs() >= NOTICE_THRESHOLD {
|
||||
notices.push(name.clone());
|
||||
}
|
||||
deltas.push((name.clone(), d));
|
||||
}
|
||||
Compared {
|
||||
deltas,
|
||||
notices,
|
||||
fingerprint_changed: baseline.fingerprint != measured.fingerprint,
|
||||
host_mismatch: baseline.host != *host,
|
||||
}
|
||||
}
|
||||
|
||||
/// The process exit code for a full run: 1 if ANY surface's fingerprint
|
||||
/// changed, else 0. (Infra errors exit 2 before this is ever consulted;
|
||||
/// timing drift is report-only by design — the #251 cadence decision.)
|
||||
pub fn exit_code(compared: &[Option<Compared>]) -> i32 {
|
||||
if compared.iter().flatten().any(|c| c.fingerprint_changed) { 1 } else { 0 }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn doc(metrics: &[(&str, f64)], fp: &str) -> BaselineDoc {
|
||||
BaselineDoc {
|
||||
surface: "s".into(),
|
||||
metrics: metrics.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
|
||||
fingerprint: fp.into(),
|
||||
reps: 3,
|
||||
host: HostInfo { hostname: "h".into(), nproc: 24 },
|
||||
profile: "release".into(),
|
||||
commit: "abc".into(),
|
||||
date: "2026-07-17".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn measured(metrics: &[(&str, f64)], fp: &str) -> Measured {
|
||||
Measured {
|
||||
surface: "s".into(),
|
||||
metrics: metrics.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
|
||||
fingerprint: fp.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn host() -> HostInfo {
|
||||
HostInfo { hostname: "h".into(), nproc: 24 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_reports_relative_drift_per_metric() {
|
||||
let c = compare(&doc(&[("wall_s", 10.0)], "f"), &measured(&[("wall_s", 10.5)], "f"), &host());
|
||||
assert_eq!(c.deltas.len(), 1);
|
||||
assert_eq!(c.deltas[0].0, "wall_s");
|
||||
assert!((c.deltas[0].1 - 0.05).abs() < 1e-12, "5% drift, got {}", c.deltas[0].1);
|
||||
assert!(c.notices.is_empty(), "5% is below the 10% notice bar");
|
||||
assert!(!c.fingerprint_changed);
|
||||
assert!(!c.host_mismatch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_flags_notice_at_ten_percent() {
|
||||
let c = compare(&doc(&[("wall_s", 10.0)], "f"), &measured(&[("wall_s", 11.0)], "f"), &host());
|
||||
assert_eq!(c.notices, vec!["wall_s".to_string()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_detects_fingerprint_change() {
|
||||
let c = compare(&doc(&[], "old"), &measured(&[], "new"), &host());
|
||||
assert!(c.fingerprint_changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_flags_host_mismatch_informationally() {
|
||||
let other = HostInfo { hostname: "elsewhere".into(), nproc: 8 };
|
||||
let c = compare(&doc(&[], "f"), &measured(&[], "f"), &other);
|
||||
assert!(c.host_mismatch);
|
||||
assert!(!c.fingerprint_changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_skips_metrics_missing_on_either_side() {
|
||||
let c = compare(
|
||||
&doc(&[("only_base", 1.0)], "f"),
|
||||
&measured(&[("only_measured", 2.0)], "f"),
|
||||
&host(),
|
||||
);
|
||||
assert!(c.deltas.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_code_is_one_only_on_fingerprint_change() {
|
||||
let clean = compare(&doc(&[], "f"), &measured(&[], "f"), &host());
|
||||
let changed = compare(&doc(&[], "f"), &measured(&[], "g"), &host());
|
||||
assert_eq!(exit_code(&[Some(clean.clone()), None]), 0);
|
||||
assert_eq!(exit_code(&[Some(clean), Some(changed)]), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_doc_round_trips_through_json() {
|
||||
let d = doc(&[("bars_per_s", 812345.0)], "last=0.1 n=5");
|
||||
let json = serde_json::to_string_pretty(&d).expect("serialize");
|
||||
let back: BaselineDoc = serde_json::from_str(&json).expect("parse");
|
||||
assert_eq!(d, back);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Child-process measurement: resolve the release `aura` binary via cargo's
|
||||
//! JSON messages, run it under `/usr/bin/time -v`, and parse wall/CPU/RSS
|
||||
//! from the stderr report. `/usr/bin/time` missing is an infra error — the
|
||||
//! campaign surfaces have no fallback RSS probe by design (one tool, one
|
||||
//! parse, the practice proven in the #277 measurement session).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Workspace root: two levels above this crate's manifest dir.
|
||||
pub fn workspace_root() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.ancestors()
|
||||
.nth(2)
|
||||
.expect("crates/aura-bench sits two levels under the workspace root")
|
||||
.to_path_buf()
|
||||
}
|
||||
|
||||
/// Build (if needed) and locate the release `aura` binary. Unmeasured setup.
|
||||
pub fn resolve_aura_bin(ws_root: &Path) -> Result<PathBuf, String> {
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
|
||||
let out = std::process::Command::new(cargo)
|
||||
.args(["build", "--release", "-p", "aura-cli", "--message-format=json"])
|
||||
.current_dir(ws_root)
|
||||
.output()
|
||||
.map_err(|e| format!("spawn cargo build: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!(
|
||||
"cargo build --release -p aura-cli failed:\n{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
));
|
||||
}
|
||||
parse_aura_executable(&String::from_utf8_lossy(&out.stdout))
|
||||
.ok_or_else(|| "no compiler-artifact message named `aura` with an executable".to_string())
|
||||
}
|
||||
|
||||
/// Extract the `aura` executable path from `--message-format=json` lines.
|
||||
pub fn parse_aura_executable(stdout: &str) -> Option<PathBuf> {
|
||||
for line in stdout.lines() {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else { continue };
|
||||
if v["reason"] == "compiler-artifact"
|
||||
&& v["target"]["name"] == "aura"
|
||||
&& v["executable"].is_string()
|
||||
{
|
||||
return Some(PathBuf::from(v["executable"].as_str().expect("checked string")));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct TimedChild {
|
||||
pub wall_s: f64,
|
||||
pub cpu_percent: Option<f64>,
|
||||
pub peak_rss_mb: Option<f64>,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
pub exit: Option<i32>,
|
||||
}
|
||||
|
||||
/// Run `argv` in `cwd` under `/usr/bin/time -v`, measuring wall in-process.
|
||||
pub fn run_timed(bin: &Path, args: &[&str], cwd: &Path) -> Result<TimedChild, String> {
|
||||
if !Path::new("/usr/bin/time").exists() {
|
||||
return Err("/usr/bin/time is required for child-process surfaces".to_string());
|
||||
}
|
||||
let t = Instant::now();
|
||||
let out = std::process::Command::new("/usr/bin/time")
|
||||
.arg("-v")
|
||||
.arg(bin)
|
||||
.args(args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.map_err(|e| format!("spawn {}: {e}", bin.display()))?;
|
||||
let wall_s = t.elapsed().as_secs_f64();
|
||||
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
||||
let (cpu_percent, peak_rss_mb) = parse_time_v(&stderr);
|
||||
Ok(TimedChild {
|
||||
wall_s,
|
||||
cpu_percent,
|
||||
peak_rss_mb,
|
||||
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
stderr,
|
||||
exit: out.status.code(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse `Percent of CPU this job got` and `Maximum resident set size` out of
|
||||
/// a `/usr/bin/time -v` stderr report.
|
||||
pub fn parse_time_v(stderr: &str) -> (Option<f64>, Option<f64>) {
|
||||
let mut cpu = None;
|
||||
let mut rss = None;
|
||||
for line in stderr.lines() {
|
||||
let line = line.trim();
|
||||
if let Some(v) = line.strip_prefix("Percent of CPU this job got: ") {
|
||||
cpu = v.trim_end_matches('%').parse::<f64>().ok();
|
||||
} else if let Some(v) = line.strip_prefix("Maximum resident set size (kbytes): ") {
|
||||
rss = v.parse::<f64>().ok().map(|kb| kb / 1024.0);
|
||||
}
|
||||
}
|
||||
(cpu, rss)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `resolve_aura_bin` hands its `cargo build --release -p aura-cli`
|
||||
/// invocation a `current_dir`, and that invocation only produces the
|
||||
/// right binary if `workspace_root` actually resolves to the real
|
||||
/// workspace root — the `.ancestors().nth(2)` computation is a
|
||||
/// hardcoded distance that would silently point at the wrong directory
|
||||
/// (and build nothing, or the wrong crate graph) if `aura-bench` were
|
||||
/// ever relocated a level deeper or shallower. Pins that it lands on
|
||||
/// the directory holding the actual workspace manifest, not merely
|
||||
/// some ancestor.
|
||||
#[test]
|
||||
fn workspace_root_resolves_the_real_cargo_workspace() {
|
||||
let root = workspace_root();
|
||||
let manifest = std::fs::read_to_string(root.join("Cargo.toml")).unwrap_or_else(|e| {
|
||||
panic!("workspace_root ({}) must contain Cargo.toml: {e}", root.display())
|
||||
});
|
||||
assert!(manifest.contains("[workspace]"), "not the workspace root: {}", root.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_time_v_extracts_cpu_and_rss() {
|
||||
let s = "\tCommand being timed: \"aura --help\"\n\
|
||||
\tPercent of CPU this job got: 2270%\n\
|
||||
\tMaximum resident set size (kbytes): 1048576\n";
|
||||
let (cpu, rss) = parse_time_v(s);
|
||||
assert_eq!(cpu, Some(2270.0));
|
||||
assert_eq!(rss, Some(1024.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aura_executable_reads_compiler_artifact_lines() {
|
||||
let lines = concat!(
|
||||
"{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"aura-bench\"},\"executable\":\"/x/aura-bench\"}\n",
|
||||
"{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"aura\"},\"executable\":\"/x/release/aura\"}\n",
|
||||
"{\"reason\":\"build-finished\",\"success\":true}\n",
|
||||
);
|
||||
assert_eq!(parse_aura_executable(lines), Some(PathBuf::from("/x/release/aura")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_aura_executable_ignores_null_executables() {
|
||||
let lines = "{\"reason\":\"compiler-artifact\",\"target\":{\"name\":\"aura\"},\"executable\":null}\n";
|
||||
assert_eq!(parse_aura_executable(lines), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Host context probes. All best-effort: a probe that fails degrades to a
|
||||
//! placeholder value and never fails a bench (the report stays honest via the
|
||||
//! host block committed inside each baseline).
|
||||
|
||||
use crate::baseline::HostInfo;
|
||||
|
||||
pub fn host_info() -> HostInfo {
|
||||
let hostname = std::fs::read_to_string("/proc/sys/kernel/hostname")
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
let nproc = std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(0);
|
||||
HostInfo { hostname, nproc }
|
||||
}
|
||||
|
||||
/// 1-minute loadavg, or `None` when unreadable.
|
||||
pub fn loadavg_1m() -> Option<f64> {
|
||||
let text = std::fs::read_to_string("/proc/loadavg").ok()?;
|
||||
text.split_whitespace().next()?.parse().ok()
|
||||
}
|
||||
|
||||
/// Short commit of the enclosing worktree, best-effort.
|
||||
pub fn git_commit() -> String {
|
||||
std::process::Command::new("git")
|
||||
.args(["rev-parse", "--short", "HEAD"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
/// UTC date `YYYY-MM-DD`, best-effort via `date -u`.
|
||||
pub fn today() -> String {
|
||||
std::process::Command::new("date")
|
||||
.args(["-u", "+%F"])
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn host_info_reads_something() {
|
||||
let h = host_info();
|
||||
assert!(!h.hostname.is_empty());
|
||||
}
|
||||
|
||||
/// #251: every committed baseline records `commit` for provenance (a
|
||||
/// baseline that can't be tied to the code it measured is useless for
|
||||
/// drift comparison). This test runs inside a real git worktree, so
|
||||
/// `git_commit` must shell out and parse successfully — falling silently
|
||||
/// back to `"unknown"` here would be exactly the failure mode the
|
||||
/// best-effort fallback exists to hide, and this pins the happy path
|
||||
/// rather than only the "never panics" degenerate case.
|
||||
#[test]
|
||||
fn git_commit_reports_the_real_short_hash_inside_a_git_worktree() {
|
||||
let commit = git_commit();
|
||||
assert_ne!(commit, "unknown", "must read the real worktree HEAD, not fall back silently");
|
||||
assert!(
|
||||
commit.len() >= 7 && commit.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"not a short hex hash: {commit:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! `aura-bench` — the wall-clock benchmark harness with committed baselines
|
||||
//! (issue #251, phase 1). Report-only: timing drift never fails; a determinism
|
||||
//! fingerprint mismatch does (exit 1), so a fast-but-wrong binary fails the
|
||||
//! bench instead of pinning a bogus baseline. Infra errors exit 2.
|
||||
|
||||
mod baseline;
|
||||
mod child;
|
||||
mod host;
|
||||
mod surfaces;
|
||||
mod synth;
|
||||
|
||||
use baseline::{compare, exit_code, BaselineDoc, Compared, Measured};
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::{Path, PathBuf};
|
||||
use surfaces::Sizing;
|
||||
|
||||
const SURFACES: &[&str] =
|
||||
&["engine_throughput", "ingest_throughput", "campaign_sweep", "campaign_heavy", "cli_fixed_cost"];
|
||||
/// The in-process surfaces — selecting only these never resolves (or builds)
|
||||
/// the release `aura` binary. Keep in sync with `measure_surface`.
|
||||
const LIBRARY_SURFACES: &[&str] = &["engine_throughput", "ingest_throughput"];
|
||||
const DEFAULT_REPS: u32 = 3;
|
||||
/// 1-min loadavg above which the banner warns the box is not quiet.
|
||||
const LOAD_WARN_1M: f64 = 2.0;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "aura-bench", about = "wall-clock benchmarks against committed baselines (#251)")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Measure surfaces and compare against committed baselines (report-only).
|
||||
Run {
|
||||
/// Only these surfaces (repeatable). Default: all.
|
||||
#[arg(long)]
|
||||
surface: Vec<String>,
|
||||
/// Tiny workloads, results written to --out, committed baselines untouched.
|
||||
#[arg(long)]
|
||||
quick: bool,
|
||||
/// Measured repetitions per surface (after 1 discarded warmup).
|
||||
#[arg(long, default_value_t = DEFAULT_REPS)]
|
||||
reps: u32,
|
||||
/// Write measured results as JSON files into this directory.
|
||||
#[arg(long)]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Measure surfaces and (re)write the committed baselines.
|
||||
Pin {
|
||||
/// Only these surfaces (repeatable). Default: all.
|
||||
#[arg(long)]
|
||||
surface: Vec<String>,
|
||||
/// Measured repetitions per surface (after 1 discarded warmup).
|
||||
#[arg(long, default_value_t = DEFAULT_REPS)]
|
||||
reps: u32,
|
||||
},
|
||||
}
|
||||
|
||||
fn baselines_dir(ws_root: &Path) -> PathBuf {
|
||||
ws_root.join("crates/aura-bench/baselines")
|
||||
}
|
||||
|
||||
fn measure_surface(
|
||||
name: &str,
|
||||
sizing: Sizing,
|
||||
reps: u32,
|
||||
aura_bin: &Path,
|
||||
) -> Result<Measured, String> {
|
||||
match name {
|
||||
"engine_throughput" => surfaces::run_reps(name, reps, || surfaces::engine::rep(sizing)),
|
||||
"ingest_throughput" => {
|
||||
let fixture = surfaces::ingest::setup(sizing)?;
|
||||
surfaces::run_reps(name, reps, || surfaces::ingest::rep(&fixture))
|
||||
}
|
||||
"campaign_sweep" => {
|
||||
surfaces::run_reps(name, reps, || surfaces::campaign::sweep_rep(aura_bin, sizing))
|
||||
}
|
||||
"campaign_heavy" => {
|
||||
surfaces::run_reps(name, reps, || surfaces::campaign::heavy_rep(aura_bin, sizing))
|
||||
}
|
||||
"cli_fixed_cost" => surfaces::run_reps(name, reps, || surfaces::fixed_cost::rep(aura_bin)),
|
||||
other => Err(format!("unknown surface: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn selected(filter: &[String]) -> Result<Vec<&'static str>, String> {
|
||||
if filter.is_empty() {
|
||||
return Ok(SURFACES.to_vec());
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
for f in filter {
|
||||
let s = SURFACES
|
||||
.iter()
|
||||
.find(|s| **s == f.as_str())
|
||||
.ok_or_else(|| format!("unknown surface {f:?}; known: {SURFACES:?}"))?;
|
||||
out.push(*s);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn fmt_metric(name: &str, v: f64) -> String {
|
||||
if name.ends_with("_ms") {
|
||||
format!("{name} {v:.1}ms")
|
||||
} else if name == "bars_per_s" {
|
||||
format!("{name} {v:.0}")
|
||||
} else if name == "wall_s" {
|
||||
format!("{name} {v:.3}s")
|
||||
} else {
|
||||
format!("{name} {v:.1}")
|
||||
}
|
||||
}
|
||||
|
||||
fn report_line(m: &Measured, c: Option<&Compared>) -> String {
|
||||
let metrics = m
|
||||
.metrics
|
||||
.iter()
|
||||
.map(|(k, v)| fmt_metric(k, *v))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let verdict = match c {
|
||||
None => "no baseline (pin to create)".to_string(),
|
||||
Some(c) if c.fingerprint_changed => "fingerprint CHANGED".to_string(),
|
||||
Some(c) => {
|
||||
let drift = c
|
||||
.deltas
|
||||
.iter()
|
||||
.map(|(k, d)| format!("{k} {:+.1}%", d * 100.0))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let notice = if c.notices.is_empty() { "" } else { " NOTICE" };
|
||||
format!("Δ {drift}{notice} fingerprint OK")
|
||||
}
|
||||
};
|
||||
format!("{:<20} {metrics} {verdict}", m.surface)
|
||||
}
|
||||
|
||||
fn load_baseline(dir: &Path, surface: &str) -> Option<BaselineDoc> {
|
||||
let text = std::fs::read_to_string(dir.join(format!("{surface}.json"))).ok()?;
|
||||
serde_json::from_str(&text).ok()
|
||||
}
|
||||
|
||||
/// One `Measured` result plus a host + rep count becomes a `BaselineDoc`,
|
||||
/// whether it's about to be committed (`pin`) or dropped into `--out` (`run`).
|
||||
fn to_baseline_doc(m: &Measured, reps: u32, host: &baseline::HostInfo) -> BaselineDoc {
|
||||
BaselineDoc {
|
||||
surface: m.surface.clone(),
|
||||
metrics: m.metrics.clone(),
|
||||
fingerprint: m.fingerprint.clone(),
|
||||
reps,
|
||||
host: host.clone(),
|
||||
profile: "release".to_string(),
|
||||
commit: host::git_commit(),
|
||||
date: host::today(),
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if cfg!(debug_assertions) {
|
||||
eprintln!("aura-bench measures nothing in a debug build; run via `cargo run --release -p aura-bench`");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let cli = Cli::parse();
|
||||
let ws_root = child::workspace_root();
|
||||
let code = match cli.cmd {
|
||||
Cmd::Run { surface, quick, reps, out } => cmd_run(&ws_root, &surface, quick, reps, out),
|
||||
Cmd::Pin { surface, reps } => cmd_pin(&ws_root, &surface, reps),
|
||||
};
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
fn prepare(ws_root: &Path, filter: &[String]) -> Result<(Vec<&'static str>, PathBuf), String> {
|
||||
let names = selected(filter)?;
|
||||
let needs_bin = names.iter().any(|n| !LIBRARY_SURFACES.contains(n));
|
||||
let bin = if needs_bin {
|
||||
child::resolve_aura_bin(ws_root)?
|
||||
} else {
|
||||
PathBuf::from("aura-unused")
|
||||
};
|
||||
Ok((names, bin))
|
||||
}
|
||||
|
||||
fn banner(quick: bool) {
|
||||
let h = host::host_info();
|
||||
let load = host::loadavg_1m().map(|l| format!("{l:.1}")).unwrap_or_else(|| "?".to_string());
|
||||
let mode = if quick { " QUICK (mechanics only)" } else { "" };
|
||||
println!("aura-bench: host {} ({} cores), load {load}{mode}", h.hostname, h.nproc);
|
||||
if let Some(l) = host::loadavg_1m()
|
||||
&& l > LOAD_WARN_1M
|
||||
{
|
||||
println!("WARNING: loadavg {l:.1} > {LOAD_WARN_1M} — measurements belong on a quiet box");
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_run(
|
||||
ws_root: &Path,
|
||||
filter: &[String],
|
||||
quick: bool,
|
||||
reps: u32,
|
||||
out: Option<PathBuf>,
|
||||
) -> i32 {
|
||||
let (names, bin) = match prepare(ws_root, filter) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return infra(&e),
|
||||
};
|
||||
banner(quick);
|
||||
let sizing = Sizing { quick };
|
||||
let reps = if quick { 1 } else { reps };
|
||||
let dir = baselines_dir(ws_root);
|
||||
let this_host = host::host_info();
|
||||
let mut compared: Vec<Option<Compared>> = Vec::new();
|
||||
let mut host_mismatch = false;
|
||||
for name in &names {
|
||||
let m = match measure_surface(name, sizing, reps, &bin) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return infra(&format!("surface {name}: {e}")),
|
||||
};
|
||||
let b = if quick { None } else { load_baseline(&dir, name) };
|
||||
let c = b.as_ref().map(|b| {
|
||||
let c = compare(b, &m, &this_host);
|
||||
host_mismatch |= c.host_mismatch;
|
||||
c
|
||||
});
|
||||
println!("{}", report_line(&m, c.as_ref()));
|
||||
if let (Some(b), Some(c)) = (&b, &c)
|
||||
&& c.fingerprint_changed
|
||||
{
|
||||
println!(" baseline: {}", b.fingerprint);
|
||||
println!(" measured: {}", m.fingerprint);
|
||||
println!(
|
||||
" The workload computed different results. If a behaviour change is\n \
|
||||
intended, re-pin (aura-bench pin); otherwise this is a correctness bug."
|
||||
);
|
||||
}
|
||||
if let Some(out_dir) = &out
|
||||
&& let Err(e) = write_result(out_dir, &m, reps, &this_host)
|
||||
{
|
||||
return infra(&e);
|
||||
}
|
||||
compared.push(c);
|
||||
}
|
||||
if host_mismatch {
|
||||
println!("HOST MISMATCH (informational): baselines were pinned on a different host");
|
||||
}
|
||||
let notices: usize = compared.iter().flatten().map(|c| c.notices.len()).sum();
|
||||
if notices > 0 {
|
||||
println!(
|
||||
"{notices} NOTICE(s) (drift >= 10%) — report-only, nothing failed. Re-pin: aura-bench pin"
|
||||
);
|
||||
}
|
||||
exit_code(&compared)
|
||||
}
|
||||
|
||||
fn cmd_pin(ws_root: &Path, filter: &[String], reps: u32) -> i32 {
|
||||
let (names, bin) = match prepare(ws_root, filter) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return infra(&e),
|
||||
};
|
||||
banner(false);
|
||||
let dir = baselines_dir(ws_root);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
return infra(&format!("create baselines dir: {e}"));
|
||||
}
|
||||
let this_host = host::host_info();
|
||||
for name in &names {
|
||||
let m = match measure_surface(name, Sizing { quick: false }, reps, &bin) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return infra(&format!("surface {name}: {e}")),
|
||||
};
|
||||
let doc = to_baseline_doc(&m, reps, &this_host);
|
||||
let path = dir.join(format!("{name}.json"));
|
||||
let json = serde_json::to_string_pretty(&doc).expect("baseline serializes");
|
||||
if let Err(e) = std::fs::write(&path, json + "\n") {
|
||||
return infra(&format!("write {}: {e}", path.display()));
|
||||
}
|
||||
println!("pinned {name} -> {}", path.display());
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn write_result(out_dir: &Path, m: &Measured, reps: u32, h: &baseline::HostInfo) -> Result<(), String> {
|
||||
std::fs::create_dir_all(out_dir).map_err(|e| format!("create out dir: {e}"))?;
|
||||
let doc = to_baseline_doc(m, reps, h);
|
||||
let json = serde_json::to_string_pretty(&doc).expect("result serializes");
|
||||
std::fs::write(out_dir.join(format!("{}.json", m.surface)), json + "\n")
|
||||
.map_err(|e| format!("write result: {e}"))
|
||||
}
|
||||
|
||||
fn infra(msg: &str) -> i32 {
|
||||
eprintln!("aura-bench: infra error: {msg}");
|
||||
2
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn measured(surface: &str, metrics: &[(&str, f64)], fp: &str) -> Measured {
|
||||
Measured {
|
||||
surface: surface.to_string(),
|
||||
metrics: metrics.iter().map(|(k, v)| (k.to_string(), *v)).collect(),
|
||||
fingerprint: fp.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// selected() with no filter runs every registered surface, in registration order.
|
||||
#[test]
|
||||
fn selected_empty_filter_returns_all_surfaces() {
|
||||
assert_eq!(selected(&[]).unwrap(), SURFACES.to_vec());
|
||||
}
|
||||
|
||||
/// selected() with a known name narrows to exactly that surface.
|
||||
#[test]
|
||||
fn selected_known_name_returns_that_surface() {
|
||||
assert_eq!(selected(&["engine_throughput".to_string()]).unwrap(), vec!["engine_throughput"]);
|
||||
}
|
||||
|
||||
/// selected() rejects an unknown surface name instead of silently dropping it,
|
||||
/// so a `--surface` typo fails loudly rather than measuring nothing.
|
||||
#[test]
|
||||
fn selected_unknown_name_is_rejected() {
|
||||
let err = selected(&["not_a_surface".to_string()]).unwrap_err();
|
||||
assert!(err.contains("not_a_surface"), "error should name the bad surface: {err}");
|
||||
}
|
||||
|
||||
/// fmt_metric renders *_ms metrics with millisecond precision and unit suffix.
|
||||
#[test]
|
||||
fn fmt_metric_formats_ms_suffixed_metrics() {
|
||||
assert_eq!(fmt_metric("help_ms", 12.345), "help_ms 12.3ms");
|
||||
}
|
||||
|
||||
/// fmt_metric renders bars_per_s as a rounded integer (throughput, not fractional).
|
||||
#[test]
|
||||
fn fmt_metric_formats_bars_per_s_without_decimals() {
|
||||
assert_eq!(fmt_metric("bars_per_s", 812345.6), "bars_per_s 812346");
|
||||
}
|
||||
|
||||
/// fmt_metric renders wall_s with millisecond-precision seconds.
|
||||
#[test]
|
||||
fn fmt_metric_formats_wall_s_with_millis_precision() {
|
||||
assert_eq!(fmt_metric("wall_s", 1.23456), "wall_s 1.235s");
|
||||
}
|
||||
|
||||
/// fmt_metric falls back to 1-decimal generic formatting for any other metric name.
|
||||
#[test]
|
||||
fn fmt_metric_falls_back_to_generic_formatting() {
|
||||
assert_eq!(fmt_metric("peak_rss_mb", 42.16), "peak_rss_mb 42.2");
|
||||
}
|
||||
|
||||
/// report_line without a baseline tells the operator to pin one, rather than
|
||||
/// implying a comparison happened.
|
||||
#[test]
|
||||
fn report_line_without_baseline_prompts_to_pin() {
|
||||
let m = measured("s", &[("wall_s", 1.0)], "fp");
|
||||
let line = report_line(&m, None);
|
||||
assert!(line.contains("no baseline (pin to create)"), "{line}");
|
||||
}
|
||||
|
||||
/// report_line surfaces a fingerprint mismatch distinctly from ordinary timing
|
||||
/// drift — this is the correctness-vs-performance line the whole harness rests on.
|
||||
#[test]
|
||||
fn report_line_flags_fingerprint_change() {
|
||||
let m = measured("s", &[("wall_s", 1.0)], "new");
|
||||
let c = Compared { deltas: vec![], notices: vec![], fingerprint_changed: true, host_mismatch: false };
|
||||
let line = report_line(&m, Some(&c));
|
||||
assert!(line.contains("fingerprint CHANGED"), "{line}");
|
||||
}
|
||||
|
||||
/// report_line prints per-metric drift percentages and an OK fingerprint when
|
||||
/// nothing changed.
|
||||
#[test]
|
||||
fn report_line_reports_drift_and_fingerprint_ok() {
|
||||
let m = measured("s", &[("wall_s", 1.0)], "fp");
|
||||
let c = Compared {
|
||||
deltas: vec![("wall_s".to_string(), 0.05)],
|
||||
notices: vec![],
|
||||
fingerprint_changed: false,
|
||||
host_mismatch: false,
|
||||
};
|
||||
let line = report_line(&m, Some(&c));
|
||||
assert!(line.contains("wall_s +5.0%"), "{line}");
|
||||
assert!(line.contains("fingerprint OK"), "{line}");
|
||||
}
|
||||
|
||||
/// `prepare` only resolves the real release `aura` binary when a selected
|
||||
/// surface actually spawns a child process; a library-only selection
|
||||
/// (`engine_throughput`/`ingest_throughput`) must never touch the
|
||||
/// filesystem to build or locate it. Protects the fast-iteration path:
|
||||
/// a regression here would force a multi-second `cargo build --release`
|
||||
/// onto every `aura-bench run --surface engine_throughput`, defeating
|
||||
/// the point of the in-process surfaces. Proven by using a nonexistent
|
||||
/// `ws_root` — `resolve_aura_bin` would error immediately if `prepare`
|
||||
/// ever called it for this filter.
|
||||
/// Pins the hand-synced `LIBRARY_SURFACES` list against `measure_surface`
|
||||
/// itself: every listed name must be a registered surface AND actually
|
||||
/// measurable without a real binary path (quick mode, 1 rep). A surface
|
||||
/// added to the list without being binary-free fails here — the silent
|
||||
/// failure mode would otherwise be a needless release build on every
|
||||
/// library-only invocation.
|
||||
#[test]
|
||||
fn library_surfaces_measure_without_the_binary() {
|
||||
for name in LIBRARY_SURFACES {
|
||||
assert!(SURFACES.contains(name), "{name} must be a registered surface");
|
||||
let fake_bin = Path::new("/nonexistent-aura-binary-for-library-surface-test");
|
||||
measure_surface(name, Sizing { quick: true }, 1, fake_bin)
|
||||
.unwrap_or_else(|e| panic!("library surface {name} must run without the binary: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_skips_binary_resolution_for_library_only_surfaces() {
|
||||
let fake_root = Path::new("/nonexistent-ws-root-for-aura-bench-test");
|
||||
let filter = vec!["engine_throughput".to_string(), "ingest_throughput".to_string()];
|
||||
let (names, bin) = prepare(fake_root, &filter).expect("must not try to resolve a binary");
|
||||
assert_eq!(names, vec!["engine_throughput", "ingest_throughput"]);
|
||||
assert_eq!(bin, PathBuf::from("aura-unused"));
|
||||
}
|
||||
|
||||
/// `write_result`'s `--out` artifact is schema-identical to a committed
|
||||
/// baseline (round-trips through `BaselineDoc`) — the property that makes
|
||||
/// a `--quick --out` result directly promotable/comparable, never a
|
||||
/// shadow format that silently drifts from what `pin` writes.
|
||||
#[test]
|
||||
fn write_result_round_trips_measured_into_baseline_shaped_json() {
|
||||
let scratch = crate::synth::ScratchDir::new("write-result-test").expect("scratch dir");
|
||||
let m = measured("cli_fixed_cost", &[("help_ms", 1.5), ("run_ms", 3.0)], "run_line_fnv=abc");
|
||||
let host = baseline::HostInfo { hostname: "h".to_string(), nproc: 8 };
|
||||
write_result(&scratch.0, &m, 2, &host).expect("write_result must succeed");
|
||||
let doc = load_baseline(&scratch.0, "cli_fixed_cost")
|
||||
.expect("write_result's file must be a loadable BaselineDoc");
|
||||
assert_eq!(doc.surface, "cli_fixed_cost");
|
||||
assert_eq!(doc.metrics, m.metrics);
|
||||
assert_eq!(doc.fingerprint, "run_line_fnv=abc");
|
||||
assert_eq!(doc.reps, 2);
|
||||
assert_eq!(doc.profile, "release");
|
||||
}
|
||||
|
||||
/// The full CLI driver glue's core promise: `pin` then `run` against the
|
||||
/// baseline it just wrote reports zero fingerprint drift — a scratch
|
||||
/// `ws_root` keeps this fully isolated from the real committed
|
||||
/// `baselines/` directory (never touches it). If `cmd_pin` and `cmd_run`
|
||||
/// ever disagreed on what a surface measures (e.g. different `Sizing`,
|
||||
/// different metric keys), a fresh `pin` would immediately fail its own
|
||||
/// `run` — exactly the workflow the README documents ("re-pin, the
|
||||
/// baseline diff is reviewed like any other commit").
|
||||
///
|
||||
/// Ignored in the default suite: it runs the engine surface at full size
|
||||
/// (1,000,000 bars, twice) in an unoptimized `dev`-profile test binary —
|
||||
/// the suite-wallclock discipline reserves full-size runs for release
|
||||
/// builds. Run it explicitly: `cargo test -p aura-bench -- --ignored`.
|
||||
#[test]
|
||||
#[ignore = "full-size (1M bar) engine surface in a dev-profile build; run via -- --ignored"]
|
||||
fn pin_then_run_round_trip_reports_zero_drift() {
|
||||
let scratch = crate::synth::ScratchDir::new("pin-run-roundtrip").expect("scratch dir");
|
||||
let filter = vec!["engine_throughput".to_string()];
|
||||
let pin_code = cmd_pin(&scratch.0, &filter, 1);
|
||||
assert_eq!(pin_code, 0, "pin must succeed against an isolated scratch ws_root");
|
||||
let run_code = cmd_run(&scratch.0, &filter, false, 1, None);
|
||||
assert_eq!(run_code, 0, "running right after pinning must report zero fingerprint drift");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
//! The two campaign surfaces: `campaign_sweep` (narrow per-cell fan-out — a
|
||||
//! sweep-only process) and `campaign_heavy` (wide fan-out — sweep → gate →
|
||||
//! walk-forward → bootstrap), both run as the release `aura` binary on a
|
||||
//! scratch data-only project over synthetic archives. Fingerprint: the winner
|
||||
//! ordinals extracted from the always-on final `{"campaign_run":...}` stdout
|
||||
//! line. One campaign shape misreads regressions in the other (#251), so both
|
||||
//! are pinned.
|
||||
|
||||
use super::{RepOutcome, Sizing};
|
||||
use crate::child::{run_timed, TimedChild};
|
||||
use crate::synth::{write_symbol_archive, ScratchDir};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
pub const FULL_INSTRUMENTS: &[&str] = &["BSYMA", "BSYMB", "BSYMC", "BSYMD"];
|
||||
pub const QUICK_INSTRUMENTS: &[&str] = &["BSYMA", "BSYMB"];
|
||||
/// Full-size span: 24 archive months with a ~22-month campaign window
|
||||
/// (2024-02-01 .. 2025-11-30 UTC), sized so the measured child runs land in
|
||||
/// whole seconds — sub-second walls put ordinary spawn/scheduler jitter within
|
||||
/// reach of the 10% NOTICE threshold and make the report flap.
|
||||
pub const ARCHIVE_MONTHS_FULL: ((i32, u32), (i32, u32)) = ((2024, 1), (2025, 12));
|
||||
pub const WINDOW_MS_FULL: (i64, i64) = (1706745600000, 1764547199999);
|
||||
/// Quick span: the green E2E fixture's shape (archive 2024-01..08, window
|
||||
/// Mar-01..Jun-30 — ~17 weeks gives the (14d, 7d, 7d) roller a comfortable
|
||||
/// tiling, research_docs.rs:2440-2441).
|
||||
pub const ARCHIVE_MONTHS_QUICK: ((i32, u32), (i32, u32)) = ((2024, 1), (2024, 8));
|
||||
pub const WINDOW_MS_QUICK: (i64, i64) = (1709251200000, 1719791999999);
|
||||
|
||||
pub const SWEEP_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "bench-sweep-only",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// The heavy shape: the E2E walk-forward process with the bootstrap sized up
|
||||
/// (1000 resamples — the #277 measured workload's count).
|
||||
pub const HEAVY_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "bench-heavy",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
|
||||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
|
||||
{ "block": "std::monte_carlo", "resamples": 1000, "block_len": 5 }
|
||||
]
|
||||
}"#;
|
||||
|
||||
pub const HEAVY_PROCESS_DOC_QUICK: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "bench-heavy",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
|
||||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
|
||||
{ "block": "std::monte_carlo", "resamples": 50, "block_len": 5 }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// The two SMA strategy blueprints: byte-identical to the shipped
|
||||
/// `crates/aura-cli/examples/r_sma.json` except the second variant's bound
|
||||
/// lengths (3/6 instead of 2/4), which gives it a distinct content id — two
|
||||
/// strategies, same axes surface.
|
||||
pub const BP_SMA_A: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
pub const BP_SMA_B: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":6}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
fn run_in(bin: &Path, dir: &Path, args: &[&str]) -> Result<(String, Option<i32>), String> {
|
||||
let out = std::process::Command::new(bin)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("spawn aura {args:?}: {e}"))?;
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
Ok((text, out.status.code()))
|
||||
}
|
||||
|
||||
/// Blueprint stems currently in the scratch store.
|
||||
fn blueprint_stems(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir.join("runs").join("blueprints"))
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().into_owned()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the
|
||||
/// E2E `seed_blueprint` pattern) and return its content id.
|
||||
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
|
||||
let before = blueprint_stems(dir);
|
||||
let (out, code) = run_in(
|
||||
bin,
|
||||
dir,
|
||||
&[
|
||||
"sweep",
|
||||
file,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
name,
|
||||
],
|
||||
)?;
|
||||
if code != Some(0) {
|
||||
return Err(format!("seed sweep failed ({code:?}): {out}"));
|
||||
}
|
||||
blueprint_stems(dir)
|
||||
.into_iter()
|
||||
.find(|s| !before.contains(s))
|
||||
.ok_or_else(|| "seed sweep must store a new blueprint".to_string())
|
||||
}
|
||||
|
||||
fn register_process(bin: &Path, dir: &Path, file: &str, doc: &str) -> Result<String, String> {
|
||||
std::fs::write(dir.join(file), doc).map_err(|e| format!("write {file}: {e}"))?;
|
||||
let (out, code) = run_in(bin, dir, &["process", "register", file])?;
|
||||
if code != Some(0) {
|
||||
return Err(format!("process register failed ({code:?}): {out}"));
|
||||
}
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("registered process "))
|
||||
.map(|l| {
|
||||
l.trim_start_matches("registered process ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("split yields at least one piece")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
})
|
||||
.ok_or_else(|| format!("no register line in: {out}"))
|
||||
}
|
||||
|
||||
fn campaign_doc(instruments: &[&str], bp_ids: &[String], proc_id: &str, quick: bool) -> String {
|
||||
let instruments_json =
|
||||
instruments.iter().map(|i| format!("\"{i}\"")).collect::<Vec<_>>().join(", ");
|
||||
// 5x5 member grid at full size (the workload lever), the E2E-shaped 3x3
|
||||
// in quick mode (mechanics only).
|
||||
let (fast_vals, slow_vals) = if quick {
|
||||
("[2, 3, 4]", "[8, 12, 16]")
|
||||
} else {
|
||||
("[2, 3, 4, 5, 6]", "[8, 10, 12, 14, 16]")
|
||||
};
|
||||
let window = if quick { WINDOW_MS_QUICK } else { WINDOW_MS_FULL };
|
||||
let strategies_json = bp_ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
format!(
|
||||
r#"{{ "ref": {{ "content_id": "{id}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": {fast_vals} }},
|
||||
"slow.length": {{ "kind": "I64", "values": {slow_vals} }} }} }}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n ");
|
||||
format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "bench",
|
||||
"data": {{ "instruments": [{instruments_json}], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||
"strategies": [ {strategies_json} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": [] }}
|
||||
}}"#,
|
||||
from = window.0,
|
||||
to = window.1,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a fresh scratch campaign project and return (scratch, campaign doc
|
||||
/// file name). Setup, unmeasured.
|
||||
fn build_scratch(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<(ScratchDir, String), String> {
|
||||
let scratch = ScratchDir::new("campaign").map_err(|e| format!("scratch dir: {e}"))?;
|
||||
let root = &scratch.0;
|
||||
std::fs::write(root.join("Aura.toml"), "[paths]\nruns = \"runs\"\ndata = \"data\"\n")
|
||||
.map_err(|e| format!("write Aura.toml: {e}"))?;
|
||||
let data_dir = root.join("data");
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create data dir: {e}"))?;
|
||||
let instruments = if sizing.quick { QUICK_INSTRUMENTS } else { FULL_INSTRUMENTS };
|
||||
let months = if sizing.quick { ARCHIVE_MONTHS_QUICK } else { ARCHIVE_MONTHS_FULL };
|
||||
for sym in instruments {
|
||||
write_symbol_archive(&data_dir, sym, months.0, months.1);
|
||||
}
|
||||
std::fs::write(root.join("bench_sma_a.json"), BP_SMA_A)
|
||||
.map_err(|e| format!("write blueprint a: {e}"))?;
|
||||
let mut bp_ids = vec![seed_blueprint(bin, root, "bench_sma_a.json", "seed-a")?];
|
||||
if !sizing.quick {
|
||||
std::fs::write(root.join("bench_sma_b.json"), BP_SMA_B)
|
||||
.map_err(|e| format!("write blueprint b: {e}"))?;
|
||||
bp_ids.push(seed_blueprint(bin, root, "bench_sma_b.json", "seed-b")?);
|
||||
}
|
||||
let proc_id = register_process(bin, root, "bench.process.json", process_doc)?;
|
||||
let doc = campaign_doc(instruments, &bp_ids, &proc_id, sizing.quick);
|
||||
std::fs::write(root.join("bench.campaign.json"), doc)
|
||||
.map_err(|e| format!("write campaign doc: {e}"))?;
|
||||
Ok((scratch, "bench.campaign.json".to_string()))
|
||||
}
|
||||
|
||||
/// Extract the determinism fingerprint from the always-on final
|
||||
/// `{"campaign_run":...}` stdout line: per stage the selection's winner
|
||||
/// ordinal (`w`), the survivor-ordinal list (`v[..]`), and the bootstrap's
|
||||
/// trade count (`b`, pooled or summed across per-survivor entries). A
|
||||
/// wrong-result regression in the sweep, gate, or bootstrap machinery
|
||||
/// therefore fails the bench — not just a wrong sweep winner. The
|
||||
/// walk-forward stage currently emits an empty realization record
|
||||
/// (no selection, no survivors, no bootstrap), so it contributes nothing
|
||||
/// directly and is covered only indirectly, through the bootstrap trade
|
||||
/// count that depends on its out-of-sample output (coverage gap parked on
|
||||
/// the tracker, #279).
|
||||
pub fn winner_fingerprint(stdout: &str) -> Result<String, String> {
|
||||
let line = stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||||
.ok_or("no campaign_run record line on stdout")?;
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| format!("campaign_run line parses: {e}"))?;
|
||||
let cells = v["campaign_run"]["cells"]
|
||||
.as_array()
|
||||
.ok_or("campaign_run.cells is an array")?;
|
||||
let mut parts = Vec::new();
|
||||
for (ci, cell) in cells.iter().enumerate() {
|
||||
let stages = cell["stages"].as_array().ok_or("cell.stages is an array")?;
|
||||
for (si, stage) in stages.iter().enumerate() {
|
||||
if let Some(w) = stage["selection"]["winner_ordinal"].as_u64() {
|
||||
parts.push(format!("c{ci}s{si}w{w}"));
|
||||
}
|
||||
if let Some(surv) = stage["survivor_ordinals"].as_array() {
|
||||
let list = surv
|
||||
.iter()
|
||||
.filter_map(|x| x.as_u64())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".");
|
||||
parts.push(format!("c{ci}s{si}v[{list}]"));
|
||||
}
|
||||
if let Some(n) = stage["bootstrap"]["pooled_oos"]["n_trades"].as_u64() {
|
||||
parts.push(format!("c{ci}s{si}b{n}"));
|
||||
} else if let Some(per) = stage["bootstrap"]["per_survivor"].as_array() {
|
||||
let n: u64 = per.iter().filter_map(|pair| pair[1]["n_trades"].as_u64()).sum();
|
||||
parts.push(format!("c{ci}s{si}b{n}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Err("no winner ordinals in the campaign_run record".to_string());
|
||||
}
|
||||
Ok(format!("cells={} {}", cells.len(), parts.join(" ")))
|
||||
}
|
||||
|
||||
fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
|
||||
let (scratch, doc) = build_scratch(bin, sizing, process_doc)?;
|
||||
let timed: TimedChild = run_timed(bin, &["campaign", "run", &doc], &scratch.0)?;
|
||||
if timed.exit != Some(0) {
|
||||
return Err(format!(
|
||||
"campaign run exited {:?}\nstdout: {}\nstderr: {}",
|
||||
timed.exit, timed.stdout, timed.stderr
|
||||
));
|
||||
}
|
||||
let fingerprint = winner_fingerprint(&timed.stdout)?;
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("wall_s".to_string(), timed.wall_s);
|
||||
if let Some(c) = timed.cpu_percent {
|
||||
metrics.insert("cpu_percent".to_string(), c);
|
||||
}
|
||||
if let Some(r) = timed.peak_rss_mb {
|
||||
metrics.insert("peak_rss_mb".to_string(), r);
|
||||
}
|
||||
Ok(RepOutcome { metrics, fingerprint })
|
||||
}
|
||||
|
||||
pub fn sweep_rep(bin: &Path, sizing: Sizing) -> Result<RepOutcome, String> {
|
||||
campaign_rep(bin, sizing, SWEEP_PROCESS_DOC)
|
||||
}
|
||||
|
||||
pub fn heavy_rep(bin: &Path, sizing: Sizing) -> Result<RepOutcome, String> {
|
||||
let doc = if sizing.quick { HEAVY_PROCESS_DOC_QUICK } else { HEAVY_PROCESS_DOC };
|
||||
campaign_rep(bin, sizing, doc)
|
||||
}
|
||||
|
||||
/// Used by `cli_fixed_cost`: a minimal one-symbol scratch project.
|
||||
pub fn build_minimal_project() -> Result<ScratchDir, String> {
|
||||
let scratch = ScratchDir::new("fixedcost").map_err(|e| format!("scratch dir: {e}"))?;
|
||||
let root = &scratch.0;
|
||||
std::fs::write(root.join("Aura.toml"), "[paths]\nruns = \"runs\"\ndata = \"data\"\n")
|
||||
.map_err(|e| format!("write Aura.toml: {e}"))?;
|
||||
let data_dir = root.join("data");
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create data dir: {e}"))?;
|
||||
write_symbol_archive(&data_dir, "BSYMA", (2024, 1), (2024, 1));
|
||||
std::fs::write(root.join("bench_sma_a.json"), BP_SMA_A)
|
||||
.map_err(|e| format!("write blueprint: {e}"))?;
|
||||
Ok(scratch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn winner_fingerprint_extracts_ordinals_from_a_record_line() {
|
||||
let stdout = "noise\n{\"campaign_run\":{\"campaign\":\"c\",\"process\":\"p\",\"run\":0,\"seed\":7,\
|
||||
\"cells\":[{\"strategy\":\"s\",\"instrument\":\"I\",\"window_ms\":[1,2],\
|
||||
\"stages\":[{\"block\":\"std::sweep\",\"selection\":{\"winner_ordinal\":4,\"params\":[],\
|
||||
\"selection\":\"argmax\"}},{\"block\":\"std::gate\",\"survivor_ordinals\":[0,2]},\
|
||||
{\"block\":\"std::monte_carlo\",\"bootstrap\":{\"pooled_oos\":{\"n_trades\":42}}}]}]}}\n";
|
||||
let fp = winner_fingerprint(stdout).expect("parses");
|
||||
assert_eq!(fp, "cells=1 c0s0w4 c0s1v[0.2] c0s2b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winner_fingerprint_sums_per_survivor_bootstrap_trades() {
|
||||
let stdout = "{\"campaign_run\":{\"campaign\":\"c\",\"process\":\"p\",\"run\":0,\"seed\":7,\
|
||||
\"cells\":[{\"strategy\":\"s\",\"instrument\":\"I\",\"window_ms\":[1,2],\
|
||||
\"stages\":[{\"block\":\"std::monte_carlo\",\"bootstrap\":{\"per_survivor\":\
|
||||
[[0,{\"n_trades\":10}],[3,{\"n_trades\":5}]]}}]}]}}\n";
|
||||
let fp = winner_fingerprint(stdout).expect("parses");
|
||||
assert_eq!(fp, "cells=1 c0s0b15");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winner_fingerprint_rejects_missing_record_line() {
|
||||
assert!(winner_fingerprint("no record here\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_doc_lists_all_instruments_and_strategies() {
|
||||
for quick in [true, false] {
|
||||
let doc = campaign_doc(
|
||||
&["BSYMA", "BSYMB"],
|
||||
&["id-a".to_string(), "id-b".to_string()],
|
||||
"proc-id",
|
||||
quick,
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_str(&doc).expect("doc is valid JSON");
|
||||
assert_eq!(v["data"]["instruments"].as_array().expect("instruments").len(), 2);
|
||||
assert_eq!(v["strategies"].as_array().expect("strategies").len(), 2);
|
||||
assert_eq!(v["seed"], 7);
|
||||
assert_eq!(v["process"]["ref"]["content_id"], "proc-id");
|
||||
let grid = v["strategies"][0]["axes"]["fast.length"]["values"]
|
||||
.as_array()
|
||||
.expect("fast axis")
|
||||
.len()
|
||||
* v["strategies"][0]["axes"]["slow.length"]["values"]
|
||||
.as_array()
|
||||
.expect("slow axis")
|
||||
.len();
|
||||
assert_eq!(grid, if quick { 9 } else { 25 }, "member grid, quick={quick}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lockstep guard for the stdout-schema coupling: build a REAL
|
||||
/// `CampaignRunRecord` out of the aura-registry / aura-analysis /
|
||||
/// aura-engine types the CLI serializes, wrap it the way the record line
|
||||
/// is printed, and assert `winner_fingerprint` extracts every layer. A
|
||||
/// field rename in any of those crates now breaks this test (compile or
|
||||
/// assert) instead of silently eroding fingerprint coverage through the
|
||||
/// lenient JSON parse.
|
||||
#[test]
|
||||
fn winner_fingerprint_stays_in_lockstep_with_the_registry_record_types() {
|
||||
use aura_analysis::{FamilySelection, SelectionMode};
|
||||
use aura_backtest::RBootstrap;
|
||||
use aura_engine::MetricStats;
|
||||
use aura_registry::{CampaignRunRecord, CellRealization, StageRealization, StageSelection};
|
||||
|
||||
let stats = MetricStats { mean: 0.1, p5: 0.0, p25: 0.05, p50: 0.1, p75: 0.15, p95: 0.2 };
|
||||
let record = CampaignRunRecord {
|
||||
campaign: "camp-id".into(),
|
||||
process: "proc-id".into(),
|
||||
run: 0,
|
||||
seed: 7,
|
||||
cells: vec![CellRealization {
|
||||
strategy: "bp-id".into(),
|
||||
instrument: "BSYMA".into(),
|
||||
window_ms: (1, 2),
|
||||
stages: vec![
|
||||
StageRealization {
|
||||
block: "std::sweep".into(),
|
||||
family_id: None,
|
||||
survivor_ordinals: None,
|
||||
selection: Some(StageSelection {
|
||||
winner_ordinal: 4,
|
||||
params: vec![],
|
||||
selection: FamilySelection {
|
||||
selection_metric: "sqn_normalized".into(),
|
||||
n_trials: 25,
|
||||
raw_winner_metric: 0.5,
|
||||
mode: SelectionMode::Argmax,
|
||||
deflated_score: None,
|
||||
overfit_probability: None,
|
||||
n_resamples: None,
|
||||
block_len: None,
|
||||
seed: None,
|
||||
neighbourhood_score: None,
|
||||
n_neighbours: None,
|
||||
},
|
||||
}),
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
},
|
||||
StageRealization {
|
||||
block: "std::gate".into(),
|
||||
family_id: None,
|
||||
survivor_ordinals: Some(vec![0, 2]),
|
||||
selection: None,
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
},
|
||||
StageRealization {
|
||||
block: "std::monte_carlo".into(),
|
||||
family_id: None,
|
||||
survivor_ordinals: None,
|
||||
selection: None,
|
||||
bootstrap: Some(aura_registry::StageBootstrap::PooledOos(RBootstrap {
|
||||
e_r: stats,
|
||||
prob_le_zero: 0.3,
|
||||
n_trades: 42,
|
||||
block_len: 5,
|
||||
n_resamples: 1000,
|
||||
})),
|
||||
window_faults: vec![],
|
||||
},
|
||||
],
|
||||
regime: None,
|
||||
regime_ordinal: 0,
|
||||
fault: None,
|
||||
coverage: None,
|
||||
}],
|
||||
generalizations: vec![],
|
||||
trace_name: None,
|
||||
};
|
||||
let line = format!(
|
||||
"{{\"campaign_run\":{}}}",
|
||||
serde_json::to_string(&record).expect("record serializes")
|
||||
);
|
||||
let fp = winner_fingerprint(&line).expect("the real record shape must parse");
|
||||
assert_eq!(fp, "cells=1 c0s0w4 c0s1v[0.2] c0s2b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_docs_are_valid_json() {
|
||||
for doc in [SWEEP_PROCESS_DOC, HEAVY_PROCESS_DOC, HEAVY_PROCESS_DOC_QUICK] {
|
||||
let v: serde_json::Value = serde_json::from_str(doc).expect("process doc parses");
|
||||
assert_eq!(v["kind"], "process");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Engine throughput: bars/s through a bootstrapped harness — an SMA-cross
|
||||
//! bias graph fed by the seeded synthetic walk via `VecSource`. Fingerprint:
|
||||
//! the `SeriesReducer` finalize row `[last, max_drawdown, sign_flips]`.
|
||||
|
||||
use super::{RepOutcome, Sizing};
|
||||
use crate::synth::synthetic_walk;
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{GraphBuilder, Harness, Source, VecSource};
|
||||
use aura_std::{SeriesReducer, Sma, Sub};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// 10M bars lands the release-profile measurement at ~0.7 s wall — big enough
|
||||
/// that in-process timing jitter sits far under the 10% NOTICE threshold.
|
||||
pub const FULL_BARS: usize = 10_000_000;
|
||||
pub const QUICK_BARS: usize = 2_000;
|
||||
|
||||
pub fn rep(sizing: Sizing) -> Result<RepOutcome, String> {
|
||||
let n_bars = if sizing.quick { QUICK_BARS } else { FULL_BARS };
|
||||
let (tx, rx) = mpsc::channel::<(Timestamp, Vec<Scalar>)>();
|
||||
let mut g = GraphBuilder::new("bench_sma_cross");
|
||||
let fast = g.add(Sma::builder().bind("length", Scalar::i64(12)));
|
||||
let slow = g.add(Sma::builder().bind("length", Scalar::i64(48)));
|
||||
let bias = g.add(Sub::builder());
|
||||
let sink = g.add(SeriesReducer::builder(Firing::Any, tx));
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(price, [fast.input("series"), slow.input("series")]);
|
||||
g.connect(fast.output("value"), bias.input("lhs"));
|
||||
g.connect(slow.output("value"), bias.input("rhs"));
|
||||
g.connect(bias.output("value"), sink.input("col[0]"));
|
||||
let flat = g
|
||||
.build()
|
||||
.map_err(|e| format!("bench graph must wire: {e:?}"))?
|
||||
.compile_with_params(&[])
|
||||
.map_err(|e| format!("bench graph must compile: {e:?}"))?;
|
||||
let mut h = Harness::bootstrap(flat).map_err(|e| format!("bench graph must bootstrap: {e:?}"))?;
|
||||
let prices = synthetic_walk(n_bars);
|
||||
let src: Vec<Box<dyn Source>> = vec![Box::new(VecSource::new(prices))];
|
||||
let t = Instant::now();
|
||||
h.run(src);
|
||||
let wall = t.elapsed().as_secs_f64();
|
||||
let (_, row) = rx.try_iter().last().ok_or("SeriesReducer must emit its finalize row")?;
|
||||
let fingerprint = format!(
|
||||
"last={:.6} max_dd={:.6} flips={} n={}",
|
||||
row[0].as_f64(),
|
||||
row[1].as_f64(),
|
||||
row[2].as_i64(),
|
||||
n_bars
|
||||
);
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("wall_s".to_string(), wall);
|
||||
metrics.insert("bars_per_s".to_string(), n_bars as f64 / wall);
|
||||
Ok(RepOutcome { metrics, fingerprint })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The determinism smoke for the engine surface: two quick reps in-process
|
||||
/// must produce byte-identical fingerprints (C1 — this is the harness's
|
||||
/// entire premise, pinned as a currently-green test).
|
||||
#[test]
|
||||
fn engine_surface_fingerprint_is_deterministic_in_quick_mode() {
|
||||
let a = rep(Sizing { quick: true }).expect("quick rep runs");
|
||||
let b = rep(Sizing { quick: true }).expect("quick rep runs");
|
||||
assert_eq!(a.fingerprint, b.fingerprint);
|
||||
assert!(a.fingerprint.starts_with("last="), "shape: {}", a.fingerprint);
|
||||
assert!(a.metrics.contains_key("bars_per_s"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
|
||||
//! computed) and a minimal data-only project run whose fingerprint is the
|
||||
//! FNV-1a hash of the run's single stdout JSON line. Fresh scratch project per
|
||||
//! repetition, so store counters start identical.
|
||||
|
||||
use super::{median, RepOutcome};
|
||||
use crate::child::run_timed;
|
||||
use crate::surfaces::campaign::build_minimal_project;
|
||||
use crate::synth::fnv1a;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Fingerprint of an `aura run` record line, invariant across rebuilds: the
|
||||
/// line's `manifest.commit` is the aura binary's compile-time build sha
|
||||
/// (crates/aura-cli/build.rs — the invariant-8 audit trail), so hashing the
|
||||
/// raw line would flip the fingerprint on EVERY new commit and report a
|
||||
/// phantom correctness change at each cycle close. Parse, blank that one
|
||||
/// volatile field, re-serialize (serde_json's map is sorted — deterministic
|
||||
/// bytes), then hash.
|
||||
pub fn run_line_fingerprint(line: &str) -> Result<String, String> {
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| format!("run record line parses: {e}"))?;
|
||||
let commit = v
|
||||
.get_mut("manifest")
|
||||
.and_then(|m| m.get_mut("commit"))
|
||||
.ok_or("run record line carries manifest.commit")?;
|
||||
*commit = serde_json::Value::String(String::new());
|
||||
let canonical = serde_json::to_string(&v).expect("re-serializing a parsed Value cannot fail");
|
||||
Ok(format!("run_line_fnv={:016x}", fnv1a(canonical.as_bytes())))
|
||||
}
|
||||
|
||||
/// Spawn samples folded into one repetition: single-digit-millisecond spawns
|
||||
/// jitter well past the 10% NOTICE threshold, so each repetition reports the
|
||||
/// median over a batch instead of one throw of the scheduler dice.
|
||||
pub const HELP_SAMPLES: u32 = 20;
|
||||
pub const RUN_SAMPLES: u32 = 10;
|
||||
|
||||
pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
|
||||
// Spawn floor: median wall over a batch of `aura --help` spawns.
|
||||
let mut help_walls = Vec::new();
|
||||
for _ in 0..HELP_SAMPLES {
|
||||
let t = Instant::now();
|
||||
let help = std::process::Command::new(bin)
|
||||
.arg("--help")
|
||||
.output()
|
||||
.map_err(|e| format!("spawn aura --help: {e}"))?;
|
||||
help_walls.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if !help.status.success() {
|
||||
return Err(format!("aura --help exited {:?}", help.status.code()));
|
||||
}
|
||||
}
|
||||
|
||||
// Project-load + minimal run: one scratch project, a batch of runs, the
|
||||
// median wall. The fingerprint is the FIRST run's record line — later
|
||||
// runs append to the same store, so their lines legitimately differ (run
|
||||
// counters), while the first run of a fresh scratch is deterministic.
|
||||
let scratch = build_minimal_project()?;
|
||||
let mut run_walls = Vec::new();
|
||||
let mut first_line = None;
|
||||
for _ in 0..RUN_SAMPLES {
|
||||
let timed = run_timed(bin, &["run", "bench_sma_a.json"], &scratch.0)?;
|
||||
if timed.exit != Some(0) {
|
||||
return Err(format!("aura run exited {:?}: {}", timed.exit, timed.stdout));
|
||||
}
|
||||
run_walls.push(timed.wall_s * 1000.0);
|
||||
if first_line.is_none() {
|
||||
first_line = Some(
|
||||
timed
|
||||
.stdout
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or("aura run must print its JSON record line")?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let line = first_line.expect("RUN_SAMPLES >= 1 sets the first line");
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("help_ms".to_string(), median(help_walls));
|
||||
metrics.insert("run_ms".to_string(), median(run_walls));
|
||||
Ok(RepOutcome { metrics, fingerprint: run_line_fingerprint(&line)? })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The symptom this fixes, pinned: two record lines that differ ONLY in
|
||||
/// `manifest.commit` (a rebuild at a new commit) must fingerprint
|
||||
/// identically — the raw-line hash flipped here and reported a phantom
|
||||
/// correctness change on the first post-commit bench run.
|
||||
#[test]
|
||||
fn run_line_fingerprint_is_invariant_across_build_commits() {
|
||||
let at_a = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.5}}"#;
|
||||
let at_b = r#"{"manifest":{"commit":"bbbb","seed":0},"metrics":{"total_pips":0.5}}"#;
|
||||
assert_eq!(
|
||||
run_line_fingerprint(at_a).expect("parses"),
|
||||
run_line_fingerprint(at_b).expect("parses"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The fingerprint still catches what it exists for: a computed-result
|
||||
/// change (any non-provenance byte) yields a different hash.
|
||||
#[test]
|
||||
fn run_line_fingerprint_still_detects_result_changes() {
|
||||
let base = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.5}}"#;
|
||||
let drifted = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.6}}"#;
|
||||
assert_ne!(
|
||||
run_line_fingerprint(base).expect("parses"),
|
||||
run_line_fingerprint(drifted).expect("parses"),
|
||||
);
|
||||
}
|
||||
|
||||
/// A line without the provenance field is malformed input, not a silent
|
||||
/// pass — the guard that keeps the blanking honest if the record schema
|
||||
/// ever moves.
|
||||
#[test]
|
||||
fn run_line_fingerprint_rejects_a_line_without_manifest_commit() {
|
||||
assert!(run_line_fingerprint(r#"{"metrics":{"total_pips":0.5}}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Ingest throughput: bars/s draining one field column out of a synthetic
|
||||
//! 48-byte M1 zip archive through the public ingest window API. A FRESH
|
||||
//! `DataServer` per repetition, so the measured drain pays zip inflate +
|
||||
//! record parse instead of the server's in-memory cache; the discarded warmup
|
||||
//! rep leaves the OS page cache warm and consistent.
|
||||
|
||||
use super::{RepOutcome, Sizing};
|
||||
use crate::synth::{write_symbol_archive, ScratchDir};
|
||||
use aura_ingest::{open_columns, DataServer, M1Field};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
pub const SYMBOL: &str = "BENCHI";
|
||||
pub const FULL_MONTHS: ((i32, u32), (i32, u32)) = ((2024, 1), (2025, 12));
|
||||
pub const QUICK_MONTHS: ((i32, u32), (i32, u32)) = ((2024, 1), (2024, 2));
|
||||
|
||||
/// The archive is written once per surface run (setup, unmeasured) and shared
|
||||
/// by every repetition. `passes` fresh drains per repetition lift the measured
|
||||
/// wall out of the millisecond regime, where ordinary jitter would reach the
|
||||
/// 10% NOTICE threshold and flap the report.
|
||||
pub struct IngestFixture {
|
||||
pub dir: ScratchDir,
|
||||
pub passes: u32,
|
||||
}
|
||||
|
||||
pub const FULL_PASSES: u32 = 20;
|
||||
|
||||
pub fn setup(sizing: Sizing) -> Result<IngestFixture, String> {
|
||||
let dir = ScratchDir::new("ingest").map_err(|e| format!("scratch dir: {e}"))?;
|
||||
let (from, to) = if sizing.quick { QUICK_MONTHS } else { FULL_MONTHS };
|
||||
write_symbol_archive(&dir.0, SYMBOL, from, to);
|
||||
Ok(IngestFixture { dir, passes: if sizing.quick { 1 } else { FULL_PASSES } })
|
||||
}
|
||||
|
||||
/// One measured drain: a FRESH `DataServer`, so the pass pays zip inflate +
|
||||
/// parse instead of hitting the in-memory FileCache.
|
||||
fn drain_once(dir: &std::path::Path) -> Result<(u64, u64), String> {
|
||||
let server = Arc::new(DataServer::new(dir));
|
||||
let mut srcs = open_columns(&server, SYMBOL, None, None, &[M1Field::Close])
|
||||
.ok_or("bench archive must be present for the close column")?;
|
||||
let mut src = srcs.pop().ok_or("one source for one field")?;
|
||||
let mut n: u64 = 0;
|
||||
let mut xor: u64 = 0;
|
||||
while let Some((_, v)) = src.next() {
|
||||
n += 1;
|
||||
xor ^= v.as_f64().to_bits();
|
||||
}
|
||||
if n == 0 {
|
||||
return Err("the synthetic archive must yield bars".to_string());
|
||||
}
|
||||
Ok((n, xor))
|
||||
}
|
||||
|
||||
pub fn rep(fixture: &IngestFixture) -> Result<RepOutcome, String> {
|
||||
let t = Instant::now();
|
||||
let (n, xor) = drain_once(&fixture.dir.0)?;
|
||||
for _ in 1..fixture.passes {
|
||||
let again = drain_once(&fixture.dir.0)?;
|
||||
if again != (n, xor) {
|
||||
return Err(format!("ingest passes disagree: {:?} vs {:?}", (n, xor), again));
|
||||
}
|
||||
}
|
||||
let wall = t.elapsed().as_secs_f64();
|
||||
let total = n * u64::from(fixture.passes);
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("wall_s".to_string(), wall);
|
||||
metrics.insert("bars_per_s".to_string(), total as f64 / wall);
|
||||
Ok(RepOutcome { metrics, fingerprint: format!("n={n} xor={xor:016x}") })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Determinism smoke: two drains of the same archive fold to the same
|
||||
/// fingerprint, and a fresh server per rep really re-reads the bytes.
|
||||
#[test]
|
||||
fn ingest_surface_fingerprint_is_deterministic_in_quick_mode() {
|
||||
let fixture = setup(Sizing { quick: true }).expect("fixture writes");
|
||||
let a = rep(&fixture).expect("first drain");
|
||||
let b = rep(&fixture).expect("second drain");
|
||||
assert_eq!(a.fingerprint, b.fingerprint);
|
||||
assert!(a.fingerprint.starts_with("n="), "shape: {}", a.fingerprint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The surface framework: a surface is a closure the harness calls once per
|
||||
//! repetition; `run_reps` applies the measurement discipline (1 discarded
|
||||
//! warmup + N measured reps, median per metric) and enforces cross-rep
|
||||
//! fingerprint identity — a surface disagreeing with itself is nondeterminism,
|
||||
//! reported as an infra error, never averaged away.
|
||||
|
||||
pub mod campaign;
|
||||
pub mod engine;
|
||||
pub mod fixed_cost;
|
||||
pub mod ingest;
|
||||
|
||||
use crate::baseline::Measured;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Workload sizing: `quick` is the test/dev mode (tiny inputs, mechanics
|
||||
/// only); the committed baselines are always full-size.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Sizing {
|
||||
pub quick: bool,
|
||||
}
|
||||
|
||||
/// One repetition's outcome.
|
||||
pub struct RepOutcome {
|
||||
pub metrics: BTreeMap<String, f64>,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
pub fn median(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).expect("no NaN metrics"));
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
/// 1 discarded warmup + `reps` measured runs; median per metric key;
|
||||
/// cross-rep fingerprint identity enforced.
|
||||
pub fn run_reps(
|
||||
surface: &str,
|
||||
reps: u32,
|
||||
mut rep: impl FnMut() -> Result<RepOutcome, String>,
|
||||
) -> Result<Measured, String> {
|
||||
let reps = reps.max(1);
|
||||
let _warmup = rep()?; // page/alloc warmup, discarded
|
||||
let mut runs = Vec::new();
|
||||
for _ in 0..reps {
|
||||
runs.push(rep()?);
|
||||
}
|
||||
let fingerprint = runs[0].fingerprint.clone();
|
||||
for r in &runs[1..] {
|
||||
if r.fingerprint != fingerprint {
|
||||
return Err(format!(
|
||||
"surface {surface} is nondeterministic across repetitions: {fingerprint:?} vs {:?}",
|
||||
r.fingerprint
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut metrics = BTreeMap::new();
|
||||
for key in runs[0].metrics.keys() {
|
||||
let vals: Vec<f64> = runs.iter().filter_map(|r| r.metrics.get(key).copied()).collect();
|
||||
metrics.insert(key.clone(), median(vals));
|
||||
}
|
||||
Ok(Measured { surface: surface.to_string(), metrics, fingerprint })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn median_picks_the_middle() {
|
||||
assert_eq!(median(vec![3.0, 1.0, 2.0]), 2.0);
|
||||
assert_eq!(median(vec![1.0, 2.0]), 2.0);
|
||||
}
|
||||
|
||||
/// The engine surface's actual deliverable: `run_reps` composed with the
|
||||
/// real engine surface (a re-bootstrapped SMA-cross harness per call),
|
||||
/// not a mock closure. Protects the wiring end to end — a warmup plus
|
||||
/// three fresh bootstraps must agree on the determinism fingerprint and
|
||||
/// the returned metrics map must carry both keys a `BaselineDoc` for
|
||||
/// this surface needs (`wall_s`, `bars_per_s`), positive and finite.
|
||||
#[test]
|
||||
fn run_reps_drives_the_real_engine_surface_deterministically() {
|
||||
let measured = run_reps("engine", 3, || engine::rep(Sizing { quick: true }))
|
||||
.expect("the real engine surface must agree with itself across repeated bootstraps");
|
||||
assert_eq!(measured.surface, "engine");
|
||||
let bars_per_s = *measured.metrics.get("bars_per_s").expect("bars_per_s metric present");
|
||||
assert!(bars_per_s.is_finite() && bars_per_s > 0.0, "bars_per_s: {bars_per_s}");
|
||||
assert!(measured.metrics.contains_key("wall_s"));
|
||||
}
|
||||
|
||||
/// The ingest surface's actual deliverable: `run_reps` composed with the
|
||||
/// real ingest surface (a fresh `DataServer` re-opened and re-drained
|
||||
/// per call).
|
||||
/// Protects that a warmup plus three fresh drains of the same synthetic
|
||||
/// archive agree on the fingerprint, and that the metrics map carries
|
||||
/// the schema the ingest `BaselineDoc` needs.
|
||||
#[test]
|
||||
fn run_reps_drives_the_real_ingest_surface_deterministically() {
|
||||
let fixture = ingest::setup(Sizing { quick: true }).expect("ingest fixture must write");
|
||||
let measured = run_reps("ingest", 3, || ingest::rep(&fixture))
|
||||
.expect("the real ingest surface must agree with itself across repeated drains");
|
||||
assert_eq!(measured.surface, "ingest");
|
||||
let bars_per_s = *measured.metrics.get("bars_per_s").expect("bars_per_s metric present");
|
||||
assert!(bars_per_s.is_finite() && bars_per_s > 0.0, "bars_per_s: {bars_per_s}");
|
||||
assert!(measured.metrics.contains_key("wall_s"));
|
||||
}
|
||||
|
||||
/// `run_reps` composed with the real campaign sweep surface — a fresh
|
||||
/// scratch project, a freshly seeded blueprint, and a genuine
|
||||
/// `aura campaign run` child process spawned per repetition (not an
|
||||
/// in-process shortcut). Protects that two independently built-and-run
|
||||
/// scratch campaigns agree on the winner-ordinal fingerprint extracted
|
||||
/// from the child's stdout — C1's determinism carried through the
|
||||
/// child-process boundary, which is the whole reason the campaign
|
||||
/// surfaces exist as a distinct measurement path from the in-process
|
||||
/// engine/ingest surfaces above — and that the metrics map carries the
|
||||
/// wall-clock key the campaign `BaselineDoc` needs.
|
||||
///
|
||||
/// Ignored in the default suite: it builds the release `aura` binary and
|
||||
/// spawns real campaign child runs (minutes cold) — the suite-wallclock
|
||||
/// discipline forbids that cost on every `cargo test`. Run it explicitly:
|
||||
/// `cargo test -p aura-bench -- --ignored`.
|
||||
#[test]
|
||||
#[ignore = "builds the release aura binary + real campaign scratch runs; run via -- --ignored"]
|
||||
fn run_reps_drives_the_real_campaign_sweep_surface_deterministically() {
|
||||
let bin = crate::child::resolve_aura_bin(&crate::child::workspace_root())
|
||||
.expect("the release aura binary must build");
|
||||
let measured = run_reps("campaign_sweep", 2, || campaign::sweep_rep(&bin, Sizing { quick: true }))
|
||||
.expect("the real campaign sweep surface must agree with itself across independent scratch runs");
|
||||
assert_eq!(measured.surface, "campaign_sweep");
|
||||
assert!(measured.metrics.contains_key("wall_s"));
|
||||
assert!(
|
||||
measured.fingerprint.starts_with("cells="),
|
||||
"fingerprint: {}",
|
||||
measured.fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
/// `run_reps` composed with the real CLI fixed-cost surface — a genuine
|
||||
/// `aura --help` spawn-floor wall plus a fresh scratch project and a real
|
||||
/// `aura run` child process per repetition (not an in-process shortcut).
|
||||
/// Protects that two independent scratch runs agree on the run-line FNV
|
||||
/// fingerprint extracted from the child's stdout, and that the metrics
|
||||
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
|
||||
///
|
||||
/// Ignored in the default suite: it builds the release `aura` binary and
|
||||
/// spawns real child processes — the suite-wallclock discipline forbids
|
||||
/// that cost on every `cargo test`. Run it explicitly:
|
||||
/// `cargo test -p aura-bench -- --ignored`.
|
||||
#[test]
|
||||
#[ignore = "builds the release aura binary + real fixed-cost child runs; run via -- --ignored"]
|
||||
fn run_reps_drives_the_real_fixed_cost_surface_deterministically() {
|
||||
let bin = crate::child::resolve_aura_bin(&crate::child::workspace_root())
|
||||
.expect("the release aura binary must build");
|
||||
let measured = run_reps("cli_fixed_cost", 2, || fixed_cost::rep(&bin))
|
||||
.expect("the real fixed_cost surface must agree with itself across independent scratch runs");
|
||||
assert_eq!(measured.surface, "cli_fixed_cost");
|
||||
assert!(measured.metrics.contains_key("help_ms"));
|
||||
assert!(measured.metrics.contains_key("run_ms"));
|
||||
assert!(
|
||||
measured.fingerprint.starts_with("run_line_fnv="),
|
||||
"fingerprint: {}",
|
||||
measured.fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reps_rejects_cross_rep_fingerprint_drift() {
|
||||
let mut n = 0;
|
||||
let r = run_reps("t", 2, move || {
|
||||
n += 1;
|
||||
Ok(RepOutcome { metrics: BTreeMap::new(), fingerprint: format!("fp{n}") })
|
||||
});
|
||||
assert!(r.is_err(), "differing fingerprints must be an error");
|
||||
}
|
||||
|
||||
/// The measurement discipline itself: the first (warmup) call is never
|
||||
/// mixed into the reported metric — its outlier value would otherwise
|
||||
/// skew the median — and the median is computed independently per
|
||||
/// metric key across only the measured reps.
|
||||
#[test]
|
||||
fn run_reps_discards_warmup_and_medians_only_measured_reps() {
|
||||
let mut n = 0;
|
||||
let measured = run_reps("t", 3, move || {
|
||||
n += 1;
|
||||
// Call 1 is the discarded warmup: an outlier that must never
|
||||
// enter the median. Calls 2..=4 (the measured reps) report
|
||||
// 1.0, 2.0, 3.0, whose median is 2.0.
|
||||
let v = if n == 1 { 1_000_000.0 } else { (n - 1) as f64 };
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("m".to_string(), v);
|
||||
Ok(RepOutcome { metrics, fingerprint: "fp".to_string() })
|
||||
})
|
||||
.expect("agreeing fingerprints must succeed");
|
||||
assert_eq!(measured.surface, "t");
|
||||
assert_eq!(measured.metrics.get("m"), Some(&2.0), "warmup outlier must not pollute the median");
|
||||
assert_eq!(measured.fingerprint, "fp");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Deterministic synthetic inputs: the seeded price walk (engine surface), the
|
||||
//! 48-byte M1 zip archive writer (ingest + campaign surfaces), and the scratch
|
||||
//! temp-dir guard. Everything here is a pure function of its arguments — same
|
||||
//! bytes on every host, every run (C1 is what makes the engine benchmarkable).
|
||||
|
||||
use aura_core::{Scalar, Timestamp};
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A seeded LCG random walk as a `VecSource` stream: 1-minute spacing in
|
||||
/// epoch-ns, prices around 100. Same constants, same bytes, everywhere.
|
||||
pub fn synthetic_walk(n: usize) -> Vec<(Timestamp, Scalar)> {
|
||||
let mut x: u64 = 0x9E37_79B9_7F4A_7C15;
|
||||
let mut price = 100.0_f64;
|
||||
(0..n)
|
||||
.map(|i| {
|
||||
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
let step = ((x >> 11) as f64 / (1u64 << 53) as f64) - 0.5;
|
||||
price += step;
|
||||
(Timestamp(i as i64 * 60_000_000_000), Scalar::f64(price))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// FNV-1a over raw bytes — the fingerprint hash for child-process stdout.
|
||||
pub fn fnv1a(bytes: &[u8]) -> u64 {
|
||||
let mut h = 0xcbf2_9ce4_8422_2325_u64;
|
||||
for b in bytes {
|
||||
h ^= u64::from(*b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// A self-deleting scratch directory under the system temp dir.
|
||||
pub struct ScratchDir(pub PathBuf);
|
||||
|
||||
impl ScratchDir {
|
||||
pub fn new(tag: &str) -> std::io::Result<Self> {
|
||||
let base = std::env::temp_dir().join(format!(
|
||||
"aura-bench-{}-{}-{}",
|
||||
std::process::id(),
|
||||
tag,
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_nanos())
|
||||
.unwrap_or(0)
|
||||
));
|
||||
std::fs::create_dir_all(&base)?;
|
||||
Ok(Self(base))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScratchDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Days since the Unix epoch for a proleptic-Gregorian civil date
|
||||
/// (Howard Hinnant's `days_from_civil` — dependency-free calendar math).
|
||||
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
||||
let y = i64::from(if m <= 2 { y - 1 } else { y });
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400; // [0, 399]
|
||||
let mp = (i64::from(m) + 9) % 12; // [0, 11], Mar=0 .. Feb=11
|
||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// Unix milliseconds at `y-m-d hh:mm:00` UTC.
|
||||
pub fn unix_ms(y: i32, m: u32, d: u32, hh: u32, mm: u32) -> i64 {
|
||||
days_from_civil(y, m, d) * 86_400_000 + (i64::from(hh) * 3600 + i64::from(mm) * 60) * 1000
|
||||
}
|
||||
|
||||
/// 0 = Sunday .. 6 = Saturday (1970-01-01 was a Thursday, day 0).
|
||||
fn weekday(y: i32, m: u32, d: u32) -> i64 {
|
||||
(days_from_civil(y, m, d) % 7 + 4 + 7) % 7
|
||||
}
|
||||
|
||||
fn days_in_month(y: i32, m: u32) -> u32 {
|
||||
match m {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
||||
2 => 28,
|
||||
_ => unreachable!("month out of range: {m}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic price at continuous minute offset `t` (minutes since
|
||||
/// 2024-01-01T00:00 UTC): a gentle upward trend plus a 180-minute sine.
|
||||
fn price_at(t_minutes: f64) -> f64 {
|
||||
let hours = t_minutes / 60.0;
|
||||
100.0 + 0.01 * hours + 5.0 * (2.0 * std::f64::consts::PI * t_minutes / 180.0).sin()
|
||||
}
|
||||
|
||||
/// Packs one `RawM1Record`-shaped 48-byte little-endian record: `time`
|
||||
/// (Delphi `TDateTime`, days since 1899-12-30), `open/high/low/close`,
|
||||
/// `spread` (f32), `volume` (i32).
|
||||
fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] {
|
||||
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
|
||||
const MS_PER_DAY: f64 = 86_400_000.0;
|
||||
let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS;
|
||||
let mut rec = [0u8; 48];
|
||||
rec[0..8].copy_from_slice(&dt.to_le_bytes());
|
||||
rec[8..16].copy_from_slice(&open.to_le_bytes());
|
||||
rec[16..24].copy_from_slice(&high.to_le_bytes());
|
||||
rec[24..32].copy_from_slice(&low.to_le_bytes());
|
||||
rec[32..40].copy_from_slice(&close.to_le_bytes());
|
||||
rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes());
|
||||
rec[44..48].copy_from_slice(&100_i32.to_le_bytes());
|
||||
rec
|
||||
}
|
||||
|
||||
/// One month's worth of weekday, 08:00-16:00 UTC, 1-minute bars (~10k).
|
||||
fn month_records(year: i32, month: u32) -> Vec<[u8; 48]> {
|
||||
let epoch_ms = unix_ms(2024, 1, 1, 0, 0);
|
||||
let mut records = Vec::new();
|
||||
for day in 1..=days_in_month(year, month) {
|
||||
if !(1..=5).contains(&weekday(year, month, day)) {
|
||||
continue; // weekend
|
||||
}
|
||||
for hh in 8..16 {
|
||||
for mm in 0..60 {
|
||||
let ms = unix_ms(year, month, day, hh, mm);
|
||||
let t = (ms - epoch_ms) as f64 / 60_000.0;
|
||||
let (open, close) = (price_at(t), price_at(t + 1.0));
|
||||
let high = open.max(close) + 0.2;
|
||||
let low = open.min(close) - 0.2;
|
||||
records.push(pack_record(ms, open, high, low, close));
|
||||
}
|
||||
}
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
/// Writes one `SYMBOL_YYYY_MM.m1` zip (a single `<SYMBOL>.bin` entry).
|
||||
fn write_month_zip(data_dir: &Path, symbol: &str, year: i32, month: u32) {
|
||||
let path = data_dir.join(format!("{symbol}_{year:04}_{month:02}.m1"));
|
||||
let file = std::fs::File::create(&path).expect("create synthetic m1 zip");
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
zip.start_file::<String, ()>(format!("{symbol}.bin"), Default::default())
|
||||
.expect("start synthetic m1 zip entry");
|
||||
for rec in month_records(year, month) {
|
||||
zip.write_all(&rec).expect("write synthetic m1 record");
|
||||
}
|
||||
zip.finish().expect("finish synthetic m1 zip");
|
||||
}
|
||||
|
||||
/// Writes every month in `[from, to]` (inclusive `(year, month)` pairs) for
|
||||
/// `symbol`, plus its `<SYMBOL>.meta.json` geometry sidecar (GER40-shaped).
|
||||
pub fn write_symbol_archive(data_dir: &Path, symbol: &str, from: (i32, u32), to: (i32, u32)) {
|
||||
let (mut y, mut m) = from;
|
||||
loop {
|
||||
write_month_zip(data_dir, symbol, y, m);
|
||||
if (y, m) == to {
|
||||
break;
|
||||
}
|
||||
m += 1;
|
||||
if m > 12 {
|
||||
m = 1;
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
std::fs::write(
|
||||
data_dir.join(format!("{symbol}.meta.json")),
|
||||
format!(
|
||||
"{{\"schemaVersion\":2,\"symbol\":\"{symbol}\",\"digits\":1,\"pipSize\":1,\
|
||||
\"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"{symbol}\",\"quoteAsset\":\"EUR\"}}"
|
||||
),
|
||||
)
|
||||
.expect("write synthetic geometry sidecar");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthetic_walk_is_deterministic_and_sized() {
|
||||
let a = synthetic_walk(1000);
|
||||
let b = synthetic_walk(1000);
|
||||
assert_eq!(a.len(), 1000);
|
||||
assert_eq!(a, b, "same seed, same bytes");
|
||||
assert_eq!(a[0].0, Timestamp(0));
|
||||
assert_eq!(a[1].0, Timestamp(60_000_000_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_symbol_archive_names_months_and_sidecar() {
|
||||
let dir = ScratchDir::new("synthtest").expect("scratch dir");
|
||||
write_symbol_archive(&dir.0, "TSYM", (2024, 11), (2025, 2));
|
||||
let mut names: Vec<String> = std::fs::read_dir(&dir.0)
|
||||
.expect("read scratch")
|
||||
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
names.sort();
|
||||
assert_eq!(
|
||||
names,
|
||||
vec![
|
||||
"TSYM.meta.json".to_string(),
|
||||
"TSYM_2024_11.m1".to_string(),
|
||||
"TSYM_2024_12.m1".to_string(),
|
||||
"TSYM_2025_01.m1".to_string(),
|
||||
"TSYM_2025_02.m1".to_string(),
|
||||
],
|
||||
"month rollover + sidecar"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fnv1a_is_stable() {
|
||||
assert_eq!(fnv1a(b""), 0xcbf2_9ce4_8422_2325);
|
||||
assert_eq!(fnv1a(b"a"), fnv1a(b"a"));
|
||||
assert_ne!(fnv1a(b"a"), fnv1a(b"b"));
|
||||
}
|
||||
|
||||
/// #251: the synthetic archive is the ingest surface's benchmark input
|
||||
/// (fed through the CLI wiring), so its bytes must be more than
|
||||
/// internally self-consistent — they must actually parse through the
|
||||
/// real production M1 reader
|
||||
/// (`aura_ingest::DataServer`/`load_m1_window`), the same path a real
|
||||
/// GER40 archive goes through. This is not a round trip against our own
|
||||
/// `pack_record`: it is the production parser recovering `pack_record`'s
|
||||
/// 48-byte layout (Delphi `TDateTime`, 4 little-endian f64 prices) and
|
||||
/// the `price_at` OHLC shape (`low <= open,close <= high` on every bar) —
|
||||
/// pins the record layout against future drift in either writer or
|
||||
/// reader.
|
||||
#[test]
|
||||
fn synthetic_archive_parses_through_the_real_ingest_reader() {
|
||||
let dir = ScratchDir::new("ingest-roundtrip").expect("scratch dir");
|
||||
write_symbol_archive(&dir.0, "BENCHSYM", (2024, 1), (2024, 1));
|
||||
let server = std::sync::Arc::new(aura_ingest::DataServer::new(&dir.0));
|
||||
let cols = aura_ingest::load_m1_window(&server, "BENCHSYM", None, None)
|
||||
.expect("the synthetic archive is a recognized data source");
|
||||
|
||||
assert!(!cols.close.is_empty(), "January 2024 has weekday 08:00-16:00 bars");
|
||||
for i in 0..cols.close.len() {
|
||||
assert!(
|
||||
cols.high[i] >= cols.open[i] && cols.high[i] >= cols.close[i],
|
||||
"bar {i}: high must dominate open/close"
|
||||
);
|
||||
assert!(
|
||||
cols.low[i] <= cols.open[i] && cols.low[i] <= cols.close[i],
|
||||
"bar {i}: low must be dominated by open/close"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
//! Integration test: drive the built `aura-bench` binary as a downstream
|
||||
//! script would (spawned as a fresh process, no in-process shortcuts).
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// Path to the freshly-built `aura-bench` binary (Cargo sets this env var
|
||||
/// for the test crate; the binary target is named `aura-bench`).
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura-bench");
|
||||
|
||||
/// #251: a debug-build wall-clock number measures the build profile, not the
|
||||
/// code, so the harness must refuse to run at all rather than silently
|
||||
/// producing a number that could later be trusted (or committed) as a
|
||||
/// baseline. This pins the profile guard's observable contract — exit code 2
|
||||
/// (the usage-error convention aura-bench shares with `aura-cli`) and a
|
||||
/// stderr message naming both the cause ("debug build") and the fix
|
||||
/// ("--release") — so a future CLI-wiring change (the surfaces glued into
|
||||
/// the CLI) cannot silently drop or weaken the guard.
|
||||
#[test]
|
||||
fn refuses_to_run_in_a_debug_build() {
|
||||
// `cargo test` builds test-harness binaries (and their bin-target
|
||||
// dependencies) in the dev profile by default, so this binary always
|
||||
// has debug_assertions on — deterministic across hosts and CI.
|
||||
let out = Command::new(BIN).output().expect("spawn aura-bench");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert_eq!(out.status.code(), Some(2), "stderr: {stderr}");
|
||||
assert!(stderr.contains("debug build"), "stderr: {stderr}");
|
||||
assert!(stderr.contains("--release"), "stderr: {stderr}");
|
||||
}
|
||||
@@ -12,8 +12,14 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
# the scalar vocabulary: params cross the MemberRunner seam as (name, Scalar) pairs.
|
||||
aura-core = { path = "../aura-core" }
|
||||
# RunReport (the member result type) + the sweep/walk_forward orchestration machinery.
|
||||
# the sweep/walk_forward orchestration machinery (metric-agnostic; RunReport
|
||||
# itself is generic here — the M = RunMetrics instantiation is aura-backtest's).
|
||||
aura-engine = { path = "../aura-engine" }
|
||||
# aura-backtest carries the trading instantiation (M = RunMetrics: RunReport/
|
||||
# SweepFamily/SweepPoint/WindowRun aliases, RMetrics, r_bootstrap) — production
|
||||
# code uses it directly now (#291/#292, C28 phase 2), promoted from
|
||||
# [dev-dependencies].
|
||||
aura-backtest = { path = "../aura-backtest" }
|
||||
# FamilySelection — the selection-provenance type stage selections carry.
|
||||
aura-analysis = { path = "../aura-analysis" }
|
||||
# family + campaign-run stores and the optimize/optimize_plateau/optimize_deflated selectors.
|
||||
@@ -23,3 +29,8 @@ aura-research = { path = "../aura-research" }
|
||||
# realization payloads derive serde (the registry per-case policy, INDEX.md).
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
# rayon is admitted under the amended C16 per-case dependency policy (INDEX.md):
|
||||
# the campaign cell loop fans C1-disjoint cells into the same process-global
|
||||
# work-stealing pool the engine's run_indexed already uses — parallelism across
|
||||
# sims, never within one. Direct versioned dep, mirroring aura-engine.
|
||||
rayon = "1"
|
||||
|
||||
+533
-120
@@ -7,22 +7,24 @@
|
||||
//! in the crate's preflight, which `execute` runs before any member runs.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use aura_analysis::{FamilySelection, SelectionMode};
|
||||
use aura_backtest::{r_bootstrap, RunMetrics, RunReport, SweepFamily, SweepPoint, WindowRun};
|
||||
use aura_core::{zip_params, Cell, ParamSpec, Scalar, Timestamp};
|
||||
use aura_engine::{
|
||||
r_bootstrap, sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest, RunMetrics,
|
||||
RunReport, Space, SweepFamily, SweepPoint, WalkForwardError, WindowBounds, WindowRoller,
|
||||
WindowRun,
|
||||
sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest,
|
||||
Space, WalkForwardError, WindowBounds, WindowRoller,
|
||||
};
|
||||
use aura_registry::{
|
||||
derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau,
|
||||
sweep_member_reports, walkforward_member_reports, CampaignGeneralization, CampaignRunRecord,
|
||||
CellRealization, FamilyKind, PlateauMode, Registry, StageBootstrap, StageRealization,
|
||||
StageSelection,
|
||||
sweep_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, FamilyKind,
|
||||
PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection,
|
||||
};
|
||||
use aura_research::{Axis, CampaignDoc, DocRef, ProcessDoc, SelectRule, StageBlock, WfMode};
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::{
|
||||
member_metric, predicate_holds, preflight, CellSpec, ExecFault, MemberFault, MemberRunner,
|
||||
@@ -73,10 +75,18 @@ pub struct CampaignOutcome {
|
||||
}
|
||||
|
||||
/// Execute a validated campaign document's process pipeline once per
|
||||
/// (strategy, instrument, window) cell, in doc order, sequentially (C1:
|
||||
/// parallelism lives inside the engine `sweep`, across members, never across
|
||||
/// cells). `campaign_id` is the campaign document's 64-hex content id (its
|
||||
/// first 8 chars prefix family names); `strategies` is index-aligned with
|
||||
/// (strategy, instrument, window) cell. Cells are grouped by instrument
|
||||
/// ordinal and walked in sequential chunks of `parallel_instruments` groups;
|
||||
/// within a chunk, every cell runs concurrently via `rayon::par_iter` (#277:
|
||||
/// parallelism now also spans cells, bounded by `parallel_instruments` — C1
|
||||
/// still holds because cells are disjoint work, and RESULT order is rebuilt
|
||||
/// in document order regardless of completion order). A non-containable
|
||||
/// fault (see `contain`) latches a shared abort flag and short-circuits the
|
||||
/// run deterministically: propagation picks the lowest document-order fault
|
||||
/// among the cells that completed before the latch, which cell that is being
|
||||
/// scheduling-dependent but acceptable for these external, non-input faults.
|
||||
/// `campaign_id` is the campaign document's 64-hex content id (its first 8
|
||||
/// chars prefix family names); `strategies` is index-aligned with
|
||||
/// `campaign.strategies` as (blueprint content id, canonical blueprint json).
|
||||
/// Writes one family per family-producing stage plus one campaign-run record;
|
||||
/// a `MemberFault` aborts the whole run before any write of the faulted stage.
|
||||
@@ -87,6 +97,7 @@ pub fn execute(
|
||||
strategies: &[(String, String)],
|
||||
runner: &dyn MemberRunner,
|
||||
registry: &Registry,
|
||||
parallel_instruments: NonZeroUsize,
|
||||
) -> Result<CampaignOutcome, ExecFault> {
|
||||
preflight(process, campaign)?;
|
||||
if strategies.len() != campaign.strategies.len() {
|
||||
@@ -116,12 +127,19 @@ pub fn execute(
|
||||
}
|
||||
let campaign_prefix = &campaign_id[..8];
|
||||
|
||||
let mut cells_out: Vec<CellOutcome> = Vec::new();
|
||||
let mut cells_rec: Vec<CellRealization> = Vec::new();
|
||||
// Per-(strategy_ordinal, window_ordinal) nominees across instruments, in
|
||||
// cell-loop (instrument) order — the campaign-scope generalize input.
|
||||
type Nominee = Option<(Vec<(String, Scalar)>, RunReport)>;
|
||||
let mut nominees: BTreeMap<(usize, usize, usize), Vec<(String, Nominee)>> = BTreeMap::new();
|
||||
// A slot's per-cell outcome — the same type `run_cell` returns, named here
|
||||
// so the chunk-walk bindings below don't nest it raw (clippy::type_complexity).
|
||||
type CellResult = Result<(CellOutcome, CellRealization), ExecFault>;
|
||||
|
||||
// Flatten: the same 4-level nesting enumerates every cell with its
|
||||
// document-order index. Execution order below is instrument-major and
|
||||
// chunked; RESULT order stays this document order (C1).
|
||||
struct PlannedCell {
|
||||
doc_index: usize,
|
||||
instrument_ordinal: usize,
|
||||
window_ordinal: usize,
|
||||
cell: CellSpec,
|
||||
}
|
||||
// Absent/empty risk = a single default cell (regime None, ordinal 0); a
|
||||
// non-empty list maps each regime to Some. The default's value is the member
|
||||
// runner's — the doc is never mutated (its content id hashes with risk absent).
|
||||
@@ -130,42 +148,108 @@ pub fn execute(
|
||||
} else {
|
||||
campaign.risk.iter().copied().enumerate().map(|(i, r)| (i, Some(r))).collect()
|
||||
};
|
||||
let mut planned: Vec<PlannedCell> = Vec::new();
|
||||
for (strategy_ordinal, (entry, (strategy_id, blueprint_json))) in
|
||||
campaign.strategies.iter().zip(strategies).enumerate()
|
||||
{
|
||||
for instrument in &campaign.data.instruments {
|
||||
for (instrument_ordinal, instrument) in campaign.data.instruments.iter().enumerate() {
|
||||
for (window_ordinal, window) in campaign.data.windows.iter().enumerate() {
|
||||
for (regime_ordinal, regime) in ®imes {
|
||||
let cell = CellSpec {
|
||||
strategy_ordinal,
|
||||
strategy_id: strategy_id.clone(),
|
||||
blueprint_json: blueprint_json.clone(),
|
||||
axes: entry.axes.clone(),
|
||||
instrument: instrument.clone(),
|
||||
window_ms: (window.from_ms, window.to_ms),
|
||||
regime: *regime,
|
||||
regime_ordinal: *regime_ordinal,
|
||||
};
|
||||
let (outcome, realization) = run_cell(
|
||||
&cell,
|
||||
process,
|
||||
campaign_prefix,
|
||||
planned.push(PlannedCell {
|
||||
doc_index: planned.len(),
|
||||
instrument_ordinal,
|
||||
window_ordinal,
|
||||
campaign.seed,
|
||||
runner,
|
||||
registry,
|
||||
)?;
|
||||
nominees
|
||||
.entry((strategy_ordinal, window_ordinal, *regime_ordinal))
|
||||
.or_default()
|
||||
.push((instrument.clone(), outcome.nominee.clone()));
|
||||
cells_out.push(outcome);
|
||||
cells_rec.push(realization);
|
||||
cell: CellSpec {
|
||||
strategy_ordinal,
|
||||
strategy_id: strategy_id.clone(),
|
||||
blueprint_json: blueprint_json.clone(),
|
||||
axes: entry.axes.clone(),
|
||||
instrument: instrument.clone(),
|
||||
window_ms: (window.from_ms, window.to_ms),
|
||||
regime: *regime,
|
||||
regime_ordinal: *regime_ordinal,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group by instrument ordinal (not name: correct even on unvalidated
|
||||
// duplicate-name input), then walk sequential chunks of K groups — the
|
||||
// structural residency bound: no cell of an instrument outside the
|
||||
// current chunk can be in flight at all.
|
||||
let mut groups: Vec<Vec<usize>> = vec![Vec::new(); campaign.data.instruments.len()];
|
||||
for p in &planned {
|
||||
groups[p.instrument_ordinal].push(p.doc_index);
|
||||
}
|
||||
let abort = AtomicBool::new(false);
|
||||
let mut slots: Vec<Option<CellResult>> = (0..planned.len()).map(|_| None).collect();
|
||||
for chunk in groups.chunks(parallel_instruments.get()) {
|
||||
let chunk_cells: Vec<usize> = chunk.iter().flatten().copied().collect();
|
||||
let results: Vec<(usize, Option<CellResult>)> = chunk_cells
|
||||
.par_iter()
|
||||
.map(|&doc_index| {
|
||||
if abort.load(Ordering::Relaxed) {
|
||||
// Never started: a fatal fault elsewhere already ends
|
||||
// the run; this slot stays empty.
|
||||
return (doc_index, None);
|
||||
}
|
||||
let p = &planned[doc_index];
|
||||
let result = run_cell(
|
||||
&p.cell,
|
||||
process,
|
||||
campaign_prefix,
|
||||
p.window_ordinal,
|
||||
campaign.seed,
|
||||
runner,
|
||||
registry,
|
||||
);
|
||||
if result.is_err() {
|
||||
abort.store(true, Ordering::Relaxed);
|
||||
}
|
||||
(doc_index, Some(result))
|
||||
})
|
||||
.collect();
|
||||
for (doc_index, result) in results {
|
||||
slots[doc_index] = result;
|
||||
}
|
||||
}
|
||||
|
||||
// Non-containable faults stay run-fatal: propagate the lowest
|
||||
// document-order fault among completed cells (deterministic attribution;
|
||||
// which cells completed before the abort latched is scheduling-dependent,
|
||||
// acceptable for external, non-input faults — see the spec).
|
||||
for slot in slots.iter_mut() {
|
||||
if matches!(slot, Some(Err(_))) {
|
||||
let Some(Err(fault)) = slot.take() else { unreachable!() };
|
||||
return Err(fault);
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild the ordered outputs in document order — identical pushes to the
|
||||
// former nested loop, so nominee grouping and record layout are unchanged.
|
||||
let mut cells_out: Vec<CellOutcome> = Vec::with_capacity(planned.len());
|
||||
let mut cells_rec: Vec<CellRealization> = Vec::with_capacity(planned.len());
|
||||
// Per-(strategy_ordinal, window_ordinal) nominees across instruments, in
|
||||
// document (instrument) order — the campaign-scope generalize input.
|
||||
type Nominee = Option<(Vec<(String, Scalar)>, RunReport)>;
|
||||
let mut nominees: BTreeMap<(usize, usize, usize), Vec<(String, Nominee)>> = BTreeMap::new();
|
||||
for (p, slot) in planned.iter().zip(slots) {
|
||||
let (outcome, realization) = match slot {
|
||||
Some(Ok(pair)) => pair,
|
||||
// No fatal fault (checked above) means abort never latched, so
|
||||
// every cell ran to Ok.
|
||||
_ => unreachable!("no fatal fault, so every cell completed"),
|
||||
};
|
||||
nominees
|
||||
.entry((p.cell.strategy_ordinal, p.window_ordinal, p.cell.regime_ordinal))
|
||||
.or_default()
|
||||
.push((p.cell.instrument.clone(), outcome.nominee.clone()));
|
||||
cells_out.push(outcome);
|
||||
cells_rec.push(realization);
|
||||
}
|
||||
|
||||
// Campaign-scope generalize (#200 d2): for each (strategy, window), grade
|
||||
// the per-instrument nominees via the shipped `generalization`; fewer than
|
||||
// 2 winners is a recorded shortfall, never computed around.
|
||||
@@ -232,6 +316,31 @@ pub fn execute(
|
||||
Ok(CampaignOutcome { record, run, cells: cells_out })
|
||||
}
|
||||
|
||||
/// The inter-stage population crossing `run_cell`'s seam: parameter points
|
||||
/// only (from `std::grid`) or executed members (from `std::sweep`). Preflight
|
||||
/// guarantees report-consuming stages (`std::gate`, `std::monte_carlo`'s
|
||||
/// per-survivor arm) never see `Points`; the defensive faults below keep that
|
||||
/// invariant loud if the shape rules ever drift.
|
||||
enum StageFlow {
|
||||
/// (ordinal into the nearest preceding family_id-bearing stage's family,
|
||||
/// param point, member report) — the shape preflight established before
|
||||
/// std::sweep populated it.
|
||||
Members(Vec<(usize, Vec<Scalar>, RunReport)>),
|
||||
Points(Vec<Vec<Scalar>>),
|
||||
}
|
||||
|
||||
impl StageFlow {
|
||||
/// Every flow can cross the seam as bare points — the walk_forward need.
|
||||
fn points(&self) -> Vec<Vec<Scalar>> {
|
||||
match self {
|
||||
StageFlow::Points(points) => points.clone(),
|
||||
StageFlow::Members(members) => {
|
||||
members.iter().map(|(_, point, _)| point.clone()).collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one cell's pipeline: stages in doc order, a realized prefix that stops
|
||||
/// at an empty gate (decision 8 — the cell truncates, the campaign continues).
|
||||
fn run_cell(
|
||||
@@ -247,15 +356,27 @@ fn run_cell(
|
||||
let mut families: Vec<StageFamily> = Vec::new();
|
||||
let mut selections: Vec<StageSelectionOut> = Vec::new();
|
||||
let mut stages: Vec<StageRealization> = Vec::new();
|
||||
// The surviving population: (ordinal into the nearest preceding
|
||||
// family_id-bearing stage's family, param point, member report).
|
||||
let mut survivors: Vec<(usize, Vec<Scalar>, RunReport)> = Vec::new();
|
||||
// The surviving population crossing the stage seam (see `StageFlow`).
|
||||
let mut flow = StageFlow::Members(Vec::new());
|
||||
// The cell's nominated generalize candidate: last wf window's OOS report
|
||||
// when a walk_forward ran, else the sweep winner; None when truncated.
|
||||
let mut nominee: Option<(Vec<(String, Scalar)>, RunReport)> = None;
|
||||
|
||||
for (stage_ordinal, stage) in process.pipeline.iter().enumerate() {
|
||||
match stage {
|
||||
StageBlock::Grid => {
|
||||
// Enumerate-only (#256 fork B): the grid's parameter points
|
||||
// cross the seam; nothing executes, nothing persists.
|
||||
flow = StageFlow::Points(grid.points.clone());
|
||||
stages.push(StageRealization {
|
||||
block: "std::grid".to_string(),
|
||||
family_id: None,
|
||||
survivor_ordinals: None,
|
||||
selection: None,
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
});
|
||||
}
|
||||
StageBlock::Sweep { selection } => {
|
||||
// Deterministic, self-describing family name (the "-{run}"
|
||||
// suffix is appended by the registry).
|
||||
@@ -263,7 +384,14 @@ fn run_cell(
|
||||
"{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}",
|
||||
cell.strategy_ordinal, cell.instrument, cell.regime_ordinal,
|
||||
);
|
||||
let family = run_members(cell, &grid, runner)?;
|
||||
let family = match run_members(cell, &grid, runner) {
|
||||
Ok(family) => family,
|
||||
Err(fault) => {
|
||||
// Registry etc. still aborts (contain's Err arm).
|
||||
let cf = contain(fault, stage_ordinal)?;
|
||||
return Ok(failed_cell(cell, stages, families, selections, cf, runner));
|
||||
}
|
||||
};
|
||||
let family_id = registry
|
||||
.append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family))
|
||||
.map_err(ExecFault::Registry)?;
|
||||
@@ -300,6 +428,7 @@ fn run_cell(
|
||||
survivor_ordinals: None,
|
||||
selection: Some(StageSelection { winner_ordinal, params, selection }),
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
});
|
||||
}
|
||||
None => {
|
||||
@@ -313,17 +442,20 @@ fn run_cell(
|
||||
survivor_ordinals: None,
|
||||
selection: None,
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
});
|
||||
}
|
||||
}
|
||||
// The whole family flows on (decision 3: `select` names the
|
||||
// recorded selection, never a filter).
|
||||
survivors = family
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
|
||||
.collect();
|
||||
flow = StageFlow::Members(
|
||||
family
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
|
||||
.collect(),
|
||||
);
|
||||
families.push(StageFamily {
|
||||
stage: stage_ordinal,
|
||||
block: "std::sweep",
|
||||
@@ -338,13 +470,21 @@ fn run_cell(
|
||||
// None`) fails the member — conservative and deterministic
|
||||
// (the metric NAME was already preflighted against
|
||||
// `PER_MEMBER_METRICS`).
|
||||
survivors.retain(|(_, _, report)| {
|
||||
let StageFlow::Members(members) = &mut flow else {
|
||||
return Err(ExecFault::PipelineShape {
|
||||
detail: format!(
|
||||
"stage {stage_ordinal}: std::gate needs executed \
|
||||
members (preflight admits no std::grid predecessor)"
|
||||
),
|
||||
});
|
||||
};
|
||||
members.retain(|(_, _, report)| {
|
||||
all.iter().all(|p| {
|
||||
member_metric(report, &p.metric)
|
||||
.is_some_and(|value| predicate_holds(&p.cmp, value, p.value))
|
||||
})
|
||||
});
|
||||
let ordinals: Vec<usize> = survivors.iter().map(|(i, _, _)| *i).collect();
|
||||
let ordinals: Vec<usize> = members.iter().map(|(i, _, _)| *i).collect();
|
||||
let empty = ordinals.is_empty();
|
||||
stages.push(StageRealization {
|
||||
block: "std::gate".to_string(),
|
||||
@@ -352,6 +492,7 @@ fn run_cell(
|
||||
survivor_ordinals: Some(ordinals),
|
||||
selection: None,
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
});
|
||||
if empty {
|
||||
nominee = None; // a truncated cell nominates no candidate
|
||||
@@ -368,9 +509,8 @@ fn run_cell(
|
||||
// The seam crossing is plain points: the walk-forward stage
|
||||
// re-sweeps the surviving param points and never needs the
|
||||
// gate bookkeeping's ordinals or reports.
|
||||
let survivor_points: Vec<Vec<Scalar>> =
|
||||
survivors.iter().map(|(_, point, _)| point.clone()).collect();
|
||||
let fam = run_walk_forward_stage(
|
||||
let survivor_points: Vec<Vec<Scalar>> = flow.points();
|
||||
match run_walk_forward_stage(
|
||||
seed,
|
||||
cell,
|
||||
stage_ordinal,
|
||||
@@ -381,22 +521,31 @@ fn run_cell(
|
||||
&family_name,
|
||||
runner,
|
||||
registry,
|
||||
)?;
|
||||
stages.push(StageRealization {
|
||||
block: "std::walk_forward".to_string(),
|
||||
family_id: Some(fam.family_id.clone()),
|
||||
survivor_ordinals: None,
|
||||
selection: None,
|
||||
bootstrap: None,
|
||||
});
|
||||
// Nominate the LAST window's OOS report (roll order per
|
||||
// walkforward_member_reports) with its chosen params — the wf
|
||||
// stage stamps them on the fresh OOS report's manifest.
|
||||
nominee = fam
|
||||
.reports
|
||||
.last()
|
||||
.map(|last| (last.manifest.params.clone(), last.clone()));
|
||||
families.push(fam);
|
||||
) {
|
||||
Ok(WfStageOutcome::Ran { family: fam, window_faults }) => {
|
||||
stages.push(StageRealization {
|
||||
block: "std::walk_forward".to_string(),
|
||||
family_id: Some(fam.family_id.clone()),
|
||||
survivor_ordinals: None,
|
||||
selection: None,
|
||||
bootstrap: None,
|
||||
window_faults,
|
||||
});
|
||||
// Nominate the LAST window's OOS report (roll order,
|
||||
// over the surviving folds) with its chosen params —
|
||||
// the wf stage stamps them on the fresh OOS report's
|
||||
// manifest.
|
||||
nominee = fam
|
||||
.reports
|
||||
.last()
|
||||
.map(|last| (last.manifest.params.clone(), last.clone()));
|
||||
families.push(fam);
|
||||
}
|
||||
Ok(WfStageOutcome::CellFailed(cf)) => {
|
||||
return Ok(failed_cell(cell, stages, families, selections, cf, runner));
|
||||
}
|
||||
Err(e) => return Err(e), // Registry / global refusal
|
||||
}
|
||||
}
|
||||
StageBlock::MonteCarlo { resamples, block_len } => {
|
||||
// Terminal annotator (#200 d1/d3): nothing flows out. Dual
|
||||
@@ -407,7 +556,7 @@ fn run_cell(
|
||||
let bootstrap =
|
||||
match families.iter().rev().find(|f| f.block == "std::walk_forward") {
|
||||
Some(fam) => StageBootstrap::PooledOos(r_bootstrap(
|
||||
&pooled_trade_rs(&fam.reports),
|
||||
&pooled_net_trade_rs(&fam.reports),
|
||||
*resamples as usize,
|
||||
*block_len as usize,
|
||||
seed,
|
||||
@@ -415,28 +564,39 @@ fn run_cell(
|
||||
// Zero-trade members (r: None) feed an empty slice —
|
||||
// the engine's defined all-zero degenerate, recorded
|
||||
// explicitly (a null result is a valid result).
|
||||
None => StageBootstrap::PerSurvivor(
|
||||
survivors
|
||||
.iter()
|
||||
.map(|(ordinal, _, report)| {
|
||||
let rs = report
|
||||
.metrics
|
||||
.r
|
||||
.as_ref()
|
||||
.map(|r| r.trade_rs.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
(
|
||||
*ordinal,
|
||||
r_bootstrap(
|
||||
rs,
|
||||
*resamples as usize,
|
||||
*block_len as usize,
|
||||
seed,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
None => {
|
||||
let StageFlow::Members(members) = &flow else {
|
||||
return Err(ExecFault::PipelineShape {
|
||||
detail: format!(
|
||||
"stage {stage_ordinal}: std::monte_carlo \
|
||||
per-survivor bootstrap needs executed members \
|
||||
(preflight admits no std::grid predecessor)"
|
||||
),
|
||||
});
|
||||
};
|
||||
StageBootstrap::PerSurvivor(
|
||||
members
|
||||
.iter()
|
||||
.map(|(ordinal, _, report)| {
|
||||
let rs = report
|
||||
.metrics
|
||||
.r
|
||||
.as_ref()
|
||||
.map(|r| r.net_trade_rs.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
(
|
||||
*ordinal,
|
||||
r_bootstrap(
|
||||
rs,
|
||||
*resamples as usize,
|
||||
*block_len as usize,
|
||||
seed,
|
||||
),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
};
|
||||
stages.push(StageRealization {
|
||||
block: "std::monte_carlo".to_string(),
|
||||
@@ -444,6 +604,7 @@ fn run_cell(
|
||||
survivor_ordinals: None,
|
||||
selection: None,
|
||||
bootstrap: Some(bootstrap),
|
||||
window_faults: vec![],
|
||||
});
|
||||
}
|
||||
StageBlock::Generalize { .. } => {
|
||||
@@ -462,6 +623,8 @@ fn run_cell(
|
||||
window_ms: cell.window_ms,
|
||||
regime: cell.regime,
|
||||
regime_ordinal: cell.regime_ordinal,
|
||||
fault: None,
|
||||
coverage: runner.window_coverage(cell),
|
||||
stages,
|
||||
},
|
||||
))
|
||||
@@ -500,6 +663,54 @@ fn enumerate_grid(axes: &BTreeMap<String, Axis>) -> SweepGrid {
|
||||
SweepGrid { specs, axis_lens, points }
|
||||
}
|
||||
|
||||
/// Ref-counted panic-hook silencer: while at least one `SilencedPanic` guard
|
||||
/// is alive, the process's panic hook is a no-op, so a member panic CONTAINED
|
||||
/// by one of this module's three `catch_unwind` sites (recorded as a
|
||||
/// `MemberFault`/`WindowFault`, the run continuing past it) does not also
|
||||
/// paint a crash-looking default backtrace to stderr — "recorded, campaign
|
||||
/// continues" stays observably true, not just true in the record. Ref-counted
|
||||
/// rather than a bare set/restore because `sweep`/`walk_forward` run these
|
||||
/// closures on multiple OS threads concurrently (C1 disjoint-parallel
|
||||
/// members): a naive per-closure set_hook/take_hook pair would race two
|
||||
/// threads' install/restore against each other. The previous hook (whatever
|
||||
/// it was — default or a caller's own) is saved on the 0→1 transition and
|
||||
/// restored on the 1→0 transition, so this never permanently clobbers a
|
||||
/// hook installed above this module.
|
||||
struct SilencedPanic;
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
static PANIC_HOOK_STATE: Mutex<(usize, Option<Box<dyn Fn(&std::panic::PanicHookInfo<'_>) + Sync + Send>>)> =
|
||||
Mutex::new((0, None));
|
||||
|
||||
impl SilencedPanic {
|
||||
/// Enter the silenced section; drop the returned guard to leave it. Hold
|
||||
/// this only around the `catch_unwind` call itself, never the whole
|
||||
/// member-run closure, so a genuinely uncontained escape (this module's
|
||||
/// own bug, not the member's) still surfaces normally once the guard has
|
||||
/// dropped and unwinding continues past `catch_unwind`.
|
||||
fn enter() -> Self {
|
||||
let mut state = PANIC_HOOK_STATE.lock().expect("panic hook state lock");
|
||||
if state.0 == 0 {
|
||||
state.1 = Some(std::panic::take_hook());
|
||||
std::panic::set_hook(Box::new(|_| {}));
|
||||
}
|
||||
state.0 += 1;
|
||||
SilencedPanic
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SilencedPanic {
|
||||
fn drop(&mut self) {
|
||||
let mut state = PANIC_HOOK_STATE.lock().expect("panic hook state lock");
|
||||
state.0 -= 1;
|
||||
if state.0 == 0
|
||||
&& let Some(prev) = state.1.take()
|
||||
{
|
||||
std::panic::set_hook(prev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run every grid point through the consumer's `MemberRunner` via the engine
|
||||
/// `sweep` (disjoint parallel members, C1 enumeration order). Faults are
|
||||
/// captured per slot (the closure must return a report), and the LOWEST
|
||||
@@ -526,9 +737,15 @@ fn run_members(
|
||||
let faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
|
||||
let family = sweep(&space, |point: &[Cell]| {
|
||||
let params = zip_params(&grid.specs, point);
|
||||
match runner.run_member(cell, ¶ms, cell.window_ms) {
|
||||
Ok(report) => report,
|
||||
Err(fault) => {
|
||||
let called = {
|
||||
let _silence = SilencedPanic::enter();
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
runner.run_member(cell, ¶ms, cell.window_ms)
|
||||
}))
|
||||
};
|
||||
match called {
|
||||
Ok(Ok(report)) => report,
|
||||
Ok(Err(fault)) => {
|
||||
let index = cells
|
||||
.iter()
|
||||
.position(|c| c.as_slice() == point)
|
||||
@@ -536,6 +753,17 @@ fn run_members(
|
||||
faults.lock().expect("fault capture lock").push((index, fault));
|
||||
placeholder_report(¶ms, cell.window_ms)
|
||||
}
|
||||
Err(payload) => {
|
||||
let index = cells
|
||||
.iter()
|
||||
.position(|c| c.as_slice() == point)
|
||||
.expect("the sweep only enumerates the grid's own points");
|
||||
faults
|
||||
.lock()
|
||||
.expect("fault capture lock")
|
||||
.push((index, MemberFault::Panic(panic_message(payload))));
|
||||
placeholder_report(¶ms, cell.window_ms)
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut captured = faults.into_inner().expect("fault capture lock");
|
||||
@@ -546,6 +774,110 @@ fn run_members(
|
||||
Ok(family)
|
||||
}
|
||||
|
||||
/// #272: which executor faults are per-cell outcomes vs campaign-wide
|
||||
/// refusals. Member and window faults are data-and-cell-local; everything else
|
||||
/// (doc-shape, registry) stays a global refusal returned to the caller.
|
||||
fn contain(fault: ExecFault, stage: usize) -> Result<aura_registry::CellFault, ExecFault> {
|
||||
match fault {
|
||||
ExecFault::Member(m) => Ok(aura_registry::CellFault {
|
||||
stage,
|
||||
kind: member_kind(&m),
|
||||
detail: member_fault_prose(&m),
|
||||
}),
|
||||
ExecFault::Window { stage: s, detail } => Ok(aura_registry::CellFault {
|
||||
stage: s,
|
||||
kind: aura_registry::CellFaultKind::Window,
|
||||
detail,
|
||||
}),
|
||||
other => Err(other),
|
||||
}
|
||||
}
|
||||
|
||||
fn member_kind(m: &MemberFault) -> aura_registry::CellFaultKind {
|
||||
use aura_registry::CellFaultKind as K;
|
||||
match m {
|
||||
MemberFault::NoData { .. } => K::NoData,
|
||||
MemberFault::Bind(_) => K::Bind,
|
||||
MemberFault::Run(_) => K::Run,
|
||||
MemberFault::Panic(_) => K::Panic,
|
||||
}
|
||||
}
|
||||
|
||||
/// Prose for a member fault. `pub` (re-exported at the crate root) so the
|
||||
/// CLI's `exec_fault_prose` phrases the `ExecFault::Member` arm by calling
|
||||
/// this directly instead of carrying its own duplicate of the wording.
|
||||
/// Debug-leak-free, self-describing.
|
||||
pub fn member_fault_prose(m: &MemberFault) -> String {
|
||||
match m {
|
||||
MemberFault::NoData { instrument, window_ms } => format!(
|
||||
"no data for instrument {instrument} in window [{}, {}] (epoch-ms)",
|
||||
window_ms.0, window_ms.1
|
||||
),
|
||||
MemberFault::Bind(detail) => format!("a member failed to bind: {detail}"),
|
||||
MemberFault::Run(detail) => format!("a member failed to run: {detail}"),
|
||||
MemberFault::Panic(detail) => format!("a member panicked: {detail}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort panic-payload message (String / &str payloads; else a fixed
|
||||
/// marker). `AssertUnwindSafe` at the call sites is a documented judgement: the
|
||||
/// runner is reused for later cells; the CLI runner holds read-only state.
|
||||
fn panic_message(payload: Box<dyn std::any::Any + Send>) -> String {
|
||||
payload
|
||||
.downcast_ref::<String>()
|
||||
.cloned()
|
||||
.or_else(|| payload.downcast_ref::<&str>().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| "member panicked (non-string payload)".to_string())
|
||||
}
|
||||
|
||||
/// The `SilencedPanic` + `catch_unwind` + `panic_message` recipe above
|
||||
/// (this module's three call sites), exposed for reuse by a member-run caller
|
||||
/// OUTSIDE this crate that drives a bare member run without going through
|
||||
/// `execute` (#278: the synthetic blueprint sweep/walk-forward family
|
||||
/// builders in `aura-runner`, which share this exact bare-member seam but
|
||||
/// carry no #272 fault boundary of their own). Returns the panic payload's
|
||||
/// best-effort message on containment; `AssertUnwindSafe` is the same
|
||||
/// judgement documented on `panic_message` above.
|
||||
pub fn catch_member_panic<R>(f: impl FnOnce() -> R) -> Result<R, String> {
|
||||
let _silence = SilencedPanic::enter();
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).map_err(panic_message)
|
||||
}
|
||||
|
||||
/// Assemble the (CellOutcome, CellRealization) pair for a cell that failed at a
|
||||
/// stage (#272): the realized stage prefix survives in `stages`, `families`/
|
||||
/// `selections` carry whatever already-persisted stages produced (empty for a
|
||||
/// sweep-first fault; the sweep family/selection for a later wf-stage fault),
|
||||
/// no nominee, the fault stamped on both, coverage from the runner.
|
||||
fn failed_cell(
|
||||
cell: &CellSpec,
|
||||
stages: Vec<StageRealization>,
|
||||
families: Vec<StageFamily>,
|
||||
selections: Vec<StageSelectionOut>,
|
||||
fault: aura_registry::CellFault,
|
||||
runner: &dyn MemberRunner,
|
||||
) -> (CellOutcome, CellRealization) {
|
||||
(
|
||||
CellOutcome { families, selections, nominee: None },
|
||||
CellRealization {
|
||||
strategy: cell.strategy_id.clone(),
|
||||
instrument: cell.instrument.clone(),
|
||||
window_ms: cell.window_ms,
|
||||
stages,
|
||||
regime: cell.regime,
|
||||
regime_ordinal: cell.regime_ordinal,
|
||||
fault: Some(fault),
|
||||
coverage: runner.window_coverage(cell),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Sentinel `RunManifest::broker` value stamped onto [`placeholder_report`]'s
|
||||
/// output — the single source both the setter and the survivor filter
|
||||
/// (`run_members`'s `... != FAULTED_MEMBER_PLACEHOLDER_BROKER` sibling) read,
|
||||
/// so the two can never drift apart into silently pooling a faulted
|
||||
/// placeholder into a real family/OOS selection.
|
||||
const FAULTED_MEMBER_PLACEHOLDER_BROKER: &str = "faulted-member-placeholder";
|
||||
|
||||
/// Slot filler for a faulted member: the engine sweep contract needs one
|
||||
/// report per slot, but a captured fault aborts `execute` before any family
|
||||
/// write, so this report is never persisted and never ranked.
|
||||
@@ -554,9 +886,10 @@ fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> Run
|
||||
manifest: RunManifest {
|
||||
commit: String::new(),
|
||||
params: params.to_vec(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: "faulted-member-placeholder".to_string(),
|
||||
broker: FAULTED_MEMBER_PLACEHOLDER_BROKER.to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
@@ -626,15 +959,16 @@ fn scalar_point(space: &[ParamSpec], point: &[Cell]) -> Vec<Scalar> {
|
||||
space.iter().zip(point).map(|(ps, c)| Scalar::from_cell(ps.kind, *c)).collect()
|
||||
}
|
||||
|
||||
/// The pooled walk-forward OOS trade-R series: the family reports' `trade_rs`
|
||||
/// The pooled walk-forward OOS trade-R series: the family reports' `net_trade_rs`
|
||||
/// concatenated in report order — which IS roll order (the wf stage builds its
|
||||
/// family via `walkforward_member_reports`, per-window OOS reports in roll
|
||||
/// order). Reports without an R block contribute nothing.
|
||||
fn pooled_trade_rs(reports: &[RunReport]) -> Vec<f64> {
|
||||
/// family via `walkforward_member_reports_of`, per-window OOS reports in roll
|
||||
/// order, over the surviving folds). Reports without an R block contribute
|
||||
/// nothing.
|
||||
fn pooled_net_trade_rs(reports: &[RunReport]) -> Vec<f64> {
|
||||
let mut pooled = Vec::new();
|
||||
for report in reports {
|
||||
if let Some(r) = &report.metrics.r {
|
||||
pooled.extend_from_slice(&r.trade_rs);
|
||||
pooled.extend_from_slice(&r.net_trade_rs);
|
||||
}
|
||||
}
|
||||
pooled
|
||||
@@ -677,7 +1011,7 @@ fn run_walk_forward_stage(
|
||||
family_name: &str,
|
||||
runner: &dyn MemberRunner,
|
||||
registry: &Registry,
|
||||
) -> Result<StageFamily, ExecFault> {
|
||||
) -> Result<WfStageOutcome, ExecFault> {
|
||||
let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select } =
|
||||
block
|
||||
else {
|
||||
@@ -756,9 +1090,16 @@ fn run_walk_forward_stage(
|
||||
// with the same member fault capture the sweep stage uses.
|
||||
let is_faults: Mutex<Vec<(usize, MemberFault)>> = Mutex::new(Vec::new());
|
||||
let is_family = sweep(&is_space, |cells| {
|
||||
match runner.run_member(cell, &zip_params(specs, cells), (w.is.0 .0, w.is.1 .0)) {
|
||||
Ok(report) => report,
|
||||
Err(fault) => {
|
||||
let params = zip_params(specs, cells);
|
||||
let called = {
|
||||
let _silence = SilencedPanic::enter();
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
runner.run_member(cell, ¶ms, (w.is.0 .0, w.is.1 .0))
|
||||
}))
|
||||
};
|
||||
match called {
|
||||
Ok(Ok(report)) => report,
|
||||
Ok(Err(fault)) => {
|
||||
let midx = is_points
|
||||
.iter()
|
||||
.position(|p| p.as_slice() == cells)
|
||||
@@ -766,6 +1107,14 @@ fn run_walk_forward_stage(
|
||||
is_faults.lock().unwrap().push((midx, fault));
|
||||
placeholder_report(&[], (0, 0))
|
||||
}
|
||||
Err(payload) => {
|
||||
let midx = is_points
|
||||
.iter()
|
||||
.position(|p| p.as_slice() == cells)
|
||||
.expect("sweep enumerates exactly is_points");
|
||||
is_faults.lock().unwrap().push((midx, MemberFault::Panic(panic_message(payload))));
|
||||
placeholder_report(&[], (0, 0))
|
||||
}
|
||||
}
|
||||
});
|
||||
let member_faults = is_faults.into_inner().expect("no thread panicked holding the lock");
|
||||
@@ -788,29 +1137,93 @@ fn run_walk_forward_stage(
|
||||
// OOS: run the winner over the out-of-sample bounds; the selection is
|
||||
// stamped on the fresh OOS report (a new record, never a mutation of
|
||||
// a stored one).
|
||||
let mut oos_report = match runner.run_member(
|
||||
cell,
|
||||
&zip_params(specs, &winner.params),
|
||||
(w.oos.0 .0, w.oos.1 .0),
|
||||
) {
|
||||
Ok(report) => report,
|
||||
Err(fault) => {
|
||||
let oos_params = zip_params(specs, &winner.params);
|
||||
let oos_called = {
|
||||
let _silence = SilencedPanic::enter();
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
runner.run_member(cell, &oos_params, (w.oos.0 .0, w.oos.1 .0))
|
||||
}))
|
||||
};
|
||||
let mut oos_report = match oos_called {
|
||||
Ok(Ok(report)) => report,
|
||||
Ok(Err(fault)) => {
|
||||
faults.lock().unwrap().push((widx, ExecFault::Member(fault)));
|
||||
return placeholder_window_run(specs);
|
||||
}
|
||||
Err(payload) => {
|
||||
faults
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((widx, ExecFault::Member(MemberFault::Panic(panic_message(payload)))));
|
||||
return placeholder_window_run(specs);
|
||||
}
|
||||
};
|
||||
oos_report.manifest.selection = Some(selection);
|
||||
WindowRun { chosen_params: winner.params, oos_equity: vec![], oos_report }
|
||||
});
|
||||
|
||||
let window_faults = faults.into_inner().expect("no thread panicked holding the lock");
|
||||
if let Some((_, fault)) = window_faults.into_iter().min_by_key(|(i, _)| *i) {
|
||||
return Err(fault);
|
||||
// Registry faults (optimizer/persistence broken) stay a global abort;
|
||||
// Member/Window faults become per-fold WindowFaults via the same
|
||||
// `contain` mapping the cell-level containment uses. One drain, in
|
||||
// ascending window-index order — ExecFault/RegistryError are not Clone,
|
||||
// so this is a single ownership-respecting pass rather than iter()-then-
|
||||
// into_iter(); the first Registry fault encountered (ascending order =
|
||||
// lowest index) wins, matching the existing lowest-index-fault convention.
|
||||
let mut captured = faults.into_inner().expect("no thread panicked holding the lock");
|
||||
captured.sort_by_key(|(idx, _)| *idx);
|
||||
let mut window_faults: Vec<aura_registry::WindowFault> = Vec::with_capacity(captured.len());
|
||||
for (widx, fault) in captured {
|
||||
match contain(fault, stage) {
|
||||
Ok(cf) => window_faults.push(aura_registry::WindowFault {
|
||||
window_ordinal: widx,
|
||||
kind: cf.kind,
|
||||
detail: cf.detail,
|
||||
}),
|
||||
Err(global) => return Err(global),
|
||||
}
|
||||
}
|
||||
|
||||
let reports = walkforward_member_reports(&result);
|
||||
// Keep only real OOS reports (drop faulted-fold placeholders by the broker
|
||||
// sentinel) before building the family.
|
||||
let surviving: Vec<&aura_engine::WindowOutcome<RunMetrics>> = result
|
||||
.windows
|
||||
.iter()
|
||||
.filter(|w| w.run.oos_report.manifest.broker != FAULTED_MEMBER_PLACEHOLDER_BROKER)
|
||||
.collect();
|
||||
if surviving.is_empty() {
|
||||
// Every fold failed — the cell fails at this stage.
|
||||
let first = window_faults.first().cloned();
|
||||
return Ok(WfStageOutcome::CellFailed(aura_registry::CellFault {
|
||||
stage,
|
||||
kind: first.as_ref().map(|w| w.kind).unwrap_or(aura_registry::CellFaultKind::Run),
|
||||
detail: format!(
|
||||
"all {} walk_forward folds failed{}",
|
||||
result.windows.len(),
|
||||
first.map(|w| format!("; first: {}", w.detail)).unwrap_or_default()
|
||||
),
|
||||
}));
|
||||
}
|
||||
let reports = walkforward_member_reports_of(&surviving);
|
||||
let family_id = registry
|
||||
.append_family(family_name, FamilyKind::WalkForward, &reports)
|
||||
.map_err(ExecFault::Registry)?;
|
||||
Ok(StageFamily { stage, block: "std::walk_forward", family_id, reports })
|
||||
Ok(WfStageOutcome::Ran {
|
||||
family: StageFamily { stage, block: "std::walk_forward", family_id, reports },
|
||||
window_faults,
|
||||
})
|
||||
}
|
||||
|
||||
/// #272: a wf stage either ran (its family + any per-fold holes) or the whole
|
||||
/// cell failed (every fold faulted). A Registry fault is a separate global Err
|
||||
/// (returned directly from `run_walk_forward_stage`, never wrapped here).
|
||||
enum WfStageOutcome {
|
||||
Ran { family: StageFamily, window_faults: Vec<aura_registry::WindowFault> },
|
||||
CellFailed(aura_registry::CellFault),
|
||||
}
|
||||
|
||||
/// The surviving (non-faulted) walk-forward windows' OOS reports, in roll
|
||||
/// order — the same mapping `walkforward_member_reports` does over a full
|
||||
/// result, applied to the filtered survivor slice.
|
||||
fn walkforward_member_reports_of(windows: &[&aura_engine::WindowOutcome<RunMetrics>]) -> Vec<RunReport> {
|
||||
windows.iter().map(|w| w.run.oos_report.clone()).collect()
|
||||
}
|
||||
|
||||
+302
-72
@@ -12,15 +12,30 @@
|
||||
//! and no UI.
|
||||
|
||||
mod exec;
|
||||
pub use exec::{execute, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut};
|
||||
pub use exec::{
|
||||
catch_member_panic, execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily,
|
||||
StageSelectionOut,
|
||||
};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use aura_core::Scalar;
|
||||
use aura_engine::RunReport;
|
||||
use aura_backtest::RunReport;
|
||||
use aura_registry::{check_r_metric, RegistryError};
|
||||
use aura_research::{Axis, CampaignDoc, Cmp, ProcessDoc, RiskRegime, SelectRule, StageBlock};
|
||||
|
||||
/// Default bound on distinct concurrently-active instruments in the campaign
|
||||
/// cell loop (the RAM lever): the external data-server retains an
|
||||
/// instrument's parsed files while any of its cells is live (~1.4 GB per
|
||||
/// instrument on the reference dataset), so the resident set scales with this
|
||||
/// bound, not with pool workers. 1 reproduces the sequential loop's
|
||||
/// footprint.
|
||||
pub const DEFAULT_PARALLEL_INSTRUMENTS: NonZeroUsize = match NonZeroUsize::new(4) {
|
||||
Some(n) => n,
|
||||
None => panic!("4 is nonzero"),
|
||||
};
|
||||
|
||||
/// One structural cell of the campaign matrix: (strategy, instrument,
|
||||
/// window) — #198 decision 7.
|
||||
pub struct CellSpec {
|
||||
@@ -47,6 +62,10 @@ pub struct CellSpec {
|
||||
/// them to its harness convention and runs the member over `cell.instrument`
|
||||
/// restricted to `window_ms` (inclusive epoch-ms, a sub-range of
|
||||
/// `cell.window_ms` — a walk-forward stage passes sub-windows).
|
||||
///
|
||||
/// The shipped default implementation is `aura_runner::DefaultMemberRunner`
|
||||
/// (the C28 assembly crate); this crate deliberately does not depend on it —
|
||||
/// consumers pass the runner in (#295).
|
||||
pub trait MemberRunner: Sync {
|
||||
fn run_member(
|
||||
&self,
|
||||
@@ -54,6 +73,13 @@ pub trait MemberRunner: Sync {
|
||||
params: &[(String, Scalar)],
|
||||
window_ms: (i64, i64),
|
||||
) -> Result<RunReport, MemberFault>;
|
||||
|
||||
/// The archive coverage of this cell's (instrument, window), when the
|
||||
/// runner can know it and it deviates from the requested window (#272).
|
||||
/// One call per cell — coverage is a property of the cell, not a member.
|
||||
fn window_coverage(&self, _cell: &CellSpec) -> Option<aura_registry::CellCoverage> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Display-free member faults (the consumer phrases them — the RefFault
|
||||
@@ -63,6 +89,9 @@ pub enum MemberFault {
|
||||
NoData { instrument: String, window_ms: (i64, i64) },
|
||||
Bind(String),
|
||||
Run(String),
|
||||
/// A panic that unwound out of `MemberRunner::run_member`, caught at the
|
||||
/// member boundary (#272) — the payload's best-effort message.
|
||||
Panic(String),
|
||||
}
|
||||
|
||||
/// Preflight + runtime refusals. Display-free and by-identifier: the
|
||||
@@ -109,10 +138,11 @@ pub enum ExecFault {
|
||||
/// `RunMetrics` scalars plus the 11 `RMetrics` scalars. The three
|
||||
/// selection-annotation names (`deflated_score` / `overfit_probability` /
|
||||
/// `neighbourhood_score`) are deliberately NOT here — they describe a
|
||||
/// selection, not a member. Hand-copied roster (the third metric-roster site
|
||||
/// beside aura-research's 17-name vocabulary and aura-registry's rankable
|
||||
/// set, #190); drift fails safe: an unknown name is a preflight refusal,
|
||||
/// never a wrong number.
|
||||
/// selection, not a member. Hand-copied roster beside aura-research's 17-name
|
||||
/// vocabulary (#190); the rankable set is single-sourced from the backtest
|
||||
/// vocabulary since #147, and this roster's nesting is oracle-pinned by the
|
||||
/// extended #190 guard. Drift fails safe: an unknown name is a preflight
|
||||
/// refusal, never a wrong number.
|
||||
pub const PER_MEMBER_METRICS: &[&str] = &[
|
||||
"total_pips", "max_drawdown", "bias_sign_flips",
|
||||
"expectancy_r", "n_trades", "win_rate", "avg_win_r", "avg_loss_r",
|
||||
@@ -120,14 +150,10 @@ pub const PER_MEMBER_METRICS: &[&str] = &[
|
||||
"sqn", "net_expectancy_r",
|
||||
];
|
||||
|
||||
/// The registry's rankable roster (`resolve_metric`'s name set) — the metrics
|
||||
/// a sweep/walk_forward stage may select on. Hand-copied (#190); drift fails
|
||||
/// safe (a name the registry would refuse is refused here first, before any
|
||||
/// member runs).
|
||||
pub const RANKABLE_METRICS: &[&str] = &[
|
||||
"total_pips", "max_drawdown", "bias_sign_flips",
|
||||
"sqn", "sqn_normalized", "expectancy_r", "net_expectancy_r",
|
||||
];
|
||||
/// The rankable subset a stage's `rank_by`/`select` may name — re-exported
|
||||
/// from the backtest vocabulary (single source, #147): this roster and the
|
||||
/// registry's refusal prose can no longer drift.
|
||||
pub use aura_backtest::RANKABLE_METRICS;
|
||||
|
||||
/// Deflation resample count — re-exported from `aura-registry`'s shared
|
||||
/// definition (single-sourced, #199).
|
||||
@@ -196,6 +222,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
||||
fn shape_rank(stage: &StageBlock) -> usize {
|
||||
match stage {
|
||||
StageBlock::Sweep { .. } => 0,
|
||||
StageBlock::Grid => 0,
|
||||
StageBlock::Gate { .. } => 1,
|
||||
StageBlock::WalkForward { .. } => 2,
|
||||
StageBlock::MonteCarlo { .. } => 3,
|
||||
@@ -205,27 +232,60 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
||||
fn block_id(stage: &StageBlock) -> &'static str {
|
||||
match stage {
|
||||
StageBlock::Sweep { .. } => "std::sweep",
|
||||
StageBlock::Grid => "std::grid",
|
||||
StageBlock::Gate { .. } => "std::gate",
|
||||
StageBlock::WalkForward { .. } => "std::walk_forward",
|
||||
StageBlock::MonteCarlo { .. } => "std::monte_carlo",
|
||||
StageBlock::Generalize { .. } => "std::generalize",
|
||||
}
|
||||
}
|
||||
// Duplicate instruments enumerate identical cells whose registry family
|
||||
// names collide (the name embeds the raw instrument string), racing one
|
||||
// name's run-index assignment under the parallel cell loop — refused here
|
||||
// as defense in depth beside the CLI's validate tier (#277 audit tidy).
|
||||
let mut seen_instruments = std::collections::BTreeSet::new();
|
||||
for instrument in &campaign.data.instruments {
|
||||
if !seen_instruments.insert(instrument.as_str()) {
|
||||
return Err(ExecFault::PipelineShape {
|
||||
detail: format!(
|
||||
"campaign.data.instruments lists \"{instrument}\" more than once — \
|
||||
duplicate instruments collide on registry family names"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
// shape: `std::sweep (std::gate)* (std::walk_forward)? (std::monte_carlo)?
|
||||
// (std::generalize)?` — a monotone rank walk over adjacent pairs captures
|
||||
// every violation: a rank drop is an out-of-order stage (an annotator
|
||||
// before a population stage, anything after std::generalize), an adjacent
|
||||
// equal rank above the gate tier is a duplicate suffix stage.
|
||||
if !matches!(process.pipeline.first(), Some(StageBlock::Sweep { .. })) {
|
||||
if !matches!(
|
||||
process.pipeline.first(),
|
||||
Some(StageBlock::Sweep { .. } | StageBlock::Grid)
|
||||
) {
|
||||
return Err(ExecFault::PipelineShape {
|
||||
detail: "the first stage must be std::sweep".to_string(),
|
||||
detail: "the first stage must be std::sweep or std::grid".to_string(),
|
||||
});
|
||||
}
|
||||
// #256 fork B: an enumerate-only first stage yields points, not executed
|
||||
// members — every stage except std::walk_forward consumes member reports,
|
||||
// so std::grid must be immediately followed by std::walk_forward (in
|
||||
// particular it is never terminal).
|
||||
if matches!(process.pipeline.first(), Some(StageBlock::Grid))
|
||||
&& !matches!(process.pipeline.get(1), Some(StageBlock::WalkForward { .. }))
|
||||
{
|
||||
return Err(ExecFault::PipelineShape {
|
||||
detail: "a std::grid first stage must be immediately followed by \
|
||||
std::walk_forward (every other stage consumes executed \
|
||||
member reports)"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
let mut prev = &process.pipeline[0];
|
||||
for (i, stage) in process.pipeline.iter().enumerate().skip(1) {
|
||||
if matches!(stage, StageBlock::Sweep { .. }) {
|
||||
if matches!(stage, StageBlock::Sweep { .. } | StageBlock::Grid) {
|
||||
return Err(ExecFault::PipelineShape {
|
||||
detail: format!("stage {i}: only the first stage may be std::sweep"),
|
||||
detail: format!("stage {i}: only the first stage may be {}", block_id(stage)),
|
||||
});
|
||||
}
|
||||
let (rank, prev_rank) = (shape_rank(stage), shape_rank(prev));
|
||||
@@ -248,6 +308,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
||||
// per-stage slot rules (shape already established above).
|
||||
for (i, stage) in process.pipeline.iter().enumerate() {
|
||||
match stage {
|
||||
StageBlock::Grid => {}
|
||||
StageBlock::Sweep { selection } => match selection {
|
||||
Some(sel) => {
|
||||
if !RANKABLE_METRICS.contains(&sel.metric.as_str()) {
|
||||
@@ -329,7 +390,8 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp};
|
||||
use aura_backtest::{RMetrics, RunMetrics};
|
||||
use aura_engine::{RunManifest, Timestamp};
|
||||
use aura_research::SweepSelection;
|
||||
|
||||
/// A report with every per-member scalar planted to a distinct value
|
||||
@@ -339,6 +401,7 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "test".to_string(),
|
||||
params: vec![],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(0), Timestamp(1)),
|
||||
seed: 0,
|
||||
broker: "sim".to_string(),
|
||||
@@ -364,7 +427,7 @@ mod tests {
|
||||
sqn: 13.0,
|
||||
net_expectancy_r: 14.0,
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
trade_rs: Vec::new(),
|
||||
net_trade_rs: Vec::new(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -730,6 +793,67 @@ mod tests {
|
||||
assert!(preflight(&process, &c).is_ok());
|
||||
}
|
||||
|
||||
/// #256 fork B: the enumerate-only leading stage is accepted exactly in
|
||||
/// the dissolved walkforward/mc shapes — first, immediately before
|
||||
/// std::walk_forward.
|
||||
#[test]
|
||||
fn preflight_accepts_a_grid_first_stage_followed_by_walk_forward() {
|
||||
let c = campaign();
|
||||
let wf_shape =
|
||||
process_of(vec![StageBlock::Grid, wf_stage("sqn_normalized", SelectRule::Argmax)]);
|
||||
assert!(preflight(&wf_shape, &c).is_ok());
|
||||
let mc_shape = process_of(vec![
|
||||
StageBlock::Grid,
|
||||
wf_stage("sqn_normalized", SelectRule::Argmax),
|
||||
mc_stage(1000, 5),
|
||||
]);
|
||||
assert!(preflight(&mc_shape, &c).is_ok());
|
||||
}
|
||||
|
||||
/// #256 fork B: every other placement is refused — grid terminal, grid
|
||||
/// before a report-consuming stage (gate / monte_carlo / generalize),
|
||||
/// grid anywhere but first. Every refusal is a PipelineShape prose fault.
|
||||
#[test]
|
||||
fn preflight_refuses_grid_placement_violations() {
|
||||
let c = campaign();
|
||||
let c2 = campaign_two_instruments();
|
||||
let adjacency = "immediately followed by std::walk_forward";
|
||||
let cases: Vec<(Vec<StageBlock>, &str, &CampaignDoc)> = vec![
|
||||
(vec![StageBlock::Grid], adjacency, &c),
|
||||
(
|
||||
vec![
|
||||
StageBlock::Grid,
|
||||
gate_stage("net_expectancy_r"),
|
||||
wf_stage("sqn_normalized", SelectRule::Argmax),
|
||||
],
|
||||
adjacency,
|
||||
&c,
|
||||
),
|
||||
(vec![StageBlock::Grid, mc_stage(1000, 5)], adjacency, &c),
|
||||
(
|
||||
vec![StageBlock::Grid, generalize_stage("net_expectancy_r")],
|
||||
adjacency,
|
||||
&c2,
|
||||
),
|
||||
(
|
||||
vec![
|
||||
sweep_stage("sqn_normalized", SelectRule::Argmax, false),
|
||||
StageBlock::Grid,
|
||||
],
|
||||
"only the first stage may be std::grid",
|
||||
&c,
|
||||
),
|
||||
];
|
||||
for (pipeline, needle, camp) in cases {
|
||||
let err =
|
||||
preflight(&process_of(pipeline), camp).expect_err("placement violation");
|
||||
let ExecFault::PipelineShape { detail } = &err else {
|
||||
panic!("expected PipelineShape, got {err:?}");
|
||||
};
|
||||
assert!(detail.contains(needle), "detail {detail:?} misses {needle:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A selection-free sweep followed by any other stage is refused: a
|
||||
/// downstream stage would have no selection/nominee to consume.
|
||||
#[test]
|
||||
@@ -870,8 +994,9 @@ mod wf_tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use aura_analysis::SelectionMode;
|
||||
use aura_backtest::{RunMetrics, RunReport};
|
||||
use aura_core::{Scalar, ScalarKind};
|
||||
use aura_engine::{RollMode, RunManifest, RunMetrics, RunReport, Timestamp, WindowRoller};
|
||||
use aura_engine::{RollMode, RunManifest, Timestamp, WindowRoller};
|
||||
use aura_registry::{FamilyKind, Registry};
|
||||
use aura_research::{
|
||||
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation,
|
||||
@@ -884,8 +1009,10 @@ mod wf_tests {
|
||||
/// A per-test registry in a fresh temp dir (a Registry's family store
|
||||
/// isolates per DIRECTORY, not per filename — see Registry::open).
|
||||
fn wf_registry(name: &str) -> Registry {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("aura-campaign-wf-{}-{}", std::process::id(), name));
|
||||
// fixed, tag-keyed, pid-free name under the build-tree tmp anchor (#258): the
|
||||
// pre-create wipe below then genuinely reclaims the previous run.
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join(format!("aura-campaign-wf-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
Registry::open(dir.join("runs.jsonl"))
|
||||
@@ -1019,6 +1146,7 @@ mod wf_tests {
|
||||
manifest: RunManifest {
|
||||
commit: "wf-fake".to_string(),
|
||||
params: params.to_vec(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: "fake".to_string(),
|
||||
@@ -1045,7 +1173,15 @@ mod wf_tests {
|
||||
) -> Result<CampaignOutcome, ExecFault> {
|
||||
let strategies = vec![("s".repeat(64), "{}".to_string())];
|
||||
let campaign_id = "e".repeat(64);
|
||||
execute(&campaign_id, campaign, process, &strategies, runner, registry)
|
||||
execute(
|
||||
&campaign_id,
|
||||
campaign,
|
||||
process,
|
||||
&strategies,
|
||||
runner,
|
||||
registry,
|
||||
crate::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1161,75 +1297,169 @@ mod wf_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A member fault raised INSIDE a window's in-sample sweep aborts the
|
||||
/// walk_forward stage with that fault, and among several faulted windows
|
||||
/// the LOWEST roll index wins (the per-window `min_by_key` attribution
|
||||
/// mirrors the sweep stage's own lowest-enumeration-index convention) —
|
||||
/// never the faulted stage's family, since a captured fault returns
|
||||
/// before any registry write.
|
||||
/// #272: a member fault raised INSIDE one window's in-sample sweep is
|
||||
/// contained as that fold's `WindowFault` — the surviving windows still
|
||||
/// run, pool into the walk_forward family, and the campaign run record
|
||||
/// persists. Faulting w1's IS run out of 3 windows (w0, w1, w2) leaves
|
||||
/// exactly 2 surviving OOS reports and one recorded window fault at
|
||||
/// ordinal 1.
|
||||
#[test]
|
||||
fn wf_is_member_fault_lowest_window_wins() {
|
||||
fn wf_one_faulted_fold_is_contained_survivors_pool() {
|
||||
let registry = wf_registry("is-fault");
|
||||
let campaign = wf_campaign((0, 99));
|
||||
let process = wf_process(None, 40, 20, 20);
|
||||
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault both w1's and
|
||||
// w2's IS runs; the widx-1 fault must win over widx-2's.
|
||||
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault only w1's IS
|
||||
// run; w0 and w2 survive and pool.
|
||||
let runner = WfFakeRunner::faulty(vec![(
|
||||
(20, 59),
|
||||
vec![("len".to_string(), Scalar::i64(4))],
|
||||
MemberFault::Run("is-w1".to_string()),
|
||||
)]);
|
||||
let outcome = run_wf(&campaign, &process, &runner, ®istry)
|
||||
.expect("a faulted fold is contained, not a stage abort");
|
||||
|
||||
let fam = outcome.cells[0]
|
||||
.families
|
||||
.iter()
|
||||
.find(|f| f.block == "std::walk_forward")
|
||||
.expect("wf family present despite the faulted fold");
|
||||
assert_eq!(fam.reports.len(), 2, "only the 2 surviving windows pool");
|
||||
|
||||
let cell = &outcome.record.cells[0];
|
||||
let wf_stage = cell
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.block == "std::walk_forward")
|
||||
.expect("wf stage realized");
|
||||
assert_eq!(wf_stage.window_faults.len(), 1);
|
||||
assert_eq!(wf_stage.window_faults[0].window_ordinal, 1);
|
||||
assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Run);
|
||||
assert!(cell.fault.is_none(), "a partial-fold stage does not fail the cell");
|
||||
|
||||
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
|
||||
let members = registry.load_family_members().expect("load members");
|
||||
assert!(members.iter().any(|m| m.kind == FamilyKind::WalkForward));
|
||||
}
|
||||
|
||||
/// #272: every fold faulting (both the IS sweep AND the OOS run, across
|
||||
/// all 3 windows) leaves zero surviving OOS reports — the whole cell
|
||||
/// fails at the wf stage, with no WalkForward family persisted, while the
|
||||
/// campaign run record still records the failed cell.
|
||||
#[test]
|
||||
fn wf_all_folds_faulted_fails_the_cell() {
|
||||
let registry = wf_registry("all-folds-fault");
|
||||
let campaign = wf_campaign((0, 99));
|
||||
let process = wf_process(None, 40, 20, 20);
|
||||
// IS bounds: w0 (0,39), w1 (20,59), w2 (40,79) — fault every window's
|
||||
// IS run so no window reaches its OOS call.
|
||||
let runner = WfFakeRunner::faulty(vec![
|
||||
(
|
||||
(0, 39),
|
||||
vec![("len".to_string(), Scalar::i64(4))],
|
||||
MemberFault::Run("is-w0".to_string()),
|
||||
),
|
||||
(
|
||||
(20, 59),
|
||||
vec![("len".to_string(), Scalar::i64(3))],
|
||||
vec![("len".to_string(), Scalar::i64(4))],
|
||||
MemberFault::Run("is-w1".to_string()),
|
||||
),
|
||||
(
|
||||
(40, 79),
|
||||
vec![("len".to_string(), Scalar::i64(2))],
|
||||
vec![("len".to_string(), Scalar::i64(4))],
|
||||
MemberFault::Run("is-w2".to_string()),
|
||||
),
|
||||
]);
|
||||
let err = run_wf(&campaign, &process, &runner, ®istry)
|
||||
.expect_err("a faulted IS member aborts the walk_forward stage");
|
||||
match err {
|
||||
ExecFault::Member(fault) => assert_eq!(
|
||||
fault,
|
||||
MemberFault::Run("is-w1".to_string()),
|
||||
"the LOWEST window index's fault wins, not w2's later one",
|
||||
),
|
||||
other => panic!("expected ExecFault::Member, got {other:?}"),
|
||||
}
|
||||
let outcome = run_wf(&campaign, &process, &runner, ®istry)
|
||||
.expect("an all-folds-faulted stage fails the cell, not the process");
|
||||
|
||||
assert!(
|
||||
outcome.cells[0].families.iter().all(|f| f.block != "std::walk_forward"),
|
||||
"no WalkForward family is produced when every fold fails"
|
||||
);
|
||||
let cell = &outcome.record.cells[0];
|
||||
let fault = cell.fault.as_ref().expect("the cell fails at the wf stage");
|
||||
assert_eq!(fault.stage, 1, "walk_forward is pipeline stage 1 here");
|
||||
assert!(fault.detail.contains("all 3 walk_forward folds failed"));
|
||||
|
||||
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
|
||||
let members = registry.load_family_members().expect("load members");
|
||||
assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward));
|
||||
assert!(registry.load_campaign_runs().expect("load campaign runs").is_empty());
|
||||
}
|
||||
|
||||
/// A member fault raised running the IS winner over its OUT-of-sample
|
||||
/// bounds (a call distinct from every IS-sweep call, since the OOS
|
||||
/// window's bounds differ from every window's IS bounds) also aborts the
|
||||
/// walk_forward stage with that fault, and — like the IS-sweep path —
|
||||
/// never persists the faulted stage's family.
|
||||
/// #272: a member PANIC inside a window's in-sample sweep is caught at the
|
||||
/// wf member boundary (`catch_unwind`) and contained as that fold's
|
||||
/// `WindowFault` with kind `panic` — the process does not abort, the
|
||||
/// surviving folds pool, exactly as an `Err` fault is contained. Guards the
|
||||
/// wf-path panic arm (the sweep-path arm is covered by `PanicRunner` in
|
||||
/// tests/execute.rs; this is its walk_forward mirror).
|
||||
#[test]
|
||||
fn wf_oos_member_fault_aborts_walk_forward() {
|
||||
let registry = wf_registry("oos-fault");
|
||||
fn wf_member_panic_in_a_fold_is_contained() {
|
||||
// Panics on w1's IS bounds (20,59); every other call returns a report.
|
||||
struct WfPanicRunner;
|
||||
impl MemberRunner for WfPanicRunner {
|
||||
fn run_member(
|
||||
&self,
|
||||
_cell: &CellSpec,
|
||||
params: &[(String, Scalar)],
|
||||
window_ms: (i64, i64),
|
||||
) -> Result<RunReport, MemberFault> {
|
||||
assert_ne!(window_ms, (20, 59), "planted panic on w1's in-sample run");
|
||||
let total: f64 = params
|
||||
.iter()
|
||||
.map(|(_, s)| match s {
|
||||
Scalar::I64(v) => *v as f64,
|
||||
Scalar::F64(v) => *v,
|
||||
_ => 0.0,
|
||||
})
|
||||
.sum();
|
||||
Ok(RunReport {
|
||||
manifest: RunManifest {
|
||||
commit: "wf-panic-fake".to_string(),
|
||||
params: params.to_vec(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: "fake".to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
},
|
||||
metrics: RunMetrics {
|
||||
total_pips: total,
|
||||
max_drawdown: 0.0,
|
||||
bias_sign_flips: 0,
|
||||
r: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
let registry = wf_registry("is-panic");
|
||||
let campaign = wf_campaign((0, 99));
|
||||
let process = wf_process(None, 40, 20, 20);
|
||||
// total_pips == len, so the IS winner is always len=4 (argmax over
|
||||
// [1,2,3,4]); fault only window 0's OOS run of that winner (OOS
|
||||
// bounds (40,59) match no window's IS bounds and no other window's
|
||||
// OOS bounds).
|
||||
let runner = WfFakeRunner::faulty(vec![(
|
||||
(40, 59),
|
||||
vec![("len".to_string(), Scalar::i64(4))],
|
||||
MemberFault::Run("oos-w0".to_string()),
|
||||
)]);
|
||||
let err = run_wf(&campaign, &process, &runner, ®istry)
|
||||
.expect_err("a faulted OOS member aborts the walk_forward stage");
|
||||
match err {
|
||||
ExecFault::Member(fault) => {
|
||||
assert_eq!(fault, MemberFault::Run("oos-w0".to_string()))
|
||||
}
|
||||
other => panic!("expected ExecFault::Member, got {other:?}"),
|
||||
}
|
||||
let members = registry.load_family_members().expect("load members");
|
||||
assert!(members.iter().all(|m| m.kind != FamilyKind::WalkForward));
|
||||
let strategies = vec![("s".repeat(64), "{}".to_string())];
|
||||
let outcome = execute(
|
||||
&"e".repeat(64),
|
||||
&campaign,
|
||||
&process,
|
||||
&strategies,
|
||||
&WfPanicRunner,
|
||||
®istry,
|
||||
crate::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)
|
||||
.expect("a member panic in a fold is contained, not a process abort");
|
||||
|
||||
let cell = &outcome.record.cells[0];
|
||||
let wf_stage = cell
|
||||
.stages
|
||||
.iter()
|
||||
.find(|s| s.block == "std::walk_forward")
|
||||
.expect("wf stage realized despite the panicked fold");
|
||||
assert_eq!(wf_stage.window_faults.len(), 1);
|
||||
assert_eq!(wf_stage.window_faults[0].window_ordinal, 1);
|
||||
assert_eq!(wf_stage.window_faults[0].kind, aura_registry::CellFaultKind::Panic);
|
||||
assert!(cell.fault.is_none(), "a partial-fold stage does not fail the cell");
|
||||
assert_eq!(registry.load_campaign_runs().expect("load campaign runs").len(), 1);
|
||||
}
|
||||
|
||||
/// select_sweep_winner's Plateau arms dispatch through the public
|
||||
|
||||
@@ -4,10 +4,13 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_campaign::{execute, CellSpec, ExecFault, MemberFault, MemberRunner};
|
||||
use aura_campaign::{
|
||||
execute, CellSpec, ExecFault, MemberFault, MemberRunner, DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
};
|
||||
use aura_backtest::{r_bootstrap, RMetrics, RunMetrics, RunReport};
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{r_bootstrap, RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode};
|
||||
use aura_registry::{generalization, FamilyKind, Registry, StageBootstrap};
|
||||
use aura_engine::{RunManifest, SelectionMode};
|
||||
use aura_registry::{generalization, FamilyKind, Registry, RegistryError, StageBootstrap};
|
||||
use aura_research::{
|
||||
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc,
|
||||
ProcessRef, SelectRule, StageBlock, StrategyEntry, SweepSelection, WfMode, Window,
|
||||
@@ -41,9 +44,9 @@ fn param_i64(params: &[(String, Scalar)], name: &str) -> i64 {
|
||||
|
||||
/// The deterministic 4-trade R series a planted report carries (matches
|
||||
/// `n_trades: 4`; non-constant so block resampling has structure). In-memory
|
||||
/// only: `trade_rs` is serde-skipped and excluded from `PartialEq`, so every
|
||||
/// only: `net_trade_rs` is serde-skipped and excluded from `PartialEq`, so every
|
||||
/// existing equality and round-trip assertion stays green.
|
||||
fn planted_trade_rs(net: f64) -> Vec<f64> {
|
||||
fn planted_net_trade_rs(net: f64) -> Vec<f64> {
|
||||
vec![net, -net, 2.0 * net, net]
|
||||
}
|
||||
|
||||
@@ -58,6 +61,7 @@ fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64,
|
||||
manifest: RunManifest {
|
||||
commit: "fake".to_string(),
|
||||
params: params.to_vec(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: "fake".to_string(),
|
||||
@@ -83,7 +87,7 @@ fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64,
|
||||
sqn_normalized: net,
|
||||
net_expectancy_r: net,
|
||||
conviction_terciles_r: [0.0, 0.0, 0.0],
|
||||
trade_rs: planted_trade_rs(net),
|
||||
net_trade_rs: planted_net_trade_rs(net),
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -125,6 +129,22 @@ impl MemberRunner for ZeroTradeRunner {
|
||||
}
|
||||
}
|
||||
|
||||
/// A runner whose `run_member` always panics — the #272 panic-containment
|
||||
/// fixture (`catch_unwind` at the member boundary must turn this into a
|
||||
/// `MemberFault::Panic`, never an unwind out of `execute`).
|
||||
struct PanicRunner;
|
||||
|
||||
impl MemberRunner for PanicRunner {
|
||||
fn run_member(
|
||||
&self,
|
||||
_cell: &CellSpec,
|
||||
_params: &[(String, Scalar)],
|
||||
_window_ms: (i64, i64),
|
||||
) -> Result<RunReport, MemberFault> {
|
||||
panic!("planted member panic")
|
||||
}
|
||||
}
|
||||
|
||||
/// fast in {2,3}, slow in {6,9} -> 4 odometer points (last axis fastest):
|
||||
/// 0:(2,6) net .26 | 1:(2,9) net .29 | 2:(3,6) net .36 | 3:(3,9) net .39.
|
||||
fn axes_2x2() -> BTreeMap<String, Axis> {
|
||||
@@ -201,6 +221,22 @@ fn generalize_stage() -> StageBlock {
|
||||
StageBlock::Generalize { metric: "net_expectancy_r".to_string() }
|
||||
}
|
||||
|
||||
/// A registry whose family store path is pre-occupied by a directory:
|
||||
/// `append_family`'s `OpenOptions::open` then fails with a real IO error
|
||||
/// (`ErrorKind::IsADirectory`) — a `RegistryError::Io` that is neither a
|
||||
/// `MemberFault` nor a `WindowFault`, so `run_cell`'s `?` on `append_family`
|
||||
/// returns it as a non-containable `ExecFault` (never routed through
|
||||
/// `contain`), unlike the `FakeRunner`-planted member faults used elsewhere
|
||||
/// in this file.
|
||||
fn temp_registry_with_blocked_family_store(name: &str) -> Registry {
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
|
||||
.join(format!("aura-campaign-exec-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp registry dir");
|
||||
std::fs::create_dir_all(dir.join("families.jsonl")).expect("occupy the family store path");
|
||||
Registry::open(dir.join("runs.jsonl"))
|
||||
}
|
||||
|
||||
fn process(pipeline: Vec<StageBlock>) -> ProcessDoc {
|
||||
ProcessDoc {
|
||||
format_version: 1,
|
||||
@@ -218,8 +254,8 @@ fn strategies() -> Vec<(String, String)> {
|
||||
/// Per-test registry DIRECTORY (family/campaign stores are per-directory
|
||||
/// siblings of the runs path — the aura-registry temp-dir idiom).
|
||||
fn temp_registry(name: &str) -> Registry {
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("aura-campaign-exec-{}-{}", std::process::id(), name));
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
|
||||
.join(format!("aura-campaign-exec-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp registry dir");
|
||||
Registry::open(dir.join("runs.jsonl"))
|
||||
@@ -230,7 +266,7 @@ fn execute_sweep_only_records_family_and_selection() {
|
||||
let reg = temp_registry("sweep_only");
|
||||
let doc = campaign(&["EURUSD"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("sweep-only campaign executes");
|
||||
|
||||
// outcome payloads: one cell, one family of 4 members, one selection
|
||||
@@ -293,7 +329,7 @@ fn execute_selection_free_sweep_records_family_without_selection() {
|
||||
let reg = temp_registry("selection_free_sweep");
|
||||
let doc = campaign(&["EURUSD"]);
|
||||
let proc_doc = process(vec![selection_free_sweep_stage()]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("selection-free sweep campaign executes");
|
||||
|
||||
// outcome payloads: one cell, one family of 4 members, NO selection
|
||||
@@ -330,7 +366,7 @@ fn execute_gate_filters_per_member() {
|
||||
let reg = temp_registry("gate_filters");
|
||||
let doc = campaign(&["EURUSD"]);
|
||||
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.3)]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("gated campaign executes");
|
||||
|
||||
// planted nets 0.26 / 0.29 / 0.36 / 0.39 -> gt 0.3 keeps ordinals 2 and 3
|
||||
@@ -349,7 +385,7 @@ fn execute_zero_survivors_truncates_cell_and_continues() {
|
||||
let doc = campaign(&["AAA", "BBB"]);
|
||||
// sweep -> gate(net > 0) -> gate(net > -1000): AAA dies at stage 1, BBB passes both
|
||||
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), gate_stage(-1000.0)]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("a zero-survivor cell is a valid result, not a fault");
|
||||
|
||||
assert_eq!(out.record.cells.len(), 2, "the second cell still runs");
|
||||
@@ -366,14 +402,46 @@ fn execute_zero_survivors_truncates_cell_and_continues() {
|
||||
assert_eq!(bbb.stages[2].survivor_ordinals, Some(vec![0, 1, 2, 3]));
|
||||
}
|
||||
|
||||
/// #277: with `parallel_instruments = 1` (forcing 2 single-instrument
|
||||
/// chunks) and 2 strategies x 2 instruments, the instrument-major chunk walk
|
||||
/// executes cell (s0,AAA) then (s1,AAA) before either BBB cell — an
|
||||
/// execution order that diverges from document order. The rebuild step must
|
||||
/// still hand back cells in document order (C1): strategy-major, then
|
||||
/// instrument, exactly as the pre-#277 nested loop did.
|
||||
#[test]
|
||||
fn execute_preserves_document_order_across_chunk_boundary() {
|
||||
let reg = temp_registry("chunk_order");
|
||||
let mut doc = campaign(&["AAA", "BBB"]);
|
||||
doc.strategies.push(doc.strategies[0].clone());
|
||||
let mut strats = strategies();
|
||||
strats.push(strats[0].clone());
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let k = std::num::NonZeroUsize::new(1).expect("1 is nonzero");
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strats, &FakeRunner::clean(), ®, k)
|
||||
.expect("chunked campaign executes");
|
||||
|
||||
assert_eq!(out.cells.len(), 4, "2 strategies x 2 instruments x 1 window");
|
||||
let base = &CAMPAIGN_ID[..8];
|
||||
let family_id = |i: usize| out.record.cells[i].stages[0].family_id.clone();
|
||||
assert_eq!(family_id(0), Some(format!("{base}-0-AAA-w0-r0-s0-0")));
|
||||
assert_eq!(family_id(1), Some(format!("{base}-0-BBB-w0-r0-s0-0")));
|
||||
assert_eq!(family_id(2), Some(format!("{base}-1-AAA-w0-r0-s0-0")));
|
||||
assert_eq!(family_id(3), Some(format!("{base}-1-BBB-w0-r0-s0-0")));
|
||||
assert_eq!(
|
||||
out.record.cells.iter().map(|c| c.instrument.as_str()).collect::<Vec<_>>(),
|
||||
vec!["AAA", "BBB", "AAA", "BBB"],
|
||||
"document order (strategy-major, then instrument) survives the K=1 chunk walk",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_is_deterministic_twice() {
|
||||
let reg = temp_registry("deterministic_twice");
|
||||
let doc = campaign(&["EURUSD"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("first run");
|
||||
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("second run");
|
||||
|
||||
// run counter advances per campaign id
|
||||
@@ -399,34 +467,57 @@ fn execute_is_deterministic_twice() {
|
||||
assert_eq!(runs[1], second.record);
|
||||
}
|
||||
|
||||
/// #272: a member fault is contained as a failed cell, not a global abort —
|
||||
/// the campaign run record still persists (the whole point of containment),
|
||||
/// while a sweep-faulted cell writes no family (`run_members` returns before
|
||||
/// `append_family`).
|
||||
#[test]
|
||||
fn execute_member_fault_aborts_with_lowest_index() {
|
||||
fn execute_member_fault_records_a_failed_cell_and_continues() {
|
||||
let reg = temp_registry("member_fault");
|
||||
let doc = campaign(&["EURUSD"]);
|
||||
// two instruments; the planted fault is keyed by param point, so both
|
||||
// cells' sweeps hit it and both fail — the containment property under test
|
||||
// is that execute returns Ok and records every cell, not a global abort.
|
||||
let doc = campaign(&["AAA", "BBB"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
// faults planted at enumeration indices 1 (2,9) and 2 (3,6)
|
||||
let point = |fast: i64, slow: i64| {
|
||||
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
|
||||
};
|
||||
// FakeRunner faults are matched by param point regardless of instrument;
|
||||
// plant one on the grid so AAA's (and BBB's) sweep hit it — the whole
|
||||
// sweep-faulted cell fails (grid hole compromises selection).
|
||||
let runner = FakeRunner {
|
||||
faults: vec![
|
||||
(point(2, 9), MemberFault::Run("boom at index 1".to_string())),
|
||||
(point(3, 6), MemberFault::Run("boom at index 2".to_string())),
|
||||
],
|
||||
faults: vec![(point(2, 9), MemberFault::Run("boom".to_string()))],
|
||||
};
|
||||
let err = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®)
|
||||
.expect_err("a faulted member aborts the run");
|
||||
match err {
|
||||
ExecFault::Member(fault) => assert_eq!(
|
||||
fault,
|
||||
MemberFault::Run("boom at index 1".to_string()),
|
||||
"the LOWEST enumeration index's fault is reported",
|
||||
),
|
||||
other => panic!("expected ExecFault::Member, got {other:?}"),
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("a faulted member is recorded, not a global abort");
|
||||
assert_eq!(out.record.cells.len(), 2, "both cells are recorded");
|
||||
for cell in &out.record.cells {
|
||||
let fault = cell.fault.as_ref().expect("sweep-fault fails the cell");
|
||||
assert_eq!(fault.stage, 0);
|
||||
assert_eq!(fault.kind, aura_registry::CellFaultKind::Run);
|
||||
}
|
||||
// the fault aborted before any family or realization write
|
||||
// The run record persisted (the whole point of containment); a sweep-fault
|
||||
// cell writes no family (run_members returns before append_family).
|
||||
assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1);
|
||||
assert!(reg.load_family_members().expect("load members").is_empty());
|
||||
assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty());
|
||||
}
|
||||
|
||||
/// #272: a panic that unwinds out of `MemberRunner::run_member` is caught at
|
||||
/// the member boundary, not propagated — the cell is recorded failed with a
|
||||
/// `Panic` kind, and the run record persists.
|
||||
#[test]
|
||||
fn execute_member_panic_is_contained_as_a_failed_cell() {
|
||||
let reg = temp_registry("member_panic");
|
||||
let doc = campaign(&["AAA", "BBB"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let runner = PanicRunner;
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("a member panic is contained, not a process abort");
|
||||
assert!(out.record.cells.iter().all(|c| c
|
||||
.fault
|
||||
.as_ref()
|
||||
.is_some_and(|f| f.kind == aura_registry::CellFaultKind::Panic)));
|
||||
assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -434,7 +525,7 @@ fn execute_deflate_uses_campaign_seed() {
|
||||
let reg = temp_registry("deflate_seed");
|
||||
let doc = campaign(&["EURUSD"]); // seed: 7
|
||||
let proc_doc = process(vec![sweep_stage(true)]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("deflated sweep executes");
|
||||
let sel = out.record.cells[0].stages[0].selection.as_ref().expect("selection recorded");
|
||||
assert_eq!(sel.selection.mode, SelectionMode::Argmax);
|
||||
@@ -448,7 +539,7 @@ fn execute_refuses_malformed_campaign_id() {
|
||||
let reg = temp_registry("bad_id");
|
||||
let doc = campaign(&["EURUSD"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let err = execute("short", &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let err = execute("short", &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect_err("a non-64-hex campaign id is refused, not sliced");
|
||||
match err {
|
||||
ExecFault::PipelineShape { detail } => assert!(
|
||||
@@ -469,7 +560,7 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
|
||||
gate_stage(0.3),
|
||||
StageBlock::MonteCarlo { resamples: 200, block_len: 2 },
|
||||
]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("mc-after-gate campaign executes");
|
||||
|
||||
// planted nets .26/.29/.36/.39 -> gate gt 0.3 keeps ordinals 2 and 3
|
||||
@@ -482,11 +573,11 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
|
||||
assert_eq!(mc.selection, None);
|
||||
|
||||
// each survivor's bootstrap == a hand-called r_bootstrap on its planted
|
||||
// trade_rs, seeded from the campaign doc (the deflation convention)
|
||||
// net_trade_rs, seeded from the campaign doc (the deflation convention)
|
||||
let net = |fast: i64, slow: i64| (fast * 10 + slow) as f64 / 100.0;
|
||||
let expected = StageBootstrap::PerSurvivor(vec![
|
||||
(2, r_bootstrap(&planted_trade_rs(net(3, 6)), 200, 2, 7)),
|
||||
(3, r_bootstrap(&planted_trade_rs(net(3, 9)), 200, 2, 7)),
|
||||
(2, r_bootstrap(&planted_net_trade_rs(net(3, 6)), 200, 2, 7)),
|
||||
(3, r_bootstrap(&planted_net_trade_rs(net(3, 9)), 200, 2, 7)),
|
||||
]);
|
||||
assert_eq!(mc.bootstrap, Some(expected));
|
||||
|
||||
@@ -495,6 +586,52 @@ fn execute_mc_after_gate_bootstraps_each_survivor() {
|
||||
assert_eq!(runs[0], out.record);
|
||||
}
|
||||
|
||||
/// Property (#256 fork B): a `std::grid` first stage crosses the `run_cell`
|
||||
/// seam as bare parameter points (`StageFlow::Points`) rather than executed
|
||||
/// members — it persists no family of its own — and the immediately-following
|
||||
/// `std::walk_forward` stage still sweeps exactly the full axis grid, picking
|
||||
/// the same per-window winner a selection-carrying `std::sweep` feeding the
|
||||
/// same wf stage would (`execute_mc_after_wf_pools_the_oos_series`'s
|
||||
/// `sweep_stage()` + `wf_stage()` shape, minus the intermediate sweep family).
|
||||
/// A regression that let `StageFlow::Points` drop or mis-order a point before
|
||||
/// crossing the seam would flip the wf winner or the per-window report count
|
||||
/// here even though the preflight placement tests (lib.rs) stay green.
|
||||
#[test]
|
||||
fn execute_grid_first_stage_feeds_walk_forward_with_all_grid_points() {
|
||||
let reg = temp_registry("grid_then_wf");
|
||||
let doc = campaign(&["EURUSD"]); // seed: 7, window [1_000, 9_000]
|
||||
let proc_doc = process(vec![StageBlock::Grid, wf_stage()]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("grid-then-wf campaign executes");
|
||||
|
||||
// std::grid persists no family of its own — only std::walk_forward's does
|
||||
assert_eq!(out.cells[0].families.len(), 1);
|
||||
let wf = &out.cells[0].families[0];
|
||||
assert_eq!(wf.block, "std::walk_forward");
|
||||
|
||||
// stage realization: std::grid first (no family/selection), then wf
|
||||
let cell = &out.record.cells[0];
|
||||
assert_eq!(cell.stages.len(), 2);
|
||||
assert_eq!(cell.stages[0].block, "std::grid");
|
||||
assert_eq!(cell.stages[0].family_id, None);
|
||||
assert_eq!(cell.stages[0].survivor_ordinals, None);
|
||||
assert_eq!(cell.stages[0].selection, None);
|
||||
assert_eq!(cell.stages[1].block, "std::walk_forward");
|
||||
|
||||
// the roller yields 2 windows over the FULL 2x2 grid (nothing filtered
|
||||
// the population before wf); both pick the planted argmax (3,9)
|
||||
assert_eq!(wf.reports.len(), 2);
|
||||
let winner_params =
|
||||
vec![("fast".to_string(), Scalar::i64(3)), ("slow".to_string(), Scalar::i64(9))];
|
||||
for report in &wf.reports {
|
||||
assert_eq!(report.manifest.params, winner_params);
|
||||
}
|
||||
|
||||
// the record round-trips through the store unchanged
|
||||
let runs = reg.load_campaign_runs().expect("load campaign runs");
|
||||
assert_eq!(runs[0], out.record);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_mc_after_wf_pools_the_oos_series() {
|
||||
let reg = temp_registry("mc_pooled");
|
||||
@@ -504,7 +641,7 @@ fn execute_mc_after_wf_pools_the_oos_series() {
|
||||
wf_stage(),
|
||||
StageBlock::MonteCarlo { resamples: 200, block_len: 3 },
|
||||
]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("mc-after-wf campaign executes");
|
||||
|
||||
// the roller yields 2 windows; both pick the planted argmax (3,9)
|
||||
@@ -512,15 +649,15 @@ fn execute_mc_after_wf_pools_the_oos_series() {
|
||||
assert_eq!(wf.block, "std::walk_forward");
|
||||
assert_eq!(wf.reports.len(), 2);
|
||||
|
||||
// pooled input = the wf family reports' trade_rs concatenated in report
|
||||
// pooled input = the wf family reports' net_trade_rs concatenated in report
|
||||
// order (roll order per walkforward_member_reports)
|
||||
let mut pooled: Vec<f64> = Vec::new();
|
||||
for report in &wf.reports {
|
||||
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
|
||||
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").net_trade_rs);
|
||||
}
|
||||
let net = (3 * 10 + 9) as f64 / 100.0;
|
||||
let mut expected_pool = planted_trade_rs(net);
|
||||
expected_pool.extend(planted_trade_rs(net));
|
||||
let mut expected_pool = planted_net_trade_rs(net);
|
||||
expected_pool.extend(planted_net_trade_rs(net));
|
||||
assert_eq!(pooled, expected_pool);
|
||||
|
||||
let mc = &out.record.cells[0].stages[2];
|
||||
@@ -540,7 +677,7 @@ fn execute_mc_zero_trade_member_records_degenerate() {
|
||||
inner: FakeRunner::clean(),
|
||||
strip: vec![("fast".to_string(), Scalar::i64(2)), ("slow".to_string(), Scalar::i64(6))],
|
||||
};
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("a zero-trade member is a valid result, not a fault");
|
||||
|
||||
let mc = &out.record.cells[0].stages[1];
|
||||
@@ -563,7 +700,7 @@ fn execute_generalize_across_two_instruments() {
|
||||
// the fake negates "AAA" nets -> divergent winners across instruments
|
||||
let doc = campaign(&["AAA", "BBB"]); // seed: 7
|
||||
let proc_doc = process(vec![sweep_stage(false), wf_stage(), generalize_stage()]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("generalize campaign executes");
|
||||
|
||||
assert_eq!(out.record.generalizations.len(), 1);
|
||||
@@ -622,7 +759,7 @@ fn execute_generalize_shortfall_records_missing() {
|
||||
// AAA's planted nets are all negative -> the gt-0 gate empties its cell
|
||||
let doc = campaign(&["AAA", "BBB"]);
|
||||
let proc_doc = process(vec![sweep_stage(false), gate_stage(0.0), generalize_stage()]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("a truncated cell is a recorded shortfall, not a fault");
|
||||
|
||||
assert!(out.cells[0].nominee.is_none(), "gate-truncated cell nominates nothing");
|
||||
@@ -643,7 +780,7 @@ fn execute_generalize_only_after_sweep() {
|
||||
let reg = temp_registry("generalize_sweep_only");
|
||||
let doc = campaign(&["EURUSD", "GER40"]);
|
||||
let proc_doc = process(vec![sweep_stage(false), generalize_stage()]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("sweep-only generalize campaign executes");
|
||||
|
||||
// no wf ran: each cell nominates its sweep winner (planted argmax (3,9))
|
||||
@@ -676,7 +813,7 @@ fn execute_generalize_only_after_sweep() {
|
||||
/// annotators compose in ONE pipeline without disturbing each other or the
|
||||
/// population stages' generalize-nominee. `std::monte_carlo` sits between
|
||||
/// `std::walk_forward` and `std::generalize` in the pipeline, yet its
|
||||
/// per-cell bootstrap still pools exactly the wf family's OOS `trade_rs`
|
||||
/// per-cell bootstrap still pools exactly the wf family's OOS `net_trade_rs`
|
||||
/// (unaffected by generalize running after it), and `std::generalize`'s
|
||||
/// campaign-scope nominee is still the wf stage's last-window winner
|
||||
/// (unaffected by mc having annotated the cell first). A regression that
|
||||
@@ -695,7 +832,7 @@ fn execute_mc_and_generalize_compose_in_one_pipeline() {
|
||||
StageBlock::MonteCarlo { resamples: 200, block_len: 2 },
|
||||
generalize_stage(),
|
||||
]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("full v2 pipeline executes");
|
||||
|
||||
// both cells realize all four per-cell stages; generalize contributes no
|
||||
@@ -705,13 +842,13 @@ fn execute_mc_and_generalize_compose_in_one_pipeline() {
|
||||
assert_eq!(cell.stages[3].block, "std::monte_carlo");
|
||||
}
|
||||
|
||||
// mc's bootstrap pools the wf family's OOS trade_rs exactly as it does
|
||||
// mc's bootstrap pools the wf family's OOS net_trade_rs exactly as it does
|
||||
// with no generalize stage after it (execute_mc_after_wf_pools_the_oos_series)
|
||||
let wf = &out.cells[0].families[1];
|
||||
assert_eq!(wf.block, "std::walk_forward");
|
||||
let mut pooled: Vec<f64> = Vec::new();
|
||||
for report in &wf.reports {
|
||||
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
|
||||
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").net_trade_rs);
|
||||
}
|
||||
let expected_mc = StageBootstrap::PooledOos(r_bootstrap(&pooled, 200, 2, 7));
|
||||
assert_eq!(out.record.cells[0].stages[3].bootstrap, Some(expected_mc));
|
||||
@@ -753,7 +890,7 @@ fn execute_persist_taps_stamps_trace_name() {
|
||||
let mut doc = campaign(&["EURUSD"]);
|
||||
doc.presentation.persist_taps = vec!["equity".to_string()];
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("persist_taps campaign executes");
|
||||
|
||||
// returned record: the derived name over the first run counter
|
||||
@@ -771,7 +908,7 @@ fn execute_persist_taps_stamps_trace_name() {
|
||||
assert_eq!(runs[0], out.record, "stored line and returned record are the same record");
|
||||
|
||||
// a second run of the same campaign derives the next counter's name
|
||||
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("second persist_taps run");
|
||||
let expected_1 = format!("{}-1", &CAMPAIGN_ID[..8]);
|
||||
assert_eq!(second.record.trace_name.as_deref(), Some(expected_1.as_str()));
|
||||
@@ -790,7 +927,7 @@ fn execute_runs_one_family_per_regime_with_distinct_ids() {
|
||||
aura_research::RiskRegime::Vol { length: 3, k: 2.0 },
|
||||
];
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("two-regime campaign executes");
|
||||
|
||||
// one cell per regime, each with its own sweep family.
|
||||
@@ -817,7 +954,7 @@ fn execute_generalize_keeps_regimes_separate() {
|
||||
aura_research::RiskRegime::Vol { length: 3, k: 2.0 },
|
||||
];
|
||||
let proc_doc = process(vec![sweep_stage(false), generalize_stage()]);
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
.expect("two-regime generalize campaign executes");
|
||||
|
||||
// 2 instruments x 2 regimes -> 4 cells, but exactly 2 generalize groups
|
||||
@@ -835,3 +972,231 @@ fn execute_generalize_keeps_regimes_separate() {
|
||||
assert!(g.generalization.is_some(), "two instruments -> graded");
|
||||
}
|
||||
}
|
||||
|
||||
/// C1 across the parallel cell loop: the same campaign under a 1-thread pool
|
||||
/// with K=1 and an 8-thread pool with K=2 (fresh registry each) yields an
|
||||
/// identical campaign-run record — the campaign-path sibling of the engine's
|
||||
/// `*_is_deterministic_across_thread_counts` tests.
|
||||
#[test]
|
||||
fn campaign_outcome_is_deterministic_across_thread_counts_and_bounds() {
|
||||
let doc = campaign(&["AAA", "BBB", "CCC"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let run_with = |threads: usize, k: usize, name: &str| {
|
||||
let reg = temp_registry(name);
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(threads)
|
||||
.build()
|
||||
.expect("build pool");
|
||||
pool.install(|| {
|
||||
execute(
|
||||
CAMPAIGN_ID,
|
||||
&doc,
|
||||
&proc_doc,
|
||||
&strategies(),
|
||||
&FakeRunner::clean(),
|
||||
®,
|
||||
std::num::NonZeroUsize::new(k).expect("nonzero"),
|
||||
)
|
||||
})
|
||||
.expect("campaign run")
|
||||
};
|
||||
let serial = run_with(1, 1, "det_across_serial");
|
||||
let parallel = run_with(8, 2, "det_across_parallel");
|
||||
assert_eq!(serial.run, parallel.run);
|
||||
assert_eq!(serial.record, parallel.record);
|
||||
}
|
||||
|
||||
/// #277: a non-containable fault (here, `append_family`'s registry-level IO
|
||||
/// refusal, never routed through `contain` since it is neither a
|
||||
/// `MemberFault` nor a `WindowFault`) still propagates as a global `Err` out
|
||||
/// of the parallel abort-latch loop, and does so with IDENTICAL content
|
||||
/// regardless of thread count or pool size. Every cell's sweep hits the SAME
|
||||
/// blocked family-store path, so whichever completed cell's `Err` the
|
||||
/// doc-order scan in `execute` happens to pick (scheduling-dependent per the
|
||||
/// exec.rs doc comment) carries the same IO error kind — exercising the
|
||||
/// abort-latch + lowest-doc-order-fault-scan path (and never the
|
||||
/// `unreachable!` rebuild arm, since this run returns `Err` before reaching
|
||||
/// the rebuild) that the fault-free determinism test above does not.
|
||||
#[test]
|
||||
fn execute_propagates_a_non_containable_fault_deterministically_under_parallel_chunks() {
|
||||
let doc = campaign(&["AAA", "BBB", "CCC"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let run_with = |threads: usize, k: usize, name: &str| {
|
||||
let reg = temp_registry_with_blocked_family_store(name);
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(threads)
|
||||
.build()
|
||||
.expect("build pool");
|
||||
pool.install(|| {
|
||||
execute(
|
||||
CAMPAIGN_ID,
|
||||
&doc,
|
||||
&proc_doc,
|
||||
&strategies(),
|
||||
&FakeRunner::clean(),
|
||||
®,
|
||||
std::num::NonZeroUsize::new(k).expect("nonzero"),
|
||||
)
|
||||
})
|
||||
};
|
||||
let serial = run_with(1, 1, "fault_prop_serial")
|
||||
.expect_err("a blocked family store is refused, not silently written");
|
||||
let parallel = run_with(8, 3, "fault_prop_parallel")
|
||||
.expect_err("same refusal under real cross-cell parallelism");
|
||||
let kind = |e: &ExecFault| match e {
|
||||
ExecFault::Registry(RegistryError::Io(io)) => io.kind(),
|
||||
other => panic!("expected ExecFault::Registry(Io), got {other:?}"),
|
||||
};
|
||||
assert_eq!(kind(&serial), std::io::ErrorKind::IsADirectory);
|
||||
assert_eq!(kind(&serial), kind(¶llel), "same fault kind regardless of scheduling");
|
||||
}
|
||||
|
||||
/// Wraps the clean fake and tracks how many DISTINCT instruments are inside
|
||||
/// `run_member` at once (refcounted per instrument; high-water mark in
|
||||
/// `max_seen`). The 5 ms hold makes overlap near-certain on an 8-thread pool
|
||||
/// if the residency bound is absent.
|
||||
struct CountingRunner {
|
||||
inner: FakeRunner,
|
||||
live: std::sync::Mutex<BTreeMap<String, usize>>,
|
||||
max_seen: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl MemberRunner for CountingRunner {
|
||||
fn run_member(
|
||||
&self,
|
||||
cell: &CellSpec,
|
||||
params: &[(String, Scalar)],
|
||||
window_ms: (i64, i64),
|
||||
) -> Result<RunReport, MemberFault> {
|
||||
let distinct = {
|
||||
let mut live = self.live.lock().expect("live instrument map");
|
||||
*live.entry(cell.instrument.clone()).or_insert(0) += 1;
|
||||
live.len()
|
||||
};
|
||||
self.max_seen.fetch_max(distinct, std::sync::atomic::Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
let out = self.inner.run_member(cell, params, window_ms);
|
||||
let mut live = self.live.lock().expect("live instrument map");
|
||||
let count = live.get_mut(&cell.instrument).expect("entered above");
|
||||
*count -= 1;
|
||||
if *count == 0 {
|
||||
live.remove(&cell.instrument);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// The structural residency bound: with K=1 on an 8-thread pool, no two
|
||||
/// distinct instruments are ever inside `run_member` at the same time.
|
||||
#[test]
|
||||
fn parallel_cells_never_exceed_the_instrument_bound() {
|
||||
let reg = temp_registry("instrument_bound");
|
||||
let doc = campaign(&["AAA", "BBB", "CCC"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let runner = CountingRunner {
|
||||
inner: FakeRunner::clean(),
|
||||
live: std::sync::Mutex::new(BTreeMap::new()),
|
||||
max_seen: std::sync::atomic::AtomicUsize::new(0),
|
||||
};
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().expect("build pool");
|
||||
pool.install(|| {
|
||||
execute(
|
||||
CAMPAIGN_ID,
|
||||
&doc,
|
||||
&proc_doc,
|
||||
&strategies(),
|
||||
&runner,
|
||||
®,
|
||||
std::num::NonZeroUsize::new(1).expect("nonzero"),
|
||||
)
|
||||
})
|
||||
.expect("campaign run");
|
||||
let max = runner.max_seen.load(std::sync::atomic::Ordering::SeqCst);
|
||||
assert!(max <= 1, "K=1 must bound distinct live instruments to 1, saw {max}");
|
||||
}
|
||||
|
||||
/// A registry-write fault is infrastructure, not a cell pathology: it stays
|
||||
/// run-fatal in the parallel loop (no cell containment, no partial
|
||||
/// campaign-run record).
|
||||
#[test]
|
||||
fn a_registry_fault_is_run_fatal_in_the_parallel_loop() {
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
|
||||
.join("aura-campaign-exec-registry_fatal");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp registry dir");
|
||||
// A directory squatting on the family store makes every append_family fail.
|
||||
std::fs::create_dir(dir.join("families.jsonl")).expect("squat the family store");
|
||||
let reg = Registry::open(dir.join("runs.jsonl"));
|
||||
let doc = campaign(&["AAA", "BBB"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let err = execute(
|
||||
CAMPAIGN_ID,
|
||||
&doc,
|
||||
&proc_doc,
|
||||
&strategies(),
|
||||
&FakeRunner::clean(),
|
||||
®,
|
||||
DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)
|
||||
.expect_err("a dead family store is run-fatal");
|
||||
assert!(matches!(err, ExecFault::Registry(_)));
|
||||
assert!(
|
||||
!dir.join("campaign_runs.jsonl").exists(),
|
||||
"no partial campaign-run record on the fatal path"
|
||||
);
|
||||
}
|
||||
|
||||
/// #272 under the parallel loop: member faults stay contained as failed
|
||||
/// cells across concurrently-running cells — the run record persists with
|
||||
/// every cell recorded, never a global abort.
|
||||
#[test]
|
||||
fn member_faults_stay_contained_in_the_parallel_loop() {
|
||||
let reg = temp_registry("parallel_containment");
|
||||
let doc = campaign(&["AAA", "BBB", "CCC"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let point = |fast: i64, slow: i64| {
|
||||
vec![("fast".to_string(), Scalar::i64(fast)), ("slow".to_string(), Scalar::i64(slow))]
|
||||
};
|
||||
let runner = FakeRunner {
|
||||
faults: vec![(point(2, 9), MemberFault::Run("boom".to_string()))],
|
||||
};
|
||||
let pool = rayon::ThreadPoolBuilder::new().num_threads(8).build().expect("build pool");
|
||||
let out = pool
|
||||
.install(|| {
|
||||
execute(
|
||||
CAMPAIGN_ID,
|
||||
&doc,
|
||||
&proc_doc,
|
||||
&strategies(),
|
||||
&runner,
|
||||
®,
|
||||
std::num::NonZeroUsize::new(2).expect("nonzero"),
|
||||
)
|
||||
})
|
||||
.expect("member faults are contained, not a global abort");
|
||||
assert_eq!(out.record.cells.len(), 3, "every cell is recorded");
|
||||
assert!(out.record.cells.iter().all(|c| c.fault.is_some()));
|
||||
assert_eq!(reg.load_campaign_runs().expect("load runs").len(), 1);
|
||||
}
|
||||
|
||||
/// Defense in depth (audit tidy, #277): `execute` itself refuses duplicate
|
||||
/// instruments via `preflight`, independent of the CLI's validate tier — a
|
||||
/// direct caller skipping `validate_campaign` must not reach the cell loop
|
||||
/// with colliding registry family names.
|
||||
#[test]
|
||||
fn execute_refuses_duplicate_instruments() {
|
||||
let reg = temp_registry("duplicate_instruments");
|
||||
let doc = campaign(&["AAA", "AAA"]);
|
||||
let proc_doc = process(vec![sweep_stage(false)]);
|
||||
let err = execute(
|
||||
CAMPAIGN_ID,
|
||||
&doc,
|
||||
&proc_doc,
|
||||
&strategies(),
|
||||
&FakeRunner::clean(),
|
||||
®,
|
||||
DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)
|
||||
.expect_err("duplicate instruments collide on family names");
|
||||
assert!(matches!(err, ExecFault::PipelineShape { .. }));
|
||||
}
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_campaign::{member_metric, CellSpec, MemberFault, MemberRunner, PER_MEMBER_METRICS, RANKABLE_METRICS};
|
||||
use aura_engine::{summarize, RMetrics, RunManifest, RunMetrics, RunReport, Scalar, Timestamp};
|
||||
use aura_backtest::{summarize, RMetrics, RunMetrics, RunReport};
|
||||
use aura_engine::{RunManifest, Scalar, Timestamp};
|
||||
|
||||
fn cell() -> CellSpec {
|
||||
CellSpec {
|
||||
@@ -28,7 +29,7 @@ fn cell() -> CellSpec {
|
||||
}
|
||||
|
||||
/// A minimal external `MemberRunner`: ignores its params and produces
|
||||
/// metrics via the real `aura_engine::summarize` reduction (not a hand-typed
|
||||
/// metrics via the real `aura_backtest::summarize` reduction (not a hand-typed
|
||||
/// `RunMetrics` literal), echoing the `window_ms` it was called with into
|
||||
/// the manifest — exactly what a real consumer's harness-binding code does.
|
||||
struct FixedRunner;
|
||||
@@ -46,6 +47,7 @@ impl MemberRunner for FixedRunner {
|
||||
manifest: RunManifest {
|
||||
commit: "e2e".to_string(),
|
||||
params: vec![],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
@@ -63,7 +65,7 @@ impl MemberRunner for FixedRunner {
|
||||
/// `&dyn MemberRunner` (the shape a heterogeneous, per-consumer roster of
|
||||
/// runners requires); the exact sub-window given at the call site — not
|
||||
/// `cell.window_ms`, a walk-forward stage passes a narrower sub-range —
|
||||
/// reaches the runner unmodified; and the real `aura_engine::summarize()`
|
||||
/// reaches the runner unmodified; and the real `aura_backtest::summarize()`
|
||||
/// output the runner produced is what `member_metric` reads back through the
|
||||
/// crate's public per-name resolver.
|
||||
#[test]
|
||||
@@ -87,8 +89,9 @@ fn member_runner_seam_dispatches_through_dyn_trait_object_and_carries_the_window
|
||||
/// Property: every metric name in `RANKABLE_METRICS` (the roster `preflight`
|
||||
/// permits a `std::sweep`/`std::walk_forward` stage to SELECT on) is also a
|
||||
/// member of `PER_MEMBER_METRICS` and resolves to `Some` via `member_metric`.
|
||||
/// The two rosters are independently hand-copied (documented drift risk,
|
||||
/// #190); were they to diverge, `preflight` would accept a selection metric
|
||||
/// Since #147 the rankable roster is a single-source re-export and the
|
||||
/// nesting is guard-pinned; were the rosters still to diverge, `preflight`
|
||||
/// would accept a selection metric
|
||||
/// that `member_metric` silently reads back as `None` for every member — a
|
||||
/// sweep/walk_forward stage that always empties, not a compile error or an
|
||||
/// early refusal.
|
||||
@@ -98,6 +101,7 @@ fn rankable_metrics_are_all_per_member_resolvable() {
|
||||
manifest: RunManifest {
|
||||
commit: "e2e".to_string(),
|
||||
params: vec![],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(0), Timestamp(1)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
@@ -123,7 +127,7 @@ fn rankable_metrics_are_all_per_member_resolvable() {
|
||||
sqn: 13.0,
|
||||
net_expectancy_r: 14.0,
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
trade_rs: Vec::new(),
|
||||
net_trade_rs: Vec::new(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
//! Cross-crate guard for #190: pins `aura_research::metric_vocabulary()` (the
|
||||
//! hand-written 17-name declared metric roster) against the ACTUAL scalar
|
||||
//! fields the three metric-bearing `aura-analysis` types serialize — not a
|
||||
//! second hand-list that could independently drift. `aura-campaign` is the
|
||||
//! one crate that already depends on both `aura-analysis` (the types) and
|
||||
//! `aura-research` (the vocabulary), so it is the only place this pin can be
|
||||
//! written today (`aura-research` deliberately has no `aura-analysis` dep —
|
||||
//! fields the three metric-bearing types serialize — `RunMetrics`/`RMetrics`
|
||||
//! from `aura-backtest` (via `summarize_r`), `FamilySelection` from
|
||||
//! `aura-analysis` — not a second hand-list that could independently drift.
|
||||
//! `aura-campaign` is the one crate that already depends on the type sources
|
||||
//! and `aura-research` (the vocabulary), so this pin lives here
|
||||
//! (`aura-research` deliberately has no dep on either type crate —
|
||||
//! `metric_vocabulary`'s own doc comment names this as an accepted residual).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use aura_analysis::{summarize_r, FamilySelection, RunMetrics, SelectionMode};
|
||||
use aura_analysis::{FamilySelection, SelectionMode};
|
||||
use aura_backtest::{summarize_r, RunMetrics};
|
||||
|
||||
/// The names of every field of a serialized sample whose JSON value is a
|
||||
/// `Number` — i.e. a scalar metric readout, not a string/enum tag, a nested
|
||||
@@ -83,7 +85,7 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
|
||||
scalar_fields.extend(selection_scalars);
|
||||
|
||||
let vocabulary: BTreeSet<String> =
|
||||
aura_research::metric_vocabulary().iter().map(|s| s.to_string()).collect();
|
||||
aura_research::metric_vocabulary().iter().map(|m| m.id.to_string()).collect();
|
||||
|
||||
let missing_from_vocabulary: Vec<_> = scalar_fields.difference(&vocabulary).collect();
|
||||
let stale_in_vocabulary: Vec<_> = vocabulary.difference(&scalar_fields).collect();
|
||||
@@ -105,3 +107,31 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
|
||||
top-level enumeration)"
|
||||
);
|
||||
}
|
||||
|
||||
/// #147: the rankable roster is single-sourced from the vocabulary impl, the
|
||||
/// rosters nest (rankable ⊆ per-member ⊆ research vocabulary), and the legacy
|
||||
/// CLI alias keeps resolving. Any drift fails here, loudly.
|
||||
#[test]
|
||||
fn rankable_roster_is_single_sourced_and_nested() {
|
||||
use aura_engine::MetricVocabulary;
|
||||
|
||||
assert_eq!(
|
||||
aura_campaign::RANKABLE_METRICS,
|
||||
<RunMetrics as MetricVocabulary>::known(),
|
||||
"campaign roster must BE the vocabulary's roster"
|
||||
);
|
||||
for name in aura_campaign::RANKABLE_METRICS {
|
||||
assert!(
|
||||
aura_campaign::PER_MEMBER_METRICS.contains(name),
|
||||
"rankable metric '{name}' missing from PER_MEMBER_METRICS"
|
||||
);
|
||||
}
|
||||
for name in aura_campaign::PER_MEMBER_METRICS {
|
||||
assert!(
|
||||
aura_research::metric_vocabulary().iter().any(|m| m.id == *name),
|
||||
"per-member metric '{name}' missing from the research vocabulary"
|
||||
);
|
||||
}
|
||||
// the pre-rename CLI alias must keep resolving (back-compat).
|
||||
assert!(<RunMetrics as MetricVocabulary>::resolve("exposure_sign_flips").is_some());
|
||||
}
|
||||
|
||||
+30
-10
@@ -12,9 +12,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
aura-core = { path = "../aura-core" }
|
||||
aura-engine = { path = "../aura-engine" }
|
||||
# aura-composites: the RiskExecutor / vol_stop composite-builders the
|
||||
# r-sma harness wires (kept out of aura-engine so the engine stays domain-free).
|
||||
aura-composites = { path = "../aura-composites" }
|
||||
aura-registry = { path = "../aura-registry" }
|
||||
aura-research = { path = "../aura-research" }
|
||||
# aura-campaign: campaign-execution semantics (preflight, cell loop, stage
|
||||
@@ -22,7 +19,17 @@ aura-research = { path = "../aura-research" }
|
||||
# and renders its outcome (#198).
|
||||
aura-campaign = { path = "../aura-campaign" }
|
||||
aura-std = { path = "../aura-std" }
|
||||
aura-strategy = { path = "../aura-strategy" }
|
||||
aura-backtest = { path = "../aura-backtest" }
|
||||
aura-vocabulary = { path = "../aura-vocabulary" }
|
||||
aura-ingest = { path = "../aura-ingest" }
|
||||
# aura-measurement: the C28 measurement rung's IC vocabulary + reduction
|
||||
# (#295), the shell re-exposes it as the `aura measure ic` verb.
|
||||
aura-measurement = { path = "../aura-measurement" }
|
||||
# aura-runner: the C28 assembly position (#295) — harness assembly, input
|
||||
# binding (C26), and the param<->config translators the shell wraps into
|
||||
# verbs.
|
||||
aura-runner = { path = "../aura-runner" }
|
||||
# data-server: the local M1 archive `aura run --real` streams from. Mirrors the
|
||||
# git line in crates/aura-ingest/Cargo.toml verbatim (same source of truth).
|
||||
data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" }
|
||||
@@ -40,16 +47,29 @@ serde_json = { workspace = true }
|
||||
# EPIPE. The standard vetted crate for signal disposition, already transitive in
|
||||
# Cargo.lock; admitted under the per-case dependency policy.
|
||||
libc = "0.2"
|
||||
# sha2: the run's topology_hash (#158) is the SHA256 of the canonical signal
|
||||
# blueprint serialization. A vetted RustCrypto crate (per-case C16 review,
|
||||
# SHA256 user-settled); lives in the research-side CLI, off the frozen engine
|
||||
# (invariant 8).
|
||||
sha2 = "0.10"
|
||||
libloading = "0.8"
|
||||
toml = "0.8"
|
||||
# clap: the vetted standard argument parser. Adopted (C16 per-case review) to
|
||||
# replace the hand-rolled argv parser + ten duplicated help surfaces with one
|
||||
# declarative source, giving scoped --help, --version, --flag=value, and
|
||||
# long-option abbreviation. Research-side CLI only (invariant 8): a dev-loop
|
||||
# compile tax, never a frozen-artifact tax.
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
|
||||
[dev-dependencies]
|
||||
# zip: hand-packs a tiny synthetic M1 archive (tests/common/mod.rs) in the
|
||||
# exact on-disk format data-server reads — already transitive via the
|
||||
# data-server dependency above; declared directly here so test code may
|
||||
# `use zip::...` (#250, no new dependency actually enters the build graph).
|
||||
zip = "2"
|
||||
# aura-composites: the RiskExecutor / vol_stop composite-builders — production
|
||||
# use moved to aura-runner (#295); the shell's own use is test-only
|
||||
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
|
||||
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
|
||||
aura-composites = { path = "../aura-composites" }
|
||||
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
|
||||
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
|
||||
aura-analysis = { path = "../aura-analysis" }
|
||||
# sha2: production topology_hash / dylib_sha256 hashing now lives entirely in
|
||||
# aura-runner (#295); the shell's own use is test-only (tests/cli_run.rs
|
||||
# re-hashes a stored blueprint to check a golden), so this rides the same
|
||||
# dev-only-edge idiom as `zip`/`aura-analysis` above.
|
||||
sha2 = "0.10"
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}]}}
|
||||
@@ -1 +0,0 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"RollingMin","name":"channel_lo","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}]}}
|
||||
@@ -1 +0,0 @@
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band","bound":[{"pos":0,"name":"factor","kind":"F64","value":{"F64":2.0}}]}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}]}}
|
||||
@@ -1 +0,0 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
@@ -1 +0,0 @@
|
||||
{"format_version":1,"blueprint":{"name":"sma_signal","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
+102
-1004
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
//! Stable stderr class markers (C14): `aura: note: <text>` for benign
|
||||
//! diagnostics on a continuing run (exit code unaffected),
|
||||
//! `aura: warning: <text>` for recorded faults the run survives.
|
||||
//! Error lines that accompany a non-zero exit — and plain info lines
|
||||
//! such as the run-record summary — keep the bare `aura:` prefix; for
|
||||
//! errors, the exit-code partition is the machine contract.
|
||||
|
||||
macro_rules! note {
|
||||
($($arg:tt)*) => {
|
||||
eprintln!("aura: note: {}", format_args!($($arg)*))
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! warning {
|
||||
($($arg:tt)*) => {
|
||||
eprintln!("aura: warning: {}", format_args!($($arg)*))
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use {note, warning};
|
||||
|
||||
/// The zero-trade note's message text (#313), pluralization-aware — the
|
||||
/// milestone fieldtest tripped over "all 1 walk-forward windows"
|
||||
/// (captured: `fieldtests/milestone-stderr-honesty/captured/
|
||||
/// example2_singular.err`). Pure over the count so the wording is
|
||||
/// unit-pinned.
|
||||
fn zero_trade_note_text(n_windows: usize) -> String {
|
||||
if n_windows == 1 {
|
||||
"the single walk-forward window recorded zero trades".to_string()
|
||||
} else {
|
||||
format!("all {n_windows} walk-forward windows recorded zero trades")
|
||||
}
|
||||
}
|
||||
|
||||
/// The #313 zero-trade note, shared by both walk-forward paths (the
|
||||
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in
|
||||
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one
|
||||
/// place. Takes the per-window trade counts; a no-op unless there is at
|
||||
/// least one window and every one of them traded zero times.
|
||||
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
|
||||
let n_windows = window_trades.len();
|
||||
if n_windows > 0 && window_trades.all(|n| n == 0) {
|
||||
note!("{}", zero_trade_note_text(n_windows));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::zero_trade_note_text;
|
||||
|
||||
#[test]
|
||||
/// #278 fieldtest friction: the single-window form must read as
|
||||
/// grammatical English while the plural form keeps the exact phrase
|
||||
/// the e2e pins grep for.
|
||||
fn zero_trade_note_pluralizes() {
|
||||
assert_eq!(
|
||||
zero_trade_note_text(1),
|
||||
"the single walk-forward window recorded zero trades"
|
||||
);
|
||||
assert_eq!(
|
||||
zero_trade_note_text(3),
|
||||
"all 3 walk-forward windows recorded zero trades"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
//! JSON op-list document deserializes into the `OpDoc` DTO (so the engine `Op`
|
||||
//! stays serde-free), which maps 1:1 into `aura_engine::Op`. The `aura graph
|
||||
//! build` / `aura graph introspect` subcommands here drive these ops through the
|
||||
//! injected `aura_std::std_vocabulary`.
|
||||
//! injected `aura_vocabulary::std_vocabulary`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
@@ -13,10 +13,10 @@ use aura_engine::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
|
||||
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
|
||||
// production code; tests still exercise it directly.
|
||||
#[cfg(test)]
|
||||
use aura_std::std_vocabulary;
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
|
||||
/// The wire DTO for one construction op — the document's by-identifier shape,
|
||||
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
|
||||
@@ -44,11 +44,19 @@ enum OpDoc {
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
},
|
||||
Tap {
|
||||
from: String,
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
},
|
||||
Gang {
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
into: Vec<String>,
|
||||
},
|
||||
/// Declare the composite's one-line meaning (C29, #316) — the op-script
|
||||
/// twin of the builder's `.doc(...)`.
|
||||
Doc { text: String },
|
||||
}
|
||||
|
||||
impl OpDoc {
|
||||
@@ -61,7 +69,9 @@ impl OpDoc {
|
||||
OpDoc::Feed { .. } => "feed",
|
||||
OpDoc::Connect { .. } => "connect",
|
||||
OpDoc::Expose { .. } => "expose",
|
||||
OpDoc::Tap { .. } => "tap",
|
||||
OpDoc::Gang { .. } => "gang",
|
||||
OpDoc::Doc { .. } => "doc",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,7 +87,9 @@ impl From<OpDoc> for Op {
|
||||
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
||||
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
||||
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
||||
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
|
||||
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
|
||||
OpDoc::Doc { text } => Op::Doc { text },
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +102,7 @@ fn format_op_error(e: &OpError) -> String {
|
||||
OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||
OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"),
|
||||
OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"),
|
||||
OpError::DuplicateTap(s) => format!("duplicate tap name {s:?}"),
|
||||
OpError::DuplicateRole(s) => format!("duplicate role {s:?}"),
|
||||
OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"),
|
||||
OpError::UnknownRole(s) => format!("unknown role {s:?}"),
|
||||
@@ -121,6 +134,7 @@ fn format_op_error(e: &OpError) -> String {
|
||||
}
|
||||
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
|
||||
OpError::Incomplete(ce) => format!("{ce:?}"),
|
||||
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +142,7 @@ fn format_op_error(e: &OpError) -> String {
|
||||
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
|
||||
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
|
||||
/// past the last op).
|
||||
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
|
||||
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
@@ -154,14 +168,14 @@ fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite,
|
||||
|
||||
/// Parse a JSON op-list document, replay it through the env's vocabulary, and
|
||||
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
|
||||
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let composite = composite_from_str(doc, env)?;
|
||||
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
|
||||
}
|
||||
|
||||
/// `aura graph build`: read the op-list document from stdin, build, and print the
|
||||
/// blueprint to stdout — or the cause to stderr and exit non-zero.
|
||||
pub fn build_cmd(env: &crate::project::Env) {
|
||||
pub fn build_cmd(env: &aura_runner::project::Env) {
|
||||
use std::io::Read;
|
||||
let mut doc = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
||||
@@ -184,7 +198,7 @@ pub fn build_cmd(env: &crate::project::Env) {
|
||||
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
|
||||
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
|
||||
/// closed vocabulary.
|
||||
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
||||
let schema = builder.schema();
|
||||
let mut out = format!("{}\n", builder.label());
|
||||
@@ -202,7 +216,7 @@ pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<Strin
|
||||
|
||||
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
||||
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
||||
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let resolver = |t: &str| env.resolve(t);
|
||||
let mut session = GraphSession::new("introspect", &resolver);
|
||||
@@ -221,7 +235,7 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
|
||||
/// group must be set; zero or more than one is the usage error (exit 2). The id
|
||||
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
|
||||
/// combine (one build, both ids, content id first).
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
|
||||
let count = cmd.vocabulary as usize
|
||||
+ cmd.node.is_some() as usize
|
||||
+ cmd.unwired as usize
|
||||
@@ -396,7 +410,7 @@ fn gang_fault_prose(e: &CompileError) -> String {
|
||||
/// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph
|
||||
/// build` path (`composite_from_str`, over `OpError`) call this same helper,
|
||||
/// so the tier texts cannot drift between the two error families.
|
||||
fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<String> {
|
||||
fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option<String> {
|
||||
if !type_id.contains("::") {
|
||||
return None;
|
||||
}
|
||||
@@ -415,7 +429,7 @@ fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<Str
|
||||
|
||||
/// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the
|
||||
/// blueprint LOAD path's entry point over `LoadError`.
|
||||
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env) -> Option<String> {
|
||||
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option<String> {
|
||||
match e {
|
||||
LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env),
|
||||
_ => None,
|
||||
@@ -439,7 +453,7 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env
|
||||
/// `Ok(())` means the document loaded cleanly; callers that only need the
|
||||
/// validation (not the `Composite`) re-parse `doc` themselves afterward
|
||||
/// (`blueprint_from_json` is cheap and infallible at that point).
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> {
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> {
|
||||
if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
|
||||
return Err(
|
||||
"this is an op-script (an op array), not a built blueprint — run \
|
||||
@@ -461,7 +475,7 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Resu
|
||||
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
|
||||
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
|
||||
/// each canonicalized by its own rules.
|
||||
pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> {
|
||||
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
|
||||
match value {
|
||||
@@ -480,22 +494,22 @@ pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Resul
|
||||
|
||||
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
|
||||
/// id in the project store (the campaign-run target-addressing convention).
|
||||
/// The id shape is `crate::campaign_run::is_content_id` — the same predicate
|
||||
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
|
||||
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
|
||||
/// cannot drift apart on what counts as a store address.
|
||||
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
// A CLI arg tolerates the `content:` display prefix (#194); doc ref
|
||||
// fields stay bare-only.
|
||||
let target = target
|
||||
.strip_prefix("content:")
|
||||
.filter(|t| crate::campaign_run::is_content_id(t))
|
||||
.filter(|t| aura_runner::axes::is_content_id(t))
|
||||
.unwrap_or(target);
|
||||
let path = Path::new(target);
|
||||
if path.is_file() {
|
||||
return std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()));
|
||||
}
|
||||
if crate::campaign_run::is_content_id(target) {
|
||||
if aura_runner::axes::is_content_id(target) {
|
||||
return match env.registry().get_blueprint(target) {
|
||||
Ok(Some(json)) => Ok(json),
|
||||
Ok(None) => Err(format!("no blueprint {target} in the project store")),
|
||||
@@ -510,7 +524,7 @@ fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<Str
|
||||
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
|
||||
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
|
||||
/// the same form `introspect --node` already uses for param kinds.
|
||||
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
use std::fmt::Write as _;
|
||||
let text = resolve_blueprint_text(target, env)?;
|
||||
// Shape-discriminated like file-mode --content-id: an op-script (array)
|
||||
@@ -527,7 +541,7 @@ fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, Strin
|
||||
/// the project vocabulary, canonicalize, content-address, and store — the
|
||||
/// `process register` pattern, printing the store path so the trail is
|
||||
/// followable. Prose to stderr + exit 1 on any refusal.
|
||||
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
|
||||
pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
|
||||
match register_blueprint(file, env) {
|
||||
Ok(line) => println!("{line}"),
|
||||
Err(m) => {
|
||||
@@ -537,7 +551,7 @@ pub fn register_cmd(file: &Path, env: &crate::project::Env) {
|
||||
}
|
||||
}
|
||||
|
||||
fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
|
||||
fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let text = std::fs::read_to_string(file)
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
// Shape-discriminated like file-mode --content-id (#202): either shape
|
||||
@@ -597,7 +611,7 @@ mod tests {
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds");
|
||||
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
|
||||
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
|
||||
}
|
||||
@@ -613,14 +627,14 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series"]},
|
||||
{"op":"expose","from":"fast.value","as":"out"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds");
|
||||
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_from_str_reports_unknown_node_at_its_op_index() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
|
||||
}
|
||||
|
||||
@@ -635,7 +649,7 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
|
||||
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
|
||||
"names the unconnected slot by-identifier, got: {err}");
|
||||
@@ -644,11 +658,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn introspect_node_lists_ports_kinds_and_params() {
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("series"), "lists the input port: {out}");
|
||||
assert!(out.contains("value"), "lists the output field: {out}");
|
||||
assert!(out.contains("length"), "lists the param path: {out}");
|
||||
assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
assert!(super::introspect_node("Nope", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
}
|
||||
|
||||
/// A bind whose value-kind mismatches the param reads as prose, like the connect
|
||||
@@ -656,7 +670,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_bad_bind_kind_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
|
||||
}
|
||||
|
||||
@@ -665,7 +679,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_unknown_bind_param_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
|
||||
}
|
||||
|
||||
@@ -686,7 +700,7 @@ mod tests {
|
||||
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
|
||||
#[test]
|
||||
fn introspect_node_shows_the_bind_form() {
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
|
||||
}
|
||||
|
||||
@@ -697,9 +711,35 @@ mod tests {
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
|
||||
let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).expect("partial document introspects");
|
||||
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
|
||||
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
|
||||
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
|
||||
}
|
||||
|
||||
/// The `tap` op-doc parses (externally tagged, `"as"`-renamed like `expose`)
|
||||
/// and `aura graph build` emits a blueprint whose serialized `taps` names the
|
||||
/// wire — the name-addressed authoring path the fieldtest wanted, no raw index.
|
||||
#[test]
|
||||
fn tap_opdoc_builds_a_blueprint_carrying_the_tap() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"tap","from":"fast.value","as":"fast_ma"},
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
]"#;
|
||||
// the tap-doc kind_label registers as "tap"
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
|
||||
assert_eq!(docs[7].kind_label(), "tap");
|
||||
// and the built blueprint carries the tap (an un-tapped composite emits no
|
||||
// `taps` key, so its presence is the proof the op threaded through)
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("tap op-doc builds");
|
||||
assert!(json.contains("\"taps\""), "blueprint carries the taps section: {json}");
|
||||
assert!(json.contains("fast_ma"), "the tap names the wire: {json}");
|
||||
}
|
||||
}
|
||||
|
||||
+643
-2649
File diff suppressed because it is too large
Load Diff
@@ -234,9 +234,9 @@ mod tests {
|
||||
/// against the prototype.
|
||||
#[test]
|
||||
fn render_html_is_self_contained_and_embeds_the_model() {
|
||||
let env = crate::project::Env::std();
|
||||
let env = aura_runner::project::Env::std();
|
||||
let bp = aura_engine::blueprint_from_json(
|
||||
include_str!("../examples/r_sma_open.json"),
|
||||
include_str!("../examples/r_sma.json"),
|
||||
&|t| env.resolve(t),
|
||||
)
|
||||
.expect("the shipped r-sma example reloads into a renderable blueprint");
|
||||
|
||||
@@ -13,7 +13,7 @@ use aura_research::{
|
||||
};
|
||||
use aura_registry::RefFault;
|
||||
|
||||
use crate::project::Env;
|
||||
use aura_runner::project::Env;
|
||||
|
||||
#[derive(clap::Args)]
|
||||
pub struct ProcessCmd {
|
||||
@@ -29,6 +29,8 @@ pub enum ProcessSub {
|
||||
Introspect(DocIntrospectCmd),
|
||||
/// Register a valid process document into the store under the runs root.
|
||||
Register { file: PathBuf },
|
||||
/// Print a registered process document's canonical bytes to stdout.
|
||||
Show { id: String },
|
||||
}
|
||||
|
||||
#[derive(clap::Args)]
|
||||
@@ -60,6 +62,17 @@ impl DocIntrospectCmd {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `--parallel-instruments` in domain terms: clap's built-in
|
||||
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
|
||||
/// zero for non-zero type") at exactly the moment a user needs guidance.
|
||||
fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
|
||||
s.parse::<usize>().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| {
|
||||
"must be a whole number of at least 1 — it bounds how many distinct \
|
||||
instruments are resident in parallel"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Exactly one introspect mode (the graph-introspect guard idiom: usage
|
||||
/// error on stderr, exit 2).
|
||||
fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
|
||||
@@ -79,9 +92,10 @@ fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
|
||||
/// vocabulary, but the mode rides the shared introspect struct and answers
|
||||
/// for both families.
|
||||
fn print_metric_roster() {
|
||||
for name in aura_research::metric_vocabulary() {
|
||||
let rankable = aura_campaign::RANKABLE_METRICS.contains(name);
|
||||
let gate = aura_campaign::PER_MEMBER_METRICS.contains(name);
|
||||
for m in aura_research::metric_vocabulary() {
|
||||
let name = m.id;
|
||||
let rankable = aura_campaign::RANKABLE_METRICS.contains(&name);
|
||||
let gate = aura_campaign::PER_MEMBER_METRICS.contains(&name);
|
||||
// The generalize applicability is the registry's own R-expectancy
|
||||
// predicate — no fourth roster site (#190/#207).
|
||||
let generalize = aura_registry::check_r_metric(name).is_ok();
|
||||
@@ -137,6 +151,9 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
||||
format!("pipeline[{stage}]: walk_forward {field} must be > 0")
|
||||
}
|
||||
DocFault::EmptyInstruments => "data.instruments is empty".into(),
|
||||
DocFault::DuplicateInstrument { index, instrument } => {
|
||||
format!("data.instruments[{index}]: \"{instrument}\" is listed more than once")
|
||||
}
|
||||
DocFault::NoWindow => "data.windows is empty".into(),
|
||||
DocFault::BadWindow { index } => {
|
||||
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
|
||||
@@ -147,6 +164,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
||||
DocFault::BadCost { index } => {
|
||||
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
|
||||
}
|
||||
DocFault::CostInstrumentKeys { index, missing, extra } => {
|
||||
let mut parts = Vec::new();
|
||||
if !missing.is_empty() {
|
||||
parts.push(format!("misses campaign instrument(s) {}", missing.join(", ")));
|
||||
}
|
||||
if !extra.is_empty() {
|
||||
parts.push(format!("names no campaign instrument(s): {}", extra.join(", ")));
|
||||
}
|
||||
format!("cost[{index}]: instrument map {}", parts.join("; "))
|
||||
}
|
||||
DocFault::NoStrategy => "strategies is empty".into(),
|
||||
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
|
||||
DocFault::EmptyAxis { strategy, axis } => {
|
||||
@@ -155,7 +182,7 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
||||
DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
|
||||
DocFault::UnknownTap { index, tap } => format!(
|
||||
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
|
||||
tap_vocabulary().join(" | ")
|
||||
tap_vocabulary().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
|
||||
),
|
||||
DocFault::UnknownBindingColumn { role, column } => format!(
|
||||
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
|
||||
@@ -164,6 +191,16 @@ pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
||||
DocFault::ProcessRefMustBeContentId => {
|
||||
"process.ref: a process is referenced by content id in this version".into()
|
||||
}
|
||||
DocFault::BadDescription { subject, fault } => match fault {
|
||||
aura_core::DocGateFault::Empty => format!(
|
||||
"description: `{subject}`'s description is empty — omit the \
|
||||
field or write a one-line meaning (C29)"
|
||||
),
|
||||
aura_core::DocGateFault::RestatesName => format!(
|
||||
"description: `{subject}`'s description merely restates the \
|
||||
document name (C29)"
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,6 +221,7 @@ pub fn process_cmd(cmd: ProcessCmd, env: &Env) {
|
||||
introspect_process(i)
|
||||
}
|
||||
ProcessSub::Register { file } => register_process(file, env),
|
||||
ProcessSub::Show { id } => show_process(id, env),
|
||||
};
|
||||
if let Err(m) = result {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -223,6 +261,15 @@ fn validate_process_file(file: &PathBuf) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"process register needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let doc = parse_valid_process(file)
|
||||
.map_err(|m| format!("refusing to register: {m}"))?;
|
||||
let canonical = process_to_json(&doc);
|
||||
@@ -235,6 +282,60 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve `id` against a document store's full candidate content-id list —
|
||||
/// mirroring the exact-first / unique-match / else-refuse shape
|
||||
/// `aura_runner::reproduce::load_family` established for #298's family/run
|
||||
/// handle. Called only once the caller's own exact-match lookup has already
|
||||
/// missed, so a filter match here is always a strict, shorter prefix (ids
|
||||
/// are fixed-length hashes; a full-length miss cannot be a prefix of
|
||||
/// another). `Ok(None)`: no candidate matches — genuinely unknown, the
|
||||
/// caller's existing not-found prose applies unchanged. `Err`: two or more
|
||||
/// candidates share the prefix — refused by name, listing every candidate,
|
||||
/// rather than guessed.
|
||||
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, String> {
|
||||
let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect();
|
||||
match matches.as_slice() {
|
||||
[] => Ok(None),
|
||||
[only] => Ok(Some((*only).clone())),
|
||||
many => {
|
||||
let listed = many.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ");
|
||||
Err(format!("ambiguous id \"{prefix}\": candidates {listed}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn show_process(id: &str, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"process show needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let registry = env.registry();
|
||||
if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? {
|
||||
// `print!`, not `println!`: `bytes` is the content-addressed canonical
|
||||
// form the store keyed `id` on — the CLI is a transport and must not
|
||||
// frame the canonical bytes with a trailing newline (#164, mirroring
|
||||
// `graph_construct::build_cmd`).
|
||||
print!("{bytes}");
|
||||
return Ok(());
|
||||
}
|
||||
let candidates = registry.list_process_ids().map_err(|e| e.to_string())?;
|
||||
match resolve_id_prefix(id, &candidates)? {
|
||||
Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? {
|
||||
Some(bytes) => {
|
||||
print!("{bytes}");
|
||||
Ok(())
|
||||
}
|
||||
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
|
||||
},
|
||||
None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> {
|
||||
if cmd.metrics {
|
||||
print_metric_roster();
|
||||
@@ -278,6 +379,9 @@ fn describe_one_block(id: &str) -> Result<(), String> {
|
||||
fn print_open_slots(slots: &[aura_research::OpenSlot]) {
|
||||
for s in slots {
|
||||
println!("open slot: {} ({})", s.path, s.hint);
|
||||
for note in &s.notes {
|
||||
println!(" {note}");
|
||||
}
|
||||
}
|
||||
if slots.is_empty() {
|
||||
println!("no open slots");
|
||||
@@ -300,10 +404,22 @@ pub enum CampaignSub {
|
||||
Register { file: PathBuf },
|
||||
/// Execute a stored campaign into a realized run-set (a .json file is
|
||||
/// register-then-run sugar; the canonical address is the content id).
|
||||
Run { target: String },
|
||||
Run {
|
||||
target: String,
|
||||
/// Bound on distinct instruments resident in parallel (the RAM
|
||||
/// lever; 1 reproduces the sequential loop's footprint).
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
value_parser = parse_parallel_instruments,
|
||||
)]
|
||||
parallel_instruments: std::num::NonZeroUsize,
|
||||
},
|
||||
/// List stored campaign realizations, or dump one campaign's records
|
||||
/// (the bare store lines, not the run-emit wrapper).
|
||||
Runs { campaign: Option<String>, run: Option<usize> },
|
||||
/// Print a registered campaign document's canonical bytes to stdout.
|
||||
Show { id: String },
|
||||
}
|
||||
|
||||
/// The one authoring trap the referential tier can name outright: a ref that
|
||||
@@ -317,6 +433,14 @@ fn prefix_hint(id: &str) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// `show_campaign`'s not-found refusal prose — campaigns have no `RefFault`
|
||||
/// sibling (unlike processes' `ref_fault_prose`), so this is the single
|
||||
/// source both the exact-miss and prefix-resolved-but-then-missing arms
|
||||
/// route through, keeping the two copies from drifting.
|
||||
fn campaign_not_found_prose(id: &str) -> String {
|
||||
format!("campaign {id} not found in the project store{}", prefix_hint(id))
|
||||
}
|
||||
|
||||
pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
||||
match f {
|
||||
RefFault::ProcessNotFound(id) => {
|
||||
@@ -403,7 +527,18 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
|
||||
/// via `exit_on_campaign_result`), so it is pulled out of the unified
|
||||
/// `Result<(), String>` match the other four subcommands still share.
|
||||
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
||||
if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
|
||||
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
|
||||
target,
|
||||
env,
|
||||
*parallel_instruments,
|
||||
));
|
||||
return;
|
||||
}
|
||||
let result = match &cmd.sub {
|
||||
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
||||
CampaignSub::Introspect(i) => {
|
||||
@@ -411,8 +546,9 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
||||
introspect_campaign(i)
|
||||
}
|
||||
CampaignSub::Register { file } => register_campaign(file, env),
|
||||
CampaignSub::Run { target } => crate::campaign_run::run_campaign(target, env),
|
||||
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
|
||||
CampaignSub::Show { id } => show_campaign(id, env),
|
||||
CampaignSub::Run { .. } => unreachable!("handled above"),
|
||||
};
|
||||
if let Err(m) = result {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -502,6 +638,15 @@ fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"campaign register needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let doc = parse_valid_campaign(file)
|
||||
.map_err(|m| format!("refusing to register: {m}"))?;
|
||||
let canonical = campaign_to_json(&doc);
|
||||
@@ -514,6 +659,36 @@ fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn show_campaign(id: &str, env: &Env) -> Result<(), String> {
|
||||
if env.provenance().is_none() {
|
||||
let cwd = std::env::current_dir()
|
||||
.map(|d| d.display().to_string())
|
||||
.unwrap_or_default();
|
||||
return Err(format!(
|
||||
"campaign show needs a project: the document store lives under the \
|
||||
project store root (no Aura.toml found up from {cwd})"
|
||||
));
|
||||
}
|
||||
let registry = env.registry();
|
||||
if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? {
|
||||
// `print!`, not `println!`: see `show_process` — the CLI must not
|
||||
// frame the canonical bytes with a trailing newline (#164).
|
||||
print!("{bytes}");
|
||||
return Ok(());
|
||||
}
|
||||
let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?;
|
||||
match resolve_id_prefix(id, &candidates)? {
|
||||
Some(full_id) => match registry.get_campaign(&full_id).map_err(|e| e.to_string())? {
|
||||
Some(bytes) => {
|
||||
print!("{bytes}");
|
||||
Ok(())
|
||||
}
|
||||
None => Err(campaign_not_found_prose(id)),
|
||||
},
|
||||
None => Err(campaign_not_found_prose(id)),
|
||||
}
|
||||
}
|
||||
|
||||
fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> {
|
||||
if cmd.metrics {
|
||||
print_metric_roster();
|
||||
@@ -651,4 +826,106 @@ mod tests {
|
||||
);
|
||||
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
|
||||
}
|
||||
|
||||
/// C29 (#316): the description-quality fault names the subject and the
|
||||
/// rule (C29) for both sub-faults — an empty description and one that
|
||||
/// merely restates the document name — never leaking the Debug variant
|
||||
/// names of either `DocFault` or the nested `DocGateFault`.
|
||||
#[test]
|
||||
fn bad_description_prose_names_subject_and_rule_for_both_faults() {
|
||||
let empty = doc_fault_prose(&DocFault::BadDescription {
|
||||
subject: "ger40-momentum".into(),
|
||||
fault: aura_core::DocGateFault::Empty,
|
||||
});
|
||||
assert!(
|
||||
empty.contains("`ger40-momentum`'s description is empty"),
|
||||
"subject and emptiness named: {empty}"
|
||||
);
|
||||
assert!(empty.contains("C29"), "rule cited: {empty}");
|
||||
assert!(!empty.contains("BadDescription") && !empty.contains("DocGateFault"), "Debug leak: {empty}");
|
||||
|
||||
let restated = doc_fault_prose(&DocFault::BadDescription {
|
||||
subject: "ger40-momentum".into(),
|
||||
fault: aura_core::DocGateFault::RestatesName,
|
||||
});
|
||||
assert!(
|
||||
restated.contains("`ger40-momentum`'s description merely restates the document name"),
|
||||
"subject and restatement named: {restated}"
|
||||
);
|
||||
assert!(restated.contains("C29"), "rule cited: {restated}");
|
||||
assert!(!restated.contains("BadDescription") && !restated.contains("DocGateFault"), "Debug leak: {restated}");
|
||||
}
|
||||
|
||||
/// #260: the instrument-map key-mismatch fault is path-addressed and names
|
||||
/// both directions — the campaign instruments the map misses AND the map
|
||||
/// keys naming no campaign instrument — in one line, no Debug leak. The
|
||||
/// extra-keys branch is otherwise unpinned (the e2e covers only missing).
|
||||
#[test]
|
||||
fn cost_instrument_keys_prose_names_both_directions() {
|
||||
let prose = doc_fault_prose(&DocFault::CostInstrumentKeys {
|
||||
index: 1,
|
||||
missing: vec!["EURUSD".into()],
|
||||
extra: vec!["GER40.cash".into()],
|
||||
});
|
||||
assert_eq!(
|
||||
prose,
|
||||
"cost[1]: instrument map misses campaign instrument(s) EURUSD; \
|
||||
names no campaign instrument(s): GER40.cash"
|
||||
);
|
||||
assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}");
|
||||
}
|
||||
|
||||
/// #300 fieldtest (df_4): outside a project (no Aura.toml → `Env::std()`),
|
||||
/// show's refusal names the missing project — the honest degradation
|
||||
/// `validate` and `campaign runs` already share ("no Aura.toml found up
|
||||
/// from <cwd>") — never a "project store" that does not exist. Blaming the
|
||||
/// id sends an author in the wrong directory chasing a registration that
|
||||
/// was never the problem. The refusal itself (Err → exit 1) is correct
|
||||
/// and stays.
|
||||
#[test]
|
||||
fn show_outside_a_project_names_the_missing_aura_toml_not_the_id() {
|
||||
let env = Env::std();
|
||||
for (verb, result) in [
|
||||
("process show", show_process("deadbeef", &env)),
|
||||
("campaign show", show_campaign("deadbeef", &env)),
|
||||
] {
|
||||
let msg = result.expect_err("show outside a project must refuse");
|
||||
assert!(
|
||||
msg.contains("no Aura.toml found up from"),
|
||||
"{verb} outside a project must name the missing Aura.toml: {msg}"
|
||||
);
|
||||
assert!(
|
||||
!msg.contains("not found in the project store"),
|
||||
"{verb} outside a project must not blame the id against a \
|
||||
store that does not exist: {msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// #302: the ambiguity arm of `resolve_id_prefix` — content ids are
|
||||
/// hash-derived, not choosable, so two documents sharing a printed
|
||||
/// prefix cannot be constructed honestly through an E2E fixture
|
||||
/// (`show_resolves_a_unique_prefix_to_the_full_id_bytes` pins the
|
||||
/// unique-match arm end-to-end instead). This unit test is the only pin
|
||||
/// the ambiguous-refusal arm gets: a prefix shared by more than one
|
||||
/// candidate refuses rather than guesses, and the refusal names every
|
||||
/// sharing candidate so the author can pick the intended one.
|
||||
#[test]
|
||||
fn resolve_id_prefix_refuses_an_ambiguous_prefix_listing_candidates() {
|
||||
let candidates = vec!["4e2d1111".to_string(), "4e2d2222".to_string(), "9f3a0000".to_string()];
|
||||
|
||||
let err = resolve_id_prefix("4e2d", &candidates).expect_err("shared prefix must refuse");
|
||||
assert!(err.contains("4e2d1111"), "ambiguity refusal must list candidate 1: {err}");
|
||||
assert!(err.contains("4e2d2222"), "ambiguity refusal must list candidate 2: {err}");
|
||||
assert!(!err.contains("9f3a0000"), "ambiguity refusal must not list a non-matching id: {err}");
|
||||
|
||||
// Companion sanity for the two other arms at the same seam: a unique
|
||||
// prefix resolves, and no matching prefix reports as unresolved
|
||||
// rather than fabricating one.
|
||||
assert_eq!(
|
||||
resolve_id_prefix("9f3a", &candidates).expect("unique prefix must resolve"),
|
||||
Some("9f3a0000".to_string())
|
||||
);
|
||||
assert_eq!(resolve_id_prefix("dead", &candidates).expect("no match is not an error"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,8 @@ impl Scale {
|
||||
pub fn new(factor: f64) -> Self {
|
||||
Self { factor, out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
// Replace `doc` below when renaming this sample node — it must keep
|
||||
// describing the node's own behaviour, not this scaffolding step.
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"__NS__::Scale",
|
||||
@@ -163,6 +165,7 @@ impl Scale {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
|
||||
doc: "scalar gain: emits the input times the factor param",
|
||||
},
|
||||
|p| Box::new(Scale::new(p[0].f64())),
|
||||
)
|
||||
@@ -219,7 +222,7 @@ crate is attached to its project via `[nodes]` in the project's `Aura.toml`.
|
||||
|
||||
const GITIGNORE_PROJECT: &str = "/runs\n";
|
||||
|
||||
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
const CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data-only)
|
||||
|
||||
@@ -280,7 +283,7 @@ pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> {
|
||||
Ok(o) if o.status.success() => {
|
||||
commit_all(&spec.target_dir, "aura new scaffold");
|
||||
}
|
||||
_ => eprintln!("aura: warning: git init failed (project created without a repo)"),
|
||||
_ => crate::diag::warning!("git init failed (project created without a repo)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -321,7 +324,7 @@ pub fn scaffold_node_crate(spec: &ScaffoldSpec) -> Result<(), String> {
|
||||
Ok(o) if o.status.success() => {
|
||||
commit_all(&spec.target_dir, "aura nodes new scaffold");
|
||||
}
|
||||
_ => eprintln!("aura: warning: git init failed (node crate created without a repo)"),
|
||||
_ => crate::diag::warning!("git init failed (node crate created without a repo)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -354,7 +357,7 @@ fn commit_all(dir: &std::path::Path, message: &str) {
|
||||
.current_dir(dir)
|
||||
.output();
|
||||
if !matches!(add, Ok(o) if o.status.success()) {
|
||||
eprintln!("aura: warning: git add failed (scaffold created without an initial commit)");
|
||||
crate::diag::warning!("git add failed (scaffold created without an initial commit)");
|
||||
return;
|
||||
}
|
||||
let commit = std::process::Command::new("git")
|
||||
@@ -371,8 +374,8 @@ fn commit_all(dir: &std::path::Path, message: &str) {
|
||||
.current_dir(dir)
|
||||
.output();
|
||||
if !matches!(commit, Ok(o) if o.status.success()) {
|
||||
eprintln!(
|
||||
"aura: warning: git commit failed (scaffold created without an initial commit)"
|
||||
crate::diag::warning!(
|
||||
"git commit failed (scaffold created without an initial commit)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -488,7 +491,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scaffold_project_refuses_existing_destination() {
|
||||
let tmp = std::env::temp_dir().join(format!("aura-pscaf-{}", std::process::id()));
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-pscaf");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("x")).unwrap();
|
||||
let spec = project_scaffold_spec("x", &tmp).unwrap();
|
||||
let e = scaffold_project(&spec).unwrap_err();
|
||||
@@ -498,7 +503,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() {
|
||||
let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id()));
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-scaf");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
// existing destination
|
||||
let mut spec = scaffold_spec("x", None, None, &tmp).unwrap();
|
||||
|
||||
@@ -53,14 +53,16 @@ pub(crate) struct SugarInvocation<'a> {
|
||||
/// by construction, so the net channel is never producible there; requesting
|
||||
/// it anyway would make `campaign_run`'s executor print an unreachable "add a
|
||||
/// cost block to the campaign document" remedy (#240). The exclusion reuses
|
||||
/// `campaign_run`'s own tap/channel classification (`tap_channel`/
|
||||
/// `aura_runner::runner`'s own tap/channel classification (`tap_channel`/
|
||||
/// `TapChannel::Net`) rather than duplicating the net-tap name here.
|
||||
fn persist_taps_from(trace: bool) -> Vec<String> {
|
||||
if trace {
|
||||
tap_vocabulary()
|
||||
.iter()
|
||||
.filter(|t| crate::campaign_run::tap_channel(t) != Some(crate::campaign_run::TapChannel::Net))
|
||||
.map(|t| t.to_string())
|
||||
.filter(|t| {
|
||||
aura_runner::runner::tap_channel(t.id) != Some(aura_runner::runner::TapChannel::Net)
|
||||
})
|
||||
.map(|t| t.id.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
@@ -285,8 +287,8 @@ pub(crate) fn register_generated_g(
|
||||
/// (campaign_run.rs) — the referential shape the dispatch-boundary name
|
||||
/// preflight (main.rs) does not catch: a subset of axes that leaves an open
|
||||
/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the
|
||||
/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses
|
||||
/// per member (campaign_run.rs) — a bound-param axis passes this
|
||||
/// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member`
|
||||
/// uses per member (aura-runner) — a bound-param axis passes this
|
||||
/// executable-shape preflight exactly like an already-open one; an axis
|
||||
/// matching neither space still fails `bind_axes`'s own named check below.
|
||||
/// `probe_params` is any one grid value per axis (checks NAME
|
||||
@@ -298,7 +300,7 @@ fn validate_before_register(
|
||||
campaign: &CampaignDoc,
|
||||
blueprint_canonical: &str,
|
||||
probe_params: &[(String, Scalar)],
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let doc_faults = aura_research::validate_campaign(campaign);
|
||||
if !doc_faults.is_empty() {
|
||||
@@ -324,68 +326,78 @@ fn validate_before_register(
|
||||
let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t))
|
||||
.expect("a generated sugar campaign's strategy ref reloads its own canonical bytes");
|
||||
let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect();
|
||||
let overrides = crate::campaign_run::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let overrides = aura_runner::axes::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space();
|
||||
let strategy_id = content_id_of(blueprint_canonical);
|
||||
crate::campaign_run::bind_axes(&space, &strategy_id, probe_params)
|
||||
aura_runner::axes::bind_axes(&space, &strategy_id, probe_params)
|
||||
.map(|_| ())
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))
|
||||
}
|
||||
|
||||
/// Run one dissolved sweep invocation end-to-end: register the generated
|
||||
/// documents, then execute through the one campaign path in sugar mode.
|
||||
/// documents, then execute through the one campaign path in sugar mode. The
|
||||
/// `Ok` carries the failed-cell count (#272), propagated verbatim from the
|
||||
/// one campaign executor.
|
||||
pub(crate) fn run_sweep_sugar(
|
||||
inv: &SugarInvocation,
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_sweep(inv)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated(®, &generated)?;
|
||||
crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly)
|
||||
crate::campaign_run::run_campaign_by_id(
|
||||
&campaign_id,
|
||||
env,
|
||||
crate::campaign_run::RunPresentation::MemberLinesOnly,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build the `CrossInstrument` family members from an executed generalize
|
||||
/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order —
|
||||
/// the same order + shape the inline `run_generalize` built its `members` in,
|
||||
/// each already carrying `manifest.instrument`. Debug-asserts one member per
|
||||
/// cell: generalize's pipeline has no gate stage, so every cell nominates —
|
||||
/// a cell silently missing its nominee here would be a campaign-executor
|
||||
/// regression, not a legitimate generalize outcome, and the filter_map below
|
||||
/// must not swallow it unnoticed.
|
||||
/// each already carrying `manifest.instrument`. #272: a failed cell nominates
|
||||
/// nothing, so `members.len() < outcome.cells.len()` is now a legitimate
|
||||
/// outcome (the caller counts and surfaces the failed cells separately, via
|
||||
/// `outcome.record.cells[].fault`) — no longer an invariant this function
|
||||
/// itself enforces.
|
||||
fn cross_instrument_members(
|
||||
outcome: &aura_campaign::CampaignOutcome,
|
||||
) -> Vec<aura_engine::RunReport> {
|
||||
let members: Vec<aura_engine::RunReport> = outcome
|
||||
) -> Vec<aura_backtest::RunReport> {
|
||||
outcome
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
|
||||
.collect();
|
||||
debug_assert_eq!(
|
||||
members.len(),
|
||||
outcome.cells.len(),
|
||||
"generalize has no gate stage — every cell must nominate"
|
||||
);
|
||||
members
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run one dissolved `generalize` invocation end-to-end: register the generated
|
||||
/// documents, run through the one campaign path, then reprint today's exact
|
||||
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
|
||||
/// persist the `CrossInstrument` family. The stdout is byte-identical to the
|
||||
/// retired welded path (the committed exact-grade anchor is the gate).
|
||||
/// retired welded path (the committed exact-grade anchor is the gate). The
|
||||
/// `Ok` carries the failed-cell count (#272): a failed cell nominates nothing
|
||||
/// (the existing no-nominee -> `missing` path already handles it), counted
|
||||
/// from the recorded cells' own `fault` field rather than from the missing
|
||||
/// nominee, so the count is right even when some OTHER member of the cell's
|
||||
/// pipeline is what actually failed.
|
||||
pub(crate) fn run_generalize_sugar(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_generalize(inv, metric)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_g(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
|
||||
// Reprint the verb's aggregate line from the recorded cross-instrument
|
||||
// grade — byte-identical to the retired welded path's `generalize_json(&agg)`.
|
||||
@@ -403,7 +415,8 @@ pub(crate) fn run_generalize_sugar(
|
||||
.append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members)
|
||||
.map_err(|e| e.to_string())?;
|
||||
println!("{{\"family_id\":\"{family_id}\"}}");
|
||||
Ok(())
|
||||
let failed = run.outcome.record.cells.iter().filter(|c| c.fault.is_some()).count();
|
||||
Ok(failed)
|
||||
}
|
||||
|
||||
/// The two generated documents of one dissolved `walkforward` invocation.
|
||||
@@ -414,12 +427,13 @@ pub(crate) struct GeneratedWalkforward {
|
||||
}
|
||||
|
||||
/// Translate one `aura walkforward --real` invocation into its two generated
|
||||
/// documents: a `[std::sweep(argmax), std::walk_forward]` process (the sweep
|
||||
/// only enumerates the IS-refit survivor grid for the wf stage; its recorded
|
||||
/// argmax selection is not part of the summary) and a campaign running the
|
||||
/// invocation's axis grid over one instrument under its single risk regime.
|
||||
/// The roller sizes come from `w` (ms). `inv.blueprint_canonical` is the user's
|
||||
/// blueprint already stored by topology hash; its content id is the strategy ref.
|
||||
/// documents: a `[std::grid, std::walk_forward]` process (the leading
|
||||
/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
|
||||
/// stage — it executes nothing and persists no family (#256)) and a campaign
|
||||
/// running the invocation's axis grid over one instrument under its single
|
||||
/// risk regime. The roller sizes come from `w` (ms). `inv.blueprint_canonical`
|
||||
/// is the user's blueprint already stored by topology hash; its content id is
|
||||
/// the strategy ref.
|
||||
pub(crate) fn translate_walkforward(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
@@ -432,13 +446,7 @@ pub(crate) fn translate_walkforward(
|
||||
name: "walkforward".to_string(),
|
||||
description: None,
|
||||
pipeline: vec![
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: metric.to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
}),
|
||||
},
|
||||
StageBlock::Grid,
|
||||
StageBlock::WalkForward {
|
||||
in_sample_ms: w.in_sample_ms,
|
||||
out_of_sample_ms: w.out_of_sample_ms,
|
||||
@@ -494,20 +502,33 @@ pub(crate) fn register_generated_wf(
|
||||
/// documents, run through the one campaign path, then reprint the per-window member
|
||||
/// lines + the summary line from the recorded `WalkForward` family. The stdout
|
||||
/// summary line is byte-identical to the retired welded path (the committed
|
||||
/// exact-grade anchor is the gate).
|
||||
/// exact-grade anchor is the gate). #272: the invocation's one cell either
|
||||
/// completed (`Ok(0)`) or failed (`Ok(1)`) — a failed single cell is not an
|
||||
/// error, since a completed-with-failed-cell run is the campaign path's own
|
||||
/// vocabulary (exit 3 at the dispatch boundary), never an "absent bootstrap"
|
||||
/// refusal the retired welded path's `.ok_or` would have raised.
|
||||
pub(crate) fn run_walkforward_sugar(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
w: WfWindows,
|
||||
select: SelectRule,
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_walkforward(inv, metric, w, select)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_wf(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
|
||||
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||
crate::diag::warning!("walkforward cell failed at stage {}: {}", f.stage, f.detail);
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
// The single cell's walk_forward StageFamily.
|
||||
let wf = run
|
||||
@@ -528,6 +549,9 @@ pub(crate) fn run_walkforward_sugar(
|
||||
summary_axes.push("stop_length".to_string());
|
||||
summary_axes.push("stop_k".to_string());
|
||||
}
|
||||
crate::diag::note_zero_trade_windows(
|
||||
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
|
||||
);
|
||||
println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes));
|
||||
|
||||
// Trace persistence runs LAST (mirroring `present_campaign`'s ordering,
|
||||
@@ -544,7 +568,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
// always `None`. `run`/`mc --trace` stay refused by design (#224), not by
|
||||
// this tail being skipped.
|
||||
if let Some(trace_name) = &run.outcome.record.trace_name {
|
||||
crate::campaign_run::persist_campaign_traces(
|
||||
aura_runner::runner::persist_campaign_traces(
|
||||
trace_name,
|
||||
&run.campaign.presentation.persist_taps,
|
||||
&run.outcome,
|
||||
@@ -554,7 +578,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
env,
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
/// The two generated documents of one dissolved `mc` invocation.
|
||||
@@ -565,8 +589,9 @@ pub(crate) struct GeneratedMc {
|
||||
}
|
||||
|
||||
/// Translate one `aura mc --real` invocation into its two generated documents:
|
||||
/// a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process (the
|
||||
/// sweep enumerates the IS-refit survivor grid for the wf stage; the terminal
|
||||
/// a `[std::grid, std::walk_forward, std::monte_carlo]` process (the leading
|
||||
/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
|
||||
/// stage — it executes nothing and persists no family (#256); the terminal
|
||||
/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps
|
||||
/// `E[R]`) and a campaign running the invocation's axis grid over one instrument
|
||||
/// under its single risk regime. Unlike `translate_walkforward` (which hardcodes
|
||||
@@ -587,13 +612,7 @@ pub(crate) fn translate_mc(
|
||||
name: "mc".to_string(),
|
||||
description: None,
|
||||
pipeline: vec![
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: metric.to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
}),
|
||||
},
|
||||
StageBlock::Grid,
|
||||
StageBlock::WalkForward {
|
||||
in_sample_ms: w.in_sample_ms,
|
||||
out_of_sample_ms: w.out_of_sample_ms,
|
||||
@@ -652,19 +671,31 @@ pub(crate) fn register_generated_mc(
|
||||
/// byte-identical to the retired welded path (the committed exact-grade anchor is
|
||||
/// the gate). mc prints ONLY the one summary line — no per-window member lines
|
||||
/// (unlike walkforward): the monte_carlo stage is a terminal annotator, not a family.
|
||||
/// #272: the invocation's one cell either completed (`Ok(0)`) or failed
|
||||
/// (`Ok(1)`) — a failed single cell is not an error (see `run_walkforward_sugar`'s
|
||||
/// twin note).
|
||||
pub(crate) fn run_mc_sugar(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
w: WfWindows,
|
||||
mc: McKnobs,
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_mc(inv, metric, w, mc)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_mc(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
|
||||
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||
crate::diag::warning!("mc cell failed at stage {}: {}", f.stage, f.detail);
|
||||
return Ok(1);
|
||||
}
|
||||
|
||||
// The single cell's terminal monte_carlo StageRealization carries the pooled-OOS
|
||||
// bootstrap (record path: outcome.record.cells[].stages[].bootstrap).
|
||||
@@ -680,7 +711,7 @@ pub(crate) fn run_mc_sugar(
|
||||
})
|
||||
.ok_or("mc produced no pooled-OOS bootstrap")?;
|
||||
println!("{}", crate::mc_r_bootstrap_json(boot));
|
||||
Ok(())
|
||||
Ok(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -895,10 +926,9 @@ mod tests {
|
||||
/// that silently, before the dispatch rewire ever exercises the path live.
|
||||
#[test]
|
||||
fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"aura-verb-sugar-generalize-registry-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-verb-sugar-generalize-registry-test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||
|
||||
@@ -934,17 +964,11 @@ mod tests {
|
||||
content_id_of(&campaign_to_json(&b.campaign)),
|
||||
"identical invocations must generate identical campaign ids"
|
||||
);
|
||||
// pipeline: sweep(argmax) then walk_forward(argmax, rolling)
|
||||
// pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling)
|
||||
assert_eq!(
|
||||
a.process.pipeline,
|
||||
vec![
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: "sqn_normalized".to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
})
|
||||
},
|
||||
StageBlock::Grid,
|
||||
StageBlock::WalkForward {
|
||||
in_sample_ms: 7_776_000_000,
|
||||
out_of_sample_ms: 2_592_000_000,
|
||||
@@ -1047,10 +1071,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"aura-verb-sugar-walkforward-registry-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-verb-sugar-walkforward-registry-test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||
let ax = wf_axes();
|
||||
@@ -1084,17 +1107,11 @@ mod tests {
|
||||
content_id_of(&campaign_to_json(&b.campaign)),
|
||||
"identical invocations must generate identical campaign ids"
|
||||
);
|
||||
// pipeline: sweep(argmax) then walk_forward(argmax, rolling) then monte_carlo
|
||||
// pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling) then monte_carlo
|
||||
assert_eq!(
|
||||
a.process.pipeline,
|
||||
vec![
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: "sqn_normalized".to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
})
|
||||
},
|
||||
StageBlock::Grid,
|
||||
StageBlock::WalkForward {
|
||||
in_sample_ms: 7_776_000_000,
|
||||
out_of_sample_ms: 2_592_000_000,
|
||||
@@ -1140,10 +1157,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"aura-verb-sugar-mc-registry-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-verb-sugar-mc-registry-test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||
let ax = wf_axes();
|
||||
@@ -1165,4 +1181,73 @@ mod tests {
|
||||
assert_eq!(stored_process, process_to_json(&generated.process));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A minimal `RunReport` stamped with `instrument`, distinguishable in a
|
||||
/// members list by nothing but that stamp — the field
|
||||
/// `cross_instrument_members` preserves verbatim from a nominating cell.
|
||||
fn fake_report(instrument: &str) -> aura_backtest::RunReport {
|
||||
aura_engine::RunReport {
|
||||
manifest: aura_engine::RunManifest {
|
||||
commit: String::new(),
|
||||
params: vec![],
|
||||
defaults: vec![],
|
||||
window: (aura_engine::Timestamp(0), aura_engine::Timestamp(1)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
selection: None,
|
||||
instrument: Some(instrument.to_string()),
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
},
|
||||
metrics: aura_backtest::RunMetrics {
|
||||
total_pips: 0.0,
|
||||
max_drawdown: 0.0,
|
||||
bias_sign_flips: 0,
|
||||
r: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_outcome(nominee_instrument: Option<&str>) -> aura_campaign::CellOutcome {
|
||||
aura_campaign::CellOutcome {
|
||||
families: vec![],
|
||||
selections: vec![],
|
||||
nominee: nominee_instrument.map(|i| (vec![], fake_report(i))),
|
||||
}
|
||||
}
|
||||
|
||||
/// #272: `cross_instrument_members` skips a cell with no nominee (a
|
||||
/// failed cell nominates nothing) rather than padding the members list or
|
||||
/// aborting — the surviving cells' reports still reach the persisted
|
||||
/// `CrossInstrument` family, in cell order, with the failed cell simply
|
||||
/// absent (not a placeholder). Three cells (AAA nominates, BBB fails, CCC
|
||||
/// nominates) must yield exactly the two survivors' reports, in order.
|
||||
#[test]
|
||||
fn cross_instrument_members_skips_a_failed_cell_and_keeps_survivor_order() {
|
||||
let outcome = aura_campaign::CampaignOutcome {
|
||||
record: aura_registry::CampaignRunRecord {
|
||||
campaign: String::new(),
|
||||
process: String::new(),
|
||||
run: 0,
|
||||
seed: 0,
|
||||
cells: vec![],
|
||||
generalizations: vec![],
|
||||
trace_name: None,
|
||||
},
|
||||
run: 0,
|
||||
cells: vec![
|
||||
cell_outcome(Some("AAA")),
|
||||
cell_outcome(None),
|
||||
cell_outcome(Some("CCC")),
|
||||
],
|
||||
};
|
||||
let members = cross_instrument_members(&outcome);
|
||||
let instruments: Vec<Option<String>> =
|
||||
members.iter().map(|r| r.manifest.instrument.clone()).collect();
|
||||
assert_eq!(
|
||||
instruments,
|
||||
vec![Some("AAA".to_string()), Some("CCC".to_string())],
|
||||
"the failed (no-nominee) cell is skipped, the two survivors keep their order"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
|
||||
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
|
||||
+1629
-285
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,11 @@
|
||||
//! and drive the `tests/fixtures/demo-project` fixture through the real
|
||||
//! `aura` binary (#223).
|
||||
//!
|
||||
//! **Process-local serialization only.** [`project_lock`]'s `Mutex` is a
|
||||
//! per-process `static`: it serializes test THREADS within one test-binary
|
||||
//! process, but grants no cross-process exclusion. A process-parallel test
|
||||
//! runner (e.g. `cargo-nextest`, which forks one process per test binary) —
|
||||
//! or simply two concurrent `cargo test` invocations over the same working
|
||||
//! tree — still race on the shared `tests/fixtures/demo-project/runs` store.
|
||||
//! That boundary is a known, documented limitation (#223), not something
|
||||
//! this module claims to close.
|
||||
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
|
||||
//! a unique tempdir per test, wired to the shared, once-built fixture crate
|
||||
//! via an absolute `[nodes]` pointer (`project.rs`'s pointer-arm routing), so
|
||||
//! no two tests ever race on the same `runs/` store — libtest's default
|
||||
//! thread parallelism applies without a serializing lock (#250).
|
||||
//!
|
||||
//! Each of the three test binaries compiles this file as its own `mod
|
||||
//! common`, so an item unused by one binary trips that binary's `-D
|
||||
@@ -18,7 +15,8 @@
|
||||
//! rather than deleting a helper just because one binary doesn't need it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, MutexGuard, OnceLock};
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// The demo-project fixture (`Aura.toml` present), built once per test-binary
|
||||
/// process. Each test binary has its own `OnceLock` (this module is compiled
|
||||
@@ -42,13 +40,111 @@ pub fn built_project() -> &'static PathBuf {
|
||||
})
|
||||
}
|
||||
|
||||
/// Serializes every test that touches the shared demo-project fixture store
|
||||
/// (they remove/re-seed `<fixture>/runs`, so parallel test threads would race
|
||||
/// on it). A poisoned lock is taken over: one failed test must not cascade
|
||||
/// into unrelated lock panics.
|
||||
pub fn project_lock() -> MutexGuard<'static, ()> {
|
||||
static LOCK: Mutex<()> = Mutex::new(());
|
||||
LOCK.lock().unwrap_or_else(|e| e.into_inner())
|
||||
/// A per-test project directory, removed on drop (including mid-test panic).
|
||||
#[allow(dead_code)]
|
||||
pub struct FreshProject {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for FreshProject {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.root);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mints a unique, empty tempdir under `std::env::temp_dir()`, named
|
||||
/// `aura-e2e-<pid>-<counter>` so concurrent test threads (same process) and
|
||||
/// concurrent `cargo test` processes never collide.
|
||||
///
|
||||
/// #258 sweep note: this site keeps `std::process::id()` deliberately, unlike
|
||||
/// the fixed, pid-free sandbox names the rest of #258's fix uses — it has no
|
||||
/// pre-create wipe to defeat in the first place. `FreshProject::drop` (above)
|
||||
/// unconditionally removes the returned root when the owning test ends (pass,
|
||||
/// fail, or panic-unwind), so nothing strands across runs; the pid instead
|
||||
/// disambiguates concurrent `cargo test` *processes* racing this one function
|
||||
/// from dozens of call sites, which a fixed tag-keyed name cannot do without
|
||||
/// plumbing a unique tag through every one of them.
|
||||
fn mint_tempdir() -> PathBuf {
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"aura-e2e-{}-{}",
|
||||
std::process::id(),
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
std::fs::create_dir_all(&root).expect("create fresh project tempdir");
|
||||
root
|
||||
}
|
||||
|
||||
/// Writes the `Aura.toml` + `demo_signal.json` common to every fresh project:
|
||||
/// an absolute `[nodes]` pointer at the shared, once-built fixture crate, and
|
||||
/// — when `data_dir` is `Some` — a `[paths] data` override pointing at it
|
||||
/// (relative, resolved against the project root — `root` itself — since
|
||||
/// `Env::data_path` joins a relative `paths.data` onto the project root,
|
||||
/// same as `runs_root()` and the `[nodes]` pointers, #254).
|
||||
fn write_project_files(root: &Path, data_dir: Option<&str>) {
|
||||
let fixture = built_project();
|
||||
let data_line = data_dir.map(|d| format!("data = \"{d}\"\n")).unwrap_or_default();
|
||||
std::fs::write(
|
||||
root.join("Aura.toml"),
|
||||
format!(
|
||||
"[paths]\nruns = \"runs\"\n{data_line}\n[nodes]\ncrates = [\"{}\"]\n",
|
||||
fixture.display()
|
||||
),
|
||||
)
|
||||
.expect("write fresh project Aura.toml");
|
||||
std::fs::copy(fixture.join("demo_signal.json"), root.join("demo_signal.json"))
|
||||
.expect("copy demo_signal.json into the fresh project");
|
||||
}
|
||||
|
||||
/// Mint a unique tempdir holding a 2-line data-level project (`Aura.toml`
|
||||
/// with an absolute `[nodes]` pointer at the shared, once-built fixture
|
||||
/// crate) plus a copy of `demo_signal.json`. Every test gets its own `runs/`
|
||||
/// store under this root, so no two tests ever contend for the same store —
|
||||
/// this is what lets `project_lock` disappear (#250). Bind the guard for the
|
||||
/// whole test body: `let (dir, _g) = fresh_project();`.
|
||||
#[allow(dead_code)]
|
||||
pub fn fresh_project() -> (PathBuf, FreshProject) {
|
||||
let root = mint_tempdir();
|
||||
write_project_files(&root, None);
|
||||
(root.clone(), FreshProject { root })
|
||||
}
|
||||
|
||||
/// Like [`fresh_project`], but additionally generates a tiny synthetic M1
|
||||
/// archive under `<root>/data/` (two symbols, `SYMA` spanning 2024-01..08 and
|
||||
/// `SYMB` spanning 2024-03..06 — `SYMB` lies strictly inside `SYMA`) and
|
||||
/// points `[paths] data` at it. For tests whose property is archive-window
|
||||
/// (span) semantics, not archive size — no host tick-data mount needed, and
|
||||
/// no `--real`-symbol data refusal is ever reachable, so gates on data
|
||||
/// presence are dead code once a test switches to this helper (#250).
|
||||
#[allow(dead_code)]
|
||||
pub fn fresh_project_with_data() -> (PathBuf, FreshProject) {
|
||||
let root = mint_tempdir();
|
||||
write_project_files(&root, Some("data"));
|
||||
let data_dir = root.join("data");
|
||||
std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir");
|
||||
synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 8));
|
||||
synthetic_data::write_symbol_archive(&data_dir, "SYMB", (2024, 3), (2024, 6));
|
||||
(root.clone(), FreshProject { root })
|
||||
}
|
||||
|
||||
/// Like [`fresh_project_with_data`], but the single symbol `GAPSYM` carries a
|
||||
/// DELIBERATE interior month gap: monthly files exist for 2024-01..2024-02 and
|
||||
/// 2024-05..2024-06, while 2024-03..2024-04 are absent. This is the Copper
|
||||
/// failure shape (#264 — files present at both ends while an interior window
|
||||
/// has no data, so a first/last-bounds check passes but a campaign aborts
|
||||
/// mid-run). For tests whose property is per-symbol month-gap reporting: the
|
||||
/// two `write_symbol_archive` calls write disjoint contiguous segments into the
|
||||
/// same `data/` dir (the second re-writes GAPSYM's identical geometry sidecar),
|
||||
/// leaving March and April as a real hole in the monthly file index.
|
||||
#[allow(dead_code)]
|
||||
pub fn fresh_project_with_gapped_data() -> (PathBuf, FreshProject) {
|
||||
let root = mint_tempdir();
|
||||
write_project_files(&root, Some("data"));
|
||||
let data_dir = root.join("data");
|
||||
std::fs::create_dir_all(&data_dir).expect("create synthetic gapped archive dir");
|
||||
synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 1), (2024, 2));
|
||||
synthetic_data::write_symbol_archive(&data_dir, "GAPSYM", (2024, 5), (2024, 6));
|
||||
(root.clone(), FreshProject { root })
|
||||
}
|
||||
|
||||
/// A scratch filesystem entry a test writes under the git-tracked
|
||||
@@ -78,3 +174,142 @@ impl Drop for ScratchGuard {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A tiny, deterministic synthetic M1 archive generator: hand-packs the exact
|
||||
/// on-disk format `data-server` reads (its `loader.rs` and `records.rs`) —
|
||||
/// one zip per `SYMBOL_YYYY_MM.m1` holding a single `.bin` entry of packed
|
||||
/// 48-byte `RawM1Record`s, plus a `SYMBOL.meta.json` geometry sidecar — so
|
||||
/// the two no-window `generalize` E2E tests get a real, tiny, hostless
|
||||
/// archive instead of streaming the 6.6 GB host archive (#250).
|
||||
#[allow(dead_code)]
|
||||
mod synthetic_data {
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
/// Days since the Unix epoch for a proleptic-Gregorian civil date.
|
||||
/// Howard Hinnant's `days_from_civil` — dependency-free calendar math so
|
||||
/// this module needs no `chrono` (data-server's own dependency, not
|
||||
/// aura-cli's).
|
||||
fn days_from_civil(y: i32, m: u32, d: u32) -> i64 {
|
||||
let y = i64::from(if m <= 2 { y - 1 } else { y });
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400; // [0, 399]
|
||||
let mp = (i64::from(m) + 9) % 12; // [0, 11], Mar=0 .. Feb=11
|
||||
let doy = (153 * mp + 2) / 5 + i64::from(d) - 1; // [0, 365]
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; // [0, 146096]
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// Unix milliseconds at `y-m-d hh:mm:00` UTC.
|
||||
fn unix_ms(y: i32, m: u32, d: u32, hh: u32, mm: u32) -> i64 {
|
||||
days_from_civil(y, m, d) * 86_400_000 + (i64::from(hh) * 3600 + i64::from(mm) * 60) * 1000
|
||||
}
|
||||
|
||||
/// 0 = Sunday .. 6 = Saturday (1970-01-01 was a Thursday, day 0).
|
||||
fn weekday(y: i32, m: u32, d: u32) -> i64 {
|
||||
(days_from_civil(y, m, d) % 7 + 4 + 7) % 7
|
||||
}
|
||||
|
||||
fn days_in_month(y: i32, m: u32) -> u32 {
|
||||
match m {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 if (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 => 29,
|
||||
2 => 28,
|
||||
_ => unreachable!("month out of range: {m}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A deterministic price at continuous minute offset `t` (minutes since
|
||||
/// 2024-01-01T00:00 UTC, shared across both symbols so their overlapping
|
||||
/// span carries the same underlying curve): a gentle upward trend plus a
|
||||
/// 180-minute sine — short enough against a day's 480 tradeable minutes to
|
||||
/// produce several SMA(3)/SMA(12) crossovers per day.
|
||||
fn price_at(t_minutes: f64) -> f64 {
|
||||
let hours = t_minutes / 60.0;
|
||||
100.0 + 0.01 * hours + 5.0 * (2.0 * std::f64::consts::PI * t_minutes / 180.0).sin()
|
||||
}
|
||||
|
||||
/// Packs one `RawM1Record`-shaped 48-byte little-endian record: `time`
|
||||
/// (Delphi `TDateTime`, days since 1899-12-30 — the inverse of
|
||||
/// `data_server::records::delphi_to_unix_ms`), `open/high/low/close`,
|
||||
/// `spread` (f32), `volume` (i32).
|
||||
fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] {
|
||||
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
|
||||
const MS_PER_DAY: f64 = 86_400_000.0;
|
||||
let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS;
|
||||
let mut rec = [0u8; 48];
|
||||
rec[0..8].copy_from_slice(&dt.to_le_bytes());
|
||||
rec[8..16].copy_from_slice(&open.to_le_bytes());
|
||||
rec[16..24].copy_from_slice(&high.to_le_bytes());
|
||||
rec[24..32].copy_from_slice(&low.to_le_bytes());
|
||||
rec[32..40].copy_from_slice(&close.to_le_bytes());
|
||||
rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes());
|
||||
rec[44..48].copy_from_slice(&100_i32.to_le_bytes());
|
||||
rec
|
||||
}
|
||||
|
||||
/// One month's worth of weekday, 08:00-16:00 UTC, 1-minute bars (~10k).
|
||||
fn month_records(year: i32, month: u32) -> Vec<[u8; 48]> {
|
||||
let epoch_ms = unix_ms(2024, 1, 1, 0, 0);
|
||||
let mut records = Vec::new();
|
||||
for day in 1..=days_in_month(year, month) {
|
||||
if !(1..=5).contains(&weekday(year, month, day)) {
|
||||
continue; // weekend
|
||||
}
|
||||
for hh in 8..16 {
|
||||
for mm in 0..60 {
|
||||
let ms = unix_ms(year, month, day, hh, mm);
|
||||
let t = (ms - epoch_ms) as f64 / 60_000.0;
|
||||
let (open, close) = (price_at(t), price_at(t + 1.0));
|
||||
let high = open.max(close) + 0.2;
|
||||
let low = open.min(close) - 0.2;
|
||||
records.push(pack_record(ms, open, high, low, close));
|
||||
}
|
||||
}
|
||||
}
|
||||
records
|
||||
}
|
||||
|
||||
/// Writes one `SYMBOL_YYYY_MM.m1` zip (a single `<SYMBOL>.bin` entry of
|
||||
/// packed records — mirrors `data-server`'s own `create_test_m1_zip` test
|
||||
/// helper) under `data_dir`.
|
||||
fn write_month_zip(data_dir: &Path, symbol: &str, year: i32, month: u32) {
|
||||
let path = data_dir.join(format!("{symbol}_{year:04}_{month:02}.m1"));
|
||||
let file = std::fs::File::create(&path).expect("create synthetic m1 zip");
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
zip.start_file::<String, ()>(format!("{symbol}.bin"), Default::default())
|
||||
.expect("start synthetic m1 zip entry");
|
||||
for rec in month_records(year, month) {
|
||||
zip.write_all(&rec).expect("write synthetic m1 record");
|
||||
}
|
||||
zip.finish().expect("finish synthetic m1 zip");
|
||||
}
|
||||
|
||||
/// Writes every month in `[from, to]` (inclusive, `(year, month)` pairs)
|
||||
/// for `symbol`, plus its `<SYMBOL>.meta.json` geometry sidecar —
|
||||
/// GER40-shaped (digits 1, pip/tick/lot sized like an index CFD) so the
|
||||
/// R/stop machinery never hits "no recorded geometry".
|
||||
pub fn write_symbol_archive(data_dir: &Path, symbol: &str, from: (i32, u32), to: (i32, u32)) {
|
||||
let (mut y, mut m) = from;
|
||||
loop {
|
||||
write_month_zip(data_dir, symbol, y, m);
|
||||
if (y, m) == to {
|
||||
break;
|
||||
}
|
||||
m += 1;
|
||||
if m > 12 {
|
||||
m = 1;
|
||||
y += 1;
|
||||
}
|
||||
}
|
||||
std::fs::write(
|
||||
data_dir.join(format!("{symbol}.meta.json")),
|
||||
format!(
|
||||
"{{\"schemaVersion\":2,\"symbol\":\"{symbol}\",\"digits\":1,\"pipSize\":1,\
|
||||
\"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"{symbol}\",\"quoteAsset\":\"EUR\"}}"
|
||||
),
|
||||
)
|
||||
.expect("write synthetic geometry sidecar");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ impl Identity {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "one-input f64 pass-through",
|
||||
},
|
||||
|_| Box::new(Identity::new()),
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ impl Identity {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "one-input f64 pass-through",
|
||||
},
|
||||
|_| Box::new(Identity::new()),
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_breakout_signal","doc":"rolling-extreme breakout latched into a long/short signal","nodes":[{"primitive":{"type":"Delay","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":0,"from_field":0},{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":1,"from_field":0},{"from":2,"to":4,"slot":0,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":5,"slot":1,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":3,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":6,"to":7,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":3,"slot":0},{"node":4,"slot":1}],"source":"F64"}],"output":[{"node":7,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":1,"pos":0,"name":"length"},{"node":2,"pos":0,"name":"length"}]}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi"}},{"primitive":{"type":"RollingMin","name":"channel_lo"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":3,"slot":0,"from_field":0},{"from":2,"to":4,"slot":1,"from_field":0},{"from":3,"to":5,"slot":0,"from_field":0},{"from":4,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":5,"to":7,"slot":0,"from_field":0},{"from":4,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":0,"from_field":0},{"from":7,"to":8,"slot":1,"from_field":0}],"input_roles":[{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},{"name":"low","targets":[{"node":1,"slot":0}],"source":"F64"},{"name":"close","targets":[{"node":4,"slot":0},{"node":5,"slot":1}],"source":"F64"}],"output":[{"node":8,"field":0,"name":"bias"}],"gangs":[{"name":"channel_length","kind":"I64","members":[{"node":2,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Mul"}},{"primitive":{"type":"EMA","name":"var_window"}},{"primitive":{"type":"Sqrt"}},{"primitive":{"type":"Scale","name":"band"}},{"primitive":{"type":"Add"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Gt"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Latch"}},{"primitive":{"type":"Sub"}}],"edges":[{"from":0,"to":1,"slot":1,"from_field":0},{"from":1,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0},{"from":4,"to":5,"slot":0,"from_field":0},{"from":0,"to":6,"slot":0,"from_field":0},{"from":5,"to":6,"slot":1,"from_field":0},{"from":0,"to":7,"slot":0,"from_field":0},{"from":5,"to":7,"slot":1,"from_field":0},{"from":6,"to":8,"slot":1,"from_field":0},{"from":7,"to":9,"slot":0,"from_field":0},{"from":8,"to":10,"slot":0,"from_field":0},{"from":9,"to":10,"slot":1,"from_field":0},{"from":9,"to":11,"slot":0,"from_field":0},{"from":8,"to":11,"slot":1,"from_field":0},{"from":11,"to":12,"slot":0,"from_field":0},{"from":10,"to":12,"slot":1,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0},{"node":8,"slot":0},{"node":9,"slot":1}],"source":"F64"}],"output":[{"node":12,"field":0,"name":"bias"}],"gangs":[{"name":"window","kind":"I64","members":[{"node":0,"pos":0,"name":"length"},{"node":3,"pos":0,"name":"length"}]}]}}
|
||||
@@ -0,0 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast"}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
/runs
|
||||
@@ -0,0 +1 @@
|
||||
# restated fixture — static project context only (C17); paths only (cycle 0102).
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "restated-project"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../../../../aura-core" }
|
||||
|
||||
# Standalone workspace root: never a member of the engine workspace
|
||||
# (docs/project-layout.md, the empty-[workspace] note).
|
||||
[workspace]
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
|
||||
//! pass-through node under the `restated` namespace whose `NodeSchema` doc
|
||||
//! merely restates its own type id (`DocGateFault::RestatesName`, the fault
|
||||
//! arm `undescribed-project` does not exercise). The charter is satisfied
|
||||
//! (prefixed id, list/resolver in sync) so the refusal under test is exactly
|
||||
//! the doc gate's name-restatement branch.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// One-input f64 pass-through whose doc is a no-content restatement of its
|
||||
/// own name.
|
||||
pub struct Echo {
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Echo {
|
||||
pub fn new() -> Self {
|
||||
Self { out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"restated::Echo",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec {
|
||||
kind: ScalarKind::F64,
|
||||
firing: Firing::Any,
|
||||
name: "value".into(),
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
// The one deliberate violation this fixture exists for: this
|
||||
// norm-equals the type id "restated::Echo" once punctuation
|
||||
// and case are stripped, so it carries no meaning beyond the
|
||||
// name itself.
|
||||
doc: "Restated Echo",
|
||||
},
|
||||
|_| Box::new(Echo::new()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Echo {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Echo {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let w = ctx.f64_in(0);
|
||||
if w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.out[0] = Cell::from_f64(w[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
"restated::Echo".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
|
||||
match type_id {
|
||||
"restated::Echo" => Some(Echo::builder()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn type_ids() -> &'static [&'static str] {
|
||||
&["restated::Echo"]
|
||||
}
|
||||
|
||||
aura_core::aura_project! {
|
||||
namespace: "restated",
|
||||
vocabulary: vocabulary,
|
||||
type_ids: type_ids,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
/runs
|
||||
@@ -0,0 +1 @@
|
||||
# undescribed fixture — static project context only (C17); paths only (cycle 0102).
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "undescribed-project"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../../../../aura-core" }
|
||||
|
||||
# Standalone workspace root: never a member of the engine workspace
|
||||
# (docs/project-layout.md, the empty-[workspace] note).
|
||||
[workspace]
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Fixture project for the C29 load-seam e2e (#316): one fully resolvable
|
||||
//! pass-through node under the `und` namespace whose `NodeSchema` carries the
|
||||
//! empty-string alibi `doc: ""` — compile-legal (the field is present), but
|
||||
//! the load gate must refuse it. The charter is satisfied (prefixed id,
|
||||
//! list/resolver in sync) so the refusal under test is exactly the doc gate.
|
||||
|
||||
use aura_core::{
|
||||
Cell, Ctx, FieldSpec, Firing, Node, NodeSchema, PortSpec, PrimitiveBuilder,
|
||||
ScalarKind,
|
||||
};
|
||||
|
||||
/// One-input f64 pass-through with a deliberately empty doc.
|
||||
pub struct Opaque {
|
||||
out: [Cell; 1],
|
||||
}
|
||||
|
||||
impl Opaque {
|
||||
pub fn new() -> Self {
|
||||
Self { out: [Cell::from_f64(0.0)] }
|
||||
}
|
||||
pub fn builder() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::new(
|
||||
"und::Opaque",
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec {
|
||||
kind: ScalarKind::F64,
|
||||
firing: Firing::Any,
|
||||
name: "value".into(),
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
// The one deliberate violation this fixture exists for.
|
||||
doc: "",
|
||||
},
|
||||
|_| Box::new(Opaque::new()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Opaque {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl Node for Opaque {
|
||||
fn lookbacks(&self) -> Vec<usize> {
|
||||
vec![1]
|
||||
}
|
||||
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Cell]> {
|
||||
let w = ctx.f64_in(0);
|
||||
if w.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.out[0] = Cell::from_f64(w[0]);
|
||||
Some(&self.out)
|
||||
}
|
||||
fn label(&self) -> String {
|
||||
"und::Opaque".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn vocabulary(type_id: &str) -> Option<PrimitiveBuilder> {
|
||||
match type_id {
|
||||
"und::Opaque" => Some(Opaque::builder()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn type_ids() -> &'static [&'static str] {
|
||||
&["und::Opaque"]
|
||||
}
|
||||
|
||||
aura_core::aura_project! {
|
||||
namespace: "und",
|
||||
vocabulary: vocabulary,
|
||||
type_ids: type_ids,
|
||||
}
|
||||
@@ -54,6 +54,40 @@ const SIGNAL_DOC: &str = r#"[
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// SIGNAL_DOC with the C29 doc op — the register-reaching variant (a store
|
||||
/// write requires a described composite; build-only tests keep SIGNAL_DOC).
|
||||
const SIGNAL_DOC_DESCRIBED: &str = r#"[
|
||||
{"op":"doc","text":"fast/slow SMA difference clamped into a directional bias"},
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// SIGNAL_DOC with a `doc` op whose text merely restates the op-script
|
||||
/// composite's name ("graph", the literal `replay("graph", ..)` gives every
|
||||
/// CLI-built op-script) -- the RestatesName arm of the C29 shape gate,
|
||||
/// reached via the op-script route rather than a builder-authored blueprint.
|
||||
const SIGNAL_DOC_NAME_RESTATED: &str = r#"[
|
||||
{"op":"doc","text":"Graph"},
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
|
||||
/// Every op below applies cleanly (all eager checks pass), but `sub.rhs` is
|
||||
/// never wired — a 0-cover input slot only the holistic finalize gate can
|
||||
/// reject. The smallest document that is op-valid yet whole-document-invalid.
|
||||
@@ -74,6 +108,21 @@ fn graph_build_emits_blueprint_for_a_valid_document() {
|
||||
assert!(stdout.contains("\"SMA\""), "carries the nodes: {stdout}");
|
||||
}
|
||||
|
||||
/// C29 (#316): the op-script `doc` op's text lands verbatim in the built
|
||||
/// blueprint's `doc` field -- `graph build` is a pure translation from ops to
|
||||
/// the canonical envelope, so a doc op is not consumed or reworded on the way
|
||||
/// through, only threaded (the shape gate is a separate, register-time check;
|
||||
/// see `graph_register_refuses_an_op_script_doc_that_restates_the_composite_name`).
|
||||
#[test]
|
||||
fn graph_build_emits_the_op_script_doc_verbatim_into_the_blueprint() {
|
||||
let (stdout, _stderr, ok) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
|
||||
assert!(ok, "exit success");
|
||||
assert!(
|
||||
stdout.contains(r#""doc":"fast/slow SMA difference clamped into a directional bias""#),
|
||||
"the doc op's exact text appears in the blueprint's doc field: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The canonical blueprint artifact carries no trailing newline: `aura graph build`
|
||||
/// emits exactly the library `blueprint_to_json` bytes (the form #158 content-addresses),
|
||||
/// not a display-framed line. Byte-canonical across the CLI and library surfaces (#164).
|
||||
@@ -121,8 +170,8 @@ fn graph_introspect_vocabulary_lists_exactly_the_closed_roster_count() {
|
||||
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
28,
|
||||
"the std-only (no project) vocabulary has exactly the roster's 28 entries: {stdout}"
|
||||
33,
|
||||
"the std-only (no project) vocabulary has exactly the roster's 33 entries: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -382,7 +431,7 @@ fn graph_introspect_no_flag_is_usage_exit_2() {
|
||||
/// A fresh, unique working directory for a test that persists content-addressed
|
||||
/// blueprints under `./runs/` (mirrors `research_docs.rs`'s `temp_cwd`).
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!("aura-graph-{}-{}", std::process::id(), name));
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-graph-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
@@ -462,6 +511,47 @@ fn graph_register_is_idempotent() {
|
||||
assert_eq!(registered_id(&out1), registered_id(&out2), "same id twice");
|
||||
}
|
||||
|
||||
/// C29 register seam end-to-end (#316): a doc-less root composite refuses at
|
||||
/// `aura graph register` naming the composite and the rule (exit 1, C14); a
|
||||
/// doc-less named nested composite refuses naming the nested name; the
|
||||
/// described shipped example registers. The gate lives at the store write
|
||||
/// every register path shares — this pins its verb surface.
|
||||
#[test]
|
||||
fn register_refuses_undescribed_composites_end_to_end() {
|
||||
let dir = temp_cwd("register-doc-gate");
|
||||
// doc-less root: the shipped example minus its doc member
|
||||
let text = std::fs::read_to_string(example("r_sma.json")).expect("read example");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&text).expect("example parses");
|
||||
v["blueprint"].as_object_mut().expect("blueprint object").remove("doc");
|
||||
let docless = dir.join("docless.json");
|
||||
std::fs::write(&docless, serde_json::to_string(&v).unwrap()).expect("write fixture");
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", docless.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "doc-less root refuses: {out} {err}");
|
||||
assert!(
|
||||
err.contains("composite `sma_signal` carries no doc"),
|
||||
"stderr names the composite and the rule: {err}"
|
||||
);
|
||||
assert!(err.contains("C29"), "stderr cites the contract: {err}");
|
||||
// doc-less named nested composite inside a described root
|
||||
v["blueprint"]["doc"] = serde_json::Value::String("a described root".into());
|
||||
v["blueprint"]["nodes"]
|
||||
.as_array_mut()
|
||||
.expect("nodes array")
|
||||
.push(serde_json::json!({"composite": {"name": "inner", "nodes": []}}));
|
||||
let nested = dir.join("nested.json");
|
||||
std::fs::write(&nested, serde_json::to_string(&v).unwrap()).expect("write fixture");
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", nested.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "doc-less nested refuses: {out} {err}");
|
||||
assert!(
|
||||
err.contains("composite `inner` carries no doc"),
|
||||
"stderr names the nested composite: {err}"
|
||||
);
|
||||
// the described shipped example registers
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", &example("r_sma.json")]);
|
||||
assert_eq!(code, Some(0), "described example registers: {out} {err}");
|
||||
assert!(out.starts_with("registered blueprint "), "prints the register line: {out}");
|
||||
}
|
||||
|
||||
/// Property (#196, the campaign-axis namespace): `--params <FILE>` prints the
|
||||
/// RAW composite param space — one `{name}:{kind:?}` line per open param, in
|
||||
/// lowering order, WITHOUT the harness-wrap prefix `aura sweep --list-axes`
|
||||
@@ -471,7 +561,7 @@ fn graph_register_is_idempotent() {
|
||||
fn graph_params_lists_raw_axis_namespace() {
|
||||
let dir = temp_cwd("params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]);
|
||||
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(stdout, "fast.length:I64\nslow.length:I64\n", "the raw open params, in order");
|
||||
}
|
||||
@@ -512,7 +602,7 @@ fn graph_introspect_mode_guard_still_exits_two_on_zero_or_two_modes() {
|
||||
#[test]
|
||||
fn graph_params_by_content_id_matches_params_by_file() {
|
||||
let dir = temp_cwd("params-by-id");
|
||||
let bp = example("r_sma_open.json");
|
||||
let bp = fixture("r_sma_open.json");
|
||||
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
|
||||
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
|
||||
let id = registered_id(®_out);
|
||||
@@ -533,7 +623,7 @@ fn graph_params_by_content_id_matches_params_by_file() {
|
||||
#[test]
|
||||
fn graph_params_tolerates_content_prefix_on_target() {
|
||||
let dir = temp_cwd("params-content-prefix");
|
||||
let bp = example("r_sma_open.json");
|
||||
let bp = fixture("r_sma_open.json");
|
||||
let (reg_out, reg_err, reg_code) = run_in(&dir, &["graph", "register", &bp]);
|
||||
assert_eq!(reg_code, Some(0), "register: {reg_out} {reg_err}");
|
||||
let id = registered_id(®_out);
|
||||
@@ -647,11 +737,12 @@ fn graph_register_rejects_an_unknown_node_type_with_prose() {
|
||||
#[test]
|
||||
fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
|
||||
let dir = temp_cwd("register-op-script");
|
||||
// The op-script form (a JSON array of ops), written to a file.
|
||||
// The op-script form (a JSON array of ops), written to a file. Register
|
||||
// requires a described composite (C29), so the described twin is used here.
|
||||
let op_script = dir.join("op-script.json");
|
||||
std::fs::write(&op_script, SIGNAL_DOC).expect("write op-script fixture");
|
||||
std::fs::write(&op_script, SIGNAL_DOC_DESCRIBED).expect("write op-script fixture");
|
||||
// Its `graph build` envelope (a JSON object), the shape `register` already accepts.
|
||||
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC);
|
||||
let (envelope_bytes, _e, built) = run(&["graph", "build"], SIGNAL_DOC_DESCRIBED);
|
||||
assert!(built, "graph build produces the envelope");
|
||||
let envelope = dir.join("envelope.json");
|
||||
std::fs::write(&envelope, &envelope_bytes).expect("write envelope fixture");
|
||||
@@ -669,6 +760,33 @@ fn graph_register_accepts_an_op_script_and_stores_the_envelope_content_id() {
|
||||
registered_id(&env_out),
|
||||
"op-script and its built envelope register to the same content id"
|
||||
);
|
||||
|
||||
// C29: the doc-less op-script form refuses at register like any other
|
||||
// doc-less composite entering the store.
|
||||
let docless_script = dir.join("docless-script.json");
|
||||
std::fs::write(&docless_script, SIGNAL_DOC).expect("write op-script fixture");
|
||||
let (dl_out, dl_err, dl_code) =
|
||||
run_in(&dir, &["graph", "register", docless_script.to_str().unwrap()]);
|
||||
assert_eq!(dl_code, Some(1), "doc-less op-script refuses: {dl_out} {dl_err}");
|
||||
assert!(dl_err.contains("carries no doc"), "stderr names the rule: {dl_err}");
|
||||
assert!(dl_err.contains("C29"), "stderr cites the contract: {dl_err}");
|
||||
}
|
||||
|
||||
/// C29: an op-script's `doc` op is not a bare presence checkbox -- its text
|
||||
/// goes through the same shape gate a builder-authored doc does. A doc that
|
||||
/// merely restates the composite's name (here "graph", every CLI op-script's
|
||||
/// literal name) refuses at register exactly like the builder-authored
|
||||
/// RestatesName case already pinned at the registry-unit layer, but reached
|
||||
/// end-to-end through the op-script CLI seam this time.
|
||||
#[test]
|
||||
fn graph_register_refuses_an_op_script_doc_that_restates_the_composite_name() {
|
||||
let dir = temp_cwd("register-op-script-restated-doc");
|
||||
let restated_script = dir.join("restated-doc-script.json");
|
||||
std::fs::write(&restated_script, SIGNAL_DOC_NAME_RESTATED).expect("write op-script fixture");
|
||||
let (out, err, code) = run_in(&dir, &["graph", "register", restated_script.to_str().unwrap()]);
|
||||
assert_eq!(code, Some(1), "name-restating doc refuses: {out} {err}");
|
||||
assert!(err.contains("merely restates"), "stderr names the rule: {err}");
|
||||
assert!(err.contains("C29"), "stderr cites the contract: {err}");
|
||||
}
|
||||
|
||||
/// Property (#202, the same on-ramp seam at the sibling verb): `aura graph
|
||||
@@ -744,7 +862,7 @@ fn shipped_r_sma_example_is_genuinely_closed() {
|
||||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||||
}
|
||||
|
||||
/// Property (#159): the shipped open example (`examples/r_sma_open.json`) is a
|
||||
/// Property (#159): the open fixture (`tests/fixtures/r_sma_open.json`) is a
|
||||
/// usable sweep-axis template from its public gallery location, not just a
|
||||
/// byte-identical artifact — `graph introspect --params` lists exactly the two
|
||||
/// unbound SMA lengths, in lowering order, matching the raw axis namespace the
|
||||
@@ -753,10 +871,10 @@ fn shipped_r_sma_example_is_genuinely_closed() {
|
||||
/// shipped copy is actually introspectable/bindable from where a consumer runs
|
||||
/// it.
|
||||
#[test]
|
||||
fn shipped_r_sma_open_example_lists_its_axis_namespace() {
|
||||
fn open_r_sma_fixture_lists_its_axis_namespace() {
|
||||
let dir = temp_cwd("example-open-params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_sma_open.json")]);
|
||||
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_sma_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout, "fast.length:I64\nslow.length:I64\n",
|
||||
@@ -764,6 +882,31 @@ fn shipped_r_sma_open_example_lists_its_axis_namespace() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#248): the four `_open` blueprint twins were RELOCATED out of the
|
||||
/// public gallery, not deleted — each stem is present under `tests/fixtures/`
|
||||
/// (the internal sweep-axis fixture location) and absent from `examples/` (the
|
||||
/// public, user-facing gallery). This is the acceptance criterion's core claim
|
||||
/// ("`examples/` ships no `_open` variants") stated as a filesystem fact, so a
|
||||
/// future example addition that reintroduces a twin — or a migration that moves
|
||||
/// the reference but forgets the file — fails here even though every
|
||||
/// `include_str!`/path-string call site already resolves.
|
||||
#[test]
|
||||
fn open_twins_are_relocated_from_examples_to_fixtures_not_deleted() {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
for stem in ["r_sma", "r_breakout", "r_meanrev", "r_channel"] {
|
||||
let gallery_path = format!("{manifest_dir}/examples/{stem}_open.json");
|
||||
assert!(
|
||||
!std::path::Path::new(&gallery_path).exists(),
|
||||
"{stem}_open.json must not ship in the public examples/ gallery (#248)"
|
||||
);
|
||||
let fixture_path = format!("{manifest_dir}/tests/fixtures/{stem}_open.json");
|
||||
assert!(
|
||||
std::path::Path::new(&fixture_path).exists(),
|
||||
"{stem}_open.json must survive relocated under tests/fixtures/ (#248)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Property (#159 cut 2): the shipped closed example (`examples/r_breakout.json`) is
|
||||
/// genuinely closed — `graph introspect --params` reports zero unbound params.
|
||||
#[test]
|
||||
@@ -775,14 +918,14 @@ fn shipped_r_breakout_example_is_genuinely_closed() {
|
||||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||||
}
|
||||
|
||||
/// Property (#159 cut 2): the shipped open example (`examples/r_breakout_open.json`)
|
||||
/// Property (#159 cut 2): the open fixture (`tests/fixtures/r_breakout_open.json`)
|
||||
/// lists its ONE ganged channel axis (#61: the two rolling windows are structurally
|
||||
/// one knob).
|
||||
#[test]
|
||||
fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
|
||||
fn open_r_breakout_fixture_lists_its_axis_namespace() {
|
||||
let dir = temp_cwd("r-breakout-example-open-params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_breakout_open.json")]);
|
||||
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_breakout_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout, "channel_length:I64\n",
|
||||
@@ -805,7 +948,7 @@ fn shipped_r_breakout_open_example_lists_its_axis_namespace() {
|
||||
/// member) would be caught here even if the internal builder-level
|
||||
/// equivalence still holds.
|
||||
#[test]
|
||||
fn shipped_r_breakout_open_gang_axis_matches_the_closed_example() {
|
||||
fn open_r_breakout_fixture_gang_axis_matches_the_closed_example() {
|
||||
let closed_dir = temp_cwd("r-breakout-gang-axis-closed");
|
||||
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_breakout.json")]);
|
||||
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
|
||||
@@ -815,7 +958,7 @@ fn shipped_r_breakout_open_gang_axis_matches_the_closed_example() {
|
||||
let sweep_dir = temp_cwd("r-breakout-gang-axis-sweep");
|
||||
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
|
||||
&sweep_dir,
|
||||
&["sweep", &example("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
|
||||
&["sweep", &fixture("r_breakout_open.json"), "--axis", "r_breakout_signal.channel_length=3"],
|
||||
);
|
||||
assert_eq!(sweep_code, Some(0), "sweep stderr: {sweep_stderr}");
|
||||
let lines: Vec<&str> = sweep_stdout.lines().collect();
|
||||
@@ -840,13 +983,13 @@ fn shipped_r_meanrev_example_is_genuinely_closed() {
|
||||
assert_eq!(stdout, "", "the closed example must leave zero params unbound");
|
||||
}
|
||||
|
||||
/// Property (#159 cut 3): the shipped open example (`examples/r_meanrev_open.json`)
|
||||
/// Property (#159 cut 3): the open fixture (`tests/fixtures/r_meanrev_open.json`)
|
||||
/// lists its raw axis namespace, in lowering (node-add) order.
|
||||
#[test]
|
||||
fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
|
||||
fn open_r_meanrev_fixture_lists_its_axis_namespace() {
|
||||
let dir = temp_cwd("r-meanrev-example-open-params");
|
||||
let (stdout, stderr, code) =
|
||||
run_in(&dir, &["graph", "introspect", "--params", &example("r_meanrev_open.json")]);
|
||||
run_in(&dir, &["graph", "introspect", "--params", &fixture("r_meanrev_open.json")]);
|
||||
assert_eq!(code, Some(0), "stdout: {stdout} stderr: {stderr}");
|
||||
assert_eq!(
|
||||
stdout, "window:I64\nband.factor:F64\n",
|
||||
@@ -863,7 +1006,7 @@ fn shipped_r_meanrev_open_example_lists_its_axis_namespace() {
|
||||
/// param positions could silently mis-bind the co-present un-ganged axis
|
||||
/// instead (or vice-versa) even though each axis alone still resolves.
|
||||
#[test]
|
||||
fn shipped_r_meanrev_open_gang_plus_plain_axis_matches_the_closed_example() {
|
||||
fn open_r_meanrev_fixture_gang_plus_plain_axis_matches_the_closed_example() {
|
||||
let closed_dir = temp_cwd("r-meanrev-gang-axis-closed");
|
||||
let (closed_stdout, closed_stderr, closed_code) = run_in(&closed_dir, &["run", &example("r_meanrev.json")]);
|
||||
assert_eq!(closed_code, Some(0), "closed run stderr: {closed_stderr}");
|
||||
@@ -874,7 +1017,7 @@ fn shipped_r_meanrev_open_gang_plus_plain_axis_matches_the_closed_example() {
|
||||
let (sweep_stdout, sweep_stderr, sweep_code) = run_in(
|
||||
&sweep_dir,
|
||||
&[
|
||||
"sweep", &example("r_meanrev_open.json"),
|
||||
"sweep", &fixture("r_meanrev_open.json"),
|
||||
"--axis", "r_meanrev_signal.window=3",
|
||||
"--axis", "r_meanrev_signal.band.factor=2.0",
|
||||
],
|
||||
@@ -1132,6 +1275,26 @@ fn graph_build_reports_gang_kind_mismatch_as_prose() {
|
||||
assert!(!stderr.contains("GangKindMismatch"), "does not leak the Debug variant name: {stderr}");
|
||||
}
|
||||
|
||||
/// The `doc` op's duplicate refusal (a second meaning line in one op-script)
|
||||
/// reads as prose at the binary seam, attributed to the second `doc` op's
|
||||
/// index -- never the raw `DuplicateDoc` Debug variant name.
|
||||
#[test]
|
||||
fn graph_build_reports_duplicate_doc_fault_as_prose() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"doc","text":"first"},
|
||||
{"op":"doc","text":"second"}
|
||||
]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], doc);
|
||||
assert!(!ok, "non-zero exit on a duplicate doc fault");
|
||||
assert!(stdout.is_empty(), "no blueprint emitted on a fault: {stdout}");
|
||||
assert!(
|
||||
stderr.contains("op 2 (doc): a doc op may appear at most once"),
|
||||
"names the op + cause as prose: {stderr}"
|
||||
);
|
||||
assert!(!stderr.contains("DuplicateDoc"), "does not leak the Debug variant name: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (corrupt-gang-blueprint, the `gang_fault_prose` seam): `aura graph
|
||||
/// register` on a hand-corrupted blueprint whose gangs section fails structural
|
||||
/// validation (a single-member gang) refuses with the `blueprint_load_prose` ->
|
||||
@@ -1269,3 +1432,107 @@ fn graph_build_hints_attach_for_namespaced_ids_in_a_data_only_project() {
|
||||
"the hint points at the attach verb: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#284): the `tap` op-script op, driven through the real `aura
|
||||
/// graph build` process (not an in-crate call to `build_from_str`), resolves
|
||||
/// a name-addressed interior wire (`"fast.value"`, session node 0 / output
|
||||
/// field 0) into the exact same `{node, field}` shape a raw-index-authored
|
||||
/// blueprint would carry — the emitted blueprint's `taps` array names the
|
||||
/// wire by resolved index. This is the process-boundary counterpart of
|
||||
/// `construction.rs`'s `tap_op_resolves_and_appends` (in-crate call): it pins
|
||||
/// that the CLI's serde front end (`OpDoc::Tap` → `Op::Tap`) actually reaches
|
||||
/// the engine and back out to stdout across a real subprocess, the surface a
|
||||
/// data-level author drives.
|
||||
#[test]
|
||||
fn graph_build_emits_a_tap_op_declared_wire() {
|
||||
let ops = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"tap","from":"fast.value","as":"fast_ma"},
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
]"#;
|
||||
let (stdout, stderr, ok) = run(&["graph", "build"], ops);
|
||||
assert!(ok, "graph build: stderr: {stderr}");
|
||||
assert!(
|
||||
stdout.contains(r#""taps":[{"name":"fast_ma","from":{"node":0,"field":0}}]"#),
|
||||
"the tap resolves to node 0 (fast), field 0 (value): {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#284): a blueprint authored through the name-addressed `tap` op
|
||||
/// — built by the real `aura graph build` process, never hand-edited by raw
|
||||
/// node index — is not merely well-formed JSON: `aura run` actually binds and
|
||||
/// persists its declared tap, exactly as a raw-index-authored tap does
|
||||
/// (`tap_recording.rs`'s coverage of the underlying bind/persist mechanism).
|
||||
/// This is the whole point of #284 (removing the raw-index miscount class):
|
||||
/// the op-authored artifact must round-trip through the full build -> run ->
|
||||
/// persisted-trace pipeline, not just parse. Taps `"slow.value"` (SMA(4),
|
||||
/// deliberately not the node-0 `"fast"` port the sibling build-only test
|
||||
/// checks) so this test cannot pass by accident on the wrong node.
|
||||
#[test]
|
||||
fn tap_authored_via_op_script_runs_and_persists_the_series() {
|
||||
const PRICES: [f64; 18] = [
|
||||
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
|
||||
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
||||
];
|
||||
let ops = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"add","type":"Bias","name":"bias","bind":{"scale":{"F64":0.5}}},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"tap","from":"slow.value","as":"slow_ma"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
let (blueprint_json, stderr, ok) = run(&["graph", "build"], ops);
|
||||
assert!(ok, "graph build: stderr: {stderr}");
|
||||
assert!(blueprint_json.contains("\"taps\""), "blueprint carries the tap: {blueprint_json}");
|
||||
|
||||
let dir = temp_cwd("tap-op-run-roundtrip");
|
||||
let bp_path = dir.join("tapped.json");
|
||||
std::fs::write(&bp_path, &blueprint_json).expect("write built blueprint");
|
||||
|
||||
let (_stdout, stderr2, code2) = run_in(&dir, &["run", bp_path.to_str().unwrap()]);
|
||||
assert_eq!(code2, Some(0), "aura run over the op-authored tap: {stderr2}");
|
||||
|
||||
// `aura graph build` always names the root composite "graph" (`composite_from_str`),
|
||||
// so the trace store's run-name directory is fixed.
|
||||
let trace_path = dir.join("runs/traces/graph/slow_ma.json");
|
||||
let trace_text = std::fs::read_to_string(&trace_path)
|
||||
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
||||
|
||||
assert_eq!(trace["tap"], "slow_ma");
|
||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||||
|
||||
// SMA(4), silent until warm (4 samples): first emission at the 4th sample
|
||||
// (ts=4), a rolling mean of the last four prices per subsequent cycle.
|
||||
let expected: Vec<f64> = (3..PRICES.len())
|
||||
.map(|i| PRICES[i - 3..=i].iter().sum::<f64>() / 4.0)
|
||||
.collect();
|
||||
let expected_ts: Vec<i64> = (4..=PRICES.len() as i64).collect();
|
||||
|
||||
let got_ts: Vec<i64> =
|
||||
trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
|
||||
assert_eq!(got_ts, expected_ts, "recorded timestamps");
|
||||
|
||||
let got: Vec<f64> = trace["columns"][0]
|
||||
.as_array()
|
||||
.expect("columns[0] array")
|
||||
.iter()
|
||||
.map(|v| v.as_f64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(got.len(), expected.len(), "recorded row count");
|
||||
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
|
||||
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
//! #290 / C28: `aura measure ic <run> --signal <tap> --price <tap>` reduces a
|
||||
//! measurement run's two recorded taps to an Information Coefficient + its
|
||||
//! permutation-null significance. Exercises the WIRING and well-formedness over a
|
||||
//! real causal run; the signal-vs-noise math is unit-tested (a look-ahead-engineered
|
||||
//! signal is impossible in a causal run, C2).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-measic-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// `examples/r_sma.json` turned MEASUREMENT-shaped with TWO taps (node 0 "signal",
|
||||
/// node 1 "price") and no `bias` output — same closed topology, runs on the built-in
|
||||
/// synthetic stream. (Two-tap authoring mirrors tests/tap_recording.rs.)
|
||||
fn two_tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([
|
||||
{"name": "signal", "from": {"node": 0, "field": 0}},
|
||||
{"name": "price", "from": {"node": 1, "field": 0}},
|
||||
]);
|
||||
v["blueprint"]["output"] = serde_json::json!([]);
|
||||
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
|
||||
}
|
||||
|
||||
fn run_measurement(cwd: &Path) {
|
||||
let bp = cwd.join("measurement.json");
|
||||
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp.to_str().unwrap()])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
|
||||
let mut args = vec!["measure", "ic", run, "--signal", "signal", "--price", "price"];
|
||||
args.extend_from_slice(extra);
|
||||
Command::new(BIN).args(&args).current_dir(cwd).output().expect("spawn aura measure ic")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_ic_emits_a_well_formed_report() {
|
||||
let cwd = temp_cwd("wellformed");
|
||||
run_measurement(&cwd);
|
||||
let out = measure_ic(&cwd, "sma_signal", &[]);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
||||
assert_eq!(r["run"], "sma_signal");
|
||||
assert_eq!(r["signal_tap"], "signal");
|
||||
assert_eq!(r["price_tap"], "price");
|
||||
assert_eq!(r["horizon"], 1);
|
||||
assert_eq!(r["permutations"], 1000);
|
||||
assert_eq!(r["seed"], 0);
|
||||
assert!(r["n_pairs"].as_u64().unwrap() >= 2, "expected aligned pairs, got {}", r["n_pairs"]);
|
||||
let ic = r["information_coefficient"].as_f64().unwrap();
|
||||
assert!(ic.is_finite() && (-1.0..=1.0).contains(&ic), "ic = {ic}");
|
||||
let p = r["overfit_probability"].as_f64().unwrap();
|
||||
assert!(p > 0.0 && p <= 1.0, "overfit_probability = {p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_ic_is_deterministic() {
|
||||
let cwd = temp_cwd("determinism");
|
||||
run_measurement(&cwd);
|
||||
let a = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
|
||||
let b = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
|
||||
assert!(a.status.success() && b.status.success());
|
||||
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_ic_unknown_run_errors() {
|
||||
let cwd = temp_cwd("unknownrun");
|
||||
run_measurement(&cwd);
|
||||
let out = measure_ic(&cwd, "no_such_run", &[]);
|
||||
assert!(!out.status.success(), "an unknown run must exit non-zero");
|
||||
}
|
||||
|
||||
/// `--horizon` is real plumbing from the CLI arg through
|
||||
/// `information_coefficient`'s alignment window, not merely echoed into the
|
||||
/// report: a horizon that exceeds the recorded price series collapses every
|
||||
/// signal/forward-return pair, hitting the reduction's documented degenerate
|
||||
/// floor (`n_pairs=0`, `ic=0.0`, `overfit_probability=1.0`, per main.rs's
|
||||
/// `information_coefficient` doc comment) — a floor the unit tests exercise
|
||||
/// only by calling the reduction in-memory, never through CLI parsing + a
|
||||
/// persisted-trace round trip.
|
||||
#[test]
|
||||
fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
|
||||
let cwd = temp_cwd("oversizedhorizon");
|
||||
run_measurement(&cwd);
|
||||
let out = measure_ic(&cwd, "sma_signal", &["--horizon", "100000000"]);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
||||
assert_eq!(r["horizon"], 100_000_000);
|
||||
assert_eq!(r["n_pairs"], 0, "an oversized horizon aligns no pairs");
|
||||
assert_eq!(r["information_coefficient"], 0.0, "degenerate floor: ic = 0.0");
|
||||
assert_eq!(r["overfit_probability"], 1.0, "degenerate floor: overfit_probability = 1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_ic_missing_tap_errors() {
|
||||
let cwd = temp_cwd("missingtap");
|
||||
run_measurement(&cwd);
|
||||
let out = Command::new(BIN)
|
||||
.args(["measure", "ic", "sma_signal", "--signal", "nope", "--price", "price"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn");
|
||||
assert!(!out.status.success(), "a missing tap must exit non-zero");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("nope"),
|
||||
"the error names the missing tap: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use std::process::{Command, Output};
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
mod common;
|
||||
use common::{built_project, project_lock};
|
||||
use common::built_project;
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project")
|
||||
@@ -19,6 +19,14 @@ fn badcharter_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/badcharter-project")
|
||||
}
|
||||
|
||||
fn undescribed_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/undescribed-project")
|
||||
}
|
||||
|
||||
fn restated_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/restated-project")
|
||||
}
|
||||
|
||||
fn aura(args: &[&str], cwd: &Path) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(args)
|
||||
@@ -98,17 +106,16 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
|
||||
/// rather than shell-relative (C17).
|
||||
#[test]
|
||||
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
|
||||
let _fixture = project_lock();
|
||||
let dir = built_project();
|
||||
let sub = dir.join("src");
|
||||
let runs_dir = dir.join("runs");
|
||||
std::fs::remove_dir_all(&runs_dir).ok();
|
||||
let open_bp =
|
||||
format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let closed_bp =
|
||||
format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args([
|
||||
"sweep",
|
||||
&open_bp,
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
@@ -165,6 +172,78 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
|
||||
assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}");
|
||||
}
|
||||
|
||||
/// C29 load seam end-to-end (#316): an extension node whose doc fails the
|
||||
/// shape gate (the empty-string alibi the compiler cannot catch) refuses at
|
||||
/// load with the entry and the rule named — through the real
|
||||
/// `aura-cli::project::load` path (libloading + descriptor read), not merely
|
||||
/// the pure `doc_gate` unit function. The described twin's load is pinned by
|
||||
/// `project_run_resolves_demo_node_and_is_bit_identical`.
|
||||
#[test]
|
||||
fn undescribed_vocabulary_entry_refuses_end_to_end() {
|
||||
let dir = undescribed_dir();
|
||||
let build = Command::new("cargo")
|
||||
.arg("build")
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn cargo build for the undescribed fixture");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"undescribed fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let run = aura(&["run", "x.json"], &dir);
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(1),
|
||||
"an undescribed vocabulary entry is a runtime refusal (exit 1), \
|
||||
not a usage error"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(
|
||||
err.contains("vocabulary entry `und::Opaque` has an empty doc"),
|
||||
"stderr must name the entry and the rule: {err}"
|
||||
);
|
||||
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
|
||||
}
|
||||
|
||||
/// C29 load seam end-to-end, the sibling fault arm (#316): a doc that merely
|
||||
/// restates its own type id (`DocGateFault::RestatesName`) refuses at load
|
||||
/// exactly like the empty-doc case above, through the same real
|
||||
/// `aura-cli::project::load` path. `RestatesName`'s rendered prose was
|
||||
/// previously pinned only against the pure `Display` impl in
|
||||
/// `aura-runner::project`'s unit tests (never reachable at all through the
|
||||
/// CLI) — a regression that reordered `doc_gate`'s two fault arms, or that
|
||||
/// skipped the gate call for this arm specifically, would pass every
|
||||
/// existing test and still ship a self-description hole for exactly this
|
||||
/// case: an alibi doc that types-checks but says nothing.
|
||||
#[test]
|
||||
fn restated_name_vocabulary_entry_refuses_end_to_end() {
|
||||
let dir = restated_dir();
|
||||
let build = Command::new("cargo")
|
||||
.arg("build")
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn cargo build for the restated fixture");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"restated fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let run = aura(&["run", "x.json"], &dir);
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(1),
|
||||
"a name-restating vocabulary entry is a runtime refusal (exit 1), \
|
||||
not a usage error"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(
|
||||
err.contains("vocabulary entry `restated::Echo` has a doc that merely restates its name"),
|
||||
"stderr must name the entry and the rule: {err}"
|
||||
);
|
||||
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
|
||||
}
|
||||
|
||||
/// Force a file's modification time to a fixed instant (explicit mtime, no
|
||||
/// sleeping — mtime granularity would make a "touch then compare" race).
|
||||
fn set_mtime(path: &Path, secs_since_epoch: u64) {
|
||||
@@ -369,7 +448,8 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
|
||||
/// "binds no node crate" hint (not the outside-project "no Aura.toml" text).
|
||||
#[test]
|
||||
fn data_only_project_hints_attach_for_namespaced_ids() {
|
||||
let tmp = std::env::temp_dir().join(format!("aura-dataonly-hint-{}", std::process::id()));
|
||||
let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-dataonly-hint");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
|
||||
let bp = tmp.join("bp.json");
|
||||
@@ -389,7 +469,8 @@ fn data_only_project_hints_attach_for_namespaced_ids() {
|
||||
#[test]
|
||||
fn missing_artifact_refuses_with_build_hint() {
|
||||
// A minimal never-built project: valid cargo metadata, no dylib on disk.
|
||||
let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id()));
|
||||
let tmp = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-nobuild");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("src")).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
|
||||
std::fs::write(tmp.join("src/lib.rs"), "").unwrap();
|
||||
|
||||
@@ -14,8 +14,19 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
|
||||
.expect("run aura")
|
||||
}
|
||||
|
||||
/// Fixed, tag-keyed, pid-free name (#258) — kept under `env::temp_dir()`
|
||||
/// rather than the `CARGO_TARGET_TMPDIR` build-tree anchor used by the other
|
||||
/// sandbox helpers: two of this file's tests (`new_outside_a_work_tree_*`)
|
||||
/// require a base genuinely detached from any git work tree, and
|
||||
/// `CARGO_TARGET_TMPDIR` resolves inside this very checkout's `target/`.
|
||||
/// The checkout discriminator keeps concurrently running checkouts/worktrees
|
||||
/// (which share `env::temp_dir()`) off each other's fixed names while the
|
||||
/// name stays deterministic per checkout, so each run still reclaims the last.
|
||||
fn tmp(tag: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("aura-new-{tag}-{}", std::process::id()));
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
env!("CARGO_MANIFEST_DIR").hash(&mut h);
|
||||
let d = std::env::temp_dir().join(format!("aura-new-{tag}-{:08x}", h.finish() as u32));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn temp_cwd(tag: &str) -> std::path::PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("aura-nodes-{tag}-{}", std::process::id()));
|
||||
let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-nodes-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
@@ -18,6 +18,39 @@ fn aura(args: &[&str], cwd: &Path) -> std::process::Output {
|
||||
.expect("spawn aura")
|
||||
}
|
||||
|
||||
/// Property (#258): a test sandbox must not outlive its run — `temp_cwd` must
|
||||
/// hand back a run-STABLE path that lives off the `env::temp_dir()` tmpfs, so
|
||||
/// its own pre-create `remove_dir_all` reclaims the previous run instead of
|
||||
/// stranding one `aura-nodes-*` directory per test-binary invocation.
|
||||
///
|
||||
/// The helper today keys the name on `std::process::id()`, so every run mints a
|
||||
/// new path the wipe can never match: >50 000 leaked dirs filled a 16 GiB tmpfs
|
||||
/// to 100%. The in-repo remedy is the knob-lab scaffold's (commit 84e1075, sibling
|
||||
/// `project_sweep_campaign.rs`): a fixed name under `CARGO_TARGET_TMPDIR`. This
|
||||
/// pins that property at one representative site — the assertion reads the path
|
||||
/// value only, so it is deterministic and does not depend on any external state.
|
||||
#[test]
|
||||
fn temp_cwd_sandbox_does_not_leak_under_env_temp_dir() {
|
||||
let sandbox = temp_cwd("leakguard");
|
||||
// Read the path value, then reclaim immediately so a red run of this very
|
||||
// test does not itself add to the leak it describes.
|
||||
let path = sandbox.clone();
|
||||
let _ = std::fs::remove_dir_all(&sandbox);
|
||||
|
||||
let pid = std::process::id().to_string();
|
||||
assert!(
|
||||
!path.to_string_lossy().contains(&pid),
|
||||
"sandbox name embeds the pid ({pid}), so no later run's pre-create wipe \
|
||||
can reclaim it — one directory leaks per run: {}",
|
||||
path.display(),
|
||||
);
|
||||
assert!(
|
||||
!path.starts_with(std::env::temp_dir()),
|
||||
"sandbox lives under env::temp_dir() — the tmpfs the >50k-dir leak filled: {}",
|
||||
path.display(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nodes_new_scaffolds_a_sibling_crate_and_attaches_it() {
|
||||
let cwd = temp_cwd("attach");
|
||||
@@ -178,3 +211,85 @@ fn attached_crate_namespace_resolves_after_build() {
|
||||
"{}", String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
}
|
||||
|
||||
/// E2E (#278): loading an attached node crate whose `src/` is newer than its
|
||||
/// built dylib emits exactly one `aura: warning: ` class-marked line naming
|
||||
/// the stale dylib, and still succeeds (the run proceeds with the existing
|
||||
/// dylib rather than refusing) — staleness is a continuing-run warning, never
|
||||
/// a fault. A freshly built dylib (no touched source since) emits none. Real
|
||||
/// `cargo build` + a real `libloading::Library::new` load, exercising
|
||||
/// `aura_runner::project::load_crate`'s actual call site rather than the pure
|
||||
/// `stale_warning` helper alone.
|
||||
#[test]
|
||||
fn attached_crate_load_warns_when_source_outruns_the_built_dylib() {
|
||||
let cwd = temp_cwd("stale-warn");
|
||||
assert!(aura(&["new", "lab"], &cwd).status.success());
|
||||
let proj = cwd.join("lab");
|
||||
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
||||
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
|
||||
let crate_root = cwd.join("lab-nodes");
|
||||
let build = Command::new("cargo").arg("build").current_dir(&crate_root).output().expect("cargo build");
|
||||
assert!(build.status.success(), "{}", String::from_utf8_lossy(&build.stderr));
|
||||
|
||||
// Fresh build, untouched source: no staleness warning.
|
||||
let fresh = aura(&["graph", "introspect", "--vocabulary"], &proj);
|
||||
assert!(fresh.status.success(), "stderr: {}", String::from_utf8_lossy(&fresh.stderr));
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&fresh.stderr).contains("may be stale"),
|
||||
"a freshly built dylib is not stale: {}",
|
||||
String::from_utf8_lossy(&fresh.stderr)
|
||||
);
|
||||
|
||||
// Bump `src/lib.rs`'s mtime a full minute past "now" (comfortably past the
|
||||
// dylib's just-completed build time) without rebuilding — the exact
|
||||
// "source outran the dylib, no rebuild yet" shape `stale_warning` guards.
|
||||
let lib_rs = crate_root.join("src/lib.rs");
|
||||
let f = std::fs::File::open(&lib_rs).expect("open lib.rs");
|
||||
f.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60))
|
||||
.expect("bump lib.rs mtime past the dylib's build time");
|
||||
|
||||
let stale = aura(&["graph", "introspect", "--vocabulary"], &proj);
|
||||
assert!(
|
||||
stale.status.success(),
|
||||
"staleness is a warning, not a refusal: {}",
|
||||
String::from_utf8_lossy(&stale.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&stale.stderr);
|
||||
assert_eq!(
|
||||
stderr.matches("may be stale").count(),
|
||||
1,
|
||||
"exactly one staleness warning: {stderr}"
|
||||
);
|
||||
assert!(stderr.contains("aura: warning: "), "carries the warning class marker (#278): {stderr}");
|
||||
assert!(stderr.contains("run `cargo build` to refresh"), "names the remedy: {stderr}");
|
||||
}
|
||||
|
||||
/// Property (#316 self-description): the scaffold's own seed node ships a
|
||||
/// `doc:` line that itself passes the C29 shape gate (`aura_core::doc_gate`)
|
||||
/// — non-empty and not a bare restatement of the type name. This exact text
|
||||
/// is what a new extension author copy-pastes as the very first node of
|
||||
/// their crate; if the scaffold's own doc failed the gate it would teach the
|
||||
/// wrong pattern (an alibi doc) to every project built from it, and nothing
|
||||
/// else would catch that — `NodeSchema.doc` is required at compile time
|
||||
/// (E0063 without it) but its *shape* is never checked by the compiler.
|
||||
#[test]
|
||||
fn nodes_new_scaffold_doc_passes_the_doc_gate() {
|
||||
let cwd = temp_cwd("doc-gate");
|
||||
assert!(aura(&["new", "lab"], &cwd).status.success());
|
||||
let proj = cwd.join("lab");
|
||||
let engine = env!("CARGO_MANIFEST_DIR").to_string() + "/../..";
|
||||
assert!(aura(&["nodes", "new", "lab-nodes", "--engine-path", &engine], &proj).status.success());
|
||||
let lib = std::fs::read_to_string(cwd.join("lab-nodes/src/lib.rs")).unwrap();
|
||||
let doc_line = lib
|
||||
.lines()
|
||||
.find(|l| l.trim_start().starts_with("doc:"))
|
||||
.unwrap_or_else(|| panic!("scaffold must carry a doc: field: {lib}"));
|
||||
let doc_text = doc_line
|
||||
.split_once('"')
|
||||
.and_then(|(_, rest)| rest.rsplit_once('"'))
|
||||
.map(|(text, _)| text)
|
||||
.unwrap_or_else(|| panic!("doc: field must be a string literal: {doc_line}"));
|
||||
aura_core::doc_gate("lab_nodes::Scale", doc_text).unwrap_or_else(|f| {
|
||||
panic!("scaffold's own doc line fails its own gate: {f:?} — {doc_text:?}")
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ const GER40_TO_MS: &str = "1727740799999";
|
||||
/// via `aura nodes new ... --namespace knob_lab`) supplies the `knob_lab`
|
||||
/// vocabulary namespace — `Scale` is the starter node every node-crate
|
||||
/// scaffold emits (#183).
|
||||
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
|
||||
const OPEN_BLUEPRINT: &str = r#"{"format_version":1,"blueprint":{"name":"scaled_signal","doc":"fast/slow SMA bias scaled by an extension gain node","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}},{"primitive":{"type":"knob_lab::Scale","name":"gain"}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0},{"from":3,"to":4,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":4,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
/// The minimal executable process pipeline (one sweep stage) — the campaign
|
||||
/// cell's methodology reference.
|
||||
@@ -63,11 +63,15 @@ fn aura_in(dir: &Path, args: &[&str]) -> Output {
|
||||
/// `blueprints/scaled_open.json`. Returns the project dir. `OnceLock` so the
|
||||
/// expensive scaffold+build happens once per test binary; the node crate's
|
||||
/// path-dep resolves into this checkout (the baked engine root), like
|
||||
/// `project_new.rs`.
|
||||
/// `project_new.rs`. The base is a FIXED name under `CARGO_TARGET_TMPDIR`
|
||||
/// (real disk, reclaimed by `cargo clean`), not a pid-suffixed tempdir: the
|
||||
/// wipe below must hit the PREVIOUS run's copy, else every test-binary run
|
||||
/// leaks the ~66 MB built scaffold — 159 of those filled the 16 GB /tmp
|
||||
/// tmpfs to 100 % (#257).
|
||||
fn built_scale_project() -> &'static PathBuf {
|
||||
static BUILT: OnceLock<PathBuf> = OnceLock::new();
|
||||
BUILT.get_or_init(|| {
|
||||
let base = std::env::temp_dir().join(format!("aura-235-{}", std::process::id()));
|
||||
let base = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-235-knob-lab");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
std::fs::create_dir_all(&base).expect("create scaffold base");
|
||||
|
||||
@@ -327,7 +331,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
|
||||
|
||||
// Archive-gated: skip cleanly on a data-less machine (the member-data
|
||||
// refusal, never a panic) — mirroring campaign_run_real_e2e_*.
|
||||
if run.status.code() == Some(1)
|
||||
if run.status.code() == Some(3)
|
||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||
{
|
||||
eprintln!("skip: no local GER40 data for the campaign cell");
|
||||
|
||||
+1346
-266
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,58 @@
|
||||
//! #286 / C28 phase 3: `aura run` on a measurement-shaped blueprint (declares a
|
||||
//! tap, exposes no `bias`) runs BARE — no wrap_r scaffold — emits a
|
||||
//! `MeasurementReport` and persists the tapped series. A taps-only blueprint is
|
||||
//! refused before this feature; this pins the new additive behaviour.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-meas-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json`, turned MEASUREMENT-shaped: one declared tap
|
||||
/// on node 0 ("fast", SMA(2)) field 0, and its `bias` output removed. Same closed
|
||||
/// topology (`sma_signal`), so it runs on the built-in synthetic stream.
|
||||
fn measurement_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
|
||||
v["blueprint"]["output"] = serde_json::json!([]); // no `bias` — a measurement, not a strategy
|
||||
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measurement_blueprint_runs_bare_and_emits_its_tap() {
|
||||
let cwd = temp_cwd("runs-bare");
|
||||
let bp_path = cwd.join("measurement.json");
|
||||
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// stdout is a MeasurementReport: the declared tap name, no R `metrics`, the
|
||||
// measurement broker sentinel.
|
||||
let report: serde_json::Value =
|
||||
serde_json::from_slice(&out.stdout).expect("stdout parses as MeasurementReport JSON");
|
||||
assert_eq!(report["taps"], serde_json::json!(["fast_tap"]));
|
||||
assert!(report.get("metrics").is_none(), "a measurement run has no R metrics");
|
||||
assert_eq!(report["manifest"]["broker"], "measurement");
|
||||
|
||||
// The tapped series is persisted through the trace store.
|
||||
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
|
||||
let trace_text = std::fs::read_to_string(&trace_path)
|
||||
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
//! `aura run <blueprint.json>` must REFUSE a user-authorable blueprint it
|
||||
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the
|
||||
//! established binding-refusal register — never a process panic. Both cases
|
||||
//! are user mistakes on the hand-authored single-run path, so each deserves a
|
||||
//! diagnosis, not a stack trace.
|
||||
//!
|
||||
//! Two independent unrunnable shapes, each pinned as its own minimal,
|
||||
//! self-contained blueprint (inlined here — no shared fixture file):
|
||||
//! 1. a declared tap whose `from` wire references an out-of-range node
|
||||
//! index (taps are authored by raw node index, so a miscount is the
|
||||
//! expected mistake) — compiles to `CompileError::TapWireOutOfRange`;
|
||||
//! 2. an exposed output that is not a `bias` stream (a measurement-only
|
||||
//! blueprint) — `wrap_r` cannot weld it to the R-evaluator.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the
|
||||
/// test crate; the binary is named `aura` in `Cargo.toml`).
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// A fresh, unique working directory per test (so nothing a run might emit
|
||||
/// dirties the repo). Unique per test + per process.
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-refuse-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// Run `aura run <path>` in an isolated cwd and assert the CLEAN-REFUSAL
|
||||
/// contract: exit code 1, an `aura:`-prefixed stderr message, and NO panic —
|
||||
/// the property both cases share. `what` names the offending shape for the
|
||||
/// failure message.
|
||||
fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
|
||||
let cwd = temp_cwd(tag);
|
||||
let bp_path = cwd.join("blueprint.json");
|
||||
std::fs::write(&bp_path, bp_json).expect("write blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
// The defining symptom of the bug: the process panics (Rust's default
|
||||
// unwind exits 101 and prints "panicked at ...") instead of refusing.
|
||||
assert!(
|
||||
!stderr.to_lowercase().contains("panic"),
|
||||
"{what}: expected a clean refusal, got a PANIC:\n{stderr}"
|
||||
);
|
||||
// The clean-refusal register: `aura: <msg>` on stderr, exit 1 — exactly
|
||||
// what the binding refusals in `run_signal_r` already emit.
|
||||
assert!(
|
||||
stderr.contains("aura:"),
|
||||
"{what}: refusal must carry the `aura:` prefix:\n{stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(1),
|
||||
"{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: a blueprint whose declared tap wire points at an out-of-range
|
||||
/// node index is refused, not crashed. Minimal trigger — a single-node closed
|
||||
/// strategy (`price → Bias → "bias"`, so the R-wrap welds cleanly) carrying
|
||||
/// one tap whose `from.node` (5) is out of range for a one-node graph. This
|
||||
/// compiles to `CompileError::TapWireOutOfRange`, which `run_signal_r`'s
|
||||
/// `compile_with_params(...).expect(...)` currently turns into a panic.
|
||||
#[test]
|
||||
fn run_refuses_a_tap_wire_pointing_out_of_range() {
|
||||
let bp = r#"{
|
||||
"format_version": 1,
|
||||
"blueprint": {
|
||||
"name": "bias_with_bad_tap",
|
||||
"nodes": [
|
||||
{"primitive": {"type": "Bias", "name": "bias",
|
||||
"bound": [{"pos": 0, "name": "scale", "kind": "F64", "value": {"F64": 1.0}}]}}
|
||||
],
|
||||
"edges": [],
|
||||
"input_roles": [{"name": "price", "targets": [{"node": 0, "slot": 0}], "source": "F64"}],
|
||||
"output": [{"node": 0, "field": 0, "name": "bias"}],
|
||||
"taps": [{"name": "bad", "from": {"node": 5, "field": 0}}]
|
||||
}
|
||||
}"#;
|
||||
assert_clean_refusal(bp, "bad-tap-wire", "out-of-range tap wire");
|
||||
}
|
||||
|
||||
/// Property: a blueprint whose single exposed output is not a `bias` stream
|
||||
/// (a measurement-only blueprint — here `price → SMA → "measurement"`) is
|
||||
/// refused, not crashed. `wrap_r` welds the nested signal by its `"bias"`
|
||||
/// output port; a non-bias output makes that weld resolve to
|
||||
/// `BuildError::UnknownOutPort`, which `wrap_r`'s `g.build().expect(...)`
|
||||
/// currently turns into a panic. (This pins the refusal only — teaching
|
||||
/// `aura run` to accept bias-less blueprints is a separate feature.)
|
||||
#[test]
|
||||
fn run_refuses_a_non_bias_output_blueprint() {
|
||||
let bp = r#"{
|
||||
"format_version": 1,
|
||||
"blueprint": {
|
||||
"name": "measurement_only",
|
||||
"nodes": [
|
||||
{"primitive": {"type": "SMA", "name": "avg",
|
||||
"bound": [{"pos": 0, "name": "length", "kind": "I64", "value": {"I64": 2}}]}}
|
||||
],
|
||||
"edges": [],
|
||||
"input_roles": [{"name": "price", "targets": [{"node": 0, "slot": 0}], "source": "F64"}],
|
||||
"output": [{"node": 0, "field": 0, "name": "measurement"}]
|
||||
}
|
||||
}"#;
|
||||
assert_clean_refusal(bp, "non-bias-output", "non-bias exposed output");
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
//! C29 coverage: every engine-shipped vocabulary entry carries a
|
||||
//! gate-passing one-line meaning. Blocks, metrics, tap slots, folds, and
|
||||
//! the std node vocabulary — the compile/unit seam of the self-description
|
||||
//! contract.
|
||||
|
||||
use aura_core::doc_gate;
|
||||
|
||||
#[test]
|
||||
fn every_shipped_vocabulary_entry_passes_the_doc_gate() {
|
||||
// process + campaign blocks (BlockSchema.doc — the pre-existing model)
|
||||
for b in aura_research::process_vocabulary()
|
||||
.iter()
|
||||
.chain(aura_research::campaign_vocabulary().iter())
|
||||
{
|
||||
doc_gate(b.id, b.doc)
|
||||
.unwrap_or_else(|f| panic!("block {}: {:?}", b.id, f));
|
||||
}
|
||||
// metrics
|
||||
for m in aura_research::metric_vocabulary() {
|
||||
doc_gate(m.id, m.doc)
|
||||
.unwrap_or_else(|f| panic!("metric {}: {:?}", m.id, f));
|
||||
}
|
||||
// tap slots
|
||||
for t in aura_research::tap_vocabulary() {
|
||||
doc_gate(t.id, t.doc)
|
||||
.unwrap_or_else(|f| panic!("tap {}: {:?}", t.id, f));
|
||||
}
|
||||
// tap folds (the #283 registry roster already carries docs)
|
||||
for (name, doc) in aura_runner::tap_plan::FoldRegistry::core().roster() {
|
||||
doc_gate(name, doc)
|
||||
.unwrap_or_else(|f| panic!("fold {}: {:?}", name, f));
|
||||
}
|
||||
// std node vocabulary: resolve each shipped type id to its schema
|
||||
let env = aura_runner::project::Env::std();
|
||||
for &type_id in aura_vocabulary::std_vocabulary_types() {
|
||||
let builder = env
|
||||
.resolve(type_id)
|
||||
.unwrap_or_else(|| panic!("std type {type_id} must resolve"));
|
||||
let schema = builder.schema();
|
||||
doc_gate(type_id, schema.doc)
|
||||
.unwrap_or_else(|f| panic!("node {type_id}: {:?}", f));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! #282: a hand-authored blueprint with a declared tap, run on the single-run
|
||||
//! path, records the tapped series; the same blueprint in a sweep does not
|
||||
//! (`run_blueprint_member`, the sweep/reduce path, never calls `bind_tap` —
|
||||
//! structurally guaranteed by Task 6 leaving it untouched, not re-asserted
|
||||
//! here).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the
|
||||
/// test crate; the binary is named `aura` in `Cargo.toml`).
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// A fresh, unique working directory for this process test (so the run's
|
||||
/// `./runs/traces/` never dirties the repo). Unique per test + per process.
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-tap-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// The exact synthetic price array `r_sma_prices()` (main.rs) feeds the
|
||||
/// `RunData::Synthetic` path — duplicated here (not re-exported; a private
|
||||
/// CLI helper) so the tapped SMA(2) column can be pinned against an
|
||||
/// independently-derived expectation, not a blind capture.
|
||||
const R_SMA_PRICES: [f64; 18] = [
|
||||
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
|
||||
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
||||
];
|
||||
|
||||
/// The shipped `examples/r_sma.json` blueprint, with one additional declared
|
||||
/// tap on node 0 ("fast", `SMA(length=2)`) field 0 — the smallest addition
|
||||
/// that names an interior producer output without touching the existing
|
||||
/// topology (nodes/edges/input_roles/output all verbatim).
|
||||
fn tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
|
||||
serde_json::to_string(&v).expect("re-serialize tapped blueprint")
|
||||
}
|
||||
|
||||
/// Property: on the single-run path (`aura run <blueprint.json>` over the
|
||||
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
|
||||
/// per-cycle series is persisted through the trace store — the persisted
|
||||
/// `fast_tap.json` carries exactly the SMA(2) values over the known synthetic
|
||||
/// price stream (silent until warm at sample 2, one value per cycle after).
|
||||
#[test]
|
||||
fn single_run_persists_a_declared_tap_series() {
|
||||
let cwd = temp_cwd("single-run-persists");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
|
||||
let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
|
||||
panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
|
||||
});
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
||||
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||||
|
||||
// SMA(2), silent until warm (2 samples): first emission at the 2nd sample
|
||||
// (ts=2), value = mean(prices[0], prices[1]); then a rolling mean of the
|
||||
// last two prices per subsequent cycle (ts = i+1 for prices[i]).
|
||||
let expected: Vec<f64> = (1..R_SMA_PRICES.len())
|
||||
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
||||
.collect();
|
||||
let expected_ts: Vec<i64> = (2..=R_SMA_PRICES.len() as i64).collect();
|
||||
|
||||
let got_ts: Vec<i64> = trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
|
||||
assert_eq!(got_ts, expected_ts, "recorded timestamps");
|
||||
|
||||
let got: Vec<f64> = trace["columns"][0]
|
||||
.as_array()
|
||||
.expect("columns[0] array")
|
||||
.iter()
|
||||
.map(|v| v.as_f64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(got.len(), expected.len(), "recorded row count");
|
||||
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
|
||||
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A tap-free blueprint run (the existing `run_prints_json_and_exits_zero`
|
||||
/// shape) leaves no `runs/` directory at all — the tap machinery is fully
|
||||
/// inert (no binds, no trace_store write) when a blueprint declares no taps,
|
||||
/// matching Task 6's byte-identical guarantee.
|
||||
#[test]
|
||||
fn tap_free_run_writes_no_trace_store() {
|
||||
let cwd = temp_cwd("tap-free-writes-nothing");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", &bp])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!cwd.join("runs").exists(), "a tap-free run must not create a trace store");
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json` blueprint with TWO declared taps that
|
||||
/// share the same name (`"dup"`), on two distinct producers (node 0 "fast" and
|
||||
/// node 1 "slow") — the shape `run_signal_r`'s `seen: BTreeSet` dedup guard
|
||||
/// exists for (`FlatGraph::bind_tap` itself keeps no cross-call state per its
|
||||
/// own doc comment, so nothing upstream of the CLI catches this).
|
||||
fn duplicate_tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([
|
||||
{"name": "dup", "from": {"node": 0, "field": 0}},
|
||||
{"name": "dup", "from": {"node": 1, "field": 0}},
|
||||
]);
|
||||
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
|
||||
}
|
||||
|
||||
/// Property: **a blueprint declaring two taps under the same name is refused
|
||||
/// on the single-run path with a named error and exit code 1, before any
|
||||
/// trace is persisted** — the CLI-owned `TapBindError::DuplicateBind` dedup
|
||||
/// guard (bind_tap itself binds a single tap and keeps no cross-call state,
|
||||
/// per its own doc comment) actually fires end-to-end, and the partial first
|
||||
/// bind never reaches disk as a half-written trace store.
|
||||
#[test]
|
||||
fn single_run_refuses_a_duplicate_tap_name_before_persisting_anything() {
|
||||
let cwd = temp_cwd("duplicate-tap-name-refused");
|
||||
let bp_path = cwd.join("dup_tap_r_sma.json");
|
||||
std::fs::write(&bp_path, duplicate_tap_blueprint_json()).expect("write dup-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("dup") && stderr.contains("more than once"),
|
||||
"stderr must name the offending tap and the duplicate-bind fault: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not persist a half-written trace store");
|
||||
}
|
||||
|
||||
/// The `aura: writing tap traces failed: …` register, pre-run arm: `runs`
|
||||
/// exists as a FILE, so the trace store's directory creation
|
||||
/// (`begin_run`) fails before the run — exit 1 through the register.
|
||||
/// (This register was code-present but untested before #283.)
|
||||
#[test]
|
||||
fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
|
||||
let cwd = temp_cwd("store-root-is-a-file");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("writing tap traces failed"),
|
||||
"the tap-trace register must fire: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same register, deferred arm (#283): the run directory pre-exists
|
||||
/// read-only, so `begin_run` succeeds (create_dir_all on an existing dir)
|
||||
/// but the record consumer's deferred open in `initialize` fails; the run
|
||||
/// COMPLETES, the failure surfaces terminally through the register, and no
|
||||
/// `index.json` is written (the store's treat-as-absent crash shape).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let cwd = temp_cwd("run-dir-read-only");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
let run_dir = cwd.join("runs/traces/sma_signal");
|
||||
std::fs::create_dir_all(&run_dir).expect("pre-create run dir");
|
||||
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
|
||||
.expect("make run dir read-only");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
// restore permissions FIRST so the next run's temp_cwd cleanup works.
|
||||
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("restore run dir permissions");
|
||||
|
||||
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("writing tap traces failed"),
|
||||
"the tap-trace register must fire terminally: {stderr}"
|
||||
);
|
||||
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
|
||||
}
|
||||
@@ -14,6 +14,8 @@ publish.workspace = true
|
||||
aura-core = { path = "../aura-core" }
|
||||
aura-engine = { path = "../aura-engine" }
|
||||
aura-std = { path = "../aura-std" }
|
||||
aura-strategy = { path = "../aura-strategy" }
|
||||
aura-backtest = { path = "../aura-backtest" }
|
||||
|
||||
[dev-dependencies]
|
||||
# serde_json: the risk_executor fixture round-trips a folded RunMetrics through
|
||||
|
||||
@@ -17,10 +17,11 @@
|
||||
//! what C19/C23 DCE deletes), so it appears nowhere here.
|
||||
use aura_core::{PrimitiveBuilder, Scalar};
|
||||
use aura_engine::{Composite, GraphBuilder, NodeHandle};
|
||||
use aura_std::{
|
||||
cost_port, intern_port, CostSum, Delay, Ema, FixedStop, LinComb, Mul,
|
||||
PositionManagement, Sizer, Sqrt, Sub, COST_FIELD_NAMES, GEOMETRY_WIDTH,
|
||||
PM_FIELD_NAMES,
|
||||
use aura_backtest::{PositionManagement, PM_FIELD_NAMES};
|
||||
use aura_std::{Delay, Ema, LinComb, Mul, Sqrt, Sub};
|
||||
use aura_strategy::{
|
||||
cost_port, intern_port, CostSum, FixedStop, Sizer, VolTfStop, COST_FIELD_NAMES,
|
||||
GEOMETRY_WIDTH,
|
||||
};
|
||||
|
||||
/// The volatility stop as a composition of primitives:
|
||||
@@ -75,6 +76,7 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
|
||||
pub enum StopRule {
|
||||
Fixed(f64),
|
||||
Vol { length: i64, k: f64 },
|
||||
VolTf { period_minutes: i64, length: i64, k: f64 },
|
||||
}
|
||||
|
||||
/// The per-symbol RiskExecutor: open input roles `bias` + `price`, internal
|
||||
@@ -88,6 +90,12 @@ pub fn risk_executor(stop: StopRule, risk_budget: f64) -> Composite {
|
||||
Box::new(move |g| match stop {
|
||||
StopRule::Fixed(d) => g.add(FixedStop::builder().bind("distance", Scalar::f64(d))),
|
||||
StopRule::Vol { length, k } => g.add(vol_stop(length, k)),
|
||||
StopRule::VolTf { period_minutes, length, k } => g.add(
|
||||
VolTfStop::builder()
|
||||
.bind("period_minutes", Scalar::i64(period_minutes))
|
||||
.bind("length", Scalar::i64(length))
|
||||
.bind("k", Scalar::f64(k)),
|
||||
),
|
||||
}),
|
||||
risk_budget,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use aura_composites::cost_graph;
|
||||
use aura_core::Scalar;
|
||||
use aura_std::{ConstantCost, VolSlippageCost};
|
||||
use aura_strategy::{ConstantCost, VolSlippageCost};
|
||||
|
||||
/// Two heterogeneous cost nodes: ConstantCost (no extras, index 0) and
|
||||
/// VolSlippageCost (one extra `volatility`, index 1). The composite exposes the 4
|
||||
|
||||
@@ -9,8 +9,9 @@ use aura_core::{
|
||||
ScalarKind, Timestamp,
|
||||
};
|
||||
use aura_composites::{risk_executor, StopRule};
|
||||
use aura_engine::{summarize_r, GraphBuilder, RunMetrics, VecSource};
|
||||
use aura_std::{Recorder, PM_FIELD_NAMES, PM_RECORD_KINDS};
|
||||
use aura_engine::{GraphBuilder, VecSource};
|
||||
use aura_backtest::{summarize_r, RunMetrics, PM_FIELD_NAMES, PM_RECORD_KINDS};
|
||||
use aura_std::Recorder;
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
// The dense-record columns this fixture reads, named in lockstep with the sibling
|
||||
@@ -34,6 +35,7 @@ impl ConstLongBias {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "price".into() }],
|
||||
output: vec![FieldSpec { name: "bias".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(ConstLongBias { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
@@ -180,11 +182,51 @@ fn risk_executor_vol_stop_arm_bootstraps_and_folds() {
|
||||
assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn);
|
||||
}
|
||||
|
||||
/// Property (#262): the RiskExecutor embeds the `VolTfStop` primitive (the new
|
||||
/// `StopRule::VolTf` arm) and folds to a finite RMetric, proving the arm's
|
||||
/// `price -> stop_distance` port/kind/firing wiring actually bootstraps and
|
||||
/// runs — not merely compiles. Timestamps are real epoch-ns minute ticks
|
||||
/// (`i * 60s` in ns) so `period_minutes: 1` rolls a bucket every cycle,
|
||||
/// matching the node's own bucket-rollover contract; a short k·σ stop over a
|
||||
/// rising-then-falling path opens a trade and closes at least one.
|
||||
#[test]
|
||||
fn risk_executor_vol_tf_stop_arm_bootstraps_and_folds() {
|
||||
const MINUTE_NS: i64 = 60 * 1_000_000_000;
|
||||
let (tx, rx) = channel();
|
||||
let mut g = GraphBuilder::new("vol_tf_harness");
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
let strat = g.add(ConstLongBias::builder());
|
||||
let exec = g.add(risk_executor(
|
||||
StopRule::VolTf { period_minutes: 1, length: 1, k: 2.0 },
|
||||
1.0,
|
||||
));
|
||||
let rec = g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx));
|
||||
g.feed(price, [strat.input("price"), exec.input("price")]);
|
||||
g.connect(strat.output("bias"), exec.input("bias"));
|
||||
for (i, field) in PM_FIELD_NAMES.iter().enumerate() {
|
||||
let col: &'static str = format!("col[{i}]").leak();
|
||||
g.connect(exec.output(field), rec.input(col));
|
||||
}
|
||||
let mut h = g
|
||||
.build()
|
||||
.expect("vol_tf_harness wires")
|
||||
.bootstrap_with_params(vec![])
|
||||
.expect("bootstraps");
|
||||
let path = [100.0, 101.0, 100.0, 101.0, 103.0, 106.0, 104.0, 99.0, 95.0, 96.0];
|
||||
let stream: Vec<(Timestamp, Scalar)> =
|
||||
path.iter().enumerate().map(|(i, &p)| (Timestamp(i as i64 * MINUTE_NS), Scalar::f64(p))).collect();
|
||||
h.run(vec![Box::new(VecSource::new(stream))]);
|
||||
let ledger: Vec<(Timestamp, Vec<Scalar>)> = rx.try_iter().collect();
|
||||
let m = summarize_r(&ledger, &[]);
|
||||
assert!(m.n_trades >= 1, "the vol_tf-stop executor must fold at least one trade");
|
||||
assert!(m.sqn.is_finite(), "SQN must be finite, got {}", m.sqn);
|
||||
}
|
||||
|
||||
/// Drain one `risk_executor`-variant harness over the shared rising-then-falling path,
|
||||
/// folding the dense R-record. `exec` is the variant under test (bound or open); `params`
|
||||
/// is the positional point the open variant needs (empty for the bound one). Shared by the
|
||||
/// open-vs-bound equivalence test below so the two arms run byte-identical inputs.
|
||||
fn fold_executor(exec: aura_engine::Composite, params: Vec<Scalar>) -> aura_engine::RMetrics {
|
||||
fn fold_executor(exec: aura_engine::Composite, params: Vec<Scalar>) -> aura_backtest::RMetrics {
|
||||
let (tx, rx) = channel();
|
||||
let mut g = GraphBuilder::new("vol_open_harness");
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
|
||||
@@ -44,8 +44,8 @@ pub use column::{Column, Window};
|
||||
pub use ctx::Ctx;
|
||||
pub use error::KindMismatch;
|
||||
pub use node::{
|
||||
zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder,
|
||||
doc_gate, zip_params, BindOpError, BoundParam, DocGateFault, FieldSpec, Firing, Node,
|
||||
NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder,
|
||||
};
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
pub use series_fold::SeriesFold;
|
||||
|
||||
@@ -345,6 +345,36 @@ pub struct NodeSchema {
|
||||
pub inputs: Vec<PortSpec>,
|
||||
pub output: Vec<FieldSpec>,
|
||||
pub params: Vec<ParamSpec>,
|
||||
/// One-line meaning of the node — non-load-bearing metadata, shown by
|
||||
/// introspection surfaces.
|
||||
pub doc: &'static str,
|
||||
}
|
||||
|
||||
/// Shape fault of a vocabulary entry's meaning line (C29 gate).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DocGateFault {
|
||||
Empty,
|
||||
RestatesName,
|
||||
}
|
||||
|
||||
/// Deterministic shape check for a vocabulary entry's meaning line —
|
||||
/// string shape only, never content judgement (the engine must not
|
||||
/// evaluate prose).
|
||||
pub fn doc_gate(name: &str, doc: &str) -> Result<(), DocGateFault> {
|
||||
if doc.trim().is_empty() {
|
||||
return Err(DocGateFault::Empty);
|
||||
}
|
||||
fn norm(s: &str) -> String {
|
||||
s.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect()
|
||||
}
|
||||
let short_name = name.rsplit("::").next().unwrap_or(name);
|
||||
if norm(doc) == norm(name) || norm(doc) == norm(short_name) {
|
||||
return Err(DocGateFault::RestatesName);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The universal composable dataflow unit (C8): a **producer, consumer, or both**.
|
||||
@@ -379,6 +409,15 @@ pub trait Node {
|
||||
"node".to_string()
|
||||
}
|
||||
|
||||
/// Start-of-stream hook: `Harness::run` calls it once per node, in
|
||||
/// topological order, before the first source value — the mirror of
|
||||
/// [`Node::finalize`]. A consumer overrides it to acquire run resources
|
||||
/// (e.g. the file a recording sink streams into). Infallible by
|
||||
/// signature: an implementation that can fail stores its error, degrades
|
||||
/// to inert, and surfaces the failure once, terminally, at `finalize`.
|
||||
/// The default is a no-op, so existing nodes are unaffected.
|
||||
fn initialize(&mut self) {}
|
||||
|
||||
/// End-of-stream hook: `Harness::run` calls it once per node, in topological
|
||||
/// order, after the source loop drains. A folding sink overrides it to flush
|
||||
/// its accumulated summary; the default is a no-op, so existing nodes are
|
||||
@@ -437,13 +476,14 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(with.label(), "SMA");
|
||||
let none = PrimitiveBuilder::new(
|
||||
"Sub",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(none.label(), "Sub");
|
||||
@@ -473,7 +513,7 @@ mod tests {
|
||||
fn node_name_strips_namespace_prefix() {
|
||||
let b = PrimitiveBuilder::new(
|
||||
"demo::Identity",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
|_| unreachable!("never built in this test"),
|
||||
);
|
||||
assert_eq!(b.node_name(), "identity");
|
||||
@@ -505,7 +545,7 @@ mod tests {
|
||||
fn primitive_builder_build_runs_the_closure() {
|
||||
let f = PrimitiveBuilder::new(
|
||||
"Bare",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(f.params(), Vec::<ParamSpec>::new());
|
||||
@@ -520,6 +560,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -529,6 +570,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_carries_declared_doc() {
|
||||
let b = PrimitiveBuilder::new(
|
||||
"SMA",
|
||||
NodeSchema {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "simple moving average",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(b.schema().doc, "simple moving average");
|
||||
}
|
||||
|
||||
/// A node whose label echoes the param vector its constructor received — lets a
|
||||
/// test read back the positional vector `.build()` reconstructed after binds.
|
||||
struct Probe(Vec<Cell>);
|
||||
@@ -556,6 +612,7 @@ mod tests {
|
||||
ParamSpec { name: "b".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "c".into(), kind: ScalarKind::F64 },
|
||||
],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|p| Box::new(Probe(p.to_vec())),
|
||||
)
|
||||
@@ -632,6 +689,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -650,6 +708,7 @@ mod tests {
|
||||
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -665,6 +724,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -716,6 +776,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
@@ -737,6 +798,43 @@ mod tests {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_gate_refuses_empty_and_whitespace() {
|
||||
assert_eq!(doc_gate("EMA", ""), Err(DocGateFault::Empty));
|
||||
assert_eq!(doc_gate("EMA", " \t"), Err(DocGateFault::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_gate_refuses_name_restatement() {
|
||||
assert_eq!(doc_gate("EMA", "EMA"), Err(DocGateFault::RestatesName));
|
||||
assert_eq!(doc_gate("EMA", "e_m_a"), Err(DocGateFault::RestatesName));
|
||||
assert_eq!(doc_gate("rolling_max", "Rolling Max"), Err(DocGateFault::RestatesName));
|
||||
}
|
||||
|
||||
/// C29 tightening (#316 fieldtest): for a namespaced entry, the bare
|
||||
/// display segment is a restatement too — a doc that adds nothing beyond
|
||||
/// the name must not pass just because the name carries a `::` prefix.
|
||||
/// Genuine meaning lines that merely *contain* the name stay legal.
|
||||
#[test]
|
||||
fn doc_gate_refuses_namespaced_short_name_restatement() {
|
||||
// The most natural alibi at the extension-author seam: the node's
|
||||
// own display name as its doc.
|
||||
assert_eq!(doc_gate("lab316_nodes::Scale", "Scale"), Err(DocGateFault::RestatesName));
|
||||
// Guard: a real meaning line stays Ok even when it contains the
|
||||
// short name as a substring...
|
||||
assert_eq!(
|
||||
doc_gate("lab316_nodes::Scale", "scalar gain: emits the input times the factor param"),
|
||||
Ok(())
|
||||
);
|
||||
// ...and a namespaced entry with a genuine doc is untouched.
|
||||
assert_eq!(doc_gate("und::Opaque", "one-input f64 pass-through"), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doc_gate_accepts_a_meaning_line() {
|
||||
assert_eq!(doc_gate("EMA", "exponential moving average over the input series"), Ok(()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -11,6 +11,32 @@ use crate::cell::Cell;
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Timestamp(pub i64);
|
||||
|
||||
impl Timestamp {
|
||||
/// Nanoseconds per minute — the one scale factor `snap_to_nearest_minute`
|
||||
/// rounds against.
|
||||
const NS_PER_MINUTE: i64 = 60 * 1_000_000_000;
|
||||
|
||||
/// Snap this instant to the nearest whole-minute boundary (round-half-up,
|
||||
/// ties toward the later minute) — the sub-second-provider-jitter
|
||||
/// correction (#280): a bar's nominal minute can be a bucket/session
|
||||
/// boundary while the raw provider stamp lands a fraction of a second
|
||||
/// early or late. Consumers deriving bucket membership or wall-clock
|
||||
/// minutes from `ctx.now()` snap the read here, once, rather than
|
||||
/// dividing/truncating the raw jittered stamp — the recorded stream
|
||||
/// itself stays byte-verbatim (record-then-replay, C11); only the
|
||||
/// consumer-side read is corrected.
|
||||
pub fn snap_to_nearest_minute(self) -> Timestamp {
|
||||
let m = Self::NS_PER_MINUTE;
|
||||
let rem = self.0.rem_euclid(m);
|
||||
let floor = self.0 - rem;
|
||||
if rem * 2 >= m {
|
||||
Timestamp(floor + m)
|
||||
} else {
|
||||
Timestamp(floor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind tag of a scalar / column, used for the edge-time type check (C7).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
|
||||
pub enum ScalarKind {
|
||||
|
||||
@@ -7,10 +7,10 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../aura-core" }
|
||||
# aura-analysis holds the pure trading-domain reductions (R-metrics, the
|
||||
# position-event table, the multiple-comparison hurdle math) lifted out of
|
||||
# `report` (issue #136). The engine re-exports them via `report::` so callers
|
||||
# resolve the moved types through aura-engine unchanged (C18 byte-identity).
|
||||
# aura-analysis holds the domain-free post-run statistics and the
|
||||
# selection-provenance types the engine's `RunManifest.selection` embeds
|
||||
# (#136, reduced to the domain-free half by #291). Re-exported via `report::`
|
||||
# so callers resolve the types through aura-engine unchanged (C18).
|
||||
aura-analysis = { path = "../aura-analysis" }
|
||||
# serde is admitted under the amended C16 per-case dependency policy (INDEX.md):
|
||||
# it gives the run-report types a typed (de)serialization path for the run
|
||||
@@ -19,8 +19,22 @@ serde = { workspace = true }
|
||||
# serde_json renders RunReport JSON for both the registry (disk) and stdout
|
||||
# (RunReport::to_json) — one shape, no hand-rolled writer (amended C16).
|
||||
serde_json = { workspace = true }
|
||||
# rayon is admitted under the amended C16 per-case dependency policy (INDEX.md):
|
||||
# it backs `run_indexed`'s work-stealing pool for the sweep/campaign executor.
|
||||
# It stays within C1 (parallelism across sims, never within one) — each sim
|
||||
# still runs its own deterministic, synchronous event loop on one thread; rayon
|
||||
# only fans the *disjoint* sims of a family out across threads, replacing
|
||||
# `std::thread::scope`. No dep on a workspace precedent (chrono's pattern:
|
||||
# direct versioned dep).
|
||||
rayon = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
# aura-backtest is TEST-ONLY (back to its pre-#291 home, C28 phase 2 D3): the
|
||||
# engine's own tests instantiate `RunReport<M>` with `M = RunMetrics`
|
||||
# (SimBroker fixtures, summarize/RunMetrics literals) via aura-backtest's
|
||||
# concrete aliases. The library code stays metric-agnostic — `RunReport<M>`
|
||||
# names no concrete metric type — so aura-backtest is out of [dependencies].
|
||||
aura-backtest = { path = "../aura-backtest" }
|
||||
# aura-std is a TEST-ONLY dependency: the engine's own integration tests
|
||||
# (r_sma_e2e, random_sweep_e2e, ger40_breakout) drive real standard nodes through
|
||||
# a bootstrapped Harness. The engine's library code never names a concrete node — it
|
||||
@@ -28,6 +42,9 @@ serde_json = { workspace = true }
|
||||
# positionally, by index), so aura-std stays out of [dependencies] and the engine is
|
||||
# `-> aura-core` only. The node-naming composite-builders live in `aura-composites`.
|
||||
aura-std = { path = "../aura-std" }
|
||||
aura-market = { path = "../aura-market" }
|
||||
aura-strategy = { path = "../aura-strategy" }
|
||||
aura-vocabulary = { path = "../aura-vocabulary" }
|
||||
# chrono / chrono-tz build the Berlin-local-wall-clock epoch-ns timestamps the
|
||||
# GER40 session-breakout E2E fixture (tests/ger40_breakout.rs) feeds the engine,
|
||||
# exactly as `Session`'s own unit test does. Pinned to aura-std's versions (the
|
||||
|
||||
@@ -18,7 +18,7 @@ use aura_core::{
|
||||
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
|
||||
};
|
||||
|
||||
use crate::harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
|
||||
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
|
||||
use crate::sweep::sweep;
|
||||
use crate::{GridSpace, ParamRange, RandomSpace, RunReport, SweepFamily};
|
||||
|
||||
@@ -33,6 +33,30 @@ pub struct OutField {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One interior output wire a tap names: a producer node's output field, by
|
||||
/// interior local index — the output-side analogue of a role `Target`
|
||||
/// (`{node, slot}`) and of `OutField`'s `{node, field}`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct TapWire {
|
||||
pub node: usize,
|
||||
pub field: usize,
|
||||
}
|
||||
|
||||
/// A declared measurement point: a name paired with an interior output wire.
|
||||
/// A PURE declaration — no side effect, no channel endpoint; the effectful sink
|
||||
/// is constructed run-mode-aware at bind time, never in the serialized artefact.
|
||||
/// The output-side twin of `Role` (`input_roles`): a `Role` names an abstract
|
||||
/// input without naming a source; a `Tap` names an interior output without
|
||||
/// naming a sink. The declaration `name` is a debug symbol for the topological
|
||||
/// content-id (blanked by `strip_debug_symbols`, like `Role.name`/`OutField.name`);
|
||||
/// the resolved flat-graph tap keeps its name load-bearing for by-name binding
|
||||
/// (like `SourceSpec.role`, #275).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct Tap {
|
||||
pub name: String,
|
||||
pub from: TapWire,
|
||||
}
|
||||
|
||||
/// A blueprint item: a primitive node or a nested composite. Both present a declared
|
||||
/// interface (typed inputs + one output) to the enclosing graph.
|
||||
pub enum BlueprintNode {
|
||||
@@ -105,7 +129,15 @@ fn derive_signature(c: &Composite) -> NodeSchema {
|
||||
.collect();
|
||||
let mut params = Vec::new();
|
||||
collect_params(c.nodes(), c.gangs(), "", &mut params);
|
||||
NodeSchema { inputs, output, params }
|
||||
NodeSchema {
|
||||
inputs,
|
||||
output,
|
||||
params,
|
||||
// A derived composite signature is graph wiring, not a vocabulary entry:
|
||||
// no seam walks this doc. The described surface is the composite's own
|
||||
// `doc`, gated at register (C29, register seam).
|
||||
doc: "",
|
||||
}
|
||||
}
|
||||
|
||||
/// The scalar kind of the interior input slot a composite target addresses,
|
||||
@@ -174,6 +206,7 @@ pub struct Composite {
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Role>,
|
||||
output: Vec<OutField>,
|
||||
taps: Vec<Tap>,
|
||||
gangs: Vec<Gang>,
|
||||
}
|
||||
|
||||
@@ -189,7 +222,7 @@ impl Composite {
|
||||
input_roles: Vec<Role>,
|
||||
output: Vec<OutField>,
|
||||
) -> Self {
|
||||
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, gangs: Vec::new() }
|
||||
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, taps: Vec::new(), gangs: Vec::new() }
|
||||
}
|
||||
|
||||
/// The authored render name (cluster title, #13). Non-load-bearing.
|
||||
@@ -207,6 +240,16 @@ impl Composite {
|
||||
pub fn doc(&self) -> Option<&str> {
|
||||
self.doc.as_deref()
|
||||
}
|
||||
/// Install declared measurement taps (the output-side twin of `input_roles`).
|
||||
/// Fluent, mirroring `with_doc`. Empty by default.
|
||||
pub fn with_taps(mut self, taps: Vec<Tap>) -> Self {
|
||||
self.taps = taps;
|
||||
self
|
||||
}
|
||||
/// The declared measurement taps (read-only). Empty for an un-tapped blueprint.
|
||||
pub fn taps(&self) -> &[Tap] {
|
||||
&self.taps
|
||||
}
|
||||
/// The interior blueprint items (read-only graph-as-data, C9).
|
||||
pub fn nodes(&self) -> &[BlueprintNode] {
|
||||
&self.nodes
|
||||
@@ -291,7 +334,7 @@ impl Composite {
|
||||
// structural validation, all pre-build (no node constructed):
|
||||
let space = self.param_space();
|
||||
check_param_namespace_injective(&space)?;
|
||||
validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
|
||||
validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output, &self.taps)?;
|
||||
check_root_roles_bound(&self.input_roles)?;
|
||||
|
||||
if point.len() != space.len() {
|
||||
@@ -310,6 +353,7 @@ impl Composite {
|
||||
let mut flat_nodes: Vec<Box<dyn Node>> = Vec::new();
|
||||
let mut flat_signatures: Vec<NodeSchema> = Vec::new();
|
||||
let mut flat_edges: Vec<Edge> = Vec::new();
|
||||
let mut flat_taps: Vec<FlatTap> = Vec::new();
|
||||
let mut cursor = 0usize;
|
||||
|
||||
let lowerings = lower_items(
|
||||
@@ -319,6 +363,7 @@ impl Composite {
|
||||
&mut flat_nodes,
|
||||
&mut flat_signatures,
|
||||
&mut flat_edges,
|
||||
&mut flat_taps,
|
||||
)?;
|
||||
|
||||
for e in &self.edges {
|
||||
@@ -335,10 +380,24 @@ impl Composite {
|
||||
for t in &role.targets {
|
||||
targets.extend(resolve_target(t, &lowerings)?);
|
||||
}
|
||||
flat_sources.push(SourceSpec { kind, targets });
|
||||
flat_sources.push(SourceSpec::named(role.name.clone(), kind, targets));
|
||||
}
|
||||
|
||||
Ok(FlatGraph { nodes: flat_nodes, signatures: flat_signatures, sources: flat_sources, edges: flat_edges })
|
||||
// the root's own declared taps resolve through the root `lowerings`, same
|
||||
// as any nested composite's — the root frame IS a composite frame, just
|
||||
// the outermost one, so it hoists into the same accumulator.
|
||||
for tap in &self.taps {
|
||||
let (node, field) = resolve_tap_wire(&tap.from, &tap.name, &lowerings, &flat_signatures)?;
|
||||
flat_taps.push(FlatTap { name: tap.name.clone(), node, field });
|
||||
}
|
||||
|
||||
Ok(FlatGraph {
|
||||
nodes: flat_nodes,
|
||||
signatures: flat_signatures,
|
||||
sources: flat_sources,
|
||||
edges: flat_edges,
|
||||
taps: flat_taps,
|
||||
})
|
||||
}
|
||||
|
||||
/// Compile from a self-describing param vector — the authoring-edge frontend
|
||||
@@ -509,9 +568,10 @@ impl SweepBinder {
|
||||
/// Resolve the named axes against `param_space()` into a positional grid and
|
||||
/// run the disjoint sweep. `sweep` is [`SweepBinder::sweep_with_lattice`] with
|
||||
/// the lattice dropped, so every existing caller is byte-unchanged.
|
||||
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
|
||||
pub fn sweep<M, F>(self, run_one: F) -> Result<SweepFamily<M>, BindError>
|
||||
where
|
||||
F: Fn(&[Cell]) -> RunReport + Sync,
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
{
|
||||
self.sweep_with_lattice(run_one).map(|(family, _lattice)| family)
|
||||
}
|
||||
@@ -520,9 +580,10 @@ impl SweepBinder {
|
||||
/// in `param_space()` / odometer order). The lattice is what a plateau
|
||||
/// neighbourhood walk needs (cycle 0077); only the engine's post-`resolve_axes`
|
||||
/// grid holds it in the correct order.
|
||||
pub fn sweep_with_lattice<F>(self, run_one: F) -> Result<(SweepFamily, Vec<usize>), BindError>
|
||||
pub fn sweep_with_lattice<M, F>(self, run_one: F) -> Result<(SweepFamily<M>, Vec<usize>), BindError>
|
||||
where
|
||||
F: Fn(&[Cell]) -> RunReport + Sync,
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
{
|
||||
let space = self.bp.param_space();
|
||||
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
|
||||
@@ -553,9 +614,10 @@ impl RandomBinder {
|
||||
/// Resolve the named ranges against `param_space()` into a positional
|
||||
/// `RandomSpace` and run the disjoint sweep. `count`/`seed` are `RandomSpace`'s
|
||||
/// extra inputs — the only signature difference from [`SweepBinder::sweep`].
|
||||
pub fn sweep<F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily, BindError>
|
||||
pub fn sweep<M, F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily<M>, BindError>
|
||||
where
|
||||
F: Fn(&[Cell]) -> RunReport + Sync,
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
{
|
||||
let space = self.bp.param_space();
|
||||
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
|
||||
@@ -812,6 +874,9 @@ pub enum CompileError {
|
||||
DoubleWiredPort { node: usize, slot: usize },
|
||||
/// A gang table failed structural validation at a minting boundary.
|
||||
BadGang(GangFault),
|
||||
/// A declared tap's wire references a non-existent interior node, or a field
|
||||
/// beyond that producer's output arity.
|
||||
TapWireOutOfRange { tap: String },
|
||||
}
|
||||
|
||||
/// The typed detail of a `CompileError::BadGang` (one arm per `check_gangs`
|
||||
@@ -860,6 +925,7 @@ pub(crate) fn validate_wiring(
|
||||
edges: &[Edge],
|
||||
roles: &[Role],
|
||||
output: &[OutField],
|
||||
taps: &[Tap],
|
||||
) -> Result<(), CompileError> {
|
||||
// edges: index-range + producer/consumer kind match. The kind-mismatch variant
|
||||
// is the SAME one bootstrap returns today (Bootstrap(KindMismatch)), just raised
|
||||
@@ -891,12 +957,20 @@ pub(crate) fn validate_wiring(
|
||||
return Err(CompileError::OutputPortOutOfRange);
|
||||
}
|
||||
}
|
||||
// taps: each declared tap's node index in range (structural leg only — deep
|
||||
// field-arity is validated at resolve time by `resolve_tap_wire`, mirroring
|
||||
// how the output leg above pins the node index).
|
||||
for tap in taps {
|
||||
if tap.from.node >= nodes.len() {
|
||||
return Err(CompileError::TapWireOutOfRange { tap: tap.name.clone() });
|
||||
}
|
||||
}
|
||||
// wiring totality: every interior input slot covered by exactly one wiring act
|
||||
check_ports_connected(nodes, edges, roles)?;
|
||||
// recurse into nested composites
|
||||
for item in nodes {
|
||||
if let BlueprintNode::Composite(c) = item {
|
||||
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?;
|
||||
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1117,6 +1191,7 @@ fn lower_items(
|
||||
flat_nodes: &mut Vec<Box<dyn Node>>,
|
||||
flat_signatures: &mut Vec<NodeSchema>,
|
||||
flat_edges: &mut Vec<Edge>,
|
||||
flat_taps: &mut Vec<FlatTap>,
|
||||
) -> Result<Vec<ItemLowering>, CompileError> {
|
||||
let mut lowerings = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
@@ -1140,6 +1215,7 @@ fn lower_items(
|
||||
flat_nodes,
|
||||
flat_signatures,
|
||||
flat_edges,
|
||||
flat_taps,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -1156,6 +1232,7 @@ fn inline_composite(
|
||||
flat_nodes: &mut Vec<Box<dyn Node>>,
|
||||
flat_signatures: &mut Vec<NodeSchema>,
|
||||
flat_edges: &mut Vec<Edge>,
|
||||
flat_taps: &mut Vec<FlatTap>,
|
||||
) -> Result<ItemLowering, CompileError> {
|
||||
// `name` is the non-load-bearing render symbol (#13); it dissolves at inline
|
||||
// (C23 — the boundary does not reach the flat graph), so it is not destructured.
|
||||
@@ -1166,11 +1243,12 @@ fn inline_composite(
|
||||
// `gangs` is an authoring-time gate only (checked at `with_gangs`); it has
|
||||
// no runtime representation in the flat graph, same as `name`. `doc` is
|
||||
// the prose twin of `name` (#125) and dissolves alongside it.
|
||||
let Composite { name: _, doc: _, nodes, edges, input_roles, output, gangs: _ } = c;
|
||||
let Composite { name: _, doc: _, nodes, edges, input_roles, output, taps, gangs: _ } = c;
|
||||
let item_count = nodes.len();
|
||||
|
||||
// recursively lower interior items, then rewrite interior edges through them
|
||||
let interior = lower_items(nodes, point, cursor, flat_nodes, flat_signatures, flat_edges)?;
|
||||
let interior = lower_items(nodes, point, cursor, flat_nodes, flat_signatures, flat_edges, flat_taps)?;
|
||||
|
||||
for e in &edges {
|
||||
for fe in rewrite_edge(e, &interior, flat_signatures)? {
|
||||
flat_edges.push(fe);
|
||||
@@ -1216,6 +1294,15 @@ fn inline_composite(
|
||||
roles.push(flat_targets);
|
||||
}
|
||||
|
||||
// this composite's own declared taps hoist into the flat, bottom-up
|
||||
// accumulator now — a tap is terminal (no re-export through the boundary,
|
||||
// unlike `output`), so it resolves through THIS level's freshly-lowered
|
||||
// `interior` and is pushed once, never touched again by an enclosing level.
|
||||
for tap in &taps {
|
||||
let (node, field) = resolve_tap_wire(&tap.from, &tap.name, &interior, flat_signatures)?;
|
||||
flat_taps.push(FlatTap { name: tap.name.clone(), node, field });
|
||||
}
|
||||
|
||||
Ok(ItemLowering::Composite { output: out, roles })
|
||||
}
|
||||
|
||||
@@ -1264,6 +1351,35 @@ fn resolve_target(t: &Target, lowerings: &[ItemLowering]) -> Result<Vec<Target>,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a declared tap's interior `{node, field}` into a flat producer
|
||||
/// `(node, field)` through the lowering table — a Leaf resolves to its remapped
|
||||
/// index with an output-arity check; a nested Composite resolves through its
|
||||
/// `output` re-export remap. Mirrors the `OutField` resolution loop in
|
||||
/// `inline_composite`. Taps do NOT re-export through the boundary (a tap is
|
||||
/// terminal) — this is used both for a composite's own taps (resolved through its
|
||||
/// freshly-lowered interior) and for the root's own taps (resolved through the
|
||||
/// root `lowerings`).
|
||||
fn resolve_tap_wire(
|
||||
w: &TapWire,
|
||||
name: &str,
|
||||
lowerings: &[ItemLowering],
|
||||
flat_signatures: &[NodeSchema],
|
||||
) -> Result<(usize, usize), CompileError> {
|
||||
let err = || CompileError::TapWireOutOfRange { tap: name.to_string() };
|
||||
if w.node >= lowerings.len() {
|
||||
return Err(err());
|
||||
}
|
||||
match &lowerings[w.node] {
|
||||
ItemLowering::Leaf { index } => {
|
||||
if w.field >= flat_signatures[*index].output.len() {
|
||||
return Err(err());
|
||||
}
|
||||
Ok((*index, w.field))
|
||||
}
|
||||
ItemLowering::Composite { output, .. } => output.get(w.field).copied().ok_or_else(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// The declared scalar kind of a flat node's input slot (for role kind-checking).
|
||||
fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result<ScalarKind, CompileError> {
|
||||
flat_signatures[t.node]
|
||||
@@ -1277,9 +1393,12 @@ fn slot_kind(t: Target, flat_signatures: &[NodeSchema]) -> Result<ScalarKind, Co
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
|
||||
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
|
||||
use crate::{f64_field, ParamRange, RandomSpace, RunManifest, VecSource};
|
||||
use aura_backtest::{summarize, RunMetrics};
|
||||
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
|
||||
use aura_std::{Bias, Ema, LinComb, Recorder, SimBroker, Sma, Sub};
|
||||
use aura_backtest::SimBroker;
|
||||
use aura_std::{Ema, LinComb, Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// One knob fanning into two sibling open params passes the gate; the
|
||||
@@ -1727,6 +1846,7 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let to = NodeSchema {
|
||||
inputs: vec![
|
||||
@@ -1735,6 +1855,7 @@ mod tests {
|
||||
],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
assert!(edge_kind_check(&from, 0, &to, 0).is_ok());
|
||||
assert_eq!(
|
||||
@@ -1751,7 +1872,7 @@ mod tests {
|
||||
/// `fn` (Copy + Sync) so it serves both as the `sweep`/binder closure and as a
|
||||
/// direct reference. Deterministic, so the same point reproduces its report
|
||||
/// exactly — that is what makes two families comparable for equality.
|
||||
fn run_point(point: &[Cell]) -> RunReport {
|
||||
fn run_point(point: &[Cell]) -> RunReport<RunMetrics> {
|
||||
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
|
||||
let mut h = bp
|
||||
.bootstrap_with_cells(point)
|
||||
@@ -1763,6 +1884,7 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "test".to_string(),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
@@ -1930,7 +2052,7 @@ mod tests {
|
||||
.axis("sma_cross.fast.length", [2.0, 3.0]) // F64 values for the I64 slot
|
||||
.axis("sma_cross.slow.length", [4])
|
||||
.axis("bias.scale", [0.5])
|
||||
.sweep(|_: &[Cell]| -> RunReport { panic!("axis pre-validation must reject before running") });
|
||||
.sweep(|_: &[Cell]| -> RunReport<RunMetrics> { panic!("axis pre-validation must reject before running") });
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(BindError::KindMismatch {
|
||||
@@ -2157,7 +2279,7 @@ mod tests {
|
||||
fn pass1() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"Pass1",
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.into()
|
||||
@@ -2165,7 +2287,12 @@ mod tests {
|
||||
fn join2() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"Join2",
|
||||
NodeSchema { inputs: vec![f64_any(), f64_any()], output: out_v(), params: vec![] },
|
||||
NodeSchema {
|
||||
inputs: vec![f64_any(), f64_any()],
|
||||
output: out_v(),
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Join2 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.into()
|
||||
@@ -2173,7 +2300,7 @@ mod tests {
|
||||
fn sink_f64() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"SinkF64",
|
||||
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![] },
|
||||
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(SinkF64),
|
||||
)
|
||||
.into()
|
||||
@@ -2185,6 +2312,7 @@ mod tests {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(SinkI64),
|
||||
)
|
||||
@@ -2364,6 +2492,8 @@ mod tests {
|
||||
sources[0].targets,
|
||||
vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]
|
||||
);
|
||||
// the bound root role's name survives lowering as the source binding key (#275)
|
||||
assert_eq!(sources[0].role.as_deref(), Some("src"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2735,6 +2865,7 @@ mod tests {
|
||||
inputs: vec![f64_any()],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
@@ -2755,14 +2886,11 @@ mod tests {
|
||||
f64_recorder_sig(),
|
||||
f64_recorder_sig(),
|
||||
],
|
||||
sources: vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
targets: vec![
|
||||
sources: vec![SourceSpec::raw(ScalarKind::F64, vec![
|
||||
Target { node: 0, slot: 0 },
|
||||
Target { node: 1, slot: 0 },
|
||||
Target { node: 4, slot: 1 },
|
||||
],
|
||||
}],
|
||||
])],
|
||||
edges: vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
@@ -2771,6 +2899,7 @@ mod tests {
|
||||
Edge { from: 4, to: 5, slot: 0, from_field: 0 },
|
||||
Edge { from: 3, to: 6, slot: 0, from_field: 0 },
|
||||
],
|
||||
taps: Vec::new(),
|
||||
})
|
||||
.expect("valid hand-wired DAG");
|
||||
(h, rx_eq, rx_ex)
|
||||
@@ -3016,7 +3145,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn param_space_reflects_only_open_knobs() {
|
||||
use aura_std::{Bias, Sma};
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
// "sma2_entry": Sma "bias" with length bound to 2 (a structural constant)
|
||||
// plus Bias "exp" whose `scale` stays open. The bound knob must be
|
||||
// absent from param_space; only the open one remains.
|
||||
@@ -3042,7 +3172,8 @@ mod tests {
|
||||
/// order — the bound value becomes a default an axis may override.
|
||||
#[test]
|
||||
fn reopen_returns_a_bound_knob_to_param_space() {
|
||||
use aura_std::{Bias, Sma};
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
let strat = Composite::new(
|
||||
"sma2_entry",
|
||||
vec![
|
||||
@@ -3063,7 +3194,8 @@ mod tests {
|
||||
/// error, not a silent no-op — open axes pass through without reopen.
|
||||
#[test]
|
||||
fn reopen_unknown_or_open_path_errors() {
|
||||
use aura_std::{Bias, Sma};
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
let strat = Composite::new(
|
||||
"sma2_entry",
|
||||
vec![
|
||||
@@ -3084,7 +3216,8 @@ mod tests {
|
||||
/// carries the bound value (the default the CLI renders in --list-axes).
|
||||
#[test]
|
||||
fn bound_param_space_is_path_qualified_with_values() {
|
||||
use aura_std::{Bias, Sma};
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
let strat = Composite::new(
|
||||
"sma2_entry",
|
||||
vec![
|
||||
@@ -3338,6 +3471,7 @@ mod tests {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::I64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::I64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| panic!("build must not run when validation fails pre-build"),
|
||||
);
|
||||
@@ -3440,7 +3574,7 @@ mod tests {
|
||||
// cannot take .named).
|
||||
let paramless_sma = PrimitiveBuilder::new(
|
||||
"Pass1",
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|
||||
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.named("sma");
|
||||
@@ -3571,4 +3705,88 @@ mod tests {
|
||||
let bn: BlueprintNode = c.into();
|
||||
assert!(matches!(bn, BlueprintNode::Composite(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_carries_declared_taps() {
|
||||
// A Composite installs and exposes a taps declaration via with_taps/taps(),
|
||||
// mirroring with_doc/doc(); a bare Composite has no taps.
|
||||
let bare = Composite::new("m", vec![], vec![], vec![], vec![]);
|
||||
assert!(bare.taps().is_empty());
|
||||
let tapped = Composite::new("m", vec![], vec![], vec![], vec![])
|
||||
.with_taps(vec![Tap { name: "p_long".into(), from: TapWire { node: 3, field: 0 } }]);
|
||||
assert_eq!(tapped.taps(), &[Tap { name: "p_long".into(), from: TapWire { node: 3, field: 0 } }]);
|
||||
}
|
||||
|
||||
/// A tap on a root leaf producer resolves to that producer's flat (node, field) —
|
||||
/// the property `compile` must give a declared tap a load-bearing flat identity.
|
||||
#[test]
|
||||
fn compile_resolves_a_root_tap_to_flat_indices() {
|
||||
let bp = Composite::new(
|
||||
"m",
|
||||
vec![Sub::builder().into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "a".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
)
|
||||
.with_taps(vec![Tap { name: "d".into(), from: TapWire { node: 0, field: 0 } }]);
|
||||
let flat = bp.compile_with_params(&[]).expect("compiles");
|
||||
assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "d".into(), node: 0, field: 0 }]);
|
||||
}
|
||||
|
||||
/// A tap wire naming a field beyond its producer's output arity is rejected —
|
||||
/// `compile` must not silently resolve a tap into a bogus flat index.
|
||||
#[test]
|
||||
fn compile_rejects_a_tap_wire_out_of_output_arity() {
|
||||
let bp = Composite::new(
|
||||
"m",
|
||||
vec![Sub::builder().into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "a".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
)
|
||||
.with_taps(vec![Tap { name: "bad".into(), from: TapWire { node: 0, field: 9 } }]); // Sub has 1 output field
|
||||
assert!(matches!(bp.compile_with_params(&[]), Err(CompileError::TapWireOutOfRange { .. })));
|
||||
}
|
||||
|
||||
/// A tap declared on a NESTED composite hoists to the root `FlatGraph.taps`
|
||||
/// with its wire remapped through the inline offset — the case the CLI
|
||||
/// wrapper needs (a tap named deep in an authored blueprint must still surface
|
||||
/// as a single flat, by-name-bindable measurement point at the root).
|
||||
#[test]
|
||||
fn compile_hoists_an_interior_composite_tap_to_the_root() {
|
||||
let inner = Composite::new(
|
||||
"inner",
|
||||
vec![Sub::builder().into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "x".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }, Target { node: 0, slot: 1 }],
|
||||
source: None,
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "o".into() }],
|
||||
)
|
||||
.with_taps(vec![Tap { name: "inner_d".into(), from: TapWire { node: 0, field: 0 } }]);
|
||||
let root = Composite::new(
|
||||
"root",
|
||||
vec![BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "a".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![],
|
||||
);
|
||||
let flat = root.compile_with_params(&[]).expect("compiles");
|
||||
// the inner Sub lowered to flat node 0; the hoisted tap points at it
|
||||
assert_eq!(flat.taps, vec![crate::harness::FlatTap { name: "inner_d".into(), node: 0, field: 0 }]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ pub struct CompositeData {
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub output: Vec<OutField>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub taps: Vec<crate::blueprint::Tap>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub gangs: Vec<Gang>,
|
||||
}
|
||||
|
||||
@@ -94,6 +96,7 @@ fn project(c: &Composite) -> CompositeData {
|
||||
edges: c.edges().to_vec(),
|
||||
input_roles: c.input_roles().to_vec(),
|
||||
output: c.output().to_vec(),
|
||||
taps: c.taps().to_vec(),
|
||||
gangs,
|
||||
}
|
||||
}
|
||||
@@ -135,10 +138,11 @@ pub fn blueprint_to_json(c: &Composite) -> Result<String, SerializeError> {
|
||||
|
||||
/// The identity-canonical form (#171): the canonical document with every
|
||||
/// non-load-bearing debug symbol (C23) blanked — composite name, instance names,
|
||||
/// bound-param names, role names, output re-export names. Everything load-bearing
|
||||
/// survives: type ids, node order, edges, role targets/order, output pairs/order,
|
||||
/// and bound positions/kinds/values (param openness stays identity-bearing: a
|
||||
/// bound slot is textually present, an open one absent). Research-side comparison
|
||||
/// bound-param names, role names, output re-export names, tap names. Everything
|
||||
/// load-bearing survives: type ids, node order, edges, role targets/order, output
|
||||
/// pairs/order, tap interior wires, and bound positions/kinds/values (param
|
||||
/// openness stays identity-bearing: a bound slot is textually present, an open
|
||||
/// one absent). Research-side comparison
|
||||
/// form ONLY — never a load path and never the reproduction store's byte form
|
||||
/// (`reproduce` re-binds params by name, so instance names are load-bearing there).
|
||||
pub fn blueprint_identity_json(c: &Composite) -> Result<String, SerializeError> {
|
||||
@@ -167,6 +171,9 @@ fn strip_debug_symbols(b: &mut CompositeData) {
|
||||
for out in &mut b.output {
|
||||
out.name = String::new(); // (node, field) + order survive
|
||||
}
|
||||
for tap in &mut b.taps {
|
||||
tap.name = String::new(); // interior wire (node, field) survives
|
||||
}
|
||||
for gang in &mut b.gangs {
|
||||
gang.name = String::new();
|
||||
for m in &mut gang.members {
|
||||
@@ -227,6 +234,7 @@ fn reconstruct(
|
||||
});
|
||||
}
|
||||
let composite = Composite::new(d.name.clone(), nodes, d.edges.clone(), d.input_roles.clone(), d.output.clone())
|
||||
.with_taps(d.taps.clone())
|
||||
.with_gangs(d.gangs.clone())
|
||||
.map_err(LoadError::Gang)?;
|
||||
Ok(match &d.doc {
|
||||
@@ -236,7 +244,7 @@ fn reconstruct(
|
||||
}
|
||||
|
||||
/// Load a blueprint from canonical JSON, resolving each primitive's type identity
|
||||
/// through the injected `resolve` (e.g. `aura_std::std_vocabulary`). The version
|
||||
/// through the injected `resolve` (e.g. `aura_vocabulary::std_vocabulary`). The version
|
||||
/// envelope is checked before the payload is reconstructed.
|
||||
pub fn blueprint_from_json(
|
||||
data: &str,
|
||||
@@ -259,7 +267,8 @@ mod tests {
|
||||
use crate::harness::{Edge, Target};
|
||||
use crate::VecSource; // crate-root re-export (blueprint.rs tests import it the same way, e.g. :923)
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use aura_std::{Bias, Recorder, Sma, Sub};
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
|
||||
// Open param point for the sink-free signal (fast.length is bound): [slow.length, bias.scale].
|
||||
@@ -295,7 +304,7 @@ mod tests {
|
||||
fn serialized_blueprint_runs_bit_identical_to_rust_built() {
|
||||
let rust_built = crate::test_fixtures::sink_free_sma_cross_signal();
|
||||
let json = blueprint_to_json(&rust_built).expect("serializes");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
|
||||
let trace_rust = run_recording(rust_built);
|
||||
let trace_loaded = run_recording(loaded);
|
||||
@@ -308,7 +317,7 @@ mod tests {
|
||||
fn serializer_and_loader_are_inverse() {
|
||||
let original = crate::test_fixtures::sink_free_sma_cross_signal();
|
||||
let json = blueprint_to_json(&original).expect("serializes");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
// serialize -> load -> serialize is byte-stable (canonical round-trip identity)
|
||||
assert_eq!(json, blueprint_to_json(&loaded).expect("re-serializes"));
|
||||
}
|
||||
@@ -331,7 +340,7 @@ mod tests {
|
||||
vec![OutField { node: 0, field: 0, name: "bias".into() }],
|
||||
);
|
||||
let json = blueprint_to_json(&outer).expect("serializes");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
assert_eq!(json, blueprint_to_json(&loaded).expect("re-serializes"));
|
||||
// the nested composite survived as a composite, not flattened
|
||||
assert!(json.contains(r#"{"composite":{"name":"sma_cross""#), "nested composite preserved");
|
||||
@@ -362,7 +371,7 @@ mod tests {
|
||||
assert_ne!(plain_json, doced_json, "the doc is canonical-byte-bearing");
|
||||
assert!(doced_json.contains("why this graph"), "doc serialized: {doced_json}");
|
||||
assert!(!plain_json.contains("\"doc\""), "absent doc emits no key: {plain_json}");
|
||||
let loaded = blueprint_from_json(&doced_json, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
||||
let loaded = blueprint_from_json(&doced_json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
assert_eq!(loaded.doc(), Some("why this graph: the spread detects trend turns"));
|
||||
assert_eq!(doced_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable");
|
||||
assert_eq!(
|
||||
@@ -372,20 +381,65 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #282: a composite's declared taps round-trip through project→reconstruct
|
||||
/// byte-stably (mirroring `doc_round_trips_canonically_and_is_identity_blind`),
|
||||
/// and an un-tapped composite's canonical bytes carry no `taps` key at all
|
||||
/// (skip_serializing_if empty) — the golden-pinned fixture's bytes stay
|
||||
/// untouched by this additive field.
|
||||
#[test]
|
||||
fn taps_round_trip_canonically_and_absent_is_byte_stable() {
|
||||
use crate::blueprint::{Tap, TapWire};
|
||||
let plain = crate::test_fixtures::sink_free_sma_cross_signal();
|
||||
let tapped = crate::test_fixtures::sink_free_sma_cross_signal()
|
||||
.with_taps(vec![Tap { name: "d".into(), from: TapWire { node: 0, field: 0 } }]);
|
||||
let plain_json = blueprint_to_json(&plain).expect("serializes");
|
||||
let tapped_json = blueprint_to_json(&tapped).expect("serializes");
|
||||
assert_ne!(plain_json, tapped_json, "taps are canonical-byte-bearing");
|
||||
assert!(!plain_json.contains("\"taps\""), "absent taps emit no key: {plain_json}");
|
||||
let loaded = blueprint_from_json(&tapped_json, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
assert_eq!(loaded.taps(), tapped.taps());
|
||||
assert_eq!(tapped_json, blueprint_to_json(&loaded).expect("re-serializes"), "round-trip byte-stable");
|
||||
}
|
||||
|
||||
/// #282: a tap's `name` is a debug symbol like `Role.name`/`OutField.name` —
|
||||
/// two blueprints differing only in tap name share identity JSON (name
|
||||
/// blanked, interior wire survives) while their canonical JSON differs,
|
||||
/// mirroring `renamed_role_and_output_share_identity_json_not_canonical_json`.
|
||||
#[test]
|
||||
fn renamed_tap_shares_identity_json_not_canonical_json() {
|
||||
use crate::blueprint::{Tap, TapWire};
|
||||
fn probe(tap_name: &str) -> Composite {
|
||||
crate::test_fixtures::sink_free_sma_cross_signal()
|
||||
.with_taps(vec![Tap { name: tap_name.into(), from: TapWire { node: 0, field: 0 } }])
|
||||
}
|
||||
let a = probe("p_long");
|
||||
let b = probe("p_short");
|
||||
assert_ne!(
|
||||
blueprint_to_json(&a).expect("serializes"),
|
||||
blueprint_to_json(&b).expect("serializes"),
|
||||
"canonical bytes keep tap names apart"
|
||||
);
|
||||
assert_eq!(
|
||||
blueprint_identity_json(&a).expect("identity-serializes"),
|
||||
blueprint_identity_json(&b).expect("identity-serializes"),
|
||||
"identity form is tap-name-blind"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_node_type_fails_named() {
|
||||
// a valid envelope naming a type outside the vocabulary -> clean, named error
|
||||
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"NoSuchNode"}}]}}"#;
|
||||
// `.err().unwrap()` (not `.unwrap_err()`): the Ok type `Composite` holds a
|
||||
// build closure and is not `Debug`, which `unwrap_err`'s bound would require.
|
||||
let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).err().unwrap();
|
||||
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
|
||||
assert!(matches!(err, LoadError::UnknownNodeType(t) if t == "NoSuchNode"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_version_fails_named() {
|
||||
let json = r#"{"format_version":2,"blueprint":{"name":"x","nodes":[]}}"#;
|
||||
let err = blueprint_from_json(json, &|t| aura_std::std_vocabulary(t)).err().unwrap();
|
||||
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
|
||||
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
|
||||
}
|
||||
|
||||
@@ -413,7 +467,7 @@ mod tests {
|
||||
assert_ne!(with_unknown, canonical, "probe must actually inject unknown keys");
|
||||
|
||||
// tolerated, not refused
|
||||
let loaded = blueprint_from_json(&with_unknown, &|t| aura_std::std_vocabulary(t))
|
||||
let loaded = blueprint_from_json(&with_unknown, &|t| aura_vocabulary::std_vocabulary(t))
|
||||
.expect("an unknown optional field is tolerated, not refused");
|
||||
|
||||
// the unknown keys did not leak into the graph: re-serialization equals the
|
||||
@@ -421,7 +475,7 @@ mod tests {
|
||||
assert_eq!(blueprint_to_json(&loaded).expect("re-serializes"), canonical);
|
||||
|
||||
// and the loaded graph runs bit-identically to the plain canonical one (C1).
|
||||
let plain = blueprint_from_json(&canonical, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
||||
let plain = blueprint_from_json(&canonical, &|t| aura_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
assert_eq!(
|
||||
run_recording(loaded),
|
||||
run_recording(plain),
|
||||
@@ -602,9 +656,9 @@ mod tests {
|
||||
}
|
||||
|
||||
// The resolver used by the gang-serde tests below (mirrors the inline
|
||||
// `&|t| aura_std::std_vocabulary(t)` closure the rest of this module uses).
|
||||
// `&|t| aura_vocabulary::std_vocabulary(t)` closure the rest of this module uses).
|
||||
fn fixture_resolver() -> impl Fn(&str) -> Option<PrimitiveBuilder> {
|
||||
|t: &str| aura_std::std_vocabulary(t)
|
||||
|t: &str| aura_vocabulary::std_vocabulary(t)
|
||||
}
|
||||
|
||||
// Shared topology: two open SMAs feeding a `Sub`. `ganged_fixture_named`
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
|
||||
use aura_core::{NodeSchema, ScalarKind};
|
||||
|
||||
use crate::blueprint::{BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role};
|
||||
use crate::blueprint::{
|
||||
BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role, Tap, TapWire,
|
||||
};
|
||||
use crate::harness::{Edge, Target};
|
||||
|
||||
/// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the
|
||||
@@ -92,6 +94,7 @@ pub struct GraphBuilder {
|
||||
edges: Vec<(OutPort, InPort)>,
|
||||
roles: Vec<(String, Option<ScalarKind>, Vec<InPort>)>,
|
||||
out: Vec<(String, OutPort)>,
|
||||
taps: Vec<(String, OutPort)>,
|
||||
gangs: Vec<(String, Vec<ParamRef>)>,
|
||||
}
|
||||
|
||||
@@ -106,6 +109,7 @@ impl GraphBuilder {
|
||||
edges: Vec::new(),
|
||||
roles: Vec::new(),
|
||||
out: Vec::new(),
|
||||
taps: Vec::new(),
|
||||
gangs: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -149,6 +153,15 @@ impl GraphBuilder {
|
||||
self.out.push((name.to_string(), from));
|
||||
}
|
||||
|
||||
/// Declare a measurement tap on a producer's output field under `name`
|
||||
/// (resolved at build) — the output-side twin of [`GraphBuilder::expose`]: a
|
||||
/// recorded observation point (a `Composite.taps` entry, #284/C27), not a
|
||||
/// boundary output. The builder-surface counterpart of the op-script `tap` op,
|
||||
/// so a Rust-authored graph reaches taps the same way an op-script does (C24).
|
||||
pub fn tap(&mut self, from: OutPort, name: &str) {
|
||||
self.taps.push((name.to_string(), from));
|
||||
}
|
||||
|
||||
/// Fuse two or more sibling params into one public knob (resolved at build).
|
||||
pub fn gang(&mut self, name: &str, members: impl IntoIterator<Item = ParamRef>) {
|
||||
self.gangs.push((name.to_string(), members.into_iter().collect()));
|
||||
@@ -185,6 +198,12 @@ impl GraphBuilder {
|
||||
output.push(OutField { node: from.node, field, name: name.clone() });
|
||||
}
|
||||
|
||||
let mut taps = Vec::with_capacity(self.taps.len());
|
||||
for (name, from) in &self.taps {
|
||||
let field = self.resolve_field(from)?;
|
||||
taps.push(Tap { name: name.clone(), from: TapWire { node: from.node, field } });
|
||||
}
|
||||
|
||||
let mut gangs = Vec::with_capacity(self.gangs.len());
|
||||
for (name, refs) in &self.gangs {
|
||||
let mut members = Vec::with_capacity(refs.len());
|
||||
@@ -230,7 +249,7 @@ impl GraphBuilder {
|
||||
let kind = kind.unwrap_or(ScalarKind::F64);
|
||||
gangs.push(Gang { name: name.clone(), kind, members });
|
||||
}
|
||||
let mut composite = Composite::new(self.name, self.nodes, edges, roles, output);
|
||||
let mut composite = Composite::new(self.name, self.nodes, edges, roles, output).with_taps(taps);
|
||||
if let Some(doc) = self.doc {
|
||||
composite = composite.with_doc(doc);
|
||||
}
|
||||
@@ -325,7 +344,9 @@ mod tests {
|
||||
use crate::test_fixtures::sma_cross as hand_sma_cross;
|
||||
use crate::{Composite, OutField, Role, Target};
|
||||
use aura_core::{Firing, ScalarKind};
|
||||
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
|
||||
use aura_backtest::SimBroker;
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// The `sma_cross` sub-composite authored through the builder.
|
||||
|
||||
@@ -14,7 +14,7 @@ use aura_core::{BindOpError, NodeSchema, PrimitiveBuilder, Scalar, ScalarKind};
|
||||
|
||||
use crate::blueprint::{
|
||||
check_param_namespace_injective, check_root_roles_bound, edge_kind_check, validate_wiring,
|
||||
BlueprintNode, Composite, Gang, GangMember, OutField, Role,
|
||||
BlueprintNode, Composite, Gang, GangMember, OutField, Role, Tap, TapWire,
|
||||
};
|
||||
use crate::builder::{resolve_input_slot, resolve_output_field, PortResolveError};
|
||||
use crate::harness::{Edge, Target};
|
||||
@@ -37,8 +37,15 @@ pub enum Op {
|
||||
Connect { from: String, to: String },
|
||||
/// Re-export `"node.field"` at the boundary under `as_name`.
|
||||
Expose { from: String, as_name: String },
|
||||
/// Declare a measurement tap on `"node.field"` under `as_name` (#284) — the
|
||||
/// output-side twin of `Expose` (a `Tap`, not an `OutField`).
|
||||
Tap { from: String, as_name: String },
|
||||
/// Fuse two or more sibling params into one public knob (#61).
|
||||
Gang { as_name: String, into: Vec<String> },
|
||||
/// Declare the composite's one-line meaning (C29, #316) — the op-script
|
||||
/// twin of the builder's `.doc(...)`. At most one per script; a second
|
||||
/// is a fault (a declarative script carries no last-wins ambiguity).
|
||||
Doc { text: String },
|
||||
}
|
||||
|
||||
/// A per-op construction fault, by-identifier so the cause names the op.
|
||||
@@ -51,6 +58,11 @@ pub enum OpError {
|
||||
/// distinct fault class from an interior node-id clash, so callers can tell the
|
||||
/// two apart from the error value alone.
|
||||
DuplicateOutput(String),
|
||||
/// A tap-name collision (the separate `tap_names` namespace) — two taps named
|
||||
/// alike would collide as recorded series. A distinct fault class from an
|
||||
/// interior id (`DuplicateIdentifier`) or a boundary-output clash
|
||||
/// (`DuplicateOutput`).
|
||||
DuplicateTap(String),
|
||||
DuplicateRole(String),
|
||||
UnknownIdentifier(String),
|
||||
UnknownRole(String),
|
||||
@@ -84,6 +96,8 @@ pub enum OpError {
|
||||
/// A holistic finalize fault (totality / injectivity / unbound root role),
|
||||
/// wrapping the unchanged engine gate's `CompileError`.
|
||||
Incomplete(CompileError),
|
||||
/// A second `doc` op — the meaning line is declared at most once.
|
||||
DuplicateDoc,
|
||||
}
|
||||
|
||||
/// A per-op-fallible blueprint accumulator. Holds the same interior data a
|
||||
@@ -101,8 +115,11 @@ pub struct GraphSession<'v> {
|
||||
role_names: HashMap<String, usize>,
|
||||
out: Vec<OutField>,
|
||||
out_names: HashSet<String>,
|
||||
taps: Vec<Tap>,
|
||||
tap_names: HashSet<String>,
|
||||
coverage: HashMap<(usize, usize), usize>,
|
||||
gangs: Vec<Gang>,
|
||||
doc: Option<String>,
|
||||
}
|
||||
|
||||
impl<'v> GraphSession<'v> {
|
||||
@@ -121,8 +138,11 @@ impl<'v> GraphSession<'v> {
|
||||
role_names: HashMap::new(),
|
||||
out: Vec::new(),
|
||||
out_names: HashSet::new(),
|
||||
taps: Vec::new(),
|
||||
tap_names: HashSet::new(),
|
||||
coverage: HashMap::new(),
|
||||
gangs: Vec::new(),
|
||||
doc: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +156,15 @@ impl<'v> GraphSession<'v> {
|
||||
Op::Feed { role, into } => self.feed(role, into),
|
||||
Op::Connect { from, to } => self.connect(from, to),
|
||||
Op::Expose { from, as_name } => self.expose(from, as_name),
|
||||
Op::Tap { from, as_name } => self.tap(from, as_name),
|
||||
Op::Gang { as_name, into } => self.gang(as_name, into),
|
||||
Op::Doc { text } => {
|
||||
if self.doc.is_some() {
|
||||
return Err(OpError::DuplicateDoc);
|
||||
}
|
||||
self.doc = Some(text);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +332,25 @@ impl<'v> GraphSession<'v> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Declare a measurement tap on a resolved interior output wire — the
|
||||
/// output-side twin of `expose`. Node resolution (`node_index` →
|
||||
/// `UnknownIdentifier`), the output-field resolver, and the name-dedup are the
|
||||
/// same as `expose`'s; only the appended item differs: a `Tap` on the separate
|
||||
/// `taps`/`tap_names` namespace (dedup → `DuplicateTap`), not an `OutField`.
|
||||
fn tap(&mut self, from: String, as_name: String) -> Result<(), OpError> {
|
||||
let (from_node, from_field_name) = Self::split_port(&from)?;
|
||||
let fi = self.node_index(&from_node)?;
|
||||
let field = resolve_output_field(&self.schemas[fi], &from_field_name).map_err(|e| match e {
|
||||
PortResolveError::Unknown => OpError::UnknownOutPort { node: from_node.clone(), name: from_field_name.clone() },
|
||||
PortResolveError::Ambiguous => OpError::AmbiguousOutPort { node: from_node.clone(), name: from_field_name.clone() },
|
||||
})?;
|
||||
if !self.tap_names.insert(as_name.clone()) {
|
||||
return Err(OpError::DuplicateTap(as_name));
|
||||
}
|
||||
self.taps.push(Tap { name: as_name, from: TapWire { node: fi, field } });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Eager arm of the gang gate: resolve every member against the CURRENT
|
||||
/// (post-bind) open param lists — a member bound at `Add` has left the open
|
||||
/// schema and correctly fails as `UnknownParam`, mirroring `try_bind` — enforce
|
||||
@@ -392,14 +439,16 @@ impl<'v> GraphSession<'v> {
|
||||
.into_iter()
|
||||
.map(|(name, source, targets)| Role { name, targets, source })
|
||||
.collect();
|
||||
// No doc is attached here: the op-script vocabulary deliberately has no
|
||||
// doc-carrying surface yet (#125 scope cut) — an op-built composite
|
||||
// carries no authored rationale until that vocabulary grows a slot.
|
||||
let c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
|
||||
// The doc op (C29, #316) threads through `Composite::with_doc`.
|
||||
let mut c = Composite::new(self.name, self.nodes, self.edges, roles, self.out)
|
||||
.with_taps(self.taps)
|
||||
.with_gangs(self.gangs)
|
||||
.map_err(OpError::Incomplete)?;
|
||||
if let Some(t) = self.doc {
|
||||
c = c.with_doc(t);
|
||||
}
|
||||
check_param_namespace_injective(&c.param_space()).map_err(OpError::Incomplete)?;
|
||||
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output()).map_err(|e| match e {
|
||||
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output(), c.taps()).map_err(|e| match e {
|
||||
CompileError::UnconnectedPort { node, slot } => OpError::UnconnectedPort {
|
||||
node: self.ids[node].clone(),
|
||||
slot: self.schemas[node].inputs[slot].name.clone(),
|
||||
@@ -439,7 +488,7 @@ pub fn replay(
|
||||
mod tests {
|
||||
use super::{GraphSession, Op, OpError};
|
||||
use aura_core::{BindOpError, Scalar, ScalarKind};
|
||||
use aura_std::std_vocabulary;
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
|
||||
fn session(name: &str) -> GraphSession<'static> {
|
||||
GraphSession::new(name, &std_vocabulary)
|
||||
@@ -639,6 +688,77 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// `tap` names an interior output wire by `"node.field"` (the output-side twin
|
||||
/// of `expose`), appending a resolved `Tap` to the composite — the
|
||||
/// name-addressed wire resolves to the same interior `{node, field}` a
|
||||
/// raw-index tap would carry (`fast` is session node 0, its `value` field 0).
|
||||
#[test]
|
||||
fn tap_op_resolves_and_appends() {
|
||||
use crate::blueprint::{Tap, TapWire};
|
||||
let mut s = scaffold();
|
||||
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] }).unwrap();
|
||||
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
|
||||
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
|
||||
s.apply(Op::Expose { from: "sub.value".into(), as_name: "bias".into() }).unwrap();
|
||||
assert_eq!(s.apply(Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() }), Ok(()));
|
||||
let c = s.finish().expect("finishes");
|
||||
assert_eq!(c.taps(), &[Tap { name: "fast_ma".into(), from: TapWire { node: 0, field: 0 } }]);
|
||||
}
|
||||
|
||||
/// `tap` reuses `expose`'s resolvers: a bad output field maps to the
|
||||
/// by-identifier `UnknownOutPort`; an unknown source node to `UnknownIdentifier`
|
||||
/// (via `node_index`), never the nonexistent `UnknownNode`.
|
||||
#[test]
|
||||
fn tap_op_bad_out_port_and_ghost_node_rejected() {
|
||||
let mut s = scaffold();
|
||||
assert_eq!(
|
||||
s.apply(Op::Tap { from: "fast.nope".into(), as_name: "t".into() }),
|
||||
Err(OpError::UnknownOutPort { node: "fast".into(), name: "nope".into() })
|
||||
);
|
||||
assert_eq!(
|
||||
s.apply(Op::Tap { from: "ghost.value".into(), as_name: "t".into() }),
|
||||
Err(OpError::UnknownIdentifier("ghost".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// A second tap under the same boundary name is a `DuplicateTap` — the twin of
|
||||
/// `expose`'s `DuplicateOutput`, over the separate `tap_names` namespace.
|
||||
#[test]
|
||||
fn tap_op_duplicate_name_rejected() {
|
||||
let mut s = scaffold();
|
||||
assert_eq!(s.apply(Op::Tap { from: "fast.value".into(), as_name: "t".into() }), Ok(()));
|
||||
assert_eq!(
|
||||
s.apply(Op::Tap { from: "slow.value".into(), as_name: "t".into() }),
|
||||
Err(OpError::DuplicateTap("t".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// `finish()` threads op-declared taps into `Composite.taps`, and the #282
|
||||
/// compile path hoists them into `FlatGraph.taps` — proving the `.with_taps`
|
||||
/// thread this op adds reaches the flat graph (guards the latent drop `finish`
|
||||
/// had: it built `.with_gangs` but never `.with_taps`). `fast` lowers to flat
|
||||
/// node 0, its `value` field 0.
|
||||
#[test]
|
||||
fn tap_op_threads_into_flat_graph() {
|
||||
use crate::harness::FlatTap;
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: None, bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||
Op::Tap { from: "fast.value".into(), as_name: "fast_ma".into() },
|
||||
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
||||
];
|
||||
let flat = super::replay("g", ops, &aura_vocabulary::std_vocabulary)
|
||||
.expect("replays")
|
||||
.compile_with_params(&[Scalar::i64(2), Scalar::i64(4)])
|
||||
.expect("compiles");
|
||||
assert_eq!(flat.taps, vec![FlatTap { name: "fast_ma".into(), node: 0, field: 0 }]);
|
||||
}
|
||||
|
||||
/// The gang op fuses two sibling params into one public knob; the replayed
|
||||
/// composite's `param_space()` carries exactly the gang address.
|
||||
#[test]
|
||||
@@ -659,6 +779,51 @@ mod tests {
|
||||
assert_eq!(names, ["length"]);
|
||||
}
|
||||
|
||||
/// #261 — the Frankfurt `Session` preset is reachable from a blueprint. The
|
||||
/// property this pins: a data-only op-script (a blueprint) that names the
|
||||
/// preset's closed-roster type-id resolves it through `std_vocabulary`,
|
||||
/// carries its one scalar `period_minutes` knob and the `bars_since_open:
|
||||
/// i64` session-index output, wires its `trigger` input through the ordinary
|
||||
/// edge convention, and builds to a finished `Composite`. Reachability is the
|
||||
/// whole ask — the node's tz-aware/DST session SEMANTICS are already pinned
|
||||
/// in `aura-std/src/session.rs` and are not re-pinned here. Today the preset
|
||||
/// id is absent from the roster (the base `Session` builder takes structural
|
||||
/// construction args, so the zero-arg macro cannot host it), so the lookup
|
||||
/// returns `None` and the op-script's `Add` would fail `UnknownNodeType`.
|
||||
#[test]
|
||||
fn session_frankfurt_preset_is_reachable_from_a_blueprint() {
|
||||
// (a) the preset resolves through the closed roster as the Session preset
|
||||
// — its one scalar knob and its i64 session-index output, not some
|
||||
// other node parked under the id.
|
||||
let preset = std_vocabulary("SessionFrankfurt")
|
||||
.expect("the Frankfurt Session preset is in the std vocabulary roster");
|
||||
assert_eq!(preset.label(), "SessionFrankfurt");
|
||||
let schema = preset.schema();
|
||||
assert_eq!(schema.params.len(), 1, "exactly one scalar knob");
|
||||
assert_eq!(schema.params[0].name, "period_minutes");
|
||||
assert_eq!(schema.params[0].kind, ScalarKind::I64);
|
||||
assert_eq!(schema.inputs.len(), 1);
|
||||
assert_eq!(schema.inputs[0].name, "trigger");
|
||||
assert_eq!(schema.output[0].name, "bars_since_open");
|
||||
assert_eq!(schema.output[0].kind, ScalarKind::I64);
|
||||
|
||||
// (b) an op-script naming that id builds: a bound trigger source feeds the
|
||||
// preset's `trigger`, its `period_minutes` knob binds, and the i64 bar
|
||||
// index exposes — a data-only blueprint reaching the session stream.
|
||||
let ops = vec![
|
||||
Op::Source { role: "trigger".into(), kind: ScalarKind::F64 },
|
||||
Op::Add {
|
||||
type_id: "SessionFrankfurt".into(),
|
||||
as_name: Some("session".into()),
|
||||
bind: vec![("period_minutes".into(), Scalar::i64(15))],
|
||||
},
|
||||
Op::Feed { role: "trigger".into(), into: vec!["session.trigger".into()] },
|
||||
Op::Expose { from: "session.bars_since_open".into(), as_name: "bars".into() },
|
||||
];
|
||||
super::replay("session_blueprint", ops, &std_vocabulary)
|
||||
.expect("the preset resolves, wires and builds through an op-script");
|
||||
}
|
||||
|
||||
/// Eager refusals: unknown member param (a member bound at `Add` has left the
|
||||
/// open schema), kind mismatch across members, a doubly-ganged member, a
|
||||
/// single-member gang, and an unknown node identifier.
|
||||
@@ -717,6 +882,32 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// C29 (#316): the doc op sets the composite's meaning line — the
|
||||
/// op-script twin of the builder's .doc(...).
|
||||
#[test]
|
||||
fn doc_op_sets_the_composite_doc() {
|
||||
let mut s = scaffold();
|
||||
s.apply(Op::Doc { text: "a described op-built composite".into() }).unwrap();
|
||||
s.apply(Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] })
|
||||
.unwrap();
|
||||
s.apply(Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() }).unwrap();
|
||||
s.apply(Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() }).unwrap();
|
||||
s.apply(Op::Expose { from: "sub.value".into(), as_name: "out".into() }).unwrap();
|
||||
let c = s.finish().expect("finishes");
|
||||
assert_eq!(c.doc(), Some("a described op-built composite"));
|
||||
}
|
||||
|
||||
/// A second doc op is a fault, not a silent overwrite.
|
||||
#[test]
|
||||
fn duplicate_doc_op_is_refused() {
|
||||
let mut s = scaffold();
|
||||
s.apply(Op::Doc { text: "first".into() }).unwrap();
|
||||
assert_eq!(
|
||||
s.apply(Op::Doc { text: "second".into() }),
|
||||
Err(OpError::DuplicateDoc)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finish_reports_incomplete_on_unwired_slot() {
|
||||
// a fully-named scaffold missing the sub.rhs connection -> holistic 0-arm
|
||||
@@ -800,7 +991,7 @@ mod tests {
|
||||
let finalize = ops.len();
|
||||
// `.err()` not `.unwrap_err()`: the Ok arm `Composite` holds build closures
|
||||
// and is not `Debug`.
|
||||
let (idx, _err) = super::replay("g", ops, &aura_std::std_vocabulary)
|
||||
let (idx, _err) = super::replay("g", ops, &aura_vocabulary::std_vocabulary)
|
||||
.err()
|
||||
.expect("a cyclic op-list must be rejected (DAG invariant 5 / C9)");
|
||||
// An eager realisation attributes the fault to the closing connect op; a
|
||||
@@ -827,7 +1018,7 @@ mod tests {
|
||||
Op::Expose { from: "s.value".into(), as_name: "out".into() },
|
||||
];
|
||||
assert!(
|
||||
super::replay("g", ops, &aura_std::std_vocabulary).is_err(),
|
||||
super::replay("g", ops, &aura_vocabulary::std_vocabulary).is_err(),
|
||||
"a self-loop op-list must be rejected (DAG invariant 5 / C9)"
|
||||
);
|
||||
}
|
||||
@@ -841,7 +1032,7 @@ mod tests {
|
||||
];
|
||||
// `.err().unwrap()` rather than `.unwrap_err()`: the Ok arm `Composite` is
|
||||
// not `Debug` (it holds build closures), which `unwrap_err` would require.
|
||||
let err = super::replay("g", ops, &aura_std::std_vocabulary).err().unwrap();
|
||||
let err = super::replay("g", ops, &aura_vocabulary::std_vocabulary).err().unwrap();
|
||||
assert_eq!(err, (1, OpError::UnknownNodeType("Nope".into())));
|
||||
}
|
||||
|
||||
@@ -855,7 +1046,8 @@ mod tests {
|
||||
fn replay_built_signal_compiles_identically_to_graphbuilder() {
|
||||
use crate::GraphBuilder;
|
||||
use aura_core::Scalar;
|
||||
use aura_std::{Bias, Sma, Sub};
|
||||
use aura_std::{Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
|
||||
// param space of the flat root: fast.length(i64), slow.length(i64),
|
||||
// bias.scale(f64) — same vector applied to both sides, so it cancels.
|
||||
@@ -874,7 +1066,7 @@ mod tests {
|
||||
Op::Connect { from: "sub.value".into(), to: "bias.signal".into() },
|
||||
Op::Expose { from: "bias.bias".into(), as_name: "bias".into() },
|
||||
];
|
||||
let replay_flat = super::replay("root", ops, &aura_std::std_vocabulary)
|
||||
let replay_flat = super::replay("root", ops, &aura_vocabulary::std_vocabulary)
|
||||
.expect("replay resolves")
|
||||
.compile_with_params(¶ms)
|
||||
.expect("replay compiles");
|
||||
@@ -947,4 +1139,56 @@ mod tests {
|
||||
assert_eq!(replay_flat.edges, built_flat.edges);
|
||||
assert_eq!(replay_flat.sources, built_flat.sources);
|
||||
}
|
||||
|
||||
/// C24 lockstep extended to taps (#284): the op-script's `Op::Tap` replay and
|
||||
/// the `GraphBuilder::tap` twin serialize to byte-identical blueprint JSON and
|
||||
/// compile to byte-identical `FlatGraph.taps` — the tap op is the by-identifier
|
||||
/// face of the same construction semantics `GraphBuilder::build` runs, not a
|
||||
/// second one. Guards the builder/op-script tap symmetry (both surfaces can
|
||||
/// declare a tap, and they resolve it identically).
|
||||
#[test]
|
||||
fn tap_replay_matches_builder() {
|
||||
use crate::blueprint_serde::blueprint_to_json;
|
||||
use crate::GraphBuilder;
|
||||
use aura_core::Scalar;
|
||||
use aura_std::{Sma, Sub};
|
||||
|
||||
// (a) op-script: an SMA crossover with a tap on the raw spread (sub.value).
|
||||
let ops = vec![
|
||||
Op::Source { role: "price".into(), kind: ScalarKind::F64 },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("fast".into()), bind: vec![] },
|
||||
Op::Add { type_id: "SMA".into(), as_name: Some("slow".into()), bind: vec![] },
|
||||
Op::Add { type_id: "Sub".into(), as_name: Some("sub".into()), bind: vec![] },
|
||||
Op::Feed { role: "price".into(), into: vec!["fast.series".into(), "slow.series".into()] },
|
||||
Op::Connect { from: "fast.value".into(), to: "sub.lhs".into() },
|
||||
Op::Connect { from: "slow.value".into(), to: "sub.rhs".into() },
|
||||
Op::Tap { from: "sub.value".into(), as_name: "spread".into() },
|
||||
Op::Expose { from: "sub.value".into(), as_name: "bias".into() },
|
||||
];
|
||||
let replayed = super::replay("g", ops, &std_vocabulary).expect("replays");
|
||||
|
||||
// (b) the GraphBuilder twin — same adds/feeds/connects + g.tap(...) / g.expose(...).
|
||||
let mut g = GraphBuilder::new("g");
|
||||
let fast = g.add(Sma::builder().named("fast"));
|
||||
let slow = g.add(Sma::builder().named("slow"));
|
||||
let sub = g.add(Sub::builder().named("sub"));
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(price, [fast.input("series"), slow.input("series")]);
|
||||
g.connect(fast.output("value"), sub.input("lhs"));
|
||||
g.connect(slow.output("value"), sub.input("rhs"));
|
||||
g.tap(sub.output("value"), "spread");
|
||||
g.expose(sub.output("value"), "bias");
|
||||
let built = g.build().expect("root resolves");
|
||||
|
||||
assert_eq!(
|
||||
blueprint_to_json(&replayed).expect("replay serializes"),
|
||||
blueprint_to_json(&built).expect("builder serializes"),
|
||||
);
|
||||
|
||||
let params = [Scalar::i64(2), Scalar::i64(4)];
|
||||
let replay_flat = replayed.compile_with_params(¶ms).expect("replay compiles");
|
||||
let built_flat = built.compile_with_params(¶ms).expect("builder compiles");
|
||||
assert_eq!(replay_flat.taps, built_flat.taps);
|
||||
assert_eq!(replay_flat.edges, built_flat.edges);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +321,8 @@ mod tests {
|
||||
use aura_core::{
|
||||
Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
|
||||
};
|
||||
use aura_std::{Bias, Recorder, Sma, Sub};
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// A bare node whose only purpose is to back a `PrimitiveBuilder` in a test.
|
||||
@@ -347,6 +348,7 @@ mod tests {
|
||||
],
|
||||
output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
@@ -461,6 +463,7 @@ mod tests {
|
||||
FieldSpec { name: "lo".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
|
||||
+703
-109
File diff suppressed because it is too large
Load Diff
@@ -13,28 +13,27 @@
|
||||
//! - [`BootstrapError`] — wiring faults caught once, at bootstrap (kind
|
||||
//! mismatch, bad index, directed cycle).
|
||||
//!
|
||||
//! Delivered in cycle 0009 — the run report surface:
|
||||
//! Delivered in cycle 0009, generic since #292 — the run-record surface:
|
||||
//!
|
||||
//! - [`RunMetrics`] / [`RunManifest`] / [`RunReport`] — the `(manifest, metrics)`
|
||||
//! pair C18 mandates per run, with [`RunReport::to_json`] for the structured
|
||||
//! C14 face;
|
||||
//! - [`summarize`] — the post-run pure reduction over a run's recorded
|
||||
//! pip-equity + exposure streams into [`RunMetrics`]; [`f64_field`] bridges a
|
||||
//! recording sink's `Vec<Scalar>` rows to it.
|
||||
//! - [`RunReport`] / [`RunManifest`] — the `(manifest, metrics)` pair C18
|
||||
//! mandates per run. [`RunReport`] is generic over its metric payload `M`:
|
||||
//! the engine names no concrete metric type; the backtest layer
|
||||
//! (`aura-backtest`) instantiates it and hosts the pip/R reductions. C14 face
|
||||
//! via [`RunReport::to_json`]; [`f64_field`] bridges a recording sink's
|
||||
//! `Vec<Scalar>` rows into the `(ts, f64)` streams a metric reduction folds.
|
||||
//!
|
||||
//! Orchestration families of the atomic sim unit
|
||||
//! (`(topology + params + data-window + seed)`) ship along three of C12's axes: the
|
||||
//! **grid** axis via [`sweep`] over a [`GridSpace`] into a [`SweepFamily`], the
|
||||
//! **window** axis via [`walk_forward`] over a [`WindowRoller`] into a
|
||||
//! [`WalkForwardResult`] (C12 axis 3: rolling in-sample/out-of-sample splits, the
|
||||
//! in-sample optimize closure-supplied), and
|
||||
//! the **seed** axis via [`monte_carlo`] over a seed set into an [`McFamily`]
|
||||
//! (C12 axis 4: Monte-Carlo *is* a sweep over seeds; both drive one shared
|
||||
//! disjoint-parallel executor). The **seed** input itself ships too:
|
||||
//! [`SyntheticSpec`] is a seeded `Source` producer (`Fn(u64) -> impl Source`,
|
||||
//! C12 seed-as-input) whose stream is fully seed-determined, making
|
||||
//! [`RunManifest`]'s seed a live captured input; the atomic unit's `-> metrics`
|
||||
//! reduction ships via [`summarize`].
|
||||
//! (`(topology + params + data-window + seed)`) ship along C12's axes: the
|
||||
//! **grid** axis via [`sweep`] over a [`GridSpace`] into a [`SweepFamily`], and
|
||||
//! the **window** axis via [`walk_forward`] over a [`WindowRoller`] into a
|
||||
//! [`WalkForwardResult`] (C12 axis 3: rolling in-sample/out-of-sample splits,
|
||||
//! the in-sample optimize closure-supplied). The **seed** axis (C12 axis 4:
|
||||
//! Monte-Carlo *is* a sweep over seeds) is assembled one rung out in
|
||||
//! `aura-backtest`, over the same shared disjoint-parallel executor this crate
|
||||
//! provides. The **seed** input itself ships here: [`SyntheticSpec`] is a
|
||||
//! seeded `Source` producer (`Fn(u64) -> impl Source`, C12 seed-as-input) whose
|
||||
//! stream is fully seed-determined, making [`RunManifest`]'s seed a live
|
||||
//! captured input.
|
||||
//!
|
||||
//! Still to come (subsequent cycles): the broker-independent position-event
|
||||
//! output and downstream broker nodes (C10), the random param-sweep
|
||||
@@ -48,14 +47,13 @@ mod builder;
|
||||
mod construction;
|
||||
mod graph_model;
|
||||
mod harness;
|
||||
mod mc;
|
||||
mod report;
|
||||
mod sweep;
|
||||
mod walkforward;
|
||||
|
||||
pub use blueprint::{
|
||||
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
|
||||
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder,
|
||||
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder, Tap, TapWire,
|
||||
};
|
||||
pub use blueprint_serde::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
|
||||
@@ -65,23 +63,23 @@ pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHan
|
||||
pub use construction::{replay, GraphSession, Op, OpError};
|
||||
pub use graph_model::model_to_json;
|
||||
pub use harness::{
|
||||
window_of, BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SplitMix64,
|
||||
SyntheticSpec, Target, VecSource,
|
||||
window_of, bind_sources, BootstrapError, Edge, FlatGraph, FlatTap, Harness, Source, SourceBindError,
|
||||
SourceSpec, SplitMix64, SyntheticSpec, TapBindError, Target, VecSource,
|
||||
};
|
||||
pub use report::{
|
||||
derive_position_events, expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,
|
||||
r_metrics_from_rs, summarize, summarize_r, ColumnarTrace, FamilySelection, JoinedRow,
|
||||
PositionAction, PositionEvent, ProjectProvenance, RMetrics, RunManifest, RunMetrics,
|
||||
expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,
|
||||
ColumnarTrace, FamilySelection, JoinedRow,
|
||||
MeasurementReport, ProjectProvenance, RunManifest,
|
||||
RunReport, SelectionMode,
|
||||
};
|
||||
pub use sweep::{
|
||||
sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily,
|
||||
SweepPoint,
|
||||
};
|
||||
pub use mc::{
|
||||
monte_carlo, r_bootstrap, resample_block, McAggregate, McDraw, McFamily, MetricStats,
|
||||
RBootstrap,
|
||||
run_indexed, sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError,
|
||||
SweepFamily, SweepPoint,
|
||||
};
|
||||
// Foundation-grade aura-analysis items, re-exported so ladder rungs without a
|
||||
// direct aura-analysis edge (e.g. aura-backtest) reach them through the engine
|
||||
// (#291; the metric-vocabulary seam rides the same path, #147).
|
||||
pub use aura_analysis::{one_sided_p_laplace, resample_block, MetricStats, MetricVocabulary};
|
||||
pub use walkforward::{
|
||||
param_stability, walk_forward, RollMode, WalkForwardError, WalkForwardResult,
|
||||
WindowBounds, WindowOutcome, WindowRoller, WindowRun,
|
||||
|
||||
@@ -8,19 +8,17 @@
|
||||
//! cycle 0029) — the same encoder the run registry uses, so a record's stdout
|
||||
//! and on-disk shapes coincide.
|
||||
|
||||
use aura_core::{Scalar, ScalarKind, SeriesFold, Timestamp};
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// The pure trading-domain reductions (R-metrics, the position-event table, the
|
||||
// multiple-comparison hurdle math) live in `aura-analysis` (issue #136); they are
|
||||
// re-imported here so `report::`'s namespace — and therefore `aura-engine`'s
|
||||
// `lib.rs` re-export and every `crate::report::X` reference — resolves unchanged
|
||||
// (C18 byte-identity). `summarize` (below) bridges trace columns into these types
|
||||
// at the engine boundary, so it stays here.
|
||||
pub use aura_analysis::{
|
||||
derive_position_events, expected_max_of_normals, inv_norm_cdf, r_metrics_from_rs, summarize_r,
|
||||
FamilySelection, PositionAction, PositionEvent, RMetrics, RunMetrics, SelectionMode,
|
||||
};
|
||||
// The backtest reductions (R-metrics, the position-event table, `summarize`)
|
||||
// moved to `aura-backtest::metrics` (C28 phase 2, #291/#292 — the engine's
|
||||
// production edge to aura-backtest is cut, D4); the domain-free hurdle math +
|
||||
// selection provenance stays re-imported here from `aura-analysis` so
|
||||
// `report::`'s namespace — and therefore `aura-engine`'s `lib.rs` re-export and
|
||||
// every `crate::report::X` reference — resolves unchanged for those names
|
||||
// (C18 byte-identity).
|
||||
pub use aura_analysis::{expected_max_of_normals, inv_norm_cdf, FamilySelection, SelectionMode};
|
||||
|
||||
/// The reproducible run descriptor (C18). **Caller-supplied**: the engine
|
||||
/// cannot introspect a git commit, an RNG seed, or a broker label — the World
|
||||
@@ -34,6 +32,16 @@ pub struct RunManifest {
|
||||
/// self-describing [`Scalar`], so the param's kind (an `i64` length vs an
|
||||
/// `f64` scale) survives into the record instead of collapsing to `f64`.
|
||||
pub params: Vec<(String, Scalar)>,
|
||||
/// The bound params this run did NOT vary — the wrap-prefixed
|
||||
/// `bound_param_space()` of the signal, EXCLUDING any bound param an axis
|
||||
/// reopened (those flow through `params` instead, #246). Complements
|
||||
/// `params` ("what varied") with "what was held": the two are disjoint by
|
||||
/// construction. Empty for every pre-#249 record. One-directional serde
|
||||
/// widening (C14/C18), identical idiom to `selection`/`instrument`/
|
||||
/// `topology_hash` — except a `Vec`, not an `Option`, so it always
|
||||
/// serializes (mirroring `params`), never skipped when empty.
|
||||
#[serde(default)]
|
||||
pub defaults: Vec<(String, Scalar)>,
|
||||
/// The data-window: inclusive `(from, to)` epoch-ns bounds (C12).
|
||||
pub window: (Timestamp, Timestamp),
|
||||
/// The RNG seed (C12 seed-as-input). `0` for a seed-free synthetic run.
|
||||
@@ -80,16 +88,18 @@ pub struct ProjectProvenance {
|
||||
pub commit: Option<String>,
|
||||
}
|
||||
|
||||
/// A run's full structured result: the descriptor plus the metrics it
|
||||
/// reproduces. The durable run record of C18 ("stores manifests + metrics,
|
||||
/// re-derives full results on demand").
|
||||
/// A run's full structured result: the descriptor plus the metric payload `M`
|
||||
/// it reproduces. Metric-agnostic (C28): the engine never names a concrete
|
||||
/// metric type; the backtest layer instantiates `M` (see `aura-backtest`). The
|
||||
/// durable run record of C18 ("stores manifests + metrics, re-derives full
|
||||
/// results on demand").
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RunReport {
|
||||
pub struct RunReport<M> {
|
||||
pub manifest: RunManifest,
|
||||
pub metrics: RunMetrics,
|
||||
pub metrics: M,
|
||||
}
|
||||
|
||||
impl RunReport {
|
||||
impl<M: serde::Serialize> RunReport<M> {
|
||||
/// Render the canonical, machine-readable JSON (C14) via serde — the same
|
||||
/// encoder the run registry uses on disk, so a record's stdout shape and its
|
||||
/// `runs.jsonl` shape are byte-identical. `params` is an array of
|
||||
@@ -101,35 +111,26 @@ impl RunReport {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reduce a run's recorded pip-equity + exposure streams into summary metrics.
|
||||
/// Pure — identical inputs yield identical metrics (C1/C12). Timestamps are
|
||||
/// carried in the input to match exactly what a sink records; the reduction
|
||||
/// itself is value-only (it does not read the timestamps).
|
||||
pub fn summarize(
|
||||
equity: &[(Timestamp, f64)],
|
||||
exposure: &[(Timestamp, f64)],
|
||||
) -> RunMetrics {
|
||||
// total_pips + max_drawdown fold over equity; sign_flips folds over exposure.
|
||||
// SeriesFold is the single arithmetic source of truth (shared with the
|
||||
// SeriesReducer sink), so this is byte-identical to the prior inline loops.
|
||||
let mut eqf = SeriesFold::new();
|
||||
for &(_, v) in equity {
|
||||
eqf.fold(v);
|
||||
}
|
||||
let mut exf = SeriesFold::new();
|
||||
for &(_, v) in exposure {
|
||||
exf.fold(v);
|
||||
}
|
||||
RunMetrics {
|
||||
total_pips: eqf.last,
|
||||
max_drawdown: eqf.max_drawdown,
|
||||
bias_sign_flips: exf.sign_flips,
|
||||
r: None,
|
||||
/// A measurement run's stdout/record shape (C28 phase 3): the run descriptor plus
|
||||
/// the declared tap names, mirroring the persisted `index.json`. A measurement run
|
||||
/// has no broker → no equity/exposure → NO R `metrics` (the difference from
|
||||
/// [`RunReport`]). The tapped series themselves persist through the trace store.
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct MeasurementReport {
|
||||
pub manifest: RunManifest,
|
||||
pub taps: Vec<String>,
|
||||
}
|
||||
|
||||
impl MeasurementReport {
|
||||
/// Canonical machine-readable JSON via serde, same encoder as [`RunReport`].
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string(self).expect("a finite MeasurementReport always serializes")
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridge a recording sink's recorded `(ts, row)` stream to [`summarize`]:
|
||||
/// extract one `f64` field of each row into `(ts, f64)` samples. Panics if a
|
||||
/// Bridge a recording sink's recorded `(ts, row)` stream to a metric reduction
|
||||
/// (e.g. `aura-backtest`'s `summarize`): extract one `f64` field of each row
|
||||
/// into `(ts, f64)` samples. Panics if a
|
||||
/// row has no such field or the field is not an `f64` scalar — a wiring bug (a
|
||||
/// sink's declared kinds are fixed at bootstrap, so a correctly-wired
|
||||
/// equity/exposure sink always yields `f64` at field 0), surfaced like the
|
||||
@@ -283,10 +284,7 @@ fn scalar_to_f64(s: Scalar) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Edge, FlatGraph, Harness, SourceSpec, Target, VecSource};
|
||||
use aura_core::{Firing, NodeSchema, PortSpec, ScalarKind};
|
||||
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc;
|
||||
use aura_backtest::RunMetrics;
|
||||
|
||||
#[test]
|
||||
fn runmanifest_without_selection_field_deserialises_to_none() {
|
||||
@@ -298,7 +296,7 @@ mod tests {
|
||||
#[test]
|
||||
fn runmanifest_without_selection_serialises_without_the_key() {
|
||||
let m = RunManifest {
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: None, instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
@@ -317,7 +315,7 @@ mod tests {
|
||||
#[test]
|
||||
fn runmanifest_instrument_round_trips_and_omits_when_none() {
|
||||
let with = RunManifest {
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: None, instrument: Some("GER40".into()),
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
@@ -328,7 +326,7 @@ mod tests {
|
||||
assert_eq!(back.instrument, Some("GER40".to_string()));
|
||||
|
||||
let without = RunManifest {
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: None, instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
@@ -370,7 +368,7 @@ mod tests {
|
||||
commit: Some("deadbeef".into()),
|
||||
};
|
||||
let m = RunManifest {
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: None, instrument: None,
|
||||
topology_hash: None,
|
||||
project: Some(p.clone()),
|
||||
@@ -393,7 +391,7 @@ mod tests {
|
||||
neighbourhood_score: None, n_neighbours: None,
|
||||
};
|
||||
let m = RunManifest {
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: Some(sel.clone()), instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
@@ -441,173 +439,6 @@ mod tests {
|
||||
assert_eq!(sel.n_neighbours, None);
|
||||
}
|
||||
|
||||
/// The declared signature of a `Recorder` over one f64 column (the sink shape
|
||||
/// the two-sink harness uses).
|
||||
fn f64_recorder_sig() -> NodeSchema {
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an f64 source stream from (timestamp, value) points (mirrors the
|
||||
/// harness.rs test helper; the e2e test needs its own copy — the harness
|
||||
/// test module's is private to that module).
|
||||
fn f64_stream(points: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
|
||||
points.iter().map(|&(t, v)| (Timestamp(t), Scalar::f64(v))).collect()
|
||||
}
|
||||
|
||||
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
|
||||
/// the SimBroker equity output (node 4 -> node 5) and one on the Bias
|
||||
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn build_two_sink_harness() -> (
|
||||
Harness,
|
||||
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 h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
Box::new(Sma::new(2)), // 0
|
||||
Box::new(Sma::new(4)), // 1
|
||||
Box::new(Sub::new()), // 2
|
||||
Box::new(Bias::new(0.5)), // 3
|
||||
Box::new(SimBroker::new(0.0001)), // 4
|
||||
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
|
||||
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
|
||||
],
|
||||
signatures: vec![
|
||||
Sma::builder().schema().clone(),
|
||||
Sma::builder().schema().clone(),
|
||||
Sub::builder().schema().clone(),
|
||||
Bias::builder().schema().clone(),
|
||||
SimBroker::builder(0.0001).schema().clone(),
|
||||
f64_recorder_sig(),
|
||||
f64_recorder_sig(),
|
||||
],
|
||||
sources: vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 },
|
||||
Target { node: 1, slot: 0 },
|
||||
Target { node: 4, slot: 1 }, // price into the broker
|
||||
],
|
||||
}],
|
||||
edges: vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
|
||||
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
|
||||
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
|
||||
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
|
||||
],
|
||||
})
|
||||
.expect("valid signal-quality DAG");
|
||||
(h, rx_eq, rx_ex)
|
||||
}
|
||||
|
||||
fn run_once() -> RunReport {
|
||||
let (mut h, rx_eq, rx_ex) = build_two_sink_harness();
|
||||
h.run(vec![Box::new(VecSource::new(f64_stream(&[
|
||||
(1, 1.0000),
|
||||
(2, 1.0010),
|
||||
(3, 1.0025),
|
||||
(4, 1.0020),
|
||||
(5, 1.0040),
|
||||
])))]);
|
||||
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||
let equity = f64_field(&eq_rows, 0);
|
||||
let exposure = f64_field(&ex_rows, 0);
|
||||
let metrics = summarize(&equity, &exposure);
|
||||
RunReport {
|
||||
manifest: RunManifest {
|
||||
commit: "test-commit".to_string(),
|
||||
params: vec![
|
||||
("sma_fast".to_string(), Scalar::i64(2)),
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("bias_scale".to_string(), Scalar::f64(0.5)),
|
||||
],
|
||||
window: (Timestamp(1), Timestamp(5)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=0.0001)".to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
},
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_is_deterministic_end_to_end() {
|
||||
let r1 = run_once();
|
||||
let r2 = run_once();
|
||||
// a run actually emitted metrics over a non-empty pip curve
|
||||
assert!(r1.metrics.total_pips.is_finite());
|
||||
// same manifest -> same metrics (C1/C12): two runs are bit-identical
|
||||
assert_eq!(r1.metrics, r2.metrics);
|
||||
assert_eq!(r1.to_json(), r2.to_json());
|
||||
}
|
||||
|
||||
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| (Timestamp(i as i64 + 1), v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_total_pips_is_last_cumulative_value() {
|
||||
let equity = samples(&[0.0, 5.0, 4.0, 12.0]);
|
||||
let m = summarize(&equity, &[]);
|
||||
assert_eq!(m.total_pips, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_is_zero_on_empty_streams() {
|
||||
let m = summarize(&[], &[]);
|
||||
assert_eq!(m.total_pips, 0.0);
|
||||
assert_eq!(m.max_drawdown, 0.0);
|
||||
assert_eq!(m.bias_sign_flips, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_max_drawdown_is_worst_peak_to_trough() {
|
||||
// peak 10 then trough 5 (drop 5), recovers to 8; worst drop is 5,
|
||||
// not the final drop (10 -> 8 = 2).
|
||||
let equity = samples(&[0.0, 10.0, 5.0, 8.0]);
|
||||
let m = summarize(&equity, &[]);
|
||||
assert_eq!(m.max_drawdown, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_max_drawdown_zero_on_monotonic_curve() {
|
||||
let equity = samples(&[0.0, 1.0, 2.0, 3.0]);
|
||||
let m = summarize(&equity, &[]);
|
||||
assert_eq!(m.max_drawdown, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_sign_flips_counts_signum_changes() {
|
||||
// signum series: + + - 0 - -> flips at +->-, -->0, 0->- = 3.
|
||||
let exposure = samples(&[0.5, 0.5, -0.5, 0.0, -0.5]);
|
||||
let m = summarize(&[], &exposure);
|
||||
assert_eq!(m.bias_sign_flips, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_sign_flips_zero_on_constant_sign() {
|
||||
let exposure = samples(&[0.2, 0.5, 1.0, 0.7]);
|
||||
let m = summarize(&[], &exposure);
|
||||
assert_eq!(m.bias_sign_flips, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f64_field_projects_the_named_field() {
|
||||
let rows = vec![
|
||||
@@ -637,6 +468,7 @@ mod tests {
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("bias_scale".to_string(), Scalar::f64(1.0)),
|
||||
],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(1), Timestamp(6)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=1.0)".to_string(),
|
||||
@@ -654,7 +486,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
report.to_json(),
|
||||
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["bias_scale",{"F64":1.0}]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"bias_sign_flips":1}}"#,
|
||||
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["bias_scale",{"F64":1.0}]],"defaults":[],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"bias_sign_flips":1}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -669,6 +501,7 @@ mod tests {
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("bias_scale".to_string(), Scalar::f64(1.0)),
|
||||
],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(1), Timestamp(6)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=1.0)".to_string(),
|
||||
@@ -693,6 +526,7 @@ mod tests {
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("bias_scale".to_string(), Scalar::f64(1.0)),
|
||||
],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(1), Timestamp(6)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=1.0)".to_string(),
|
||||
@@ -706,7 +540,7 @@ mod tests {
|
||||
let json = serde_json::to_string(&report).expect("serialize RunReport");
|
||||
// window is a 2-element [from, to] array (Timestamp newtype is transparent)
|
||||
assert!(json.contains("\"window\":[1,6]"), "window shape: {json}");
|
||||
let back: RunReport = serde_json::from_str(&json).expect("deserialize RunReport");
|
||||
let back: RunReport<RunMetrics> = serde_json::from_str(&json).expect("deserialize RunReport");
|
||||
assert_eq!(back, report);
|
||||
}
|
||||
|
||||
|
||||
+155
-77
@@ -12,7 +12,6 @@
|
||||
use aura_core::{zip_params, Cell, ParamSpec, Scalar, ScalarKind};
|
||||
use crate::RunReport;
|
||||
use crate::harness::SplitMix64;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
/// A validated cartesian grid over a blueprint's param-space: one discrete
|
||||
/// value-list per param slot, in `param_space()` order. Enumerates a family of
|
||||
@@ -345,20 +344,20 @@ impl Space for ListSpace {
|
||||
/// and `report` carries the run's `(manifest, metrics)` — the unit the run
|
||||
/// registry indexes (C18).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepPoint {
|
||||
pub struct SweepPoint<M> {
|
||||
pub params: Vec<Cell>,
|
||||
pub report: RunReport,
|
||||
pub report: RunReport<M>,
|
||||
}
|
||||
|
||||
/// The ordered result family of a sweep — one `SweepPoint` per grid point, in
|
||||
/// enumeration (odometer) order, independent of thread completion order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SweepFamily {
|
||||
pub struct SweepFamily<M> {
|
||||
pub space: Vec<ParamSpec>,
|
||||
pub points: Vec<SweepPoint>,
|
||||
pub points: Vec<SweepPoint<M>>,
|
||||
}
|
||||
|
||||
impl SweepFamily {
|
||||
impl<M> SweepFamily<M> {
|
||||
/// The i-th point's params paired with their names — a derived view over the
|
||||
/// carried param-space (reuses [`zip_params`]); no new per-point state.
|
||||
pub fn named_params(&self, i: usize) -> Vec<(String, Scalar)> {
|
||||
@@ -366,77 +365,21 @@ impl SweepFamily {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `run_one` over every point the `space` produces (`Space::points` —
|
||||
/// `GridSpace`'s cartesian product or `RandomSpace`'s seeded draws), disjointly
|
||||
/// in parallel (C1), and collect the family in enumeration order. `run_one`
|
||||
/// builds + bootstraps + runs +
|
||||
/// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs
|
||||
/// are lock-free. Parallelism is `available_parallelism()` workers — `std` only,
|
||||
/// via `std::thread::scope`.
|
||||
pub fn sweep<S, F>(space: &S, run_one: F) -> SweepFamily
|
||||
/// The single assembly path behind both [`sweep`] and the thread-count-explicit
|
||||
/// test wrapper: run `run_one` over each already-enumerated `point` via
|
||||
/// `run_indexed` (on whichever pool is ambient when called — the process-wide
|
||||
/// global pool, or a locally `install`ed one) and collect the family in
|
||||
/// enumeration order. Kept as one source of truth so the two callers can never
|
||||
/// drift on assembly logic (grid points, `RandomSpace` draws) while their pool
|
||||
/// selection differs.
|
||||
fn assemble_sweep<M, F>(space: Vec<ParamSpec>, points: Vec<Vec<Cell>>, run_one: &F) -> SweepFamily<M>
|
||||
where
|
||||
S: Space,
|
||||
F: Fn(&[Cell]) -> RunReport + Sync,
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
{
|
||||
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
sweep_with_threads(space, nthreads, run_one)
|
||||
}
|
||||
|
||||
/// Run `n` disjoint jobs in parallel and collect their results in **job-index
|
||||
/// order** — the shared disjoint-parallel core both `sweep` (over grid points)
|
||||
/// and `monte_carlo` (over seeds) drive (C1: order is the input order, not the
|
||||
/// completion order). Workers pull job indices from a shared atomic cursor
|
||||
/// (work-stealing load-balances uneven per-job cost); each tags its result with
|
||||
/// the index, and the results are sorted on that index after the scope joins.
|
||||
/// Only the cursor is shared; the results side is lock-free. `nthreads` is
|
||||
/// clamped to `[1, n.max(1)]` (a 0-job call yields an empty vec; a 0 thread
|
||||
/// count coerces to 1).
|
||||
pub(crate) fn run_indexed<T, F>(n: usize, nthreads: usize, run_one: F) -> Vec<T>
|
||||
where
|
||||
T: Send,
|
||||
F: Fn(usize) -> T + Sync,
|
||||
{
|
||||
let nthreads = nthreads.clamp(1, n.max(1));
|
||||
let cursor = AtomicUsize::new(0);
|
||||
|
||||
let mut results: Vec<(usize, T)> = std::thread::scope(|scope| {
|
||||
let handles: Vec<_> = (0..nthreads)
|
||||
.map(|_| {
|
||||
scope.spawn(|| {
|
||||
let mut local: Vec<(usize, T)> = Vec::new();
|
||||
loop {
|
||||
let i = cursor.fetch_add(1, Ordering::Relaxed);
|
||||
if i >= n {
|
||||
break;
|
||||
}
|
||||
local.push((i, run_one(i)));
|
||||
}
|
||||
local
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
handles.into_iter().flat_map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
|
||||
results.sort_by_key(|&(i, _)| i);
|
||||
results.into_iter().map(|(_, t)| t).collect()
|
||||
}
|
||||
|
||||
/// The thread-count-explicit core of [`sweep`]. Module-private: the public
|
||||
/// `sweep` derives the count, while the tests drive it at 1 and at N to pin
|
||||
/// determinism under concurrency (C1). A thin adapter over [`run_indexed`]: it
|
||||
/// enumerates the `space`'s param-space points (`Space::points`), runs each
|
||||
/// disjointly, and zips the reports back onto their points in enumeration
|
||||
/// (odometer) order.
|
||||
fn sweep_with_threads<S, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily
|
||||
where
|
||||
S: Space,
|
||||
F: Fn(&[Cell]) -> RunReport + Sync,
|
||||
{
|
||||
let points = space.points();
|
||||
let reports = run_indexed(points.len(), nthreads, |i| run_one(&points[i]));
|
||||
let reports = run_indexed(points.len(), |i| run_one(&points[i]));
|
||||
SweepFamily {
|
||||
space: space.param_specs().to_vec(),
|
||||
space,
|
||||
points: points
|
||||
.into_iter()
|
||||
.zip(reports)
|
||||
@@ -445,11 +388,82 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `run_one` over every point the `space` produces (`Space::points` —
|
||||
/// `GridSpace`'s cartesian product or `RandomSpace`'s seeded draws), disjointly
|
||||
/// in parallel (C1), and collect the family in enumeration order. `run_one`
|
||||
/// builds + bootstraps + runs + summarizes one point; it shares nothing
|
||||
/// mutable, so it is `Sync` and the runs are lock-free. Parallelism is the
|
||||
/// shared rayon pool (`run_indexed`): a nested sweep / walk-forward shares its
|
||||
/// workers rather than each spawning its own `available_parallelism()`
|
||||
/// OS-thread batch.
|
||||
pub fn sweep<S, M, F>(space: &S, run_one: F) -> SweepFamily<M>
|
||||
where
|
||||
S: Space,
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
{
|
||||
let points = space.points();
|
||||
assemble_sweep(space.param_specs().to_vec(), points, &run_one)
|
||||
}
|
||||
|
||||
/// Run `n` disjoint jobs in parallel and collect their results in **job-index
|
||||
/// order** — the shared disjoint-parallel core that `sweep` (over grid points),
|
||||
/// `monte_carlo` (over seeds), and `walk_forward` (over window bounds) drive
|
||||
/// (C1: order is the input order, not the completion order). Jobs run on the
|
||||
/// ambient rayon pool: the process-wide global pool at top level, or — inside a
|
||||
/// `ThreadPool::install` scope (the `*_with_threads` test wrappers) — that local
|
||||
/// pool. Nested `run_indexed` calls (a `walk_forward` window running an inner
|
||||
/// `sweep`) share the same workers via work-stealing, so total live workers
|
||||
/// never exceed the pool size — no per-call OS-thread batch, no multiplicative
|
||||
/// oversubscription. `IndexedParallelIterator::collect` restores enumeration
|
||||
/// order regardless of which worker ran which job; a 0-job call yields an empty
|
||||
/// vec. `run_one` is shared by reference across workers (`Sync`), never moved.
|
||||
// `run_one` is captured by reference in the `map` closure below (needs only
|
||||
// `F: Sync`, not `F: Send`): the seemingly-redundant closure is load-bearing —
|
||||
// passing `run_one` directly would move it into `Map` and require `F: Send`, a
|
||||
// bound the `_with_threads` wrappers' `install`-borrowed callers don't carry.
|
||||
#[allow(clippy::redundant_closure)]
|
||||
pub fn run_indexed<T, F>(n: usize, run_one: F) -> Vec<T>
|
||||
where
|
||||
T: Send,
|
||||
F: Fn(usize) -> T + Sync,
|
||||
{
|
||||
use rayon::prelude::*;
|
||||
(0..n).into_par_iter().map(|i| run_one(i)).collect()
|
||||
}
|
||||
|
||||
/// The thread-count-explicit wrapper of [`sweep`]. Module-private: the public
|
||||
/// `sweep` runs on the ambient pool, while the tests drive this at 1 and at N
|
||||
/// to pin determinism under concurrency (C1). The point enumeration runs
|
||||
/// outside the pool; `assemble_sweep` (run_indexed + family assembly) runs
|
||||
/// inside a local rayon pool of `nthreads` workers via `ThreadPool::install`,
|
||||
/// sharing the exact assembly path `sweep` uses — no second copy to drift. A
|
||||
/// 1-worker pool runs the jobs inline (no deadlock) — the sequential-equivalent
|
||||
/// reference. Bounds match `sweep` (`F: Sync`): the install closure only
|
||||
/// *borrows* `run_one` and moves the already-owned `space`/`points`, so no
|
||||
/// `Send` bound on `F` is needed.
|
||||
#[cfg(test)]
|
||||
fn sweep_with_threads<S, M, F>(space: &S, nthreads: usize, run_one: F) -> SweepFamily<M>
|
||||
where
|
||||
S: Space,
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
{
|
||||
let points = space.points();
|
||||
let param_specs = space.param_specs().to_vec();
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(nthreads)
|
||||
.build()
|
||||
.expect("rayon thread pool");
|
||||
pool.install(|| assemble_sweep(param_specs, points, &run_one))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_fixtures::{composite_sma_cross_harness, synthetic_prices};
|
||||
use crate::{f64_field, summarize, RunManifest, VecSource};
|
||||
use crate::{f64_field, RunManifest, VecSource};
|
||||
use aura_backtest::{summarize, RunMetrics};
|
||||
use aura_core::{Cell, ParamSpec, Scalar, ScalarKind, Timestamp};
|
||||
|
||||
fn i64_space(n: usize) -> Vec<ParamSpec> {
|
||||
@@ -710,7 +724,7 @@ mod tests {
|
||||
/// AND a direct reference for the "sweep == N independent runs" comparison.
|
||||
/// The manifest is a minimal fixed fixture — the metrics are the run's, and
|
||||
/// determinism makes `run_point` reproduce a point's report exactly.
|
||||
fn run_point(point: &[Cell]) -> RunReport {
|
||||
fn run_point(point: &[Cell]) -> RunReport<RunMetrics> {
|
||||
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
|
||||
let mut h = bp
|
||||
.bootstrap_with_cells(point)
|
||||
@@ -722,6 +736,7 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "test".to_string(),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
@@ -802,6 +817,68 @@ mod tests {
|
||||
assert_eq!(one, sweep(&grid, run_point));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_run_indexed_shares_the_pool() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
// A shared gauge: live leaf jobs on entry, max seen. Under one shared pool,
|
||||
// nested run_indexed (outer jobs each spawning an inner run_indexed) must
|
||||
// never exceed the pool size — the pre-rayon per-call scope would have run
|
||||
// POOL*POOL threads here. The sleep lets concurrency actually build; the
|
||||
// assertion is an upper bound, so the test is not timing-flaky.
|
||||
const POOL: usize = 4;
|
||||
let live = AtomicUsize::new(0);
|
||||
let max = AtomicUsize::new(0);
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(POOL)
|
||||
.build()
|
||||
.expect("rayon thread pool");
|
||||
pool.install(|| {
|
||||
run_indexed(POOL * 2, |_outer| {
|
||||
run_indexed(POOL * 2, |_inner| {
|
||||
let now = live.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
max.fetch_max(now, Ordering::SeqCst);
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
live.fetch_sub(1, Ordering::SeqCst);
|
||||
0u8
|
||||
})
|
||||
});
|
||||
});
|
||||
let peak = max.load(Ordering::SeqCst);
|
||||
assert!(
|
||||
peak <= POOL,
|
||||
"nested run_indexed peaked at {peak} live workers, exceeding the {POOL}-worker pool"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "benchmark: cargo test -p aura-engine bench_nested_pool -- --ignored --nocapture"]
|
||||
fn bench_nested_pool_walltime() {
|
||||
use std::time::Instant;
|
||||
// Synthetic nested workload: an outer fan-out whose each job runs an inner
|
||||
// fan-out of CPU-bound leaves — the walk_forward(windows) -> sweep(members)
|
||||
// shape, without any archive. Measures wall time under the shared pool; run
|
||||
// before/after the rayon swap to see the oversubscription collapse. Not a
|
||||
// committed baseline (that is #251).
|
||||
fn busy(rounds: u64) -> u64 {
|
||||
(0..rounds).fold(0u64, |a, x| a.wrapping_add(x.wrapping_mul(x).rotate_left(7)))
|
||||
}
|
||||
const OUTER: usize = 48;
|
||||
const INNER: usize = 12;
|
||||
let start = Instant::now();
|
||||
let checksum: u64 = run_indexed(OUTER, |_w| {
|
||||
run_indexed(INNER, |i| busy(4_000_000 + i as u64))
|
||||
.into_iter()
|
||||
.fold(0u64, u64::wrapping_add)
|
||||
})
|
||||
.into_iter()
|
||||
.fold(0u64, u64::wrapping_add);
|
||||
let elapsed = start.elapsed();
|
||||
println!(
|
||||
"nested run_indexed ({OUTER}x{INNER} CPU-bound leaves): {elapsed:?} \
|
||||
(checksum {checksum})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_points_produce_distinct_metrics() {
|
||||
let family = sweep(&sma_cross_grid(), run_point);
|
||||
@@ -872,11 +949,12 @@ mod tests {
|
||||
/// A fake report whose manifest `commit` encodes the input cells — enough to
|
||||
/// prove the sweep closure ran on exactly the given point, without a harness
|
||||
/// run. A free `fn` (Sync) so it serves directly as the `sweep` closure.
|
||||
fn tagged_report(point: &[Cell]) -> RunReport {
|
||||
fn tagged_report(point: &[Cell]) -> RunReport<RunMetrics> {
|
||||
RunReport {
|
||||
manifest: RunManifest {
|
||||
commit: point.iter().map(|c| c.i64().to_string()).collect::<Vec<_>>().join(","),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
use crate::{BlueprintNode, Composite, Edge, OutField, Role, Target};
|
||||
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
|
||||
use aura_backtest::SimBroker;
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// Seven synthetic F64 ticks driving the harness; deterministic input fixture.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user