Compare commits

8 Commits

Author SHA1 Message Date
claude e7f16c179b fix(test): anchor the knob-lab scaffold under CARGO_TARGET_TMPDIR
built_scale_project() scaffolded ~66 MB (built node crate) into a
pid-suffixed tempdir, so its wipe-before-scaffold never hit a previous
run's copy: one leaked directory per test-binary run. 159 of those
(~10 GB) filled the host's 16 GB /tmp tmpfs to 100%, breaking every
std::env::temp_dir() consumer. A fixed name under CARGO_TARGET_TMPDIR
(real disk, reclaimed by cargo clean) makes the existing wipe reclaim
the previous run — steady state is exactly one directory. Trade-off:
two concurrent cargo-test invocations over the same checkout collide
on the fixed name, the documented pre-existing caveat class for this
suite (#223).

closes #257
2026-07-13 16:33:19 +02:00
claude 3e15fb6269 perf(ingest,cli): derive archive bounds from the monthly file index — probe_window drain retired
probe_window drained a source's entire close column just to learn its
first and last bar timestamp; both live callers (open_real_source and
campaign_window_ms on the campaign trunk) now resolve through
aura-ingest's new archive_extent(): list the symbol's SYM_YYYY_MM.m1
files, load only the boundary months via the existing loader seam, and
walk across gap months forward/backward — O(2 file loads) typical
instead of O(archive). Empty-window refusal semantics are unchanged
(pinned). Two characterization tests captured the resolved windows
against the OLD implementation over the synthetic per-test archive and
stay green unchanged after the swap.

Measured: the probe itself drops 366ms -> 13ms (~28x) on the full
2014-2026 GER40 archive; end-to-end command wall-clock moves only
~2-3% because the sim loop dominates — the fix removes an
O(archive-size) term, not the dominant cost.

closes #252
2026-07-13 16:33:18 +02:00
claude d1b9d2b7e5 perf(cli): synthetic walkforward pre-flight validates axes without running members
blueprint_walkforward_family's pre-flight executed the full first-IS-
window sweep via blueprint_sweep_over and discarded the result, purely
to surface axis errors once before the parallel window fan-out (#177).
validate_axis_grid now drives the sweep terminal's own resolve/arity/
kind checks with a constant placeholder report — same single-sourced
rejection wording, no member run, no roller derivation needed.

Scope correction against the issue text: this path serves only the
synthetic (non---real) walkforward. The --real trunk routes through
verb_sugar into aura-campaign's [Sweep, WalkForward] process, whose
leading full-window sweep is a separate, larger duplication — re-filed
with corrected attribution in the issue's closing comment.

closes #253
2026-07-13 15:50:35 +02:00
claude 1f5c5ad80f fix(cli): resolve a relative paths.data against the project root
Env::data_path() handed paths.data to the data layer verbatim, so a
relative value resolved against the invoking process's cwd — aura run
from a project subdirectory silently read a different archive location
than from the root, while runs/ stayed anchored. Relative values now
join onto the project root, matching runs_root() and the [nodes]
pointer resolution; absolute values and the no-project default pass
through unchanged. RED-first unit test pins the root-anchored
resolution.

closes #254
2026-07-13 15:34:30 +02:00
claude 7abe89cb4f test(cli): synthetic per-test M1 archive — no-window generalize tests go hostless
The two generalize E2E tests whose property is no-window span
resolution (shared-window fallback, intersection semantics) no longer
stream the 6.6 GB host archive: fresh_project_with_data() generates a
deterministic two-symbol M1 archive (SYMA 2024-01..08, SYMB 2024-03..06
strictly inside it, so the intersection differs from symbols[0]'s
window on both bounds) in the exact zip/48-byte-record format
data-server reads, plus geometry sidecars, under the per-test project's
paths.data. Both tests drop their local_data_present gates — they now
run on any host, data mount or not.

~35s and ~50s become 0.74s and 1.14s; the full cli_run binary lands at
~25s (211s at the branch base). Full workspace suite: 247s -> 45.7s.

refs #250
2026-07-13 15:24:00 +02:00
claude bd174d10d9 test(cli): per-test project dirs via a [nodes] pointer — project_lock retired
Every E2E test that mutated the shared demo-project fixture store now
mints its own tempdir project whose 2-line Aura.toml points at the
once-built fixture crate by absolute [nodes] pointer; the runs/ store
follows the project root, so no two tests contend and libtest's
default thread parallelism applies. project_load keeps driving the
fixture directly (the suite's only proof of the inline-crate routing
arm); its single store-mutating test is that binary's only one, so no
lock is needed there either. This also closes the documented
cross-process race window (#223): there is no shared mutable store
left to race on.

Measured on the data-full host: cli_run 211s -> 57.2s (140 tests),
research_docs 21.1s -> 2.1s (73 tests), project_load green, clippy
clean.

refs #250
2026-07-13 15:11:21 +02:00
claude bc6c8fe5b4 build: compile dependencies at opt-level 2 in the dev profile
The E2E suite spawns the debug aura binary, whose wall-clock is
dominated by dependency code (zip inflate of the tick archive, sha2
over the project dylib, serde). Workspace crates stay at opt-level 0
for fast rebuilds and debugging; every pinned float is computed by
workspace code, so the byte-identity anchors are untouched.

refs #250
2026-07-13 14:53:14 +02:00
claude 052cb46274 test(cli): bound three whale E2E windows instead of streaming full archives
The two dissolved-walkforward flag tests assert window-independent
properties (plateau acceptance, per-flag stop defaulting) and now use
the ~135-day window the reproduce e2e already uses. The mc roller-fit
test replaces its full-archive probe sweep with a fixed ~30-day window
inside that same span: the property needs a data-carrying window far
shorter than the 90+30-day roller, not a live-span derivation, and the
archive only grows forward. Measured on the data-full host: 19.6s,
19.6s and 14.5s+ of serialized real-data compute drop to 1.1-1.9s each.

refs #250
2026-07-13 14:52:10 +02:00
507 changed files with 9619 additions and 39111 deletions
-12
View File
@@ -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
View File
@@ -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"
-7
View File
@@ -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]
+3 -8
View File
@@ -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.
+8 -6
View File
@@ -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 }
File diff suppressed because it is too large Load Diff
-37
View File
@@ -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"
-40
View File
@@ -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
-166
View File
@@ -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");
}
}
-42
View File
@@ -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" }
-47
View File
@@ -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"
}
-164
View File
@@ -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);
}
}
-149
View File
@@ -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);
}
}
-69
View File
@@ -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:?}"
);
}
}
-462
View File
@@ -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");
}
}
-457
View File
@@ -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");
}
}
}
-72
View File
@@ -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());
}
}
-86
View File
@@ -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);
}
}
-197
View File
@@ -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");
}
}
-251
View File
@@ -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"
);
}
}
}
-28
View File
@@ -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}");
}
+1 -12
View File
@@ -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
View File
@@ -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 &regimes {
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, &params, cell.window_ms)
}))
};
match called {
Ok(Ok(report)) => report,
Ok(Err(fault)) => {
match runner.run_member(cell, &params, 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(&params, 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(&params, 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, &params, (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
View File
@@ -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, &registry)
.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, &registry)
.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, &registry)
.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,
&registry,
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, &registry)
.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
+55 -420
View File
@@ -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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, 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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let first = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.expect("first run");
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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, &reg, 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, &reg)
.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, &reg, 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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let err = execute("short", &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, 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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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, &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &runner, &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let second = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(), &reg, DEFAULT_PARALLEL_INSTRUMENTS)
let out = execute(CAMPAIGN_ID, &doc, &proc_doc, &strategies(), &FakeRunner::clean(), &reg)
.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(),
&reg,
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(),
&reg,
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(&parallel), "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,
&reg,
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(),
&reg,
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,
&reg,
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(),
&reg,
DEFAULT_PARALLEL_INSTRUMENTS,
)
.expect_err("duplicate instruments collide on family names");
assert!(matches!(err, ExecFault::PipelineShape { .. }));
}
+6 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"}]}}
+1
View File
@@ -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.
File diff suppressed because it is too large Load Diff
-65
View File
@@ -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"
);
}
}
+74 -606
View File
@@ -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, &registry)?;
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
View File
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)"
);
}
}
+2 -2
View File
@@ -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");
+11 -337
View File
@@ -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);
}
}
+13 -67
View File
@@ -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();
+91 -176
View File
@@ -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(&param_names, &raw_space, &raw_signal);
let overrides = crate::campaign_run::raw_bound_overrides_of(&param_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(&reg, &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(&reg, &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(&reg, &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(&reg, &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"
);
}
}
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
-29
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(&reg.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);
}
-125
View File
@@ -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)
);
}
+7 -89
View File
@@ -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();
+3 -14
View File
@@ -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));
+1 -116
View File
@@ -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:?}")
});
}
+13 -15
View File
@@ -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
-140
View File
@@ -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");
}
-43
View File
@@ -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));
}
}
-422
View File
@@ -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());
}
}
-2
View File
@@ -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
+7 -15
View File
@@ -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);
+1 -1
View File
@@ -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
+3 -45
View File
@@ -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);
-4
View File
@@ -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 }
+1 -2
View File
@@ -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};
+5 -469
View File
@@ -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)]
-26
View File
@@ -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 {
+4 -21
View File
@@ -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
+38 -419
View File
@@ -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 }]);
}
}
+31 -289
View File
@@ -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:?}"
);
}
}
+3 -24
View File
@@ -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
+1 -4
View File
@@ -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),
)
File diff suppressed because it is too large Load Diff
+36 -36
View File
@@ -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);
+221 -55
View File
@@ -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