Compare commits
184 Commits
84e1075176
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 77ad0465cb | |||
| e6b60bf680 | |||
| 2f1baceec7 | |||
| 3aa63833f1 | |||
| 1476990cfd | |||
| dc5f1742f4 | |||
| 9eb6d6b4f6 | |||
| 06d7e0f30a | |||
| db8f947441 | |||
| 24782caaec | |||
| 5dc8e03249 | |||
| 696d7fe59a | |||
| da19e27b6a | |||
| 78b80ec0fd | |||
| 9cfe2965c0 | |||
| 32eb5a6a9e | |||
| 564a767974 | |||
| 4ff85b94e5 | |||
| 10570b75f8 | |||
| a851af993a | |||
| bbac29db2d | |||
| 5e32f3ccdf | |||
| 4474814fa7 | |||
| b3b7115825 | |||
| 3e1e7e21da | |||
| a3785a6ec6 | |||
| 697d81dd22 | |||
| eb2b0a132c | |||
| 14c43474ab | |||
| 6ca359ae00 | |||
| bc8fb46110 | |||
| a8b1ba45c5 | |||
| e84ad6d0d2 | |||
| f449cb06f2 | |||
| 26b3d689df | |||
| cb3330ceb5 | |||
| 4a30222fdc | |||
| 2c2c2fdef6 | |||
| 623d304b7f | |||
| 9e26be60f3 | |||
| 7392075aa6 | |||
| 05e9a00afc | |||
| 120d116982 | |||
| 938397295d | |||
| 98342246f6 | |||
| fa7453dd9f | |||
| 8dbca82756 | |||
| 73ad87b08a | |||
| 7943b123ae | |||
| 7cc3ce0d9e | |||
| e482f0ec35 | |||
| 1baee774bb | |||
| 9124275bf3 | |||
| d26f0c84a4 | |||
| 162bf849ce | |||
| 829a1984e6 | |||
| 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 |
@@ -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
+165
-5
@@ -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,22 +153,28 @@ dependencies = [
|
||||
name = "aura-cli"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-analysis",
|
||||
"aura-backtest",
|
||||
"aura-campaign",
|
||||
"aura-composites",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-market",
|
||||
"aura-measurement",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"aura-runner",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
"data-server",
|
||||
"libc",
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -145,9 +182,11 @@ dependencies = [
|
||||
name = "aura-composites"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -155,6 +194,7 @@ dependencies = [
|
||||
name = "aura-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono-tz",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -164,10 +204,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",
|
||||
]
|
||||
@@ -176,23 +221,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",
|
||||
]
|
||||
@@ -207,13 +279,56 @@ 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",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"aura-strategy",
|
||||
]
|
||||
|
||||
[[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]]
|
||||
@@ -404,6 +519,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"
|
||||
@@ -479,6 +613,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"
|
||||
@@ -803,6 +943,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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -31,47 +31,46 @@ Invoke it as `aura <command> …` (examples below use the plain name).
|
||||
|
||||
- **Blueprint** — a serialized signal graph as data: a param-generic
|
||||
`price → bias` node graph. A blueprint file (`blueprint.json`) is the unit a
|
||||
downstream consumer loads and drives verbs over.
|
||||
downstream consumer loads and executes or varies.
|
||||
- **Open vs. closed** — a blueprint with unbound (free) numeric knobs is *open*;
|
||||
one with every knob bound is *closed*. Different verbs want different states
|
||||
(see each verb below).
|
||||
- **Family** — the set of runs one verb produces over a blueprint (a sweep grid,
|
||||
a Monte-Carlo seed set, a walk-forward window sequence). Families are persisted
|
||||
in a content-addressed store and can be listed, ranked, and reproduced.
|
||||
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
|
||||
one with every knob bound is *closed*. A plain `exec` wants closed; naming a
|
||||
knob as a campaign axis (or an `exec --override`) is what reopens it.
|
||||
- **Family** — the set of runs a campaign document's process pipeline produces
|
||||
over a blueprint (a sweep grid, a Monte-Carlo seed set, a walk-forward window
|
||||
sequence). Families are persisted in a content-addressed store and can be
|
||||
listed, ranked, and reproduced.
|
||||
- **Axis** — one named, sweepable knob of a blueprint (e.g. `fast.length`),
|
||||
bound with a comma-separated value list. A **gang** fuses several sibling
|
||||
knobs into one axis (one value drives all members).
|
||||
|
||||
## Running & orchestrating a loaded blueprint
|
||||
## Executing a document
|
||||
|
||||
These verbs all take the **blueprint file as their first positional argument**.
|
||||
Most drive a family of runs over it; `graph` renders its structure so a mis-wire
|
||||
is visible before a run. `walkforward`, `mc`, and `generalize` share
|
||||
`sweep`'s generic grammar — the blueprint positional plus `--real` and a
|
||||
repeatable `--axis <name>=<csv>` (`generalize` takes `--real <SYM1,SYM2,…>`, at
|
||||
least two instruments, with a single value per axis) — see `--help`.
|
||||
`aura exec <target>` (#319) is the one executor verb: `<target>` is either a
|
||||
loaded **blueprint** file (a single synthetic run) or a **campaign document**
|
||||
(a `.json` file or its registered 64-hex content id — instruments × windows ×
|
||||
strategy × param axes × process, see `docs/authoring-guide.md` §3). `graph`
|
||||
renders a blueprint's structure so a mis-wire is visible before a run.
|
||||
|
||||
| Command | Purpose |
|
||||
|---|---|
|
||||
| `aura run <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or use `sweep`. |
|
||||
| `aura exec <bp.json>` | Run one backtest of a **closed** blueprint and print its report. An open (free-knob) blueprint is refused with a clean error — bind it, or vary it as a campaign axis. |
|
||||
| `aura exec <bp.json> --override <node.param>=<value>` | Reopen one **bound** param for this single execution only (the value is recorded raw in the manifest); repeatable. |
|
||||
| `aura exec <bp.json> --tap <name>=<fold>` | Subscribe a declared tap to a fold (`record`/`count`/`sum`/`mean`/`min`/`max`/`first`/`last`) for this run; repeatable. |
|
||||
| `aura graph <bp.json>` | **Render** the blueprint's structure as an interactive HTML DAG so a mis-wire is visible before a run. Omit the file to render the built-in sample; a named-but-unreadable file is a usage error. |
|
||||
| `aura sweep <bp.json> --list-axes` | **Discover** the blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`. Run this first to learn the axis names. |
|
||||
| `aura sweep <bp.json> --axis <name>=<csv> [--axis …]` | Run a **grid family** over the named axes and persist it. |
|
||||
| `aura mc <bp.json> --seeds <n>` | Run a **synthetic Monte-Carlo family** of `n` seeded realizations. Wants a **closed** blueprint (the inverse of sweep). |
|
||||
| `aura mc <bp.json> --real <sym> --axis <name>=<csv> [--axis …]` | Run a **Monte-Carlo R-bootstrap campaign** over recorded data — driven through the same generated-campaign pipeline as `sweep`; `--block-len`/`--resamples`/`--seed` tune the bootstrap. |
|
||||
| `aura walkforward <bp.json> --axis <name>=<csv> [--axis …]` | Run an **in-sample-refit walk-forward family** — rolling optimize + out-of-sample test across windows, stitched into one verdict + parameter stability. `--select` chooses the per-window objective. |
|
||||
| `aura graph introspect --params <bp.json\|id>` | **Discover** a blueprint's open, sweepable knobs. Prints each as `<name>:<kind>`, bound params trailing `default=<value>`. Run this first to learn the axis names for a campaign document. |
|
||||
| `aura exec <campaign.json\|id> [--parallel-instruments <n>]` | Execute a **campaign document** — a grid family, gates, walk-forward, Monte-Carlo, and cross-instrument generalization per its process document, one cell per (strategy, instrument, window). |
|
||||
| `aura runs families` | List every persisted family (id, kind, member count). |
|
||||
| `aura runs family <id> [rank <metric>]` | List one family's members, optionally ranked best-first by an R metric (e.g. `sqn_normalized`, `expectancy_r`). |
|
||||
| `aura reproduce <family-id>` | Re-derive every member of a persisted family from the content-addressed store and check it is bit-identical (the C18/C1 determinism guarantee). |
|
||||
|
||||
**Important contract — every open knob is mandatory.** On `sweep` and
|
||||
`walkforward`, the knobs enumerated by `--list-axes` are *all* required: you must
|
||||
supply an `--axis` for each open knob. There is no default value — pin a knob you
|
||||
don't want to vary with a single-value axis (`--axis name=4`). Omitting one is a
|
||||
clean error naming the missing knob, not a silent default.
|
||||
**Important contract — every open knob is mandatory on a campaign axis.** A
|
||||
campaign document's `strategies[].axes` must name every open knob
|
||||
`graph introspect --params` lists: there is no default value — pin a knob you
|
||||
don't want to vary with a single-value axis (`{"kind":"I64","values":[4]}`).
|
||||
Omitting one is a clean error naming the missing knob, not a silent default.
|
||||
|
||||
Use `aura <cmd> --help` for the full data-window (`--real`/`--from`/`--to`),
|
||||
naming (`--name`), and selection (`--select`) flags.
|
||||
Use `aura exec --help` for the exact flag grammar, `docs/authoring-guide.md`
|
||||
for the campaign document shape (data window, naming, presentation/emit).
|
||||
|
||||
## Authoring & introspecting topology
|
||||
|
||||
@@ -111,15 +110,20 @@ Each element is one op, tagged by `"op"`. Node params are bound with the **typed
|
||||
```
|
||||
|
||||
Piping this document into `aura graph build` emits a `blueprint.json` you can
|
||||
then feed to `run` / `sweep` / `mc` / `walkforward` above:
|
||||
then feed to `exec` above:
|
||||
|
||||
```sh
|
||||
aura graph build < crossover.ops.json > crossover.bp.json
|
||||
aura sweep crossover.bp.json --list-axes
|
||||
aura graph introspect --params crossover.bp.json
|
||||
```
|
||||
|
||||
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`, `doc`, and `use` (`tap` declares a recorded measurement point on an
|
||||
interior wire; `doc` declares the composite's one-line meaning, required at
|
||||
register — C29; `use` splices a registered blueprint in as a nested composite,
|
||||
by content id or label; `add` additionally takes an `args` object for
|
||||
**arg-bearing** types (`Session`, `LinComb`, `CostSum`) — structural,
|
||||
non-scalar construction consumed before `bind`; 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`
|
||||
@@ -351,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.487171,
|
||||
"run_ms": 3.376192
|
||||
},
|
||||
"fingerprint": "run_line_fnv=9bdbc3acf7b2926a",
|
||||
"reps": 3,
|
||||
"host": {
|
||||
"hostname": "Raki",
|
||||
"nproc": 24
|
||||
},
|
||||
"profile": "release",
|
||||
"commit": "10570b7",
|
||||
"date": "2026-07-25"
|
||||
}
|
||||
@@ -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,444 @@
|
||||
//! 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 `graph register` (#319: the
|
||||
/// surviving registration surface) 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, &["graph", "register", file, "--name", name])?;
|
||||
if code != Some(0) {
|
||||
return Err(format!("seed register failed ({code:?}): {out}"));
|
||||
}
|
||||
blueprint_stems(dir)
|
||||
.into_iter()
|
||||
.find(|s| !before.contains(s))
|
||||
.ok_or_else(|| "seed register 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, &["exec", &doc], &scratch.0)?;
|
||||
if timed.exit != Some(0) {
|
||||
return Err(format!(
|
||||
"aura exec 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 exec whose fingerprint is the
|
||||
//! FNV-1a hash of the exec'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 exec` 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, &["exec", "bench_sma_a.json"], &scratch.0)?;
|
||||
if timed.exit != Some(0) {
|
||||
return Err(format!("aura exec 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 exec 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 exec` 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 exec` 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"
|
||||
|
||||
+532
-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.
|
||||
@@ -557,7 +889,7 @@ fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> Run
|
||||
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,
|
||||
@@ -627,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
|
||||
@@ -678,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 {
|
||||
@@ -757,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)
|
||||
@@ -767,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");
|
||||
@@ -789,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()
|
||||
}
|
||||
|
||||
+300
-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
|
||||
@@ -365,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(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -731,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]
|
||||
@@ -871,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,
|
||||
@@ -885,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"))
|
||||
@@ -1047,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]
|
||||
@@ -1163,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]
|
||||
}
|
||||
|
||||
@@ -84,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),
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -126,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> {
|
||||
@@ -202,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,
|
||||
@@ -219,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"))
|
||||
@@ -231,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
|
||||
@@ -294,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
|
||||
@@ -331,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
|
||||
@@ -350,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");
|
||||
@@ -367,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
|
||||
@@ -400,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]
|
||||
@@ -435,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);
|
||||
@@ -449,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!(
|
||||
@@ -470,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
|
||||
@@ -483,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));
|
||||
|
||||
@@ -496,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");
|
||||
@@ -505,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)
|
||||
@@ -513,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];
|
||||
@@ -541,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];
|
||||
@@ -564,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);
|
||||
@@ -623,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");
|
||||
@@ -644,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))
|
||||
@@ -677,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
|
||||
@@ -696,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
|
||||
@@ -706,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));
|
||||
@@ -754,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
|
||||
@@ -772,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()));
|
||||
@@ -791,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.
|
||||
@@ -818,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
|
||||
@@ -836,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;
|
||||
@@ -64,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]
|
||||
@@ -88,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.
|
||||
@@ -125,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());
|
||||
}
|
||||
|
||||
+32
-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,13 +47,6 @@ 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
|
||||
@@ -60,3 +60,25 @@ clap = { version = "4", features = ["derive"] }
|
||||
# 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-market: the shell's own use is test-only (#271's identity-bridge test,
|
||||
# `identity_id_bridges_the_rust_configured_session_and_args_script`, needs
|
||||
# `Session::configured` as the Rust-authored twin of the args op-script path)
|
||||
# — rides the same dev-only-edge idiom as `aura-composites`/`aura-analysis`.
|
||||
aura-market = { path = "../aura-market" }
|
||||
# chrono-tz: the SAME identity-bridge test needs a `chrono_tz::Tz` to pass
|
||||
# `Session::configured` — test-only, pinned to the same version every other
|
||||
# crate's chrono-tz entry uses.
|
||||
chrono-tz = { version = "0.10", default-features = false }
|
||||
# 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 +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 +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 +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"}]}}
|
||||
+171
-1043
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 (#319 Task 5: migrated onto the campaign
|
||||
/// walk-forward leg in `campaign_run.rs`, the surviving executor path) 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
+1036
-4349
File diff suppressed because it is too large
Load Diff
@@ -234,7 +234,7 @@ 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.json"),
|
||||
&|t| env.resolve(t),
|
||||
|
||||
@@ -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.
|
||||
pub(crate) 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();
|
||||
@@ -93,10 +107,12 @@ fn print_metric_roster() {
|
||||
(true, false) => "rankable",
|
||||
(false, false) => "annotation",
|
||||
};
|
||||
// C29 (#315): the roster carries each metric's one-line meaning, not
|
||||
// only where it may be used.
|
||||
if generalize {
|
||||
println!("{name:<24} {base} | generalize");
|
||||
println!("{name:<24} {base} | generalize — {}", m.doc);
|
||||
} else {
|
||||
println!("{name:<24} {base}");
|
||||
println!("{name:<24} {base} — {}", m.doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +153,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 +166,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 +184,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 +193,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 +223,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 +263,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 +284,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.
|
||||
pub(crate) 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();
|
||||
@@ -271,6 +374,13 @@ fn describe_one_block(id: &str) -> Result<(), String> {
|
||||
for s in schema.slots {
|
||||
let req = if s.required { "required" } else { "optional" };
|
||||
println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
|
||||
// C29 (#315): the tap slot's closed value vocabulary carries its
|
||||
// per-entry meanings, indented under the slot line.
|
||||
if matches!(s.kind, aura_research::SlotKind::TapKinds) {
|
||||
for t in aura_research::tap_vocabulary() {
|
||||
println!(" {:<14} {}", t.id, t.doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -278,6 +388,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");
|
||||
@@ -298,12 +411,11 @@ pub enum CampaignSub {
|
||||
Introspect(DocIntrospectCmd),
|
||||
/// Register a valid campaign document into the store under the runs root.
|
||||
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 },
|
||||
/// 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 +429,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) => {
|
||||
@@ -329,8 +449,14 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
||||
RefFault::StrategyUnloadable { id, error } => {
|
||||
format!("strategy {id} cannot be loaded: {error}")
|
||||
}
|
||||
RefFault::AxisNotInParamSpace { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
|
||||
RefFault::AxisNotInParamSpace { strategy, axis, raw_candidate } => {
|
||||
let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space");
|
||||
if let Some(candidate) = raw_candidate {
|
||||
msg.push_str(&format!(
|
||||
"; axis names are raw node.param paths — did you mean \"{candidate}\"?"
|
||||
));
|
||||
}
|
||||
msg
|
||||
}
|
||||
RefFault::AxisKindMismatch { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
||||
@@ -411,8 +537,8 @@ 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),
|
||||
};
|
||||
if let Err(m) = result {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -502,6 +628,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 +649,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();
|
||||
@@ -577,6 +742,7 @@ mod tests {
|
||||
ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "nope".into(),
|
||||
raw_candidate: None,
|
||||
}),
|
||||
ref_fault_prose(&RefFault::AxisKindMismatch {
|
||||
strategy: "9f3a".into(),
|
||||
@@ -597,6 +763,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous
|
||||
/// and verbatim, exactly when `raw_candidate` is present — the document-
|
||||
/// side refusal seam's mirror of the sweep `--axis` intake translation
|
||||
/// (`aura-runner`'s `axes.rs` classify predicate), sharing the same
|
||||
/// `axis names are raw node.param paths` clause pinned there.
|
||||
#[test]
|
||||
fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() {
|
||||
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "graph.fast.length".into(),
|
||||
raw_candidate: Some("fast.length".into()),
|
||||
});
|
||||
assert_eq!(
|
||||
msg,
|
||||
"strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \
|
||||
axis names are raw node.param paths — did you mean \"fast.length\"?"
|
||||
);
|
||||
}
|
||||
|
||||
/// #328 (negative): a rejected axis with no `raw_candidate` (stripping one
|
||||
/// leading segment yields no param-space hit, or the axis has no leading
|
||||
/// segment to strip at all) renders today's prose unchanged — no
|
||||
/// speculative suggestion.
|
||||
#[test]
|
||||
fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() {
|
||||
let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "nope".into(),
|
||||
raw_candidate: None,
|
||||
});
|
||||
assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space");
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #194 (the prefix trap): a doc ref that carries the display `content:`
|
||||
/// prefix (a copy-paste from register/introspect output) is bare-only in
|
||||
@@ -651,4 +850,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,9 @@ impl Scale {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![ParamSpec { name: "factor".into(), kind: ScalarKind::F64 }],
|
||||
// C29: every vocabulary entry ships a one-line meaning — the
|
||||
// doc field is required (E0063 without it) and gated at load.
|
||||
doc: "scalar gain: emits the input times the factor param",
|
||||
},
|
||||
|p| Box::new(Scale::new(p[0].f64())),
|
||||
)
|
||||
@@ -219,22 +224,32 @@ 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)
|
||||
|
||||
This directory is an aura project: blueprints + research documents over the
|
||||
std vocabulary, anchored by `Aura.toml`. There is no crate and no build step.
|
||||
|
||||
- Run: `aura run blueprints/signal.json` (the starter is closed — all
|
||||
params bound; bound values are defaults — any `--axis` may override them
|
||||
(#246))
|
||||
- Sweep: `aura sweep blueprints/signal.json --axis __NAME_SNAKE___signal.fast.length=2,4,8`
|
||||
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes)
|
||||
- Run: `aura exec blueprints/signal.json` (single smoke run, synthetic stream
|
||||
— the starter is closed; all params bound; bound values are defaults —
|
||||
`--override NODE.PARAM=VALUE` may override one for this run (#246))
|
||||
- Campaign: `aura exec <campaign.json>` executes a registered campaign
|
||||
document (file or content id) — the multi-cell/axis surface
|
||||
- Axes: `aura graph introspect --params blueprints/signal.json` lists the
|
||||
open + bound-overridable axes, RAW `<node>.<param>` names (#328)
|
||||
- Native nodes: when the project needs its first project-specific node,
|
||||
`aura nodes new <name>` scaffolds a node crate beside this project and
|
||||
attaches it via `[nodes]` in `Aura.toml` (build it with `cargo build`).
|
||||
- Topology is data (`blueprints/*.json`); results land in `runs/`.
|
||||
- Execution model: a strategy emits a bias in [-1,+1] per cycle, held as the
|
||||
continuously-tracked target position; a protective stop defines the risk
|
||||
unit R, and quality metrics are R-based. Entry signals become held state
|
||||
via the signal-side latch/edge-pulse idiom (see
|
||||
`aura graph introspect --vocabulary`).
|
||||
- Data plane: process/campaign documents are authored directly with
|
||||
`aura process` / `aura campaign`, growing one from a bare `{}` via
|
||||
`aura campaign introspect --unwired`.
|
||||
"#;
|
||||
|
||||
/// Render a data-only project template: only `__NAME__`/`__NAME_SNAKE__`
|
||||
@@ -280,7 +295,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 +336,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 +369,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 +386,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)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -445,11 +460,47 @@ mod tests {
|
||||
}
|
||||
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
|
||||
assert!(claude.contains("demo-lab"));
|
||||
// The CLAUDE.md sweep quickstart targets the closed starter itself
|
||||
// with the rendered blueprint-name axis prefix (#246: a bound param
|
||||
// is a default an axis overrides).
|
||||
// The CLAUDE.md quickstart targets the closed starter itself through
|
||||
// the surviving surface (#319): exec for both document classes, the
|
||||
// #246 override residue, and raw-namespace axis discovery (#328).
|
||||
assert!(claude.contains("blueprints/signal.json"));
|
||||
assert!(claude.contains("demo_lab_signal.fast.length"));
|
||||
assert!(claude.contains("--override NODE.PARAM=VALUE"));
|
||||
assert!(claude.contains("graph introspect --params"));
|
||||
}
|
||||
|
||||
/// #315: the scaffolded project CLAUDE.md teaches the execution semantics
|
||||
/// (bias as held target position, protective stop, R) and the document
|
||||
/// data plane — the two things the 2026-07-22 field agent had to discover
|
||||
/// forensically.
|
||||
#[test]
|
||||
fn project_claude_md_teaches_execution_semantics_and_the_data_plane() {
|
||||
let claude = render_project(CLAUDE_MD_PROJECT, "demo-lab");
|
||||
assert!(claude.contains("target position"), "names the held-target model: {claude}");
|
||||
assert!(claude.contains("protective stop"), "names the stop that defines R: {claude}");
|
||||
assert!(claude.contains("aura process"), "points at the document layer: {claude}");
|
||||
assert!(claude.contains("introspect --unwired"), "names the bare-{{}} ramp: {claude}");
|
||||
}
|
||||
|
||||
/// #323: the authoring guide's S0 worked literal is byte-identical to the
|
||||
/// scaffold's `LIB_RS` template (rendered with the guide's `my_lab`
|
||||
/// namespace). The scaffold e2e tests compile that template for real, so
|
||||
/// this equality pin is a transitive compile guard — the guide literal
|
||||
/// can no longer drift silently from the schema (it broke against the
|
||||
/// required `NodeSchema.doc` once).
|
||||
#[test]
|
||||
fn guide_worked_example_matches_the_scaffold_template() {
|
||||
let guide = include_str!("../../../docs/authoring-guide.md");
|
||||
let anchor = "### Worked example: `Scale`";
|
||||
let after = &guide[guide.find(anchor).expect("guide keeps the S0 worked example")..];
|
||||
let start = after.find("```rust\n").expect("worked example is a rust fence") + 8;
|
||||
let fence = &after[start..];
|
||||
let fence = &fence[..fence.find("\n```").expect("fence closes") + 1];
|
||||
let body = &LIB_RS[LIB_RS.find("use aura_core::{").expect("template body starts at the import")..];
|
||||
let expected = body.replace("__NS__", "my_lab");
|
||||
assert_eq!(
|
||||
fence, expected,
|
||||
"docs/authoring-guide.md S0 literal drifted from the compiled scaffold template"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
|
||||
@@ -488,7 +539,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 +551,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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,47 +5,136 @@
|
||||
//! handled cleanly, never a panic.
|
||||
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
mod common;
|
||||
use common::{fresh_project_with_data, ScratchGuard, ScratchPath};
|
||||
|
||||
/// 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 so the spawned `aura sweep` writes its
|
||||
/// `./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 _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
|
||||
let out = Command::new(BIN).args(args).current_dir(dir).output().expect("binary runs");
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
(text, out.status.code())
|
||||
}
|
||||
|
||||
fn write_doc(dir: &Path, name: &str, text: &str) -> std::path::PathBuf {
|
||||
let p = dir.join(name);
|
||||
std::fs::write(&p, text).expect("write doc");
|
||||
p
|
||||
}
|
||||
|
||||
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
|
||||
/// `research_docs.rs`'s constant of the same name.
|
||||
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "sweep-only",
|
||||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||||
}"#;
|
||||
|
||||
/// Seed one blueprint into the built demo project's store via `aura graph
|
||||
/// register` and return its content id — copied verbatim from
|
||||
/// `research_docs.rs`'s recipe of the same name (#319 — the retired `sweep`
|
||||
/// side-effect seeding this used to ride played no role in the returned
|
||||
/// content id, a pure function of the loaded blueprint's canonical bytes;
|
||||
/// `name` labels the registered id, mirroring the old per-call sweep family
|
||||
/// name).
|
||||
fn seed_blueprint(dir: &Path, name: &str) -> String {
|
||||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let (out, code) = run_code_in(dir, &["graph", "register", &closed_bp, "--name", name]);
|
||||
assert_eq!(code, Some(0), "seed register failed: {out}");
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("registered blueprint "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Register `doc` as a process document in the project store; returns its
|
||||
/// id — copied verbatim from `research_docs.rs`'s recipe of the same name.
|
||||
fn register_process_doc(dir: &Path, file: &str, doc: &str) -> String {
|
||||
write_doc(dir, file, doc);
|
||||
let (out, code) = run_code_in(dir, &["process", "register", file]);
|
||||
assert_eq!(code, Some(0), "process register failed: {out}");
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("registered process "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered process ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A one-strategy, two-axis (2×2) campaign document over `bp_id`/`proc_id` —
|
||||
/// copied verbatim from `exec.rs`'s `campaign_doc_json_for` of the same
|
||||
/// shape, which yields exactly four family members (the four-line stream
|
||||
/// this test's property needs).
|
||||
fn campaign_doc_json_for(instrument: &str, bp_id: &str, proc_id: &str, window: (i64, i64)) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "broken-pipe",
|
||||
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||||
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#,
|
||||
from = window.0,
|
||||
to = window.1,
|
||||
)
|
||||
}
|
||||
|
||||
/// Property: a family-emitting command whose stdout reader closes early exits on
|
||||
/// the EPIPE cleanly — it does NOT panic (`failed printing to stdout: Broken
|
||||
/// pipe`) and does NOT exit 101. `aura sweep` streams four family-member JSON
|
||||
/// lines via `println!`; when the reader closes stdout after the first line, the
|
||||
/// next write hits a closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at
|
||||
/// startup, so that write returns EPIPE and `println!` panics rather than the
|
||||
/// process terminating quietly — exactly the behaviour a CLI must not have. This
|
||||
/// is the contract: any aura subcommand can be piped into `| head` / `| less` /
|
||||
/// pipe`) and does NOT exit 101. `aura exec <campaign.json>` streams four
|
||||
/// family-member JSON lines via `println!` (a 2×2 axis grid — #319: the
|
||||
/// surviving surface for what a bare `aura sweep` demo grid used to stream);
|
||||
/// when the reader closes stdout after the first line, the next write hits a
|
||||
/// closed pipe. Rust's runtime sets SIGPIPE to SIG_IGN at startup, so that
|
||||
/// write returns EPIPE and `println!` panics rather than the process
|
||||
/// terminating quietly — exactly the behaviour a CLI must not have. This is
|
||||
/// the contract: any aura subcommand can be piped into `| head` / `| less` /
|
||||
/// a UI pane that goes away, and it must finish quietly, not panic.
|
||||
///
|
||||
/// Autonomous: constructs its own early-closing reader inline (no `head`, no
|
||||
/// shell, no fixture files) by spawning the binary with a piped stdout and
|
||||
/// dropping the read handle after a single short read, which closes the pipe's
|
||||
/// read end and makes the writer's next write hit EPIPE.
|
||||
#[test]
|
||||
fn family_emitting_command_survives_early_reader_close() {
|
||||
let cwd = temp_cwd("broken-pipe-sweep");
|
||||
let (dir, _fixture) = fresh_project_with_data();
|
||||
let runs_dir = dir.join("runs");
|
||||
std::fs::remove_dir_all(&runs_dir).ok();
|
||||
let _cleanup = ScratchGuard(vec![
|
||||
ScratchPath::Dir(runs_dir),
|
||||
ScratchPath::File(dir.join("broken-pipe.process.json")),
|
||||
ScratchPath::File(dir.join("broken-pipe.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(&dir, "broken-pipe-seed");
|
||||
let proc_id = register_process_doc(&dir, "broken-pipe.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||
let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999));
|
||||
write_doc(&dir, "broken-pipe.campaign.json", &doc);
|
||||
|
||||
let mut child = Command::new(BIN)
|
||||
.arg("sweep")
|
||||
.current_dir(&cwd)
|
||||
.args(["exec", "broken-pipe.campaign.json"])
|
||||
.current_dir(&dir)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("spawn aura sweep");
|
||||
.expect("spawn aura exec");
|
||||
|
||||
// Read just enough to guarantee the writer is mid-stream, then drop the read
|
||||
// handle. Dropping it closes the read end of the pipe; the next `println!`
|
||||
@@ -63,9 +152,7 @@ fn family_emitting_command_survives_early_reader_close() {
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
let _ = err.read_to_string(&mut stderr);
|
||||
}
|
||||
let status = child.wait().expect("reap aura sweep");
|
||||
|
||||
let _ = std::fs::remove_dir_all(&cwd);
|
||||
let status = child.wait().expect("reap aura exec");
|
||||
|
||||
// The defect: stderr carries the broken-pipe panic.
|
||||
assert!(
|
||||
|
||||
+2860
-4203
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
//! Shared fixture helpers for the `aura-cli` end-to-end test binaries
|
||||
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`), all of which build
|
||||
//! and drive the `tests/fixtures/demo-project` fixture through the real
|
||||
//! `aura` binary (#223).
|
||||
//! (`research_docs.rs`, `cli_run.rs`, `project_load.rs`, `exec.rs`, and —
|
||||
//! for [`synthetic_data`] alone — `project_new.rs`, `cli_broken_pipe.rs`,
|
||||
//! `graph_construct.rs`), all of which build and drive the
|
||||
//! `tests/fixtures/demo-project` fixture through the real `aura` binary
|
||||
//! (#223).
|
||||
//!
|
||||
//! **Per-test project directories, no shared store.** [`fresh_project`] mints
|
||||
//! a unique tempdir per test, wired to the shared, once-built fixture crate
|
||||
@@ -9,10 +11,10 @@
|
||||
//! 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
|
||||
//! warnings` `dead_code` lint; see the `#[allow(dead_code)]` markers below
|
||||
//! rather than deleting a helper just because one binary doesn't need it.
|
||||
//! Each consuming test binary compiles this file as its own `mod common`, so
|
||||
//! an item unused by one binary trips that binary's `-D warnings` `dead_code`
|
||||
//! lint; see the `#[allow(dead_code)]` markers below rather than deleting a
|
||||
//! helper just because one binary doesn't need it.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::OnceLock;
|
||||
@@ -55,6 +57,15 @@ impl Drop for FreshProject {
|
||||
/// 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!(
|
||||
@@ -118,6 +129,26 @@ pub fn fresh_project_with_data() -> (PathBuf, FreshProject) {
|
||||
(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
|
||||
/// demo-project fixture root (only `runs/` there is fixture-gitignored),
|
||||
/// removed on drop — including during a mid-test panic — so a failed
|
||||
@@ -153,7 +184,7 @@ impl Drop for ScratchGuard {
|
||||
/// 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 {
|
||||
pub mod synthetic_data {
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()),
|
||||
)
|
||||
|
||||
+1
-1
@@ -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"}},{"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"}]}]}}
|
||||
{"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"}]}]}}
|
||||
+1
-1
@@ -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"}},{"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"}]}]}}
|
||||
{"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"}]}]}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"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"}]}]}}
|
||||
{"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"}]}]}}
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"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"}]}}
|
||||
{"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,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,242 @@
|
||||
//! End-to-end pins for the self-description surfaces (#315, #323, #319): the
|
||||
//! help opens with the two-layer concept, `exec` names both document classes
|
||||
//! it executes, every introspection roster carries per-entry meanings, and
|
||||
//! `graph build --help` carries the op-list reference. Driven over the
|
||||
//! built binary — the zero-setup surface a reader without repo access gets.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// Run `aura <args>` (no stdin); return (stdout, stderr, success).
|
||||
fn run(args: &[&str]) -> (String, String, bool) {
|
||||
let out = Command::new(BIN).args(args).output().expect("spawn aura");
|
||||
(
|
||||
String::from_utf8_lossy(&out.stdout).into_owned(),
|
||||
String::from_utf8_lossy(&out.stderr).into_owned(),
|
||||
out.status.success(),
|
||||
)
|
||||
}
|
||||
|
||||
/// #315: `aura --help` opens with the concepts paragraph — the two layers,
|
||||
/// the bias-as-target-position + protective-stop execution model, and how
|
||||
/// traces come to exist. Each pin sits on one authored line of the explicit
|
||||
/// `long_about` string, so clap's wrapping cannot split it.
|
||||
#[test]
|
||||
fn top_level_help_opens_with_the_two_layer_concept() {
|
||||
let (out, err, ok) = run(&["--help"]);
|
||||
assert!(ok, "aura --help failed: {err}");
|
||||
assert!(
|
||||
out.contains("the one executor over both document classes"),
|
||||
"names exec + the document classes: {out}"
|
||||
);
|
||||
assert!(out.contains("directly authorable"), "names the document data plane: {out}");
|
||||
assert!(out.contains("bias in [-1,+1]"), "names the bias output: {out}");
|
||||
assert!(out.contains("defines the risk unit"), "names the protective stop / R: {out}");
|
||||
assert!(out.contains("introspect --unwired"), "names the bare-{{}} ramp: {out}");
|
||||
assert!(out.contains("aura chart"), "names the trace consumers: {out}");
|
||||
}
|
||||
|
||||
/// #319: `exec --help` names both document classes it accepts — the sugar
|
||||
/// verbs' retirement leaves `exec` as the one executor over a campaign
|
||||
/// document (file or content id) or a signal blueprint (single run).
|
||||
#[test]
|
||||
fn exec_help_names_both_document_classes() {
|
||||
let (out, err, ok) = run(&["exec", "--help"]);
|
||||
assert!(ok, "aura exec --help failed: {err}");
|
||||
assert!(out.contains("campaign"), "names the campaign document class: {out}");
|
||||
assert!(out.contains("blueprint"), "names the blueprint document class: {out}");
|
||||
assert!(out.contains("single run"), "names the blueprint leg's single-run shape: {out}");
|
||||
}
|
||||
|
||||
/// #323: `graph build --help` carries the op-list reference — the op kinds
|
||||
/// and fields are learnable from the binary, not only from serde refusals.
|
||||
/// #317: the `use` op joins the roster (nine -> ten). #331: the `name` op
|
||||
/// joins the roster (ten -> eleven).
|
||||
#[test]
|
||||
fn graph_build_help_carries_the_op_reference() {
|
||||
let (out, err, ok) = run(&["graph", "build", "--help"]);
|
||||
assert!(ok, "aura graph build --help failed: {err}");
|
||||
for op in [
|
||||
r#"{"op":"source""#,
|
||||
r#"{"op":"input""#,
|
||||
r#"{"op":"add""#,
|
||||
r#"{"op":"feed""#,
|
||||
r#"{"op":"connect""#,
|
||||
r#"{"op":"expose""#,
|
||||
r#"{"op":"tap""#,
|
||||
r#"{"op":"gang""#,
|
||||
r#"{"op":"doc""#,
|
||||
r#"{"op":"use""#,
|
||||
r#"{"op":"name""#,
|
||||
] {
|
||||
assert!(out.contains(op), "op reference carries {op}: {out}");
|
||||
}
|
||||
assert!(out.contains("graph introspect --vocabulary"), "points at the node roster: {out}");
|
||||
}
|
||||
|
||||
/// #317: `graph introspect --registered` lists every registry label — row
|
||||
/// shape `<label> <id prefix> <root doc>` — and the empty-store form reads
|
||||
/// as prose, not a blank/garbled listing.
|
||||
#[test]
|
||||
fn graph_introspect_registered_lists_labels() {
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-help-registered");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
|
||||
// Empty store: prose, not a blank listing, exit 0.
|
||||
let out_empty = Command::new(BIN)
|
||||
.args(["graph", "introspect", "--registered"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert!(out_empty.status.success(), "empty store still exits 0");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&out_empty.stdout),
|
||||
"no labels registered\n",
|
||||
"the empty-store form reads as prose"
|
||||
);
|
||||
|
||||
// Register + label a described open pattern, then list it.
|
||||
let pattern = r#"[
|
||||
{"op":"doc","text":"a reusable smoothing pattern"},
|
||||
{"op":"input","role":"x"},
|
||||
{"op":"add","type":"SMA","name":"sma"},
|
||||
{"op":"feed","role":"x","into":["sma.series"]},
|
||||
{"op":"expose","from":"sma.value","as":"out"}
|
||||
]"#;
|
||||
let bp_path = dir.join("pattern.ops.json");
|
||||
std::fs::write(&bp_path, pattern).expect("write pattern fixture");
|
||||
let built = Command::new(BIN)
|
||||
.args(["graph", "build"])
|
||||
.current_dir(&dir)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
use std::io::Write;
|
||||
child.stdin.take().unwrap().write_all(pattern.as_bytes())?;
|
||||
child.wait_with_output()
|
||||
})
|
||||
.expect("build the pattern");
|
||||
assert!(built.status.success(), "the pattern builds");
|
||||
let envelope = dir.join("pattern.bp.json");
|
||||
std::fs::write(&envelope, &built.stdout).expect("write built envelope");
|
||||
|
||||
let reg = Command::new(BIN)
|
||||
.args(["graph", "register", envelope.to_str().unwrap(), "--name", "smooth"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura register");
|
||||
assert!(reg.status.success(), "register --name succeeds: {}", String::from_utf8_lossy(®.stderr));
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["graph", "introspect", "--registered"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
assert!(out.status.success(), "listing succeeds");
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
assert!(stdout.contains("smooth"), "lists the label: {stdout}");
|
||||
assert!(stdout.contains("a reusable smoothing pattern"), "lists the root doc: {stdout}");
|
||||
let row = stdout.lines().find(|l| l.starts_with("smooth")).expect("smooth row");
|
||||
let fields: Vec<&str> = row.split_whitespace().collect();
|
||||
assert_eq!(fields[1].len(), 12, "the id prefix is 12 hex chars: {row}");
|
||||
assert!(fields[1].chars().all(|c| c.is_ascii_hexdigit()), "the prefix is hex: {row}");
|
||||
}
|
||||
|
||||
/// #315: the node vocabulary listing carries each type's one-line meaning —
|
||||
/// no more bare names that teach nothing.
|
||||
#[test]
|
||||
fn graph_introspect_vocabulary_carries_meanings() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--vocabulary"]);
|
||||
assert!(ok, "vocabulary listing failed: {err}");
|
||||
let sma = out
|
||||
.lines()
|
||||
.find(|l| l.split_whitespace().next() == Some("SMA"))
|
||||
.unwrap_or_else(|| panic!("no SMA line: {out}"));
|
||||
assert!(sma.contains("moving average"), "SMA line carries its meaning: {sma}");
|
||||
for l in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
assert!(l.split_whitespace().count() >= 2, "bare name without meaning: {l}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #315: `--node <T>`'s head line carries the type's meaning beside its label.
|
||||
#[test]
|
||||
fn graph_introspect_node_head_line_carries_the_meaning() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--node", "SMA"]);
|
||||
assert!(ok, "node introspect failed: {err}");
|
||||
let head = out.lines().next().unwrap_or("");
|
||||
assert!(head.contains("moving average"), "head line carries the meaning: {out}");
|
||||
}
|
||||
|
||||
/// #332: `--folds` renders the fold-REGISTRY roster (the same roster
|
||||
/// `aura run --tap TAP=FOLD` resolves labels against), not the aura-std
|
||||
/// `FoldKind` table — lowercase, parseable labels, including the `record`
|
||||
/// entry that has no `FoldKind` counterpart. Previously this surface printed
|
||||
/// capitalized `FoldKind` ids (`Mean`) and omitted `record`, so the help
|
||||
/// text's own discovery surface described input `--tap` refused.
|
||||
#[test]
|
||||
fn graph_introspect_folds_lists_the_fold_registry_roster() {
|
||||
let (out, err, ok) = run(&["graph", "introspect", "--folds"]);
|
||||
assert!(ok, "folds listing failed: {err}");
|
||||
let lines: Vec<&str> = out.lines().filter(|l| !l.trim().is_empty()).collect();
|
||||
assert_eq!(lines.len(), 8, "one line per registry entry (record + 7 folds): {out}");
|
||||
assert!(
|
||||
!out.split_whitespace().any(|w| w == "Mean"),
|
||||
"no capitalized FoldKind id leaks through: {out}"
|
||||
);
|
||||
let record = lines.iter().find(|l| l.starts_with("record ")).expect("record row");
|
||||
assert!(record.contains("series"), "record row meaning: {record}");
|
||||
let mean = lines.iter().find(|l| l.starts_with("mean ")).expect("mean row");
|
||||
assert!(mean.contains("f64") && mean.contains("mean"), "mean row rules + meaning: {mean}");
|
||||
let count = lines.iter().find(|l| l.starts_with("count ")).expect("count row");
|
||||
assert!(count.contains("i64") && count.contains("any"), "count row rules: {count}");
|
||||
}
|
||||
|
||||
/// #315: the metric roster carries each metric's one-line meaning beside its
|
||||
/// applicability tags.
|
||||
#[test]
|
||||
fn process_introspect_metrics_carries_meanings() {
|
||||
let (out, err, ok) = run(&["process", "introspect", "--metrics"]);
|
||||
assert!(ok, "metric roster failed: {err}");
|
||||
let er = out
|
||||
.lines()
|
||||
.find(|l| l.split_whitespace().next() == Some("expectancy_r"))
|
||||
.unwrap_or_else(|| panic!("no expectancy_r line: {out}"));
|
||||
assert!(er.contains("mean realized R"), "meaning text on the roster line: {er}");
|
||||
for l in out.lines().filter(|l| !l.trim().is_empty()) {
|
||||
assert!(l.contains(" — "), "roster line without meaning: {l}");
|
||||
}
|
||||
}
|
||||
|
||||
/// #315: the persisted-tap vocabulary's meanings are readable where the taps
|
||||
/// are advertised — under the `std::presentation` block's slot listing.
|
||||
#[test]
|
||||
fn campaign_introspect_block_presentation_lists_tap_meanings() {
|
||||
let (out, err, ok) = run(&["campaign", "introspect", "--block", "std::presentation"]);
|
||||
assert!(ok, "presentation describe failed: {err}");
|
||||
assert!(out.contains("cumulative pip equity"), "equity tap meaning: {out}");
|
||||
assert!(out.contains("after the cost model"), "net_r_equity tap meaning: {out}");
|
||||
}
|
||||
|
||||
/// #323: `introspect --unwired` enumerates the optional C29 `description`
|
||||
/// slot for both document kinds — the authoring side of the gate is
|
||||
/// advertised where every other slot is.
|
||||
#[test]
|
||||
fn introspect_unwired_lists_the_description_slot() {
|
||||
let dir = std::env::temp_dir().join(format!("aura-selfdesc-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let bare = dir.join("bare.json");
|
||||
std::fs::write(&bare, "{}").unwrap();
|
||||
for family in ["process", "campaign"] {
|
||||
let (out, err, ok) = run(&[family, "introspect", "--unwired", bare.to_str().unwrap()]);
|
||||
assert!(ok, "{family} --unwired failed: {err}");
|
||||
assert!(
|
||||
out.contains("open slot: description"),
|
||||
"{family} --unwired lists the description slot: {out}"
|
||||
);
|
||||
assert!(out.contains("C29-gated"), "{family} hint names the gate: {out}");
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -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(["exec", 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)
|
||||
);
|
||||
}
|
||||
@@ -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)
|
||||
@@ -30,13 +38,13 @@ fn aura(args: &[&str], cwd: &Path) -> Output {
|
||||
#[test]
|
||||
fn project_run_resolves_demo_node_and_is_bit_identical() {
|
||||
let dir = built_project();
|
||||
let a = aura(&["run", "demo_signal.json"], dir);
|
||||
let a = aura(&["exec", "demo_signal.json"], dir);
|
||||
assert!(
|
||||
a.status.success(),
|
||||
"run failed: {}",
|
||||
String::from_utf8_lossy(&a.stderr)
|
||||
);
|
||||
let b = aura(&["run", "demo_signal.json"], dir);
|
||||
let b = aura(&["exec", "demo_signal.json"], dir);
|
||||
assert!(b.status.success());
|
||||
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
|
||||
}
|
||||
@@ -44,7 +52,7 @@ fn project_run_resolves_demo_node_and_is_bit_identical() {
|
||||
#[test]
|
||||
fn project_run_manifest_carries_provenance() {
|
||||
let dir = built_project();
|
||||
let out = aura(&["run", "demo_signal.json"], dir);
|
||||
let out = aura(&["exec", "demo_signal.json"], dir);
|
||||
assert!(out.status.success());
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(&out.stdout).expect("run report is JSON");
|
||||
@@ -75,7 +83,7 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
|
||||
// cwd = the aura-cli crate dir: no Aura.toml anywhere up the tree.
|
||||
let cwd = Path::new(env!("CARGO_MANIFEST_DIR"));
|
||||
let bp = fixture_dir().join("demo_signal.json");
|
||||
let out = aura(&["run", bp.to_str().unwrap()], cwd);
|
||||
let out = aura(&["exec", bp.to_str().unwrap()], cwd);
|
||||
assert!(!out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
@@ -96,31 +104,91 @@ fn outside_a_project_the_demo_blueprint_is_unknown() {
|
||||
/// `<project-root>/runs/`, and no stray `runs/` must appear under the
|
||||
/// subdirectory. This is the property that makes `[paths]` project-relative
|
||||
/// rather than shell-relative (C17).
|
||||
///
|
||||
/// #319 vehicle note: the synthetic in-process family builder this test used
|
||||
/// to drive via bare `sweep --axis` (no `--real`) retired with the quintet —
|
||||
/// no surviving CLI surface produces a family record without touching a real
|
||||
/// archive (`exec`'s campaign leg always resolves against `DataServer`), so
|
||||
/// the vehicle is now a one-cell campaign over the `fresh_project_with_data`
|
||||
/// synthetic `SYMA` archive (mirrors `exec.rs`'s `seed_blueprint` +
|
||||
/// `campaign_doc_json_for` recipe), executed from a project subdirectory.
|
||||
#[test]
|
||||
fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
|
||||
let dir = built_project();
|
||||
let sub = dir.join("src");
|
||||
let (dir, _fixture) = common::fresh_project_with_data();
|
||||
let sub = dir.join("sub");
|
||||
std::fs::create_dir_all(&sub).expect("create invocation subdirectory");
|
||||
let runs_dir = dir.join("runs");
|
||||
std::fs::remove_dir_all(&runs_dir).ok();
|
||||
let closed_bp =
|
||||
format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
|
||||
let closed_bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let reg_out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["graph", "register", &closed_bp, "--name", "proj-anchor-seed"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura graph register");
|
||||
assert!(reg_out.status.success(), "register failed: {}", String::from_utf8_lossy(®_out.stderr));
|
||||
let reg_text = String::from_utf8_lossy(®_out.stdout);
|
||||
let bp_id = reg_text
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered blueprint "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.to_string();
|
||||
|
||||
let process_doc = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "sweep-only",
|
||||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||||
}"#;
|
||||
std::fs::write(dir.join("anchor.process.json"), process_doc).expect("write process doc");
|
||||
let proc_out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["process", "register", "anchor.process.json"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura process register");
|
||||
assert!(proc_out.status.success(), "process register failed: {}", String::from_utf8_lossy(&proc_out.stderr));
|
||||
let proc_text = String::from_utf8_lossy(&proc_out.stdout);
|
||||
let proc_id = proc_text
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered process "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered process ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string();
|
||||
|
||||
// The established `SYMA` window (2024-03..06, fully inside its 2024-01..08
|
||||
// span) other #319 vehicle campaigns use.
|
||||
let campaign_doc = format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "run-seam",
|
||||
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1709251200000, "to_ms": 1719791999999 }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 4] }},
|
||||
"slow.length": {{ "kind": "I64", "values": [8, 16] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#
|
||||
);
|
||||
std::fs::write(dir.join("anchor.campaign.json"), &campaign_doc).expect("write campaign doc");
|
||||
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args([
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
"--axis",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
"proj-anchor",
|
||||
])
|
||||
.args(["exec", "../anchor.campaign.json"])
|
||||
.current_dir(&sub)
|
||||
.output()
|
||||
.expect("spawn aura sweep");
|
||||
.expect("spawn aura exec");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"sweep failed: {}",
|
||||
"exec failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
assert!(
|
||||
@@ -154,7 +222,7 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
|
||||
"badcharter fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let run = aura(&["run", "x.json"], &dir);
|
||||
let run = aura(&["exec", "x.json"], &dir);
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(1),
|
||||
@@ -164,6 +232,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(&["exec", "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(&["exec", "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) {
|
||||
@@ -207,7 +347,7 @@ fn stale_dylib_warns_naming_both_timestamps_but_still_runs() {
|
||||
set_mtime(&dylib, DYLIB_MTIME);
|
||||
set_mtime(&src, SOURCE_MTIME);
|
||||
|
||||
let out = aura(&["run", "demo_signal.json"], dir);
|
||||
let out = aura(&["exec", "demo_signal.json"], dir);
|
||||
|
||||
// Restore sane mtimes before asserting, so a failed assertion does not
|
||||
// leave the shared fixture wedged "stale" (or with a future mtime that
|
||||
@@ -271,7 +411,7 @@ fn nested_nodes_dir() -> PathBuf {
|
||||
fn data_only_project_runs_and_stamps_commit_only_provenance() {
|
||||
let dir = dataonly_dir();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", "signal.json"])
|
||||
.args(["exec", "signal.json"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -307,7 +447,7 @@ fn data_only_project_runs_and_stamps_commit_only_provenance() {
|
||||
fn multi_crate_pointer_refuses_end_to_end() {
|
||||
let dir = multicrate_dir();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", "x.json"])
|
||||
.args(["exec", "x.json"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -347,7 +487,7 @@ fn nodes_pointer_crate_resolves_and_stamps_its_namespace() {
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", "pointer_signal.json"])
|
||||
.args(["exec", "pointer_signal.json"])
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
@@ -368,13 +508,14 @@ 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");
|
||||
std::fs::write(&bp, r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"nosuch::Node"}}],"edges":[],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0}],"source":"F64"}],"output":[{"node":0,"field":0,"name":"bias"}]}}"#).unwrap();
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(["run", bp.to_str().unwrap()])
|
||||
.args(["exec", bp.to_str().unwrap()])
|
||||
.current_dir(&tmp)
|
||||
.output()
|
||||
.expect("spawn aura");
|
||||
@@ -388,7 +529,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();
|
||||
@@ -397,7 +539,7 @@ fn missing_artifact_refuses_with_build_hint() {
|
||||
"[package]\nname = \"nobuild\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[lib]\ncrate-type = [\"cdylib\"]\n\n[workspace]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let out = aura(&["run", "x.json"], &tmp);
|
||||
let out = aura(&["exec", "x.json"], &tmp);
|
||||
assert_eq!(out.status.code(), Some(1), "runtime refusal");
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("cargo build"), "build hint present: {err}");
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
//! E2E: the `aura new` scaffolder (#241 T4). Proves the data-only authoring
|
||||
//! loop — scaffold → `aura run`/`aura sweep`, zero Rust toolchain interaction
|
||||
//! — plus the refusal contract. Scaffolded projects live in per-test temp
|
||||
//! dirs.
|
||||
//! loop — scaffold → `aura exec`, zero Rust toolchain interaction — plus the
|
||||
//! refusal contract. Scaffolded projects live in per-test temp dirs.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Output};
|
||||
|
||||
mod common;
|
||||
|
||||
fn aura(args: &[&str], cwd: &Path) -> Output {
|
||||
Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args(args)
|
||||
@@ -14,8 +15,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
|
||||
@@ -50,17 +62,17 @@ fn init_and_commit(dir: &Path) {
|
||||
///
|
||||
/// Documented deviation from the task text: the plan's wording asks this
|
||||
/// headline to also assert that the run "writes a manifest under `x/runs/`".
|
||||
/// Verified against `dispatch_run`/`run_signal_r` (main.rs, around the
|
||||
/// `tx_req`/`rx_req` channel comment): plain `aura run` never touches the
|
||||
/// trace store — it only prints the `RunReport` to stdout; only
|
||||
/// `sweep`/`mc`/campaign verbs persist (`env.trace_store()`), a pre-#241
|
||||
/// split this task's file list does not touch (giving `run` a store write
|
||||
/// would widen scope beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new`
|
||||
/// and this test file). There is thus no on-disk manifest for THIS verb to
|
||||
/// assert against; the assertion below proves that absence directly instead
|
||||
/// of only asserting it in prose, and the persisted-store property the plan
|
||||
/// wanted is covered by `data_only_project_sweeps_without_any_build` below,
|
||||
/// which does assert `proj.join("runs").exists()`.
|
||||
/// Verified against `dispatch_exec`/`exec_blueprint_leg`/`run_signal_r`
|
||||
/// (main.rs): a single-blueprint `aura exec` never touches the trace store —
|
||||
/// it only prints the `RunReport` to stdout; only exec's campaign leg
|
||||
/// persists (`env.trace_store()`), a pre-#241 split this task's file list
|
||||
/// does not touch (giving the blueprint leg a store write would widen scope
|
||||
/// beyond `scaffold.rs`/`main.rs`'s `NewCmd`/`dispatch_new` and this test
|
||||
/// file). There is thus no on-disk manifest for THIS leg to assert against;
|
||||
/// the assertion below proves that absence directly instead of only
|
||||
/// asserting it in prose, and the persisted-store property the plan wanted
|
||||
/// is covered by `data_only_project_execs_a_campaign_without_any_build`
|
||||
/// below, which does assert `proj.join("runs").exists()`.
|
||||
#[test]
|
||||
fn new_scaffolds_and_runs_the_data_only_loop() {
|
||||
let base = tmp("loop");
|
||||
@@ -91,13 +103,13 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
|
||||
assert!(!proj.join("Cargo.toml").exists(), "data-only project must have no Cargo.toml");
|
||||
assert!(!proj.join("src/lib.rs").exists(), "data-only project must have no src/lib.rs");
|
||||
|
||||
let a = aura(&["run", "blueprints/signal.json"], &proj);
|
||||
let a = aura(&["exec", "blueprints/signal.json"], &proj);
|
||||
assert!(
|
||||
a.status.success(),
|
||||
"run failed: {}",
|
||||
"exec failed: {}",
|
||||
String::from_utf8_lossy(&a.stderr)
|
||||
);
|
||||
let b = aura(&["run", "blueprints/signal.json"], &proj);
|
||||
let b = aura(&["exec", "blueprints/signal.json"], &proj);
|
||||
assert_eq!(a.stdout, b.stdout, "two runs must be byte-identical (C1)");
|
||||
let v: serde_json::Value = serde_json::from_slice(&a.stdout).expect("report is JSON");
|
||||
let p = &v["manifest"]["project"];
|
||||
@@ -105,50 +117,140 @@ fn new_scaffolds_and_runs_the_data_only_loop() {
|
||||
assert!(p.get("namespace").is_none(), "no crate was loaded, namespace must be absent: {p}");
|
||||
assert!(
|
||||
!proj.join("runs").exists(),
|
||||
"plain `aura run` must not persist a trace store (only sweep/mc/campaign do) — \
|
||||
the task text's \"writes a manifest under x/runs/\" does not hold for this verb"
|
||||
"a plain single-blueprint `aura exec` must not persist a trace store (only exec's \
|
||||
campaign leg does) — the task text's \"writes a manifest under x/runs/\" does not \
|
||||
hold for this leg"
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// #241 headline: a fresh data-only project sweeps a dissolved verb (#218's
|
||||
/// gate) with zero build step — using the scaffolded (closed) starter
|
||||
/// blueprint and the exact axis the scaffolded CLAUDE.md advertises.
|
||||
/// The minimal executable pipeline (one sweep stage) — copied verbatim from
|
||||
/// `research_docs.rs`'s constant of the same name.
|
||||
const SWEEP_ONLY_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "sweep-only",
|
||||
"pipeline": [ { "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" } ]
|
||||
}"#;
|
||||
|
||||
/// Writes a tiny, self-contained January-2024 `SYMA` archive into
|
||||
/// `<proj>/data` and points the scaffolded `Aura.toml`'s `[paths]` table at
|
||||
/// it. #319: `exec`'s campaign leg (the surviving surface for what `aura
|
||||
/// sweep --axis` used to do inline) needs a registered instrument, unlike a
|
||||
/// plain blueprint run — this keeps the scaffolder's "zero host setup"
|
||||
/// headline intact (no real archive mount) by minting the archive straight
|
||||
/// into the scaffold instead.
|
||||
fn seed_synthetic_archive(proj: &Path) {
|
||||
let data_dir = proj.join("data");
|
||||
std::fs::create_dir_all(&data_dir).expect("create synthetic archive dir");
|
||||
common::synthetic_data::write_symbol_archive(&data_dir, "SYMA", (2024, 1), (2024, 1));
|
||||
let toml_path = proj.join("Aura.toml");
|
||||
let toml = std::fs::read_to_string(&toml_path).expect("read scaffolded Aura.toml");
|
||||
let toml = toml.replacen("runs = \"runs\"\n", "runs = \"runs\"\ndata = \"data\"\n", 1);
|
||||
std::fs::write(&toml_path, toml).expect("point Aura.toml at the synthetic archive");
|
||||
}
|
||||
|
||||
/// The id extracted from a `registered <kind> <id> (<path>)` line (#194: bare
|
||||
/// id, no `content:` prefix — tolerant of the pre-#194 prefix all the same).
|
||||
fn registered_id(stdout: &str, kind: &str) -> String {
|
||||
let needle = format!("registered {kind} ");
|
||||
stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&needle))
|
||||
.unwrap_or_else(|| panic!("no \"{needle}\" line: {stdout}"))
|
||||
.trim_start_matches(&needle)
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("id")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A one-strategy, one-axis campaign document over `bp_id`/`proc_id` — the
|
||||
/// surviving surface for what `aura sweep --axis` used to do inline over the
|
||||
/// scaffolded starter's bound `fast.length` (#246: a bound param is a default
|
||||
/// an axis overrides). Windowed fully inside `seed_synthetic_archive`'s
|
||||
/// January-2024 `SYMA` archive.
|
||||
fn one_axis_campaign_doc(bp_id: &str, proc_id: &str, axis_name: &str, axis_values: &str) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "quickstart",
|
||||
"data": {{ "instruments": ["SYMA"], "windows": [ {{ "from_ms": 1704067200000, "to_ms": 1706745599999 }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }},
|
||||
"axes": {{ "{axis_name}": {{ "kind": "I64", "values": [{axis_values}] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||
"seed": 1,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#
|
||||
)
|
||||
}
|
||||
|
||||
/// #241 headline: a fresh data-only project executes a small campaign — #319's
|
||||
/// surviving surface for the retired `aura sweep` — with zero build step,
|
||||
/// using the scaffolded (closed) starter blueprint and the exact axis the
|
||||
/// scaffolded CLAUDE.md advertises. Registering the blueprint straight (`aura
|
||||
/// graph register`, not a sweep side-effect) and seeding a tiny synthetic
|
||||
/// archive (`seed_synthetic_archive`) keeps the loop free of any host data
|
||||
/// mount, matching the pre-retirement headline.
|
||||
///
|
||||
/// #246: the closed starter IS the sweep target — a bound param is a default
|
||||
/// an axis overrides.
|
||||
/// #246: the closed starter IS the campaign axis target — a bound param is a
|
||||
/// default an axis overrides.
|
||||
#[test]
|
||||
fn data_only_project_sweeps_without_any_build() {
|
||||
fn data_only_project_execs_a_campaign_without_any_build() {
|
||||
let base = tmp("sweep");
|
||||
let new = aura(&["new", "scratch"], &base);
|
||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||
let proj = base.join("scratch");
|
||||
let out = aura(
|
||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
|
||||
&proj,
|
||||
);
|
||||
seed_synthetic_archive(&proj);
|
||||
|
||||
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
|
||||
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr));
|
||||
let bp_id = registered_id(&String::from_utf8_lossy(®.stdout), "blueprint");
|
||||
|
||||
std::fs::write(proj.join("quickstart.process.json"), SWEEP_ONLY_PROCESS_DOC)
|
||||
.expect("write process doc");
|
||||
let preg = aura(&["process", "register", "quickstart.process.json"], &proj);
|
||||
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
|
||||
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
|
||||
|
||||
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4");
|
||||
std::fs::write(proj.join("quickstart.campaign.json"), &doc).expect("write campaign doc");
|
||||
let out = aura(&["exec", "quickstart.campaign.json"], &proj);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(proj.join("runs").exists(), "store must land inside the project");
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
}
|
||||
|
||||
/// Property (#246): the scaffold's sweep quickstart genuinely re-opens the
|
||||
/// Property (#246): the scaffold's campaign quickstart genuinely re-opens the
|
||||
/// bound `fast.length` param — the persisted family has exactly one member
|
||||
/// per axis value, not a single collapsed member. A silent regression where
|
||||
/// the bound-param override is dropped (member always built from the
|
||||
/// blueprint's own default) would still exit 0 and still create a `runs`
|
||||
/// store, so `data_only_project_sweeps_without_any_build` above cannot catch
|
||||
/// it; only the member count can.
|
||||
/// store, so `data_only_project_execs_a_campaign_without_any_build` above
|
||||
/// cannot catch it; only the member count can.
|
||||
#[test]
|
||||
fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
|
||||
fn data_only_project_campaign_over_the_starter_opens_one_member_per_axis_value() {
|
||||
let base = tmp("sweep-members");
|
||||
let new = aura(&["new", "scratch"], &base);
|
||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||
let proj = base.join("scratch");
|
||||
let out = aura(
|
||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
|
||||
&proj,
|
||||
);
|
||||
seed_synthetic_archive(&proj);
|
||||
|
||||
let reg = aura(&["graph", "register", "blueprints/signal.json"], &proj);
|
||||
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr));
|
||||
let bp_id = registered_id(&String::from_utf8_lossy(®.stdout), "blueprint");
|
||||
|
||||
std::fs::write(proj.join("members.process.json"), SWEEP_ONLY_PROCESS_DOC)
|
||||
.expect("write process doc");
|
||||
let preg = aura(&["process", "register", "members.process.json"], &proj);
|
||||
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
|
||||
let proc_id = registered_id(&String::from_utf8_lossy(&preg.stdout), "process");
|
||||
|
||||
let doc = one_axis_campaign_doc(&bp_id, &proc_id, "fast.length", "2, 4, 8");
|
||||
std::fs::write(proj.join("members.campaign.json"), &doc).expect("write campaign doc");
|
||||
let out = aura(&["exec", "members.campaign.json"], &proj);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let fams = aura(&["runs", "families"], &proj);
|
||||
assert!(fams.status.success(), "runs families exit: {:?}", fams.status);
|
||||
@@ -290,10 +392,10 @@ fn new_outside_a_work_tree_leaves_a_resolvable_head() {
|
||||
|
||||
// ...so the first quickstart run stamps a commit into the manifest, not
|
||||
// an empty provenance block.
|
||||
let run = aura(&["run", "blueprints/signal.json"], &proj);
|
||||
let run = aura(&["exec", "blueprints/signal.json"], &proj);
|
||||
assert!(
|
||||
run.status.success(),
|
||||
"run failed: {}",
|
||||
"exec failed: {}",
|
||||
String::from_utf8_lossy(&run.stderr)
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_slice(&run.stdout).expect("report is JSON");
|
||||
|
||||
@@ -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.
|
||||
@@ -149,72 +149,110 @@ fn fresh_project() -> (&'static PathBuf, (RunsCleanup, MutexGuard<'static, ()>))
|
||||
}
|
||||
|
||||
/// Acceptance box 1 (#235): a project-authored node with an OPEN param is
|
||||
/// discoverable as a sweep axis, sweeps over real data into a persisted family,
|
||||
/// and that family reproduces bit-identically — the defining research loop of a
|
||||
/// genuine external project ("sweep my OWN node's knob"), proven end to end.
|
||||
/// discoverable as an axis, execs over real data (a campaign — #319: the
|
||||
/// surviving surface for what `aura sweep --axis` used to do inline) into a
|
||||
/// persisted family, and that family reproduces bit-identically — the
|
||||
/// defining research loop of a genuine external project ("vary my OWN node's
|
||||
/// knob"), proven end to end.
|
||||
#[test]
|
||||
fn project_node_open_param_sweeps_and_reproduces() {
|
||||
let (dir, _g) = fresh_project();
|
||||
|
||||
// (a) `--list-axes` discovers the PROJECT node's own open knob, followed by
|
||||
// the fixture's three BOUND params as `default=`-lines (#246: every bound
|
||||
// param is an equally re-openable `--axis`, listed regardless of how many
|
||||
// knobs happen to be open alongside it). NOT gated: enumerating axes needs
|
||||
// no data, so the discoverability half runs on every host. The wrapped
|
||||
// name is `<blueprint>.<node>.<param>`.
|
||||
let axes = aura_in(dir, &["sweep", "blueprints/scaled_open.json", "--list-axes"]);
|
||||
// (a) `graph introspect --params` discovers the PROJECT node's own open
|
||||
// knob, followed by the fixture's three BOUND params as `default=`-lines
|
||||
// (#246: every bound param is an equally re-openable axis, listed
|
||||
// regardless of how many knobs happen to be open alongside it). NOT
|
||||
// gated: enumerating axes needs no data, so the discoverability half runs
|
||||
// on every host. The name is RAW `<node>.<param>` (#328: the blueprint
|
||||
// name stays out of axis paths).
|
||||
let axes = aura_in(dir, &["graph", "introspect", "--params", "blueprints/scaled_open.json"]);
|
||||
assert!(
|
||||
axes.status.success(),
|
||||
"--list-axes stderr: {}",
|
||||
"graph introspect --params stderr: {}",
|
||||
String::from_utf8_lossy(&axes.stderr)
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&axes.stdout),
|
||||
"scaled_signal.gain.factor:F64\n\
|
||||
scaled_signal.fast.length:I64 default=2\n\
|
||||
scaled_signal.slow.length:I64 default=4\n\
|
||||
scaled_signal.bias.scale:F64 default=0.5\n",
|
||||
"gain.factor:F64\n\
|
||||
fast.length:I64 default=2\n\
|
||||
slow.length:I64 default=4\n\
|
||||
bias.scale:F64 default=0.5\n",
|
||||
"the project node's own OPEN param is the one open axis, alongside the \
|
||||
fixture's bound defaults; stderr: {}",
|
||||
String::from_utf8_lossy(&axes.stderr)
|
||||
);
|
||||
|
||||
// The real-data sweep/reproduce legs are archive-gated.
|
||||
// The real-data campaign/reproduce legs are archive-gated.
|
||||
if !local_data_present() {
|
||||
eprintln!("skip: no local data at {}", data_server::DEFAULT_DATA_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
// (b) sweep the project node's knob over real GER40 data -> two members.
|
||||
let sweep = aura_in(
|
||||
dir,
|
||||
&[
|
||||
"sweep",
|
||||
"blueprints/scaled_open.json",
|
||||
"--real",
|
||||
"GER40",
|
||||
"--from",
|
||||
GER40_FROM_MS,
|
||||
"--to",
|
||||
GER40_TO_MS,
|
||||
"--axis",
|
||||
"scaled_signal.gain.factor=0.5,1.0",
|
||||
"--name",
|
||||
"knob",
|
||||
],
|
||||
// (b) exec a campaign over the project node's knob, real GER40 data ->
|
||||
// two members. Register the blueprint straight (not a sweep side-effect)
|
||||
// and a minimal sweep-only process, then reference both by content id.
|
||||
let reg = aura_in(dir, &["graph", "register", "blueprints/scaled_open.json"]);
|
||||
assert!(reg.status.success(), "graph register stderr: {}", String::from_utf8_lossy(®.stderr));
|
||||
let reg_out = String::from_utf8_lossy(®.stdout).into_owned();
|
||||
let bp_id = reg_out
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered blueprint "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered blueprint ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("blueprint id")
|
||||
.to_string();
|
||||
|
||||
std::fs::write(dir.join("knob-reproduce.process.json"), SWEEP_ONLY_PROCESS)
|
||||
.expect("write process doc");
|
||||
let preg = aura_in(dir, &["process", "register", "knob-reproduce.process.json"]);
|
||||
assert!(preg.status.success(), "process register stderr: {}", String::from_utf8_lossy(&preg.stderr));
|
||||
let preg_out = String::from_utf8_lossy(&preg.stdout).into_owned();
|
||||
let proc_id = preg_out
|
||||
.lines()
|
||||
.find(|l| l.starts_with("registered process "))
|
||||
.expect("register line")
|
||||
.trim_start_matches("registered process ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("process id")
|
||||
.to_string();
|
||||
|
||||
let campaign = format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "knob-reproduce",
|
||||
"data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
|
||||
"axes": {{ "gain.factor": {{ "kind": "F64", "values": [0.5, 1.0] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#,
|
||||
from = GER40_FROM_MS,
|
||||
to = GER40_TO_MS,
|
||||
bp = bp_id,
|
||||
proc = proc_id,
|
||||
);
|
||||
std::fs::write(dir.join("knob-reproduce.campaign.json"), &campaign).expect("write campaign doc");
|
||||
|
||||
let exec = aura_in(dir, &["exec", "knob-reproduce.campaign.json"]);
|
||||
assert!(
|
||||
sweep.status.success(),
|
||||
"sweep stderr: {}",
|
||||
String::from_utf8_lossy(&sweep.stderr)
|
||||
exec.status.success(),
|
||||
"exec stderr: {}",
|
||||
String::from_utf8_lossy(&exec.stderr)
|
||||
);
|
||||
let stdout = String::from_utf8_lossy(&sweep.stdout).into_owned();
|
||||
let lines: Vec<&str> = stdout.lines().collect();
|
||||
let stdout = String::from_utf8_lossy(&exec.stdout).into_owned();
|
||||
let lines: Vec<&str> = stdout.lines().filter(|l| l.starts_with(r#"{"family_id":"#)).collect();
|
||||
assert_eq!(lines.len(), 2, "one member line per factor point: {stdout}");
|
||||
|
||||
// Each member's manifest carries the swept PROJECT-node param binding under
|
||||
// its wrapped name — the load-bearing proof that it is MY node's knob that
|
||||
// varied across the family, not some incidental std axis.
|
||||
// Each member's manifest carries the swept PROJECT-node param binding
|
||||
// under its RAW name (this cycle's tidy fix, #328 batch 2: the
|
||||
// real/campaign route's own `manifest.params` mints raw too now) — the
|
||||
// load-bearing proof that it is MY node's knob that varied across the
|
||||
// family, not some incidental std axis.
|
||||
for (line, factor) in lines.iter().zip([0.5_f64, 1.0]) {
|
||||
let v: serde_json::Value = serde_json::from_str(line).expect("member line parses as JSON");
|
||||
assert_eq!(
|
||||
@@ -227,7 +265,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
.expect("manifest.params is an array");
|
||||
let bound = params
|
||||
.iter()
|
||||
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
|
||||
.find(|p| p[0].as_str() == Some("gain.factor"))
|
||||
.and_then(|p| p[1]["F64"].as_f64());
|
||||
assert_eq!(
|
||||
bound,
|
||||
@@ -322,7 +360,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
|
||||
);
|
||||
std::fs::write(dir.join("knob.campaign.json"), &campaign).expect("write campaign doc");
|
||||
|
||||
let run = aura_in(dir, &["campaign", "run", "knob.campaign.json"]);
|
||||
let run = aura_in(dir, &["exec", "knob.campaign.json"]);
|
||||
let out = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&run.stdout),
|
||||
@@ -331,7 +369,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");
|
||||
|
||||
+1200
-145
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
//! #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(["exec", 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"]));
|
||||
}
|
||||
|
||||
/// #310: the measurement arm honours the `--tap` selector — a `last`
|
||||
/// fold lands one summary row instead of the full series.
|
||||
#[test]
|
||||
fn measurement_run_honours_the_tap_selector() {
|
||||
let cwd = temp_cwd("selector-last");
|
||||
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(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=last"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
|
||||
.expect("read fold trace");
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
||||
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
||||
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
||||
// last warm SMA(2) value over the synthetic stream = (1.0097 + 1.0092) / 2
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
let expected = (1.0097_f64 + 1.0092) / 2.0;
|
||||
assert!((got - expected).abs() < 1e-9, "last row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
/// #331 delta re-review, the third authored-file intake route: `dispatch_run`
|
||||
/// loads a FRESH FILE via `blueprint_from_json` and, on this measurement arm,
|
||||
/// feeds `signal.name()` straight into `bind_tap_plan` -> `TraceStore::begin_run`
|
||||
/// (`trace_store.rs` joins the name unsanitized) — a hand-crafted envelope with
|
||||
/// `"name":"../x"` used to reach `--tap fast_tap=record` and escape
|
||||
/// `runs/traces/<name>/` for `runs/x/` (one level up, since `traces/../x`
|
||||
/// normalizes there) instead of refusing at the same root-name choke point
|
||||
/// `register`/`introspect --content-id <FILE>` already gate through. This pins
|
||||
/// the closed route: exit nonzero (dispatch_run's own bad-input-file
|
||||
/// convention, exit 2), the shared `name_gate_fault_prose` wording, and — the
|
||||
/// property that actually matters — the escaped directory is never created.
|
||||
#[test]
|
||||
fn run_refuses_a_hand_crafted_envelope_with_a_bad_root_name_before_any_trace_write() {
|
||||
let cwd = temp_cwd("bad-root-name");
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(&measurement_blueprint_json()).expect("parse measurement blueprint");
|
||||
v["blueprint"]["name"] = serde_json::json!("../x");
|
||||
let bad = serde_json::to_string(&v).expect("re-serialize with a shape-violating root name");
|
||||
let bp_path = cwd.join("bad-root-name.json");
|
||||
std::fs::write(&bp_path, &bad).expect("write bad-root-name blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=record"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(2),
|
||||
"a shape-violating root name refuses at dispatch_run's own bad-file exit code: {:?} stderr={}",
|
||||
out.status,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains(r#"blueprint name "../x" is invalid"#),
|
||||
"the shared name_gate_fault_prose wording names the offending root name: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
out.stdout.is_empty(),
|
||||
"a refused run must not print a report: {:?}",
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
);
|
||||
|
||||
// The property that actually matters: the escaped write target (one level
|
||||
// up from `traces/`, since `trace_store.rs` joins the name unsanitized)
|
||||
// was never created — the gate fires before `begin_run` ever touches disk.
|
||||
assert!(
|
||||
!cwd.join("runs/x").exists(),
|
||||
"the escaped trace directory must never be created"
|
||||
);
|
||||
assert!(
|
||||
!cwd.join("runs/traces").exists(),
|
||||
"no trace directory of any shape is written on a refused run"
|
||||
);
|
||||
}
|
||||
@@ -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(["exec", 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,422 @@
|
||||
//! #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(["exec", 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(["exec", &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")
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json` blueprint with two distinctly named
|
||||
/// declared taps: `fast_tap` on node 0 ("fast", SMA(2)) and `slow_tap` on
|
||||
/// node 1 ("slow") — the smallest fixture on which an explicit `--tap`
|
||||
/// plan and the record-all default can be compared file-by-file (#310).
|
||||
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": "fast_tap", "from": {"node": 0, "field": 0}},
|
||||
{"name": "slow_tap", "from": {"node": 1, "field": 0}},
|
||||
]);
|
||||
serde_json::to_string(&v).expect("re-serialize two-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(["exec", 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(["exec", 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(["exec", 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");
|
||||
}
|
||||
|
||||
/// #310: `--tap fast_tap=mean` subscribes the declared tap to the `mean`
|
||||
/// fold instead of the record-all default — the trace store holds ONE
|
||||
/// summary row whose value is the mean of the full SMA(2) series, and
|
||||
/// `index.json` lists exactly the subscribed tap.
|
||||
#[test]
|
||||
fn run_tap_selector_persists_a_fold_summary_row() {
|
||||
let cwd = temp_cwd("tap-selector-mean");
|
||||
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(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let index_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
|
||||
.expect("read index.json");
|
||||
let index: serde_json::Value = serde_json::from_str(&index_text).expect("parse index.json");
|
||||
assert_eq!(index["taps"], serde_json::json!(["fast_tap"]));
|
||||
|
||||
let trace_text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/fast_tap.json"))
|
||||
.expect("read fold trace");
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse fold trace");
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
let rows = trace["columns"][0].as_array().expect("columns[0] array");
|
||||
assert_eq!(rows.len(), 1, "a fold lands exactly one summary row");
|
||||
let sma: Vec<f64> = (1..R_SMA_PRICES.len())
|
||||
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
||||
.collect();
|
||||
let expected = sma.iter().sum::<f64>() / sma.len() as f64;
|
||||
let got = rows[0].as_f64().expect("f64 row");
|
||||
assert!((got - expected).abs() < 1e-9, "mean row: got {got}, expected {expected}");
|
||||
}
|
||||
|
||||
/// #310: an all-`record` explicit plan is byte-identical to the no-flag
|
||||
/// record-all default — the selector replaces the default without
|
||||
/// changing what `record` itself writes (C1 determinism across runs).
|
||||
#[test]
|
||||
fn run_explicit_record_plan_matches_the_record_all_default() {
|
||||
let cwd_a = temp_cwd("record-default");
|
||||
let cwd_b = temp_cwd("record-explicit");
|
||||
for cwd in [&cwd_a, &cwd_b] {
|
||||
std::fs::write(cwd.join("two_tap.json"), two_tap_blueprint_json())
|
||||
.expect("write two-tap blueprint");
|
||||
}
|
||||
let run = |cwd: &std::path::Path, extra: &[&str]| {
|
||||
let mut args = vec!["exec", "two_tap.json"];
|
||||
args.extend_from_slice(extra);
|
||||
let out = Command::new(BIN)
|
||||
.args(&args)
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
run(&cwd_a, &[]);
|
||||
run(&cwd_b, &["--tap", "fast_tap=record", "--tap", "slow_tap=record"]);
|
||||
|
||||
for file in ["fast_tap.json", "slow_tap.json"] {
|
||||
let a = std::fs::read(cwd_a.join("runs/traces/sma_signal").join(file)).expect("default trace");
|
||||
let b = std::fs::read(cwd_b.join("runs/traces/sma_signal").join(file)).expect("explicit trace");
|
||||
assert_eq!(a, b, "{file} must be byte-identical across default and explicit record plans");
|
||||
}
|
||||
let taps_of = |cwd: &std::path::Path| -> serde_json::Value {
|
||||
let text = std::fs::read_to_string(cwd.join("runs/traces/sma_signal/index.json"))
|
||||
.expect("read index.json");
|
||||
serde_json::from_str::<serde_json::Value>(&text).expect("parse index.json")["taps"].clone()
|
||||
};
|
||||
assert_eq!(taps_of(&cwd_a), taps_of(&cwd_b));
|
||||
}
|
||||
|
||||
/// #310: an unknown fold label is refused through the registry's
|
||||
/// roster-enumerating refusal, before any store write.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_an_unknown_label_with_the_roster() {
|
||||
let cwd = temp_cwd("tap-selector-unknown-label");
|
||||
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(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=medain"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(!out.status.success(), "an unknown fold label must refuse");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("medain") && stderr.contains("mean"),
|
||||
"refusal must name the label and enumerate the roster: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
||||
}
|
||||
|
||||
/// #310/#333: a selection naming a tap the blueprint does not declare is
|
||||
/// refused typed (UndeclaredTap), before any store write, and the refusal
|
||||
/// enumerates the blueprint's declared taps (the same way the unknown-fold
|
||||
/// refusal enumerates the fold-registry roster) — on the two-tap fixture,
|
||||
/// both `fast_tap` and `slow_tap` must be named so the author does not have
|
||||
/// to reopen the blueprint JSON.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_an_undeclared_tap() {
|
||||
let cwd = temp_cwd("tap-selector-undeclared");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "nope=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(!out.status.success(), "an undeclared tap must refuse");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("nope"), "refusal must name the offending tap: {stderr}");
|
||||
assert!(
|
||||
stderr.contains("fast_tap") && stderr.contains("slow_tap"),
|
||||
"refusal must enumerate the blueprint's declared taps: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not write the store");
|
||||
}
|
||||
|
||||
/// #334: an explicit `--tap` plan that leaves a declared tap unbound emits
|
||||
/// the C14 benign skipped-tap note on stderr, per unbound tap, naming it —
|
||||
/// exit code stays 0 (this is a note, not a refusal). `fast_tap` (subscribed)
|
||||
/// gets no such note; `slow_tap` (left unlisted) does.
|
||||
#[test]
|
||||
fn run_tap_selector_notes_unbound_declared_taps_on_stderr() {
|
||||
let cwd = temp_cwd("tap-selector-unbound-note");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", bp_path.to_str().expect("utf-8 path"), "--tap", "fast_tap=mean"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("aura: note: declared tap \"slow_tap\" unbound this run"),
|
||||
"stderr must note the unbound declared tap: {stderr}"
|
||||
);
|
||||
assert!(
|
||||
!stderr.contains("\"fast_tap\" unbound"),
|
||||
"the subscribed tap must not be noted as unbound: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #334: the record-all default (no `--tap` flag) leaves no declared tap
|
||||
/// unbound — nothing is skipped, so the note must never appear.
|
||||
#[test]
|
||||
fn run_with_no_tap_flag_emits_no_unbound_note() {
|
||||
let cwd = temp_cwd("tap-selector-default-no-note");
|
||||
let bp_path = cwd.join("two_tap.json");
|
||||
std::fs::write(&bp_path, two_tap_blueprint_json()).expect("write two-tap blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["exec", 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 stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
!stderr.contains("unbound this run"),
|
||||
"the record-all default must never note a skipped tap: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// #310: a malformed or duplicate `--tap` is a usage error (exit 2)
|
||||
/// before anything runs.
|
||||
#[test]
|
||||
fn run_tap_selector_refuses_malformed_and_duplicate_pairs() {
|
||||
let cwd = temp_cwd("tap-selector-usage");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
for args in [
|
||||
vec!["--tap", "fast_tapmean"],
|
||||
vec!["--tap", "fast_tap=mean", "--tap", "fast_tap=last"],
|
||||
] {
|
||||
let mut full = vec!["exec", bp_path.to_str().expect("utf-8 path")];
|
||||
full.extend(args);
|
||||
let out = Command::new(BIN)
|
||||
.args(&full)
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert_eq!(out.status.code(), Some(2), "usage errors exit 2");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(stderr.contains("--tap"), "usage message names the flag: {stderr}");
|
||||
assert!(!cwd.join("runs").exists());
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -51,8 +52,8 @@ fn vol_stop_inner(knobs: Option<(i64, f64)>) -> Composite {
|
||||
let sqrt = g.add(Sqrt::builder()); // → σ (price units)
|
||||
// k·σ: weights[0] bound (Some) or open and named `stop_k` (None).
|
||||
let scale = g.add(match knobs {
|
||||
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
|
||||
None => LinComb::builder(1).named("stop_k"),
|
||||
Some((_, k)) => LinComb::configured(1).bind("weights[0]", Scalar::f64(k)),
|
||||
None => LinComb::configured(1).named("stop_k"),
|
||||
});
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
@@ -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,
|
||||
)
|
||||
@@ -166,7 +174,7 @@ pub fn cost_graph(cost_nodes: Vec<PrimitiveBuilder>) -> Composite {
|
||||
let entry = g.input_role("entry_price");
|
||||
let stop = g.input_role("stop_price");
|
||||
|
||||
let agg = g.add(CostSum::builder(n));
|
||||
let agg = g.add(CostSum::configured(n));
|
||||
|
||||
// Per-role geometry fan targets, collected across all nodes, fed once per role.
|
||||
let mut closed_t = Vec::with_capacity(n);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,10 @@ publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
# ArgValue::Tz carries chrono_tz::Tz (the closed IANA table, exact names only) —
|
||||
# already a workspace dependency of four other crates (see docs/specs/
|
||||
# construction-args.md § Dependency note).
|
||||
chrono-tz = { version = "0.10", default-features = false }
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
|
||||
@@ -44,7 +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,
|
||||
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
|
||||
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder,
|
||||
};
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
|
||||
@@ -112,6 +112,7 @@ pub struct PrimitiveBuilder {
|
||||
instance_name: Option<String>,
|
||||
schema: NodeSchema,
|
||||
bound: Vec<BoundParam>,
|
||||
args: ArgsState, // NEW: `Plain` for every existing (non-arg-bearing) node
|
||||
// The build closure's type is exactly the recipe contract (a param slice in, a
|
||||
// boxed node out); a type alias would not clarify it.
|
||||
#[allow(clippy::type_complexity)]
|
||||
@@ -127,7 +128,108 @@ impl PrimitiveBuilder {
|
||||
schema: NodeSchema,
|
||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||
) -> Self {
|
||||
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
|
||||
Self { name, instance_name: None, schema, bound: Vec::new(), args: ArgsState::Plain, build: Box::new(build) }
|
||||
}
|
||||
|
||||
/// An arg-bearing recipe: introspectable (doc + declared `ArgSpec`s) but
|
||||
/// not yet constructible. `schema` carries only the doc line (empty
|
||||
/// inputs/output/params — the real signature forms inside `make`, once
|
||||
/// `try_args` has parsed values to hand it); `build` on a pending builder
|
||||
/// panics (the `bind` panic-contract twin) — the data path always goes
|
||||
/// through `try_args` first, so no pending builder ever reaches `build`.
|
||||
pub fn pending(
|
||||
name: &'static str,
|
||||
doc: &'static str,
|
||||
specs: &'static [ArgSpec],
|
||||
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
|
||||
) -> Self {
|
||||
Self {
|
||||
name,
|
||||
instance_name: None,
|
||||
schema: NodeSchema { doc, ..Default::default() },
|
||||
bound: Vec::new(),
|
||||
args: ArgsState::Pending { specs, make },
|
||||
build: Box::new(move |_| {
|
||||
panic!(
|
||||
"PrimitiveBuilder::build: `{name}` is an unconfigured arg-bearing type — \
|
||||
call try_args first"
|
||||
)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate raw `(name, value)` pairs against the declared `ArgSpec`s
|
||||
/// (strict form — see `ArgKind::parse`), then run `make`, returning the
|
||||
/// configured builder (`args: Configured{..}`) with this builder's
|
||||
/// `instance_name` carried over. `Plain` + empty raw is `Ok(self)`
|
||||
/// (uniform op application, #157's try_bind precedent); `Plain` +
|
||||
/// non-empty raw is `NotArgBearing`. Validation order (deterministic):
|
||||
/// first unknown arg name, then a duplicate, then the first spec-order
|
||||
/// missing arg, then per-kind parse failures (`BadValue`).
|
||||
pub fn try_args(self, raw: &[(String, String)]) -> Result<Self, ArgOpError> {
|
||||
let (specs, make) = match &self.args {
|
||||
ArgsState::Plain | ArgsState::Configured { .. } => {
|
||||
return if raw.is_empty() { Ok(self) } else { Err(ArgOpError::NotArgBearing) };
|
||||
}
|
||||
ArgsState::Pending { specs, make } => (*specs, *make),
|
||||
};
|
||||
|
||||
for (name, _) in raw {
|
||||
if !specs.iter().any(|s| s.name == name.as_str()) {
|
||||
return Err(ArgOpError::UnknownArg(name.clone()));
|
||||
}
|
||||
}
|
||||
for i in 0..raw.len() {
|
||||
if raw[i + 1..].iter().any(|(n, _)| n == &raw[i].0) {
|
||||
return Err(ArgOpError::DuplicateArg(raw[i].0.clone()));
|
||||
}
|
||||
}
|
||||
for spec in specs {
|
||||
if !raw.iter().any(|(n, _)| n.as_str() == spec.name) {
|
||||
return Err(ArgOpError::MissingArg(spec.name.to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
let mut values = Vec::with_capacity(specs.len());
|
||||
let mut accepted = Vec::with_capacity(specs.len());
|
||||
for spec in specs {
|
||||
let raw_val = &raw.iter().find(|(n, _)| n.as_str() == spec.name).expect("presence checked above").1;
|
||||
let value = spec.kind.parse(raw_val).map_err(|()| ArgOpError::BadValue {
|
||||
arg: spec.name.to_string(),
|
||||
kind: spec.kind,
|
||||
got: raw_val.clone(),
|
||||
})?;
|
||||
values.push((spec.name.to_string(), value));
|
||||
accepted.push(ConstructionArg { name: spec.name.to_string(), value: raw_val.clone() });
|
||||
}
|
||||
|
||||
let mut built = make(&values);
|
||||
built.instance_name = self.instance_name;
|
||||
built.args = ArgsState::Configured { values: accepted };
|
||||
Ok(built)
|
||||
}
|
||||
|
||||
/// The declared construction-arg specs (a view into the pending recipe);
|
||||
/// empty unless this builder `is_pending()`.
|
||||
pub fn arg_specs(&self) -> &[ArgSpec] {
|
||||
match &self.args {
|
||||
ArgsState::Pending { specs, .. } => specs,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The accepted, verbatim construction-arg pairs (the serialize surface);
|
||||
/// empty unless this builder was configured via `try_args`.
|
||||
pub fn construction_args(&self) -> &[ConstructionArg] {
|
||||
match &self.args {
|
||||
ArgsState::Configured { values } => values,
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// True for an arg-bearing recipe not yet configured via `try_args`.
|
||||
pub fn is_pending(&self) -> bool {
|
||||
matches!(self.args, ArgsState::Pending { .. })
|
||||
}
|
||||
/// Set this node instance's explicit name. Must be non-empty: the name forms
|
||||
/// a knob-address segment (`<composite>.<name>.<param>`, via `node_name()` in
|
||||
@@ -324,7 +426,10 @@ impl PrimitiveBuilder {
|
||||
/// A fallible-bind fault — the `Result` twin of `bind`'s panics, for the
|
||||
/// data-level construction surface (`GraphSession`, #157). `bind` keeps its
|
||||
/// panic contract (and its pinned messages); `try_bind` reports the same three
|
||||
/// conditions as values.
|
||||
/// conditions as values. `AlreadyGanged` is a fourth condition `try_bind`
|
||||
/// itself never raises — only `Composite::bind_path`'s gang guard (#317
|
||||
/// follow-up) does, refusing a ganged member's raw path at the use seam
|
||||
/// before it silently de-fuses the gang.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BindOpError {
|
||||
/// No still-open param has this name.
|
||||
@@ -333,6 +438,141 @@ pub enum BindOpError {
|
||||
AmbiguousParam(String),
|
||||
/// The value's kind does not equal the param's declared kind.
|
||||
KindMismatch { param: String, expected: ScalarKind, got: ScalarKind },
|
||||
/// `param` (the full qualified path) is a member of the gang named
|
||||
/// `gang` — binding it directly would freeze that one member while the
|
||||
/// gang's public knob keeps driving its siblings. Bind the gang's own
|
||||
/// public knob instead (accepted residue: unbindable at the use seam,
|
||||
/// #317).
|
||||
AlreadyGanged { param: String, gang: String },
|
||||
}
|
||||
|
||||
/// Closed construction-arg kinds (spec §Closedness) — deliberately NOT
|
||||
/// `ScalarKind`: the four scalar kinds are the streamed set (invariant 4); args
|
||||
/// are bootstrap metadata, never streamed, so the two closedness axes stay
|
||||
/// separate types.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum ArgKind {
|
||||
/// An IANA timezone name (`chrono_tz::Tz`'s own parser — exact, case-sensitive).
|
||||
Tz,
|
||||
/// A local wall-clock time, strict zero-padded `HH:MM` (00-23 / 00-59).
|
||||
TimeOfDay,
|
||||
/// A plain positive decimal count (>= 1, no leading zeros).
|
||||
Count,
|
||||
}
|
||||
|
||||
impl ArgKind {
|
||||
/// Parse `raw` in this kind's strict canonical form. The accepted form IS
|
||||
/// the canonical form (spec §Closedness) — there is no normalization
|
||||
/// layer, so a near-miss string refuses rather than being silently
|
||||
/// rewritten (content ids stay input-variance-free by refusal).
|
||||
// The spec's pinned shape (`Result<ArgValue, ()>`, mirroring `ArgOpError`'s
|
||||
// caller-side `BadValue` wrapping the plain refusal): the unit error carries
|
||||
// no information of its own — `try_args` is the only caller and always maps
|
||||
// it to `ArgOpError::BadValue`, which is where the real detail lives.
|
||||
#[allow(clippy::result_unit_err)]
|
||||
pub fn parse(&self, raw: &str) -> Result<ArgValue, ()> {
|
||||
match self {
|
||||
ArgKind::Tz => raw.parse::<chrono_tz::Tz>().map(ArgValue::Tz).map_err(|_| ()),
|
||||
ArgKind::TimeOfDay => {
|
||||
let bytes = raw.as_bytes();
|
||||
if bytes.len() != 5 || bytes[2] != b':' {
|
||||
return Err(());
|
||||
}
|
||||
let (h, m) = (&raw[0..2], &raw[3..5]);
|
||||
if !h.bytes().all(|b| b.is_ascii_digit()) || !m.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(());
|
||||
}
|
||||
let hour: u32 = h.parse().map_err(|_| ())?;
|
||||
let minute: u32 = m.parse().map_err(|_| ())?;
|
||||
if hour > 23 || minute > 59 {
|
||||
return Err(());
|
||||
}
|
||||
Ok(ArgValue::TimeOfDay { hour, minute })
|
||||
}
|
||||
ArgKind::Count => {
|
||||
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return Err(());
|
||||
}
|
||||
if raw.len() > 1 && raw.starts_with('0') {
|
||||
return Err(()); // leading zero: not the canonical form
|
||||
}
|
||||
let n: usize = raw.parse().map_err(|_| ())?;
|
||||
if n == 0 {
|
||||
return Err(()); // Count is >= 1
|
||||
}
|
||||
Ok(ArgValue::Count(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The static, per-kind hint line (introspection surface, C29) — a closed
|
||||
/// table keyed only by `ArgKind`, never per-entry freetext.
|
||||
pub fn hint(&self) -> &'static str {
|
||||
match self {
|
||||
ArgKind::Tz => "IANA timezone name, e.g. Europe/Berlin",
|
||||
ArgKind::TimeOfDay => "local wall-clock HH:MM",
|
||||
ArgKind::Count => "positive integer count",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One declared construction arg of an arg-bearing `PrimitiveBuilder` (the
|
||||
/// pending recipe's twin of `ParamSpec`): its render name and closed `ArgKind`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ArgSpec {
|
||||
pub name: &'static str,
|
||||
pub kind: ArgKind,
|
||||
}
|
||||
|
||||
/// A parsed, validated construction-arg value (in-memory only; documents carry
|
||||
/// the canonical string form in [`ConstructionArg`]).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ArgValue {
|
||||
Tz(chrono_tz::Tz),
|
||||
TimeOfDay { hour: u32, minute: u32 },
|
||||
Count(usize),
|
||||
}
|
||||
|
||||
/// A consumed construction arg — the id-bearing, serialized twin of
|
||||
/// `BoundParam`. `value` is the accepted strict-form string, stored verbatim
|
||||
/// (never re-normalized — see `ArgKind::parse`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ConstructionArg {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// A fallible-`try_args` fault — the construction-arg twin of `BindOpError`,
|
||||
/// for the data-level construction surface.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ArgOpError {
|
||||
/// Args were given, but this type declares none (a `Plain` builder).
|
||||
NotArgBearing,
|
||||
/// A given arg name is not among the declared `ArgSpec`s.
|
||||
UnknownArg(String),
|
||||
/// The same arg name was given more than once.
|
||||
DuplicateArg(String),
|
||||
/// A declared arg has no value in the given set.
|
||||
MissingArg(String),
|
||||
/// A given value failed its declared kind's strict-form parse.
|
||||
BadValue { arg: String, kind: ArgKind, got: String },
|
||||
}
|
||||
|
||||
/// The args-channel state of a [`PrimitiveBuilder`] (private: callers only
|
||||
/// ever observe it through `arg_specs`/`construction_args`/`is_pending`).
|
||||
enum ArgsState {
|
||||
/// No construction args declared (every existing node, unaffected).
|
||||
Plain,
|
||||
/// An arg-bearing recipe: declared `ArgSpec`s and the `make` that turns a
|
||||
/// parsed value set into the real, configured builder.
|
||||
Pending {
|
||||
specs: &'static [ArgSpec],
|
||||
#[allow(clippy::type_complexity)]
|
||||
make: fn(&[(String, ArgValue)]) -> PrimitiveBuilder,
|
||||
},
|
||||
/// A configured arg-bearing builder: the accepted, verbatim (name, value)
|
||||
/// pairs — the render/serialize surface (`construction_args`).
|
||||
Configured { values: Vec<ConstructionArg> },
|
||||
}
|
||||
|
||||
/// A node's declared interface: its inputs (in order) and its output record — an
|
||||
@@ -345,6 +585,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 +649,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 +716,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 +753,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 +785,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 +800,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 +810,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 +852,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 +929,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 +948,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 +964,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 +1016,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 +1038,169 @@ 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(()));
|
||||
}
|
||||
|
||||
// --- construction args (ArgKind/ArgSpec/ArgValue/ConstructionArg/ArgOpError) ---
|
||||
|
||||
const DEMO_ARG_SPECS: &[ArgSpec] =
|
||||
&[ArgSpec { name: "tz", kind: ArgKind::Tz }, ArgSpec { name: "open", kind: ArgKind::TimeOfDay }];
|
||||
|
||||
/// A minimal `make`: the fixture does not need `values` to shape a real
|
||||
/// schema (that's `Session::make`'s job, Task 2) — it only proves
|
||||
/// `try_args` reaches `make` with parsed values and carries the result
|
||||
/// forward as `Configured`.
|
||||
fn demo_make(values: &[(String, ArgValue)]) -> PrimitiveBuilder {
|
||||
let _ = values;
|
||||
PrimitiveBuilder::new(
|
||||
"Demo",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only arg-bearing schema" },
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
}
|
||||
|
||||
fn demo_pending() -> PrimitiveBuilder {
|
||||
PrimitiveBuilder::pending("Demo", "test-only arg-bearing schema", DEMO_ARG_SPECS, demo_make)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_not_arg_bearing_on_a_plain_builder_with_args() {
|
||||
let plain = PrimitiveBuilder::new("Bare", NodeSchema::default(), |_| Box::new(Bare));
|
||||
assert_eq!(
|
||||
plain.try_args(&[("x".into(), "1".into())]).err(),
|
||||
Some(ArgOpError::NotArgBearing),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_plain_with_empty_raw_is_ok_unchanged() {
|
||||
let plain = PrimitiveBuilder::new(
|
||||
"Bare",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "plain doc" },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
let after = plain.try_args(&[]).expect("empty raw against a Plain builder is Ok(self)");
|
||||
assert_eq!(after.schema().doc, "plain doc");
|
||||
assert!(after.construction_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_unknown_arg_names_the_first_unknown() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[("nope".into(), "x".into()), ("tz".into(), "Europe/Berlin".into())])
|
||||
.err(),
|
||||
Some(ArgOpError::UnknownArg("nope".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_duplicate_arg_names_the_repeated_name() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[
|
||||
("tz".into(), "Europe/Berlin".into()),
|
||||
("tz".into(), "Europe/Berlin".into()),
|
||||
("open".into(), "09:30".into()),
|
||||
])
|
||||
.err(),
|
||||
Some(ArgOpError::DuplicateArg("tz".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_missing_arg_names_the_first_missing_in_spec_order() {
|
||||
assert_eq!(
|
||||
demo_pending().try_args(&[("tz".into(), "Europe/Berlin".into())]).err(),
|
||||
Some(ArgOpError::MissingArg("open".into())),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_bad_value_names_arg_kind_and_the_rejected_string() {
|
||||
assert_eq!(
|
||||
demo_pending()
|
||||
.try_args(&[("tz".into(), "berlin".into()), ("open".into(), "09:30".into())])
|
||||
.err(),
|
||||
Some(ArgOpError::BadValue { arg: "tz".into(), kind: ArgKind::Tz, got: "berlin".into() }),
|
||||
);
|
||||
}
|
||||
|
||||
/// Strict form IS the canonical form (spec §Closedness): no normalization
|
||||
/// layer, so a near-miss string refuses rather than being rewritten.
|
||||
#[test]
|
||||
fn arg_kind_parse_strict_form_refusals_and_accepts() {
|
||||
assert!(ArgKind::TimeOfDay.parse("9:30").is_err(), "not zero-padded");
|
||||
assert!(ArgKind::Tz.parse("berlin").is_err(), "not the exact IANA name");
|
||||
assert!(ArgKind::Count.parse("02").is_err(), "leading zero");
|
||||
assert!(ArgKind::TimeOfDay.parse("09:30").is_ok());
|
||||
assert!(ArgKind::Tz.parse("Europe/Berlin").is_ok());
|
||||
assert!(ArgKind::Count.parse("2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_args_accepts_a_valid_set_and_echoes_verbatim_pairs() {
|
||||
let configured = demo_pending()
|
||||
.try_args(&[("tz".into(), "America/New_York".into()), ("open".into(), "09:30".into())])
|
||||
.expect("a full valid set configures");
|
||||
assert!(!configured.is_pending());
|
||||
assert_eq!(
|
||||
configured.construction_args(),
|
||||
&[
|
||||
ConstructionArg { name: "tz".into(), value: "America/New_York".into() },
|
||||
ConstructionArg { name: "open".into(), value: "09:30".into() },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_arg_specs_are_visible_before_configuration() {
|
||||
let pending = demo_pending();
|
||||
assert!(pending.is_pending());
|
||||
assert_eq!(pending.arg_specs().iter().map(|s| s.name).collect::<Vec<_>>(), ["tz", "open"]);
|
||||
assert!(pending.construction_args().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "unconfigured")]
|
||||
fn pending_build_panics() {
|
||||
let _ = demo_pending().build(&[]);
|
||||
}
|
||||
}
|
||||
|
||||
#[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
|
||||
|
||||
@@ -15,10 +15,11 @@
|
||||
//! C23) and no external dependency (C16).
|
||||
|
||||
use aura_core::{
|
||||
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
|
||||
BindOpError, 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 +34,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 +130,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,
|
||||
@@ -162,6 +195,38 @@ pub struct GangMember {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Deterministic shape gate for authored composite render names (#331) —
|
||||
/// shape only, never content judgement (C29 discipline): non-empty, no path
|
||||
/// separator (`/` or `\`), and not the special segments `.` or `..`. The rule
|
||||
/// exists because the name keys a trace directory on disk unsanitized
|
||||
/// (`traces/<name>/`, `crates/aura-registry/src/trace_store.rs`). Applied at
|
||||
/// both data-borne birth routes for a name (the skeptic's two-seam finding):
|
||||
/// the `Op::Name` op intake (`construction.rs`) and the CLI's
|
||||
/// blueprint-envelope root-name intake — never at store read-back (C29:
|
||||
/// registered artifacts are never retroactively invalidated), and never on
|
||||
/// the Rust builder API (`GraphBuilder::new`, role-2 native authoring).
|
||||
pub fn name_gate(name: &str) -> Result<(), NameGateFault> {
|
||||
if name.is_empty() {
|
||||
return Err(NameGateFault::Empty);
|
||||
}
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
return Err(NameGateFault::ContainsSeparator);
|
||||
}
|
||||
if name == "." || name == ".." {
|
||||
return Err(NameGateFault::DotSegment);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The [`name_gate`] shape violation — WHY the name was refused; the caller
|
||||
/// already holds the offending name (WHAT) to build a message from.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NameGateFault {
|
||||
Empty,
|
||||
ContainsSeparator,
|
||||
DotSegment,
|
||||
}
|
||||
|
||||
/// A reusable sub-graph fragment compiled away by inlining (C9/C23). It is **not**
|
||||
/// a [`Node`]: it is never `eval`'d. It holds interior items (local indices),
|
||||
/// interior edges (local indices), input roles (role `r` fans into the interior
|
||||
@@ -174,6 +239,7 @@ pub struct Composite {
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Role>,
|
||||
output: Vec<OutField>,
|
||||
taps: Vec<Tap>,
|
||||
gangs: Vec<Gang>,
|
||||
}
|
||||
|
||||
@@ -189,7 +255,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 +273,47 @@ impl Composite {
|
||||
pub fn doc(&self) -> Option<&str> {
|
||||
self.doc.as_deref()
|
||||
}
|
||||
/// Rename this composite's render symbol in place (#317, `Op::Use`): the
|
||||
/// interior — nodes, edges, roles, output — is untouched; only `name()`
|
||||
/// changes, so a fetched, registered subgraph renders (and path-prefixes
|
||||
/// its `param_space()`, via `collect_params`'s composite arm) under the
|
||||
/// caller-chosen instance identifier instead of its own authored name.
|
||||
pub(crate) fn renamed(mut self, name: impl Into<String>) -> Composite {
|
||||
self.name = name.into();
|
||||
self
|
||||
}
|
||||
/// Apply one path-qualified bind after `Op::Use`'s splice (#317): the
|
||||
/// same underlying mechanic every bind goes through
|
||||
/// (`PrimitiveBuilder::try_bind`), reached by walking `path` down through
|
||||
/// nested composite frames by node/composite-name prefix — the segmentation
|
||||
/// `reopen_in` (#246) also walks, but binding an open param instead of
|
||||
/// unbinding a bound one. A path matching no node at any level is
|
||||
/// `BindOpError::UnknownParam(path)` (the WHOLE qualified path — no open
|
||||
/// param lives there); a path that reaches a primitive but whose leaf
|
||||
/// param name itself is unknown/ambiguous/ill-kinded rewrites `try_bind`'s
|
||||
/// own fault to carry the full qualified path in place of the bare leaf
|
||||
/// name, so every fault out of this walk names the same thing: the path
|
||||
/// the caller wrote. A path landing on a GANGED member's own param is
|
||||
/// `BindOpError::AlreadyGanged` (review finding, #317 follow-up) — binding
|
||||
/// it directly would silently de-fuse the gang (the member frozen while
|
||||
/// the gang's public knob keeps driving its siblings); the gang's own
|
||||
/// public knob stays unbindable at the use seam (accepted residue). On
|
||||
/// `Err` `self` is dropped (consumed) — the same discard-on-ambiguity
|
||||
/// posture `reopen` uses.
|
||||
pub(crate) fn bind_path(mut self, path: &str, value: Scalar) -> Result<Composite, BindOpError> {
|
||||
bind_path_in(&mut self.nodes, &self.gangs, path, path, value)?;
|
||||
Ok(self)
|
||||
}
|
||||
/// 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 +398,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 +417,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 +427,7 @@ impl Composite {
|
||||
&mut flat_nodes,
|
||||
&mut flat_signatures,
|
||||
&mut flat_edges,
|
||||
&mut flat_taps,
|
||||
)?;
|
||||
|
||||
for e in &self.edges {
|
||||
@@ -335,10 +444,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
|
||||
@@ -451,7 +574,8 @@ pub enum ReopenError {
|
||||
|
||||
/// One entry of the aggregated BOUND param surface (#246): the path-qualified
|
||||
/// twin of [`ParamSpec`] carrying the bound value — the default a sweep axis
|
||||
/// may override, and what `--list-axes` renders as `default=`.
|
||||
/// may override, and what `aura graph introspect --params` renders as
|
||||
/// `default=`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct BoundSpec {
|
||||
pub name: String,
|
||||
@@ -509,9 +633,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 +645,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 +679,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 +939,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 +990,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 +1022,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(())
|
||||
@@ -1023,6 +1162,92 @@ fn reopen_in(items: &mut [BlueprintNode], path: &str) -> usize {
|
||||
hits
|
||||
}
|
||||
|
||||
/// Recursive mutable walk for `Composite::bind_path` (#317): mirrors
|
||||
/// `reopen_in`'s node/composite-name prefix segmentation, but BINDS an open
|
||||
/// param (`PrimitiveBuilder::try_bind`) instead of unbinding a bound one.
|
||||
/// `full_path` is the whole original path (named in every fault this
|
||||
/// produces); `rest` is the remaining suffix at this recursion depth. `gangs`
|
||||
/// is the CURRENT frame's gang table (mirrors `collect_params`'s `gangs`
|
||||
/// parameter) — before a matched primitive's leaf param reaches `try_bind`,
|
||||
/// its (node, original-pos) is checked against every gang member; a hit
|
||||
/// refuses with `BindOpError::AlreadyGanged` rather than silently freezing
|
||||
/// the member out from under its gang's public knob (review finding,
|
||||
/// #317 follow-up). Uses `Vec::remove`/`insert` (not `&mut` in place) because
|
||||
/// `try_bind` consumes its receiver — the same reason `lower_items` moves
|
||||
/// `BlueprintNode`s by value rather than mutating through a reference.
|
||||
fn bind_path_in(
|
||||
items: &mut Vec<BlueprintNode>,
|
||||
gangs: &[Gang],
|
||||
full_path: &str,
|
||||
rest: &str,
|
||||
value: Scalar,
|
||||
) -> Result<(), BindOpError> {
|
||||
// The prefix this frame has already consumed off `full_path` (composite
|
||||
// names and up) — used to qualify a gang's public name the same way
|
||||
// `collect_params` does, so the refusal below names the SAME address
|
||||
// `param_space()` would show for that gang.
|
||||
let prefix = &full_path[..full_path.len() - rest.len()];
|
||||
for i in 0..items.len() {
|
||||
let child_rest = match &items[i] {
|
||||
BlueprintNode::Primitive(b) => rest.strip_prefix(&format!("{}.", b.node_name())),
|
||||
BlueprintNode::Composite(c) => rest.strip_prefix(&format!("{}.", c.name())),
|
||||
};
|
||||
let Some(child_rest) = child_rest else { continue };
|
||||
let child_rest = child_rest.to_string();
|
||||
// Gang guard (#317 follow-up review finding): a raw path landing on a
|
||||
// GANGED member's own param must not silently freeze it while the
|
||||
// gang's public knob keeps driving its siblings — refuse by
|
||||
// identifier before ever reaching `try_bind`. The gang's own public
|
||||
// knob staying unbindable at the use seam is accepted residue.
|
||||
if let BlueprintNode::Primitive(b) = &items[i]
|
||||
&& let Some(pos) = b.params().iter().position(|p| p.name == child_rest).map(|idx| b.original_pos(idx))
|
||||
&& let Some(g) = gangs.iter().find(|g| g.members.iter().any(|m| m.node == i && m.pos == pos))
|
||||
{
|
||||
return Err(BindOpError::AlreadyGanged {
|
||||
param: full_path.to_string(),
|
||||
gang: format!("{prefix}{}", g.name),
|
||||
});
|
||||
}
|
||||
let item = items.remove(i);
|
||||
return match item {
|
||||
BlueprintNode::Primitive(b) => match b.try_bind(&child_rest, value) {
|
||||
Ok(b2) => {
|
||||
items.insert(i, BlueprintNode::Primitive(b2));
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(qualify_bind_error(e, full_path)),
|
||||
},
|
||||
BlueprintNode::Composite(mut c) => {
|
||||
let r = bind_path_in(&mut c.nodes, &c.gangs, full_path, &child_rest, value);
|
||||
items.insert(i, BlueprintNode::Composite(c));
|
||||
r
|
||||
}
|
||||
};
|
||||
}
|
||||
// no node at any level matched `rest`'s leading segment: no open param
|
||||
// lives at this path.
|
||||
Err(BindOpError::UnknownParam(full_path.to_string()))
|
||||
}
|
||||
|
||||
/// Rewrite a leaf `try_bind` fault to carry the FULL qualified path (#317)
|
||||
/// in place of the bare leaf param name `try_bind` only ever sees (it has no
|
||||
/// visibility past the one primitive it was called on).
|
||||
fn qualify_bind_error(e: BindOpError, full_path: &str) -> BindOpError {
|
||||
match e {
|
||||
BindOpError::UnknownParam(_) => BindOpError::UnknownParam(full_path.to_string()),
|
||||
BindOpError::AmbiguousParam(_) => BindOpError::AmbiguousParam(full_path.to_string()),
|
||||
BindOpError::KindMismatch { expected, got, .. } => {
|
||||
BindOpError::KindMismatch { param: full_path.to_string(), expected, got }
|
||||
}
|
||||
// `try_bind` never raises this one (it has no gang awareness) — the
|
||||
// gang guard above constructs it directly, already fully qualified.
|
||||
// Kept here only so this match stays total over `BindOpError`.
|
||||
BindOpError::AlreadyGanged { gang, .. } => {
|
||||
BindOpError::AlreadyGanged { param: full_path.to_string(), gang }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read-only twin of `collect_params` over the BOUND surface (#246): same
|
||||
/// recursion shape and prefix rules, but enumerating `bound_params()` (with
|
||||
/// values) instead of the open `params()`. Gangs are irrelevant here — a gang
|
||||
@@ -1117,6 +1342,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 +1366,7 @@ fn lower_items(
|
||||
flat_nodes,
|
||||
flat_signatures,
|
||||
flat_edges,
|
||||
flat_taps,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -1156,6 +1383,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 +1394,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 +1445,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 +1502,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,11 +1544,27 @@ 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;
|
||||
|
||||
/// `name_gate` (#331) unit table: an ordinary name passes; an empty
|
||||
/// name, one containing either path separator, and the two dot segments
|
||||
/// each fault with their own fault variant.
|
||||
#[test]
|
||||
fn name_gate_accepts_ordinary_names_and_refuses_shape_violations() {
|
||||
assert_eq!(name_gate("ny_momentum"), Ok(()));
|
||||
assert_eq!(name_gate(""), Err(NameGateFault::Empty));
|
||||
assert_eq!(name_gate("a/b"), Err(NameGateFault::ContainsSeparator));
|
||||
assert_eq!(name_gate("a\\b"), Err(NameGateFault::ContainsSeparator));
|
||||
assert_eq!(name_gate("."), Err(NameGateFault::DotSegment));
|
||||
assert_eq!(name_gate(".."), Err(NameGateFault::DotSegment));
|
||||
}
|
||||
|
||||
/// One knob fanning into two sibling open params passes the gate; the
|
||||
/// value carries the gang table.
|
||||
#[test]
|
||||
@@ -1584,7 +1867,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
LinComb::configured(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -1623,7 +1906,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::builder(2).into(),
|
||||
LinComb::configured(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -1727,6 +2010,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 +2019,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 +2036,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)
|
||||
@@ -1931,7 +2216,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 {
|
||||
@@ -2158,7 +2443,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()
|
||||
@@ -2166,7 +2451,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()
|
||||
@@ -2174,7 +2464,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()
|
||||
@@ -2186,6 +2476,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),
|
||||
)
|
||||
@@ -2365,6 +2656,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]
|
||||
@@ -2736,6 +3029,7 @@ mod tests {
|
||||
inputs: vec![f64_any()],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
@@ -2756,14 +3050,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 },
|
||||
@@ -2772,6 +3063,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)
|
||||
@@ -2987,7 +3279,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||
vec![],
|
||||
vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }],
|
||||
vec![OutField { node: 0, field: 0, name: "out".into() }],
|
||||
@@ -3017,7 +3309,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.
|
||||
@@ -3043,7 +3336,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![
|
||||
@@ -3064,7 +3358,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![
|
||||
@@ -3082,10 +3377,12 @@ mod tests {
|
||||
}
|
||||
|
||||
/// #246: bound_param_space mirrors param_space's path qualification and
|
||||
/// carries the bound value (the default the CLI renders in --list-axes).
|
||||
/// carries the bound value (the default the CLI renders in
|
||||
/// `aura graph introspect --params`).
|
||||
#[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![
|
||||
@@ -3191,7 +3488,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(2).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||
// fan fast_slow's output into both LinComb terms so every interior slot
|
||||
// is wired (the totality check, cycle 0040); param order is unaffected.
|
||||
vec![
|
||||
@@ -3251,7 +3548,7 @@ mod tests {
|
||||
use aura_std::{LinComb, Sma};
|
||||
let bp = Composite::new(
|
||||
"root",
|
||||
vec![Sma::builder().into(), LinComb::builder(2).into()],
|
||||
vec![Sma::builder().into(), LinComb::configured(2).into()],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![], // output
|
||||
@@ -3339,6 +3636,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"),
|
||||
);
|
||||
@@ -3441,7 +3739,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");
|
||||
@@ -3572,4 +3870,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 }]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,29 @@
|
||||
//! closure and the declared schema are re-derived on load from the injected
|
||||
//! resolver. This is the inverse of the lossy render half (`model_to_json`); it
|
||||
//! carries no node logic (C17) and references a closed vocabulary (C24).
|
||||
//!
|
||||
//! Construction args (#271) are structural, id-bearing data — like `bound`
|
||||
//! values, unlike debug-symbol names — so `strip_debug_symbols` leaves
|
||||
//! `PrimitiveData.args` untouched.
|
||||
|
||||
use crate::blueprint::{BlueprintNode, Composite, Gang, OutField, Role};
|
||||
use crate::harness::Edge;
|
||||
use aura_core::{BoundParam, PrimitiveBuilder};
|
||||
use aura_core::{BoundParam, ConstructionArg, PrimitiveBuilder};
|
||||
|
||||
/// The format version the loader understands. Bumped only by a load-bearing
|
||||
/// (Tier-2) change; additive optional fields do not bump it (#156). Pre-ship
|
||||
/// dormancy (#61, 2026-07-10): while the project is unshipped every document
|
||||
/// lives in-repo and reader/writer change atomically, so the Tier-2 bump
|
||||
/// discipline activates at the first external ship — which consciously
|
||||
/// freezes v1 (gangs included).
|
||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 1;
|
||||
/// The CEILING format version this build's loader understands (and the
|
||||
/// writer may ever emit) — bumped only by a load-bearing (Tier-2) change;
|
||||
/// additive optional fields do not bump it (#156). Pre-ship dormancy (#61,
|
||||
/// 2026-07-10): while the project is unshipped every document lives in-repo
|
||||
/// and reader/writer change atomically, so the Tier-2 bump discipline
|
||||
/// activates at the first external ship.
|
||||
///
|
||||
/// #271 (data-driven version): the writer no longer emits a single fixed
|
||||
/// version. A document emits `1` when args-free (byte-identical to every
|
||||
/// pre-#271 document — content ids stable, C18) and `2` the moment any
|
||||
/// primitive, at any nesting depth, carries construction `args` — the
|
||||
/// must-understand signal a pre-#271 loader needs. The loader accepts the
|
||||
/// closed range `1..=BLUEPRINT_FORMAT_VERSION`.
|
||||
pub const BLUEPRINT_FORMAT_VERSION: u32 = 2;
|
||||
|
||||
/// Top-level envelope: the version is read before the payload is interpreted.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
@@ -46,6 +57,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>,
|
||||
}
|
||||
|
||||
@@ -59,17 +72,33 @@ pub enum NodeData {
|
||||
}
|
||||
|
||||
/// A primitive node as data: its compiled-in type identity, optional instance
|
||||
/// name, and bound params. The schema + build closure are re-derived on load.
|
||||
/// name, its accepted construction args (#271), and bound params. The schema
|
||||
/// + build closure are re-derived on load.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
pub struct PrimitiveData {
|
||||
#[serde(rename = "type")]
|
||||
pub type_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
/// The consumed `(name, value)` construction-arg pairs (#271) — the
|
||||
/// id-bearing, serialized twin of `bound`. Additive-optional: absent
|
||||
/// (empty) for every args-free primitive, so a document with no args
|
||||
/// anywhere stays byte-identical to its pre-#271 form.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub args: Vec<ArgData>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub bound: Vec<BoundParam>,
|
||||
}
|
||||
|
||||
/// One serialized construction-arg pair (#271) — the wire twin of
|
||||
/// [`aura_core::ConstructionArg`]: `value` is the accepted strict-form
|
||||
/// string, stored verbatim (never re-normalized).
|
||||
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ArgData {
|
||||
pub name: String,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Serializer failure (typed, named).
|
||||
#[derive(Debug)]
|
||||
pub enum SerializeError {
|
||||
@@ -94,6 +123,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,
|
||||
}
|
||||
}
|
||||
@@ -108,9 +138,19 @@ fn project_node(n: &BlueprintNode) -> NodeData {
|
||||
// construction once a multi-param node enters the vocabulary.
|
||||
let mut bound = b.bound_params().to_vec();
|
||||
bound.sort_by_key(|bp| bp.pos);
|
||||
// Construction args (#271) are declared in a fixed `ArgSpec` order
|
||||
// (not player-chosen like binds), so no re-canonicalization is
|
||||
// needed — `construction_args()` already returns them in that
|
||||
// deterministic order.
|
||||
let args: Vec<ArgData> = b
|
||||
.construction_args()
|
||||
.iter()
|
||||
.map(|ConstructionArg { name, value }| ArgData { name: name.clone(), value: value.clone() })
|
||||
.collect();
|
||||
NodeData::Primitive(PrimitiveData {
|
||||
type_id: b.label(),
|
||||
name: b.instance_name().map(str::to_string),
|
||||
args,
|
||||
bound,
|
||||
})
|
||||
}
|
||||
@@ -118,8 +158,32 @@ fn project_node(n: &BlueprintNode) -> NodeData {
|
||||
}
|
||||
}
|
||||
|
||||
/// Any primitive, at any nesting depth, carries construction args (#271) —
|
||||
/// the must-understand signal for the data-driven version (spec §Data-driven
|
||||
/// format version).
|
||||
fn has_args(b: &CompositeData) -> bool {
|
||||
b.nodes.iter().any(|n| match n {
|
||||
NodeData::Primitive(p) => !p.args.is_empty(),
|
||||
NodeData::Composite(c) => has_args(c),
|
||||
})
|
||||
}
|
||||
|
||||
/// The version THIS document must declare (#271): `1` for an args-free
|
||||
/// document (byte-identical to every pre-#271 document, C18) or `2` the
|
||||
/// moment any primitive anywhere carries args — never the fixed
|
||||
/// `BLUEPRINT_FORMAT_VERSION` ceiling.
|
||||
fn document_version(b: &CompositeData) -> u32 {
|
||||
if has_args(b) {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
fn build_doc(c: &Composite) -> BlueprintDoc {
|
||||
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
|
||||
let blueprint = project(c);
|
||||
let format_version = document_version(&blueprint);
|
||||
BlueprintDoc { format_version, blueprint }
|
||||
}
|
||||
|
||||
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
|
||||
@@ -135,10 +199,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 +232,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 {
|
||||
@@ -193,11 +261,16 @@ pub enum LoadError {
|
||||
/// addition needs finer granularity than a version bump.
|
||||
UnsupportedVersion { found: u32, supported: u32 },
|
||||
/// `resolve` returned `None` for a serialized `type_id` (outside the injected
|
||||
/// vocabulary — unknown node, or a construction-arg/sink node not in #155's set).
|
||||
/// vocabulary — unknown node, or a sink node not in #155's set).
|
||||
UnknownNodeType(String),
|
||||
/// The document's gangs section fails structural validation against its
|
||||
/// own nodes (a hand-edited or corrupted document).
|
||||
Gang(crate::blueprint::CompileError),
|
||||
/// A primitive's `args` (#271) failed `try_args`: unknown/duplicate/
|
||||
/// missing arg, a malformed value, OR (the pending-with-no-args refusal)
|
||||
/// an arg-bearing type serialized/hand-written with an empty `args` —
|
||||
/// a document naming an arg-bearing type without args must not load.
|
||||
BadArg(aura_core::ArgOpError),
|
||||
}
|
||||
|
||||
fn reconstruct(
|
||||
@@ -213,6 +286,15 @@ fn reconstruct(
|
||||
if let Some(n) = &p.name {
|
||||
b = b.named(n);
|
||||
}
|
||||
// Apply construction args (#271) BEFORE bound params — the
|
||||
// `try_args` seam. An arg-bearing type with no `args` in the
|
||||
// document refuses HERE as `MissingArg` (no pending builder
|
||||
// ever reaches `bind`/enters the built graph); a `Plain` type
|
||||
// has empty `p.args`, so this is `Ok(self)` unchanged for
|
||||
// every pre-#271 document.
|
||||
let args: Vec<(String, String)> =
|
||||
p.args.iter().map(|a| (a.name.clone(), a.value.clone())).collect();
|
||||
b = b.try_args(&args).map_err(LoadError::BadArg)?;
|
||||
// Re-apply bound params BY NAME. The effective param vector is
|
||||
// order-independent (each `bind` computes its slot against the
|
||||
// shrunk schema); ascending original `pos` is the canonical order.
|
||||
@@ -227,6 +309,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,14 +319,16 @@ 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,
|
||||
resolve: &dyn Fn(&str) -> Option<PrimitiveBuilder>,
|
||||
) -> Result<Composite, LoadError> {
|
||||
let doc: BlueprintDoc = serde_json::from_str(data).map_err(LoadError::Json)?;
|
||||
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
|
||||
// #271: the loader accepts the closed range 1..=BLUEPRINT_FORMAT_VERSION
|
||||
// (today 1..=2) — a data-driven ceiling, not a single fixed version.
|
||||
if !(1..=BLUEPRINT_FORMAT_VERSION).contains(&doc.format_version) {
|
||||
return Err(LoadError::UnsupportedVersion {
|
||||
found: doc.format_version,
|
||||
supported: BLUEPRINT_FORMAT_VERSION,
|
||||
@@ -258,8 +343,10 @@ mod tests {
|
||||
use crate::blueprint::{Composite, GangMember, OutField, Role};
|
||||
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_core::{ArgOpError, Scalar, ScalarKind, Timestamp};
|
||||
use aura_market::Session;
|
||||
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 +382,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 +395,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 +418,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 +449,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,21 +459,69 @@ 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"));
|
||||
}
|
||||
|
||||
/// #271 flipped pin (named contract change, not a regression): `format_version: 2`
|
||||
/// used to be refused (v1 was the only understood version); now that v2 loads
|
||||
/// (the args-bearing tier), only a version PAST the new ceiling is unsupported.
|
||||
#[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();
|
||||
assert!(matches!(err, LoadError::UnsupportedVersion { found: 2, supported: 1 }));
|
||||
let json = r#"{"format_version":3,"blueprint":{"name":"x","nodes":[]}}"#;
|
||||
let err = blueprint_from_json(json, &|t| aura_vocabulary::std_vocabulary(t)).err().unwrap();
|
||||
assert!(matches!(err, LoadError::UnsupportedVersion { found: 3, supported: 2 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -413,7 +548,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 +556,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 +737,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`
|
||||
@@ -759,4 +894,127 @@ mod tests {
|
||||
"the gang itself is identity-bearing"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- construction args (#271) --------------------------------------
|
||||
|
||||
// A single-node composite around an arg-configured `Session` (arity-free —
|
||||
// a `trigger` input role feeds it, its `bars_since_open` output re-exports).
|
||||
fn session_arg_fixture(tz: chrono_tz::Tz) -> Composite {
|
||||
Composite::new(
|
||||
"sess",
|
||||
vec![Session::configured(9, 30, tz, 15).into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
)
|
||||
}
|
||||
|
||||
/// #271 acceptance (engine layer): an args-bearing composite serializes its
|
||||
/// construction-arg pairs, declares `format_version: 2` (the data-driven
|
||||
/// must-understand tier, spec §Data-driven format version), and round-trips
|
||||
/// byte-stably through load -> re-serialize.
|
||||
#[test]
|
||||
fn args_round_trip_preserves_pairs_and_version_2() {
|
||||
let c = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let json = blueprint_to_json(&c).expect("serializes");
|
||||
assert!(json.contains(r#""format_version":2"#), "args-bearing document is version 2: {json}");
|
||||
assert!(
|
||||
json.contains(r#""args":[{"name":"tz","value":"Europe/Berlin"},{"name":"open","value":"09:30"}]"#),
|
||||
"carries the verbatim accepted arg pairs: {json}"
|
||||
);
|
||||
let loaded = blueprint_from_json(&json, &fixture_resolver()).expect("loads");
|
||||
assert_eq!(
|
||||
blueprint_to_json(&loaded).expect("re-serializes"),
|
||||
json,
|
||||
"args round-trip byte-stably"
|
||||
);
|
||||
}
|
||||
|
||||
/// #271 acceptance (C18 stability): a document with NO args anywhere stays
|
||||
/// version 1 and byte-identical to its pre-#271 canonical form — the exact
|
||||
/// golden `signal_serializes_to_canonical_golden` already pins, re-asserted
|
||||
/// here under the #271-specific test name the plan names.
|
||||
#[test]
|
||||
fn args_free_document_serializes_byte_identical_and_version_1() {
|
||||
let signal = crate::test_fixtures::sink_free_sma_cross_signal();
|
||||
let json = blueprint_to_json(&signal).expect("serializes");
|
||||
assert!(json.starts_with(r#"{"format_version":1,"#), "args-free document stays version 1: {json}");
|
||||
assert!(!json.contains("\"args\""), "an args-free document emits no args key: {json}");
|
||||
let golden = r#"{"format_version":1,"blueprint":{"name":"sma_cross","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow"}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias"}}],"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}]}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
assert_eq!(json, golden, "byte-identical to the pre-#271 canonical form (content ids stable, C18)");
|
||||
}
|
||||
|
||||
/// #271 (nested propagation): an args-bearing primitive nested INSIDE an
|
||||
/// outer composite still trips the outer document's version to 2 — the
|
||||
/// version walk recurses through `NodeData::Composite`, not just the
|
||||
/// top-level node list.
|
||||
#[test]
|
||||
fn nested_spliced_args_composite_is_version_2() {
|
||||
let inner = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let outer = Composite::new(
|
||||
"outer",
|
||||
vec![crate::blueprint::BlueprintNode::Composite(inner)],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
);
|
||||
let json = blueprint_to_json(&outer).expect("serializes");
|
||||
assert!(json.contains(r#""format_version":2"#), "a nested arg-bearing primitive still trips version 2: {json}");
|
||||
assert!(json.contains("\"args\""), "the nested primitive's args survive: {json}");
|
||||
}
|
||||
|
||||
/// #271 (identity is arg-bearing): construction args are structural,
|
||||
/// id-bearing data — like a bound value, unlike a debug-symbol name — so
|
||||
/// two builds differing ONLY in one accepted arg value never share an
|
||||
/// identity JSON, while renaming the composite (a pure debug symbol)
|
||||
/// still does not affect it.
|
||||
#[test]
|
||||
fn identity_json_retains_args() {
|
||||
let berlin = session_arg_fixture(chrono_tz::Europe::Berlin);
|
||||
let paris = session_arg_fixture(chrono_tz::Europe::Paris);
|
||||
assert_ne!(
|
||||
blueprint_identity_json(&berlin).expect("identity-serializes"),
|
||||
blueprint_identity_json(&paris).expect("identity-serializes"),
|
||||
"a differing construction-arg value survives the identity projection"
|
||||
);
|
||||
// renaming the composite (a debug symbol) does not affect identity.
|
||||
let renamed = Composite::new(
|
||||
"sess_renamed",
|
||||
vec![Session::configured(9, 30, chrono_tz::Europe::Berlin, 15).into()],
|
||||
vec![],
|
||||
vec![Role {
|
||||
name: "trigger".into(),
|
||||
targets: vec![Target { node: 0, slot: 0 }],
|
||||
source: Some(ScalarKind::F64),
|
||||
}],
|
||||
vec![OutField { node: 0, field: 0, name: "bars".into() }],
|
||||
);
|
||||
assert_eq!(
|
||||
blueprint_identity_json(&berlin).expect("identity-serializes"),
|
||||
blueprint_identity_json(&renamed).expect("identity-serializes"),
|
||||
"the composite's own debug name does not affect identity"
|
||||
);
|
||||
}
|
||||
|
||||
/// #271 (refuse, don't guess): a hand-written document naming an
|
||||
/// arg-bearing type (`Session`) with NO `args` key refuses on load as
|
||||
/// `LoadError::BadArg(ArgOpError::MissingArg(..))` — a pending builder
|
||||
/// must never silently enter the built graph.
|
||||
#[test]
|
||||
fn loading_arg_bearing_type_without_args_refuses() {
|
||||
let json = r#"{"format_version":1,"blueprint":{"name":"x","nodes":[{"primitive":{"type":"Session"}}]}}"#;
|
||||
let err = blueprint_from_json(json, &fixture_resolver()).err().unwrap();
|
||||
assert!(
|
||||
matches!(&err, LoadError::BadArg(ArgOpError::MissingArg(a)) if a == "tz"),
|
||||
"an arg-bearing type with no args refuses as MissingArg, got {err:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,40 +47,40 @@ 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,
|
||||
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
|
||||
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
|
||||
Tap, TapWire,
|
||||
};
|
||||
pub use blueprint_serde::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, BlueprintDoc, CompositeData,
|
||||
LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
|
||||
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||
};
|
||||
pub use builder::{BuildError, GraphBuilder, InPort, NodeHandle, OutPort, RoleHandle};
|
||||
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,
|
||||
@@ -90,7 +89,8 @@ pub use walkforward::{
|
||||
// (SourceSpec.kind is a ScalarKind; sources/Recorder columns are Scalar /
|
||||
// Firing / Timestamp) so a graph builder has one import surface, not two.
|
||||
pub use aura_core::{
|
||||
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
|
||||
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
|
||||
Timestamp,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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
|
||||
@@ -90,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
|
||||
@@ -111,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
|
||||
@@ -293,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() {
|
||||
@@ -451,174 +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)),
|
||||
],
|
||||
defaults: vec![],
|
||||
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![
|
||||
@@ -720,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);
|
||||
}
|
||||
|
||||
|
||||
+153
-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)
|
||||
@@ -803,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);
|
||||
@@ -873,7 +949,7 @@ 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(","),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -120,23 +120,23 @@ impl Iterator for WindowRoller {
|
||||
/// What a per-window run yields: the params chosen on the in-sample window, the
|
||||
/// recorded OOS pip-equity segment (for stitching), and the OOS [`RunReport`] (the
|
||||
/// per-window C18 record). Self-describing, analog to
|
||||
/// [`SweepPoint`](crate::SweepPoint)/[`McDraw`](crate::McDraw).
|
||||
/// [`SweepPoint`](crate::SweepPoint) (and the backtest layer's `McDraw`).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WindowRun {
|
||||
pub struct WindowRun<M> {
|
||||
/// The chosen params as the tag-free coordinate [`Cell`]s the inner sweep
|
||||
/// produced (C7: the kind lives once on [`WalkForwardResult::space`], not at the
|
||||
/// value). The named/typed view is `zip_params(&result.space, &chosen_params)` —
|
||||
/// the same idiom as [`SweepFamily::named_params`](crate::SweepFamily).
|
||||
pub chosen_params: Vec<Cell>,
|
||||
pub oos_equity: Vec<(Timestamp, f64)>,
|
||||
pub oos_report: RunReport,
|
||||
pub oos_report: RunReport<M>,
|
||||
}
|
||||
|
||||
/// One completed window: its bounds + the run the closure produced, in roll order.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WindowOutcome {
|
||||
pub struct WindowOutcome<M> {
|
||||
pub bounds: WindowBounds,
|
||||
pub run: WindowRun,
|
||||
pub run: WindowRun<M>,
|
||||
}
|
||||
|
||||
/// The result family of a walk-forward. Analog to [`SweepFamily`](crate::SweepFamily)
|
||||
@@ -146,7 +146,7 @@ pub struct WindowOutcome {
|
||||
/// points and `optimize`/`rank_by` are computed on demand). Non-redundant by
|
||||
/// construction.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct WalkForwardResult {
|
||||
pub struct WalkForwardResult<M> {
|
||||
/// The param-space schema (kinds + slot names), once for the whole family —
|
||||
/// the kinds the per-window [`WindowRun::chosen_params`] cells are read against
|
||||
/// (C7). Identical for every window (same blueprint). Mirrors
|
||||
@@ -154,7 +154,7 @@ pub struct WalkForwardResult {
|
||||
pub space: Vec<ParamSpec>,
|
||||
/// Per-window outcomes in roll order (window 0 first), independent of thread
|
||||
/// completion (C1).
|
||||
pub windows: Vec<WindowOutcome>,
|
||||
pub windows: Vec<WindowOutcome<M>>,
|
||||
/// The stitched continuous OOS pip-equity curve: each window's OOS segment
|
||||
/// offset by the running sum of all prior segments' final cumulative values,
|
||||
/// so equity carries forward across the IS/OOS boundaries (continuous, not a
|
||||
@@ -165,7 +165,7 @@ pub struct WalkForwardResult {
|
||||
pub stitched_oos_equity: Vec<(Timestamp, f64)>,
|
||||
}
|
||||
|
||||
impl WalkForwardResult {
|
||||
impl<M> WalkForwardResult<M> {
|
||||
/// The chosen params of the `window`-th outcome paired with their names — a
|
||||
/// derived view over the carried param-space (reuses [`zip_params`]); the
|
||||
/// walk-forward mirror of [`SweepFamily::named_params`](crate::SweepFamily).
|
||||
@@ -174,41 +174,22 @@ impl WalkForwardResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Roll the windows, run `run_window` on each disjointly in parallel (C1, via the
|
||||
/// shared [`run_indexed`](crate::sweep) core), then stitch the OOS equity into one
|
||||
/// continuous curve. The varying dimension is the *data window* (C12 axis 3).
|
||||
/// Eager-agnostic (#71): `run_window` receives only [`WindowBounds`]; its data
|
||||
/// comes from a bounds-keyed producer in the closure body, never a materialized
|
||||
/// stream `Vec` in this API. Precondition: the roller yields `>= 1` window
|
||||
/// (`WindowRoller::new` rejects an empty roll). Param stability is a separate
|
||||
/// on-demand reduction ([`param_stability`]), never computed here.
|
||||
pub fn walk_forward<F>(roller: WindowRoller, space: Vec<ParamSpec>, run_window: F) -> WalkForwardResult
|
||||
where
|
||||
F: Fn(WindowBounds) -> WindowRun + Sync,
|
||||
{
|
||||
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
walk_forward_with_threads(roller, space, nthreads, run_window)
|
||||
}
|
||||
|
||||
/// The thread-count-explicit core of [`walk_forward`]. Module-private: the public
|
||||
/// `walk_forward` 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 collects the roller's bounds (tiny — four
|
||||
/// timestamps each, no tick data), runs each window disjointly, zips the runs back
|
||||
/// onto their bounds in roll order, and stitches the OOS segments.
|
||||
fn walk_forward_with_threads<F>(
|
||||
roller: WindowRoller,
|
||||
/// The single assembly path behind both [`walk_forward`] and the thread-count-
|
||||
/// explicit test wrapper: run `run_window` over each already-rolled `bounds` via
|
||||
/// `run_indexed` (on whichever pool is ambient when called), stitch the OOS
|
||||
/// equity, and assemble the result. One source of truth so the two callers can
|
||||
/// never drift on assembly logic while their pool selection differs.
|
||||
fn assemble_walk_forward<M, F>(
|
||||
space: Vec<ParamSpec>,
|
||||
nthreads: usize,
|
||||
run_window: F,
|
||||
) -> WalkForwardResult
|
||||
bounds: Vec<WindowBounds>,
|
||||
run_window: &F,
|
||||
) -> WalkForwardResult<M>
|
||||
where
|
||||
F: Fn(WindowBounds) -> WindowRun + Sync,
|
||||
M: Send,
|
||||
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
|
||||
{
|
||||
let bounds: Vec<WindowBounds> = roller.collect();
|
||||
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
|
||||
let runs = run_indexed(bounds.len(), nthreads, |i| run_window(bounds[i]));
|
||||
let windows: Vec<WindowOutcome> = bounds
|
||||
let runs = run_indexed(bounds.len(), |i| run_window(bounds[i]));
|
||||
let windows: Vec<WindowOutcome<M>> = bounds
|
||||
.into_iter()
|
||||
.zip(runs)
|
||||
.map(|(bounds, run)| WindowOutcome { bounds, run })
|
||||
@@ -221,11 +202,57 @@ where
|
||||
WalkForwardResult { space, windows, stitched_oos_equity }
|
||||
}
|
||||
|
||||
/// Roll the windows, run `run_window` on each disjointly in parallel (C1, via the
|
||||
/// shared [`run_indexed`](crate::sweep) core), then stitch the OOS equity into one
|
||||
/// continuous curve. The varying dimension is the *data window* (C12 axis 3).
|
||||
/// Eager-agnostic (#71): `run_window` receives only [`WindowBounds`]; its data
|
||||
/// comes from a bounds-keyed producer in the closure body, never a materialized
|
||||
/// stream `Vec` in this API. Precondition: the roller yields `>= 1` window
|
||||
/// (`WindowRoller::new` rejects an empty roll). Param stability is a separate
|
||||
/// on-demand reduction ([`param_stability`]), never computed here.
|
||||
pub fn walk_forward<M, F>(roller: WindowRoller, space: Vec<ParamSpec>, run_window: F) -> WalkForwardResult<M>
|
||||
where
|
||||
M: Send,
|
||||
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
|
||||
{
|
||||
let bounds: Vec<WindowBounds> = roller.collect();
|
||||
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
|
||||
assemble_walk_forward(space, bounds, &run_window)
|
||||
}
|
||||
|
||||
/// The thread-count-explicit wrapper of [`walk_forward`]. Module-private: the
|
||||
/// public `walk_forward` runs on the ambient pool; the tests drive this at 1
|
||||
/// and at N to pin determinism (C1). The roll enumeration runs outside the
|
||||
/// pool; `assemble_walk_forward` (run_indexed + stitch + result assembly) runs
|
||||
/// inside a local rayon pool of `nthreads` workers, sharing the exact assembly
|
||||
/// path `walk_forward` uses — no second copy to drift. Bounds match
|
||||
/// `walk_forward` (`F: Sync`): the install closure only borrows `run_window`,
|
||||
/// never moves it.
|
||||
#[cfg(test)]
|
||||
fn walk_forward_with_threads<M, F>(
|
||||
roller: WindowRoller,
|
||||
space: Vec<ParamSpec>,
|
||||
nthreads: usize,
|
||||
run_window: F,
|
||||
) -> WalkForwardResult<M>
|
||||
where
|
||||
M: Send,
|
||||
F: Fn(WindowBounds) -> WindowRun<M> + Sync,
|
||||
{
|
||||
let bounds: Vec<WindowBounds> = roller.collect();
|
||||
debug_assert!(!bounds.is_empty(), "walk_forward requires a non-empty roll");
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(nthreads)
|
||||
.build()
|
||||
.expect("rayon thread pool");
|
||||
pool.install(|| assemble_walk_forward(space, bounds, &run_window))
|
||||
}
|
||||
|
||||
/// Fold the per-window OOS equity segments into one continuous curve: each
|
||||
/// segment's samples are offset by the running sum of all prior segments' final
|
||||
/// cumulative values, so equity carries forward across windows (no per-window
|
||||
/// reset). An empty segment adds `0.0` to the offset and no points.
|
||||
fn stitch(windows: &[WindowOutcome]) -> Vec<(Timestamp, f64)> {
|
||||
fn stitch<M>(windows: &[WindowOutcome<M>]) -> Vec<(Timestamp, f64)> {
|
||||
let mut out = Vec::new();
|
||||
let mut offset = 0.0_f64;
|
||||
for w in windows {
|
||||
@@ -251,7 +278,7 @@ fn stitch(windows: &[WindowOutcome]) -> Vec<(Timestamp, f64)> {
|
||||
/// value, a `bool` slot to `0.0`/`1.0` (so `mean` is the fraction of windows that
|
||||
/// chose `true`). A `timestamp` slot is structurally impossible (C20: a timestamp
|
||||
/// is a structural axis, never a numeric knob).
|
||||
pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
|
||||
pub fn param_stability<M>(result: &WalkForwardResult<M>) -> Vec<MetricStats> {
|
||||
if result.windows.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
@@ -281,9 +308,10 @@ pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{summarize, RunManifest};
|
||||
use crate::RunManifest;
|
||||
use aura_backtest::{summarize, RunMetrics};
|
||||
|
||||
fn dummy_report() -> RunReport {
|
||||
fn dummy_report() -> RunReport<RunMetrics> {
|
||||
RunReport {
|
||||
manifest: RunManifest {
|
||||
commit: "t".to_string(),
|
||||
@@ -312,7 +340,7 @@ mod tests {
|
||||
|
||||
/// A WindowOutcome with explicit OOS equity + chosen params (zero bounds — the
|
||||
/// stitch/stability reductions ignore bounds).
|
||||
fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec<Cell>) -> WindowOutcome {
|
||||
fn outcome(oos_equity: Vec<(Timestamp, f64)>, chosen: Vec<Cell>) -> WindowOutcome<RunMetrics> {
|
||||
WindowOutcome {
|
||||
bounds: WindowBounds {
|
||||
is: (Timestamp(0), Timestamp(0)),
|
||||
@@ -325,7 +353,7 @@ mod tests {
|
||||
/// A deterministic per-window run keyed by the OOS start, so distinct windows
|
||||
/// differ. No real harness — these tests pin the orchestration (roll order,
|
||||
/// stitch, stability); mc/sweep cover the harness path. Free `fn` => `Sync`.
|
||||
fn run_window_fixture(w: WindowBounds) -> WindowRun {
|
||||
fn run_window_fixture(w: WindowBounds) -> WindowRun<RunMetrics> {
|
||||
let Timestamp(k) = w.oos.0;
|
||||
WindowRun {
|
||||
chosen_params: vec![Cell::from_i64(k % 3), Cell::from_f64(k as f64 * 0.5)],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user