Compare commits
8 Commits
main
..
e7f16c179b
| Author | SHA1 | Date | |
|---|---|---|---|
| e7f16c179b | |||
| 3e15fb6269 | |||
| d1b9d2b7e5 | |||
| 1f5c5ad80f | |||
| 7abe89cb4f | |||
| bd174d10d9 | |||
| bc6c8fe5b4 | |||
| 052cb46274 |
@@ -159,16 +159,6 @@ 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
|
||||
|
||||
@@ -188,8 +178,6 @@ 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
+4
-164
@@ -99,52 +99,21 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "aura-analysis"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"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",
|
||||
]
|
||||
@@ -153,28 +122,22 @@ 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",
|
||||
]
|
||||
|
||||
@@ -182,11 +145,9 @@ dependencies = [
|
||||
name = "aura-composites"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
@@ -194,7 +155,6 @@ dependencies = [
|
||||
name = "aura-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono-tz",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
@@ -204,15 +164,10 @@ 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",
|
||||
]
|
||||
@@ -221,50 +176,23 @@ 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",
|
||||
]
|
||||
@@ -279,56 +207,13 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-runner"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-campaign",
|
||||
"aura-composites",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"data-server",
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-std"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-core",
|
||||
"aura-strategy",
|
||||
]
|
||||
|
||||
[[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",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -519,25 +404,6 @@ 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"
|
||||
@@ -613,12 +479,6 @@ 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"
|
||||
@@ -943,26 +803,6 @@ 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,11 +11,6 @@ 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",
|
||||
@@ -23,9 +18,7 @@ members = [
|
||||
"crates/aura-ingest",
|
||||
"crates/aura-registry",
|
||||
"crates/aura-research",
|
||||
"crates/aura-runner",
|
||||
"crates/aura-campaign",
|
||||
"crates/aura-bench",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -38,7 +38,7 @@ Invoke it as `aura <command> …` (examples below use the plain name).
|
||||
- **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. `fast.length`),
|
||||
- **Axis** — one named, sweepable knob of a blueprint (e.g. `graph.fast.length`),
|
||||
bound with a comma-separated value list. A **gang** fuses several sibling
|
||||
knobs into one axis (one value drives all members).
|
||||
|
||||
@@ -118,13 +118,8 @@ aura graph build < crossover.ops.json > crossover.bp.json
|
||||
aura sweep crossover.bp.json --list-axes
|
||||
```
|
||||
|
||||
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, `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
|
||||
The op kinds are `source`, `input`, `add`, `feed`, `connect`, `expose`, and
|
||||
`gang`. 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,13 +6,15 @@ license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# 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).
|
||||
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 = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
# 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 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 = { workspace = true }
|
||||
|
||||
+1006
-281
File diff suppressed because it is too large
Load Diff
@@ -1,37 +0,0 @@
|
||||
[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"
|
||||
@@ -1,40 +0,0 @@
|
||||
//! `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>;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,166 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
[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" }
|
||||
@@ -1,47 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
//! 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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
//! `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");
|
||||
}
|
||||
}
|
||||
@@ -1,457 +0,0 @@
|
||||
//! The two campaign surfaces: `campaign_sweep` (narrow per-cell fan-out — a
|
||||
//! sweep-only process) and `campaign_heavy` (wide fan-out — sweep → gate →
|
||||
//! walk-forward → bootstrap), both run as the release `aura` binary on a
|
||||
//! scratch data-only project over synthetic archives. Fingerprint: the winner
|
||||
//! ordinals extracted from the always-on final `{"campaign_run":...}` stdout
|
||||
//! line. One campaign shape misreads regressions in the other (#251), so both
|
||||
//! are pinned.
|
||||
|
||||
use super::{RepOutcome, Sizing};
|
||||
use crate::child::{run_timed, TimedChild};
|
||||
use crate::synth::{write_symbol_archive, ScratchDir};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
pub const FULL_INSTRUMENTS: &[&str] = &["BSYMA", "BSYMB", "BSYMC", "BSYMD"];
|
||||
pub const QUICK_INSTRUMENTS: &[&str] = &["BSYMA", "BSYMB"];
|
||||
/// Full-size span: 24 archive months with a ~22-month campaign window
|
||||
/// (2024-02-01 .. 2025-11-30 UTC), sized so the measured child runs land in
|
||||
/// whole seconds — sub-second walls put ordinary spawn/scheduler jitter within
|
||||
/// reach of the 10% NOTICE threshold and make the report flap.
|
||||
pub const ARCHIVE_MONTHS_FULL: ((i32, u32), (i32, u32)) = ((2024, 1), (2025, 12));
|
||||
pub const WINDOW_MS_FULL: (i64, i64) = (1706745600000, 1764547199999);
|
||||
/// Quick span: the green E2E fixture's shape (archive 2024-01..08, window
|
||||
/// Mar-01..Jun-30 — ~17 weeks gives the (14d, 7d, 7d) roller a comfortable
|
||||
/// tiling, research_docs.rs:2440-2441).
|
||||
pub const ARCHIVE_MONTHS_QUICK: ((i32, u32), (i32, u32)) = ((2024, 1), (2024, 8));
|
||||
pub const WINDOW_MS_QUICK: (i64, i64) = (1709251200000, 1719791999999);
|
||||
|
||||
pub const SWEEP_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "bench-sweep-only",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// The heavy shape: the E2E walk-forward process with the bootstrap sized up
|
||||
/// (1000 resamples — the #277 measured workload's count).
|
||||
pub const HEAVY_PROCESS_DOC: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "bench-heavy",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
|
||||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
|
||||
{ "block": "std::monte_carlo", "resamples": 1000, "block_len": 5 }
|
||||
]
|
||||
}"#;
|
||||
|
||||
pub const HEAVY_PROCESS_DOC_QUICK: &str = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "bench-heavy",
|
||||
"pipeline": [
|
||||
{ "block": "std::sweep", "metric": "sqn_normalized", "select": "argmax" },
|
||||
{ "block": "std::gate", "all": [ { "metric": "n_trades", "cmp": "ge", "value": 0.0 } ] },
|
||||
{ "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000,
|
||||
"step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" },
|
||||
{ "block": "std::monte_carlo", "resamples": 50, "block_len": 5 }
|
||||
]
|
||||
}"#;
|
||||
|
||||
/// The two SMA strategy blueprints: byte-identical to the shipped
|
||||
/// `crates/aura-cli/examples/r_sma.json` except the second variant's bound
|
||||
/// lengths (3/6 instead of 2/4), which gives it a distinct content id — two
|
||||
/// strategies, same axes surface.
|
||||
pub const BP_SMA_A: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":2}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":4}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
pub const BP_SMA_B: &str = r#"{"format_version":1,"blueprint":{"name":"sma_signal","doc":"fast/slow SMA difference clamped into a directional bias","nodes":[{"primitive":{"type":"SMA","name":"fast","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":3}}]}},{"primitive":{"type":"SMA","name":"slow","bound":[{"pos":0,"name":"length","kind":"I64","value":{"I64":6}}]}},{"primitive":{"type":"Sub"}},{"primitive":{"type":"Bias","name":"bias","bound":[{"pos":0,"name":"scale","kind":"F64","value":{"F64":0.5}}]}}],"edges":[{"from":0,"to":2,"slot":0,"from_field":0},{"from":1,"to":2,"slot":1,"from_field":0},{"from":2,"to":3,"slot":0,"from_field":0}],"input_roles":[{"name":"price","targets":[{"node":0,"slot":0},{"node":1,"slot":0}],"source":"F64"}],"output":[{"node":3,"field":0,"name":"bias"}]}}"#;
|
||||
|
||||
fn run_in(bin: &Path, dir: &Path, args: &[&str]) -> Result<(String, Option<i32>), String> {
|
||||
let out = std::process::Command::new(bin)
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.output()
|
||||
.map_err(|e| format!("spawn aura {args:?}: {e}"))?;
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
Ok((text, out.status.code()))
|
||||
}
|
||||
|
||||
/// Blueprint stems currently in the scratch store.
|
||||
fn blueprint_stems(dir: &Path) -> Vec<String> {
|
||||
std::fs::read_dir(dir.join("runs").join("blueprints"))
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.filter_map(|e| e.path().file_stem().map(|s| s.to_string_lossy().into_owned()))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Seed one blueprint into the scratch store via a tiny synthetic sweep (the
|
||||
/// E2E `seed_blueprint` pattern) and return its content id.
|
||||
fn seed_blueprint(bin: &Path, dir: &Path, file: &str, name: &str) -> Result<String, String> {
|
||||
let before = blueprint_stems(dir);
|
||||
let (out, code) = run_in(
|
||||
bin,
|
||||
dir,
|
||||
&[
|
||||
"sweep",
|
||||
file,
|
||||
"--axis",
|
||||
"fast.length=2,4",
|
||||
"--axis",
|
||||
"slow.length=8,16",
|
||||
"--name",
|
||||
name,
|
||||
],
|
||||
)?;
|
||||
if code != Some(0) {
|
||||
return Err(format!("seed sweep failed ({code:?}): {out}"));
|
||||
}
|
||||
blueprint_stems(dir)
|
||||
.into_iter()
|
||||
.find(|s| !before.contains(s))
|
||||
.ok_or_else(|| "seed sweep must store a new blueprint".to_string())
|
||||
}
|
||||
|
||||
fn register_process(bin: &Path, dir: &Path, file: &str, doc: &str) -> Result<String, String> {
|
||||
std::fs::write(dir.join(file), doc).map_err(|e| format!("write {file}: {e}"))?;
|
||||
let (out, code) = run_in(bin, dir, &["process", "register", file])?;
|
||||
if code != Some(0) {
|
||||
return Err(format!("process register failed ({code:?}): {out}"));
|
||||
}
|
||||
out.lines()
|
||||
.find(|l| l.starts_with("registered process "))
|
||||
.map(|l| {
|
||||
l.trim_start_matches("registered process ")
|
||||
.split(' ')
|
||||
.next()
|
||||
.expect("split yields at least one piece")
|
||||
.trim_start_matches("content:")
|
||||
.to_string()
|
||||
})
|
||||
.ok_or_else(|| format!("no register line in: {out}"))
|
||||
}
|
||||
|
||||
fn campaign_doc(instruments: &[&str], bp_ids: &[String], proc_id: &str, quick: bool) -> String {
|
||||
let instruments_json =
|
||||
instruments.iter().map(|i| format!("\"{i}\"")).collect::<Vec<_>>().join(", ");
|
||||
// 5x5 member grid at full size (the workload lever), the E2E-shaped 3x3
|
||||
// in quick mode (mechanics only).
|
||||
let (fast_vals, slow_vals) = if quick {
|
||||
("[2, 3, 4]", "[8, 12, 16]")
|
||||
} else {
|
||||
("[2, 3, 4, 5, 6]", "[8, 10, 12, 14, 16]")
|
||||
};
|
||||
let window = if quick { WINDOW_MS_QUICK } else { WINDOW_MS_FULL };
|
||||
let strategies_json = bp_ids
|
||||
.iter()
|
||||
.map(|id| {
|
||||
format!(
|
||||
r#"{{ "ref": {{ "content_id": "{id}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": {fast_vals} }},
|
||||
"slow.length": {{ "kind": "I64", "values": {slow_vals} }} }} }}"#
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",\n ");
|
||||
format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "bench",
|
||||
"data": {{ "instruments": [{instruments_json}], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||
"strategies": [ {strategies_json} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc_id}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": [] }}
|
||||
}}"#,
|
||||
from = window.0,
|
||||
to = window.1,
|
||||
)
|
||||
}
|
||||
|
||||
/// Build a fresh scratch campaign project and return (scratch, campaign doc
|
||||
/// file name). Setup, unmeasured.
|
||||
fn build_scratch(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<(ScratchDir, String), String> {
|
||||
let scratch = ScratchDir::new("campaign").map_err(|e| format!("scratch dir: {e}"))?;
|
||||
let root = &scratch.0;
|
||||
std::fs::write(root.join("Aura.toml"), "[paths]\nruns = \"runs\"\ndata = \"data\"\n")
|
||||
.map_err(|e| format!("write Aura.toml: {e}"))?;
|
||||
let data_dir = root.join("data");
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create data dir: {e}"))?;
|
||||
let instruments = if sizing.quick { QUICK_INSTRUMENTS } else { FULL_INSTRUMENTS };
|
||||
let months = if sizing.quick { ARCHIVE_MONTHS_QUICK } else { ARCHIVE_MONTHS_FULL };
|
||||
for sym in instruments {
|
||||
write_symbol_archive(&data_dir, sym, months.0, months.1);
|
||||
}
|
||||
std::fs::write(root.join("bench_sma_a.json"), BP_SMA_A)
|
||||
.map_err(|e| format!("write blueprint a: {e}"))?;
|
||||
let mut bp_ids = vec![seed_blueprint(bin, root, "bench_sma_a.json", "seed-a")?];
|
||||
if !sizing.quick {
|
||||
std::fs::write(root.join("bench_sma_b.json"), BP_SMA_B)
|
||||
.map_err(|e| format!("write blueprint b: {e}"))?;
|
||||
bp_ids.push(seed_blueprint(bin, root, "bench_sma_b.json", "seed-b")?);
|
||||
}
|
||||
let proc_id = register_process(bin, root, "bench.process.json", process_doc)?;
|
||||
let doc = campaign_doc(instruments, &bp_ids, &proc_id, sizing.quick);
|
||||
std::fs::write(root.join("bench.campaign.json"), doc)
|
||||
.map_err(|e| format!("write campaign doc: {e}"))?;
|
||||
Ok((scratch, "bench.campaign.json".to_string()))
|
||||
}
|
||||
|
||||
/// Extract the determinism fingerprint from the always-on final
|
||||
/// `{"campaign_run":...}` stdout line: per stage the selection's winner
|
||||
/// ordinal (`w`), the survivor-ordinal list (`v[..]`), and the bootstrap's
|
||||
/// trade count (`b`, pooled or summed across per-survivor entries). A
|
||||
/// wrong-result regression in the sweep, gate, or bootstrap machinery
|
||||
/// therefore fails the bench — not just a wrong sweep winner. The
|
||||
/// walk-forward stage currently emits an empty realization record
|
||||
/// (no selection, no survivors, no bootstrap), so it contributes nothing
|
||||
/// directly and is covered only indirectly, through the bootstrap trade
|
||||
/// count that depends on its out-of-sample output (coverage gap parked on
|
||||
/// the tracker, #279).
|
||||
pub fn winner_fingerprint(stdout: &str) -> Result<String, String> {
|
||||
let line = stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with("{\"campaign_run\":"))
|
||||
.ok_or("no campaign_run record line on stdout")?;
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| format!("campaign_run line parses: {e}"))?;
|
||||
let cells = v["campaign_run"]["cells"]
|
||||
.as_array()
|
||||
.ok_or("campaign_run.cells is an array")?;
|
||||
let mut parts = Vec::new();
|
||||
for (ci, cell) in cells.iter().enumerate() {
|
||||
let stages = cell["stages"].as_array().ok_or("cell.stages is an array")?;
|
||||
for (si, stage) in stages.iter().enumerate() {
|
||||
if let Some(w) = stage["selection"]["winner_ordinal"].as_u64() {
|
||||
parts.push(format!("c{ci}s{si}w{w}"));
|
||||
}
|
||||
if let Some(surv) = stage["survivor_ordinals"].as_array() {
|
||||
let list = surv
|
||||
.iter()
|
||||
.filter_map(|x| x.as_u64())
|
||||
.map(|x| x.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".");
|
||||
parts.push(format!("c{ci}s{si}v[{list}]"));
|
||||
}
|
||||
if let Some(n) = stage["bootstrap"]["pooled_oos"]["n_trades"].as_u64() {
|
||||
parts.push(format!("c{ci}s{si}b{n}"));
|
||||
} else if let Some(per) = stage["bootstrap"]["per_survivor"].as_array() {
|
||||
let n: u64 = per.iter().filter_map(|pair| pair[1]["n_trades"].as_u64()).sum();
|
||||
parts.push(format!("c{ci}s{si}b{n}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if parts.is_empty() {
|
||||
return Err("no winner ordinals in the campaign_run record".to_string());
|
||||
}
|
||||
Ok(format!("cells={} {}", cells.len(), parts.join(" ")))
|
||||
}
|
||||
|
||||
fn campaign_rep(bin: &Path, sizing: Sizing, process_doc: &str) -> Result<RepOutcome, String> {
|
||||
let (scratch, doc) = build_scratch(bin, sizing, process_doc)?;
|
||||
let timed: TimedChild = run_timed(bin, &["campaign", "run", &doc], &scratch.0)?;
|
||||
if timed.exit != Some(0) {
|
||||
return Err(format!(
|
||||
"campaign run exited {:?}\nstdout: {}\nstderr: {}",
|
||||
timed.exit, timed.stdout, timed.stderr
|
||||
));
|
||||
}
|
||||
let fingerprint = winner_fingerprint(&timed.stdout)?;
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("wall_s".to_string(), timed.wall_s);
|
||||
if let Some(c) = timed.cpu_percent {
|
||||
metrics.insert("cpu_percent".to_string(), c);
|
||||
}
|
||||
if let Some(r) = timed.peak_rss_mb {
|
||||
metrics.insert("peak_rss_mb".to_string(), r);
|
||||
}
|
||||
Ok(RepOutcome { metrics, fingerprint })
|
||||
}
|
||||
|
||||
pub fn sweep_rep(bin: &Path, sizing: Sizing) -> Result<RepOutcome, String> {
|
||||
campaign_rep(bin, sizing, SWEEP_PROCESS_DOC)
|
||||
}
|
||||
|
||||
pub fn heavy_rep(bin: &Path, sizing: Sizing) -> Result<RepOutcome, String> {
|
||||
let doc = if sizing.quick { HEAVY_PROCESS_DOC_QUICK } else { HEAVY_PROCESS_DOC };
|
||||
campaign_rep(bin, sizing, doc)
|
||||
}
|
||||
|
||||
/// Used by `cli_fixed_cost`: a minimal one-symbol scratch project.
|
||||
pub fn build_minimal_project() -> Result<ScratchDir, String> {
|
||||
let scratch = ScratchDir::new("fixedcost").map_err(|e| format!("scratch dir: {e}"))?;
|
||||
let root = &scratch.0;
|
||||
std::fs::write(root.join("Aura.toml"), "[paths]\nruns = \"runs\"\ndata = \"data\"\n")
|
||||
.map_err(|e| format!("write Aura.toml: {e}"))?;
|
||||
let data_dir = root.join("data");
|
||||
std::fs::create_dir_all(&data_dir).map_err(|e| format!("create data dir: {e}"))?;
|
||||
write_symbol_archive(&data_dir, "BSYMA", (2024, 1), (2024, 1));
|
||||
std::fs::write(root.join("bench_sma_a.json"), BP_SMA_A)
|
||||
.map_err(|e| format!("write blueprint: {e}"))?;
|
||||
Ok(scratch)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn winner_fingerprint_extracts_ordinals_from_a_record_line() {
|
||||
let stdout = "noise\n{\"campaign_run\":{\"campaign\":\"c\",\"process\":\"p\",\"run\":0,\"seed\":7,\
|
||||
\"cells\":[{\"strategy\":\"s\",\"instrument\":\"I\",\"window_ms\":[1,2],\
|
||||
\"stages\":[{\"block\":\"std::sweep\",\"selection\":{\"winner_ordinal\":4,\"params\":[],\
|
||||
\"selection\":\"argmax\"}},{\"block\":\"std::gate\",\"survivor_ordinals\":[0,2]},\
|
||||
{\"block\":\"std::monte_carlo\",\"bootstrap\":{\"pooled_oos\":{\"n_trades\":42}}}]}]}}\n";
|
||||
let fp = winner_fingerprint(stdout).expect("parses");
|
||||
assert_eq!(fp, "cells=1 c0s0w4 c0s1v[0.2] c0s2b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winner_fingerprint_sums_per_survivor_bootstrap_trades() {
|
||||
let stdout = "{\"campaign_run\":{\"campaign\":\"c\",\"process\":\"p\",\"run\":0,\"seed\":7,\
|
||||
\"cells\":[{\"strategy\":\"s\",\"instrument\":\"I\",\"window_ms\":[1,2],\
|
||||
\"stages\":[{\"block\":\"std::monte_carlo\",\"bootstrap\":{\"per_survivor\":\
|
||||
[[0,{\"n_trades\":10}],[3,{\"n_trades\":5}]]}}]}]}}\n";
|
||||
let fp = winner_fingerprint(stdout).expect("parses");
|
||||
assert_eq!(fp, "cells=1 c0s0b15");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn winner_fingerprint_rejects_missing_record_line() {
|
||||
assert!(winner_fingerprint("no record here\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn campaign_doc_lists_all_instruments_and_strategies() {
|
||||
for quick in [true, false] {
|
||||
let doc = campaign_doc(
|
||||
&["BSYMA", "BSYMB"],
|
||||
&["id-a".to_string(), "id-b".to_string()],
|
||||
"proc-id",
|
||||
quick,
|
||||
);
|
||||
let v: serde_json::Value = serde_json::from_str(&doc).expect("doc is valid JSON");
|
||||
assert_eq!(v["data"]["instruments"].as_array().expect("instruments").len(), 2);
|
||||
assert_eq!(v["strategies"].as_array().expect("strategies").len(), 2);
|
||||
assert_eq!(v["seed"], 7);
|
||||
assert_eq!(v["process"]["ref"]["content_id"], "proc-id");
|
||||
let grid = v["strategies"][0]["axes"]["fast.length"]["values"]
|
||||
.as_array()
|
||||
.expect("fast axis")
|
||||
.len()
|
||||
* v["strategies"][0]["axes"]["slow.length"]["values"]
|
||||
.as_array()
|
||||
.expect("slow axis")
|
||||
.len();
|
||||
assert_eq!(grid, if quick { 9 } else { 25 }, "member grid, quick={quick}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Lockstep guard for the stdout-schema coupling: build a REAL
|
||||
/// `CampaignRunRecord` out of the aura-registry / aura-analysis /
|
||||
/// aura-engine types the CLI serializes, wrap it the way the record line
|
||||
/// is printed, and assert `winner_fingerprint` extracts every layer. A
|
||||
/// field rename in any of those crates now breaks this test (compile or
|
||||
/// assert) instead of silently eroding fingerprint coverage through the
|
||||
/// lenient JSON parse.
|
||||
#[test]
|
||||
fn winner_fingerprint_stays_in_lockstep_with_the_registry_record_types() {
|
||||
use aura_analysis::{FamilySelection, SelectionMode};
|
||||
use aura_backtest::RBootstrap;
|
||||
use aura_engine::MetricStats;
|
||||
use aura_registry::{CampaignRunRecord, CellRealization, StageRealization, StageSelection};
|
||||
|
||||
let stats = MetricStats { mean: 0.1, p5: 0.0, p25: 0.05, p50: 0.1, p75: 0.15, p95: 0.2 };
|
||||
let record = CampaignRunRecord {
|
||||
campaign: "camp-id".into(),
|
||||
process: "proc-id".into(),
|
||||
run: 0,
|
||||
seed: 7,
|
||||
cells: vec![CellRealization {
|
||||
strategy: "bp-id".into(),
|
||||
instrument: "BSYMA".into(),
|
||||
window_ms: (1, 2),
|
||||
stages: vec![
|
||||
StageRealization {
|
||||
block: "std::sweep".into(),
|
||||
family_id: None,
|
||||
survivor_ordinals: None,
|
||||
selection: Some(StageSelection {
|
||||
winner_ordinal: 4,
|
||||
params: vec![],
|
||||
selection: FamilySelection {
|
||||
selection_metric: "sqn_normalized".into(),
|
||||
n_trials: 25,
|
||||
raw_winner_metric: 0.5,
|
||||
mode: SelectionMode::Argmax,
|
||||
deflated_score: None,
|
||||
overfit_probability: None,
|
||||
n_resamples: None,
|
||||
block_len: None,
|
||||
seed: None,
|
||||
neighbourhood_score: None,
|
||||
n_neighbours: None,
|
||||
},
|
||||
}),
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
},
|
||||
StageRealization {
|
||||
block: "std::gate".into(),
|
||||
family_id: None,
|
||||
survivor_ordinals: Some(vec![0, 2]),
|
||||
selection: None,
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
},
|
||||
StageRealization {
|
||||
block: "std::monte_carlo".into(),
|
||||
family_id: None,
|
||||
survivor_ordinals: None,
|
||||
selection: None,
|
||||
bootstrap: Some(aura_registry::StageBootstrap::PooledOos(RBootstrap {
|
||||
e_r: stats,
|
||||
prob_le_zero: 0.3,
|
||||
n_trades: 42,
|
||||
block_len: 5,
|
||||
n_resamples: 1000,
|
||||
})),
|
||||
window_faults: vec![],
|
||||
},
|
||||
],
|
||||
regime: None,
|
||||
regime_ordinal: 0,
|
||||
fault: None,
|
||||
coverage: None,
|
||||
}],
|
||||
generalizations: vec![],
|
||||
trace_name: None,
|
||||
};
|
||||
let line = format!(
|
||||
"{{\"campaign_run\":{}}}",
|
||||
serde_json::to_string(&record).expect("record serializes")
|
||||
);
|
||||
let fp = winner_fingerprint(&line).expect("the real record shape must parse");
|
||||
assert_eq!(fp, "cells=1 c0s0w4 c0s1v[0.2] c0s2b42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_docs_are_valid_json() {
|
||||
for doc in [SWEEP_PROCESS_DOC, HEAVY_PROCESS_DOC, HEAVY_PROCESS_DOC_QUICK] {
|
||||
let v: serde_json::Value = serde_json::from_str(doc).expect("process doc parses");
|
||||
assert_eq!(v["kind"], "process");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
//! CLI fixed cost: the spawn floor (`aura --help`, no fingerprint — nothing is
|
||||
//! computed) and a minimal data-only project run whose fingerprint is the
|
||||
//! FNV-1a hash of the run's single stdout JSON line. Fresh scratch project per
|
||||
//! repetition, so store counters start identical.
|
||||
|
||||
use super::{median, RepOutcome};
|
||||
use crate::child::run_timed;
|
||||
use crate::surfaces::campaign::build_minimal_project;
|
||||
use crate::synth::fnv1a;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Fingerprint of an `aura run` record line, invariant across rebuilds: the
|
||||
/// line's `manifest.commit` is the aura binary's compile-time build sha
|
||||
/// (crates/aura-cli/build.rs — the invariant-8 audit trail), so hashing the
|
||||
/// raw line would flip the fingerprint on EVERY new commit and report a
|
||||
/// phantom correctness change at each cycle close. Parse, blank that one
|
||||
/// volatile field, re-serialize (serde_json's map is sorted — deterministic
|
||||
/// bytes), then hash.
|
||||
pub fn run_line_fingerprint(line: &str) -> Result<String, String> {
|
||||
let mut v: serde_json::Value =
|
||||
serde_json::from_str(line).map_err(|e| format!("run record line parses: {e}"))?;
|
||||
let commit = v
|
||||
.get_mut("manifest")
|
||||
.and_then(|m| m.get_mut("commit"))
|
||||
.ok_or("run record line carries manifest.commit")?;
|
||||
*commit = serde_json::Value::String(String::new());
|
||||
let canonical = serde_json::to_string(&v).expect("re-serializing a parsed Value cannot fail");
|
||||
Ok(format!("run_line_fnv={:016x}", fnv1a(canonical.as_bytes())))
|
||||
}
|
||||
|
||||
/// Spawn samples folded into one repetition: single-digit-millisecond spawns
|
||||
/// jitter well past the 10% NOTICE threshold, so each repetition reports the
|
||||
/// median over a batch instead of one throw of the scheduler dice.
|
||||
pub const HELP_SAMPLES: u32 = 20;
|
||||
pub const RUN_SAMPLES: u32 = 10;
|
||||
|
||||
pub fn rep(bin: &Path) -> Result<RepOutcome, String> {
|
||||
// Spawn floor: median wall over a batch of `aura --help` spawns.
|
||||
let mut help_walls = Vec::new();
|
||||
for _ in 0..HELP_SAMPLES {
|
||||
let t = Instant::now();
|
||||
let help = std::process::Command::new(bin)
|
||||
.arg("--help")
|
||||
.output()
|
||||
.map_err(|e| format!("spawn aura --help: {e}"))?;
|
||||
help_walls.push(t.elapsed().as_secs_f64() * 1000.0);
|
||||
if !help.status.success() {
|
||||
return Err(format!("aura --help exited {:?}", help.status.code()));
|
||||
}
|
||||
}
|
||||
|
||||
// Project-load + minimal run: one scratch project, a batch of runs, the
|
||||
// median wall. The fingerprint is the FIRST run's record line — later
|
||||
// runs append to the same store, so their lines legitimately differ (run
|
||||
// counters), while the first run of a fresh scratch is deterministic.
|
||||
let scratch = build_minimal_project()?;
|
||||
let mut run_walls = Vec::new();
|
||||
let mut first_line = None;
|
||||
for _ in 0..RUN_SAMPLES {
|
||||
let timed = run_timed(bin, &["run", "bench_sma_a.json"], &scratch.0)?;
|
||||
if timed.exit != Some(0) {
|
||||
return Err(format!("aura run exited {:?}: {}", timed.exit, timed.stdout));
|
||||
}
|
||||
run_walls.push(timed.wall_s * 1000.0);
|
||||
if first_line.is_none() {
|
||||
first_line = Some(
|
||||
timed
|
||||
.stdout
|
||||
.lines()
|
||||
.next()
|
||||
.ok_or("aura run must print its JSON record line")?
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let line = first_line.expect("RUN_SAMPLES >= 1 sets the first line");
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("help_ms".to_string(), median(help_walls));
|
||||
metrics.insert("run_ms".to_string(), median(run_walls));
|
||||
Ok(RepOutcome { metrics, fingerprint: run_line_fingerprint(&line)? })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The symptom this fixes, pinned: two record lines that differ ONLY in
|
||||
/// `manifest.commit` (a rebuild at a new commit) must fingerprint
|
||||
/// identically — the raw-line hash flipped here and reported a phantom
|
||||
/// correctness change on the first post-commit bench run.
|
||||
#[test]
|
||||
fn run_line_fingerprint_is_invariant_across_build_commits() {
|
||||
let at_a = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.5}}"#;
|
||||
let at_b = r#"{"manifest":{"commit":"bbbb","seed":0},"metrics":{"total_pips":0.5}}"#;
|
||||
assert_eq!(
|
||||
run_line_fingerprint(at_a).expect("parses"),
|
||||
run_line_fingerprint(at_b).expect("parses"),
|
||||
);
|
||||
}
|
||||
|
||||
/// The fingerprint still catches what it exists for: a computed-result
|
||||
/// change (any non-provenance byte) yields a different hash.
|
||||
#[test]
|
||||
fn run_line_fingerprint_still_detects_result_changes() {
|
||||
let base = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.5}}"#;
|
||||
let drifted = r#"{"manifest":{"commit":"aaaa","seed":0},"metrics":{"total_pips":0.6}}"#;
|
||||
assert_ne!(
|
||||
run_line_fingerprint(base).expect("parses"),
|
||||
run_line_fingerprint(drifted).expect("parses"),
|
||||
);
|
||||
}
|
||||
|
||||
/// A line without the provenance field is malformed input, not a silent
|
||||
/// pass — the guard that keeps the blanking honest if the record schema
|
||||
/// ever moves.
|
||||
#[test]
|
||||
fn run_line_fingerprint_rejects_a_line_without_manifest_commit() {
|
||||
assert!(run_line_fingerprint(r#"{"metrics":{"total_pips":0.5}}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
//! The surface framework: a surface is a closure the harness calls once per
|
||||
//! repetition; `run_reps` applies the measurement discipline (1 discarded
|
||||
//! warmup + N measured reps, median per metric) and enforces cross-rep
|
||||
//! fingerprint identity — a surface disagreeing with itself is nondeterminism,
|
||||
//! reported as an infra error, never averaged away.
|
||||
|
||||
pub mod campaign;
|
||||
pub mod engine;
|
||||
pub mod fixed_cost;
|
||||
pub mod ingest;
|
||||
|
||||
use crate::baseline::Measured;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Workload sizing: `quick` is the test/dev mode (tiny inputs, mechanics
|
||||
/// only); the committed baselines are always full-size.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Sizing {
|
||||
pub quick: bool,
|
||||
}
|
||||
|
||||
/// One repetition's outcome.
|
||||
pub struct RepOutcome {
|
||||
pub metrics: BTreeMap<String, f64>,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
pub fn median(mut xs: Vec<f64>) -> f64 {
|
||||
xs.sort_by(|a, b| a.partial_cmp(b).expect("no NaN metrics"));
|
||||
xs[xs.len() / 2]
|
||||
}
|
||||
|
||||
/// 1 discarded warmup + `reps` measured runs; median per metric key;
|
||||
/// cross-rep fingerprint identity enforced.
|
||||
pub fn run_reps(
|
||||
surface: &str,
|
||||
reps: u32,
|
||||
mut rep: impl FnMut() -> Result<RepOutcome, String>,
|
||||
) -> Result<Measured, String> {
|
||||
let reps = reps.max(1);
|
||||
let _warmup = rep()?; // page/alloc warmup, discarded
|
||||
let mut runs = Vec::new();
|
||||
for _ in 0..reps {
|
||||
runs.push(rep()?);
|
||||
}
|
||||
let fingerprint = runs[0].fingerprint.clone();
|
||||
for r in &runs[1..] {
|
||||
if r.fingerprint != fingerprint {
|
||||
return Err(format!(
|
||||
"surface {surface} is nondeterministic across repetitions: {fingerprint:?} vs {:?}",
|
||||
r.fingerprint
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut metrics = BTreeMap::new();
|
||||
for key in runs[0].metrics.keys() {
|
||||
let vals: Vec<f64> = runs.iter().filter_map(|r| r.metrics.get(key).copied()).collect();
|
||||
metrics.insert(key.clone(), median(vals));
|
||||
}
|
||||
Ok(Measured { surface: surface.to_string(), metrics, fingerprint })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn median_picks_the_middle() {
|
||||
assert_eq!(median(vec![3.0, 1.0, 2.0]), 2.0);
|
||||
assert_eq!(median(vec![1.0, 2.0]), 2.0);
|
||||
}
|
||||
|
||||
/// The engine surface's actual deliverable: `run_reps` composed with the
|
||||
/// real engine surface (a re-bootstrapped SMA-cross harness per call),
|
||||
/// not a mock closure. Protects the wiring end to end — a warmup plus
|
||||
/// three fresh bootstraps must agree on the determinism fingerprint and
|
||||
/// the returned metrics map must carry both keys a `BaselineDoc` for
|
||||
/// this surface needs (`wall_s`, `bars_per_s`), positive and finite.
|
||||
#[test]
|
||||
fn run_reps_drives_the_real_engine_surface_deterministically() {
|
||||
let measured = run_reps("engine", 3, || engine::rep(Sizing { quick: true }))
|
||||
.expect("the real engine surface must agree with itself across repeated bootstraps");
|
||||
assert_eq!(measured.surface, "engine");
|
||||
let bars_per_s = *measured.metrics.get("bars_per_s").expect("bars_per_s metric present");
|
||||
assert!(bars_per_s.is_finite() && bars_per_s > 0.0, "bars_per_s: {bars_per_s}");
|
||||
assert!(measured.metrics.contains_key("wall_s"));
|
||||
}
|
||||
|
||||
/// The ingest surface's actual deliverable: `run_reps` composed with the
|
||||
/// real ingest surface (a fresh `DataServer` re-opened and re-drained
|
||||
/// per call).
|
||||
/// Protects that a warmup plus three fresh drains of the same synthetic
|
||||
/// archive agree on the fingerprint, and that the metrics map carries
|
||||
/// the schema the ingest `BaselineDoc` needs.
|
||||
#[test]
|
||||
fn run_reps_drives_the_real_ingest_surface_deterministically() {
|
||||
let fixture = ingest::setup(Sizing { quick: true }).expect("ingest fixture must write");
|
||||
let measured = run_reps("ingest", 3, || ingest::rep(&fixture))
|
||||
.expect("the real ingest surface must agree with itself across repeated drains");
|
||||
assert_eq!(measured.surface, "ingest");
|
||||
let bars_per_s = *measured.metrics.get("bars_per_s").expect("bars_per_s metric present");
|
||||
assert!(bars_per_s.is_finite() && bars_per_s > 0.0, "bars_per_s: {bars_per_s}");
|
||||
assert!(measured.metrics.contains_key("wall_s"));
|
||||
}
|
||||
|
||||
/// `run_reps` composed with the real campaign sweep surface — a fresh
|
||||
/// scratch project, a freshly seeded blueprint, and a genuine
|
||||
/// `aura campaign run` child process spawned per repetition (not an
|
||||
/// in-process shortcut). Protects that two independently built-and-run
|
||||
/// scratch campaigns agree on the winner-ordinal fingerprint extracted
|
||||
/// from the child's stdout — C1's determinism carried through the
|
||||
/// child-process boundary, which is the whole reason the campaign
|
||||
/// surfaces exist as a distinct measurement path from the in-process
|
||||
/// engine/ingest surfaces above — and that the metrics map carries the
|
||||
/// wall-clock key the campaign `BaselineDoc` needs.
|
||||
///
|
||||
/// Ignored in the default suite: it builds the release `aura` binary and
|
||||
/// spawns real campaign child runs (minutes cold) — the suite-wallclock
|
||||
/// discipline forbids that cost on every `cargo test`. Run it explicitly:
|
||||
/// `cargo test -p aura-bench -- --ignored`.
|
||||
#[test]
|
||||
#[ignore = "builds the release aura binary + real campaign scratch runs; run via -- --ignored"]
|
||||
fn run_reps_drives_the_real_campaign_sweep_surface_deterministically() {
|
||||
let bin = crate::child::resolve_aura_bin(&crate::child::workspace_root())
|
||||
.expect("the release aura binary must build");
|
||||
let measured = run_reps("campaign_sweep", 2, || campaign::sweep_rep(&bin, Sizing { quick: true }))
|
||||
.expect("the real campaign sweep surface must agree with itself across independent scratch runs");
|
||||
assert_eq!(measured.surface, "campaign_sweep");
|
||||
assert!(measured.metrics.contains_key("wall_s"));
|
||||
assert!(
|
||||
measured.fingerprint.starts_with("cells="),
|
||||
"fingerprint: {}",
|
||||
measured.fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
/// `run_reps` composed with the real CLI fixed-cost surface — a genuine
|
||||
/// `aura --help` spawn-floor wall plus a fresh scratch project and a real
|
||||
/// `aura run` child process per repetition (not an in-process shortcut).
|
||||
/// Protects that two independent scratch runs agree on the run-line FNV
|
||||
/// fingerprint extracted from the child's stdout, and that the metrics
|
||||
/// map carries the wall-clock keys the fixed_cost `BaselineDoc` needs.
|
||||
///
|
||||
/// Ignored in the default suite: it builds the release `aura` binary and
|
||||
/// spawns real child processes — the suite-wallclock discipline forbids
|
||||
/// that cost on every `cargo test`. Run it explicitly:
|
||||
/// `cargo test -p aura-bench -- --ignored`.
|
||||
#[test]
|
||||
#[ignore = "builds the release aura binary + real fixed-cost child runs; run via -- --ignored"]
|
||||
fn run_reps_drives_the_real_fixed_cost_surface_deterministically() {
|
||||
let bin = crate::child::resolve_aura_bin(&crate::child::workspace_root())
|
||||
.expect("the release aura binary must build");
|
||||
let measured = run_reps("cli_fixed_cost", 2, || fixed_cost::rep(&bin))
|
||||
.expect("the real fixed_cost surface must agree with itself across independent scratch runs");
|
||||
assert_eq!(measured.surface, "cli_fixed_cost");
|
||||
assert!(measured.metrics.contains_key("help_ms"));
|
||||
assert!(measured.metrics.contains_key("run_ms"));
|
||||
assert!(
|
||||
measured.fingerprint.starts_with("run_line_fnv="),
|
||||
"fingerprint: {}",
|
||||
measured.fingerprint
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_reps_rejects_cross_rep_fingerprint_drift() {
|
||||
let mut n = 0;
|
||||
let r = run_reps("t", 2, move || {
|
||||
n += 1;
|
||||
Ok(RepOutcome { metrics: BTreeMap::new(), fingerprint: format!("fp{n}") })
|
||||
});
|
||||
assert!(r.is_err(), "differing fingerprints must be an error");
|
||||
}
|
||||
|
||||
/// The measurement discipline itself: the first (warmup) call is never
|
||||
/// mixed into the reported metric — its outlier value would otherwise
|
||||
/// skew the median — and the median is computed independently per
|
||||
/// metric key across only the measured reps.
|
||||
#[test]
|
||||
fn run_reps_discards_warmup_and_medians_only_measured_reps() {
|
||||
let mut n = 0;
|
||||
let measured = run_reps("t", 3, move || {
|
||||
n += 1;
|
||||
// Call 1 is the discarded warmup: an outlier that must never
|
||||
// enter the median. Calls 2..=4 (the measured reps) report
|
||||
// 1.0, 2.0, 3.0, whose median is 2.0.
|
||||
let v = if n == 1 { 1_000_000.0 } else { (n - 1) as f64 };
|
||||
let mut metrics = BTreeMap::new();
|
||||
metrics.insert("m".to_string(), v);
|
||||
Ok(RepOutcome { metrics, fingerprint: "fp".to_string() })
|
||||
})
|
||||
.expect("agreeing fingerprints must succeed");
|
||||
assert_eq!(measured.surface, "t");
|
||||
assert_eq!(measured.metrics.get("m"), Some(&2.0), "warmup outlier must not pollute the median");
|
||||
assert_eq!(measured.fingerprint, "fp");
|
||||
}
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
//! 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,14 +12,8 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
# the scalar vocabulary: params cross the MemberRunner seam as (name, Scalar) pairs.
|
||||
aura-core = { path = "../aura-core" }
|
||||
# the sweep/walk_forward orchestration machinery (metric-agnostic; RunReport
|
||||
# itself is generic here — the M = RunMetrics instantiation is aura-backtest's).
|
||||
# RunReport (the member result type) + the sweep/walk_forward orchestration machinery.
|
||||
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.
|
||||
@@ -29,8 +23,3 @@ 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"
|
||||
|
||||
+120
-533
@@ -7,24 +7,22 @@
|
||||
//! 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::{
|
||||
sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest,
|
||||
Space, WalkForwardError, WindowBounds, WindowRoller,
|
||||
r_bootstrap, sweep, walk_forward, GridSpace, ListSpace, RollMode, RunManifest, RunMetrics,
|
||||
RunReport, Space, SweepFamily, SweepPoint, WalkForwardError, WindowBounds, WindowRoller,
|
||||
WindowRun,
|
||||
};
|
||||
use aura_registry::{
|
||||
derive_trace_name, generalization, optimize, optimize_deflated, optimize_plateau,
|
||||
sweep_member_reports, CampaignGeneralization, CampaignRunRecord, CellRealization, FamilyKind,
|
||||
PlateauMode, Registry, StageBootstrap, StageRealization, StageSelection,
|
||||
sweep_member_reports, walkforward_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,
|
||||
@@ -75,18 +73,10 @@ pub struct CampaignOutcome {
|
||||
}
|
||||
|
||||
/// Execute a validated campaign document's process pipeline once per
|
||||
/// (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
|
||||
/// (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
|
||||
/// `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.
|
||||
@@ -97,7 +87,6 @@ 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() {
|
||||
@@ -127,19 +116,12 @@ pub fn execute(
|
||||
}
|
||||
let campaign_prefix = &campaign_id[..8];
|
||||
|
||||
// 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,
|
||||
}
|
||||
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();
|
||||
// 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).
|
||||
@@ -148,108 +130,42 @@ 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_ordinal, instrument) in campaign.data.instruments.iter().enumerate() {
|
||||
for instrument in &campaign.data.instruments {
|
||||
for (window_ordinal, window) in campaign.data.windows.iter().enumerate() {
|
||||
for (regime_ordinal, regime) in ®imes {
|
||||
planned.push(PlannedCell {
|
||||
doc_index: planned.len(),
|
||||
instrument_ordinal,
|
||||
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,
|
||||
window_ordinal,
|
||||
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,
|
||||
},
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -316,31 +232,6 @@ 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(
|
||||
@@ -356,27 +247,15 @@ 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 crossing the stage seam (see `StageFlow`).
|
||||
let mut flow = StageFlow::Members(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 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).
|
||||
@@ -384,14 +263,7 @@ fn run_cell(
|
||||
"{campaign_prefix}-{}-{}-w{window_ordinal}-r{}-s{stage_ordinal}",
|
||||
cell.strategy_ordinal, cell.instrument, cell.regime_ordinal,
|
||||
);
|
||||
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 = run_members(cell, &grid, runner)?;
|
||||
let family_id = registry
|
||||
.append_family(&family_name, FamilyKind::Sweep, &sweep_member_reports(&family))
|
||||
.map_err(ExecFault::Registry)?;
|
||||
@@ -428,7 +300,6 @@ fn run_cell(
|
||||
survivor_ordinals: None,
|
||||
selection: Some(StageSelection { winner_ordinal, params, selection }),
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
});
|
||||
}
|
||||
None => {
|
||||
@@ -442,20 +313,17 @@ 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).
|
||||
flow = StageFlow::Members(
|
||||
family
|
||||
.points
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, p)| (i, scalar_point(&family.space, &p.params), p.report.clone()))
|
||||
.collect(),
|
||||
);
|
||||
survivors = 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",
|
||||
@@ -470,21 +338,13 @@ fn run_cell(
|
||||
// None`) fails the member — conservative and deterministic
|
||||
// (the metric NAME was already preflighted against
|
||||
// `PER_MEMBER_METRICS`).
|
||||
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)| {
|
||||
survivors.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> = members.iter().map(|(i, _, _)| *i).collect();
|
||||
let ordinals: Vec<usize> = survivors.iter().map(|(i, _, _)| *i).collect();
|
||||
let empty = ordinals.is_empty();
|
||||
stages.push(StageRealization {
|
||||
block: "std::gate".to_string(),
|
||||
@@ -492,7 +352,6 @@ fn run_cell(
|
||||
survivor_ordinals: Some(ordinals),
|
||||
selection: None,
|
||||
bootstrap: None,
|
||||
window_faults: vec![],
|
||||
});
|
||||
if empty {
|
||||
nominee = None; // a truncated cell nominates no candidate
|
||||
@@ -509,8 +368,9 @@ 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>> = flow.points();
|
||||
match run_walk_forward_stage(
|
||||
let survivor_points: Vec<Vec<Scalar>> =
|
||||
survivors.iter().map(|(_, point, _)| point.clone()).collect();
|
||||
let fam = run_walk_forward_stage(
|
||||
seed,
|
||||
cell,
|
||||
stage_ordinal,
|
||||
@@ -521,31 +381,22 @@ fn run_cell(
|
||||
&family_name,
|
||||
runner,
|
||||
registry,
|
||||
) {
|
||||
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
|
||||
}
|
||||
)?;
|
||||
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);
|
||||
}
|
||||
StageBlock::MonteCarlo { resamples, block_len } => {
|
||||
// Terminal annotator (#200 d1/d3): nothing flows out. Dual
|
||||
@@ -556,7 +407,7 @@ fn run_cell(
|
||||
let bootstrap =
|
||||
match families.iter().rev().find(|f| f.block == "std::walk_forward") {
|
||||
Some(fam) => StageBootstrap::PooledOos(r_bootstrap(
|
||||
&pooled_net_trade_rs(&fam.reports),
|
||||
&pooled_trade_rs(&fam.reports),
|
||||
*resamples as usize,
|
||||
*block_len as usize,
|
||||
seed,
|
||||
@@ -564,39 +415,28 @@ 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 => {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
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(),
|
||||
),
|
||||
};
|
||||
stages.push(StageRealization {
|
||||
block: "std::monte_carlo".to_string(),
|
||||
@@ -604,7 +444,6 @@ fn run_cell(
|
||||
survivor_ordinals: None,
|
||||
selection: None,
|
||||
bootstrap: Some(bootstrap),
|
||||
window_faults: vec![],
|
||||
});
|
||||
}
|
||||
StageBlock::Generalize { .. } => {
|
||||
@@ -623,8 +462,6 @@ fn run_cell(
|
||||
window_ms: cell.window_ms,
|
||||
regime: cell.regime,
|
||||
regime_ordinal: cell.regime_ordinal,
|
||||
fault: None,
|
||||
coverage: runner.window_coverage(cell),
|
||||
stages,
|
||||
},
|
||||
))
|
||||
@@ -663,54 +500,6 @@ 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
|
||||
@@ -737,15 +526,9 @@ 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);
|
||||
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)) => {
|
||||
match runner.run_member(cell, ¶ms, cell.window_ms) {
|
||||
Ok(report) => report,
|
||||
Err(fault) => {
|
||||
let index = cells
|
||||
.iter()
|
||||
.position(|c| c.as_slice() == point)
|
||||
@@ -753,17 +536,6 @@ 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");
|
||||
@@ -774,110 +546,6 @@ 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.
|
||||
@@ -886,10 +554,9 @@ fn placeholder_report(params: &[(String, Scalar)], window_ms: (i64, i64)) -> Run
|
||||
manifest: RunManifest {
|
||||
commit: String::new(),
|
||||
params: params.to_vec(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: FAULTED_MEMBER_PLACEHOLDER_BROKER.to_string(),
|
||||
broker: "faulted-member-placeholder".to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
@@ -959,16 +626,15 @@ 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' `net_trade_rs`
|
||||
/// The pooled walk-forward OOS trade-R series: the family reports' `trade_rs`
|
||||
/// concatenated in report order — which IS roll order (the wf stage builds its
|
||||
/// 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> {
|
||||
/// 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> {
|
||||
let mut pooled = Vec::new();
|
||||
for report in reports {
|
||||
if let Some(r) = &report.metrics.r {
|
||||
pooled.extend_from_slice(&r.net_trade_rs);
|
||||
pooled.extend_from_slice(&r.trade_rs);
|
||||
}
|
||||
}
|
||||
pooled
|
||||
@@ -1011,7 +677,7 @@ fn run_walk_forward_stage(
|
||||
family_name: &str,
|
||||
runner: &dyn MemberRunner,
|
||||
registry: &Registry,
|
||||
) -> Result<WfStageOutcome, ExecFault> {
|
||||
) -> Result<StageFamily, ExecFault> {
|
||||
let StageBlock::WalkForward { in_sample_ms, out_of_sample_ms, step_ms, mode, metric, select } =
|
||||
block
|
||||
else {
|
||||
@@ -1090,16 +756,9 @@ 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| {
|
||||
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)) => {
|
||||
match runner.run_member(cell, &zip_params(specs, cells), (w.is.0 .0, w.is.1 .0)) {
|
||||
Ok(report) => report,
|
||||
Err(fault) => {
|
||||
let midx = is_points
|
||||
.iter()
|
||||
.position(|p| p.as_slice() == cells)
|
||||
@@ -1107,14 +766,6 @@ 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");
|
||||
@@ -1137,93 +788,29 @@ 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 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)) => {
|
||||
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) => {
|
||||
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 }
|
||||
});
|
||||
|
||||
// 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 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);
|
||||
}
|
||||
|
||||
// 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 reports = walkforward_member_reports(&result);
|
||||
let family_id = registry
|
||||
.append_family(family_name, FamilyKind::WalkForward, &reports)
|
||||
.map_err(ExecFault::Registry)?;
|
||||
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()
|
||||
Ok(StageFamily { stage, block: "std::walk_forward", family_id, reports })
|
||||
}
|
||||
|
||||
+72
-302
@@ -12,30 +12,15 @@
|
||||
//! and no UI.
|
||||
|
||||
mod exec;
|
||||
pub use exec::{
|
||||
catch_member_panic, execute, member_fault_prose, CampaignOutcome, CellOutcome, StageFamily,
|
||||
StageSelectionOut,
|
||||
};
|
||||
pub use exec::{execute, CampaignOutcome, CellOutcome, StageFamily, StageSelectionOut};
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use aura_core::Scalar;
|
||||
use aura_backtest::RunReport;
|
||||
use aura_engine::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 {
|
||||
@@ -62,10 +47,6 @@ 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,
|
||||
@@ -73,13 +54,6 @@ 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
|
||||
@@ -89,9 +63,6 @@ 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
|
||||
@@ -138,11 +109,10 @@ 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 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.
|
||||
/// 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.
|
||||
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",
|
||||
@@ -150,10 +120,14 @@ pub const PER_MEMBER_METRICS: &[&str] = &[
|
||||
"sqn", "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;
|
||||
/// 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",
|
||||
];
|
||||
|
||||
/// Deflation resample count — re-exported from `aura-registry`'s shared
|
||||
/// definition (single-sourced, #199).
|
||||
@@ -222,7 +196,6 @@ 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,
|
||||
@@ -232,60 +205,27 @@ 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 { .. } | StageBlock::Grid)
|
||||
) {
|
||||
if !matches!(process.pipeline.first(), Some(StageBlock::Sweep { .. })) {
|
||||
return Err(ExecFault::PipelineShape {
|
||||
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(),
|
||||
detail: "the first stage must be std::sweep".to_string(),
|
||||
});
|
||||
}
|
||||
let mut prev = &process.pipeline[0];
|
||||
for (i, stage) in process.pipeline.iter().enumerate().skip(1) {
|
||||
if matches!(stage, StageBlock::Sweep { .. } | StageBlock::Grid) {
|
||||
if matches!(stage, StageBlock::Sweep { .. }) {
|
||||
return Err(ExecFault::PipelineShape {
|
||||
detail: format!("stage {i}: only the first stage may be {}", block_id(stage)),
|
||||
detail: format!("stage {i}: only the first stage may be std::sweep"),
|
||||
});
|
||||
}
|
||||
let (rank, prev_rank) = (shape_rank(stage), shape_rank(prev));
|
||||
@@ -308,7 +248,6 @@ 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()) {
|
||||
@@ -390,8 +329,7 @@ pub fn preflight(process: &ProcessDoc, campaign: &CampaignDoc) -> Result<(), Exe
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_backtest::{RMetrics, RunMetrics};
|
||||
use aura_engine::{RunManifest, Timestamp};
|
||||
use aura_engine::{RMetrics, RunManifest, RunMetrics, Timestamp};
|
||||
use aura_research::SweepSelection;
|
||||
|
||||
/// A report with every per-member scalar planted to a distinct value
|
||||
@@ -401,7 +339,6 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "test".to_string(),
|
||||
params: vec![],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(0), Timestamp(1)),
|
||||
seed: 0,
|
||||
broker: "sim".to_string(),
|
||||
@@ -427,7 +364,7 @@ mod tests {
|
||||
sqn: 13.0,
|
||||
net_expectancy_r: 14.0,
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
net_trade_rs: Vec::new(),
|
||||
trade_rs: Vec::new(),
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -793,67 +730,6 @@ 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]
|
||||
@@ -994,9 +870,8 @@ 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, Timestamp, WindowRoller};
|
||||
use aura_engine::{RollMode, RunManifest, RunMetrics, RunReport, Timestamp, WindowRoller};
|
||||
use aura_registry::{FamilyKind, Registry};
|
||||
use aura_research::{
|
||||
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation,
|
||||
@@ -1009,10 +884,8 @@ 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 {
|
||||
// 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 dir = std::env::temp_dir()
|
||||
.join(format!("aura-campaign-wf-{}-{}", std::process::id(), name));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
Registry::open(dir.join("runs.jsonl"))
|
||||
@@ -1146,7 +1019,6 @@ mod wf_tests {
|
||||
manifest: RunManifest {
|
||||
commit: "wf-fake".to_string(),
|
||||
params: params.to_vec(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: "fake".to_string(),
|
||||
@@ -1173,15 +1045,7 @@ 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,
|
||||
crate::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)
|
||||
execute(&campaign_id, campaign, process, &strategies, runner, registry)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1297,169 +1161,75 @@ mod wf_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #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.
|
||||
/// 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.
|
||||
#[test]
|
||||
fn wf_one_faulted_fold_is_contained_survivors_pool() {
|
||||
fn wf_is_member_fault_lowest_window_wins() {
|
||||
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 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.
|
||||
// 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.
|
||||
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(4))],
|
||||
vec![("len".to_string(), Scalar::i64(3))],
|
||||
MemberFault::Run("is-w1".to_string()),
|
||||
),
|
||||
(
|
||||
(40, 79),
|
||||
vec![("len".to_string(), Scalar::i64(4))],
|
||||
vec![("len".to_string(), Scalar::i64(2))],
|
||||
MemberFault::Run("is-w2".to_string()),
|
||||
),
|
||||
]);
|
||||
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 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 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());
|
||||
}
|
||||
|
||||
/// #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).
|
||||
/// 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.
|
||||
#[test]
|
||||
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");
|
||||
fn wf_oos_member_fault_aborts_walk_forward() {
|
||||
let registry = wf_registry("oos-fault");
|
||||
let campaign = wf_campaign((0, 99));
|
||||
let process = wf_process(None, 40, 20, 20);
|
||||
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);
|
||||
// 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));
|
||||
}
|
||||
|
||||
/// select_sweep_winner's Plateau arms dispatch through the public
|
||||
|
||||
@@ -4,13 +4,10 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_campaign::{
|
||||
execute, CellSpec, ExecFault, MemberFault, MemberRunner, DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
};
|
||||
use aura_backtest::{r_bootstrap, RMetrics, RunMetrics, RunReport};
|
||||
use aura_campaign::{execute, CellSpec, ExecFault, MemberFault, MemberRunner};
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use aura_engine::{RunManifest, SelectionMode};
|
||||
use aura_registry::{generalization, FamilyKind, Registry, RegistryError, StageBootstrap};
|
||||
use aura_engine::{r_bootstrap, RMetrics, RunManifest, RunMetrics, RunReport, SelectionMode};
|
||||
use aura_registry::{generalization, FamilyKind, Registry, StageBootstrap};
|
||||
use aura_research::{
|
||||
Axis, CampaignDoc, Cmp, DataSection, DocKind, DocRef, Predicate, Presentation, ProcessDoc,
|
||||
ProcessRef, SelectRule, StageBlock, StrategyEntry, SweepSelection, WfMode, Window,
|
||||
@@ -44,9 +41,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: `net_trade_rs` is serde-skipped and excluded from `PartialEq`, so every
|
||||
/// only: `trade_rs` is serde-skipped and excluded from `PartialEq`, so every
|
||||
/// existing equality and round-trip assertion stays green.
|
||||
fn planted_net_trade_rs(net: f64) -> Vec<f64> {
|
||||
fn planted_trade_rs(net: f64) -> Vec<f64> {
|
||||
vec![net, -net, 2.0 * net, net]
|
||||
}
|
||||
|
||||
@@ -61,7 +58,6 @@ fn planted_report(cell: &CellSpec, params: &[(String, Scalar)], window_ms: (i64,
|
||||
manifest: RunManifest {
|
||||
commit: "fake".to_string(),
|
||||
params: params.to_vec(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: "fake".to_string(),
|
||||
@@ -87,7 +83,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],
|
||||
net_trade_rs: planted_net_trade_rs(net),
|
||||
trade_rs: planted_trade_rs(net),
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -129,22 +125,6 @@ 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> {
|
||||
@@ -221,22 +201,6 @@ 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,
|
||||
@@ -254,8 +218,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::path::Path::new(env!("CARGO_TARGET_TMPDIR"))
|
||||
.join(format!("aura-campaign-exec-{name}"));
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("aura-campaign-exec-{}-{}", std::process::id(), 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"))
|
||||
@@ -266,7 +230,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("sweep-only campaign executes");
|
||||
|
||||
// outcome payloads: one cell, one family of 4 members, one selection
|
||||
@@ -329,7 +293,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("selection-free sweep campaign executes");
|
||||
|
||||
// outcome payloads: one cell, one family of 4 members, NO selection
|
||||
@@ -366,7 +330,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("gated campaign executes");
|
||||
|
||||
// planted nets 0.26 / 0.29 / 0.36 / 0.39 -> gt 0.3 keeps ordinals 2 and 3
|
||||
@@ -385,7 +349,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("a zero-survivor cell is a valid result, not a fault");
|
||||
|
||||
assert_eq!(out.record.cells.len(), 2, "the second cell still runs");
|
||||
@@ -402,46 +366,14 @@ 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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("first run");
|
||||
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("second run");
|
||||
|
||||
// run counter advances per campaign id
|
||||
@@ -467,57 +399,34 @@ 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_records_a_failed_cell_and_continues() {
|
||||
fn execute_member_fault_aborts_with_lowest_index() {
|
||||
let reg = temp_registry("member_fault");
|
||||
// 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 doc = campaign(&["EURUSD"]);
|
||||
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".to_string()))],
|
||||
faults: vec![
|
||||
(point(2, 9), MemberFault::Run("boom at index 1".to_string())),
|
||||
(point(3, 6), MemberFault::Run("boom at index 2".to_string())),
|
||||
],
|
||||
};
|
||||
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);
|
||||
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:?}"),
|
||||
}
|
||||
// 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);
|
||||
// the fault aborted before any family or realization write
|
||||
assert!(reg.load_family_members().expect("load members").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);
|
||||
assert!(reg.load_campaign_runs().expect("load campaign runs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -525,7 +434,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.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);
|
||||
@@ -539,7 +448,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let err = execute("short", &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect_err("a non-64-hex campaign id is refused, not sliced");
|
||||
match err {
|
||||
ExecFault::PipelineShape { detail } => assert!(
|
||||
@@ -560,7 +469,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("mc-after-gate campaign executes");
|
||||
|
||||
// planted nets .26/.29/.36/.39 -> gate gt 0.3 keeps ordinals 2 and 3
|
||||
@@ -573,11 +482,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
|
||||
// net_trade_rs, seeded from the campaign doc (the deflation convention)
|
||||
// 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_net_trade_rs(net(3, 6)), 200, 2, 7)),
|
||||
(3, r_bootstrap(&planted_net_trade_rs(net(3, 9)), 200, 2, 7)),
|
||||
(2, r_bootstrap(&planted_trade_rs(net(3, 6)), 200, 2, 7)),
|
||||
(3, r_bootstrap(&planted_trade_rs(net(3, 9)), 200, 2, 7)),
|
||||
]);
|
||||
assert_eq!(mc.bootstrap, Some(expected));
|
||||
|
||||
@@ -586,52 +495,6 @@ 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");
|
||||
@@ -641,7 +504,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("mc-after-wf campaign executes");
|
||||
|
||||
// the roller yields 2 windows; both pick the planted argmax (3,9)
|
||||
@@ -649,15 +512,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' net_trade_rs concatenated in report
|
||||
// pooled input = the wf family reports' 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").net_trade_rs);
|
||||
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").trade_rs);
|
||||
}
|
||||
let net = (3 * 10 + 9) as f64 / 100.0;
|
||||
let mut expected_pool = planted_net_trade_rs(net);
|
||||
expected_pool.extend(planted_net_trade_rs(net));
|
||||
let mut expected_pool = planted_trade_rs(net);
|
||||
expected_pool.extend(planted_trade_rs(net));
|
||||
assert_eq!(pooled, expected_pool);
|
||||
|
||||
let mc = &out.record.cells[0].stages[2];
|
||||
@@ -677,7 +540,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, ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, ®)
|
||||
.expect("a zero-trade member is a valid result, not a fault");
|
||||
|
||||
let mc = &out.record.cells[0].stages[1];
|
||||
@@ -700,7 +563,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("generalize campaign executes");
|
||||
|
||||
assert_eq!(out.record.generalizations.len(), 1);
|
||||
@@ -759,7 +622,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("a truncated cell is a recorded shortfall, not a fault");
|
||||
|
||||
assert!(out.cells[0].nominee.is_none(), "gate-truncated cell nominates nothing");
|
||||
@@ -780,7 +643,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("sweep-only generalize campaign executes");
|
||||
|
||||
// no wf ran: each cell nominates its sweep winner (planted argmax (3,9))
|
||||
@@ -813,7 +676,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 `net_trade_rs`
|
||||
/// per-cell bootstrap still pools exactly the wf family's OOS `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
|
||||
@@ -832,7 +695,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("full v2 pipeline executes");
|
||||
|
||||
// both cells realize all four per-cell stages; generalize contributes no
|
||||
@@ -842,13 +705,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 net_trade_rs exactly as it does
|
||||
// mc's bootstrap pools the wf family's OOS 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").net_trade_rs);
|
||||
pooled.extend_from_slice(&report.metrics.r.as_ref().expect("planted R block").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));
|
||||
@@ -890,7 +753,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("persist_taps campaign executes");
|
||||
|
||||
// returned record: the derived name over the first run counter
|
||||
@@ -908,7 +771,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.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()));
|
||||
@@ -927,7 +790,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("two-regime campaign executes");
|
||||
|
||||
// one cell per regime, each with its own sweep family.
|
||||
@@ -954,7 +817,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(), ®, DEFAULT_PARALLEL_INSTRUMENTS)
|
||||
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), ®)
|
||||
.expect("two-regime generalize campaign executes");
|
||||
|
||||
// 2 instruments x 2 regimes -> 4 cells, but exactly 2 generalize groups
|
||||
@@ -972,231 +835,3 @@ 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,8 +12,7 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use aura_campaign::{member_metric, CellSpec, MemberFault, MemberRunner, PER_MEMBER_METRICS, RANKABLE_METRICS};
|
||||
use aura_backtest::{summarize, RMetrics, RunMetrics, RunReport};
|
||||
use aura_engine::{RunManifest, Scalar, Timestamp};
|
||||
use aura_engine::{summarize, RMetrics, RunManifest, RunMetrics, RunReport, Scalar, Timestamp};
|
||||
|
||||
fn cell() -> CellSpec {
|
||||
CellSpec {
|
||||
@@ -29,7 +28,7 @@ fn cell() -> CellSpec {
|
||||
}
|
||||
|
||||
/// A minimal external `MemberRunner`: ignores its params and produces
|
||||
/// metrics via the real `aura_backtest::summarize` reduction (not a hand-typed
|
||||
/// metrics via the real `aura_engine::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;
|
||||
@@ -47,7 +46,6 @@ impl MemberRunner for FixedRunner {
|
||||
manifest: RunManifest {
|
||||
commit: "e2e".to_string(),
|
||||
params: vec![],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(window_ms.0), Timestamp(window_ms.1)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
@@ -65,7 +63,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_backtest::summarize()`
|
||||
/// reaches the runner unmodified; and the real `aura_engine::summarize()`
|
||||
/// output the runner produced is what `member_metric` reads back through the
|
||||
/// crate's public per-name resolver.
|
||||
#[test]
|
||||
@@ -89,9 +87,8 @@ 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`.
|
||||
/// 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
|
||||
/// The two rosters are independently hand-copied (documented drift risk,
|
||||
/// #190); were they 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.
|
||||
@@ -101,7 +98,6 @@ fn rankable_metrics_are_all_per_member_resolvable() {
|
||||
manifest: RunManifest {
|
||||
commit: "e2e".to_string(),
|
||||
params: vec![],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(0), Timestamp(1)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
@@ -127,7 +123,7 @@ fn rankable_metrics_are_all_per_member_resolvable() {
|
||||
sqn: 13.0,
|
||||
net_expectancy_r: 14.0,
|
||||
conviction_terciles_r: [0.0; 3],
|
||||
net_trade_rs: Vec::new(),
|
||||
trade_rs: Vec::new(),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
//! 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 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 —
|
||||
//! 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 —
|
||||
//! `metric_vocabulary`'s own doc comment names this as an accepted residual).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use aura_analysis::{FamilySelection, SelectionMode};
|
||||
use aura_backtest::{summarize_r, RunMetrics};
|
||||
use aura_analysis::{summarize_r, FamilySelection, RunMetrics, SelectionMode};
|
||||
|
||||
/// 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
|
||||
@@ -85,7 +83,7 @@ fn metric_vocabulary_covers_all_shipped_scalar_metrics() {
|
||||
scalar_fields.extend(selection_scalars);
|
||||
|
||||
let vocabulary: BTreeSet<String> =
|
||||
aura_research::metric_vocabulary().iter().map(|m| m.id.to_string()).collect();
|
||||
aura_research::metric_vocabulary().iter().map(|s| s.to_string()).collect();
|
||||
|
||||
let missing_from_vocabulary: Vec<_> = scalar_fields.difference(&vocabulary).collect();
|
||||
let stale_in_vocabulary: Vec<_> = vocabulary.difference(&scalar_fields).collect();
|
||||
@@ -107,31 +105,3 @@ 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());
|
||||
}
|
||||
|
||||
+10
-32
@@ -12,6 +12,9 @@ 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
|
||||
@@ -19,17 +22,7 @@ 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" }
|
||||
@@ -47,6 +40,13 @@ 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,25 +60,3 @@ 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","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"}]}}
|
||||
{"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"}]}}
|
||||
@@ -0,0 +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"}]}]}}
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"hl_channel","doc":"prior high/low channel breaks latched into a directional signal","nodes":[{"primitive":{"type":"Delay","name":"prev_high","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"Delay","name":"prev_low","bound":[{"pos":0,"name":"lag","kind":"I64","value":{"I64":1}}]}},{"primitive":{"type":"RollingMax","name":"channel_hi","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","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"}]}}
|
||||
@@ -0,0 +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"}]}]}}
|
||||
@@ -1 +1 @@
|
||||
{"format_version":1,"blueprint":{"name":"r_meanrev_signal","doc":"EMA deviation against a volatility band, latched into a mean-reversion signal","nodes":[{"primitive":{"type":"EMA","name":"mean_window","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","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"}]}}
|
||||
@@ -0,0 +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"}]}]}}
|
||||
@@ -1 +1 @@
|
||||
{"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"}]}}
|
||||
{"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"}]}}
|
||||
@@ -0,0 +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"}]}}
|
||||
@@ -9,6 +9,12 @@
|
||||
//! root roles (`wrap_r`) — in the same canonical `M1Field` declaration order,
|
||||
//! so the two halves of the old single-price weld can no longer drift apart
|
||||
//! (C4 merge tie-break = role declaration order = source open order).
|
||||
//!
|
||||
//! `dead_code` is allowed module-wide only while nothing outside this
|
||||
//! module consumes it; the allow is scaffolding to REMOVE with the first
|
||||
//! consumer (the wrap seam), at which point genuinely unused items must
|
||||
//! surface again.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -29,7 +35,7 @@ pub(crate) const COLUMN_VOCABULARY: &str =
|
||||
/// common case at the wrap seam, so declaring a port for a vocabulary-named
|
||||
/// role needs no per-build leak; only an override-renamed (non-vocabulary)
|
||||
/// role falls back to leaking its name.
|
||||
pub fn static_role_name(name: &str) -> Option<&'static str> {
|
||||
pub(crate) fn static_role_name(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"open" => Some("open"),
|
||||
"high" => Some("high"),
|
||||
@@ -70,7 +76,7 @@ pub(crate) fn column_name(column: M1Field) -> &'static str {
|
||||
|
||||
/// The scalar kind a column streams (mirrors `aura_ingest::decode`: Volume is
|
||||
/// the one `i64` column; everything else is `f64`).
|
||||
pub fn column_kind(column: M1Field) -> ScalarKind {
|
||||
pub(crate) fn column_kind(column: M1Field) -> ScalarKind {
|
||||
match column {
|
||||
M1Field::Volume => ScalarKind::I64,
|
||||
_ => ScalarKind::F64,
|
||||
@@ -94,7 +100,7 @@ fn rank(column: M1Field) -> usize {
|
||||
/// name), the archive column it binds, and whether it feeds the signal
|
||||
/// (`false` only for the synthesized broker/executor-only close entry).
|
||||
#[derive(Debug)]
|
||||
pub struct BindingEntry {
|
||||
pub(crate) struct BindingEntry {
|
||||
pub role: String,
|
||||
pub column: M1Field,
|
||||
pub feeds_signal: bool,
|
||||
@@ -104,25 +110,25 @@ pub struct BindingEntry {
|
||||
/// openers (column opening) share: entries in canonical column order, with a
|
||||
/// close entry guaranteed (the broker/executor price feed).
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedBinding {
|
||||
pub(crate) struct ResolvedBinding {
|
||||
entries: Vec<BindingEntry>,
|
||||
}
|
||||
|
||||
impl ResolvedBinding {
|
||||
pub fn entries(&self) -> &[BindingEntry] {
|
||||
pub(crate) fn entries(&self) -> &[BindingEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// The columns to open, one per entry, in canonical order — exactly the
|
||||
/// order the entries' roles are declared in, so source index i always
|
||||
/// feeds role i.
|
||||
pub fn columns(&self) -> Vec<M1Field> {
|
||||
pub(crate) fn columns(&self) -> Vec<M1Field> {
|
||||
self.entries.iter().map(|e| e.column).collect()
|
||||
}
|
||||
|
||||
/// True iff the strategy consumes the close column only — the one shape
|
||||
/// the synthetic walk (a single close series) can drive.
|
||||
pub fn close_only(&self) -> bool {
|
||||
pub(crate) fn close_only(&self) -> bool {
|
||||
self.columns() == [M1Field::Close]
|
||||
}
|
||||
}
|
||||
@@ -147,7 +153,7 @@ fn finish(mut entries: Vec<BindingEntry>) -> ResolvedBinding {
|
||||
/// override wins over the name default; a role that is neither
|
||||
/// vocabulary-named nor overridden is a refusal naming the vocabulary and the
|
||||
/// override remedy.
|
||||
pub fn resolve_binding(
|
||||
pub(crate) fn resolve_binding(
|
||||
strategy: &str,
|
||||
roles: &[Role],
|
||||
overrides: &BTreeMap<String, String>,
|
||||
@@ -160,17 +166,6 @@ pub fn resolve_binding(
|
||||
\"price\" is the alias of \"close\", so the pair is ambiguous; rename one role"
|
||||
));
|
||||
}
|
||||
// Duplicate role names would lower to duplicate keyed feeds, which the by-name
|
||||
// bind (`run_bound`) rejects as `DuplicateFeed`. The blueprint path does not
|
||||
// enforce role-name uniqueness (only the construction op-script path does), so
|
||||
// refuse here — a named refusal on every CLI run path, not a panic behind the
|
||||
// `.expect()` the single-binding invariant relies on.
|
||||
if let Some(dup) = first_duplicate_role(roles) {
|
||||
return Err(format!(
|
||||
"strategy \"{strategy}\" declares input role \"{dup}\" more than once — \
|
||||
input role names must be unique"
|
||||
));
|
||||
}
|
||||
let mut entries: Vec<BindingEntry> = Vec::with_capacity(roles.len() + 1);
|
||||
for role in roles {
|
||||
let column = match overrides.get(&role.name) {
|
||||
@@ -205,25 +200,13 @@ pub fn resolve_binding(
|
||||
Ok(finish(entries))
|
||||
}
|
||||
|
||||
/// The first role name that appears more than once in `roles`, in declaration
|
||||
/// order, or `None` if every name is unique.
|
||||
fn first_duplicate_role(roles: &[Role]) -> Option<&str> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
for r in roles {
|
||||
if !seen.insert(r.name.as_str()) {
|
||||
return Some(r.name.as_str());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The lenient PROBE binding for the throwaway param-space wrap
|
||||
/// (`blueprint_axis_probe`): every unresolvable role falls back to the close
|
||||
/// column. The probe composite is built but never run over data — the
|
||||
/// fallback is a placeholder exactly like the probe's synthetic pip, so
|
||||
/// probing never refuses a blueprint the (strictly resolved) run path may
|
||||
/// still bind via campaign overrides.
|
||||
pub fn probe_binding(roles: &[Role]) -> ResolvedBinding {
|
||||
pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding {
|
||||
let entries: Vec<BindingEntry> = roles
|
||||
.iter()
|
||||
.map(|role| BindingEntry {
|
||||
@@ -237,7 +220,7 @@ pub fn probe_binding(roles: &[Role]) -> ResolvedBinding {
|
||||
|
||||
/// The refusal for a multi-column binding on synthetic data (which generates
|
||||
/// a close series only). Callers invoke it only when `!binding.close_only()`.
|
||||
pub fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String {
|
||||
pub(crate) fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String {
|
||||
let beyond: Vec<&str> = binding
|
||||
.entries()
|
||||
.iter()
|
||||
@@ -375,20 +358,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Two input roles with the same name would lower to a duplicate keyed feed;
|
||||
/// `resolve_binding` refuses at the binding boundary (a named refusal, not a
|
||||
/// `run_bound` `DuplicateFeed` panic behind the CLI's `.expect()`).
|
||||
#[test]
|
||||
fn duplicate_role_name_refuses() {
|
||||
let err =
|
||||
resolve_binding("x", &[role("high"), role("high")], &BTreeMap::new()).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
"strategy \"x\" declares input role \"high\" more than once — \
|
||||
input role names must be unique"
|
||||
);
|
||||
}
|
||||
|
||||
/// A role literally named "close" overridden away from the close column
|
||||
/// would otherwise collide with `finish`'s synthesized close entry (two
|
||||
/// entries both named "close") — refused instead of double-naming.
|
||||
+1004
-102
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
||||
//! Stable stderr class markers (C14): `aura: note: <text>` for benign
|
||||
//! diagnostics on a continuing run (exit code unaffected),
|
||||
//! `aura: warning: <text>` for recorded faults the run survives.
|
||||
//! Error lines that accompany a non-zero exit — and plain info lines
|
||||
//! such as the run-record summary — keep the bare `aura:` prefix; for
|
||||
//! errors, the exit-code partition is the machine contract.
|
||||
|
||||
macro_rules! note {
|
||||
($($arg:tt)*) => {
|
||||
eprintln!("aura: note: {}", format_args!($($arg)*))
|
||||
};
|
||||
}
|
||||
|
||||
macro_rules! warning {
|
||||
($($arg:tt)*) => {
|
||||
eprintln!("aura: warning: {}", format_args!($($arg)*))
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) use {note, warning};
|
||||
|
||||
/// The zero-trade note's message text (#313), pluralization-aware — the
|
||||
/// milestone fieldtest tripped over "all 1 walk-forward windows"
|
||||
/// (captured: `fieldtests/milestone-stderr-honesty/captured/
|
||||
/// example2_singular.err`). Pure over the count so the wording is
|
||||
/// unit-pinned.
|
||||
fn zero_trade_note_text(n_windows: usize) -> String {
|
||||
if n_windows == 1 {
|
||||
"the single walk-forward window recorded zero trades".to_string()
|
||||
} else {
|
||||
format!("all {n_windows} walk-forward windows recorded zero trades")
|
||||
}
|
||||
}
|
||||
|
||||
/// The #313 zero-trade note, shared by both walk-forward paths (the
|
||||
/// synthetic-blueprint path in `main.rs` and the `--real` sugar path in
|
||||
/// `verb_sugar.rs`) so the wording AND the condition live in exactly one
|
||||
/// place. Takes the per-window trade counts; a no-op unless there is at
|
||||
/// least one window and every one of them traded zero times.
|
||||
pub(crate) fn note_zero_trade_windows(mut window_trades: impl ExactSizeIterator<Item = u64>) {
|
||||
let n_windows = window_trades.len();
|
||||
if n_windows > 0 && window_trades.all(|n| n == 0) {
|
||||
note!("{}", zero_trade_note_text(n_windows));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::zero_trade_note_text;
|
||||
|
||||
#[test]
|
||||
/// #278 fieldtest friction: the single-window form must read as
|
||||
/// grammatical English while the plural form keeps the exact phrase
|
||||
/// the e2e pins grep for.
|
||||
fn zero_trade_note_pluralizes() {
|
||||
assert_eq!(
|
||||
zero_trade_note_text(1),
|
||||
"the single walk-forward window recorded zero trades"
|
||||
);
|
||||
assert_eq!(
|
||||
zero_trade_note_text(3),
|
||||
"all 3 walk-forward windows recorded zero trades"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,67 +2,28 @@
|
||||
//! JSON op-list document deserializes into the `OpDoc` DTO (so the engine `Op`
|
||||
//! stays serde-free), which maps 1:1 into `aura_engine::Op`. The `aura graph
|
||||
//! build` / `aura graph introspect` subcommands here drive these ops through the
|
||||
//! injected `aura_vocabulary::std_vocabulary`.
|
||||
//! injected `aura_std::std_vocabulary`.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
use aura_engine::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, name_gate, replay, ArgOpError,
|
||||
BindOpError, BlueprintDoc, CompileError, Composite, GraphSession, LoadError, NameGateFault, Op,
|
||||
OpError, Scalar, ScalarKind,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, replay, BindOpError,
|
||||
CompileError, Composite, GraphSession, LoadError, Op, OpError, Scalar, ScalarKind,
|
||||
};
|
||||
use aura_runner::runner::render_value;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::research_docs::resolve_id_prefix;
|
||||
|
||||
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
|
||||
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
|
||||
// production code; tests still exercise it directly.
|
||||
#[cfg(test)]
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
|
||||
/// The op-list reference `aura graph build --help` appends (#323): the eleven
|
||||
/// op kinds with their fields and one worked element each (#331: `name` joins
|
||||
/// the roster, ten -> eleven). Lives beside [`OpDoc`] so a new op variant is
|
||||
/// one screen away from its help line.
|
||||
pub const OP_REFERENCE: &str = r#"Op-list reference (stdin: a JSON array of op objects, applied in order):
|
||||
{"op":"source","role":"price","kind":"F64"}
|
||||
declare a bound root input role of a scalar kind
|
||||
{"op":"input","role":"price"}
|
||||
declare an open input role (wired by an enclosing graph)
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}}
|
||||
instantiate a node ("name" optional; "bind" maps param -> typed scalar)
|
||||
{"op":"add","type":"Session","name":"ny","args":{"tz":"America/New_York","open":"09:30"},"bind":{"period_minutes":{"I64":15}}}
|
||||
an arg-bearing type applies "args" (closed, per-type-declared string
|
||||
pairs) BEFORE "bind" — see graph introspect --node <T> for its args
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]}
|
||||
wire a role into one or more input slots
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
wire a node output into an input slot
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
name a graph output field
|
||||
{"op":"tap","from":"sub.value","as":"spread"}
|
||||
declare a recordable tap on a wire (expose's output-side twin)
|
||||
{"op":"gang","as":"length","into":["fast.length","slow.length"]}
|
||||
fuse two or more sibling params into one public knob
|
||||
{"op":"doc","text":"..."}
|
||||
declare the composite's one-line meaning (C29)
|
||||
{"op":"use","ref":{"name":"agree"},"name":"gate","bind":{"sma.length":{"I64":9}}}
|
||||
splice a registered blueprint (by "content_id" or "name") under an
|
||||
instance name ("bind" path-qualifies the spliced instance's params)
|
||||
{"op":"name","name":"ny_momentum"}
|
||||
set the composite's render name, at most once per script (default
|
||||
"graph" if omitted)
|
||||
|
||||
Node types and their ports: aura graph introspect --vocabulary | --node <T>"#;
|
||||
use aura_std::std_vocabulary;
|
||||
|
||||
/// The wire DTO for one construction op — the document's by-identifier shape,
|
||||
/// internally tagged by `"op"`, mapped into the serde-free engine `Op`. Bind
|
||||
/// values are the typed `Scalar` form (`{"I64":2}`); `kind` the capitalized
|
||||
/// `ScalarKind` form (`"F64"`) — both the #155 representations.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "lowercase", deny_unknown_fields)]
|
||||
#[serde(tag = "op", rename_all = "lowercase")]
|
||||
enum OpDoc {
|
||||
Source { role: String, kind: ScalarKind },
|
||||
Input { role: String },
|
||||
@@ -73,12 +34,6 @@ enum OpDoc {
|
||||
// naming, not an aliasing (contrast `expose`'s `as`, which is a real alias).
|
||||
#[serde(rename = "name", default)]
|
||||
as_name: Option<String>,
|
||||
// Construction args (#271) — the typed, closed channel applied BEFORE
|
||||
// `bind`. A `BTreeMap` (deterministic iteration order into `Op::Add`'s
|
||||
// `Vec`, mirroring `bind`'s own convention) — absent for every
|
||||
// args-free type, so an old-style `add` document is unaffected.
|
||||
#[serde(default)]
|
||||
args: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
},
|
||||
@@ -89,126 +44,40 @@ enum OpDoc {
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
},
|
||||
Tap {
|
||||
from: String,
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
},
|
||||
Gang {
|
||||
#[serde(rename = "as")]
|
||||
as_name: String,
|
||||
into: Vec<String>,
|
||||
},
|
||||
/// Declare the composite's one-line meaning (C29, #316) — the op-script
|
||||
/// twin of the builder's `.doc(...)`.
|
||||
Doc { text: String },
|
||||
/// Splice a registered blueprint into the building graph as a nested
|
||||
/// composite (#317) — the DTO the engine's `Op::Use` never sees
|
||||
/// directly: the CLI resolves `ref` (a store content id, a unique
|
||||
/// content-id prefix, or a registry name label) to the full content id
|
||||
/// at conversion time, before the engine ever runs.
|
||||
Use {
|
||||
#[serde(rename = "ref")]
|
||||
r#ref: UseRef,
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
},
|
||||
/// Set the composite's render name (#331) — script-level, at most once;
|
||||
/// the op-script twin of `replay`'s seeded default name (`"graph"`).
|
||||
Name { name: String },
|
||||
}
|
||||
|
||||
/// A `use` op's reference (#317) — exactly one of a store content id (full
|
||||
/// or a unique prefix, #302 semantics) or a registry name label
|
||||
/// (`graph register --name`, latest-wins).
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
enum UseRef {
|
||||
#[serde(rename = "content_id")]
|
||||
ContentId(String),
|
||||
#[serde(rename = "name")]
|
||||
Name(String),
|
||||
}
|
||||
|
||||
impl OpDoc {
|
||||
/// The op-kind label for the `op N (kind): cause` message. A `use` op
|
||||
/// carries its instance name (when the author gave one) so a `use`
|
||||
/// fault reads `use "gate"`, not a bare, undifferentiated `use` — the
|
||||
/// only kind whose label is not a fixed string (#317).
|
||||
fn kind_label(&self) -> String {
|
||||
/// The op-kind label for the `op N (kind): cause` message.
|
||||
fn kind_label(&self) -> &'static str {
|
||||
match self {
|
||||
OpDoc::Source { .. } => "source".to_string(),
|
||||
OpDoc::Input { .. } => "input".to_string(),
|
||||
OpDoc::Add { .. } => "add".to_string(),
|
||||
OpDoc::Feed { .. } => "feed".to_string(),
|
||||
OpDoc::Connect { .. } => "connect".to_string(),
|
||||
OpDoc::Expose { .. } => "expose".to_string(),
|
||||
OpDoc::Tap { .. } => "tap".to_string(),
|
||||
OpDoc::Gang { .. } => "gang".to_string(),
|
||||
OpDoc::Doc { .. } => "doc".to_string(),
|
||||
OpDoc::Use { name: Some(n), .. } => format!("use {n:?}"),
|
||||
OpDoc::Use { name: None, .. } => "use".to_string(),
|
||||
OpDoc::Name { .. } => "name".to_string(),
|
||||
OpDoc::Source { .. } => "source",
|
||||
OpDoc::Input { .. } => "input",
|
||||
OpDoc::Add { .. } => "add",
|
||||
OpDoc::Feed { .. } => "feed",
|
||||
OpDoc::Connect { .. } => "connect",
|
||||
OpDoc::Expose { .. } => "expose",
|
||||
OpDoc::Gang { .. } => "gang",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OpDoc> for Op {
|
||||
/// Infallible, context-free conversion — used directly only by
|
||||
/// build-free introspection paths (`introspect --unwired`, #317's
|
||||
/// spec: "Build-free introspection paths pass a `|_| None` closure"),
|
||||
/// which never resolve a `use` ref through the registry. A bare
|
||||
/// `OpDoc::Use` therefore maps its `UseRef` payload verbatim into
|
||||
/// `ref_id` (unresolved) — that session's `subgraph` closure is always
|
||||
/// `&|_| None`, so any `use` op there faults `UnknownSubgraph`
|
||||
/// regardless of the exact `ref_id` text; `graph build`'s real path
|
||||
/// (`composite_from_str`) never reaches this arm — it resolves and
|
||||
/// replaces each `Op::Use` before conversion (see `resolve_use_op`).
|
||||
fn from(d: OpDoc) -> Op {
|
||||
match d {
|
||||
OpDoc::Source { role, kind } => Op::Source { role, kind },
|
||||
OpDoc::Input { role } => Op::Input { role },
|
||||
OpDoc::Add { type_id, as_name, args, bind } => Op::Add {
|
||||
type_id,
|
||||
as_name,
|
||||
args: args.into_iter().collect(),
|
||||
bind: bind.into_iter().collect(),
|
||||
},
|
||||
OpDoc::Add { type_id, as_name, bind } => {
|
||||
Op::Add { type_id, as_name, bind: bind.into_iter().collect() }
|
||||
}
|
||||
OpDoc::Feed { role, into } => Op::Feed { role, into },
|
||||
OpDoc::Connect { from, to } => Op::Connect { from, to },
|
||||
OpDoc::Expose { from, as_name } => Op::Expose { from, as_name },
|
||||
OpDoc::Tap { from, as_name } => Op::Tap { from, as_name },
|
||||
OpDoc::Gang { as_name, into } => Op::Gang { as_name, into },
|
||||
OpDoc::Doc { text } => Op::Doc { text },
|
||||
OpDoc::Use { r#ref, name, bind } => Op::Use {
|
||||
ref_id: match r#ref {
|
||||
UseRef::ContentId(id) => id,
|
||||
UseRef::Name(name) => name,
|
||||
},
|
||||
name,
|
||||
bind: bind.into_iter().collect(),
|
||||
},
|
||||
OpDoc::Name { name } => Op::Name { name },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Phrase one `ArgOpError` (#271) as a human cause, WITHOUT the node prefix —
|
||||
/// shared by `format_op_error`'s `BadArg` arm (the `add`-op path) and
|
||||
/// `blueprint_load_prose`'s `BadArg` arm (the blueprint LOAD path), so the two
|
||||
/// error families cannot phrase the same fault differently. Exhaustive over
|
||||
/// `ArgOpError`; `BadValue` names the arg, its declared `ArgKind`, AND the
|
||||
/// closed per-kind `hint()` line (the same hint `introspect --node` shows).
|
||||
fn arg_op_error_prose(err: &ArgOpError) -> String {
|
||||
match err {
|
||||
ArgOpError::NotArgBearing => "takes no construction args".to_string(),
|
||||
ArgOpError::UnknownArg(a) => format!("has no construction arg {a:?}"),
|
||||
ArgOpError::DuplicateArg(a) => format!("was given arg {a:?} more than once"),
|
||||
ArgOpError::MissingArg(a) => format!("is missing required arg {a:?}"),
|
||||
ArgOpError::BadValue { arg, kind, got } => {
|
||||
format!("arg {arg:?} expects {kind:?} ({}) — got {got:?}", kind.hint())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,7 +90,6 @@ fn format_op_error(e: &OpError) -> String {
|
||||
OpError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||
OpError::DuplicateIdentifier(s) => format!("duplicate identifier {s:?}"),
|
||||
OpError::DuplicateOutput(s) => format!("duplicate output name {s:?}"),
|
||||
OpError::DuplicateTap(s) => format!("duplicate tap name {s:?}"),
|
||||
OpError::DuplicateRole(s) => format!("duplicate role {s:?}"),
|
||||
OpError::UnknownIdentifier(s) => format!("unknown node identifier {s:?}"),
|
||||
OpError::UnknownRole(s) => format!("unknown role {s:?}"),
|
||||
@@ -241,13 +109,10 @@ fn format_op_error(e: &OpError) -> String {
|
||||
BindOpError::KindMismatch { param, expected, got } => {
|
||||
format!("param {node}.{param} expects {expected:?} but got {got:?}")
|
||||
}
|
||||
BindOpError::AlreadyGanged { param, gang } => {
|
||||
format!("cannot bind {node}.{param} — member of gang {gang:?}")
|
||||
}
|
||||
},
|
||||
OpError::BadArg { node, err } => format!("node {node} {}", arg_op_error_prose(err)),
|
||||
OpError::UnconnectedPort { node, slot } => format!("slot {node}.{slot} is unconnected"),
|
||||
OpError::RoleKindMismatch { role } => format!("input role {role} fans into slots of differing kinds"),
|
||||
OpError::UnboundRootRole { role } => format!("root input role {role} is unbound"),
|
||||
OpError::GangKindMismatch { member, expected, got } => {
|
||||
format!("gang: member `{member}` is {got:?}, expected {expected:?}")
|
||||
}
|
||||
@@ -256,191 +121,18 @@ fn format_op_error(e: &OpError) -> String {
|
||||
}
|
||||
OpError::GangArity { gang } => format!("gang `{gang}`: needs at least two members"),
|
||||
OpError::Incomplete(ce) => format!("{ce:?}"),
|
||||
OpError::DuplicateDoc => "a doc op may appear at most once".to_string(),
|
||||
// #317: `graph build`'s real path (`composite_from_str`) resolves and
|
||||
// fetches every `use` op before replay, so this never fires there —
|
||||
// but `introspect --unwired` stays build-free/subgraph-free by spec
|
||||
// (`&|_| None`, see `From<OpDoc> for Op`), so a `use` op in a partial
|
||||
// document reaches this arm through THAT path, by-identifier on the
|
||||
// (unresolved) `ref_id` text.
|
||||
OpError::UnknownSubgraph { ref_id } => {
|
||||
format!("use: no subgraph for content id {ref_id:?}")
|
||||
}
|
||||
OpError::DuplicateName => "a script names its blueprint at most once".to_string(),
|
||||
OpError::BadName { name, fault } => name_gate_fault_prose(name, fault),
|
||||
}
|
||||
}
|
||||
|
||||
/// Phrase a `name_gate` shape violation as prose (#331): shared by this
|
||||
/// module's op-intake `format_op_error` `BadName` arm and the
|
||||
/// blueprint-envelope intake's root-name gate (`composite_from_authored_text`
|
||||
/// below) — the shape rule has exactly one seam-independent cause per fault,
|
||||
/// so both data-borne birth routes for a name read identically.
|
||||
fn name_gate_fault_prose(name: &str, fault: &NameGateFault) -> String {
|
||||
let cause = match fault {
|
||||
NameGateFault::Empty => "must be non-empty",
|
||||
NameGateFault::ContainsSeparator => "must not contain '/' or '\\'",
|
||||
NameGateFault::DotSegment => "must not be \".\" or \"..\"",
|
||||
};
|
||||
format!("blueprint name {name:?} is invalid: {cause} (a single path segment)")
|
||||
}
|
||||
|
||||
/// Resolve one `use` op's [`UseRef`] to the full store content id (#317):
|
||||
/// verbatim if it already IS a 64-hex content id, else a unique content-id
|
||||
/// prefix (#302 semantics, reusing [`resolve_id_prefix`]) or a registry name
|
||||
/// label (latest-wins, [`aura_registry::Registry::resolve_blueprint_label`]).
|
||||
/// Returns the full id plus the `<label-or-prefix> -> <full id>` text the
|
||||
/// resolution echo shows — the bare cause on a miss (unknown label / unknown
|
||||
/// or ambiguous prefix), for the caller to attribute by op index.
|
||||
fn resolve_use_ref_id(r: &UseRef, registry: &aura_registry::Registry) -> Result<(String, String), String> {
|
||||
match r {
|
||||
UseRef::Name(label) => {
|
||||
let id = registry.resolve_blueprint_label(label).ok_or_else(|| {
|
||||
let labels = registry.blueprint_labels();
|
||||
if labels.is_empty() {
|
||||
format!("no registered blueprint labeled {label:?} — no labels registered")
|
||||
} else {
|
||||
let names = labels.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join(", ");
|
||||
format!("no registered blueprint labeled {label:?} — registered labels: {names}")
|
||||
}
|
||||
})?;
|
||||
Ok((id.clone(), format!("{label} -> {id}")))
|
||||
}
|
||||
UseRef::ContentId(raw) => {
|
||||
if aura_runner::axes::is_content_id(raw) {
|
||||
return Ok((raw.clone(), format!("{raw} -> {raw}")));
|
||||
}
|
||||
let candidates = registry.list_blueprint_ids().map_err(|e| e.to_string())?;
|
||||
match resolve_id_prefix(raw, &candidates)? {
|
||||
Some(full) => {
|
||||
let shown = format!("{raw} -> {full}");
|
||||
Ok((full, shown))
|
||||
}
|
||||
None => Err(format!("no registered blueprint matches content id {raw:?}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The 12-char content-id prefix convention `graph introspect --registered`
|
||||
/// shows (#317), reused in the use-seam C29 refusal so both surfaces name a
|
||||
/// blueprint the same shortened way.
|
||||
fn id_prefix12(id: &str) -> &str {
|
||||
&id[..id.len().min(12)]
|
||||
}
|
||||
|
||||
/// The use-seam C29 doc-gate refusal cause (#317): re-run
|
||||
/// [`aura_registry::gate_composite_docs`] — the SAME walk `graph register`
|
||||
/// gates the write path with — over a FETCHED composite, before it ever
|
||||
/// reaches the session; names the fetched blueprint's id-prefix and the
|
||||
/// failing (root or nested — from the consumer's view, everything fetched is
|
||||
/// "nested") composite by identifier, mirroring `research_docs.rs`'s
|
||||
/// `BadDescription` phrasing for the same two `DocGateFault` kinds.
|
||||
fn use_doc_gate_cause(full_id: &str, e: &aura_registry::RegistryError) -> String {
|
||||
let aura_registry::RegistryError::UndescribedComposite { name, fault } = e else {
|
||||
// Defensive: `gate_composite_docs` only ever constructs
|
||||
// `UndescribedComposite`; every other `RegistryError` variant is
|
||||
// unreachable from this call, but the match stays total rather than
|
||||
// panicking on a surprise variant.
|
||||
return format!("registered blueprint {} fails the description gate", id_prefix12(full_id));
|
||||
};
|
||||
let rule = match fault {
|
||||
aura_core::DocGateFault::Empty => format!(
|
||||
"nested composite {name:?} has no description — re-register it with a \"doc\" op (C29)"
|
||||
),
|
||||
aura_core::DocGateFault::RestatesName => {
|
||||
format!("nested composite {name:?} has a doc that merely restates its name (C29)")
|
||||
}
|
||||
};
|
||||
format!("registered blueprint {} fails the description gate: {rule}", id_prefix12(full_id))
|
||||
}
|
||||
|
||||
/// Resolve, fetch, C29-gate, and echo one `use` op (#317) — the CLI-side
|
||||
/// half of the store/engine split (the engine never resolves a label, a
|
||||
/// prefix, or the registry itself). On success, caches the fetched
|
||||
/// CANONICAL JSON (not the parsed `Composite` — `Composite` is not `Clone`,
|
||||
/// mirroring the engine's own `use_fixture`-reload test pattern) under the
|
||||
/// full content id, so the `subgraph` closure handed to `replay` is a pure,
|
||||
/// registry-free lookup that re-parses on every call. Returns the bare
|
||||
/// cause on any refusal; the caller attributes it by op index.
|
||||
fn resolve_use_op(
|
||||
r#ref: UseRef,
|
||||
name: Option<String>,
|
||||
bind: BTreeMap<String, Scalar>,
|
||||
env: &aura_runner::project::Env,
|
||||
cache: &mut std::collections::HashMap<String, String>,
|
||||
) -> Result<Op, String> {
|
||||
let registry = env.registry();
|
||||
let (full_id, shown) = resolve_use_ref_id(&r#ref, ®istry)?;
|
||||
let json = registry
|
||||
.get_blueprint(&full_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("no registered blueprint {full_id}"))?;
|
||||
let doc: BlueprintDoc = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("registered blueprint {full_id} is not a valid blueprint: {e}"))?;
|
||||
// C29 at the use seam: gate the DATA form (no vocabulary needed) before
|
||||
// the composite ever reaches the session.
|
||||
if let Err(e) = aura_registry::gate_composite_docs(&doc.blueprint) {
|
||||
return Err(use_doc_gate_cause(&full_id, &e));
|
||||
}
|
||||
// Resolve the fetched envelope through the FULL vocabulary here, at DTO
|
||||
// conversion (review finding, #317 follow-up): deferring this to the
|
||||
// `subgraph` closure's lazy re-parse let a deserialize/vocab-resolution
|
||||
// failure fold into `.ok() -> None`, which `replay` then misreports as
|
||||
// `UnknownSubgraph` — a fetched-but-unloadable blueprint is a runtime
|
||||
// content fault (exit 1), not a missing reference. `blueprint_load_prose`
|
||||
// + `unresolved_namespace_hint` are the same house-style pair
|
||||
// `blueprint_slot_prose`/`composite_from_any` already use over a
|
||||
// `LoadError`.
|
||||
if let Err(e) = blueprint_from_json(&json, &|t| env.resolve(t)) {
|
||||
let mut msg = blueprint_load_prose(&e);
|
||||
if let Some(hint) = unresolved_namespace_hint(&e, env) {
|
||||
msg.push_str(" — ");
|
||||
msg.push_str(&hint);
|
||||
}
|
||||
return Err(format!("registered blueprint {} is invalid: {msg}", id_prefix12(&full_id)));
|
||||
}
|
||||
let instance = name.clone().unwrap_or_else(|| doc.blueprint.name.clone());
|
||||
// The resolution echo (C14 benign class): stderr only, so stdout stays
|
||||
// clean payload (#164's convention, extended to this new note).
|
||||
crate::diag::note!("use {instance:?}: {shown}");
|
||||
cache.insert(full_id.clone(), json);
|
||||
Ok(Op::Use { ref_id: full_id, name, bind: bind.into_iter().collect() })
|
||||
}
|
||||
|
||||
/// Parse a JSON op-list document and replay it through the env's vocabulary into
|
||||
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
|
||||
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
|
||||
/// past the last op). Every `use` op resolves through the registry HERE, before
|
||||
/// replay (#317): a resolution/doc-gate fault is attributed exactly like any
|
||||
/// other per-op construction fault (same `op N (kind): cause` shape, exit 1).
|
||||
fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
/// past the last op).
|
||||
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let labels: Vec<String> = docs.iter().map(OpDoc::kind_label).collect();
|
||||
let mut cache: std::collections::HashMap<String, String> = std::collections::HashMap::new();
|
||||
let mut ops: Vec<Op> = Vec::with_capacity(docs.len());
|
||||
for (idx, d) in docs.into_iter().enumerate() {
|
||||
let op = match d {
|
||||
OpDoc::Use { r#ref, name, bind } => match resolve_use_op(r#ref, name, bind, env, &mut cache) {
|
||||
Ok(op) => op,
|
||||
Err(cause) => return Err(format!("op {idx} ({}): {cause}", labels[idx])),
|
||||
},
|
||||
other => Op::from(other),
|
||||
};
|
||||
ops.push(op);
|
||||
}
|
||||
// The injected `subgraph` lookup (#317): a pure, registry-free map read —
|
||||
// every `use` op's blueprint was already fetched, gated, AND resolved
|
||||
// (`resolve_use_op`'s eager `blueprint_from_json` check) above; this
|
||||
// closure only re-parses the cached bytes (`Composite` is not `Clone`).
|
||||
// The `.ok()` is defensive, not a fallible path in practice: re-parsing
|
||||
// the SAME cached JSON through the SAME pure resolver cannot fail here
|
||||
// once it has already succeeded once above (review finding, #317
|
||||
// follow-up — the failure case now surfaces eagerly, at DTO conversion).
|
||||
let subgraph = |ref_id: &str| {
|
||||
cache.get(ref_id).and_then(|json| blueprint_from_json(json, &|t| env.resolve(t)).ok())
|
||||
};
|
||||
replay("graph", ops, &|t| env.resolve(t), &subgraph).map_err(|(idx, err)| {
|
||||
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
replay("graph", ops, &|t| env.resolve(t)).map_err(|(idx, err)| {
|
||||
let mut cause = format_op_error(&err);
|
||||
// #244: the op-script path reports an unresolvable namespaced type as
|
||||
// `OpError::UnknownNodeType`, a different error family than the
|
||||
@@ -462,14 +154,14 @@ fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Comp
|
||||
|
||||
/// Parse a JSON op-list document, replay it through the env's vocabulary, and
|
||||
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
|
||||
pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
let composite = composite_from_str(doc, env)?;
|
||||
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
|
||||
}
|
||||
|
||||
/// `aura graph build`: read the op-list document from stdin, build, and print the
|
||||
/// blueprint to stdout — or the cause to stderr and exit non-zero.
|
||||
pub fn build_cmd(env: &aura_runner::project::Env) {
|
||||
pub fn build_cmd(env: &crate::project::Env) {
|
||||
use std::io::Read;
|
||||
let mut doc = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
||||
@@ -492,22 +184,10 @@ pub fn build_cmd(env: &aura_runner::project::Env) {
|
||||
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
|
||||
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
|
||||
/// closed vocabulary.
|
||||
pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
|
||||
let schema = builder.schema();
|
||||
// C29 (#315): the head line carries the node's one-line meaning.
|
||||
let mut out = format!("{} — {}\n", builder.label(), schema.doc);
|
||||
// #271: an arg-bearing (pending) type declares its ArgSpecs but no real
|
||||
// ports/params yet — those form only once `try_args` runs (`make`). Show
|
||||
// the arg rows first (they must be supplied before anything else can be
|
||||
// discovered) plus the one-line note the spec's worked discovery example
|
||||
// pins.
|
||||
for spec in builder.arg_specs() {
|
||||
out.push_str(&format!(" arg {}: {:?} ({})\n", spec.name, spec.kind, spec.kind.hint()));
|
||||
}
|
||||
if builder.is_pending() {
|
||||
out.push_str(" note ports and params form at construction; args are required\n");
|
||||
}
|
||||
let mut out = format!("{}\n", builder.label());
|
||||
for port in &schema.inputs {
|
||||
out.push_str(&format!(" in {}:{:?}\n", port.name, port.kind));
|
||||
}
|
||||
@@ -522,14 +202,10 @@ pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result
|
||||
|
||||
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
||||
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
||||
pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
|
||||
let resolver = |t: &str| env.resolve(t);
|
||||
// #317: build-free introspection stays subgraph-free by design (spec:
|
||||
// "Build-free introspection paths pass a `|_| None` closure") — a `use`
|
||||
// op here always misses (`OpError::UnknownSubgraph`, `From<OpDoc>`'s own
|
||||
// doc comment), never a registry read.
|
||||
let mut session = GraphSession::new("introspect", &resolver, &|_: &str| None);
|
||||
let mut session = GraphSession::new("introspect", &resolver);
|
||||
for (i, d) in docs.into_iter().enumerate() {
|
||||
session.apply(Op::from(d)).map_err(|e| format!("op {i}: {}", format_op_error(&e)))?;
|
||||
}
|
||||
@@ -540,82 +216,26 @@ pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph introspect --registered` (#317): one row per registry label —
|
||||
/// `<label> <12-char id prefix> <root doc line>`, latest-wins (the label
|
||||
/// sidecar's own resolution rule) — the discovery surface the worked
|
||||
/// acceptance program's last step reads. An empty store is `no labels
|
||||
/// registered` on stdout, exit 0 (a listing, not a fault); dangling label
|
||||
/// rows (content id no longer resolves) are already skipped by
|
||||
/// `blueprint_labels()`.
|
||||
fn introspect_registered(env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let registry = env.registry();
|
||||
let labels = registry.blueprint_labels();
|
||||
if labels.is_empty() {
|
||||
return Ok("no labels registered\n".to_string());
|
||||
}
|
||||
let mut out = String::new();
|
||||
for (name, id) in labels {
|
||||
let json = registry
|
||||
.get_blueprint(&id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.ok_or_else(|| format!("label {name:?} names {id}, which is not in the store"))?;
|
||||
let doc: BlueprintDoc = serde_json::from_str(&json)
|
||||
.map_err(|e| format!("registered blueprint {id} is not a valid blueprint: {e}"))?;
|
||||
let root_doc = doc.blueprint.doc.as_deref().unwrap_or("");
|
||||
out.push_str(&format!("{name} {} {root_doc}\n", id_prefix12(&id)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph introspect`: dispatch the read-only queries. Exactly one of
|
||||
/// `--vocabulary` / `--node <T>` / `--unwired` / `--params <FILE|ID>` / the id
|
||||
/// group must be set; zero or more than one is the usage error (exit 2). The id
|
||||
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
|
||||
/// combine (one build, both ids, content id first).
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
|
||||
let count = cmd.vocabulary as usize
|
||||
+ cmd.node.is_some() as usize
|
||||
+ cmd.unwired as usize
|
||||
+ cmd.folds as usize
|
||||
+ cmd.registered as usize
|
||||
+ cmd.params.is_some() as usize
|
||||
+ (cmd.content_id.is_some() || cmd.identity_id) as usize;
|
||||
if count != 1 {
|
||||
eprintln!(
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --folds | --registered | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
|
||||
"aura: Usage: aura graph introspect --vocabulary | --node <T> | --unwired | --params <FILE|ID> | --content-id [FILE] | --identity-id (the two id flags may be combined)"
|
||||
);
|
||||
std::process::exit(2);
|
||||
}
|
||||
if cmd.vocabulary {
|
||||
// C29 (#315): each type id carries its schema's one-line meaning —
|
||||
// bare names teach nothing (What do Latch, When, Select, Bias do?).
|
||||
// A rostered id that fails to resolve is an invariant breach; better
|
||||
// loud than a silent C29-incomplete bare row.
|
||||
for t in env.type_ids() {
|
||||
let b = env.resolve(t).expect("every rostered type id resolves to a builder");
|
||||
println!("{t:<18} {}", b.schema().doc);
|
||||
}
|
||||
} else if cmd.folds {
|
||||
// The fold vocabulary binds at graph-declared taps (C27), so the
|
||||
// graph introspect namespace is its discovery surface (#315). #332:
|
||||
// this renders the fold-REGISTRY roster — the same roster `aura run
|
||||
// --tap TAP=FOLD` resolves labels against (aura-runner's layered
|
||||
// `FoldRegistry`) — not the aura-std `FoldKind` table: labels here
|
||||
// must be exactly what `--tap` accepts (lowercase), including the
|
||||
// `record` entry that has no `FoldKind` counterpart.
|
||||
for (label, doc) in aura_runner::FoldRegistry::core().roster() {
|
||||
println!("{label:<7} — {doc}");
|
||||
}
|
||||
} else if cmd.registered {
|
||||
// The label sidecar's discovery surface (#317): one row per
|
||||
// registered label — nothing to build, so a store-only read, exit 0
|
||||
// even on an empty store (a listing, not a fault).
|
||||
match introspect_registered(env) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("{t}");
|
||||
}
|
||||
} else if let Some(type_id) = cmd.node.as_deref() {
|
||||
match introspect_node(type_id, env) {
|
||||
@@ -640,12 +260,10 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
|
||||
}
|
||||
}
|
||||
} else if let Some(target) = cmd.params.as_deref() {
|
||||
// --params <FILE|ID> (#196): the RAW composite param space — the one
|
||||
// namespace campaign axes are validated against
|
||||
// (`validate_campaign_refs`) and `aura sweep --list-axes` prints
|
||||
// (#328: the wrapped `<blueprint>.<node>.<param>` form was retired —
|
||||
// there is exactly one axis namespace now, so the two discovery
|
||||
// surfaces agree line-for-line, see `params_lines`'s own doc comment).
|
||||
// --params <FILE|ID> (#196): the RAW composite param space — exactly the
|
||||
// namespace campaign axes are validated against (`validate_campaign_refs`
|
||||
// checks the raw space; the wrapped `--list-axes` namespace on `aura sweep`
|
||||
// is the sweep-verb view, not the campaign view).
|
||||
match params_lines(target, env) {
|
||||
Ok(s) => print!("{s}"),
|
||||
Err(m) => {
|
||||
@@ -674,7 +292,7 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
match composite_from_authored_text(&text, env) {
|
||||
match composite_from_any(&text, env) {
|
||||
Ok(c) => c,
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -733,7 +351,6 @@ pub(crate) fn blueprint_load_prose(e: &LoadError) -> String {
|
||||
}
|
||||
LoadError::UnknownNodeType(t) => format!("unknown node type {t:?}"),
|
||||
LoadError::Gang(e) => format!("gang section invalid: {}", gang_fault_prose(e)),
|
||||
LoadError::BadArg(e) => format!("construction args invalid: {}", arg_op_error_prose(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -779,7 +396,7 @@ fn gang_fault_prose(e: &CompileError) -> String {
|
||||
/// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph
|
||||
/// build` path (`composite_from_str`, over `OpError`) call this same helper,
|
||||
/// so the tier texts cannot drift between the two error families.
|
||||
fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option<String> {
|
||||
fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<String> {
|
||||
if !type_id.contains("::") {
|
||||
return None;
|
||||
}
|
||||
@@ -798,7 +415,7 @@ fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Opti
|
||||
|
||||
/// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the
|
||||
/// blueprint LOAD path's entry point over `LoadError`.
|
||||
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option<String> {
|
||||
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env) -> Option<String> {
|
||||
match e {
|
||||
LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env),
|
||||
_ => None,
|
||||
@@ -822,7 +439,7 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::projec
|
||||
/// `Ok(())` means the document loaded cleanly; callers that only need the
|
||||
/// validation (not the `Composite`) re-parse `doc` themselves afterward
|
||||
/// (`blueprint_from_json` is cheap and infallible at that point).
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> {
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> {
|
||||
if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
|
||||
return Err(
|
||||
"this is an op-script (an op array), not a built blueprint — run \
|
||||
@@ -844,19 +461,7 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -
|
||||
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
|
||||
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
|
||||
/// each canonicalized by its own rules.
|
||||
///
|
||||
/// **Ungated by design (#331 review finding).** This is the STORE READ-BACK
|
||||
/// shape: `params_lines`'s content-id fetch (`resolve_blueprint_text`'s
|
||||
/// non-file branch) — `introspect --params <ID>` and, by the same
|
||||
/// convention, `validate_campaign_refs`'s already-ungated `blueprint_from_json`
|
||||
/// call — reads whatever is already sitting in the store, and C29 says a
|
||||
/// registered artifact is never retroactively invalidated. Freshly authored
|
||||
/// FILE text (a hand-edited document that has not yet passed through a gated
|
||||
/// intake) goes through [`composite_from_authored_text`] instead, never here
|
||||
/// — `params_lines`'s OWN file branch is such a case (#331 delta re-review:
|
||||
/// it used to call straight through to this fn, missing the gate; fixed by
|
||||
/// branching on `resolve_blueprint_text`'s file-vs-store flag).
|
||||
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
|
||||
match value {
|
||||
@@ -873,101 +478,26 @@ pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) ->
|
||||
}
|
||||
}
|
||||
|
||||
/// The FILE-intake counterpart of [`composite_from_any`] (#331 review
|
||||
/// finding): identical parse, plus the blueprint-envelope root-name shape
|
||||
/// gate. Every call site that builds a `Composite` from text freshly read
|
||||
/// off disk (`graph register`, `graph introspect --content-id <FILE>`, the
|
||||
/// bare `aura graph <FILE>` viewer, and `graph introspect --params <FILE>`'s
|
||||
/// file branch) goes through this wrapper — never the ungated
|
||||
/// `composite_from_any` — because a hand-edited envelope with
|
||||
/// `"name":"../x"` would otherwise register/build cleanly and write
|
||||
/// `traces/../x/` at run time (`trace_store.rs` joins the name unsanitized).
|
||||
/// Gating the composite's name unconditionally (not just on the Object
|
||||
/// branch) is harmless: an op-script-built composite's name already passed
|
||||
/// the op-intake gate (`GraphSession::set_name`), so re-checking it here is
|
||||
/// redundant, not restrictive — it keeps this wrapper a single shared choke
|
||||
/// point rather than one that has to re-discriminate the JSON shape. Only
|
||||
/// the ROOT name is checked: the filesystem seam consumes exclusively the
|
||||
/// root name. Store read-back (reproduce, `use`-splice resolution from the
|
||||
/// registry, and `params_lines`'s content-id branch) never reaches this
|
||||
/// function — C29's "registered artifacts are never retroactively
|
||||
/// invalidated" stays intact.
|
||||
///
|
||||
/// **Deliberate exceptions — direct `gate_authored_root_name` callers
|
||||
/// (main.rs).** A handful of fresh-FILE intakes reach the same unsanitized
|
||||
/// `traces/<name>/` seam (or `put_blueprint` the loaded envelope straight
|
||||
/// into the registry) without going through this wrapper, because each
|
||||
/// already parses the document its own way and only needs the shared root-
|
||||
/// name gate bolted on, not the shape-discrimination `composite_from_any`
|
||||
/// does:
|
||||
/// - `aura run <blueprint.json>` (`dispatch_run`): envelope-only grammar (no
|
||||
/// op-script fallback), feeds `signal.name()` into
|
||||
/// `run_signal_r`/`run_measurement` -> `bind_tap_plan` ->
|
||||
/// `TraceStore::begin_run`.
|
||||
/// - `validate_and_register_axes` (shared by `generalize`,
|
||||
/// `sweep --real`, `walkforward --real`, `mc --real`): `put_blueprint`s
|
||||
/// the loaded envelope by topology hash before any of those four verbs
|
||||
/// touches an archive.
|
||||
/// - `run_blueprint_sweep` / `run_blueprint_walkforward` / `run_blueprint_mc`
|
||||
/// (the synthetic, no-`--real` family builders): same `put_blueprint`
|
||||
/// reason, on the routes that bypass `validate_and_register_axes`
|
||||
/// entirely (#331 cycle-close — these used to plant an ungated envelope
|
||||
/// in the store; see each fn's own comment).
|
||||
/// - `list_blueprint_axes` (`aura sweep <FILE> --list-axes`, main.rs): no
|
||||
/// registry write and no trace directory here, but a shape-violating root
|
||||
/// name otherwise mangles through `wrapped_to_raw_axis` into a printed,
|
||||
/// non-bindable axis name instead of refusing (#331 fieldtest finding
|
||||
/// c331_2e) — the class rule is every CLI intake reading an authored
|
||||
/// envelope from a file gates the root name, not only the writing ones.
|
||||
///
|
||||
/// All of these share the identical `name_gate` + `name_gate_fault_prose`
|
||||
/// primitives this wrapper uses, so the refusal wording is byte-identical
|
||||
/// across every authored-file-intake route even though the shape-
|
||||
/// discrimination step is not shared by them.
|
||||
pub(crate) fn composite_from_authored_text(
|
||||
text: &str,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<Composite, String> {
|
||||
let composite = composite_from_any(text, env)?;
|
||||
gate_authored_root_name(composite.name())?;
|
||||
Ok(composite)
|
||||
}
|
||||
|
||||
/// The root-name shape gate itself (#331 delta re-review), factored out of
|
||||
/// [`composite_from_authored_text`] so `dispatch_run`'s narrower-grammar file
|
||||
/// intake (see that fn's doc comment) can share the exact `name_gate` call and
|
||||
/// `name_gate_fault_prose` wording without going through the shape-discriminating
|
||||
/// wrapper.
|
||||
pub(crate) fn gate_authored_root_name(name: &str) -> Result<(), String> {
|
||||
name_gate(name).map_err(|fault| name_gate_fault_prose(name, &fault))
|
||||
}
|
||||
|
||||
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
|
||||
/// id in the project store (the campaign-run target-addressing convention).
|
||||
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
|
||||
/// The id shape is `crate::campaign_run::is_content_id` — the same predicate
|
||||
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
|
||||
/// cannot drift apart on what counts as a store address. The `bool` names
|
||||
/// which branch fired: `true` for a FRESH FILE read, `false` for a STORE
|
||||
/// (content-id) fetch — `params_lines` (#331 delta re-review) uses it to
|
||||
/// decide whether the root-name gate applies (a fresh file must gate; a
|
||||
/// store read-back must not, C29) without re-deriving the FILE-vs-id
|
||||
/// distinction a second time.
|
||||
fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<(String, bool), String> {
|
||||
/// cannot drift apart on what counts as a store address.
|
||||
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
// A CLI arg tolerates the `content:` display prefix (#194); doc ref
|
||||
// fields stay bare-only.
|
||||
let target = target
|
||||
.strip_prefix("content:")
|
||||
.filter(|t| aura_runner::axes::is_content_id(t))
|
||||
.filter(|t| crate::campaign_run::is_content_id(t))
|
||||
.unwrap_or(target);
|
||||
let path = Path::new(target);
|
||||
if path.is_file() {
|
||||
return std::fs::read_to_string(path)
|
||||
.map(|text| (text, true))
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()));
|
||||
}
|
||||
if aura_runner::axes::is_content_id(target) {
|
||||
if crate::campaign_run::is_content_id(target) {
|
||||
return match env.registry().get_blueprint(target) {
|
||||
Ok(Some(json)) => Ok((json, false)),
|
||||
Ok(Some(json)) => Ok(json),
|
||||
Ok(None) => Err(format!("no blueprint {target} in the project store")),
|
||||
Err(e) => Err(e.to_string()),
|
||||
};
|
||||
@@ -979,45 +509,27 @@ fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Resu
|
||||
/// line per open param of the RAW composite (no harness wrap) — the
|
||||
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
|
||||
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
|
||||
/// the same form `introspect --node` already uses for param kinds. Followed
|
||||
/// by one `{name}:{kind:?} default={value}` line per BOUND param (#328):
|
||||
/// `bound_param_space()`'s names are already RAW (#203), so no blueprint-name
|
||||
/// concatenation is needed — line-identical to the reconciled `--list-axes`
|
||||
/// bound pass (`list_blueprint_axes`, main.rs), same `render_value` lexicon.
|
||||
/// #331 delta re-review: the FILE target is a FOURTH freshly-authored-file
|
||||
/// intake this fn's own `composite_from_any` call had missed gating — a FILE
|
||||
/// routes through [`composite_from_authored_text`] instead; a content id
|
||||
/// (STORE read-back) keeps the ungated `composite_from_any` (C29).
|
||||
fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
/// the same form `introspect --node` already uses for param kinds.
|
||||
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> {
|
||||
use std::fmt::Write as _;
|
||||
let (text, is_file) = resolve_blueprint_text(target, env)?;
|
||||
let text = resolve_blueprint_text(target, env)?;
|
||||
// Shape-discriminated like file-mode --content-id: an op-script (array)
|
||||
// builds through the one-build tail, an envelope (object) loads (#202).
|
||||
let composite = if is_file { composite_from_authored_text(&text, env)? } else { composite_from_any(&text, env)? };
|
||||
let composite = composite_from_any(&text, env)?;
|
||||
let mut out = String::new();
|
||||
for p in composite.param_space() {
|
||||
let _ = writeln!(out, "{}:{:?}", p.name, p.kind);
|
||||
}
|
||||
for b in composite.bound_param_space() {
|
||||
let _ = writeln!(out, "{}:{:?} default={}", b.name, b.kind, render_value(&b.value));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `aura graph register <blueprint.json> [--name <label>]` (#196, `--name`
|
||||
/// #317): parse the blueprint through the project vocabulary, canonicalize,
|
||||
/// content-address, and store — the `process register` pattern, printing
|
||||
/// the store path so the trail is followable. With `--name`, additionally
|
||||
/// labels the stored content id (`aura_registry::Registry::put_blueprint_label`),
|
||||
/// echoing the repoint when the label already pointed elsewhere. Prose to
|
||||
/// stderr + exit 1 on any refusal.
|
||||
pub fn register_cmd(file: &Path, name: Option<&str>, env: &aura_runner::project::Env) {
|
||||
match register_blueprint(file, name, env) {
|
||||
Ok(lines) => {
|
||||
for line in lines {
|
||||
println!("{line}");
|
||||
}
|
||||
}
|
||||
/// `aura graph register <blueprint.json>` (#196): parse the blueprint through
|
||||
/// the project vocabulary, canonicalize, content-address, and store — the
|
||||
/// `process register` pattern, printing the store path so the trail is
|
||||
/// followable. Prose to stderr + exit 1 on any refusal.
|
||||
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
|
||||
match register_blueprint(file, env) {
|
||||
Ok(line) => println!("{line}"),
|
||||
Err(m) => {
|
||||
eprintln!("aura: {m}");
|
||||
std::process::exit(1);
|
||||
@@ -1025,37 +537,20 @@ pub fn register_cmd(file: &Path, name: Option<&str>, env: &aura_runner::project:
|
||||
}
|
||||
}
|
||||
|
||||
fn register_blueprint(
|
||||
file: &Path,
|
||||
name: Option<&str>,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<Vec<String>, String> {
|
||||
fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
|
||||
let text = std::fs::read_to_string(file)
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
// Shape-discriminated like file-mode --content-id (#202): either shape
|
||||
// canonicalizes to the envelope form, so the stored id is shape-invariant.
|
||||
// Gated (#331 review finding): this text is freshly authored FILE bytes,
|
||||
// not a store read-back, so `composite_from_authored_text` applies.
|
||||
let composite = composite_from_authored_text(&text, env)?;
|
||||
let composite = composite_from_any(&text, env)?;
|
||||
let canonical = blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))?;
|
||||
let id = crate::content_id(&canonical);
|
||||
let registry = env.registry();
|
||||
registry.put_blueprint(&id, &canonical).map_err(|e| e.to_string())?;
|
||||
let mut lines = vec![format!(
|
||||
Ok(format!(
|
||||
"registered blueprint {id} ({})",
|
||||
registry.blueprint_path(&id).display()
|
||||
)];
|
||||
if let Some(label) = name {
|
||||
// Resolve BEFORE writing, so the repoint echo compares against the
|
||||
// pre-write target (#317).
|
||||
let previous = registry.resolve_blueprint_label(label);
|
||||
registry.put_blueprint_label(label, &id).map_err(|e| e.to_string())?;
|
||||
lines.push(match previous {
|
||||
Some(old) if old != id => format!("label {label:?} -> {id} (was {old})"),
|
||||
_ => format!("label {label:?} -> {id}"),
|
||||
});
|
||||
}
|
||||
Ok(lines)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1084,8 +579,7 @@ mod tests {
|
||||
assert_eq!(docs[1].kind_label(), "add");
|
||||
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
|
||||
// both SMA lengths are bound, so the only open param is bias.scale (f64).
|
||||
let composite =
|
||||
replay("graph", ops, &std_vocabulary, &|_: &str| None).expect("replay resolves");
|
||||
let composite = replay("graph", ops, &std_vocabulary).expect("replay resolves");
|
||||
composite.compile_with_params(&[Scalar::f64(0.5)]).expect("compiles");
|
||||
}
|
||||
|
||||
@@ -1103,7 +597,7 @@ mod tests {
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds");
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
|
||||
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
|
||||
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
|
||||
}
|
||||
@@ -1119,14 +613,14 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series"]},
|
||||
{"op":"expose","from":"fast.value","as":"out"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds");
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
|
||||
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_from_str_reports_unknown_node_at_its_op_index() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
|
||||
}
|
||||
|
||||
@@ -1141,7 +635,7 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
|
||||
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
|
||||
"names the unconnected slot by-identifier, got: {err}");
|
||||
@@ -1150,11 +644,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn introspect_node_lists_ports_kinds_and_params() {
|
||||
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("series"), "lists the input port: {out}");
|
||||
assert!(out.contains("value"), "lists the output field: {out}");
|
||||
assert!(out.contains("length"), "lists the param path: {out}");
|
||||
assert!(super::introspect_node("Nope", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
}
|
||||
|
||||
/// A bind whose value-kind mismatches the param reads as prose, like the connect
|
||||
@@ -1162,7 +656,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_bad_bind_kind_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
|
||||
}
|
||||
|
||||
@@ -1171,7 +665,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_unknown_bind_param_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
|
||||
}
|
||||
|
||||
@@ -1192,7 +686,7 @@ mod tests {
|
||||
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
|
||||
#[test]
|
||||
fn introspect_node_shows_the_bind_form() {
|
||||
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
|
||||
}
|
||||
|
||||
@@ -1203,35 +697,9 @@ mod tests {
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).expect("partial document introspects");
|
||||
let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
|
||||
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
|
||||
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
|
||||
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
|
||||
}
|
||||
|
||||
/// The `tap` op-doc parses (externally tagged, `"as"`-renamed like `expose`)
|
||||
/// and `aura graph build` emits a blueprint whose serialized `taps` names the
|
||||
/// wire — the name-addressed authoring path the fieldtest wanted, no raw index.
|
||||
#[test]
|
||||
fn tap_opdoc_builds_a_blueprint_carrying_the_tap() {
|
||||
let doc = r#"[
|
||||
{"op":"source","role":"price","kind":"F64"},
|
||||
{"op":"add","type":"SMA","name":"fast","bind":{"length":{"I64":2}}},
|
||||
{"op":"add","type":"SMA","name":"slow","bind":{"length":{"I64":4}}},
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"},
|
||||
{"op":"connect","from":"slow.value","to":"sub.rhs"},
|
||||
{"op":"tap","from":"fast.value","as":"fast_ma"},
|
||||
{"op":"expose","from":"sub.value","as":"bias"}
|
||||
]"#;
|
||||
// the tap-doc kind_label registers as "tap"
|
||||
let docs: Vec<OpDoc> = serde_json::from_str(doc).expect("document parses");
|
||||
assert_eq!(docs[7].kind_label(), "tap");
|
||||
// and the built blueprint carries the tap (an un-tapped composite emits no
|
||||
// `taps` key, so its presence is the proof the op threaded through)
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("tap op-doc builds");
|
||||
assert!(json.contains("\"taps\""), "blueprint carries the taps section: {json}");
|
||||
assert!(json.contains("fast_ma"), "the tap names the wire: {json}");
|
||||
}
|
||||
}
|
||||
|
||||
+2727
-1089
File diff suppressed because it is too large
Load Diff
@@ -7,14 +7,14 @@
|
||||
//! vocabulary charter. `Env` is the per-invocation context every verb reads:
|
||||
//! merged resolver, runs root, data path, provenance.
|
||||
|
||||
use aura_core::{DocGateFault, PrimitiveBuilder, doc_gate};
|
||||
use aura_core::PrimitiveBuilder;
|
||||
use aura_core::project::{
|
||||
AURA_DESCRIPTOR_MAGIC, AURA_DESCRIPTOR_VERSION, AURA_PROJECT_SYMBOL,
|
||||
CORE_VERSION, ProjectDescriptor, RUSTC_VERSION, StrSlice,
|
||||
};
|
||||
use aura_engine::ProjectProvenance;
|
||||
use aura_registry::{Registry, TraceStore};
|
||||
use aura_vocabulary::{std_vocabulary, std_vocabulary_types};
|
||||
use aura_std::{std_vocabulary, std_vocabulary_types};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::fmt;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -63,11 +63,6 @@ pub enum ProjectError {
|
||||
/// handing off to `load_crate`, so this never reaches the cargo-metadata
|
||||
/// probe (whose raw os-error text would otherwise leak through).
|
||||
PointerDirMissing(PathBuf),
|
||||
/// C29 load seam (#316): a resolved extension-vocabulary entry whose doc
|
||||
/// fails the shape gate — the load refuses rather than admit an
|
||||
/// undescribed node. Field presence is compile-enforced; shape is not,
|
||||
/// so the gate carries the failed rule for the refusal prose.
|
||||
UndescribedVocabularyEntry { type_id: String, fault: DocGateFault },
|
||||
}
|
||||
|
||||
impl fmt::Display for ProjectError {
|
||||
@@ -105,19 +100,6 @@ impl fmt::Display for ProjectError {
|
||||
"node crate at `{pointer}` (from [nodes] in Aura.toml): {inner}"
|
||||
),
|
||||
Self::PointerDirMissing(p) => write!(f, "{} does not exist", p.display()),
|
||||
Self::UndescribedVocabularyEntry { type_id, fault } => {
|
||||
let rule = match fault {
|
||||
DocGateFault::Empty => "has an empty doc",
|
||||
DocGateFault::RestatesName => {
|
||||
"has a doc that merely restates its name"
|
||||
}
|
||||
};
|
||||
write!(
|
||||
f,
|
||||
"vocabulary entry `{type_id}` {rule} — every vocabulary \
|
||||
entry ships a one-line meaning (C29)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -462,7 +444,7 @@ fn stale_warning(
|
||||
) -> Option<String> {
|
||||
if source_mtime > dylib_mtime {
|
||||
Some(format!(
|
||||
"aura: warning: {} may be stale — dylib built {} but a project source \
|
||||
"warning: {} may be stale — dylib built {} but a project source \
|
||||
file was modified {} (run `cargo build` to refresh); proceeding \
|
||||
with the existing dylib",
|
||||
dylib_path.display(),
|
||||
@@ -597,18 +579,6 @@ fn load_crate(crate_root: &Path, release: bool) -> Result<NativeEnv, ProjectErro
|
||||
let type_id_list = (desc.type_ids)();
|
||||
check_charter(&namespace, type_id_list, &|t| resolver(t))?;
|
||||
|
||||
// C29 load seam (#316): every resolved entry must describe itself. The
|
||||
// charter cross-check above guarantees each listed id resolves, so a
|
||||
// `None` here is unreachable — skipped rather than panicked on (a
|
||||
// hostile dylib must never panic the host).
|
||||
for &t in type_id_list {
|
||||
if let Some(builder) = resolver(t) {
|
||||
doc_gate(t, builder.schema().doc).map_err(|fault| {
|
||||
ProjectError::UndescribedVocabularyEntry { type_id: t.to_string(), fault }
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NativeEnv { namespace, dylib_sha256, resolver, type_id_list })
|
||||
}
|
||||
|
||||
@@ -618,9 +588,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn discover_walks_up_to_aura_toml() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-disc");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-disc-{}", std::process::id()));
|
||||
let nested = tmp.join("a/b/c");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
assert_eq!(discover_from(&nested), None);
|
||||
@@ -643,9 +611,7 @@ mod tests {
|
||||
/// and stamps commit-only provenance.
|
||||
#[test]
|
||||
fn data_only_project_loads_without_a_crate() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-dataonly");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-dataonly-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "[paths]\nruns = \"runs\"\n").unwrap();
|
||||
let p = load(&tmp, false).expect("data-only load");
|
||||
@@ -666,9 +632,7 @@ mod tests {
|
||||
/// changes the data location depending on the caller's working directory.
|
||||
#[test]
|
||||
fn data_path_resolves_a_relative_paths_data_against_the_project_root() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-datarel");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-datarel-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "[paths]\ndata = \"data\"\n").unwrap();
|
||||
let p = load(&tmp, false).expect("data-only load");
|
||||
@@ -679,9 +643,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn two_nodes_pointers_refuse_with_multi_crate() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-multi");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-multi-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"a\", \"b\"]\n").unwrap();
|
||||
let e = load(&tmp, false).unwrap_err();
|
||||
@@ -691,9 +653,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_broken_nodes_pointer_names_the_pointer() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-badptr");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-badptr-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "[nodes]\ncrates = [\"../nope\"]\n").unwrap();
|
||||
let e = load(&tmp, false).unwrap_err();
|
||||
@@ -711,9 +671,7 @@ mod tests {
|
||||
/// that (`load` still refuses → CLI exit 1).
|
||||
#[test]
|
||||
fn a_missing_nodes_pointer_dir_refuses_without_the_cargo_metadata_leak() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-missingptr");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-missingptr-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
// `missing-node-crate` is never created — the pointer target is absent.
|
||||
std::fs::write(
|
||||
@@ -916,7 +874,6 @@ mod tests {
|
||||
let source_mtime = UNIX_EPOCH + Duration::from_secs(2_003_702_400); // 2033-06-30
|
||||
let path = PathBuf::from("/tmp/x.so");
|
||||
let msg = stale_warning(&path, dylib_mtime, source_mtime).expect("source is newer");
|
||||
assert!(msg.starts_with("aura: warning: "), "carries the warning class marker (#278): {msg}");
|
||||
assert!(msg.contains("2001"), "names the dylib's mtime: {msg}");
|
||||
assert!(msg.contains("2033"), "names the source's newer mtime: {msg}");
|
||||
}
|
||||
@@ -934,32 +891,4 @@ mod tests {
|
||||
"older source: not stale"
|
||||
);
|
||||
}
|
||||
|
||||
/// The C29 load-seam refusal prose (`UndescribedVocabularyEntry`) names
|
||||
/// the entry and the failed rule for BOTH `DocGateFault` arms. Only
|
||||
/// `Empty` is reachable end-to-end today (the fixture in
|
||||
/// `aura-cli/tests/project_load.rs` uses `doc: ""`); `RestatesName` has
|
||||
/// no such fixture, so this pins its rendered message directly against
|
||||
/// `Display` — a regression there would otherwise pass every test.
|
||||
#[test]
|
||||
fn undescribed_vocabulary_entry_display_names_the_entry_and_rule() {
|
||||
let empty = ProjectError::UndescribedVocabularyEntry {
|
||||
type_id: "ns::Foo".to_string(),
|
||||
fault: DocGateFault::Empty,
|
||||
};
|
||||
assert_eq!(
|
||||
empty.to_string(),
|
||||
"vocabulary entry `ns::Foo` has an empty doc — every vocabulary \
|
||||
entry ships a one-line meaning (C29)"
|
||||
);
|
||||
let restates = ProjectError::UndescribedVocabularyEntry {
|
||||
type_id: "ns::Foo".to_string(),
|
||||
fault: DocGateFault::RestatesName,
|
||||
};
|
||||
assert_eq!(
|
||||
restates.to_string(),
|
||||
"vocabulary entry `ns::Foo` has a doc that merely restates its \
|
||||
name — every vocabulary entry ships a one-line meaning (C29)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -234,9 +234,9 @@ mod tests {
|
||||
/// against the prototype.
|
||||
#[test]
|
||||
fn render_html_is_self_contained_and_embeds_the_model() {
|
||||
let env = aura_runner::project::Env::std();
|
||||
let env = crate::project::Env::std();
|
||||
let bp = aura_engine::blueprint_from_json(
|
||||
include_str!("../examples/r_sma.json"),
|
||||
include_str!("../examples/r_sma_open.json"),
|
||||
&|t| env.resolve(t),
|
||||
)
|
||||
.expect("the shipped r-sma example reloads into a renderable blueprint");
|
||||
|
||||
@@ -13,7 +13,7 @@ use aura_research::{
|
||||
};
|
||||
use aura_registry::RefFault;
|
||||
|
||||
use aura_runner::project::Env;
|
||||
use crate::project::Env;
|
||||
|
||||
#[derive(clap::Args)]
|
||||
pub struct ProcessCmd {
|
||||
@@ -29,8 +29,6 @@ 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)]
|
||||
@@ -62,17 +60,6 @@ impl DocIntrospectCmd {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `--parallel-instruments` in domain terms: clap's built-in
|
||||
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
|
||||
/// zero for non-zero type") at exactly the moment a user needs guidance.
|
||||
fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
|
||||
s.parse::<usize>().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| {
|
||||
"must be a whole number of at least 1 — it bounds how many distinct \
|
||||
instruments are resident in parallel"
|
||||
.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
/// Exactly one introspect mode (the graph-introspect guard idiom: usage
|
||||
/// error on stderr, exit 2).
|
||||
fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
|
||||
@@ -92,10 +79,9 @@ 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 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);
|
||||
for name in aura_research::metric_vocabulary() {
|
||||
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();
|
||||
@@ -107,12 +93,10 @@ 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 — {}", m.doc);
|
||||
println!("{name:<24} {base} | generalize");
|
||||
} else {
|
||||
println!("{name:<24} {base} — {}", m.doc);
|
||||
println!("{name:<24} {base}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,9 +137,6 @@ 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")
|
||||
@@ -166,16 +147,6 @@ 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 } => {
|
||||
@@ -184,7 +155,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().iter().map(|t| t.id).collect::<Vec<_>>().join(" | ")
|
||||
tap_vocabulary().join(" | ")
|
||||
),
|
||||
DocFault::UnknownBindingColumn { role, column } => format!(
|
||||
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
|
||||
@@ -193,16 +164,6 @@ 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)"
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,7 +184,6 @@ 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}");
|
||||
@@ -263,15 +223,6 @@ 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);
|
||||
@@ -284,60 +235,6 @@ 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();
|
||||
@@ -374,13 +271,6 @@ 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(())
|
||||
}
|
||||
@@ -388,9 +278,6 @@ 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");
|
||||
@@ -413,22 +300,10 @@ pub enum CampaignSub {
|
||||
Register { file: PathBuf },
|
||||
/// Execute a stored campaign into a realized run-set (a .json file is
|
||||
/// register-then-run sugar; the canonical address is the content id).
|
||||
Run {
|
||||
target: String,
|
||||
/// Bound on distinct instruments resident in parallel (the RAM
|
||||
/// lever; 1 reproduces the sequential loop's footprint).
|
||||
#[arg(
|
||||
long,
|
||||
default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
value_parser = parse_parallel_instruments,
|
||||
)]
|
||||
parallel_instruments: std::num::NonZeroUsize,
|
||||
},
|
||||
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
|
||||
@@ -442,14 +317,6 @@ 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) => {
|
||||
@@ -462,14 +329,8 @@ pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
||||
RefFault::StrategyUnloadable { id, error } => {
|
||||
format!("strategy {id} cannot be loaded: {error}")
|
||||
}
|
||||
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::AxisNotInParamSpace { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
|
||||
}
|
||||
RefFault::AxisKindMismatch { strategy, axis } => {
|
||||
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
||||
@@ -542,18 +403,7 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
|
||||
/// via `exit_on_campaign_result`), so it is pulled out of the unified
|
||||
/// `Result<(), String>` match the other four subcommands still share.
|
||||
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
|
||||
if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
|
||||
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
|
||||
target,
|
||||
env,
|
||||
*parallel_instruments,
|
||||
));
|
||||
return;
|
||||
}
|
||||
let result = match &cmd.sub {
|
||||
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
||||
CampaignSub::Introspect(i) => {
|
||||
@@ -561,9 +411,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),
|
||||
CampaignSub::Run { .. } => unreachable!("handled above"),
|
||||
};
|
||||
if let Err(m) = result {
|
||||
eprintln!("aura: {m}");
|
||||
@@ -653,15 +502,6 @@ 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);
|
||||
@@ -674,36 +514,6 @@ 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();
|
||||
@@ -767,7 +577,6 @@ mod tests {
|
||||
ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
||||
strategy: "9f3a".into(),
|
||||
axis: "nope".into(),
|
||||
raw_candidate: None,
|
||||
}),
|
||||
ref_fault_prose(&RefFault::AxisKindMismatch {
|
||||
strategy: "9f3a".into(),
|
||||
@@ -788,39 +597,6 @@ 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
|
||||
@@ -875,106 +651,4 @@ 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,8 +152,6 @@ 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",
|
||||
@@ -165,9 +163,6 @@ 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())),
|
||||
)
|
||||
@@ -224,7 +219,7 @@ crate is attached to its project via `[nodes]` in the project's `Aura.toml`.
|
||||
|
||||
const GITIGNORE_PROJECT: &str = "/runs\n";
|
||||
|
||||
const SIGNAL_JSON_STD: &str = r#"{"format_version":1,"blueprint":{"name":"__NAME_SNAKE___signal","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 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 CLAUDE_MD_PROJECT: &str = r#"# __NAME__ — an aura research project (data-only)
|
||||
|
||||
@@ -234,21 +229,12 @@ 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 fast.length=2,4,8`
|
||||
(`aura sweep <bp> --list-axes` lists the open + bound-overridable axes, RAW
|
||||
`<node>.<param>` names — #328)
|
||||
- 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)
|
||||
- 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: the research verbs are sugar over registered process/campaign
|
||||
documents — author them 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__`
|
||||
@@ -294,7 +280,7 @@ pub fn scaffold_project(spec: &ProjectScaffoldSpec) -> Result<(), String> {
|
||||
Ok(o) if o.status.success() => {
|
||||
commit_all(&spec.target_dir, "aura new scaffold");
|
||||
}
|
||||
_ => crate::diag::warning!("git init failed (project created without a repo)"),
|
||||
_ => eprintln!("aura: warning: git init failed (project created without a repo)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -335,7 +321,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");
|
||||
}
|
||||
_ => crate::diag::warning!("git init failed (node crate created without a repo)"),
|
||||
_ => eprintln!("aura: warning: git init failed (node crate created without a repo)"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -368,7 +354,7 @@ fn commit_all(dir: &std::path::Path, message: &str) {
|
||||
.current_dir(dir)
|
||||
.output();
|
||||
if !matches!(add, Ok(o) if o.status.success()) {
|
||||
crate::diag::warning!("git add failed (scaffold created without an initial commit)");
|
||||
eprintln!("aura: warning: git add failed (scaffold created without an initial commit)");
|
||||
return;
|
||||
}
|
||||
let commit = std::process::Command::new("git")
|
||||
@@ -385,8 +371,8 @@ fn commit_all(dir: &std::path::Path, message: &str) {
|
||||
.current_dir(dir)
|
||||
.output();
|
||||
if !matches!(commit, Ok(o) if o.status.success()) {
|
||||
crate::diag::warning!(
|
||||
"git commit failed (scaffold created without an initial commit)"
|
||||
eprintln!(
|
||||
"aura: warning: git commit failed (scaffold created without an initial commit)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -460,46 +446,10 @@ 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 RAW axis name (#246: a bound param is a default an axis
|
||||
// overrides; #328: the axis namespace is raw, no blueprint-name
|
||||
// prefix).
|
||||
// with the rendered blueprint-name axis prefix (#246: a bound param
|
||||
// is a default an axis overrides).
|
||||
assert!(claude.contains("blueprints/signal.json"));
|
||||
assert!(claude.contains("--axis fast.length"));
|
||||
}
|
||||
|
||||
/// #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"
|
||||
);
|
||||
assert!(claude.contains("demo_lab_signal.fast.length"));
|
||||
}
|
||||
|
||||
/// Property (#183, fieldtest F2 gap): the scaffold's own starter node teaches
|
||||
@@ -538,9 +488,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scaffold_project_refuses_existing_destination() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-pscaf");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-pscaf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(tmp.join("x")).unwrap();
|
||||
let spec = project_scaffold_spec("x", &tmp).unwrap();
|
||||
let e = scaffold_project(&spec).unwrap_err();
|
||||
@@ -550,9 +498,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn scaffold_node_crate_refuses_existing_dir_and_missing_engine() {
|
||||
let tmp = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-scaf");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-scaf-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
// existing destination
|
||||
let mut spec = scaffold_spec("x", None, None, &tmp).unwrap();
|
||||
|
||||
@@ -53,16 +53,14 @@ pub(crate) struct SugarInvocation<'a> {
|
||||
/// by construction, so the net channel is never producible there; requesting
|
||||
/// it anyway would make `campaign_run`'s executor print an unreachable "add a
|
||||
/// cost block to the campaign document" remedy (#240). The exclusion reuses
|
||||
/// `aura_runner::runner`'s own tap/channel classification (`tap_channel`/
|
||||
/// `campaign_run`'s own tap/channel classification (`tap_channel`/
|
||||
/// `TapChannel::Net`) rather than duplicating the net-tap name here.
|
||||
fn persist_taps_from(trace: bool) -> Vec<String> {
|
||||
if trace {
|
||||
tap_vocabulary()
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
aura_runner::runner::tap_channel(t.id) != Some(aura_runner::runner::TapChannel::Net)
|
||||
})
|
||||
.map(|t| t.id.to_string())
|
||||
.filter(|t| crate::campaign_run::tap_channel(t) != Some(crate::campaign_run::TapChannel::Net))
|
||||
.map(|t| t.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
@@ -287,8 +285,8 @@ pub(crate) fn register_generated_g(
|
||||
/// (campaign_run.rs) — the referential shape the dispatch-boundary name
|
||||
/// preflight (main.rs) does not catch: a subset of axes that leaves an open
|
||||
/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the
|
||||
/// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member`
|
||||
/// uses per member (aura-runner) — a bound-param axis passes this
|
||||
/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses
|
||||
/// per member (campaign_run.rs) — a bound-param axis passes this
|
||||
/// executable-shape preflight exactly like an already-open one; an axis
|
||||
/// matching neither space still fails `bind_axes`'s own named check below.
|
||||
/// `probe_params` is any one grid value per axis (checks NAME
|
||||
@@ -300,7 +298,7 @@ fn validate_before_register(
|
||||
campaign: &CampaignDoc,
|
||||
blueprint_canonical: &str,
|
||||
probe_params: &[(String, Scalar)],
|
||||
env: &aura_runner::project::Env,
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let doc_faults = aura_research::validate_campaign(campaign);
|
||||
if !doc_faults.is_empty() {
|
||||
@@ -326,78 +324,68 @@ fn validate_before_register(
|
||||
let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t))
|
||||
.expect("a generated sugar campaign's strategy ref reloads its own canonical bytes");
|
||||
let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect();
|
||||
let overrides = aura_runner::axes::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let overrides = crate::campaign_run::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space();
|
||||
let strategy_id = content_id_of(blueprint_canonical);
|
||||
aura_runner::axes::bind_axes(&space, &strategy_id, probe_params)
|
||||
crate::campaign_run::bind_axes(&space, &strategy_id, probe_params)
|
||||
.map(|_| ())
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))
|
||||
}
|
||||
|
||||
/// Run one dissolved sweep invocation end-to-end: register the generated
|
||||
/// documents, then execute through the one campaign path in sugar mode. The
|
||||
/// `Ok` carries the failed-cell count (#272), propagated verbatim from the
|
||||
/// one campaign executor.
|
||||
/// documents, then execute through the one campaign path in sugar mode.
|
||||
pub(crate) fn run_sweep_sugar(
|
||||
inv: &SugarInvocation,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let generated = translate_sweep(inv)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated(®, &generated)?;
|
||||
crate::campaign_run::run_campaign_by_id(
|
||||
&campaign_id,
|
||||
env,
|
||||
crate::campaign_run::RunPresentation::MemberLinesOnly,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)
|
||||
crate::campaign_run::run_campaign_by_id(&campaign_id, env, crate::campaign_run::RunPresentation::MemberLinesOnly)
|
||||
}
|
||||
|
||||
/// Build the `CrossInstrument` family members from an executed generalize
|
||||
/// campaign: each cell's nominee `RunReport`, in cell (instrument doc) order —
|
||||
/// the same order + shape the inline `run_generalize` built its `members` in,
|
||||
/// each already carrying `manifest.instrument`. #272: a failed cell nominates
|
||||
/// nothing, so `members.len() < outcome.cells.len()` is now a legitimate
|
||||
/// outcome (the caller counts and surfaces the failed cells separately, via
|
||||
/// `outcome.record.cells[].fault`) — no longer an invariant this function
|
||||
/// itself enforces.
|
||||
/// each already carrying `manifest.instrument`. Debug-asserts one member per
|
||||
/// cell: generalize's pipeline has no gate stage, so every cell nominates —
|
||||
/// a cell silently missing its nominee here would be a campaign-executor
|
||||
/// regression, not a legitimate generalize outcome, and the filter_map below
|
||||
/// must not swallow it unnoticed.
|
||||
fn cross_instrument_members(
|
||||
outcome: &aura_campaign::CampaignOutcome,
|
||||
) -> Vec<aura_backtest::RunReport> {
|
||||
outcome
|
||||
) -> Vec<aura_engine::RunReport> {
|
||||
let members: Vec<aura_engine::RunReport> = outcome
|
||||
.cells
|
||||
.iter()
|
||||
.filter_map(|c| c.nominee.as_ref().map(|(_, report)| report.clone()))
|
||||
.collect()
|
||||
.collect();
|
||||
debug_assert_eq!(
|
||||
members.len(),
|
||||
outcome.cells.len(),
|
||||
"generalize has no gate stage — every cell must nominate"
|
||||
);
|
||||
members
|
||||
}
|
||||
|
||||
/// Run one dissolved `generalize` invocation end-to-end: register the generated
|
||||
/// documents, run through the one campaign path, then reprint today's exact
|
||||
/// `{"generalize":{…}}` + `{"family_id":…}` lines from the recorded outcome and
|
||||
/// persist the `CrossInstrument` family. The stdout is byte-identical to the
|
||||
/// retired welded path (the committed exact-grade anchor is the gate). The
|
||||
/// `Ok` carries the failed-cell count (#272): a failed cell nominates nothing
|
||||
/// (the existing no-nominee -> `missing` path already handles it), counted
|
||||
/// from the recorded cells' own `fault` field rather than from the missing
|
||||
/// nominee, so the count is right even when some OTHER member of the cell's
|
||||
/// pipeline is what actually failed.
|
||||
/// retired welded path (the committed exact-grade anchor is the gate).
|
||||
pub(crate) fn run_generalize_sugar(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let generated = translate_generalize(inv, metric)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_g(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
|
||||
// Reprint the verb's aggregate line from the recorded cross-instrument
|
||||
// grade — byte-identical to the retired welded path's `generalize_json(&agg)`.
|
||||
@@ -415,8 +403,7 @@ pub(crate) fn run_generalize_sugar(
|
||||
.append_family(&inv.name, aura_registry::FamilyKind::CrossInstrument, &members)
|
||||
.map_err(|e| e.to_string())?;
|
||||
println!("{{\"family_id\":\"{family_id}\"}}");
|
||||
let failed = run.outcome.record.cells.iter().filter(|c| c.fault.is_some()).count();
|
||||
Ok(failed)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The two generated documents of one dissolved `walkforward` invocation.
|
||||
@@ -427,13 +414,12 @@ pub(crate) struct GeneratedWalkforward {
|
||||
}
|
||||
|
||||
/// Translate one `aura walkforward --real` invocation into its two generated
|
||||
/// documents: a `[std::grid, std::walk_forward]` process (the leading
|
||||
/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
|
||||
/// stage — it executes nothing and persists no family (#256)) and a campaign
|
||||
/// running the invocation's axis grid over one instrument under its single
|
||||
/// risk regime. The roller sizes come from `w` (ms). `inv.blueprint_canonical`
|
||||
/// is the user's blueprint already stored by topology hash; its content id is
|
||||
/// the strategy ref.
|
||||
/// documents: a `[std::sweep(argmax), std::walk_forward]` process (the sweep
|
||||
/// only enumerates the IS-refit survivor grid for the wf stage; its recorded
|
||||
/// argmax selection is not part of the summary) and a campaign running the
|
||||
/// invocation's axis grid over one instrument under its single risk regime.
|
||||
/// The roller sizes come from `w` (ms). `inv.blueprint_canonical` is the user's
|
||||
/// blueprint already stored by topology hash; its content id is the strategy ref.
|
||||
pub(crate) fn translate_walkforward(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
@@ -446,7 +432,13 @@ pub(crate) fn translate_walkforward(
|
||||
name: "walkforward".to_string(),
|
||||
description: None,
|
||||
pipeline: vec![
|
||||
StageBlock::Grid,
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: metric.to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
}),
|
||||
},
|
||||
StageBlock::WalkForward {
|
||||
in_sample_ms: w.in_sample_ms,
|
||||
out_of_sample_ms: w.out_of_sample_ms,
|
||||
@@ -502,33 +494,20 @@ pub(crate) fn register_generated_wf(
|
||||
/// documents, run through the one campaign path, then reprint the per-window member
|
||||
/// lines + the summary line from the recorded `WalkForward` family. The stdout
|
||||
/// summary line is byte-identical to the retired welded path (the committed
|
||||
/// exact-grade anchor is the gate). #272: the invocation's one cell either
|
||||
/// completed (`Ok(0)`) or failed (`Ok(1)`) — a failed single cell is not an
|
||||
/// error, since a completed-with-failed-cell run is the campaign path's own
|
||||
/// vocabulary (exit 3 at the dispatch boundary), never an "absent bootstrap"
|
||||
/// refusal the retired welded path's `.ok_or` would have raised.
|
||||
/// exact-grade anchor is the gate).
|
||||
pub(crate) fn run_walkforward_sugar(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
w: WfWindows,
|
||||
select: SelectRule,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let generated = translate_walkforward(inv, metric, w, select)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_wf(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
|
||||
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||
crate::diag::warning!("walkforward cell failed at stage {}: {}", f.stage, f.detail);
|
||||
return Ok(1);
|
||||
}
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
|
||||
// The single cell's walk_forward StageFamily.
|
||||
let wf = run
|
||||
@@ -549,9 +528,6 @@ pub(crate) fn run_walkforward_sugar(
|
||||
summary_axes.push("stop_length".to_string());
|
||||
summary_axes.push("stop_k".to_string());
|
||||
}
|
||||
crate::diag::note_zero_trade_windows(
|
||||
wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)),
|
||||
);
|
||||
println!("{}", crate::walkforward_summary_json_from_reports(&wf.reports, &summary_axes));
|
||||
|
||||
// Trace persistence runs LAST (mirroring `present_campaign`'s ordering,
|
||||
@@ -568,7 +544,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
// always `None`. `run`/`mc --trace` stay refused by design (#224), not by
|
||||
// this tail being skipped.
|
||||
if let Some(trace_name) = &run.outcome.record.trace_name {
|
||||
aura_runner::runner::persist_campaign_traces(
|
||||
crate::campaign_run::persist_campaign_traces(
|
||||
trace_name,
|
||||
&run.campaign.presentation.persist_taps,
|
||||
&run.outcome,
|
||||
@@ -578,7 +554,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
env,
|
||||
)?;
|
||||
}
|
||||
Ok(0)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The two generated documents of one dissolved `mc` invocation.
|
||||
@@ -589,9 +565,8 @@ pub(crate) struct GeneratedMc {
|
||||
}
|
||||
|
||||
/// Translate one `aura mc --real` invocation into its two generated documents:
|
||||
/// a `[std::grid, std::walk_forward, std::monte_carlo]` process (the leading
|
||||
/// `std::grid` stage only enumerates the IS-refit survivor grid for the wf
|
||||
/// stage — it executes nothing and persists no family (#256); the terminal
|
||||
/// a `[std::sweep(argmax), std::walk_forward, std::monte_carlo]` process (the
|
||||
/// sweep enumerates the IS-refit survivor grid for the wf stage; the terminal
|
||||
/// monte_carlo stage pools the per-window OOS trade-R series and r-bootstraps
|
||||
/// `E[R]`) and a campaign running the invocation's axis grid over one instrument
|
||||
/// under its single risk regime. Unlike `translate_walkforward` (which hardcodes
|
||||
@@ -612,7 +587,13 @@ pub(crate) fn translate_mc(
|
||||
name: "mc".to_string(),
|
||||
description: None,
|
||||
pipeline: vec![
|
||||
StageBlock::Grid,
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: metric.to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
}),
|
||||
},
|
||||
StageBlock::WalkForward {
|
||||
in_sample_ms: w.in_sample_ms,
|
||||
out_of_sample_ms: w.out_of_sample_ms,
|
||||
@@ -671,31 +652,19 @@ pub(crate) fn register_generated_mc(
|
||||
/// byte-identical to the retired welded path (the committed exact-grade anchor is
|
||||
/// the gate). mc prints ONLY the one summary line — no per-window member lines
|
||||
/// (unlike walkforward): the monte_carlo stage is a terminal annotator, not a family.
|
||||
/// #272: the invocation's one cell either completed (`Ok(0)`) or failed
|
||||
/// (`Ok(1)`) — a failed single cell is not an error (see `run_walkforward_sugar`'s
|
||||
/// twin note).
|
||||
pub(crate) fn run_mc_sugar(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
w: WfWindows,
|
||||
mc: McKnobs,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
env: &crate::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let generated = translate_mc(inv, metric, w, mc)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
validate_before_register(&generated.process, &generated.campaign, inv.blueprint_canonical, &probe_params, env)?;
|
||||
let reg = env.registry();
|
||||
let (_process_id, campaign_id) = register_generated_mc(®, &generated)?;
|
||||
let run = crate::campaign_run::run_campaign_returning(
|
||||
&campaign_id,
|
||||
env,
|
||||
aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
|
||||
)?;
|
||||
|
||||
if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) {
|
||||
crate::diag::warning!("mc cell failed at stage {}: {}", f.stage, f.detail);
|
||||
return Ok(1);
|
||||
}
|
||||
let run = crate::campaign_run::run_campaign_returning(&campaign_id, env)?;
|
||||
|
||||
// The single cell's terminal monte_carlo StageRealization carries the pooled-OOS
|
||||
// bootstrap (record path: outcome.record.cells[].stages[].bootstrap).
|
||||
@@ -711,7 +680,7 @@ pub(crate) fn run_mc_sugar(
|
||||
})
|
||||
.ok_or("mc produced no pooled-OOS bootstrap")?;
|
||||
println!("{}", crate::mc_r_bootstrap_json(boot));
|
||||
Ok(0)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -926,9 +895,10 @@ mod tests {
|
||||
/// that silently, before the dispatch rewire ever exercises the path live.
|
||||
#[test]
|
||||
fn register_generated_g_stores_documents_the_campaign_can_actually_resolve() {
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-verb-sugar-generalize-registry-test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"aura-verb-sugar-generalize-registry-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||
|
||||
@@ -964,11 +934,17 @@ mod tests {
|
||||
content_id_of(&campaign_to_json(&b.campaign)),
|
||||
"identical invocations must generate identical campaign ids"
|
||||
);
|
||||
// pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling)
|
||||
// pipeline: sweep(argmax) then walk_forward(argmax, rolling)
|
||||
assert_eq!(
|
||||
a.process.pipeline,
|
||||
vec![
|
||||
StageBlock::Grid,
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: "sqn_normalized".to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
})
|
||||
},
|
||||
StageBlock::WalkForward {
|
||||
in_sample_ms: 7_776_000_000,
|
||||
out_of_sample_ms: 2_592_000_000,
|
||||
@@ -1071,9 +1047,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn register_generated_wf_stores_documents_the_campaign_can_actually_resolve() {
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-verb-sugar-walkforward-registry-test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"aura-verb-sugar-walkforward-registry-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||
let ax = wf_axes();
|
||||
@@ -1107,11 +1084,17 @@ mod tests {
|
||||
content_id_of(&campaign_to_json(&b.campaign)),
|
||||
"identical invocations must generate identical campaign ids"
|
||||
);
|
||||
// pipeline: std::grid (enumerate-only) then walk_forward(argmax, rolling) then monte_carlo
|
||||
// pipeline: sweep(argmax) then walk_forward(argmax, rolling) then monte_carlo
|
||||
assert_eq!(
|
||||
a.process.pipeline,
|
||||
vec![
|
||||
StageBlock::Grid,
|
||||
StageBlock::Sweep {
|
||||
selection: Some(SweepSelection {
|
||||
metric: "sqn_normalized".to_string(),
|
||||
select: SelectRule::Argmax,
|
||||
deflate: false,
|
||||
})
|
||||
},
|
||||
StageBlock::WalkForward {
|
||||
in_sample_ms: 7_776_000_000,
|
||||
out_of_sample_ms: 2_592_000_000,
|
||||
@@ -1157,9 +1140,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn register_generated_mc_stores_documents_the_campaign_can_actually_resolve() {
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join("aura-verb-sugar-mc-registry-test");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"aura-verb-sugar-mc-registry-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let reg = aura_registry::Registry::open(dir.join("runs.jsonl"));
|
||||
let ax = wf_axes();
|
||||
@@ -1181,73 +1165,4 @@ mod tests {
|
||||
assert_eq!(stored_process, process_to_json(&generated.process));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A minimal `RunReport` stamped with `instrument`, distinguishable in a
|
||||
/// members list by nothing but that stamp — the field
|
||||
/// `cross_instrument_members` preserves verbatim from a nominating cell.
|
||||
fn fake_report(instrument: &str) -> aura_backtest::RunReport {
|
||||
aura_engine::RunReport {
|
||||
manifest: aura_engine::RunManifest {
|
||||
commit: String::new(),
|
||||
params: vec![],
|
||||
defaults: vec![],
|
||||
window: (aura_engine::Timestamp(0), aura_engine::Timestamp(1)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
selection: None,
|
||||
instrument: Some(instrument.to_string()),
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
},
|
||||
metrics: aura_backtest::RunMetrics {
|
||||
total_pips: 0.0,
|
||||
max_drawdown: 0.0,
|
||||
bias_sign_flips: 0,
|
||||
r: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_outcome(nominee_instrument: Option<&str>) -> aura_campaign::CellOutcome {
|
||||
aura_campaign::CellOutcome {
|
||||
families: vec![],
|
||||
selections: vec![],
|
||||
nominee: nominee_instrument.map(|i| (vec![], fake_report(i))),
|
||||
}
|
||||
}
|
||||
|
||||
/// #272: `cross_instrument_members` skips a cell with no nominee (a
|
||||
/// failed cell nominates nothing) rather than padding the members list or
|
||||
/// aborting — the surviving cells' reports still reach the persisted
|
||||
/// `CrossInstrument` family, in cell order, with the failed cell simply
|
||||
/// absent (not a placeholder). Three cells (AAA nominates, BBB fails, CCC
|
||||
/// nominates) must yield exactly the two survivors' reports, in order.
|
||||
#[test]
|
||||
fn cross_instrument_members_skips_a_failed_cell_and_keeps_survivor_order() {
|
||||
let outcome = aura_campaign::CampaignOutcome {
|
||||
record: aura_registry::CampaignRunRecord {
|
||||
campaign: String::new(),
|
||||
process: String::new(),
|
||||
run: 0,
|
||||
seed: 0,
|
||||
cells: vec![],
|
||||
generalizations: vec![],
|
||||
trace_name: None,
|
||||
},
|
||||
run: 0,
|
||||
cells: vec![
|
||||
cell_outcome(Some("AAA")),
|
||||
cell_outcome(None),
|
||||
cell_outcome(Some("CCC")),
|
||||
],
|
||||
};
|
||||
let members = cross_instrument_members(&outcome);
|
||||
let instruments: Vec<Option<String>> =
|
||||
members.iter().map(|r| r.manifest.instrument.clone()).collect();
|
||||
assert_eq!(
|
||||
instruments,
|
||||
vec![Some("AAA".to_string()), Some("CCC".to_string())],
|
||||
"the failed (no-nominee) cell is skipped, the two survivors keep their order"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
/// `./runs/runs.jsonl` into a throwaway dir, never dirtying the repo. Unique
|
||||
/// per test + per process; no external tempfile dependency (mirrors `cli_run.rs`).
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-{name}"));
|
||||
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
|
||||
|
||||
+270
-1866
File diff suppressed because it is too large
Load Diff
@@ -55,15 +55,6 @@ 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!(
|
||||
@@ -127,26 +118,6 @@ 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
|
||||
|
||||
@@ -26,7 +26,6 @@ impl Identity {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "one-input f64 pass-through",
|
||||
},
|
||||
|_| Box::new(Identity::new()),
|
||||
)
|
||||
|
||||
@@ -29,7 +29,6 @@ impl Identity {
|
||||
}],
|
||||
output: vec![FieldSpec { name: "value".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "one-input f64 pass-through",
|
||||
},
|
||||
|_| Box::new(Identity::new()),
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"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 +0,0 @@
|
||||
{"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 +0,0 @@
|
||||
{"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 +0,0 @@
|
||||
{"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"}]}}
|
||||
@@ -1,3 +0,0 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
/runs
|
||||
@@ -1 +0,0 @@
|
||||
# restated fixture — static project context only (C17); paths only (cycle 0102).
|
||||
@@ -1,15 +0,0 @@
|
||||
[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]
|
||||
@@ -1,83 +0,0 @@
|
||||
//! 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,
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
/target
|
||||
Cargo.lock
|
||||
/runs
|
||||
@@ -1 +0,0 @@
|
||||
# undescribed fixture — static project context only (C17); paths only (cycle 0102).
|
||||
@@ -1,15 +0,0 @@
|
||||
[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]
|
||||
@@ -1,78 +0,0 @@
|
||||
//! 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
@@ -1,249 +0,0 @@
|
||||
//! End-to-end pins for the self-description surfaces (#315, #323): the help
|
||||
//! opens with the two-layer concept, the sugar verbs name the document shape
|
||||
//! they desugar to, 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("research verbs"), "names the sugar layer: {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}");
|
||||
}
|
||||
|
||||
/// #315: each document-bridged sugar verb's long help names the process
|
||||
/// shape it desugars to and points at the document layer. `run` (not
|
||||
/// document-bridged) points at the canonical document-first form instead.
|
||||
#[test]
|
||||
fn sugar_verbs_name_their_document_shape() {
|
||||
for (verb, needle) in [
|
||||
("sweep", "std::sweep"),
|
||||
("walkforward", "std::walk_forward"),
|
||||
("mc", "std::monte_carlo"),
|
||||
("generalize", "std::generalize"),
|
||||
] {
|
||||
let (out, err, ok) = run(&[verb, "--help"]);
|
||||
assert!(ok, "aura {verb} --help failed: {err}");
|
||||
assert!(out.contains("Sugar"), "{verb} --help names the sugar relation: {out}");
|
||||
assert!(out.contains(needle), "{verb} --help names {needle}: {out}");
|
||||
assert!(out.contains("aura campaign"), "{verb} --help points at the documents: {out}");
|
||||
}
|
||||
let (out, err, ok) = run(&["run", "--help"]);
|
||||
assert!(ok, "aura run --help failed: {err}");
|
||||
assert!(out.contains("document-first"), "run --help names the canonical form: {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);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
//! #290 / C28: `aura measure ic <run> --signal <tap> --price <tap>` reduces a
|
||||
//! measurement run's two recorded taps to an Information Coefficient + its
|
||||
//! permutation-null significance. Exercises the WIRING and well-formedness over a
|
||||
//! real causal run; the signal-vs-noise math is unit-tested (a look-ahead-engineered
|
||||
//! signal is impossible in a causal run, C2).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-measic-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// `examples/r_sma.json` turned MEASUREMENT-shaped with TWO taps (node 0 "signal",
|
||||
/// node 1 "price") and no `bias` output — same closed topology, runs on the built-in
|
||||
/// synthetic stream. (Two-tap authoring mirrors tests/tap_recording.rs.)
|
||||
fn two_tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([
|
||||
{"name": "signal", "from": {"node": 0, "field": 0}},
|
||||
{"name": "price", "from": {"node": 1, "field": 0}},
|
||||
]);
|
||||
v["blueprint"]["output"] = serde_json::json!([]);
|
||||
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
|
||||
}
|
||||
|
||||
fn run_measurement(cwd: &Path) {
|
||||
let bp = cwd.join("measurement.json");
|
||||
std::fs::write(&bp, two_tap_blueprint_json()).expect("write blueprint");
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp.to_str().unwrap()])
|
||||
.current_dir(cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "aura run stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
fn measure_ic(cwd: &Path, run: &str, extra: &[&str]) -> std::process::Output {
|
||||
let mut args = vec!["measure", "ic", run, "--signal", "signal", "--price", "price"];
|
||||
args.extend_from_slice(extra);
|
||||
Command::new(BIN).args(&args).current_dir(cwd).output().expect("spawn aura measure ic")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_ic_emits_a_well_formed_report() {
|
||||
let cwd = temp_cwd("wellformed");
|
||||
run_measurement(&cwd);
|
||||
let out = measure_ic(&cwd, "sma_signal", &[]);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
||||
assert_eq!(r["run"], "sma_signal");
|
||||
assert_eq!(r["signal_tap"], "signal");
|
||||
assert_eq!(r["price_tap"], "price");
|
||||
assert_eq!(r["horizon"], 1);
|
||||
assert_eq!(r["permutations"], 1000);
|
||||
assert_eq!(r["seed"], 0);
|
||||
assert!(r["n_pairs"].as_u64().unwrap() >= 2, "expected aligned pairs, got {}", r["n_pairs"]);
|
||||
let ic = r["information_coefficient"].as_f64().unwrap();
|
||||
assert!(ic.is_finite() && (-1.0..=1.0).contains(&ic), "ic = {ic}");
|
||||
let p = r["overfit_probability"].as_f64().unwrap();
|
||||
assert!(p > 0.0 && p <= 1.0, "overfit_probability = {p}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_ic_is_deterministic() {
|
||||
let cwd = temp_cwd("determinism");
|
||||
run_measurement(&cwd);
|
||||
let a = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
|
||||
let b = measure_ic(&cwd, "sma_signal", &["--seed", "9"]);
|
||||
assert!(a.status.success() && b.status.success());
|
||||
assert_eq!(a.stdout, b.stdout, "same seed → byte-identical report");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_ic_unknown_run_errors() {
|
||||
let cwd = temp_cwd("unknownrun");
|
||||
run_measurement(&cwd);
|
||||
let out = measure_ic(&cwd, "no_such_run", &[]);
|
||||
assert!(!out.status.success(), "an unknown run must exit non-zero");
|
||||
}
|
||||
|
||||
/// `--horizon` is real plumbing from the CLI arg through
|
||||
/// `information_coefficient`'s alignment window, not merely echoed into the
|
||||
/// report: a horizon that exceeds the recorded price series collapses every
|
||||
/// signal/forward-return pair, hitting the reduction's documented degenerate
|
||||
/// floor (`n_pairs=0`, `ic=0.0`, `overfit_probability=1.0`, per main.rs's
|
||||
/// `information_coefficient` doc comment) — a floor the unit tests exercise
|
||||
/// only by calling the reduction in-memory, never through CLI parsing + a
|
||||
/// persisted-trace round trip.
|
||||
#[test]
|
||||
fn measure_ic_oversized_horizon_degenerates_through_the_cli() {
|
||||
let cwd = temp_cwd("oversizedhorizon");
|
||||
run_measurement(&cwd);
|
||||
let out = measure_ic(&cwd, "sma_signal", &["--horizon", "100000000"]);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let r: serde_json::Value = serde_json::from_slice(&out.stdout).expect("stdout is IcReport JSON");
|
||||
assert_eq!(r["horizon"], 100_000_000);
|
||||
assert_eq!(r["n_pairs"], 0, "an oversized horizon aligns no pairs");
|
||||
assert_eq!(r["information_coefficient"], 0.0, "degenerate floor: ic = 0.0");
|
||||
assert_eq!(r["overfit_probability"], 1.0, "degenerate floor: overfit_probability = 1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measure_ic_missing_tap_errors() {
|
||||
let cwd = temp_cwd("missingtap");
|
||||
run_measurement(&cwd);
|
||||
let out = Command::new(BIN)
|
||||
.args(["measure", "ic", "sma_signal", "--signal", "nope", "--price", "price"])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn");
|
||||
assert!(!out.status.success(), "a missing tap must exit non-zero");
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("nope"),
|
||||
"the error names the missing tap: {}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
@@ -19,14 +19,6 @@ 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)
|
||||
@@ -110,16 +102,16 @@ fn project_registry_anchors_at_discovered_root_not_invocation_cwd() {
|
||||
let sub = dir.join("src");
|
||||
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 open_bp =
|
||||
format!("{}/examples/r_sma_open.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_aura"))
|
||||
.args([
|
||||
"sweep",
|
||||
&closed_bp,
|
||||
&open_bp,
|
||||
"--axis",
|
||||
"fast.length=2,4",
|
||||
"sma_signal.fast.length=2,4",
|
||||
"--axis",
|
||||
"slow.length=8,16",
|
||||
"sma_signal.slow.length=8,16",
|
||||
"--name",
|
||||
"proj-anchor",
|
||||
])
|
||||
@@ -172,78 +164,6 @@ fn vocabulary_charter_violation_refuses_end_to_end() {
|
||||
assert!(err.contains("lacks the project prefix"), "stderr must name the charter cause: {err}");
|
||||
}
|
||||
|
||||
/// C29 load seam end-to-end (#316): an extension node whose doc fails the
|
||||
/// shape gate (the empty-string alibi the compiler cannot catch) refuses at
|
||||
/// load with the entry and the rule named — through the real
|
||||
/// `aura-cli::project::load` path (libloading + descriptor read), not merely
|
||||
/// the pure `doc_gate` unit function. The described twin's load is pinned by
|
||||
/// `project_run_resolves_demo_node_and_is_bit_identical`.
|
||||
#[test]
|
||||
fn undescribed_vocabulary_entry_refuses_end_to_end() {
|
||||
let dir = undescribed_dir();
|
||||
let build = Command::new("cargo")
|
||||
.arg("build")
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn cargo build for the undescribed fixture");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"undescribed fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let run = aura(&["run", "x.json"], &dir);
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(1),
|
||||
"an undescribed vocabulary entry is a runtime refusal (exit 1), \
|
||||
not a usage error"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(
|
||||
err.contains("vocabulary entry `und::Opaque` has an empty doc"),
|
||||
"stderr must name the entry and the rule: {err}"
|
||||
);
|
||||
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
|
||||
}
|
||||
|
||||
/// C29 load seam end-to-end, the sibling fault arm (#316): a doc that merely
|
||||
/// restates its own type id (`DocGateFault::RestatesName`) refuses at load
|
||||
/// exactly like the empty-doc case above, through the same real
|
||||
/// `aura-cli::project::load` path. `RestatesName`'s rendered prose was
|
||||
/// previously pinned only against the pure `Display` impl in
|
||||
/// `aura-runner::project`'s unit tests (never reachable at all through the
|
||||
/// CLI) — a regression that reordered `doc_gate`'s two fault arms, or that
|
||||
/// skipped the gate call for this arm specifically, would pass every
|
||||
/// existing test and still ship a self-description hole for exactly this
|
||||
/// case: an alibi doc that types-checks but says nothing.
|
||||
#[test]
|
||||
fn restated_name_vocabulary_entry_refuses_end_to_end() {
|
||||
let dir = restated_dir();
|
||||
let build = Command::new("cargo")
|
||||
.arg("build")
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.expect("spawn cargo build for the restated fixture");
|
||||
assert!(
|
||||
build.status.success(),
|
||||
"restated fixture build failed:\n{}",
|
||||
String::from_utf8_lossy(&build.stderr)
|
||||
);
|
||||
let run = aura(&["run", "x.json"], &dir);
|
||||
assert_eq!(
|
||||
run.status.code(),
|
||||
Some(1),
|
||||
"a name-restating vocabulary entry is a runtime refusal (exit 1), \
|
||||
not a usage error"
|
||||
);
|
||||
let err = String::from_utf8_lossy(&run.stderr);
|
||||
assert!(
|
||||
err.contains("vocabulary entry `restated::Echo` has a doc that merely restates its name"),
|
||||
"stderr must name the entry and the rule: {err}"
|
||||
);
|
||||
assert!(err.contains("C29"), "stderr must cite the contract: {err}");
|
||||
}
|
||||
|
||||
/// Force a file's modification time to a fixed instant (explicit mtime, no
|
||||
/// sleeping — mtime granularity would make a "touch then compare" race).
|
||||
fn set_mtime(path: &Path, secs_since_epoch: u64) {
|
||||
@@ -448,8 +368,7 @@ 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 = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-dataonly-hint");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-dataonly-hint-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&tmp).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
|
||||
let bp = tmp.join("bp.json");
|
||||
@@ -469,8 +388,7 @@ 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 = Path::new(env!("CARGO_TARGET_TMPDIR")).join("aura-nobuild");
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
let tmp = std::env::temp_dir().join(format!("aura-nobuild-{}", std::process::id()));
|
||||
std::fs::create_dir_all(tmp.join("src")).unwrap();
|
||||
std::fs::write(tmp.join("Aura.toml"), "").unwrap();
|
||||
std::fs::write(tmp.join("src/lib.rs"), "").unwrap();
|
||||
|
||||
@@ -14,19 +14,8 @@ 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 {
|
||||
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 d = std::env::temp_dir().join(format!("aura-new-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
@@ -135,7 +124,7 @@ fn data_only_project_sweeps_without_any_build() {
|
||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||
let proj = base.join("scratch");
|
||||
let out = aura(
|
||||
&["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4"],
|
||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4"],
|
||||
&proj,
|
||||
);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
@@ -157,7 +146,7 @@ fn data_only_project_sweep_over_the_starter_opens_one_member_per_axis_value() {
|
||||
assert!(new.status.success(), "{}", String::from_utf8_lossy(&new.stderr));
|
||||
let proj = base.join("scratch");
|
||||
let out = aura(
|
||||
&["sweep", "blueprints/signal.json", "--axis", "fast.length=2,4,8"],
|
||||
&["sweep", "blueprints/signal.json", "--axis", "scratch_signal.fast.length=2,4,8"],
|
||||
&proj,
|
||||
);
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
fn temp_cwd(tag: &str) -> std::path::PathBuf {
|
||||
let d = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-nodes-{tag}"));
|
||||
let d = std::env::temp_dir().join(format!("aura-nodes-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
@@ -18,39 +18,6 @@ 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");
|
||||
@@ -211,85 +178,3 @@ 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","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"}]}}"#;
|
||||
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"}]}}"#;
|
||||
|
||||
/// The minimal executable process pipeline (one sweep stage) — the campaign
|
||||
/// cell's methodology reference.
|
||||
@@ -160,8 +160,8 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
// 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).
|
||||
// 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"]);
|
||||
assert!(
|
||||
axes.status.success(),
|
||||
@@ -170,10 +170,10 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
);
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&axes.stdout),
|
||||
"gain.factor:F64\n\
|
||||
fast.length:I64 default=2\n\
|
||||
slow.length:I64 default=4\n\
|
||||
bias.scale:F64 default=0.5\n",
|
||||
"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",
|
||||
"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)
|
||||
@@ -198,7 +198,7 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
"--to",
|
||||
GER40_TO_MS,
|
||||
"--axis",
|
||||
"gain.factor=0.5,1.0",
|
||||
"scaled_signal.gain.factor=0.5,1.0",
|
||||
"--name",
|
||||
"knob",
|
||||
],
|
||||
@@ -212,11 +212,9 @@ fn project_node_open_param_sweeps_and_reproduces() {
|
||||
let lines: Vec<&str> = stdout.lines().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 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.
|
||||
// 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.
|
||||
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!(
|
||||
@@ -229,7 +227,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("gain.factor"))
|
||||
.find(|p| p[0].as_str() == Some("scaled_signal.gain.factor"))
|
||||
.and_then(|p| p[1]["F64"].as_f64());
|
||||
assert_eq!(
|
||||
bound,
|
||||
@@ -333,7 +331,7 @@ fn project_node_open_param_runs_one_campaign_cell() {
|
||||
|
||||
// Archive-gated: skip cleanly on a data-less machine (the member-data
|
||||
// refusal, never a panic) — mirroring campaign_run_real_e2e_*.
|
||||
if run.status.code() == Some(3)
|
||||
if run.status.code() == Some(1)
|
||||
&& (out.contains("no recorded geometry") || out.contains("no data for instrument"))
|
||||
{
|
||||
eprintln!("skip: no local GER40 data for the campaign cell");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,140 +0,0 @@
|
||||
//! #286 / C28 phase 3: `aura run` on a measurement-shaped blueprint (declares a
|
||||
//! tap, exposes no `bias`) runs BARE — no wrap_r scaffold — emits a
|
||||
//! `MeasurementReport` and persists the tapped series. A taps-only blueprint is
|
||||
//! refused before this feature; this pins the new additive behaviour.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-meas-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json`, turned MEASUREMENT-shaped: one declared tap
|
||||
/// on node 0 ("fast", SMA(2)) field 0, and its `bias` output removed. Same closed
|
||||
/// topology (`sma_signal`), so it runs on the built-in synthetic stream.
|
||||
fn measurement_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
|
||||
v["blueprint"]["output"] = serde_json::json!([]); // no `bias` — a measurement, not a strategy
|
||||
serde_json::to_string(&v).expect("re-serialize measurement blueprint")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn measurement_blueprint_runs_bare_and_emits_its_tap() {
|
||||
let cwd = temp_cwd("runs-bare");
|
||||
let bp_path = cwd.join("measurement.json");
|
||||
std::fs::write(&bp_path, measurement_blueprint_json()).expect("write measurement blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
// stdout is a MeasurementReport: the declared tap name, no R `metrics`, the
|
||||
// measurement broker sentinel.
|
||||
let report: serde_json::Value =
|
||||
serde_json::from_slice(&out.stdout).expect("stdout parses as MeasurementReport JSON");
|
||||
assert_eq!(report["taps"], serde_json::json!(["fast_tap"]));
|
||||
assert!(report.get("metrics").is_none(), "a measurement run has no R metrics");
|
||||
assert_eq!(report["manifest"]["broker"], "measurement");
|
||||
|
||||
// The tapped series is persisted through the trace store.
|
||||
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
|
||||
let trace_text = std::fs::read_to_string(&trace_path)
|
||||
.unwrap_or_else(|e| panic!("expected a persisted tap trace at {}: {e}", trace_path.display()));
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||||
}
|
||||
|
||||
/// #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(["run", 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(["run", 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"
|
||||
);
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
//! `aura run <blueprint.json>` must REFUSE a user-authorable blueprint it
|
||||
//! cannot run — a clean `aura:`-prefixed message on stderr and exit 1, the
|
||||
//! established binding-refusal register — never a process panic. Both cases
|
||||
//! are user mistakes on the hand-authored single-run path, so each deserves a
|
||||
//! diagnosis, not a stack trace.
|
||||
//!
|
||||
//! Two independent unrunnable shapes, each pinned as its own minimal,
|
||||
//! self-contained blueprint (inlined here — no shared fixture file):
|
||||
//! 1. a declared tap whose `from` wire references an out-of-range node
|
||||
//! index (taps are authored by raw node index, so a miscount is the
|
||||
//! expected mistake) — compiles to `CompileError::TapWireOutOfRange`;
|
||||
//! 2. an exposed output that is not a `bias` stream (a measurement-only
|
||||
//! blueprint) — `wrap_r` cannot weld it to the R-evaluator.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the
|
||||
/// test crate; the binary is named `aura` in `Cargo.toml`).
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// A fresh, unique working directory per test (so nothing a run might emit
|
||||
/// dirties the repo). Unique per test + per process.
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-refuse-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// Run `aura run <path>` in an isolated cwd and assert the CLEAN-REFUSAL
|
||||
/// contract: exit code 1, an `aura:`-prefixed stderr message, and NO panic —
|
||||
/// the property both cases share. `what` names the offending shape for the
|
||||
/// failure message.
|
||||
fn assert_clean_refusal(bp_json: &str, tag: &str, what: &str) {
|
||||
let cwd = temp_cwd(tag);
|
||||
let bp_path = cwd.join("blueprint.json");
|
||||
std::fs::write(&bp_path, bp_json).expect("write blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
|
||||
// The defining symptom of the bug: the process panics (Rust's default
|
||||
// unwind exits 101 and prints "panicked at ...") instead of refusing.
|
||||
assert!(
|
||||
!stderr.to_lowercase().contains("panic"),
|
||||
"{what}: expected a clean refusal, got a PANIC:\n{stderr}"
|
||||
);
|
||||
// The clean-refusal register: `aura: <msg>` on stderr, exit 1 — exactly
|
||||
// what the binding refusals in `run_signal_r` already emit.
|
||||
assert!(
|
||||
stderr.contains("aura:"),
|
||||
"{what}: refusal must carry the `aura:` prefix:\n{stderr}"
|
||||
);
|
||||
assert_eq!(
|
||||
out.status.code(),
|
||||
Some(1),
|
||||
"{what}: a refusal exits 1 (not 101 panic, not 2 usage):\n{stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: a blueprint whose declared tap wire points at an out-of-range
|
||||
/// node index is refused, not crashed. Minimal trigger — a single-node closed
|
||||
/// strategy (`price → Bias → "bias"`, so the R-wrap welds cleanly) carrying
|
||||
/// one tap whose `from.node` (5) is out of range for a one-node graph. This
|
||||
/// compiles to `CompileError::TapWireOutOfRange`, which `run_signal_r`'s
|
||||
/// `compile_with_params(...).expect(...)` currently turns into a panic.
|
||||
#[test]
|
||||
fn run_refuses_a_tap_wire_pointing_out_of_range() {
|
||||
let bp = r#"{
|
||||
"format_version": 1,
|
||||
"blueprint": {
|
||||
"name": "bias_with_bad_tap",
|
||||
"nodes": [
|
||||
{"primitive": {"type": "Bias", "name": "bias",
|
||||
"bound": [{"pos": 0, "name": "scale", "kind": "F64", "value": {"F64": 1.0}}]}}
|
||||
],
|
||||
"edges": [],
|
||||
"input_roles": [{"name": "price", "targets": [{"node": 0, "slot": 0}], "source": "F64"}],
|
||||
"output": [{"node": 0, "field": 0, "name": "bias"}],
|
||||
"taps": [{"name": "bad", "from": {"node": 5, "field": 0}}]
|
||||
}
|
||||
}"#;
|
||||
assert_clean_refusal(bp, "bad-tap-wire", "out-of-range tap wire");
|
||||
}
|
||||
|
||||
/// Property: a blueprint whose single exposed output is not a `bias` stream
|
||||
/// (a measurement-only blueprint — here `price → SMA → "measurement"`) is
|
||||
/// refused, not crashed. `wrap_r` welds the nested signal by its `"bias"`
|
||||
/// output port; a non-bias output makes that weld resolve to
|
||||
/// `BuildError::UnknownOutPort`, which `wrap_r`'s `g.build().expect(...)`
|
||||
/// currently turns into a panic. (This pins the refusal only — teaching
|
||||
/// `aura run` to accept bias-less blueprints is a separate feature.)
|
||||
#[test]
|
||||
fn run_refuses_a_non_bias_output_blueprint() {
|
||||
let bp = r#"{
|
||||
"format_version": 1,
|
||||
"blueprint": {
|
||||
"name": "measurement_only",
|
||||
"nodes": [
|
||||
{"primitive": {"type": "SMA", "name": "avg",
|
||||
"bound": [{"pos": 0, "name": "length", "kind": "I64", "value": {"I64": 2}}]}}
|
||||
],
|
||||
"edges": [],
|
||||
"input_roles": [{"name": "price", "targets": [{"node": 0, "slot": 0}], "source": "F64"}],
|
||||
"output": [{"node": 0, "field": 0, "name": "measurement"}]
|
||||
}
|
||||
}"#;
|
||||
assert_clean_refusal(bp, "non-bias-output", "non-bias exposed output");
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
@@ -1,422 +0,0 @@
|
||||
//! #282: a hand-authored blueprint with a declared tap, run on the single-run
|
||||
//! path, records the tapped series; the same blueprint in a sweep does not
|
||||
//! (`run_blueprint_member`, the sweep/reduce path, never calls `bind_tap` —
|
||||
//! structurally guaranteed by Task 6 leaving it untouched, not re-asserted
|
||||
//! here).
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// Path to the freshly-built `aura` binary (Cargo sets this env var for the
|
||||
/// test crate; the binary is named `aura` in `Cargo.toml`).
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_aura");
|
||||
|
||||
/// A fresh, unique working directory for this process test (so the run's
|
||||
/// `./runs/traces/` never dirties the repo). Unique per test + per process.
|
||||
fn temp_cwd(name: &str) -> std::path::PathBuf {
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("aura-cli-tap-{name}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp cwd");
|
||||
dir
|
||||
}
|
||||
|
||||
/// The exact synthetic price array `r_sma_prices()` (main.rs) feeds the
|
||||
/// `RunData::Synthetic` path — duplicated here (not re-exported; a private
|
||||
/// CLI helper) so the tapped SMA(2) column can be pinned against an
|
||||
/// independently-derived expectation, not a blind capture.
|
||||
const R_SMA_PRICES: [f64; 18] = [
|
||||
1.0000, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034, 1.0012, 0.9998,
|
||||
1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
|
||||
];
|
||||
|
||||
/// The shipped `examples/r_sma.json` blueprint, with one additional declared
|
||||
/// tap on node 0 ("fast", `SMA(length=2)`) field 0 — the smallest addition
|
||||
/// that names an interior producer output without touching the existing
|
||||
/// topology (nodes/edges/input_roles/output all verbatim).
|
||||
fn tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([{"name": "fast_tap", "from": {"node": 0, "field": 0}}]);
|
||||
serde_json::to_string(&v).expect("re-serialize tapped blueprint")
|
||||
}
|
||||
|
||||
/// Property: on the single-run path (`aura run <blueprint.json>` over the
|
||||
/// built-in synthetic stream, no `--real`), a declared tap is bound and its
|
||||
/// per-cycle series is persisted through the trace store — the persisted
|
||||
/// `fast_tap.json` carries exactly the SMA(2) values over the known synthetic
|
||||
/// price stream (silent until warm at sample 2, one value per cycle after).
|
||||
#[test]
|
||||
fn single_run_persists_a_declared_tap_series() {
|
||||
let cwd = temp_cwd("single-run-persists");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
|
||||
let trace_path = cwd.join("runs/traces/sma_signal/fast_tap.json");
|
||||
let trace_text = std::fs::read_to_string(&trace_path).unwrap_or_else(|e| {
|
||||
panic!("expected a persisted tap trace at {}: {e}", trace_path.display())
|
||||
});
|
||||
let trace: serde_json::Value = serde_json::from_str(&trace_text).expect("parse tap trace json");
|
||||
|
||||
assert_eq!(trace["tap"], "fast_tap");
|
||||
assert_eq!(trace["kinds"], serde_json::json!(["F64"]));
|
||||
|
||||
// SMA(2), silent until warm (2 samples): first emission at the 2nd sample
|
||||
// (ts=2), value = mean(prices[0], prices[1]); then a rolling mean of the
|
||||
// last two prices per subsequent cycle (ts = i+1 for prices[i]).
|
||||
let expected: Vec<f64> = (1..R_SMA_PRICES.len())
|
||||
.map(|i| (R_SMA_PRICES[i - 1] + R_SMA_PRICES[i]) / 2.0)
|
||||
.collect();
|
||||
let expected_ts: Vec<i64> = (2..=R_SMA_PRICES.len() as i64).collect();
|
||||
|
||||
let got_ts: Vec<i64> = trace["ts"].as_array().expect("ts array").iter().map(|v| v.as_i64().unwrap()).collect();
|
||||
assert_eq!(got_ts, expected_ts, "recorded timestamps");
|
||||
|
||||
let got: Vec<f64> = trace["columns"][0]
|
||||
.as_array()
|
||||
.expect("columns[0] array")
|
||||
.iter()
|
||||
.map(|v| v.as_f64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(got.len(), expected.len(), "recorded row count");
|
||||
for (i, (g, e)) in got.iter().zip(expected.iter()).enumerate() {
|
||||
assert!((g - e).abs() < 1e-9, "row {i}: got {g}, expected {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A tap-free blueprint run (the existing `run_prints_json_and_exits_zero`
|
||||
/// shape) leaves no `runs/` directory at all — the tap machinery is fully
|
||||
/// inert (no binds, no trace_store write) when a blueprint declares no taps,
|
||||
/// matching Task 6's byte-identical guarantee.
|
||||
#[test]
|
||||
fn tap_free_run_writes_no_trace_store() {
|
||||
let cwd = temp_cwd("tap-free-writes-nothing");
|
||||
let bp = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", &bp])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
assert!(!cwd.join("runs").exists(), "a tap-free run must not create a trace store");
|
||||
}
|
||||
|
||||
/// The shipped `examples/r_sma.json` blueprint with TWO declared taps that
|
||||
/// share the same name (`"dup"`), on two distinct producers (node 0 "fast" and
|
||||
/// node 1 "slow") — the shape `run_signal_r`'s `seen: BTreeSet` dedup guard
|
||||
/// exists for (`FlatGraph::bind_tap` itself keeps no cross-call state per its
|
||||
/// own doc comment, so nothing upstream of the CLI catches this).
|
||||
fn duplicate_tap_blueprint_json() -> String {
|
||||
let path = format!("{}/examples/r_sma.json", env!("CARGO_MANIFEST_DIR"));
|
||||
let doc = std::fs::read_to_string(path).expect("read examples/r_sma.json");
|
||||
let mut v: serde_json::Value = serde_json::from_str(&doc).expect("parse r_sma.json");
|
||||
v["blueprint"]["taps"] = serde_json::json!([
|
||||
{"name": "dup", "from": {"node": 0, "field": 0}},
|
||||
{"name": "dup", "from": {"node": 1, "field": 0}},
|
||||
]);
|
||||
serde_json::to_string(&v).expect("re-serialize duplicate-tap blueprint")
|
||||
}
|
||||
|
||||
/// 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(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
assert!(!out.status.success(), "a duplicate tap name must be refused, not silently bound");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("dup") && stderr.contains("more than once"),
|
||||
"stderr must name the offending tap and the duplicate-bind fault: {stderr}"
|
||||
);
|
||||
assert!(!cwd.join("runs").exists(), "a refused run must not persist a half-written trace store");
|
||||
}
|
||||
|
||||
/// The `aura: writing tap traces failed: …` register, pre-run arm: `runs`
|
||||
/// exists as a FILE, so the trace store's directory creation
|
||||
/// (`begin_run`) fails before the run — exit 1 through the register.
|
||||
/// (This register was code-present but untested before #283.)
|
||||
#[test]
|
||||
fn an_unwritable_store_root_exits_through_the_tap_trace_register() {
|
||||
let cwd = temp_cwd("store-root-is-a-file");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
std::fs::write(cwd.join("runs"), b"not a directory").expect("occupy runs as a file");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
assert!(!out.status.success(), "an unwritable store must refuse, not succeed");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("writing tap traces failed"),
|
||||
"the tap-trace register must fire: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same register, deferred arm (#283): the run directory pre-exists
|
||||
/// read-only, so `begin_run` succeeds (create_dir_all on an existing dir)
|
||||
/// but the record consumer's deferred open in `initialize` fails; the run
|
||||
/// COMPLETES, the failure surfaces terminally through the register, and no
|
||||
/// `index.json` is written (the store's treat-as-absent crash shape).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_read_only_run_dir_surfaces_terminally_through_the_tap_trace_register() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let cwd = temp_cwd("run-dir-read-only");
|
||||
let bp_path = cwd.join("tapped_r_sma.json");
|
||||
std::fs::write(&bp_path, tap_blueprint_json()).expect("write tapped blueprint");
|
||||
let run_dir = cwd.join("runs/traces/sma_signal");
|
||||
std::fs::create_dir_all(&run_dir).expect("pre-create run dir");
|
||||
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o555))
|
||||
.expect("make run dir read-only");
|
||||
|
||||
let out = Command::new(BIN)
|
||||
.args(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
|
||||
// restore permissions FIRST so the next run's temp_cwd cleanup works.
|
||||
std::fs::set_permissions(&run_dir, std::fs::Permissions::from_mode(0o755))
|
||||
.expect("restore run dir permissions");
|
||||
|
||||
assert!(!out.status.success(), "a failed writer must surface, not pass silently");
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
stderr.contains("writing tap traces failed"),
|
||||
"the tap-trace register must fire terminally: {stderr}"
|
||||
);
|
||||
assert!(!run_dir.join("index.json").exists(), "no index — treat-as-absent crash shape");
|
||||
}
|
||||
|
||||
/// #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(["run", 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!["run", "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(["run", 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(["run", 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(["run", 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(["run", bp_path.to_str().expect("utf-8 path")])
|
||||
.current_dir(&cwd)
|
||||
.output()
|
||||
.expect("spawn aura run");
|
||||
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
|
||||
let 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!["run", 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,8 +14,6 @@ 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,11 +17,10 @@
|
||||
//! what C19/C23 DCE deletes), so it appears nowhere here.
|
||||
use aura_core::{PrimitiveBuilder, Scalar};
|
||||
use aura_engine::{Composite, GraphBuilder, NodeHandle};
|
||||
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,
|
||||
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,
|
||||
};
|
||||
|
||||
/// The volatility stop as a composition of primitives:
|
||||
@@ -52,8 +51,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::configured(1).bind("weights[0]", Scalar::f64(k)),
|
||||
None => LinComb::configured(1).named("stop_k"),
|
||||
Some((_, k)) => LinComb::builder(1).bind("weights[0]", Scalar::f64(k)),
|
||||
None => LinComb::builder(1).named("stop_k"),
|
||||
});
|
||||
g.feed(price, [delay.input("series"), sub.input("lhs")]);
|
||||
g.connect(delay.output("value"), sub.input("rhs"));
|
||||
@@ -76,7 +75,6 @@ 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
|
||||
@@ -90,12 +88,6 @@ 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,
|
||||
)
|
||||
@@ -174,7 +166,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::configured(n));
|
||||
let agg = g.add(CostSum::builder(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_strategy::{ConstantCost, VolSlippageCost};
|
||||
use aura_std::{ConstantCost, VolSlippageCost};
|
||||
|
||||
/// Two heterogeneous cost nodes: ConstantCost (no extras, index 0) and
|
||||
/// VolSlippageCost (one extra `volatility`, index 1). The composite exposes the 4
|
||||
|
||||
@@ -9,9 +9,8 @@ use aura_core::{
|
||||
ScalarKind, Timestamp,
|
||||
};
|
||||
use aura_composites::{risk_executor, StopRule};
|
||||
use aura_engine::{GraphBuilder, VecSource};
|
||||
use aura_backtest::{summarize_r, RunMetrics, PM_FIELD_NAMES, PM_RECORD_KINDS};
|
||||
use aura_std::Recorder;
|
||||
use aura_engine::{summarize_r, GraphBuilder, RunMetrics, VecSource};
|
||||
use aura_std::{Recorder, PM_FIELD_NAMES, PM_RECORD_KINDS};
|
||||
use std::sync::mpsc::channel;
|
||||
|
||||
// The dense-record columns this fixture reads, named in lockstep with the sibling
|
||||
@@ -35,7 +34,6 @@ 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)] }),
|
||||
)
|
||||
@@ -182,51 +180,11 @@ 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_backtest::RMetrics {
|
||||
fn fold_executor(exec: aura_engine::Composite, params: Vec<Scalar>) -> aura_engine::RMetrics {
|
||||
let (tx, rx) = channel();
|
||||
let mut g = GraphBuilder::new("vol_open_harness");
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
|
||||
@@ -7,10 +7,6 @@ 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,8 +44,7 @@ pub use column::{Column, Window};
|
||||
pub use ctx::Ctx;
|
||||
pub use error::KindMismatch;
|
||||
pub use node::{
|
||||
doc_gate, zip_params, ArgKind, ArgOpError, ArgSpec, ArgValue, BindOpError, BoundParam,
|
||||
ConstructionArg, DocGateFault, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
zip_params, BindOpError, BoundParam, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec,
|
||||
PrimitiveBuilder,
|
||||
};
|
||||
pub use scalar::{Scalar, ScalarKind, Timestamp};
|
||||
|
||||
@@ -112,7 +112,6 @@ 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)]
|
||||
@@ -128,108 +127,7 @@ impl PrimitiveBuilder {
|
||||
schema: NodeSchema,
|
||||
build: impl Fn(&[Cell]) -> Box<dyn Node> + 'static,
|
||||
) -> Self {
|
||||
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 { .. })
|
||||
Self { name, instance_name: None, schema, bound: Vec::new(), build: Box::new(build) }
|
||||
}
|
||||
/// 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
|
||||
@@ -426,10 +324,7 @@ 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. `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.
|
||||
/// conditions as values.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum BindOpError {
|
||||
/// No still-open param has this name.
|
||||
@@ -438,141 +333,6 @@ 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
|
||||
@@ -585,36 +345,6 @@ 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**.
|
||||
@@ -649,15 +379,6 @@ 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
|
||||
@@ -716,14 +437,13 @@ 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![], doc: "test-only schema" },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(none.label(), "Sub");
|
||||
@@ -753,7 +473,7 @@ mod tests {
|
||||
fn node_name_strips_namespace_prefix() {
|
||||
let b = PrimitiveBuilder::new(
|
||||
"demo::Identity",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
|_| unreachable!("never built in this test"),
|
||||
);
|
||||
assert_eq!(b.node_name(), "identity");
|
||||
@@ -785,7 +505,7 @@ mod tests {
|
||||
fn primitive_builder_build_runs_the_closure() {
|
||||
let f = PrimitiveBuilder::new(
|
||||
"Bare",
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
NodeSchema { inputs: vec![], output: vec![], params: vec![] },
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
assert_eq!(f.params(), Vec::<ParamSpec>::new());
|
||||
@@ -800,7 +520,6 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -810,21 +529,6 @@ 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>);
|
||||
@@ -852,7 +556,6 @@ 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())),
|
||||
)
|
||||
@@ -929,7 +632,6 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -948,7 +650,6 @@ mod tests {
|
||||
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
||||
ParamSpec { name: "dup".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -964,7 +665,6 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
);
|
||||
@@ -1016,7 +716,6 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![],
|
||||
params: vec![ParamSpec { name: "length".into(), kind: ScalarKind::I64 }],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
@@ -1038,169 +737,6 @@ 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,32 +11,6 @@ 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 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 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 = { 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,22 +19,8 @@ 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
|
||||
@@ -42,9 +28,6 @@ aura-backtest = { path = "../aura-backtest" }
|
||||
# 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,11 +15,10 @@
|
||||
//! C23) and no external dependency (C16).
|
||||
|
||||
use aura_core::{
|
||||
BindOpError, Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar,
|
||||
ScalarKind,
|
||||
Cell, FieldSpec, Firing, Node, NodeSchema, ParamSpec, PortSpec, PrimitiveBuilder, Scalar, ScalarKind,
|
||||
};
|
||||
|
||||
use crate::harness::{BootstrapError, Edge, FlatGraph, FlatTap, Harness, SourceSpec, Target};
|
||||
use crate::harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
|
||||
use crate::sweep::sweep;
|
||||
use crate::{GridSpace, ParamRange, RandomSpace, RunReport, SweepFamily};
|
||||
|
||||
@@ -34,30 +33,6 @@ 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 {
|
||||
@@ -130,15 +105,7 @@ fn derive_signature(c: &Composite) -> NodeSchema {
|
||||
.collect();
|
||||
let mut params = Vec::new();
|
||||
collect_params(c.nodes(), c.gangs(), "", &mut 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: "",
|
||||
}
|
||||
NodeSchema { inputs, output, params }
|
||||
}
|
||||
|
||||
/// The scalar kind of the interior input slot a composite target addresses,
|
||||
@@ -195,38 +162,6 @@ 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
|
||||
@@ -239,7 +174,6 @@ pub struct Composite {
|
||||
edges: Vec<Edge>,
|
||||
input_roles: Vec<Role>,
|
||||
output: Vec<OutField>,
|
||||
taps: Vec<Tap>,
|
||||
gangs: Vec<Gang>,
|
||||
}
|
||||
|
||||
@@ -255,7 +189,7 @@ impl Composite {
|
||||
input_roles: Vec<Role>,
|
||||
output: Vec<OutField>,
|
||||
) -> Self {
|
||||
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, taps: Vec::new(), gangs: Vec::new() }
|
||||
Self { name: name.into(), doc: None, nodes, edges, input_roles, output, gangs: Vec::new() }
|
||||
}
|
||||
|
||||
/// The authored render name (cluster title, #13). Non-load-bearing.
|
||||
@@ -273,47 +207,6 @@ 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
|
||||
@@ -398,7 +291,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, &self.taps)?;
|
||||
validate_wiring(&self.nodes, &self.edges, &self.input_roles, &self.output)?;
|
||||
check_root_roles_bound(&self.input_roles)?;
|
||||
|
||||
if point.len() != space.len() {
|
||||
@@ -417,7 +310,6 @@ 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(
|
||||
@@ -427,7 +319,6 @@ impl Composite {
|
||||
&mut flat_nodes,
|
||||
&mut flat_signatures,
|
||||
&mut flat_edges,
|
||||
&mut flat_taps,
|
||||
)?;
|
||||
|
||||
for e in &self.edges {
|
||||
@@ -444,24 +335,10 @@ impl Composite {
|
||||
for t in &role.targets {
|
||||
targets.extend(resolve_target(t, &lowerings)?);
|
||||
}
|
||||
flat_sources.push(SourceSpec::named(role.name.clone(), kind, targets));
|
||||
flat_sources.push(SourceSpec { kind, targets });
|
||||
}
|
||||
|
||||
// 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,
|
||||
})
|
||||
Ok(FlatGraph { nodes: flat_nodes, signatures: flat_signatures, sources: flat_sources, edges: flat_edges })
|
||||
}
|
||||
|
||||
/// Compile from a self-describing param vector — the authoring-edge frontend
|
||||
@@ -632,10 +509,9 @@ 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<M, F>(self, run_one: F) -> Result<SweepFamily<M>, BindError>
|
||||
pub fn sweep<F>(self, run_one: F) -> Result<SweepFamily, BindError>
|
||||
where
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
F: Fn(&[Cell]) -> RunReport + Sync,
|
||||
{
|
||||
self.sweep_with_lattice(run_one).map(|(family, _lattice)| family)
|
||||
}
|
||||
@@ -644,10 +520,9 @@ 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<M, F>(self, run_one: F) -> Result<(SweepFamily<M>, Vec<usize>), BindError>
|
||||
pub fn sweep_with_lattice<F>(self, run_one: F) -> Result<(SweepFamily, Vec<usize>), BindError>
|
||||
where
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
F: Fn(&[Cell]) -> RunReport + Sync,
|
||||
{
|
||||
let space = self.bp.param_space();
|
||||
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
|
||||
@@ -678,10 +553,9 @@ 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<M, F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily<M>, BindError>
|
||||
pub fn sweep<F>(self, count: usize, seed: u64, run_one: F) -> Result<SweepFamily, BindError>
|
||||
where
|
||||
M: Send,
|
||||
F: Fn(&[Cell]) -> RunReport<M> + Sync,
|
||||
F: Fn(&[Cell]) -> RunReport + Sync,
|
||||
{
|
||||
let space = self.bp.param_space();
|
||||
check_param_namespace_injective(&space).map_err(BindError::Compile)?;
|
||||
@@ -938,9 +812,6 @@ 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`
|
||||
@@ -989,7 +860,6 @@ 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
|
||||
@@ -1021,20 +891,12 @@ 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(), c.taps())?;
|
||||
validate_wiring(c.nodes(), c.edges(), c.input_roles(), c.output())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -1161,92 +1023,6 @@ 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
|
||||
@@ -1341,7 +1117,6 @@ 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 {
|
||||
@@ -1365,7 +1140,6 @@ fn lower_items(
|
||||
flat_nodes,
|
||||
flat_signatures,
|
||||
flat_edges,
|
||||
flat_taps,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
@@ -1382,7 +1156,6 @@ 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.
|
||||
@@ -1393,12 +1166,11 @@ 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, taps, gangs: _ } = c;
|
||||
let Composite { name: _, doc: _, nodes, edges, input_roles, output, 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, flat_taps)?;
|
||||
|
||||
let interior = lower_items(nodes, point, cursor, flat_nodes, flat_signatures, flat_edges)?;
|
||||
for e in &edges {
|
||||
for fe in rewrite_edge(e, &interior, flat_signatures)? {
|
||||
flat_edges.push(fe);
|
||||
@@ -1444,15 +1216,6 @@ 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 })
|
||||
}
|
||||
|
||||
@@ -1501,35 +1264,6 @@ 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]
|
||||
@@ -1543,27 +1277,11 @@ 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, ParamRange, RandomSpace, RunManifest, VecSource};
|
||||
use aura_backtest::{summarize, RunMetrics};
|
||||
use crate::{f64_field, summarize, ParamRange, RandomSpace, RunManifest, VecSource};
|
||||
use aura_core::{Cell, Ctx, FieldSpec, Firing, NodeSchema, Timestamp};
|
||||
use aura_backtest::SimBroker;
|
||||
use aura_std::{Ema, LinComb, Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::{Bias, Ema, LinComb, Recorder, SimBroker, Sma, Sub};
|
||||
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]
|
||||
@@ -1866,7 +1584,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::configured(2).into(),
|
||||
LinComb::builder(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -1905,7 +1623,7 @@ mod tests {
|
||||
Composite::new(
|
||||
"sig3",
|
||||
vec![
|
||||
LinComb::configured(2).into(),
|
||||
LinComb::builder(2).into(),
|
||||
Bias::builder().into(),
|
||||
Sma::builder().named("c").into(),
|
||||
],
|
||||
@@ -2009,7 +1727,6 @@ mod tests {
|
||||
inputs: vec![],
|
||||
output: vec![FieldSpec { name: "v".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let to = NodeSchema {
|
||||
inputs: vec![
|
||||
@@ -2018,7 +1735,6 @@ mod tests {
|
||||
],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
assert!(edge_kind_check(&from, 0, &to, 0).is_ok());
|
||||
assert_eq!(
|
||||
@@ -2035,7 +1751,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<RunMetrics> {
|
||||
fn run_point(point: &[Cell]) -> RunReport {
|
||||
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
|
||||
let mut h = bp
|
||||
.bootstrap_with_cells(point)
|
||||
@@ -2047,7 +1763,6 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "test".to_string(),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0,
|
||||
broker: "test".to_string(),
|
||||
@@ -2215,7 +1930,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<RunMetrics> { panic!("axis pre-validation must reject before running") });
|
||||
.sweep(|_: &[Cell]| -> RunReport { panic!("axis pre-validation must reject before running") });
|
||||
assert_eq!(
|
||||
result,
|
||||
Err(BindError::KindMismatch {
|
||||
@@ -2442,7 +2157,7 @@ mod tests {
|
||||
fn pass1() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"Pass1",
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|
||||
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.into()
|
||||
@@ -2450,12 +2165,7 @@ mod tests {
|
||||
fn join2() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"Join2",
|
||||
NodeSchema {
|
||||
inputs: vec![f64_any(), f64_any()],
|
||||
output: out_v(),
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
NodeSchema { inputs: vec![f64_any(), f64_any()], output: out_v(), params: vec![] },
|
||||
|_| Box::new(Join2 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.into()
|
||||
@@ -2463,7 +2173,7 @@ mod tests {
|
||||
fn sink_f64() -> BlueprintNode {
|
||||
PrimitiveBuilder::new(
|
||||
"SinkF64",
|
||||
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![], doc: "test-only schema" },
|
||||
NodeSchema { inputs: vec![f64_any()], output: vec![], params: vec![] },
|
||||
|_| Box::new(SinkF64),
|
||||
)
|
||||
.into()
|
||||
@@ -2475,7 +2185,6 @@ 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),
|
||||
)
|
||||
@@ -2655,8 +2364,6 @@ 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]
|
||||
@@ -3028,7 +2735,6 @@ mod tests {
|
||||
inputs: vec![f64_any()],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
};
|
||||
let h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
@@ -3049,11 +2755,14 @@ mod tests {
|
||||
f64_recorder_sig(),
|
||||
f64_recorder_sig(),
|
||||
],
|
||||
sources: vec![SourceSpec::raw(ScalarKind::F64, vec![
|
||||
sources: vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
targets: 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 },
|
||||
@@ -3062,7 +2771,6 @@ 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)
|
||||
@@ -3278,7 +2986,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(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() }],
|
||||
@@ -3308,8 +3016,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn param_space_reflects_only_open_knobs() {
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
use aura_std::{Bias, 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.
|
||||
@@ -3335,8 +3042,7 @@ 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_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
use aura_std::{Bias, Sma};
|
||||
let strat = Composite::new(
|
||||
"sma2_entry",
|
||||
vec![
|
||||
@@ -3357,8 +3063,7 @@ mod tests {
|
||||
/// error, not a silent no-op — open axes pass through without reopen.
|
||||
#[test]
|
||||
fn reopen_unknown_or_open_path_errors() {
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
use aura_std::{Bias, Sma};
|
||||
let strat = Composite::new(
|
||||
"sma2_entry",
|
||||
vec![
|
||||
@@ -3379,8 +3084,7 @@ mod tests {
|
||||
/// carries the bound value (the default the CLI renders in --list-axes).
|
||||
#[test]
|
||||
fn bound_param_space_is_path_qualified_with_values() {
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
use aura_std::{Bias, Sma};
|
||||
let strat = Composite::new(
|
||||
"sma2_entry",
|
||||
vec![
|
||||
@@ -3486,7 +3190,7 @@ mod tests {
|
||||
// outer composite "strategy": the inner composite + a LinComb([1,-1])
|
||||
let strategy = Composite::new(
|
||||
"strategy",
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::configured(2).into()],
|
||||
vec![BlueprintNode::Composite(fast_slow), LinComb::builder(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![
|
||||
@@ -3546,7 +3250,7 @@ mod tests {
|
||||
use aura_std::{LinComb, Sma};
|
||||
let bp = Composite::new(
|
||||
"root",
|
||||
vec![Sma::builder().into(), LinComb::configured(2).into()],
|
||||
vec![Sma::builder().into(), LinComb::builder(2).into()],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![], // output
|
||||
@@ -3634,7 +3338,6 @@ 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"),
|
||||
);
|
||||
@@ -3737,7 +3440,7 @@ mod tests {
|
||||
// cannot take .named).
|
||||
let paramless_sma = PrimitiveBuilder::new(
|
||||
"Pass1",
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![], doc: "test-only schema" },
|
||||
NodeSchema { inputs: vec![f64_any()], output: out_v(), params: vec![] },
|
||||
|_| Box::new(Pass1 { out: [Cell::from_f64(0.0)] }),
|
||||
)
|
||||
.named("sma");
|
||||
@@ -3868,88 +3571,4 @@ 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,29 +7,18 @@
|
||||
//! 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, ConstructionArg, PrimitiveBuilder};
|
||||
use aura_core::{BoundParam, PrimitiveBuilder};
|
||||
|
||||
/// 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;
|
||||
/// 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;
|
||||
|
||||
/// Top-level envelope: the version is read before the payload is interpreted.
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
@@ -57,8 +46,6 @@ 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>,
|
||||
}
|
||||
|
||||
@@ -72,33 +59,17 @@ pub enum NodeData {
|
||||
}
|
||||
|
||||
/// A primitive node as data: its compiled-in type identity, optional instance
|
||||
/// name, its accepted construction args (#271), and bound params. The schema
|
||||
/// + build closure are re-derived on load.
|
||||
/// name, 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 {
|
||||
@@ -123,7 +94,6 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -138,19 +108,9 @@ 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,
|
||||
})
|
||||
}
|
||||
@@ -158,32 +118,8 @@ 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 {
|
||||
let blueprint = project(c);
|
||||
let format_version = document_version(&blueprint);
|
||||
BlueprintDoc { format_version, blueprint }
|
||||
BlueprintDoc { format_version: BLUEPRINT_FORMAT_VERSION, blueprint: project(c) }
|
||||
}
|
||||
|
||||
fn serialize_doc(doc: &BlueprintDoc) -> Result<String, SerializeError> {
|
||||
@@ -199,11 +135,10 @@ 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, 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
|
||||
/// 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
|
||||
/// 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> {
|
||||
@@ -232,9 +167,6 @@ 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 {
|
||||
@@ -261,16 +193,11 @@ 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 sink node not in #155's set).
|
||||
/// vocabulary — unknown node, or a construction-arg/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(
|
||||
@@ -286,15 +213,6 @@ 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.
|
||||
@@ -309,7 +227,6 @@ 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 {
|
||||
@@ -319,16 +236,14 @@ fn reconstruct(
|
||||
}
|
||||
|
||||
/// Load a blueprint from canonical JSON, resolving each primitive's type identity
|
||||
/// through the injected `resolve` (e.g. `aura_vocabulary::std_vocabulary`). The version
|
||||
/// through the injected `resolve` (e.g. `aura_std::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)?;
|
||||
// #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) {
|
||||
if doc.format_version != BLUEPRINT_FORMAT_VERSION {
|
||||
return Err(LoadError::UnsupportedVersion {
|
||||
found: doc.format_version,
|
||||
supported: BLUEPRINT_FORMAT_VERSION,
|
||||
@@ -343,10 +258,8 @@ 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::{ArgOpError, Scalar, ScalarKind, Timestamp};
|
||||
use aura_market::Session;
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use aura_std::{Bias, Recorder, Sma, Sub};
|
||||
use std::sync::mpsc;
|
||||
|
||||
// Open param point for the sink-free signal (fast.length is bound): [slow.length, bias.scale].
|
||||
@@ -382,7 +295,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_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
||||
|
||||
let trace_rust = run_recording(rust_built);
|
||||
let trace_loaded = run_recording(loaded);
|
||||
@@ -395,7 +308,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_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_std::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"));
|
||||
}
|
||||
@@ -418,7 +331,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_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
let loaded = blueprint_from_json(&json, &|t| aura_std::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");
|
||||
@@ -449,7 +362,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_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
let loaded = blueprint_from_json(&doced_json, &|t| aura_std::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!(
|
||||
@@ -459,69 +372,21 @@ 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_vocabulary::std_vocabulary(t)).err().unwrap();
|
||||
let err = blueprint_from_json(json, &|t| aura_std::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":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 }));
|
||||
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 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -548,7 +413,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_vocabulary::std_vocabulary(t))
|
||||
let loaded = blueprint_from_json(&with_unknown, &|t| aura_std::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
|
||||
@@ -556,7 +421,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_vocabulary::std_vocabulary(t)).expect("loads");
|
||||
let plain = blueprint_from_json(&canonical, &|t| aura_std::std_vocabulary(t)).expect("loads");
|
||||
assert_eq!(
|
||||
run_recording(loaded),
|
||||
run_recording(plain),
|
||||
@@ -737,9 +602,9 @@ mod tests {
|
||||
}
|
||||
|
||||
// The resolver used by the gang-serde tests below (mirrors the inline
|
||||
// `&|t| aura_vocabulary::std_vocabulary(t)` closure the rest of this module uses).
|
||||
// `&|t| aura_std::std_vocabulary(t)` closure the rest of this module uses).
|
||||
fn fixture_resolver() -> impl Fn(&str) -> Option<PrimitiveBuilder> {
|
||||
|t: &str| aura_vocabulary::std_vocabulary(t)
|
||||
|t: &str| aura_std::std_vocabulary(t)
|
||||
}
|
||||
|
||||
// Shared topology: two open SMAs feeding a `Sub`. `ganged_fixture_named`
|
||||
@@ -894,127 +759,4 @@ 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,9 +10,7 @@
|
||||
|
||||
use aura_core::{NodeSchema, ScalarKind};
|
||||
|
||||
use crate::blueprint::{
|
||||
BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role, Tap, TapWire,
|
||||
};
|
||||
use crate::blueprint::{BlueprintNode, CompileError, Composite, Gang, GangFault, GangMember, OutField, Role};
|
||||
use crate::harness::{Edge, Target};
|
||||
|
||||
/// A typed, `Copy` reference to a node added to a [`GraphBuilder`], carrying the
|
||||
@@ -94,7 +92,6 @@ 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>)>,
|
||||
}
|
||||
|
||||
@@ -109,7 +106,6 @@ impl GraphBuilder {
|
||||
edges: Vec::new(),
|
||||
roles: Vec::new(),
|
||||
out: Vec::new(),
|
||||
taps: Vec::new(),
|
||||
gangs: Vec::new(),
|
||||
}
|
||||
}
|
||||
@@ -153,15 +149,6 @@ 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()));
|
||||
@@ -198,12 +185,6 @@ 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());
|
||||
@@ -249,7 +230,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).with_taps(taps);
|
||||
let mut composite = Composite::new(self.name, self.nodes, edges, roles, output);
|
||||
if let Some(doc) = self.doc {
|
||||
composite = composite.with_doc(doc);
|
||||
}
|
||||
@@ -344,9 +325,7 @@ 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_backtest::SimBroker;
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::{Bias, Recorder, SimBroker, Sma, Sub};
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// The `sma_cross` sub-composite authored through the builder.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -321,8 +321,7 @@ mod tests {
|
||||
use aura_core::{
|
||||
Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar,
|
||||
};
|
||||
use aura_std::{Recorder, Sma, Sub};
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::{Bias, Recorder, Sma, Sub};
|
||||
use std::sync::mpsc;
|
||||
|
||||
/// A bare node whose only purpose is to back a `PrimitiveBuilder` in a test.
|
||||
@@ -348,7 +347,6 @@ mod tests {
|
||||
],
|
||||
output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
@@ -463,7 +461,6 @@ mod tests {
|
||||
FieldSpec { name: "lo".into(), kind: ScalarKind::I64 },
|
||||
],
|
||||
params: vec![],
|
||||
doc: "test-only schema",
|
||||
},
|
||||
|_| Box::new(Bare),
|
||||
)
|
||||
|
||||
+109
-703
File diff suppressed because it is too large
Load Diff
@@ -13,27 +13,28 @@
|
||||
//! - [`BootstrapError`] — wiring faults caught once, at bootstrap (kind
|
||||
//! mismatch, bad index, directed cycle).
|
||||
//!
|
||||
//! Delivered in cycle 0009, generic since #292 — the run-record surface:
|
||||
//! Delivered in cycle 0009 — the run report surface:
|
||||
//!
|
||||
//! - [`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.
|
||||
//! - [`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.
|
||||
//!
|
||||
//! Orchestration families of the atomic sim unit
|
||||
//! (`(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.
|
||||
//! (`(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`].
|
||||
//!
|
||||
//! Still to come (subsequent cycles): the broker-independent position-event
|
||||
//! output and downstream broker nodes (C10), the random param-sweep
|
||||
@@ -47,40 +48,40 @@ mod builder;
|
||||
mod construction;
|
||||
mod graph_model;
|
||||
mod harness;
|
||||
mod mc;
|
||||
mod report;
|
||||
mod sweep;
|
||||
mod walkforward;
|
||||
|
||||
pub use blueprint::{
|
||||
name_gate, BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang,
|
||||
GangFault, GangMember, NameGateFault, OutField, RandomBinder, ReopenError, Role, SweepBinder,
|
||||
Tap, TapWire,
|
||||
BindError, Binder, BlueprintNode, BoundSpec, CompileError, Composite, Gang, GangFault,
|
||||
GangMember, OutField, RandomBinder, ReopenError, Role, SweepBinder,
|
||||
};
|
||||
pub use blueprint_serde::{
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, ArgData, BlueprintDoc,
|
||||
CompositeData, LoadError, NodeData, PrimitiveData, SerializeError, BLUEPRINT_FORMAT_VERSION,
|
||||
blueprint_from_json, blueprint_identity_json, blueprint_to_json, 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, bind_sources, BootstrapError, Edge, FlatGraph, FlatTap, Harness, Source, SourceBindError,
|
||||
SourceSpec, SplitMix64, SyntheticSpec, TapBindError, Target, VecSource,
|
||||
window_of, BootstrapError, Edge, FlatGraph, Harness, Source, SourceSpec, SplitMix64,
|
||||
SyntheticSpec, Target, VecSource,
|
||||
};
|
||||
pub use report::{
|
||||
expected_max_of_normals, f64_field, inv_norm_cdf, join_on_ts,
|
||||
ColumnarTrace, FamilySelection, JoinedRow,
|
||||
MeasurementReport, ProjectProvenance, RunManifest,
|
||||
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,
|
||||
RunReport, SelectionMode,
|
||||
};
|
||||
pub use sweep::{
|
||||
run_indexed, sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError,
|
||||
SweepFamily, SweepPoint,
|
||||
sweep, GridSpace, ListSpace, ParamRange, RandomSpace, Space, SweepError, SweepFamily,
|
||||
SweepPoint,
|
||||
};
|
||||
pub use mc::{
|
||||
monte_carlo, r_bootstrap, resample_block, McAggregate, McDraw, McFamily, MetricStats,
|
||||
RBootstrap,
|
||||
};
|
||||
// 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,
|
||||
@@ -89,8 +90,7 @@ 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::{
|
||||
ArgKind, ArgOpError, BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind,
|
||||
Timestamp,
|
||||
BindOpError, Firing, NodeSchema, ParamSpec, PortSpec, Scalar, ScalarKind, Timestamp,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -3,25 +3,18 @@
|
||||
//! 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`](aura_engine::run_indexed)
|
||||
//! realizations). It reuses the disjoint-parallel [`run_indexed`](crate::sweep)
|
||||
//! 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. 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.
|
||||
//! concern.
|
||||
|
||||
// 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};
|
||||
use crate::sweep::run_indexed;
|
||||
use crate::{RunMetrics, RunReport, Scalar};
|
||||
use crate::harness::SplitMix64;
|
||||
|
||||
/// One Monte-Carlo realization: the seed that drove it and the full `RunReport`.
|
||||
/// Self-describing, analog to [`SweepPoint`](aura_engine::SweepPoint).
|
||||
/// Self-describing, analog to [`SweepPoint`](crate::SweepPoint).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct McDraw {
|
||||
pub seed: u64,
|
||||
@@ -30,7 +23,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`](aura_engine::SweepFamily).
|
||||
/// [`McAggregate`]. Analog to [`SweepFamily`](crate::SweepFamily).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct McFamily {
|
||||
pub draws: Vec<McDraw>,
|
||||
@@ -48,6 +41,38 @@ 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
|
||||
@@ -66,24 +91,23 @@ impl McAggregate {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
/// 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]
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `run_one(seed, base_point)` over every seed, disjointly in parallel (C1),
|
||||
@@ -95,23 +119,20 @@ where
|
||||
///
|
||||
/// 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`](aura_engine::sweep).
|
||||
/// signature is preserved (no `Result`), matching [`sweep`](crate::sweep).
|
||||
pub fn monte_carlo<F>(base_point: &[Scalar], seeds: &[u64], run_one: F) -> McFamily
|
||||
where
|
||||
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
|
||||
{
|
||||
debug_assert!(!seeds.is_empty(), "monte_carlo requires a non-empty seed set");
|
||||
assemble_mc(base_point, seeds, &run_one)
|
||||
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||
monte_carlo_with_threads(base_point, seeds, nthreads, run_one)
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
/// 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.
|
||||
fn monte_carlo_with_threads<F>(
|
||||
base_point: &[Scalar],
|
||||
seeds: &[u64],
|
||||
@@ -122,11 +143,14 @@ where
|
||||
F: Fn(u64, &[Scalar]) -> RunReport + Sync,
|
||||
{
|
||||
debug_assert!(!seeds.is_empty(), "monte_carlo requires a non-empty seed set");
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(nthreads)
|
||||
.build()
|
||||
.expect("rayon thread pool");
|
||||
pool.install(|| assemble_mc(base_point, seeds, &run_one))
|
||||
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 }
|
||||
}
|
||||
|
||||
/// Distribution of `E[R]` under a moving-block bootstrap of an OOS per-trade R series
|
||||
@@ -142,6 +166,26 @@ 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
|
||||
@@ -172,83 +216,9 @@ pub fn r_bootstrap(rs: &[f64], n_resamples: usize, block_len: usize, seed: u64)
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
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)
|
||||
}
|
||||
use crate::test_fixtures::composite_sma_cross_harness;
|
||||
use crate::{f64_field, summarize, RunManifest, SyntheticSpec, Timestamp};
|
||||
use aura_core::Scalar;
|
||||
|
||||
/// Build + bootstrap + run + drain + summarize one (seed, point) into a
|
||||
/// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `monte_carlo`
|
||||
@@ -269,7 +239,6 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "test".to_string(),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window,
|
||||
seed,
|
||||
broker: "test".to_string(),
|
||||
@@ -297,7 +266,6 @@ mod tests {
|
||||
manifest: RunManifest {
|
||||
commit: "t".to_string(),
|
||||
params: Vec::new(),
|
||||
defaults: Vec::new(),
|
||||
window: (Timestamp(0), Timestamp(0)),
|
||||
seed: i as u64,
|
||||
broker: "t".to_string(),
|
||||
@@ -381,6 +349,75 @@ 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);
|
||||
@@ -8,17 +8,19 @@
|
||||
//! cycle 0029) — the same encoder the run registry uses, so a record's stdout
|
||||
//! and on-disk shapes coincide.
|
||||
|
||||
use aura_core::{Scalar, ScalarKind, Timestamp};
|
||||
use aura_core::{Scalar, ScalarKind, SeriesFold, Timestamp};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// 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 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 reproducible run descriptor (C18). **Caller-supplied**: the engine
|
||||
/// cannot introspect a git commit, an RNG seed, or a broker label — the World
|
||||
@@ -32,16 +34,6 @@ pub struct RunManifest {
|
||||
/// self-describing [`Scalar`], so the param's kind (an `i64` length vs an
|
||||
/// `f64` scale) survives into the record instead of collapsing to `f64`.
|
||||
pub params: Vec<(String, Scalar)>,
|
||||
/// The bound params this run did NOT vary — the wrap-prefixed
|
||||
/// `bound_param_space()` of the signal, EXCLUDING any bound param an axis
|
||||
/// reopened (those flow through `params` instead, #246). Complements
|
||||
/// `params` ("what varied") with "what was held": the two are disjoint by
|
||||
/// construction. Empty for every pre-#249 record. One-directional serde
|
||||
/// widening (C14/C18), identical idiom to `selection`/`instrument`/
|
||||
/// `topology_hash` — except a `Vec`, not an `Option`, so it always
|
||||
/// serializes (mirroring `params`), never skipped when empty.
|
||||
#[serde(default)]
|
||||
pub defaults: Vec<(String, Scalar)>,
|
||||
/// The data-window: inclusive `(from, to)` epoch-ns bounds (C12).
|
||||
pub window: (Timestamp, Timestamp),
|
||||
/// The RNG seed (C12 seed-as-input). `0` for a seed-free synthetic run.
|
||||
@@ -88,18 +80,16 @@ pub struct ProjectProvenance {
|
||||
pub commit: Option<String>,
|
||||
}
|
||||
|
||||
/// 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").
|
||||
/// 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").
|
||||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RunReport<M> {
|
||||
pub struct RunReport {
|
||||
pub manifest: RunManifest,
|
||||
pub metrics: M,
|
||||
pub metrics: RunMetrics,
|
||||
}
|
||||
|
||||
impl<M: serde::Serialize> RunReport<M> {
|
||||
impl RunReport {
|
||||
/// 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,26 +101,35 @@ impl<M: serde::Serialize> RunReport<M> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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")
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// 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
|
||||
@@ -284,7 +283,10 @@ fn scalar_to_f64(s: Scalar) -> f64 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use aura_backtest::RunMetrics;
|
||||
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;
|
||||
|
||||
#[test]
|
||||
fn runmanifest_without_selection_field_deserialises_to_none() {
|
||||
@@ -296,7 +298,7 @@ mod tests {
|
||||
#[test]
|
||||
fn runmanifest_without_selection_serialises_without_the_key() {
|
||||
let m = RunManifest {
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: None, instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
@@ -315,7 +317,7 @@ mod tests {
|
||||
#[test]
|
||||
fn runmanifest_instrument_round_trips_and_omits_when_none() {
|
||||
let with = RunManifest {
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: None, instrument: Some("GER40".into()),
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
@@ -326,7 +328,7 @@ mod tests {
|
||||
assert_eq!(back.instrument, Some("GER40".to_string()));
|
||||
|
||||
let without = RunManifest {
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: None, instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
@@ -368,7 +370,7 @@ mod tests {
|
||||
commit: Some("deadbeef".into()),
|
||||
};
|
||||
let m = RunManifest {
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: None, instrument: None,
|
||||
topology_hash: None,
|
||||
project: Some(p.clone()),
|
||||
@@ -391,7 +393,7 @@ mod tests {
|
||||
neighbourhood_score: None, n_neighbours: None,
|
||||
};
|
||||
let m = RunManifest {
|
||||
commit: "c".into(), params: vec![], defaults: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
commit: "c".into(), params: vec![], window: (Timestamp(0), Timestamp(0)),
|
||||
seed: 0, broker: "b".into(), selection: Some(sel.clone()), instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
@@ -439,6 +441,173 @@ mod tests {
|
||||
assert_eq!(sel.n_neighbours, None);
|
||||
}
|
||||
|
||||
/// The declared signature of a `Recorder` over one f64 column (the sink shape
|
||||
/// the two-sink harness uses).
|
||||
fn f64_recorder_sig() -> NodeSchema {
|
||||
NodeSchema {
|
||||
inputs: vec![PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "in".into() }],
|
||||
output: vec![],
|
||||
params: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an f64 source stream from (timestamp, value) points (mirrors the
|
||||
/// harness.rs test helper; the e2e test needs its own copy — the harness
|
||||
/// test module's is private to that module).
|
||||
fn f64_stream(points: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
|
||||
points.iter().map(|&(t, v)| (Timestamp(t), Scalar::f64(v))).collect()
|
||||
}
|
||||
|
||||
/// Bootstrap the cycle-0007 signal-quality harness with TWO sinks: one on
|
||||
/// the SimBroker equity output (node 4 -> node 5) and one on the Bias
|
||||
/// output (node 3 -> node 6). Returns the harness plus the two receivers.
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn build_two_sink_harness() -> (
|
||||
Harness,
|
||||
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
||||
mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
|
||||
) {
|
||||
let (tx_eq, rx_eq) = mpsc::channel();
|
||||
let (tx_ex, rx_ex) = mpsc::channel();
|
||||
let h = Harness::bootstrap(FlatGraph {
|
||||
nodes: vec![
|
||||
Box::new(Sma::new(2)), // 0
|
||||
Box::new(Sma::new(4)), // 1
|
||||
Box::new(Sub::new()), // 2
|
||||
Box::new(Bias::new(0.5)), // 3
|
||||
Box::new(SimBroker::new(0.0001)), // 4
|
||||
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
|
||||
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
|
||||
],
|
||||
signatures: vec![
|
||||
Sma::builder().schema().clone(),
|
||||
Sma::builder().schema().clone(),
|
||||
Sub::builder().schema().clone(),
|
||||
Bias::builder().schema().clone(),
|
||||
SimBroker::builder(0.0001).schema().clone(),
|
||||
f64_recorder_sig(),
|
||||
f64_recorder_sig(),
|
||||
],
|
||||
sources: vec![SourceSpec {
|
||||
kind: ScalarKind::F64,
|
||||
targets: vec![
|
||||
Target { node: 0, slot: 0 },
|
||||
Target { node: 1, slot: 0 },
|
||||
Target { node: 4, slot: 1 }, // price into the broker
|
||||
],
|
||||
}],
|
||||
edges: vec![
|
||||
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
||||
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
||||
Edge { from: 2, to: 3, slot: 0, from_field: 0 },
|
||||
Edge { from: 3, to: 4, slot: 0, from_field: 0 },
|
||||
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
|
||||
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
|
||||
],
|
||||
})
|
||||
.expect("valid signal-quality DAG");
|
||||
(h, rx_eq, rx_ex)
|
||||
}
|
||||
|
||||
fn run_once() -> RunReport {
|
||||
let (mut h, rx_eq, rx_ex) = build_two_sink_harness();
|
||||
h.run(vec![Box::new(VecSource::new(f64_stream(&[
|
||||
(1, 1.0000),
|
||||
(2, 1.0010),
|
||||
(3, 1.0025),
|
||||
(4, 1.0020),
|
||||
(5, 1.0040),
|
||||
])))]);
|
||||
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
||||
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
||||
let equity = f64_field(&eq_rows, 0);
|
||||
let exposure = f64_field(&ex_rows, 0);
|
||||
let metrics = summarize(&equity, &exposure);
|
||||
RunReport {
|
||||
manifest: RunManifest {
|
||||
commit: "test-commit".to_string(),
|
||||
params: vec![
|
||||
("sma_fast".to_string(), Scalar::i64(2)),
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("bias_scale".to_string(), Scalar::f64(0.5)),
|
||||
],
|
||||
window: (Timestamp(1), Timestamp(5)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=0.0001)".to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
},
|
||||
metrics,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_is_deterministic_end_to_end() {
|
||||
let r1 = run_once();
|
||||
let r2 = run_once();
|
||||
// a run actually emitted metrics over a non-empty pip curve
|
||||
assert!(r1.metrics.total_pips.is_finite());
|
||||
// same manifest -> same metrics (C1/C12): two runs are bit-identical
|
||||
assert_eq!(r1.metrics, r2.metrics);
|
||||
assert_eq!(r1.to_json(), r2.to_json());
|
||||
}
|
||||
|
||||
fn samples(values: &[f64]) -> Vec<(Timestamp, f64)> {
|
||||
values
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &v)| (Timestamp(i as i64 + 1), v))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_total_pips_is_last_cumulative_value() {
|
||||
let equity = samples(&[0.0, 5.0, 4.0, 12.0]);
|
||||
let m = summarize(&equity, &[]);
|
||||
assert_eq!(m.total_pips, 12.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_is_zero_on_empty_streams() {
|
||||
let m = summarize(&[], &[]);
|
||||
assert_eq!(m.total_pips, 0.0);
|
||||
assert_eq!(m.max_drawdown, 0.0);
|
||||
assert_eq!(m.bias_sign_flips, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_max_drawdown_is_worst_peak_to_trough() {
|
||||
// peak 10 then trough 5 (drop 5), recovers to 8; worst drop is 5,
|
||||
// not the final drop (10 -> 8 = 2).
|
||||
let equity = samples(&[0.0, 10.0, 5.0, 8.0]);
|
||||
let m = summarize(&equity, &[]);
|
||||
assert_eq!(m.max_drawdown, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_max_drawdown_zero_on_monotonic_curve() {
|
||||
let equity = samples(&[0.0, 1.0, 2.0, 3.0]);
|
||||
let m = summarize(&equity, &[]);
|
||||
assert_eq!(m.max_drawdown, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_sign_flips_counts_signum_changes() {
|
||||
// signum series: + + - 0 - -> flips at +->-, -->0, 0->- = 3.
|
||||
let exposure = samples(&[0.5, 0.5, -0.5, 0.0, -0.5]);
|
||||
let m = summarize(&[], &exposure);
|
||||
assert_eq!(m.bias_sign_flips, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summarize_sign_flips_zero_on_constant_sign() {
|
||||
let exposure = samples(&[0.2, 0.5, 1.0, 0.7]);
|
||||
let m = summarize(&[], &exposure);
|
||||
assert_eq!(m.bias_sign_flips, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn f64_field_projects_the_named_field() {
|
||||
let rows = vec![
|
||||
@@ -468,7 +637,6 @@ mod tests {
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("bias_scale".to_string(), Scalar::f64(1.0)),
|
||||
],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(1), Timestamp(6)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=1.0)".to_string(),
|
||||
@@ -486,7 +654,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
report.to_json(),
|
||||
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["bias_scale",{"F64":1.0}]],"defaults":[],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"bias_sign_flips":1}}"#,
|
||||
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["bias_scale",{"F64":1.0}]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"bias_sign_flips":1}}"#,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -501,7 +669,6 @@ mod tests {
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("bias_scale".to_string(), Scalar::f64(1.0)),
|
||||
],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(1), Timestamp(6)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=1.0)".to_string(),
|
||||
@@ -526,7 +693,6 @@ mod tests {
|
||||
("sma_slow".to_string(), Scalar::i64(4)),
|
||||
("bias_scale".to_string(), Scalar::f64(1.0)),
|
||||
],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(1), Timestamp(6)),
|
||||
seed: 0,
|
||||
broker: "sim-optimal(pip_size=1.0)".to_string(),
|
||||
@@ -540,7 +706,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<RunMetrics> = serde_json::from_str(&json).expect("deserialize RunReport");
|
||||
let back: RunReport = serde_json::from_str(&json).expect("deserialize RunReport");
|
||||
assert_eq!(back, report);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user