From 5c2ac982bc2f07f6f89f1e8dada8df8362770760 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 21 Jul 2026 05:20:27 +0200 Subject: [PATCH] refactor: extract the member-run recipe into library crates (#295 part 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shell no longer defines what an aura backtest is. Tasks 1-9 of the shell-boundary cycle — structural extraction, behaviour byte-identical: - aura-runner (new; the C28 assembly position): input binding (the C26 module, moved whole), the C1-load-bearing param<->config translators, harness assembly (wrap_r / run_signal_r / run_blueprint_member and the probe/reopen cluster), axis + risk-regime conventions (bind_axes et al.), the campaign family builders + MC guards, reproduce (process::exit -> RunnerError, shell remaps to identical bytes), the measurement-run orchestration, project loading (Env / cdylib load / charter / staleness, moved whole), the coverage gap-walk (deduplicated onto interior_gap_months), and DefaultMemberRunner — the public implementation of aura_campaign::MemberRunner (ex CliMemberRunner). The MemberRunner trait stays in aura-campaign; the column still imports no harness/data-binding machinery. - aura-measurement (new; seeds C28 rung 3): the IcMetrics/IcKey vocabulary + information_coefficient, verbatim incl. serde derives (#294 duplicate-timestamp semantics move as-is, unresolved). - aura-backtest: the pure per-run scaffold (point_from_params, wf_ms_sizes / fit_wf_ms_sizes, intersect_shared_window). - shell residue: main.rs / campaign_run.rs keep argv/dispatch, argv->document translation, and presentation only; walkforward_summary_json_from_reports now calls the public aura_engine::param_stability instead of its inline twin. - worked example (crates/aura-runner/examples/world_member_run.rs): a World program runs a member backtest through the library alone. Held quality findings, adjudicated: the plan's literal pub-visibility list for binding.rs kept (cosmetic); ~20 refusal sites inside aura-runner (family/member/measure/translate) still process::exit — behaviour-identical today, conversion to RunnerError is filed forward (refs #297); rustfmt line-width drift on re-pathed call sites left for a repo-wide fmt decision. Verification: cargo test --workspace green (1471 passed, 0 failed); cargo clippy --workspace --all-targets -D warnings clean. Byte-identity is pinned by the untouched shell E2E suites (cli_run, measure_ic, run_measurement, research_docs, run_refuses_unrunnable_blueprint, tap_recording — zero assertion edits). Remaining in this cycle: full-workspace c28_layering + shell-content check, ledger amendments (C28 assembly position, C25/C14 control-surface consequence, C26 realization note). refs #295 --- Cargo.lock | 36 +- Cargo.toml | 2 + crates/aura-backtest/src/lib.rs | 5 + crates/aura-backtest/src/scaffold.rs | 166 + crates/aura-cli/Cargo.toml | 31 +- crates/aura-cli/src/campaign_run.rs | 1143 +----- crates/aura-cli/src/graph_construct.rs | 58 +- crates/aura-cli/src/main.rs | 3396 +---------------- crates/aura-cli/src/render.rs | 2 +- crates/aura-cli/src/research_docs.rs | 2 +- crates/aura-cli/src/verb_sugar.rs | 26 +- crates/aura-measurement/Cargo.toml | 17 + crates/aura-measurement/src/ic.rs | 224 ++ crates/aura-measurement/src/lib.rs | 9 + crates/aura-runner/Cargo.toml | 32 + crates/aura-runner/build.rs | 62 + .../aura-runner/examples/world_member_run.rs | 43 + crates/aura-runner/src/axes.rs | 249 ++ .../{aura-cli => aura-runner}/src/binding.rs | 26 +- crates/aura-runner/src/coverage.rs | 161 + crates/aura-runner/src/family.rs | 669 ++++ crates/aura-runner/src/lib.rs | 35 + crates/aura-runner/src/measure.rs | 135 + crates/aura-runner/src/member.rs | 1649 ++++++++ .../{aura-cli => aura-runner}/src/project.rs | 0 crates/aura-runner/src/reproduce.rs | 234 ++ crates/aura-runner/src/runner.rs | 756 ++++ crates/aura-runner/src/translate.rs | 249 ++ crates/aura-vocabulary/tests/c28_layering.rs | 1 + 29 files changed, 5048 insertions(+), 4370 deletions(-) create mode 100644 crates/aura-backtest/src/scaffold.rs create mode 100644 crates/aura-measurement/Cargo.toml create mode 100644 crates/aura-measurement/src/ic.rs create mode 100644 crates/aura-measurement/src/lib.rs create mode 100644 crates/aura-runner/Cargo.toml create mode 100644 crates/aura-runner/build.rs create mode 100644 crates/aura-runner/examples/world_member_run.rs create mode 100644 crates/aura-runner/src/axes.rs rename crates/{aura-cli => aura-runner}/src/binding.rs (95%) create mode 100644 crates/aura-runner/src/coverage.rs create mode 100644 crates/aura-runner/src/family.rs create mode 100644 crates/aura-runner/src/lib.rs create mode 100644 crates/aura-runner/src/measure.rs create mode 100644 crates/aura-runner/src/member.rs rename crates/{aura-cli => aura-runner}/src/project.rs (100%) create mode 100644 crates/aura-runner/src/reproduce.rs create mode 100644 crates/aura-runner/src/runner.rs create mode 100644 crates/aura-runner/src/translate.rs diff --git a/Cargo.lock b/Cargo.lock index 3d3be77..00dcd21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -160,19 +160,19 @@ dependencies = [ "aura-core", "aura-engine", "aura-ingest", + "aura-measurement", "aura-registry", "aura-research", + "aura-runner", "aura-std", "aura-strategy", "aura-vocabulary", "clap", "data-server", "libc", - "libloading", "serde", "serde_json", "sha2", - "toml", "zip", ] @@ -239,6 +239,15 @@ dependencies = [ "chrono-tz", ] +[[package]] +name = "aura-measurement" +version = "0.1.0" +dependencies = [ + "aura-analysis", + "aura-core", + "serde", +] + [[package]] name = "aura-registry" version = "0.1.0" @@ -265,6 +274,29 @@ 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", +] + [[package]] name = "aura-std" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 624b42c..7aee779 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ members = [ "crates/aura-std", "crates/aura-vocabulary", "crates/aura-market", + "crates/aura-measurement", "crates/aura-strategy", "crates/aura-backtest", "crates/aura-engine", @@ -22,6 +23,7 @@ members = [ "crates/aura-ingest", "crates/aura-registry", "crates/aura-research", + "crates/aura-runner", "crates/aura-campaign", "crates/aura-bench", ] diff --git a/crates/aura-backtest/src/lib.rs b/crates/aura-backtest/src/lib.rs index 5d7a66c..e96ae1e 100644 --- a/crates/aura-backtest/src/lib.rs +++ b/crates/aura-backtest/src/lib.rs @@ -9,6 +9,7 @@ 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 @@ -23,6 +24,10 @@ 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 diff --git a/crates/aura-backtest/src/scaffold.rs b/crates/aura-backtest/src/scaffold.rs new file mode 100644 index 0000000..c7306a8 --- /dev/null +++ b/crates/aura-backtest/src/scaffold.rs @@ -0,0 +1,166 @@ +//! The pure per-run scaffold: bootstrap-adjacent arithmetic that has no +//! dependency on the CLI shell — reconstructing a bootstrap point from +//! recorded params, sizing the real walk-forward roller, and intersecting +//! per-symbol data windows. Relocated verbatim from `aura-cli::main` (#295) +//! so it is reachable (and unit-testable) without the shell. + +use aura_core::{Cell, ParamSpec, Scalar}; + +/// Reconstruct a member's bootstrap point from its recorded named params — the inverse +/// of `zip_params(space, point)`. Walks the reloaded signal's `param_space` in order +/// (deterministic for the same blueprint) and reads each knob's value from the manifest. +/// +/// `Err` carries the exact refusal message a manifest missing a param the reloaded +/// space expects has always produced (corrupted-on-disk data): this crate has no +/// process-exit register of its own (#295 — a pure library function reports a +/// refusal, it does not end the process), so the caller is the one that turns this +/// into `aura:` + exit 1, byte-identical to before. +pub fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Result, 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"); + } +} diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index e20ae79..aae3f6c 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -12,9 +12,6 @@ path = "src/main.rs" [dependencies] aura-core = { path = "../aura-core" } aura-engine = { path = "../aura-engine" } -# aura-composites: the RiskExecutor / vol_stop composite-builders the -# r-sma harness wires (kept out of aura-engine so the engine stays domain-free). -aura-composites = { path = "../aura-composites" } aura-registry = { path = "../aura-registry" } aura-research = { path = "../aura-research" } # aura-campaign: campaign-execution semantics (preflight, cell loop, stage @@ -24,9 +21,15 @@ aura-campaign = { path = "../aura-campaign" } aura-std = { path = "../aura-std" } aura-strategy = { path = "../aura-strategy" } aura-backtest = { path = "../aura-backtest" } -aura-analysis = { path = "../aura-analysis" } 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" } @@ -44,13 +47,6 @@ serde_json = { workspace = true } # EPIPE. The standard vetted crate for signal disposition, already transitive in # Cargo.lock; admitted under the per-case dependency policy. libc = "0.2" -# sha2: the run's topology_hash (#158) is the SHA256 of the canonical signal -# blueprint serialization. A vetted RustCrypto crate (per-case C16 review, -# SHA256 user-settled); lives in the research-side CLI, off the frozen engine -# (invariant 8). -sha2 = "0.10" -libloading = "0.8" -toml = "0.8" # clap: the vetted standard argument parser. Adopted (C16 per-case review) to # replace the hand-rolled argv parser + ten duplicated help surfaces with one # declarative source, giving scoped --help, --version, --flag=value, and @@ -64,3 +60,16 @@ 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-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" diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index c232e9f..34c3d1f 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -1,113 +1,42 @@ //! `aura campaign run` — the driver that turns a stored campaign document into //! a realized run-set (#198). The execution *semantics* (preflight, cell loop, -//! stage sequencing, selection, registry writes) live in `aura-campaign`; this -//! module owns what is CLI-specific: target resolution (file sugar vs content -//! id), the project + referential gates, the [`MemberRunner`] implementation -//! over the shipped loaded-blueprint machinery (`wrap_r` reduce-mode member -//! runs via `run_blueprint_member` + `M1FieldSource` windowed real-data -//! binding), fault prose (`exec_fault_prose` lives HERE, beside its consumer, -//! not in `research_docs.rs` — it phrases `aura_campaign` types the doc-tier -//! module deliberately does not import), and stdout/stderr emission. +//! stage sequencing, selection, registry writes) live in `aura-campaign`; the +//! [`MemberRunner`] implementation over the shipped loaded-blueprint machinery +//! and the trace-persistence disk-layout writers live in `aura-runner`'s +//! `runner` module (#295 Task 7 — `DefaultMemberRunner` + +//! `persist_campaign_traces`). This module owns what is CLI-specific: target +//! resolution (file sugar vs content id), the project + referential gates, +//! fault prose (`exec_fault_prose` lives HERE, beside its consumer, not in +//! `research_docs.rs` — it phrases `aura_campaign` types the doc-tier module +//! deliberately does not import), and stdout/stderr emission. //! -//! Root items (`blueprint_axis_probe`, `run_blueprint_member`, -//! `family_member_line`) are reached via `crate::` — the `graph_construct` -//! submodule idiom: main.rs is the crate root, so its private items are -//! visible to child modules without promotion. +//! The member-run recipe (`blueprint_axis_probe`, `run_blueprint_member`, +//! `wrap_r`, …) and the axis/translate helpers live in `aura-runner` (#295) +//! and are reached by plain name via the `use` imports below. Remaining +//! main.rs-root items (`family_member_line`, …) are still reached via +//! `crate::` — the `graph_construct` submodule idiom: main.rs is the crate +//! root, so its private items are visible to child modules without promotion. -use std::collections::{BTreeMap, HashSet}; use std::num::NonZeroUsize; use std::path::{Path, PathBuf}; -use std::sync::{mpsc, Arc}; +use std::sync::Arc; -use aura_backtest::{summarize, summarize_r, RunReport}; -use aura_campaign::{CampaignOutcome, CellOutcome, CellSpec, ExecFault, MemberFault, MemberRunner}; -use aura_core::{Cell, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp}; -use aura_engine::{ - blueprint_from_json, f64_field, ColumnarTrace, Composite, - FamilySelection, -}; -use aura_ingest::{instrument_geometry, open_columns_window, unix_ms_to_epoch_ns}; -use aura_registry::{CampaignRunRecord, WriteKind}; +use aura_campaign::ExecFault; +use aura_core::Scalar; +use aura_engine::FamilySelection; +use aura_registry::CampaignRunRecord; use aura_research::{ campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign, - validate_process, CampaignDoc, CostSpec, DocRef, + validate_process, CampaignDoc, DocRef, }; -use aura_strategy::{CarryCost, ConstantCost, VolSlippageCost}; -use crate::project::Env; +use aura_runner::axes::is_content_id; +use aura_runner::project::Env; +use aura_runner::runner::{persist_campaign_traces, DefaultMemberRunner}; use crate::research_docs::{ doc_error_prose, doc_fault_prose, fault_block, parse_valid_campaign, ref_fault_prose, }; -/// A bare store address: exactly 64 lowercase hex chars (the content-id key -/// shape). `pub(crate)`: `graph_construct`'s `--params ` resolution -/// (#196) shares this exact predicate rather than re-inlining it, so the two -/// FILE-or-id surfaces cannot drift apart on the id shape a second time. -pub(crate) fn is_content_id(s: &str) -> bool { - s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) -} - -/// The one regime->StopRule binding, shared by `CliMemberRunner::run_member` -/// and `persist_campaign_traces`'s drift-alarm re-run (#219): `None` is the -/// default vol-stop, `Some(RiskRegime::Vol { .. })` binds that regime's own -/// params. Single-sourced so the persist-side re-run structurally cannot -/// diverge from the run-side binding again (the #219 divergence class). -fn stop_rule_for_regime(regime: Option) -> aura_composites::StopRule { - match regime { - None => aura_composites::StopRule::Vol { - length: crate::R_SMA_STOP_LENGTH, - k: crate::R_SMA_STOP_K, - }, - Some(aura_research::RiskRegime::Vol { length, k }) => { - aura_composites::StopRule::Vol { length, k } - } - Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => { - aura_composites::StopRule::VolTf { period_minutes, length, k } - } - } -} - -/// The one CostSpec -> cost-node binding (#234), beside its risk sibling -/// `stop_rule_for_regime` and shared the same way: `CliMemberRunner::run_member` -/// (via `run_blueprint_member`) and `persist_campaign_traces`'s drift-alarm -/// re-run both bind through here, so the persist-side re-run structurally -/// cannot diverge from the run-side cost model (the #219 divergence class, -/// cost edition). The bound knob names are the builders' own `ParamSpec` names -/// — the `CostSpec` serde vocabulary conforms to them. -pub(crate) fn cost_nodes_for(specs: &[CostSpec], instrument: &str) -> Vec { - specs - .iter() - .map(|s| { - let (knob, v) = cost_knob(s, instrument); - match s { - CostSpec::Constant { .. } => ConstantCost::builder().bind(knob, Scalar::f64(v)), - CostSpec::VolSlippage { .. } => VolSlippageCost::builder().bind(knob, Scalar::f64(v)), - CostSpec::Carry { .. } => CarryCost::builder().bind(knob, Scalar::f64(v)), - } - }) - .collect() -} - -/// The one CostSpec-variant -> (knob name, value) mapping, shared by -/// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp -/// (main.rs): the stamp key must equal the bind key for reproduce to -/// re-derive a `CostSpec` from a stored manifest, so both sites read the -/// name off this single function rather than each carrying its own literal. -pub(crate) fn cost_knob(spec: &CostSpec, instrument: &str) -> (&'static str, f64) { - let (knob, value) = match spec { - CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade), - CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult), - CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle), - }; - let v = value.resolve(instrument).unwrap_or_else(|| { - // Unreachable after intrinsic validation (map keys ≡ instruments); - // refuse loudly rather than charging 0 silently if it ever surfaces. - eprintln!("aura: cost {knob}: no entry for instrument {instrument}"); - std::process::exit(1); - }); - (knob, v) -} - /// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed /// (stage index + block id), the `doc_fault_prose`/`ref_fault_prose` register. pub(crate) fn exec_fault_prose(f: &ExecFault) -> String { @@ -179,290 +108,6 @@ struct CampaignRunLine<'a> { campaign_run: &'a CampaignRunRecord, } -/// Strip the wrapper's exactly-one leading node segment from a WRAPPED param -/// path, yielding the RAW campaign-axis name the doc speaks (#203): the bind -/// convention prefixes a strategy's own param paths with one root-composite -/// segment before they enter the runnable (wrapped) param space, so the raw -/// axis is everything after that segment's dot. Returns `name` unchanged if -/// there is no dot (defensive; every wrapped param space produced by -/// `blueprint_axis_probe` has one). This is the single definition of "the -/// wrapper segment" — [`raw_matches_wrapped`] is built on it so a wrap-depth -/// change cannot desync the two directions of the #203 convention. -pub(crate) fn wrapped_to_raw_axis(name: &str) -> &str { - name.split_once('.').map(|(_, rest)| rest).unwrap_or(name) -} - -/// Does a RAW campaign-axis name (the doc's own namespace) address this ONE -/// wrapped param path? True for an exact match (an unwrapped param, if one -/// ever exists) or when stripping the wrapper's one node segment -/// ([`wrapped_to_raw_axis`]) yields exactly `raw`. Inverse of -/// `wrapped_to_raw_axis`, co-located and built on it (#203): the two are the -/// only two places the wrapper-segment convention is expressed. -pub(crate) fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool { - wrapped == raw || wrapped_to_raw_axis(wrapped) == raw -} - -/// Suffix-join each raw campaign axis onto exactly one wrapped param -/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one -/// node segment yields raw), then require every wrapped slot to be covered. -/// Pure and independent of the env/data seam so the three fault arms are -/// unit-testable without a project, a store, or a loaded blueprint. -pub(crate) fn bind_axes( - space: &[ParamSpec], - strategy_id: &str, - params: &[(String, Scalar)], -) -> Result, MemberFault> { - let mut slots: Vec> = vec![None; space.len()]; - for (raw, value) in params { - let hits: Vec = space - .iter() - .enumerate() - .filter(|(_, p)| raw_matches_wrapped(raw, &p.name)) - .map(|(i, _)| i) - .collect(); - match hits.as_slice() { - [i] => slots[*i] = Some(*value), - [] => { - return Err(MemberFault::Bind(format!( - "axis \"{raw}\" matches no open param of the wrapped strategy {strategy_id}" - ))); - } - _ => { - let names: Vec<&str> = hits.iter().map(|&i| space[i].name.as_str()).collect(); - return Err(MemberFault::Bind(format!( - "axis \"{raw}\" is ambiguous in the wrapped param space of \ - strategy {strategy_id}: matches {}", - names.join(", ") - ))); - } - } - } - let mut point: Vec = Vec::with_capacity(space.len()); - for (spec, slot) in space.iter().zip(&slots) { - match slot { - Some(v) => point.push(v.cell()), - None => { - // Speak the RAW campaign-axis namespace (the doc's own): the - // wrapped space prefixes the signal's params with one node - // segment, so the raw path is everything after it (#203). - let raw = wrapped_to_raw_axis(&spec.name); - return Err(MemberFault::Bind(format!( - "open param \"{raw}\" of strategy {strategy_id} is bound by no \ - campaign axis (wrapped path: {})", - spec.name - ))); - } - } - } - Ok(point) -} - -/// The #246 override subset of one member's campaign-axis names (already RAW -/// — a campaign document speaks the raw namespace, #203, hence the `raw_` -/// name prefix distinguishing this from main.rs's WRAPPED-input siblings): -/// every name that matches no entry of the un-reopened wrapped OPEN -/// `open_space` but names a BOUND param of `raw_signal` (`bound_param_space()`'s -/// `.name` is already path-qualified in strategy/RAW coordinates, exactly -/// like a raw campaign axis — no wrap-prefix stripping needed, unlike -/// `wrapped_bound_overrides_of`/`override_paths` in main.rs, which start from -/// WRAPPED CLI axis names). Silent like `wrapped_bound_overrides_of` (skips a -/// name matching neither space), NOT the validating `override_paths`: -/// `run_member` runs inside a sweep worker and must never call -/// `std::process::exit` — an unmatched name still surfaces as `bind_axes`'s -/// own named `MemberFault::Bind` once the (reopened) space below is built. -pub(crate) fn raw_bound_overrides_of( - param_names: &[String], - open_space: &[ParamSpec], - raw_signal: &Composite, -) -> Vec { - let bound: HashSet = - raw_signal.bound_param_space().into_iter().map(|b| b.name).collect(); - param_names - .iter() - .filter(|raw| { - !open_space.iter().any(|p| raw_matches_wrapped(raw, &p.name)) - && bound.contains(raw.as_str()) - }) - .cloned() - .collect() -} - -/// The CLI's harness/data binding seam for `aura_campaign::execute`: members -/// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode -/// via `crate::run_blueprint_member`) over windowed real M1 close bars -/// (`M1FieldSource::open_window` — the ms→ns crossing happens at exactly this -/// seam, via `unix_ms_to_epoch_ns`). All refusals are member faults for the -/// library to surface; never a process exit inside a sweep worker. -struct CliMemberRunner<'a> { - env: &'a Env, - server: Arc, - /// The campaign's `data.bindings` overrides (role -> column), threaded - /// into per-member binding resolution (#231: rebind per campaign without - /// touching the blueprint's content id). - bindings: BTreeMap, - /// The campaign's cost model (#234), threaded into every member run via - /// `cost_nodes_for` (empty = zero costs, today's graph). - cost: Vec, -} - -impl MemberRunner for CliMemberRunner<'_> { - fn run_member( - &self, - cell: &CellSpec, - params: &[(String, Scalar)], - window_ms: (i64, i64), - ) -> Result { - // The member's blueprint, loaded RAW (no re-open yet) — the SAME - // reload the probe below wraps, so `bound_param_space()` here and - // the probe's `param_space()` after `blueprint_axis_probe_reopened` - // resolve against one topology. - let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t)) - .expect("stored blueprint passed the referential gate; reload is infallible"); - - // The un-reopened wrapped OPEN space (#246) — the SAME probe the - // sweep verbs resolve against (single-sourced in main.rs; identical - // `false, true, None` wrap) — derives which of this cell's RAW axis - // names re-open a bound param before the real, reopened probe/reload - // are built: probe and member reload must re-open identically so - // `params` resolves against one space. - let raw_space = crate::blueprint_axis_probe(&cell.blueprint_json, self.env).param_space(); - let param_names: Vec = params.iter().map(|(n, _)| n.clone()).collect(); - let overrides = raw_bound_overrides_of(¶m_names, &raw_space, &signal); - let space = - crate::blueprint_axis_probe_reopened(&cell.blueprint_json, self.env, &overrides) - .param_space(); - let point = bind_axes(&space, &cell.strategy_id, params)?; - let signal = crate::reopen_all(signal, &overrides); - - // The member's resolved input binding (campaign data.bindings - // overrides win over name defaults). A refusal is a member fault, - // never a process exit. - let binding = - crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &self.bindings) - .map_err(MemberFault::Bind)?; - - // Real windowed data — geometry BEFORE bar data (the shipped pre-data - // refusal order of `open_real_source`), both as member faults. - let geo = instrument_geometry(&self.server, &cell.instrument).ok_or_else(|| { - MemberFault::Run(format!( - "no recorded geometry for symbol '{}' at {} — refusing to run a \ - real instrument with a guessed pip", - cell.instrument, - self.env.data_path() - )) - })?; - let from = unix_ms_to_epoch_ns(window_ms.0); - let to = unix_ms_to_epoch_ns(window_ms.1); - // One source per resolved binding column, canonical order (the same - // order `wrap_r` declares the roles in — the shared plan). - let sources = match open_columns_window( - &self.server, - &cell.instrument, - Some(from), - Some(to), - &binding.columns(), - ) { - Some(s) => s, - // No archived file overlaps the window at all. - None => { - return Err(MemberFault::NoData { - instrument: cell.instrument.clone(), - window_ms, - }); - } - }; - // A window that overlaps a file but holds zero matching bars yields - // sources whose first peek is None (open_window's documented contract) - // — the same no-data condition (all columns decode the same bars, so - // probing the first source covers the set). - if aura_engine::Source::peek(sources[0].as_ref()).is_none() { - return Err(MemberFault::NoData { - instrument: cell.instrument.clone(), - window_ms, - }); - } - - // The shipped member recipe (wrap, bind, run, summarize): - // `run_blueprint_member` verbatim. Seed 0 (seed-free real-data runs), - // topology_hash = the strategy's content id, project provenance - // stamped inside the helper. - let stop = stop_rule_for_regime(cell.regime); - let mut report = crate::run_blueprint_member( - signal, - &point, - &space, - sources, - (from, to), - 0, - geo.pip_size, - &cell.strategy_id, - self.env, - stop, - &binding, - &self.cost, - &cell.instrument, - ); - report.manifest.instrument = Some(cell.instrument.clone()); - Ok(report) - } - - /// #272: derive the cell's archive coverage from the #264 primitives — - /// `None` when the archive has no file for the instrument at all (the - /// `NoData` member fault's job, not coverage) or when the effective - /// evaluated span covers the requested window exactly with no interior - /// gap month. One call per cell (not per member): coverage is a property - /// of the cell's (instrument, window), never a swept param point. - fn window_coverage(&self, cell: &CellSpec) -> Option { - let data_path = PathBuf::from(self.env.data_path()); - let months = aura_ingest::list_m1_months(&data_path, &cell.instrument); - if months.is_empty() { - return None; // no archive at all is the NoData fault's job, not coverage - } - let (eff_from_ts, eff_to_ts) = aura_ingest::archive_extent( - &self.server, - &data_path, - &cell.instrument, - Some(cell.window_ms.0), - Some(cell.window_ms.1), - )?; - let eff_from = aura_ingest::epoch_ns_to_unix_ms(eff_from_ts); - let eff_to = aura_ingest::epoch_ns_to_unix_ms(eff_to_ts); - let gap_months = interior_gap_months(&months, cell.window_ms); - if eff_from == cell.window_ms.0 && eff_to == cell.window_ms.1 && gap_months.is_empty() { - return None; // fully covered — nothing to annotate - } - Some(aura_registry::CellCoverage { - effective_from_ms: eff_from, - effective_to_ms: eff_to, - gap_months, - }) - } -} - -/// The whole `(year, month)`s missing INSIDE `months`' own span (mirrors the -/// interior-gap walk `data_coverage_report`, main.rs, performs over a full -/// archive listing) that also fall inside `window_ms` (Unix-ms) — one -/// `"YYYY-MM"` entry per missing month, never a collapsed range: the -/// registry's `CellCoverage::gap_months` is a flat list an aggregate counts -/// directly. `months` is assumed sorted ([`aura_ingest::list_m1_months`]'s own -/// contract). -fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec { - let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0); - let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1); - let mut out = Vec::new(); - for pair in months.windows(2) { - let (prev, next) = (pair[0], pair[1]); - let mut ym = crate::next_year_month(prev); - while ym != next { - if ym >= from_ym && ym <= to_ym { - out.push(crate::fmt_year_month(ym)); - } - ym = crate::next_year_month(ym); - } - } - out -} - /// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated /// member/selection lines + the always-on final `campaign_run` record line). /// `MemberLinesOnly` is dissolved-verb sugar: the generated document's @@ -630,12 +275,12 @@ pub(crate) fn run_campaign_returning( strategies.push((content_id_of(&canonical), canonical)); } - let runner = CliMemberRunner { + let runner = DefaultMemberRunner::new( env, - server: Arc::new(data_server::DataServer::new(env.data_path())), - bindings: campaign.data.bindings.clone(), - cost: campaign.cost.clone(), - }; + Arc::new(data_server::DataServer::new(env.data_path())), + campaign.data.bindings.clone(), + campaign.cost.clone(), + ); let outcome = aura_campaign::execute( campaign_id, &campaign, @@ -647,7 +292,7 @@ pub(crate) fn run_campaign_returning( ) .map_err(|f| exec_fault_prose(&f))?; - Ok(CampaignRun { outcome, campaign, strategies, server: runner.server }) + Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() }) } /// Emit an executed campaign's results per `presentation`: the zero-survivor @@ -787,406 +432,9 @@ fn cell_fault_kind_label(kind: aura_registry::CellFaultKind) -> &'static str { } } -/// Which drained wrap-convention channel a requested tap persists from on a -/// campaign trace re-run. The routing follows the wrap-convention channels: -/// equity <- the SimBroker equity recorder (tx_eq), exposure <- the Bias -/// recorder (tx_ex), r_equity <- the cum_realized_r + unrealized_r recorder -/// (tx_req), net_r_equity <- the cost leg's LinComb(4) net-curve recorder -/// (tx_net, #234) — produced only when the campaign document carries a cost -/// block (the caller's producibility check). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum TapChannel { - Equity, - Exposure, - REquity, - Net, -} - -/// Route one requested tap of the closed vocabulary to its drained channel; -/// `None` marks a name outside the vocabulary (unreachable — `validate_campaign` -/// refuses it — and mapped defensively rather than guessed at). -pub(crate) fn tap_channel(tap: &str) -> Option { - // The emit_vocabulary debug_assert's twin: every vocabulary tap must be - // routable here — a future fifth vocabulary entry fails loudly instead of - // silently skipping. - debug_assert!( - aura_research::tap_vocabulary() - .iter() - .all(|t| matches!(*t, "equity" | "exposure" | "r_equity" | "net_r_equity")), - "tap_vocabulary drifted from the channels campaign_run routes" - ); - match tap { - "equity" => Some(TapChannel::Equity), - "exposure" => Some(TapChannel::Exposure), - "r_equity" => Some(TapChannel::REquity), - "net_r_equity" => Some(TapChannel::Net), - _ => None, - } -} - -/// The content-derived on-disk key of one campaign cell under -/// `traces//` — the `member_key` discipline (content, never a -/// runtime ordinal): strategy content-id prefix + instrument + -/// doc-positional window ordinal, sanitized to one portable path component. -/// `regime_ordinal` (#219/#212) discriminates two risk regimes over the same -/// (strategy, instrument, window) cell so their traces never collide: the -/// default regime (ordinal 0) keeps the pre-#219 key unchanged (no stored -/// trace dir is renamed by this change), a non-default regime appends -/// `-r{ordinal}`. -fn campaign_cell_key( - strategy: &str, - instrument: &str, - window_ordinal: usize, - regime_ordinal: usize, -) -> String { - let strategy8 = strategy.get(..8).unwrap_or(strategy); - let base = format!("{strategy8}-{instrument}-w{window_ordinal}"); - let base = - if regime_ordinal == 0 { base } else { format!("{base}-r{regime_ordinal}") }; - crate::sanitize_component(&base) -} - -/// The per-member subdirectory key under a swept cell's trace dir (#224): -/// the member's own manifest params label — the exact `"name=value, ..."` -/// join `aura reproduce` prints — sanitized for filesystem use (a raw label -/// carries `=`/`, ` which `sanitize_component` maps to `_`). The member's -/// ordinal into the family is the fallback when the label is empty (a closed -/// blueprint / a monte-carlo seed member carries no tuning params). -fn member_trace_key(report: &RunReport, ordinal: usize) -> String { - let label = report - .manifest - .params - .iter() - .map(|(n, v)| format!("{n}={}", crate::render_value(v))) - .collect::>() - .join(", "); - let key = if label.is_empty() { ordinal.to_string() } else { label }; - crate::sanitize_component(&key) -} - -/// The per-cell member fan-out (#224): a nominee cell writes its one trace -/// directly at `/` (`None` subdir key, unchanged since 0109); a -/// no-nominee cell with a completed terminal family (the selection-free sweep -/// shape) writes every one of that family's members under its own -/// `//` subdirectory instead of silently dropping every -/// member but a (non-existent) nominee. An empty return (no nominee AND no -/// non-empty terminal family) is the caller's cue to skip the cell loudly. -/// Pure over the executed outcome's own `CellOutcome`, so it is unit-testable -/// without booting a real archive run. -fn cell_member_fanout(cell_out: &CellOutcome) -> Vec<(Option, &RunReport)> { - match &cell_out.nominee { - Some((_, nominee_report)) => vec![(None, nominee_report)], - None => match cell_out.families.last() { - Some(fam) if !fam.reports.is_empty() => fam - .reports - .iter() - .enumerate() - .map(|(i, r)| (Some(member_trace_key(r, i)), r)) - .collect(), - _ => Vec::new(), - }, - } -} - -/// Persist the requested `persist_taps` for every cell under -/// `traces///` (0109, #201 d5; #224): a cell with a -/// nominee (walkforward/generalize/mc — selection-bearing pipelines) writes -/// its single trace directly at `/`, unchanged since 0109. A cell -/// with NO nominee but a completed terminal family (a selection-free sweep, -/// #224's headline: "each swept member's tap series") writes EVERY member of -/// that family under its own `//` subdirectory — never -/// narrowing to one nominated member, which would silently drop the others -/// the sweep actually produced. Each written member is independently re-run -/// once in non-reduce trace mode over its own recorded `manifest.window`, -/// asserting the re-run METRICS equal the recorded member metrics (the C1 -/// drift alarm — manifest fields are fresh-context and not compared), and -/// writes the requested-AND-producible taps through the sweep verbs' -/// member-dir mechanism: a slash-joined `"{name}/{key}"` handed to -/// `TraceStore::write` (the slash-joined member-dir layout the sweep verbs -/// established). The written index manifest is the RECORDED -/// member's — the trace documents that run's provenance, not a re-derived -/// fresh-context one. Loud stderr per no-candidate cell (neither a nominee -/// nor a non-empty terminal family) and ONCE per unproducible requested tap -/// (producibility is run-configuration-level, not per-cell); one summary -/// line at the end. Every `Err` is a refusal `campaign_cmd` renders as -/// `aura: {msg}` + exit 1. -pub(crate) fn persist_campaign_traces( - trace_name: &str, - taps: &[String], - outcome: &CampaignOutcome, - campaign: &CampaignDoc, - strategies: &[(String, String)], - server: &Arc, - env: &Env, -) -> Result<(), String> { - let store = env.trace_store(); - store - .ensure_name_free(trace_name, WriteKind::Family) - .map_err(|e| e.to_string())?; - - // Requested ∩ producible, in request order. `net_r_equity` is producible - // exactly when the document carries a cost block (#234); the skip notice - // names the remedy — the tap is optional presentation, not a refusal. - // When nothing producible was requested, no member dir is written and the - // summary honestly reports 0 tap(s). - let mut routed: Vec<(&str, TapChannel)> = Vec::new(); - for tap in taps { - match tap_channel(tap) { - Some(TapChannel::Net) if campaign.cost.is_empty() => eprintln!( - "aura: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped" - ), - Some(ch) => routed.push((tap.as_str(), ch)), - None => eprintln!("aura: tap \"{tap}\" is not produced by this run; skipped"), - } - } - - // `outcome.cells` is index-aligned with `outcome.record.cells` by - // construction: `execute` pushes both in the same cell-loop iteration. - let mut persisted_cells = 0usize; - for (cell_rec, cell_out) in outcome.record.cells.iter().zip(&outcome.cells) { - // A nominee cell writes its one trace directly at `/` - // (`None` subdir key, unchanged since 0109); a no-nominee cell with a - // completed terminal family (the selection-free sweep shape, #224) - // writes every one of that family's members under its own - // `//` subdirectory instead of silently - // dropping every member but a (non-existent) nominee. - let members = cell_member_fanout(cell_out); - if members.is_empty() { - eprintln!( - "aura: cell {}/{}/[{}, {}]: no nominee; no traces persisted", - cell_rec.strategy, cell_rec.instrument, cell_rec.window_ms.0, cell_rec.window_ms.1 - ); - continue; - } - if routed.is_empty() { - continue; - } - - // The cell key is doc-derived: the window ordinal is the POSITION - // of the cell's window in campaign.data.windows (mirroring the - // family-name convention), never a loop counter re-derived here. - let window_ordinal = campaign - .data - .windows - .iter() - .position(|w| (w.from_ms, w.to_ms) == cell_rec.window_ms) - .ok_or_else(|| { - format!( - "cell window [{}, {}] is not one of campaign.data.windows", - cell_rec.window_ms.0, cell_rec.window_ms.1 - ) - })?; - let cell_key = campaign_cell_key( - &cell_rec.strategy, - &cell_rec.instrument, - window_ordinal, - cell_rec.regime_ordinal, - ); - - // The cell's blueprint: the run's own index-aligned resolution, by - // content id (exactly the bytes the executor's members ran). - let (_, blueprint_json) = strategies - .iter() - .find(|(id, _)| *id == cell_rec.strategy) - .ok_or_else(|| { - format!( - "cell strategy {} is not among the run's resolved strategies", - cell_rec.strategy - ) - })?; - // The #246 override set (silent variant, `reproduce_family_in`'s - // recipe): re-derived from the cell's own recorded manifest param - // names, against a raw probe space + raw strategy load — the members - // of one cell share the same knob NAMES (only values differ), so this - // is safe to compute once, cell-invariant, from the first member. - let raw_space = crate::blueprint_axis_probe(blueprint_json, env).param_space(); - let raw_signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t)) - .expect("stored blueprint passed the referential gate; reload is infallible"); - let recorded: Vec = members - .first() - .map(|(_, r)| r.manifest.params.iter().map(|(n, _)| n.clone()).collect()) - .unwrap_or_default(); - let overrides = crate::wrapped_bound_overrides_of(&recorded, &raw_space, &raw_signal); - // Cell-invariant across every member of this cell (the wrapped param - // space depends only on the cell's blueprint; the resolved geometry - // only on the cell's instrument) — computed once here rather than - // redundantly inside the member loop below. - let space = - crate::blueprint_axis_probe_reopened(blueprint_json, env, &overrides).param_space(); - // Same resolved pip the member ran at (`CliMemberRunner::run_member`'s - // `geo.pip_size`), else the C1 drift-alarm equality below would trip - // spuriously on a re-run computed at a different (default) pip. - let geo = instrument_geometry(server, &cell_rec.instrument).ok_or_else(|| { - format!( - "no recorded geometry for symbol '{}' at {} — refusing to re-run a \ - real instrument with a guessed pip", - cell_rec.instrument, - env.data_path() - ) - })?; - - for (member_subdir, member_report) in members.iter().cloned() { - // Re-run the member, non-reduce: the SAME member the executor ran - // (same wrapped space, same params, same window, seed-free real - // data), mirroring `CliMemberRunner::run_member` with the reduce - // fold off so the per-cycle tap streams exist. The window is the - // member report's own `manifest.window` — already epoch-ns - // (`run_blueprint_member` stamped the post-seam bounds), so no - // second ms->ns crossing here. - let point = crate::point_from_params(&space, &member_report.manifest.params); - let (from, to) = member_report.manifest.window; - let no_data = || { - format!( - "no data for instrument {} in the member window [{}, {}] (epoch-ns)", - cell_rec.instrument, from.0, to.0 - ) - }; - let signal = crate::reopen_all( - blueprint_from_json(blueprint_json, &|t| env.resolve(t)) - .expect("stored blueprint passed the referential gate; reload is infallible"), - &overrides, - ); - // Campaign data.bindings overrides win over name defaults — the - // SAME resolution the member ran under, so the C1 drift alarm - // compares like with like. - let binding = crate::binding::resolve_binding( - &cell_rec.strategy, - signal.input_roles(), - &campaign.data.bindings, - )?; - let sources = open_columns_window( - server, - &cell_rec.instrument, - Some(from), - Some(to), - &binding.columns(), - ) - .ok_or_else(no_data)?; - if aura_engine::Source::peek(sources[0].as_ref()).is_none() { - return Err(no_data()); - } - // The cell's OWN regime (#219/#212), not the hardcoded default: the - // recorded member ran under `cell_rec.regime`, bound through the same - // `stop_rule_for_regime` helper `CliMemberRunner::run_member` uses, so - // the two sites cannot drift apart again (the #219 divergence class) - // and the re-run binds the same stop the C1 drift alarm below relies on. - let stop = stop_rule_for_regime(cell_rec.regime); - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, rx_req) = mpsc::channel(); - // The campaign's OWN cost model (#234), bound the same way - // `CliMemberRunner::run_member` binds it (via `cost_nodes_for`): - // empty = no leg, so a cost-less campaign's re-run is unchanged. - // A non-empty model MUST be threaded here too — the recorded - // member ran net (Task 4's `run_blueprint_member`); leaving this - // re-run gross would trip the C1 drift alarm below on every - // legitimate costed campaign, not just on a real divergence. - let (tx_cost, rx_cost) = mpsc::channel(); - let (tx_net, rx_net) = mpsc::channel(); - let cost_leg = (!campaign.cost.is_empty()).then(|| crate::CostLeg { - nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument), - tx_cost, - tx_net, - }); - let mut h = - crate::wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg) - .bootstrap_with_cells(&point) - .expect("the member's point re-bootstraps (it already ran this realization)"); - // The recorded member already ran this realization once (see the - // `.expect` immediately above); `sources` are re-opened against the - // SAME `binding` this trace re-run resolved, so a `SourceBindError` - // here can only be an internal wiring inconsistency, not a fresh - // user input mistake. Panic like the sibling bootstrap invariant - // instead of a process-exit dressed as a refusal message. - h.run_bound(crate::key_supply(&binding, sources)) - .expect("sources re-opened against `binding` key-match that binding's own roles by construction"); - // Drain ALL SIX channels (`run_signal_r` leaves req undrained; the - // trace path must not): eq/ex/req/net feed the taps, r+cost feed - // the metrics. - let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); - let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); - let net_rows: Vec<(Timestamp, Vec)> = rx_net.try_iter().collect(); - - // The C1 drift alarm: metrics equality against the recorded - // member. The reduce-mode fold shares its arithmetic with this - // non-reduce reduction (SeriesFold via `summarize`; GatedRecorder - // emits exactly the rows `summarize_r`'s ledger reads), so equality - // is bit-exact. - let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - rerun_metrics.r = Some(summarize_r(&r_rows, &cost_rows)); - if rerun_metrics != member_report.metrics { - return Err(format!( - "trace re-run diverged from the recorded member (C1 violation): cell \ - {cell_key} of trace {trace_name} does not reproduce its recorded \ - metrics; refusing to persist a silently-wrong trace" - )); - } - - let traces: Vec = routed - .iter() - .map(|&(tap, ch)| { - let rows: &[(Timestamp, Vec)] = match ch { - TapChannel::Equity => &eq_rows, - TapChannel::Exposure => &ex_rows, - TapChannel::REquity => &req_rows, - TapChannel::Net => &net_rows, - }; - ColumnarTrace::from_rows(tap, &[ScalarKind::F64], rows) - }) - .collect(); - let write_key = match &member_subdir { - Some(sub) => format!("{trace_name}/{cell_key}/{sub}"), - None => format!("{trace_name}/{cell_key}"), - }; - store - .write(&write_key, &member_report.manifest, &traces) - .map_err(|e| e.to_string())?; - } - persisted_cells += 1; - } - - eprintln!( - "aura: traces persisted: {trace_name} ({} tap(s) x {persisted_cells} cell(s))", - routed.len() - ); - Ok(()) -} - #[cfg(test)] mod tests { use super::*; - use aura_campaign::StageFamily; - use aura_core::ScalarKind; - use aura_engine::RunManifest; - - fn spec(name: &str) -> ParamSpec { - ParamSpec { name: name.to_string(), kind: ScalarKind::I64 } - } - - /// A minimal `RunReport` fixture carrying exactly the manifest params - /// `member_trace_key`/`cell_member_fanout` read; the metrics are an empty - /// `summarize`, irrelevant to either function under test. - fn report_with_params(params: Vec<(String, Scalar)>) -> RunReport { - RunReport { - manifest: RunManifest { - commit: "test".to_string(), - params, - defaults: Vec::new(), - window: (Timestamp(0), Timestamp(0)), - seed: 0, - broker: "test".to_string(), - selection: None, - instrument: None, - topology_hash: None, - project: None, - }, - metrics: summarize(&[], &[]), - } - } #[test] /// #197 (fieldtest 0107 F9): the executor's preflight refusal for a @@ -1264,323 +512,6 @@ mod tests { } #[test] - /// The only shape treated as a direct store address is a bare 64-char - /// lowercase-hex token; anything else (wrong length, uppercase, non-hex) - /// falls through to `run_campaign`'s file-path branch instead. - fn is_content_id_accepts_only_64_lowercase_hex() { - assert!(is_content_id(&"a".repeat(64))); - assert!(!is_content_id(&"A".repeat(64))); - assert!(!is_content_id(&"a".repeat(63))); - assert!(!is_content_id(&format!("{}g", "a".repeat(63)))); - } - - #[test] - /// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to - /// `StopRule::VolTf` field-for-field — the resolve-side half of the - /// write/resolve pair the manifest round-trip test (main.rs) covers from - /// the stamp side; together the two arms this regime touches - /// (`campaign_run.rs`'s bind and `main.rs`'s stamp) are both exercised. - fn stop_rule_for_regime_binds_vol_tf_field_for_field() { - let regime = - Some(aura_research::RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }); - assert_eq!( - stop_rule_for_regime(regime), - aura_composites::StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 } - ); - } - - #[test] - /// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse - /// pair of one wrapper-segment convention — stripping the wrapper off a - /// wrapped name must satisfy the match predicate against that SAME - /// wrapped name, and a raw name from a DIFFERENT wrapped param must not. - fn raw_matches_wrapped_and_wrapped_to_raw_axis_are_inverses() { - let wrapped = "sma_signal.fast.length"; - assert_eq!(wrapped_to_raw_axis(wrapped), "fast.length"); - assert!(raw_matches_wrapped(wrapped_to_raw_axis(wrapped), wrapped)); - assert!(!raw_matches_wrapped("fast.length", "sma_signal.slow.length")); - } - - #[test] - /// bind_axes resolves a raw axis name against the ONE wrapped param whose - /// path ends with ".{raw}" (or equals it), producing a co-indexed point. - fn bind_axes_resolves_a_unique_suffix_match() { - let space = vec![spec("sma.length")]; - let params = vec![("length".to_string(), Scalar::i64(14))]; - let point = bind_axes(&space, "strat", ¶ms).unwrap(); - assert_eq!(point, vec![Scalar::i64(14).cell()]); - } - - #[test] - /// A raw campaign axis that matches no open param of the wrapped - /// strategy is a named Bind fault naming the axis, never a silently - /// dropped binding. - fn bind_axes_refuses_an_unmatched_axis() { - let space = vec![spec("sma.length")]; - let params = vec![("period".to_string(), Scalar::i64(14))]; - let err = bind_axes(&space, "strat", ¶ms).unwrap_err(); - let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") }; - assert!(m.contains("period") && m.contains("matches no open param")); - } - - #[test] - /// A raw axis matching MORE THAN ONE wrapped param is a named Bind fault - /// that lists every ambiguous candidate, never a silent first-match pick. - fn bind_axes_refuses_an_ambiguous_axis() { - let space = vec![spec("fast.length"), spec("slow.length")]; - let params = vec![("length".to_string(), Scalar::i64(14))]; - let err = bind_axes(&space, "strat", ¶ms).unwrap_err(); - let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") }; - assert!(m.contains("ambiguous") && m.contains("fast.length") && m.contains("slow.length")); - } - - #[test] - /// An open wrapped param left unbound by any campaign axis is a named - /// Bind fault naming the param, not an implicit default. - fn bind_axes_refuses_an_uncovered_param() { - let space = vec![spec("sma.length"), spec("sma.threshold")]; - let params = vec![("length".to_string(), Scalar::i64(14))]; - let err = bind_axes(&space, "strat", ¶ms).unwrap_err(); - let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") }; - // The refusal speaks the RAW campaign-axis namespace (what the doc - // must say), with the wrapped bind-time path as a parenthetical (#203). - assert!( - m.contains("open param \"threshold\"") && m.contains("(wrapped path: sma.threshold)"), - "raw-namespace prose expected: {m}" - ); - } - - #[test] - /// #246: `raw_bound_overrides_of` names exactly the raw axis names that - /// miss the un-reopened wrapped OPEN space but match a BOUND param of the - /// raw signal (in strategy/RAW coordinates, per its own doc comment) — an - /// axis already covered by the open space is not re-flagged as an - /// override, and an axis matching neither space is silently skipped here - /// (bind_axes surfaces THAT case as its own named fault once the - /// reopened space is built downstream). - fn raw_bound_overrides_of_selects_axes_naming_a_bound_param() { - use aura_strategy::Bias; - use aura_std::Sma; - let raw_signal = Composite::new( - "fixture", - vec![ - Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(), - Bias::builder().named("exp").into(), - ], - vec![], - vec![], - vec![], - ); - // The un-reopened wrapped OPEN space carries one extra wrap segment - // (the CLI's own outer wrap, mirroring `blueprint_axis_probe`'s - // output shape) ahead of the composite's own "exp.scale" path. - let open_space = vec![ParamSpec { name: "wrap.exp.scale".to_string(), kind: ScalarKind::F64 }]; - let param_names = - vec!["exp.scale".to_string(), "fast.length".to_string(), "nope.thing".to_string()]; - let overrides = raw_bound_overrides_of(¶m_names, &open_space, &raw_signal); - assert_eq!( - overrides, - vec!["fast.length".to_string()], - "only the bound-param axis is selected; the already-open axis and the \ - unmatched axis are excluded" - ); - } - - #[test] - /// 0109: the campaign cell key is content-derived (the `member_key` - /// discipline — never a runtime ordinal): strategy content-id prefix + - /// instrument + doc-positional window ordinal, one portable component. - fn campaign_cell_key_is_content_derived() { - let strategy = "bb34aa55".repeat(8); // a 64-hex content id - assert_eq!(campaign_cell_key(&strategy, "GER40", 0, 0), "bb34aa55-GER40-w0"); - } - - #[test] - /// A non-portable instrument byte maps to `_` (the shared - /// `sanitize_component` charset) — one path component, never a nested - /// path or an invalid directory name. - fn campaign_cell_key_sanitizes_non_portable_bytes() { - let strategy = "0123abcd".repeat(8); - assert_eq!(campaign_cell_key(&strategy, "GER/40", 3, 0), "0123abcd-GER_40-w3"); - } - - #[test] - /// A non-default regime_ordinal (#219/#212) appends `-r{ordinal}` so two - /// regimes over the same (strategy, instrument, window) cell land in - /// distinct trace dirs; ordinal 0 (the default regime) is covered above - /// and stays unsuffixed for pre-#219 key stability. - fn campaign_cell_key_appends_regime_suffix_for_non_default_ordinal() { - let strategy = "bb34aa55".repeat(8); - assert_eq!(campaign_cell_key(&strategy, "GER40", 0, 2), "bb34aa55-GER40-w0-r2"); - } - - #[test] - /// #224: `member_trace_key` renders the SAME `"name=value, ..."` join - /// `aura reproduce` prints for a member's params (single-sourced label - /// format), then sanitizes it for filesystem use — a raw label's `=` and - /// `, ` separators are hostile to a path component and must map to `_` - /// (the shared `sanitize_component` charset), never survive verbatim. - fn member_trace_key_mirrors_reproduce_label_and_sanitizes_it() { - let report = report_with_params(vec![ - ("fast".to_string(), Scalar::i64(2)), - ("slow".to_string(), Scalar::i64(4)), - ]); - // The raw reproduce-format label is "fast=2, slow=4"; sanitizing maps - // each of `=`, `,`, ` ` individually to `_` (so the ", " separator - // becomes two underscores, not one). - assert_eq!(member_trace_key(&report, 0), "fast_2__slow_4"); - } - - #[test] - /// #224: a member with no tuning params (a closed blueprint, or a - /// monte-carlo seed member) renders an empty reproduce label, so - /// `member_trace_key` falls back to the member's own ordinal into the - /// family — never an empty (invalid) path component. - fn member_trace_key_falls_back_to_the_ordinal_when_params_are_empty() { - let report = report_with_params(Vec::new()); - assert_eq!(member_trace_key(&report, 3), "3"); - } - - #[test] - /// #224: two members with different params must land at distinct keys — - /// the whole point of per-member subdirectories is that the sweep's - /// members never collide into one on-disk slot. - fn member_trace_key_differs_across_members_with_different_params() { - let a = report_with_params(vec![("length".to_string(), Scalar::i64(10))]); - let b = report_with_params(vec![("length".to_string(), Scalar::i64(20))]); - assert_ne!(member_trace_key(&a, 0), member_trace_key(&b, 1)); - } - - /// A no-nominee `CellOutcome` whose terminal family holds two members - /// (a plain selection-free sweep, #224's headline shape). - fn two_member_family_cell() -> CellOutcome { - CellOutcome { - families: vec![StageFamily { - stage: 0, - block: "std::sweep", - family_id: "fam".to_string(), - reports: vec![ - report_with_params(vec![("length".to_string(), Scalar::i64(10))]), - report_with_params(vec![("length".to_string(), Scalar::i64(20))]), - ], - }], - selections: Vec::new(), - nominee: None, - } - } - - #[test] - /// #224: a no-nominee cell with a non-empty terminal family fans out to - /// ONE (subdir, report) pair PER member — never narrowing to a single - /// nominated member, which would silently drop every other member the - /// sweep actually produced. Each pair carries a distinct `Some(key)` - /// subdir (never `None`, which is reserved for the nominee case). - fn cell_member_fanout_yields_one_entry_per_family_member() { - let cell = two_member_family_cell(); - let members = cell_member_fanout(&cell); - assert_eq!(members.len(), 2, "one dir per member, not one nominee"); - let Some(key0) = &members[0].0 else { panic!("member 0 must carry a subdir key") }; - let Some(key1) = &members[1].0 else { panic!("member 1 must carry a subdir key") }; - assert_ne!(key0, key1, "distinct members must land at distinct subdirs"); - } - - #[test] - /// #224: a nominee cell (walkforward/generalize/mc) still writes its one - /// trace directly at `/` — a `None` subdir key, unchanged since - /// 0109 — regardless of how many stage families it also carries. - fn cell_member_fanout_prefers_the_nominee_with_no_subdir() { - let mut cell = two_member_family_cell(); - let nominee_report = report_with_params(vec![("length".to_string(), Scalar::i64(30))]); - cell.nominee = Some((vec![("length".to_string(), Scalar::i64(30))], nominee_report)); - let members = cell_member_fanout(&cell); - assert_eq!(members.len(), 1, "the nominee is the cell's single trace"); - assert!(members[0].0.is_none(), "the nominee writes directly at /, no subdir"); - } - - #[test] - /// #224: a cell with neither a nominee nor a non-empty terminal family - /// (every family is empty, or there are none) fans out to nothing — the - /// caller's cue to skip the cell loudly rather than write an empty dir. - fn cell_member_fanout_is_empty_with_no_nominee_and_no_members() { - let cell = CellOutcome { families: Vec::new(), selections: Vec::new(), nominee: None }; - assert!(cell_member_fanout(&cell).is_empty()); - } - - #[test] - /// #234 — a DELIBERATE pin move (was: `net_r_equity` routes to `None` on - /// the cost-free wrap): the full closed tap vocabulary now routes, - /// `net_r_equity` to the cost leg's net-curve channel. Producibility - /// (does THIS run carry a cost model?) is the caller's check, not the - /// routing's. - fn tap_channel_routes_the_full_vocabulary() { - assert_eq!(tap_channel("equity"), Some(TapChannel::Equity)); - assert_eq!(tap_channel("exposure"), Some(TapChannel::Exposure)); - assert_eq!(tap_channel("r_equity"), Some(TapChannel::REquity)); - assert_eq!(tap_channel("net_r_equity"), Some(TapChannel::Net)); - assert_eq!(tap_channel("bogus"), None); - } - - #[test] - /// #234: the one CostSpec -> builder binding maps each component to its - /// shipped cost node with the knob BOUND — a bound component adds no open - /// param, so the wrapped param_space stays cost-invariant (the probe/member - /// space equivalence the campaign path relies on). - fn cost_nodes_for_maps_each_component_to_its_bound_builder() { - let nodes = cost_nodes_for( - &[ - aura_research::CostSpec::Constant { - cost_per_trade: aura_research::CostValue::Scalar(2.0), - }, - aura_research::CostSpec::VolSlippage { - slip_vol_mult: aura_research::CostValue::Scalar(0.5), - }, - aura_research::CostSpec::Carry { - carry_per_cycle: aura_research::CostValue::Scalar(0.1), - }, - ], - "GER40", - ); - let labels: Vec = nodes.iter().map(|n| n.label()).collect(); - assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]); - assert!( - nodes.iter().all(|n| n.params().is_empty()), - "every component must be fully bound (no open param leaks into param_space)" - ); - assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes"); - } - - #[test] - /// #234: the manifest-stamp/reproduce round-trip (`cost[k].` -> - /// `cost_specs_from_params`) reconstructs a component from its knob NAME — - /// sound only while every shipped cost node declares exactly one, - /// distinctly-named knob. A second knob (or a name collision) would break - /// variant reconstruction with no compiler error; this pin makes it loud. - fn every_cost_builder_declares_exactly_one_distinct_knob() { - let knobs: Vec = [ - aura_strategy::ConstantCost::builder(), - aura_strategy::VolSlippageCost::builder(), - aura_strategy::CarryCost::builder(), - ] - .iter() - .map(|b| { - let params = b.params(); - assert_eq!(params.len(), 1, "one knob per cost node: {}", b.label()); - params[0].name.clone() - }) - .collect(); - let mut dedup = knobs.clone(); - dedup.sort(); - dedup.dedup(); - assert_eq!(dedup.len(), knobs.len(), "knob names must be pairwise distinct: {knobs:?}"); - } - - #[test] - /// #272: `interior_gap_months` names a whole calendar month missing - /// BETWEEN two present archive months (the Copper failure shape) when the - /// requested window spans across it — not just when the window's own - /// bounds fall short of full coverage. GAPSYM-shaped input (files at - /// 2024-01..02 and 2024-05..06, absent 03..04) over a Jan-through-June - /// window must name exactly the two missing interior months. /// #272: the summary label is a CHECKED mirror of the persisted serde form /// — an aggregate over `campaign_runs.jsonl` counts by the serialized /// `CellFaultKind` string, so the human-facing label must be exactly that @@ -1597,18 +528,4 @@ mod tests { ); } } - - #[test] - fn interior_gap_months_names_a_whole_missing_month_spanned_by_the_window() { - let months = [(2024u16, 1u8), (2024, 2), (2024, 5), (2024, 6)]; - // 2024-01-01T00:00:00Z .. 2024-07-01T00:00:00Z (Unix ms) — spans every - // present month plus the March/April hole. - let window_ms = (1_704_067_200_000i64, 1_719_792_000_000i64); - let gaps = interior_gap_months(&months, window_ms); - assert_eq!( - gaps, - vec!["2024-03".to_string(), "2024-04".to_string()], - "both interior gap months must be named, in order" - ); - } } diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index c1991cb..0b2d5c8 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -13,7 +13,7 @@ use aura_engine::{ }; use serde::Deserialize; -// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in +// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in // production code; tests still exercise it directly. #[cfg(test)] use aura_vocabulary::std_vocabulary; @@ -136,7 +136,7 @@ fn format_op_error(e: &OpError) -> String { /// a built `Composite` — or a `op N (kind): cause` message (a per-op fault, /// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault /// past the last op). -fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result { +fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect(); let ops: Vec = docs.into_iter().map(Op::from).collect(); @@ -162,14 +162,14 @@ fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result Result { +pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result { let composite = composite_from_str(doc, env)?; blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")) } /// `aura graph build`: read the op-list document from stdin, build, and print the /// blueprint to stdout — or the cause to stderr and exit non-zero. -pub fn build_cmd(env: &crate::project::Env) { +pub fn build_cmd(env: &aura_runner::project::Env) { use std::io::Read; let mut doc = String::new(); if let Err(e) = std::io::stdin().read_to_string(&mut doc) { @@ -192,7 +192,7 @@ pub fn build_cmd(env: &crate::project::Env) { /// `aura graph introspect --node `: a type's ports + kinds + param paths, /// read off the pre-build schema (no graph built). `Err` if `T` is not in the /// closed vocabulary. -pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result { +pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result { let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; let schema = builder.schema(); let mut out = format!("{}\n", builder.label()); @@ -210,7 +210,7 @@ pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result Result { +pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result { let docs: Vec = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let resolver = |t: &str| env.resolve(t); let mut session = GraphSession::new("introspect", &resolver); @@ -229,7 +229,7 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result String { /// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph /// build` path (`composite_from_str`, over `OpError`) call this same helper, /// so the tier texts cannot drift between the two error families. -fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option { +fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option { if !type_id.contains("::") { return None; } @@ -423,7 +423,7 @@ fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option Option { +pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option { match e { LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env), _ => None, @@ -447,7 +447,7 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env /// `Ok(())` means the document loaded cleanly; callers that only need the /// validation (not the `Composite`) re-parse `doc` themselves afterward /// (`blueprint_from_json` is cheap and infallible at that point). -pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> { +pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> { if matches!(serde_json::from_str::(doc), Ok(serde_json::Value::Array(_))) { return Err( "this is an op-script (an op array), not a built blueprint — run \ @@ -469,7 +469,7 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Resu /// envelope (a JSON object: `format_version` + `blueprint`) OR a construction /// op-list (a JSON array) — shape-discriminated on the top-level JSON type, /// each canonicalized by its own rules. -pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Result { +pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result { let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?; match value { @@ -488,22 +488,22 @@ pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Resul /// Resolve a blueprint document's bytes from a file path or a 64-hex content /// id in the project store (the campaign-run target-addressing convention). -/// The id shape is `crate::campaign_run::is_content_id` — the same predicate +/// The id shape is `aura_runner::axes::is_content_id` — the same predicate /// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces /// cannot drift apart on what counts as a store address. -fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result { +fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result { // A CLI arg tolerates the `content:` display prefix (#194); doc ref // fields stay bare-only. let target = target .strip_prefix("content:") - .filter(|t| crate::campaign_run::is_content_id(t)) + .filter(|t| aura_runner::axes::is_content_id(t)) .unwrap_or(target); let path = Path::new(target); if path.is_file() { return std::fs::read_to_string(path) .map_err(|e| format!("cannot read {}: {e}", path.display())); } - if crate::campaign_run::is_content_id(target) { + if aura_runner::axes::is_content_id(target) { return match env.registry().get_blueprint(target) { Ok(Some(json)) => Ok(json), Ok(None) => Err(format!("no blueprint {target} in the project store")), @@ -518,7 +518,7 @@ fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result Result { +fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result { use std::fmt::Write as _; let text = resolve_blueprint_text(target, env)?; // Shape-discriminated like file-mode --content-id: an op-script (array) @@ -535,7 +535,7 @@ fn params_lines(target: &str, env: &crate::project::Env) -> Result println!("{line}"), Err(m) => { @@ -545,7 +545,7 @@ pub fn register_cmd(file: &Path, env: &crate::project::Env) { } } -fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result { +fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result { 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 @@ -605,7 +605,7 @@ mod tests { {"op":"connect","from":"sub.value","to":"bias.signal"}, {"op":"expose","from":"bias.bias","as":"bias"} ]"#; - let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds"); + let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds"); assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}"); assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}"); } @@ -621,14 +621,14 @@ mod tests { {"op":"feed","role":"price","into":["fast.series"]}, {"op":"expose","from":"fast.value","as":"out"} ]"#; - let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds"); + let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds"); assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}"); } #[test] fn build_from_str_reports_unknown_node_at_its_op_index() { let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#; - let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err(); + let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err(); assert_eq!(err, "op 1 (add): unknown node type \"Nope\""); } @@ -643,7 +643,7 @@ mod tests { {"op":"feed","role":"price","into":["fast.series","slow.series"]}, {"op":"connect","from":"fast.value","to":"sub.lhs"} ]"#; - let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err(); + let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err(); assert!(err.starts_with("finalize: "), "finalize fault, got: {err}"); assert!(err.contains("sub.rhs") && err.contains("is unconnected"), "names the unconnected slot by-identifier, got: {err}"); @@ -652,11 +652,11 @@ mod tests { #[test] fn introspect_node_lists_ports_kinds_and_params() { - let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary"); + let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary"); assert!(out.contains("series"), "lists the input port: {out}"); assert!(out.contains("value"), "lists the output field: {out}"); assert!(out.contains("length"), "lists the param path: {out}"); - assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type"); + assert!(super::introspect_node("Nope", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type"); } /// A bind whose value-kind mismatches the param reads as prose, like the connect @@ -664,7 +664,7 @@ mod tests { #[test] fn build_from_str_bad_bind_kind_reads_as_prose() { let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#; - let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err(); + let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err(); assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64"); } @@ -673,7 +673,7 @@ mod tests { #[test] fn build_from_str_unknown_bind_param_reads_as_prose() { let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#; - let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err(); + let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err(); assert_eq!(err, "op 0 (add): node fast has no param \"window\""); } @@ -694,7 +694,7 @@ mod tests { /// does not have to read source to learn the `{"I64": }` wrapping. #[test] fn introspect_node_shows_the_bind_form() { - let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary"); + let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary"); assert!(out.contains("(bind {\"I64\": })"), "shows the bind-value form: {out}"); } @@ -705,7 +705,7 @@ mod tests { {"op":"add","type":"Sub"}, {"op":"connect","from":"fast.value","to":"sub.lhs"} ]"#; - let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects"); + let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).expect("partial document introspects"); assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}"); assert!(out.contains("fast.series"), "fast.series is still open: {out}"); assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}"); @@ -732,7 +732,7 @@ mod tests { 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, &crate::project::Env::std()).expect("tap op-doc builds"); + 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}"); } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 077f77e..6e590e2 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -3,191 +3,124 @@ //! (or the `--strategy r-sma` sugar) and print canonical JSON metrics/manifests; //! this binary authors no built-in harness. -mod binding; mod render; mod graph_construct; -mod project; mod campaign_run; mod research_docs; mod scaffold; mod verb_sugar; use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series}; -use aura_core::{zip_params, Cell, Firing, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp}; -use aura_composites::{cost_graph, risk_executor, StopRule}; +use aura_core::{Scalar, Timestamp}; +#[cfg(test)] +use aura_core::ScalarKind; +#[cfg(test)] +use aura_composites::StopRule; use aura_engine::{ blueprint_from_json, blueprint_to_json, f64_field, join_on_ts, param_stability, - walk_forward, window_of, BindError, BlueprintNode, - Composite, FamilySelection, GraphBuilder, Harness, JoinedRow, - MeasurementReport, RollMode, RunManifest, + Composite, JoinedRow, SelectionMode, - SyntheticSpec, VecSource, - WindowBounds, WindowRoller, }; +#[cfg(test)] +use aura_engine::{window_of, ColumnarTrace, Harness, VecSource}; use aura_registry::{ - check_r_metric, group_families, mc_member_reports, optimize_deflated, - optimize_plateau, rank_by, sweep_member_reports, walkforward_member_reports, Family, FamilyKind, - FamilyMember, Generalization, NameKind, PlateauMode, Registry, RunTraces, - DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES, + check_r_metric, group_families, mc_member_reports, + rank_by, sweep_member_reports, walkforward_member_reports, FamilyKind, + FamilyMember, Generalization, NameKind, PlateauMode, RunTraces, }; +#[cfg(test)] +use aura_registry::Registry; use aura_backtest::{ - monte_carlo, r_metrics_from_rs, summarize, summarize_r, McAggregate, McFamily, RBootstrap, - RunMetrics, RunReport, SimBroker, SweepFamily, SweepPoint, WalkForwardResult, WindowRun, - PM_FIELD_NAMES, PM_RECORD_KINDS, + fit_wf_ms_sizes, intersect_shared_window, r_metrics_from_rs, + McAggregate, RBootstrap, RunReport, + WalkForwardResult, }; -use aura_analysis::{one_sided_p_laplace, pearson_corr, permute, MetricVocabulary, SplitMix64}; -use aura_std::{ - GatedRecorder, LinComb, Recorder, RollingMax, RollingMin, SeriesReducer, Sub, -}; -use aura_strategy::{cost_port, GEOMETRY_WIDTH}; -// `std_vocabulary` is now only reached through `project::Env::resolve` in production +#[cfg(test)] +use aura_backtest::{SweepFamily, WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS}; +use aura_measurement::information_coefficient; +// The C28 assembly position (#295): the member-run recipe, the axis/translate +// helpers, and the harness-assembly constants all live in `aura-runner` now; +// brought into scope by plain name so call sites are unchanged. +// `blueprint_axis_probe_reopened` is production code only from `verb_sugar.rs` +// (a sibling module reaching it via `crate::blueprint_axis_probe_reopened`, +// the crate-root re-export this `use` gives it), not from this module itself. +use aura_runner::member::{blueprint_axis_probe, blueprint_axis_probe_reopened, run_signal_r, wrapped_bound_names, RunData}; +#[cfg(test)] +use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE}; +// The family builders (blueprint sweep / walk-forward / MC), the shared +// `DataSource`/`DataChoice` data provider, and the `--select` objective +// `Selection` all live in `aura_runner::family` now (#295 Task 8); brought +// into scope by plain name so call sites are unchanged. `blueprint_sweep_over` +// and `run_oos_blueprint` are reached only from `blueprint_walkforward_family` +// itself (now inside `aura_runner::family`, not from this module). +use aura_runner::family::{blueprint_mc_family, blueprint_sweep_family, blueprint_walkforward_family, DataChoice, DataSource, Selection}; +#[cfg(test)] +use aura_runner::family::{render_bind_error, select_winner, showcase_prices}; +// `reproduce_family_in` (#295) lives in `aura_runner::reproduce`, returning +// `Result<_, aura_runner::RunnerError>`; the test module's own call sites +// unwrap it (the production dispatch arm is `dispatch_reproduce`). +#[cfg(test)] +use aura_runner::reproduce::reproduce_family_in; +// The measurement run path (#295 Task 8) lives in `aura_runner::measure` now. +use aura_runner::measure::run_measurement; +// `render_value` is the single source (aura-runner, #295 Task 7): the +// campaign trace layout's member-key label and this shell's own +// presentation (reproduce output, `--list-axes` default printing) render a +// scalar identically. +use aura_runner::runner::render_value; +use aura_runner::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH}; +// `sim_optimal_manifest`/`GraphBuilder`/`summarize`/`summarize_r`/`Sub` — every +// production call site now lives inside `aura_runner::member` (manifest +// construction, harness assembly); the test module still builds reference +// blueprints and hand-computed metrics directly, so the imports are test-only, +// mirroring `Bias` below. +#[cfg(test)] +use aura_runner::member::sim_optimal_manifest; +// `CostLeg` is only reached from the test module's persist-side !reduce +// re-run (production cost-leg construction happens inside +// `run_blueprint_member`, not here); the import is test-only, mirroring +// `Bias` below. The r_breakout|r_meanrev|r_channel carves and +// `r_sma_prices` moved to `aura_runner::member` proper (#295) along with the +// tests that exercised them — no longer imported here. +#[cfg(test)] +use aura_runner::member::CostLeg; +// `IcMetrics` is only reached from the test module's `ic_report`/`ic_family` carves +// (the `IcReport` DTO holds the scalar directly in production), mirroring `Bias` +// below. +#[cfg(test)] +use aura_measurement::IcMetrics; +#[cfg(test)] +use aura_backtest::{summarize, summarize_r}; +#[cfg(test)] +use aura_engine::GraphBuilder; +#[cfg(test)] +use aura_std::Sub; +// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in production // code; the test module still builds reference blueprints against it directly, so // the import is test-only. #[cfg(test)] use aura_vocabulary::std_vocabulary; -// `Delay` is now only used by the test-only `r_breakout_signal` carve (#159 cut 2's -// fused-builder retirement dropped the production caller); the import is test-only. -#[cfg(test)] -use aura_std::Delay; -// `Scale` is only used by the test-only `r_meanrev_signal` carve (#159 cut 3; the -// band half-width uses `Scale` in place of `LinComb(1)`) — no production caller, so -// the import is test-only, mirroring `Delay` above. -#[cfg(test)] -use aura_std::Scale; -// `Add`/`Gt`/`Latch`/`Mul`/`Sqrt` are now only reached from the test-only -// `r_breakout_signal`/`r_meanrev_signal` carves — #159 cut 3's retirement of the -// last fused mean-reversion builder dropped the last production caller, mirroring -// `Delay`/`Scale` above. -#[cfg(test)] -use aura_std::{Add, Gt, Latch, Mul, Sqrt}; -// `ColumnarTrace` is now also reached from `run_signal_r`'s tap-persistence -// path, so it is a production import (no longer test-only). -use aura_engine::ColumnarTrace; -// `Bias`/`Ema`/`Sma` are only reached from the test module; the imports are -// test-only, mirroring `Delay`/`Scale` above. +// `Bias`/`Sma` are only reached from the test module; the imports are test-only. #[cfg(test)] use aura_strategy::Bias; #[cfg(test)] -use aura_std::{Ema, Sma}; +use aura_std::Sma; #[cfg(test)] -use aura_strategy::{ConstantCost, VolSlippageCost}; use std::sync::mpsc; -use std::sync::LazyLock; -use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::collections::HashSet; +#[cfg(test)] +use std::collections::BTreeMap; use clap::{Args, Parser, Subcommand}; -/// The pip size the built-in *synthetic* families (sweep/walkforward/mc over a -/// blueprint) run at: a 5-decimal FX major (`EURUSD`-shaped). The synthetic streams -/// carry no instrument, so there is no recorded geometry sidecar to thread; a -/// single named source keeps the broker's divisor (`SimBroker::builder`) and the -/// recorded broker label (`sim_optimal_manifest`) in lockstep, so they cannot -/// silently drift apart. The real path threads the sidecar's looked-up `pip_size` -/// instead. -const SYNTHETIC_PIP_SIZE: f64 = 0.0001; - -/// 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; -const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS; -const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS; -const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS; - /// The winner-selection objective for walk-forward's per-window IS refit — the -/// deflation-aware SQN variant (#144 default). Named once so the three call sites -/// (the inline r-sma roller, the inline blueprint roller, and the dissolved -/// campaign sugar's bridge) cannot drift apart on the token. +/// deflation-aware SQN variant (#144 default). Named once so the two shell-side +/// call sites (the dissolved campaign sugar's sweep/walkforward/mc bridges) +/// cannot drift apart on the token; `aura_runner::family` keeps its own copy +/// of this same token for `select_winner`/`blueprint_walkforward_family` +/// (#295 Task 8 — the family builders are not shell code, so the constant +/// does not cross the boundary). const WINNER_SELECTION_METRIC: &str = "sqn_normalized"; -/// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward -/// winner selection. Recorded on each winner's manifest (so `overfit_probability` -/// is reproducible by re-run); a CLI flag for it is a deferred refinement. The -/// resample count and block length are the shared `aura_registry::DEFLATION_*`. -const DEFLATION_SEED: u64 = 0xDEF1_A7ED; - -/// A warm-up-adequate synthetic stream (~18 ticks rising, falling, then rising -/// again) used as `DataSource::Synthetic`'s full-window stream for the built-in -/// blueprint/campaign sweep, walk-forward, and MC families. Deterministic and -/// fixed (C1). -fn showcase_prices() -> Vec<(Timestamp, Scalar)> { - [ - 1.0000_f64, 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, - ] - .iter() - .enumerate() - .map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p))) - .collect() -} - -/// Build the sim-optimal `RunManifest`: the engine-external descriptor fields -/// (commit, seed-free synthetic run, broker label) are constant across the CLI's -/// built-in harnesses — only `params` and `window` vary. Centralizing the broker -/// label keeps it a single source (and a single edit point for per-asset pip -/// work): the `pip_size` it renders is the one its caller ran the broker at. -fn sim_optimal_manifest( - params: Vec<(String, Scalar)>, - window: (Timestamp, Timestamp), - seed: u64, - pip_size: f64, -) -> RunManifest { - // Typed params pass straight through: the manifest carries self-describing - // Scalars, so a length stays `i64` and a scale `f64` in the record. - RunManifest { - commit: ENGINE_COMMIT.to_string(), - params, - defaults: Vec::new(), - window, - seed, - broker: format!("sim-optimal(pip_size={pip_size})"), - selection: None, - instrument: None, - topology_hash: None, - project: None, - } -} - -/// The wrap-prefixed BOUND-param defaults of `signal` (#249): every -/// `bound_param_space()` entry as a `(., value)` pair, in -/// `bound_param_space()` order — the `RunManifest.defaults` field. Must be read -/// off `signal` BEFORE it is consumed by `wrap_r`/reopening: an axis-reopened -/// bound param has already left `bound_param_space()` by construction -/// (`Composite::reopen` forgets the bound value), so a caller that reopens -/// overrides before calling this naturally excludes them — no separate filter -/// needed. Same prefixing rule as `wrapped_bound_names`, kept separate because -/// that helper discards the value this one needs. -fn wrapped_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> { - let prefix = format!("{}.", signal.name()); - signal - .bound_param_space() - .into_iter() - .map(|b| (format!("{prefix}{}", b.name), b.value)) - .collect() -} - -/// Map any byte outside the portable directory-name charset `[A-Za-z0-9._-]` to -/// `_`. The single source of filesystem-portability for an on-disk path component -/// (valid on Linux / Windows / macOS, also URL-path- and cloud-sync-safe). Used by -/// `campaign_run`'s per-window member naming. -fn sanitize_component(s: &str) -> String { - s.chars() - .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' }) - .collect() -} - -/// Render a scalar value case-lessly: integers/timestamps as decimal digits, bool -/// as `true`/`false`, f64 via Rust's `Display` (decimal, shortest round-trip, NO -/// scientific notation). Case-less rendering is what keeps two members of one -/// family from ever differing only by letter case (case-insensitive-FS safety). -fn render_value(v: &Scalar) -> String { - match v { - Scalar::I64(n) => n.to_string(), - Scalar::F64(x) => x.to_string(), - Scalar::Bool(b) => b.to_string(), - Scalar::Timestamp(t) => t.0.to_string(), - } -} - /// Default decimation budget: target horizontal buckets. ~2000 buckets ⇒ ≤ ~4000 /// spine slots (min+max per bucket) — a few-thousand-point page regardless of the /// underlying multi-year M1 point count. @@ -427,7 +360,7 @@ fn filter_to_tap(data: ChartData, tap: &str) -> Result { /// single run charts all its taps (or the one `--tap` selects); a family overlays /// one tap (default `equity`) across its members; an unknown name is a runtime error /// (stderr + exit 1), never a panic. -fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &project::Env) { +fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &aura_runner::project::Env) { let store = env.trace_store(); render_chart_by_kind(name, store.name_kind(name), tap, mode, env, &store); } @@ -438,7 +371,7 @@ fn emit_chart(name: &str, tap: Option<&str>, mode: ChartMode, env: &project::Env /// trace-store handle's own kind (never the un-resolved name), so the /// handle-charted path (Run/Family) is exercised exactly once either way. fn render_chart_by_kind( - name: &str, kind: NameKind, tap: Option<&str>, mode: ChartMode, env: &project::Env, + name: &str, kind: NameKind, tap: Option<&str>, mode: ChartMode, env: &aura_runner::project::Env, store: &aura_registry::TraceStore, ) { match kind { @@ -532,7 +465,7 @@ enum NameResolution { /// skipped rather than propagated — a resolution failure downgrades to "not a /// campaign name", not a hard error, since the trace-store NotFound message is /// still an honest fallback. -fn resolve_campaign_name(name: &str, env: &project::Env) -> NameResolution { +fn resolve_campaign_name(name: &str, env: &aura_runner::project::Env) -> NameResolution { let registry = env.registry(); let records = match registry.load_campaign_runs() { Ok(r) => r, @@ -554,244 +487,10 @@ fn resolve_campaign_name(name: &str, env: &project::Env) -> NameResolution { } } -/// What `--real` parsing yields: the synthetic default, or a real symbol + an -/// optional window (parsed, not yet opened). Pure, so the grammar is unit-testable. -#[derive(Debug, Clone, PartialEq)] -enum DataChoice { - Synthetic, - Real { symbol: String, from_ms: Option, to_ms: Option }, -} - -/// The source provider threaded into the family builders: synthetic built-in -/// streams, or real M1 close bars from the data-server archive. Replaces the -/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come -/// from one place (Fork B/D/F). -/// -/// `Synthetic` denotes a *consumer-dependent* built-in stream, not one fixed -/// series: the full-window consumers (`full_window` / `run_sources`, used by -/// sweep / MC) draw the 18-bar `showcase_prices()`, while the windowed consumers -/// (`windowed_sources` / `wf_window_sizes`, used by walk-forward) draw the 60-bar -/// `walkforward_prices()` so the `(24,12,12)`-bar roller fits its span. The two -/// faces never reach one consumer (a family is either full-window or windowed), so -/// the split is invisible per call site but real across the type — read both -/// family builders to see it whole. -enum DataSource { - Synthetic, - Real { - server: std::sync::Arc, - symbol: String, - from_ms: Option, - to_ms: Option, - pip: f64, - }, -} - -/// No-local-data refusal — stderr + exit(1). -fn no_real_data(symbol: &str, env: &project::Env) -> ! { - eprintln!("aura: no local data for symbol '{symbol}' at {}", env.data_path()); - std::process::exit(1) -} - -/// Empty-in-window refusal — stderr + exit(1). Distinct from `no_real_data`: -/// used only inside `probe_window`, whose callers have already proven the -/// symbol present via `has_symbol` — an empty probe result there is a fact -/// about the requested `--from`/`--to` window, not about symbol absence, so -/// it must not reuse the symbol-absence message (#242). -fn no_data_in_window(symbol: &str, from_ms: Option, to_ms: Option, env: &project::Env) -> ! { - let bound = |b: Option| b.map_or_else(|| "unbounded".to_string(), |v| v.to_string()); - eprintln!( - "aura: no data for symbol '{symbol}' in the requested window [{}, {}] at {}", - bound(from_ms), - bound(to_ms), - env.data_path() - ); - std::process::exit(1) -} - -/// Resolve the per-instrument pip from the recorded geometry sidecar, or refuse -/// (stderr + exit 1) when the symbol has no recorded geometry — the single home of -/// the guessed-pip refusal, shared by `open_real_source` and `from_choice`. Reads -/// the symbol's geometry metadata (not bar data) before the run source opens, so an -/// instrument with no recorded geometry refuses without a guessed pip; the pip is -/// the provider's recorded value, honest by construction. -fn pip_or_refuse( - server: &std::sync::Arc, symbol: &str, env: &project::Env, -) -> f64 { - match aura_ingest::instrument_geometry(server, symbol) { - Some(geo) => geo.pip_size, - None => { - eprintln!( - "aura: no recorded geometry for symbol '{symbol}' at {} — \ - refusing to run a real instrument with a guessed pip", - env.data_path() - ); - std::process::exit(1); - } - } -} - -/// Probe the full data window: open a single-pass probe source through the -/// shared opener, drain it for the first/last timestamp, and return -/// `(first, last)`. Refuses (via `no_data_in_window`) when the symbol/window -/// yields no source or no bars — callers have already proven the symbol -/// present, so an empty result here is a window fact, not symbol absence. -/// Shared by `open_real_source` (which needs the manifest -/// window from a probe separate from the run sources) and -/// `DataSource::full_window`. The probe drains the CLOSE column regardless of -/// the strategy's binding: the bounds are field-independent (every archived bar -/// carries all six fields at one timestamp), and file-level absence is -/// field-independent too. -fn probe_window( - server: &std::sync::Arc, - symbol: &str, - from_ms: Option, - to_ms: Option, - env: &project::Env, -) -> (Timestamp, Timestamp) { - aura_ingest::archive_extent(server, std::path::Path::new(&env.data_path()), symbol, from_ms, to_ms) - .unwrap_or_else(|| no_data_in_window(symbol, from_ms, to_ms, env)) -} - -/// Open the real M1 sources for a recorded symbol over an optional window — one -/// source per resolved binding column, in canonical order — returning the run -/// sources paired with the manifest `window` and the per-instrument `pip_size`. -/// Single home of the real-source construction the single-run handlers share — the -/// sidecar-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and -/// the run-source open (each refusal an stderr + exit 1). Pre-data refusals keep the -/// pip honest by construction. Used by `resolve_run_data`. -fn open_real_source( - symbol: &str, - from_ms: Option, - to_ms: Option, - env: &project::Env, - fields: &[aura_ingest::M1Field], -) -> (Vec>, (Timestamp, Timestamp), f64) { - // Per-instrument pip from the recorded sidecar; resolved BEFORE bar-data access - // so an instrument with no geometry refuses without touching the archive. - let server = std::sync::Arc::new(data_server::DataServer::new(env.data_path())); - let pip = pip_or_refuse(&server, symbol, env); - if !server.has_symbol(symbol) { - no_real_data(symbol, env); - } - // Manifest window: drain a separate probe (single-pass Source) for first/last ts. - let window = probe_window(&server, symbol, from_ms, to_ms, env); - let sources = aura_ingest::open_columns(&server, symbol, from_ms, to_ms, fields) - .unwrap_or_else(|| no_real_data(symbol, env)); - (sources, window, pip) -} - -impl DataSource { - /// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol - /// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G), - /// via the same `pip_or_refuse` / `no_real_data` helpers `open_real_source` uses. - fn from_choice(choice: DataChoice, env: &project::Env) -> DataSource { - match choice { - DataChoice::Synthetic => DataSource::Synthetic, - DataChoice::Real { symbol, from_ms, to_ms } => { - let server = std::sync::Arc::new(data_server::DataServer::new(env.data_path())); - let pip = pip_or_refuse(&server, &symbol, env); - if !server.has_symbol(&symbol) { - no_real_data(&symbol, env); - } - DataSource::Real { server, symbol, from_ms, to_ms, pip } - } - } - } - - fn pip_size(&self) -> f64 { - match self { - DataSource::Synthetic => SYNTHETIC_PIP_SIZE, - DataSource::Real { pip, .. } => *pip, - } - } - - /// The full run window, probed once. Synthetic: the showcase span. Real: - /// `probe_window` drains a separate single-pass probe source for first/last ts - /// (the same helper `open_real_source` uses for its manifest window). - fn full_window(&self, env: &project::Env) -> (Timestamp, Timestamp) { - match self { - DataSource::Synthetic => { - let s: Vec> = vec![Box::new(VecSource::new(showcase_prices()))]; - window_of(&s).expect("non-empty showcase stream") - } - DataSource::Real { server, symbol, from_ms, to_ms, .. } => { - probe_window(server, symbol, *from_ms, *to_ms, env) - } - } - } - - /// The full walk-forward span. Synthetic draws the 60-bar `walkforward_prices` - /// span — NOT `showcase_prices` (which `full_window` uses): walk-forward is a - /// *windowed* consumer whose roller `(24,12,12)` needs 36 bars, so it uses the - /// longer built-in stream (byte-unchanged from the retired pre-`DataSource` - /// `walkforward_family`, which derived its span the same way). Real: the same - /// probed `--from..--to` window as `full_window`. - fn wf_full_span(&self, env: &project::Env) -> (Timestamp, Timestamp) { - match self { - DataSource::Synthetic => { - let s: Vec> = vec![Box::new(VecSource::new(walkforward_prices()))]; - window_of(&s).expect("non-empty walkforward stream") - } - DataSource::Real { server, symbol, from_ms, to_ms, .. } => { - probe_window(server, symbol, *from_ms, *to_ms, env) - } - } - } - - /// A fresh full-window source set per member (single-pass): the synthetic - /// showcase close stream, or one real source per resolved binding column - /// in canonical order (callers guard the synthetic arm to `{close}`). - fn run_sources(&self, env: &project::Env, fields: &[aura_ingest::M1Field]) -> Vec> { - match self { - DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))], - DataSource::Real { server, symbol, from_ms, to_ms, .. } => { - aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields) - .unwrap_or_else(|| no_real_data(symbol, env)) - } - } - } - - /// A fresh windowed source set for an IS/OOS sub-window (walk-forward). - /// Synthetic: `walkforward_window_source`. Real: one source per resolved - /// binding column over the ns-native window. - fn windowed_sources( - &self, from: Timestamp, to: Timestamp, env: &project::Env, fields: &[aura_ingest::M1Field], - ) -> Vec> { - match self { - DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))], - DataSource::Real { server, symbol, .. } => { - aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields) - .unwrap_or_else(|| no_real_data(symbol, env)) - } - } - } - - /// WindowRoller sizes per data kind (Fork F): bar-index for synthetic (24/12/12 - /// over the 60-bar span), calendar-ns for real. - fn wf_window_sizes(&self) -> (i64, i64, i64) { - match self { - DataSource::Synthetic => (24, 12, 12), - DataSource::Real { .. } => (WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS), - } - } - -} - -/// The honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor -/// branch alongside the SimBroker, so the plain "sim-optimal" label would under-report -/// it (#132). Shared by the single run and the sweep so the two cannot drift. -fn r_sma_broker_label(pip_size: f64) -> String { - format!("sim-optimal+risk-executor(pip_size={pip_size})") -} - -/// In-sample winner-selection objective for walk-forward (cycle 0077). `Argmax` is -/// the bare-best pick deflated for trials (#144, the default); `Plateau` argmaxes -/// the neighbourhood-smoothed surface instead (opt-in via `--select`). -#[derive(Clone, Copy)] -enum Selection { - Argmax, - Plateau(PlateauMode), -} +// `DataChoice`/`DataSource` (the family builders' `--real`/synthetic data +// provider) and `Selection` (the `--select` winner objective) moved to +// `aura_runner::family` (#295 Task 8) — imported below by plain name so +// call sites are unchanged. /// Parse a `--select` token: `argmax` | `plateau:mean` | `plateau:worst`. Unknown /// tokens are a usage error (the caller maps `Err(())` to exit 2). @@ -855,31 +554,6 @@ fn mc_member_line(id: &str, seed: u64, report: &RunReport) -> String { ) } -/// Resolve the in-sample winner under the chosen selection objective. `Argmax` -/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid -/// surface — it needs the grid lattice, so a sweep with no lattice (a future random -/// walk-forward producer) is refused rather than silently argmaxed. The metric is -/// always known at the call sites, so a metric error is unreachable (`expect`); the -/// only fallible outcome is the plateau-without-lattice refusal, returned as -/// `Err(message)` for the caller to print and exit 2. -fn select_winner( - family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>, -) -> Result<(SweepPoint, FamilySelection), String> { - match select { - Selection::Argmax => Ok(optimize_deflated( - family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED, - ).expect("walk-forward metrics are known")), - Selection::Plateau(mode) => match lattice { - Some(lens) => Ok(optimize_plateau(family, lens, metric, mode) - .expect("walk-forward metrics are known")), - None => Err( - "--select plateau requires a grid sweep; a random sweep has no parameter lattice" - .to_string(), - ), - }, - } -} - /// Pool every OOS window's per-trade R series into one flat vector, in roll order /// (window order, then within-window trade order). Windows with no `r` block /// contribute nothing. The single home of the pooling-in-roll-order semantics — @@ -919,7 +593,7 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String { /// final cumulative value, and a window's OOS segment ends at its `total_pips`); /// `param_stability` reduces each IS-refit axis in `axes` over the per-window /// chosen params (read from each report's `manifest.params` via -/// [`campaign_run::raw_matches_wrapped`] — blueprint axes are recorded wrapped +/// [`aura_runner::axes::raw_matches_wrapped`] — blueprint axes are recorded wrapped /// (e.g. `sma_signal.fast.length`) by the strategy's own param space, while /// `stop_length`/`stop_k` ride the risk regime unwrapped, so an exact-name match /// would miss the wrapped ones) through the same `MetricStats::from_values`; the @@ -932,35 +606,50 @@ fn walkforward_summary_json(result: &WalkForwardResult) -> String { /// `param_stability[0].mean` = the first axis's refit mean. fn walkforward_summary_json_from_reports(reports: &[RunReport], axes: &[String]) -> String { let total: f64 = reports.iter().map(|r| r.metrics.total_pips).sum(); - // Coercion to `f64` is decided per-value at runtime by the `Scalar` variant - // match below (i64 axes cast value-as-f64, the f64 axis passes through), - // exactly as `param_stability`'s per-`ScalarKind` coerce does — the axis - // list carries no per-axis type flag. - let param_stability: Vec = axes + // Reconstructs the minimal `WalkForwardResult` shape `aura_engine::param_stability` + // actually reads (`space` + `windows[*].run.chosen_params`) from the recorded + // reports, so this path shares the ONE per-axis coercion+reduction with + // `walkforward_summary_json` (line ~578) instead of a second copy of it. + // `bounds`/`oos_equity` play no role in the reduction (cheap placeholders); + // `oos_report` carries the already-owned report along. + let space: Vec = axes .iter() .map(|axis| { - let vals: Vec = reports + let (_, v) = reports[0] + .manifest + .params .iter() - .map(|r| { - let (_, v) = r - .manifest - .params - .iter() - .find(|(name, _)| campaign_run::raw_matches_wrapped(axis, name)) - .expect("each walk-forward window records its chosen axis"); - match v { - Scalar::I64(i) => *i as f64, - Scalar::F64(f) => *f, - Scalar::Bool(b) => *b as i64 as f64, - Scalar::Timestamp(_) => { - unreachable!("a timestamp is a structural axis, never a param knob") - } - } - }) - .collect(); - aura_engine::MetricStats::from_values(&vals) + .find(|(name, _)| aura_runner::axes::raw_matches_wrapped(axis, name)) + .expect("each walk-forward window records its chosen axis"); + aura_engine::ParamSpec { name: axis.clone(), kind: v.kind() } }) .collect(); + let windows: Vec<_> = reports + .iter() + .map(|r| { + let chosen_params = axes + .iter() + .map(|axis| { + r.manifest + .params + .iter() + .find(|(name, _)| aura_runner::axes::raw_matches_wrapped(axis, name)) + .expect("each walk-forward window records its chosen axis") + .1 + .cell() + }) + .collect(); + aura_engine::WindowOutcome { + bounds: aura_engine::WindowBounds { + is: (Timestamp(0), Timestamp(0)), + oos: (Timestamp(0), Timestamp(0)), + }, + run: aura_engine::WindowRun { chosen_params, oos_equity: Vec::new(), oos_report: r.clone() }, + } + }) + .collect(); + let result = aura_engine::WalkForwardResult { space, windows, stitched_oos_equity: Vec::new() }; + let stability = param_stability(&result); let pooled_rs: Vec = reports .iter() .flat_map(|r| r.metrics.r.as_ref().map(|m| m.net_trade_rs.clone()).unwrap_or_default()) @@ -968,7 +657,7 @@ fn walkforward_summary_json_from_reports(reports: &[RunReport], axes: &[String]) let mut obj = serde_json::json!({ "windows": reports.len(), "stitched_total_pips": total, - "param_stability": param_stability, + "param_stability": stability, }); if reports.iter().any(|r| r.metrics.r.is_some()) { obj["oos_r"] = serde_json::to_value(r_metrics_from_rs(&pooled_rs)) @@ -996,30 +685,6 @@ fn generalize_json(agg: &Generalization) -> String { serde_json::json!({ "generalize": obj }).to_string() } -/// A longer deterministic stream than `showcase_prices` — enough for several -/// IS/OOS windows with SMA warm-up. Seed-determined via `SyntheticSpec` (C1). -fn walkforward_prices() -> Vec<(Timestamp, Scalar)> { - let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 }; - let mut src = spec.source(7); - let mut out = Vec::new(); - while let Some(item) = aura_engine::Source::next(&mut src) { - out.push(item); - } - out -} - -/// The in-memory windowed source the synthetic path uses (the firewall mapping to -/// `DataServer::stream_m1_windowed` is the real-data path; synthetic stays in-memory, -/// mirroring `run_blueprint_sweep`'s `showcase_prices`). Inclusive `[from, to]`. -fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource { - VecSource::new( - walkforward_prices() - .into_iter() - .filter(|&(t, _)| t >= from && t <= to) - .collect(), - ) -} - /// Render an `McAggregate` as one canonical JSON line. `McAggregate` itself is not /// `Serialize` (only its `MetricStats` fields are), so the line is built from the /// three per-metric stat blocks. @@ -1051,7 +716,7 @@ fn mc_r_bootstrap_json(b: &RBootstrap) -> String { /// `aura runs families`: one header line per stored family (id, kind, member /// count), in first-seen store order. -fn runs_families(env: &project::Env) { +fn runs_families(env: &aura_runner::project::Env) { let reg = env.registry(); let members = match reg.load_family_members() { Ok(m) => m, @@ -1071,7 +736,7 @@ fn runs_families(env: &project::Env) { /// `aura runs family [rank ]`: list one family's member reports in /// ordinal order, or best-first by `metric`. An unknown id is an empty family /// (prints nothing, exit 0); an unknown metric is a usage error (stderr + exit 2). -fn runs_family(id: &str, rank: Option<&str>, env: &project::Env) { +fn runs_family(id: &str, rank: Option<&str>, env: &aura_runner::project::Env) { let reg = env.registry(); let members = match reg.load_family_members() { Ok(m) => m, @@ -1120,356 +785,8 @@ fn runs_family(id: &str, rank: Option<&str>, env: &project::Env) { } } -/// The outcome of reproducing one persisted family: per member, whether its re-run -/// metrics are bit-identical to the stored metrics (C1). -struct ReproduceReport { - outcomes: Vec<(String, bool)>, -} - -/// 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. -fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Vec { - space - .iter() - .map(|ps| { - params - .iter() - .find(|(n, _)| n == &ps.name) - .map(|(_, s)| s.cell()) - .unwrap_or_else(|| { - // A manifest missing a param the reloaded space expects is - // corrupted-on-disk data — exit cleanly (`aura:` + exit 1) like - // every other persisted-data failure on the reproduce path, not - // a panic. - eprintln!("aura: manifest is missing param {}", ps.name); - std::process::exit(1); - }) - }) - .collect() -} - -/// Re-derive the `StopRule` a member was minted under from its manifest params -/// (`stop_length`/`stop_k`, or `stop_period_minutes`/`stop_length`/`stop_k` for -/// the #262 timescale-matched variant, stamped by `run_blueprint_member` — the -/// stop rides the risk regime OUTSIDE the wrapped param_space, so -/// `point_from_params` cannot recover it). `stop_period_minutes` present -/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default -/// vol-stop regime when the manifest carries no stop knobs at all (pre-#233 -/// members), mirroring `stop_rule_for_regime`'s `None` arm (campaign_run.rs) -/// for the same one-directional widening `point_from_params` already applies -/// to missing manifest params. -fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule { - let period_minutes = - params.iter().find(|(n, _)| n == "stop_period_minutes").map(|(_, s)| s.as_i64()); - let length = params.iter().find(|(n, _)| n == "stop_length").map(|(_, s)| s.as_i64()); - let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64()); - match (period_minutes, length, k) { - (Some(period_minutes), Some(length), Some(k)) => { - StopRule::VolTf { period_minutes, length, k } - } - (None, Some(length), Some(k)) => StopRule::Vol { length, k }, - _ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, - } -} - -/// Re-derive the cost model a member was minted under from its manifest params -/// (`cost[k].`, stamped by `run_blueprint_member`) — the #233 stop-regime -/// pattern: like the stop, the cost model rides OUTSIDE the wrapped -/// param_space, so `point_from_params` cannot recover it. Components are read -/// in index order; the builder knob name discriminates the variant (each -/// shipped cost node has exactly one, distinctly named knob). No `cost[0].*` -/// param = the empty (gross) model — every pre-cost member widens to it, -/// mirroring `stop_rule_from_params`'s default arm. -fn cost_specs_from_params(params: &[(String, Scalar)]) -> Vec { - let mut specs = Vec::new(); - for k in 0.. { - let prefix = format!("cost[{k}]."); - let Some((knob, v)) = params - .iter() - .find_map(|(n, s)| n.strip_prefix(&prefix).map(|rest| (rest, *s))) - else { - break; - }; - specs.push(match knob { - "cost_per_trade" => aura_research::CostSpec::Constant { - cost_per_trade: aura_research::CostValue::Scalar(v.as_f64()), - }, - "slip_vol_mult" => aura_research::CostSpec::VolSlippage { - slip_vol_mult: aura_research::CostValue::Scalar(v.as_f64()), - }, - "carry_per_cycle" => aura_research::CostSpec::Carry { - carry_per_cycle: aura_research::CostValue::Scalar(v.as_f64()), - }, - other => { - // Corrupted-on-disk manifest data — the reproduce path's clean - // exit register (`aura:` + exit 1), like point_from_params. - eprintln!("aura: manifest cost param cost[{k}].{other} names no cost component"); - std::process::exit(1); - } - }); - } - specs -} - -/// Look up a persisted family by id, or exit 1 (unknown id / registry load failure) — -/// the single place `reproduce_family` and `reproduce_family_in` resolve a family, so -/// the two exit-1 error phrasings can't drift out of sync between the call sites. -fn load_family_or_exit(reg: &Registry, id: &str) -> Family { - let members = reg.load_family_members().unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }); - group_families(members).into_iter().find(|f| f.id == id).unwrap_or_else(|| { - // reproduce is an action, not a lookup: an unknown id is a hard error (distinct - // from `runs family `'s treat-as-empty exit 0). - eprintln!("aura: no such family '{id}'"); - std::process::exit(1); - }) -} - -/// Re-derive every member of a persisted sweep family from the content-addressed store -/// and compare to the stored result, against an explicit registry (testable seam). -fn reproduce_family_in( - reg: &Registry, - id: &str, - data: &DataSource, - env: &project::Env, -) -> ReproduceReport { - let family = load_family_or_exit(reg, id); - let pip = data.pip_size(); - let mut outcomes = Vec::new(); - for member in &family.members { - let stored = &member.report; - let hash = stored.manifest.topology_hash.clone().unwrap_or_else(|| { - eprintln!("aura: family member has no topology_hash; not a generated run"); - std::process::exit(1); - }); - let doc = reg - .get_blueprint(&hash) - .unwrap_or_else(|e| { - eprintln!("aura: {e}"); - std::process::exit(1); - }) - .unwrap_or_else(|| { - eprintln!("aura: blueprint {hash} missing from store"); - std::process::exit(1); - }); - // The #246 override set (silent variant): re-derived from the RECORDED - // manifest param names, against a raw probe space + raw strategy load — a - // name matching neither space is simply not an override (falls through to - // `point_from_params`'s existing missing-knob refusal below), unlike the - // sweep boundary's `override_paths`, which errors on it. - let recorded: Vec = - stored.manifest.params.iter().map(|(n, _)| n.clone()).collect(); - let raw_space = blueprint_axis_probe(&doc, env).param_space(); - let raw_signal = blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| { - eprintln!("aura: stored blueprint {hash} does not parse: {e:?}"); - std::process::exit(1); - }); - let overrides = wrapped_bound_overrides_of(&recorded, &raw_space, &raw_signal); - // Reload the stored blueprint per use: a Composite is !Clone, and both the - // param-space probe (below) and the re-run each consume one. The doc was - // canonical-serialized at store time, so every reload is infallible. Every - // reload re-opens the same override set derived above, so the recorded - // param names resolve against the space they were minted under (#246). - let reload = || { - reopen_all( - blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| { - eprintln!("aura: stored blueprint {hash} does not parse: {e:?}"); - std::process::exit(1); - }), - &overrides, - ) - }; - // The param_space of the WRAPPED signal — its knobs carry the `r_sma` - // wrapper's `sma_signal.` node-path prefix, exactly the names the manifest - // recorded at write time. Mirrors `blueprint_sweep_family`'s probe so the - // reproduce-side space name-matches the stored params (raw `signal.param_space()` - // would drop the prefix and `point_from_params` could not find the knobs). - let (tx_eq, _) = mpsc::channel(); - let (tx_ex, _) = mpsc::channel(); - let (tx_r, _) = mpsc::channel(); - let (tx_req, _) = mpsc::channel(); - let stop = stop_rule_from_params(&stored.manifest.params); - // The member's cost model, re-derived from its manifest (the #233 - // stop pattern): a costed family re-runs under the exact components it - // was minted with, so `net_expectancy_r` reproduces bit-identically. - let cost = cost_specs_from_params(&stored.manifest.params); - // The member's binding, re-derived from the stored blueprint's own - // input roles (name defaults — family manifests carry no overrides). - let binding = binding::resolve_binding(&hash, reload().input_roles(), &BTreeMap::new()) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); - if matches!(data, DataSource::Synthetic) && !binding.close_only() { - eprintln!("aura: {}", binding::synthetic_refusal(&hash, &binding)); - std::process::exit(1); - } - let space = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding, None).param_space(); - let point = point_from_params(&space, &stored.manifest.params); - // A MonteCarlo member carries no tuning params (the params-join is empty), so its - // reproduce line would print a BLANK member label; the seed IS its realization - // identity, so surface `seed=` instead. Sweep / walk-forward members echo their - // tuning params (the params-join), unchanged. - let label = match family.kind { - FamilyKind::MonteCarlo => format!("seed={}", stored.manifest.seed), - _ => stored - .manifest - .params - .iter() - .map(|(n, v)| format!("{n}={}", render_value(v))) - .collect::>() - .join(", "), - }; - // Realization-aware: a MonteCarlo member ran over a seed-driven synthetic walk, - // not the showcase — reconstruct it from manifest.seed so the re-run matches (C1). - // The seed is manifest-carried and identical across realizations; only the sources - // and window vary by kind. - let seed = stored.manifest.seed; - let (sources, member_window) = match family.kind { - FamilyKind::MonteCarlo => { - let s = synthetic_walk_sources(seed); - let w = window_of(&s).expect("non-empty synthetic walk"); - (s, w) - } - FamilyKind::WalkForward => { - // each member is one OOS window: rebuild its windowed slice from the - // stored window bounds; the winner params come from the shared - // manifest->cells recovery below (as Sweep members do). - let (from, to) = stored.manifest.window; - let s = data.windowed_sources(from, to, env, &binding.columns()); - let w = window_of(&s).expect("non-empty OOS window"); - (s, w) - } - // Sweep / plain-run members: a Real source reopens the exact per-member - // window the manifest recorded, the same `windowed_sources` loader - // WalkForward uses above (and `CliMemberRunner` uses at mint time, #229) - // — never a fresh full-archive probe, which need not match the window the - // family was minted over. Synthetic keeps the pre-#229 full-window path - // (byte-identical: `data.full_window` is a pure, no-IO computation). - _ => match data { - DataSource::Real { .. } => { - let (from, to) = stored.manifest.window; - (data.windowed_sources(from, to, env, &binding.columns()), (from, to)) - } - DataSource::Synthetic => (data.run_sources(env, &binding.columns()), data.full_window(env)), - }, - }; - let rerun = run_blueprint_member( - reload(), - &point, - &space, - sources, - member_window, - seed, - pip, - &hash, - env, - stop, - &binding, - &cost, - // The re-run specs come from the stamp and are scalar by - // construction (`cost_specs_from_params`), so the instrument is - // inert; the fallback is never resolved against a map. - stored.manifest.instrument.as_deref().unwrap_or(""), - ); - outcomes.push((label, rerun.metrics == stored.metrics)); - } - ReproduceReport { outcomes } -} - -/// `aura reproduce `: re-derive a persisted sweep family from the -/// content-addressed blueprint store and verify each member reproduces bit-identically -/// (C18 "re-derives full results on demand"). The data source is reconstructed from -/// the family's own manifest (#229) — a hardcoded `DataSource::Synthetic` here would -/// re-derive a real-data family over the wrong stream; see `reproduce_family_in`'s -/// per-member window loader for how the reconstructed source is actually used. -fn reproduce_family(id: &str, env: &project::Env) { - let reg = env.registry(); - let family = load_family_or_exit(®, id); - // Reconstruct the DataSource the family was minted over: `None` instrument - // (every synthetic-family member, pre-#229 lines) stays the built-in synthetic - // stream; a real instrument reopens the same local archive `--real` runs use, - // via the same named-data refusal (exit 1) on a missing sidecar/archive. - let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) { - None => DataSource::Synthetic, - Some(symbol) => { - DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env) - } - }; - let rep = reproduce_family_in(®, id, &data, env); - let total = rep.outcomes.len(); - let ok = rep.outcomes.iter().filter(|(_, b)| *b).count(); - for (label, identical) in &rep.outcomes { - let verdict = if *identical { "bit-identical" } else { "DIVERGED" }; - println!("{id} member {label} reproduced: {verdict}"); - } - println!("reproduced {ok}/{total} members bit-identically"); - if ok != total { - std::process::exit(1); - } -} - // --- r-sma harness (the SMA-cross signal scored in R) -------------------- -/// The r-sma vol-stop EWMA length (cycles). Single source for the `StopRule::Vol` -/// the blueprint embeds and the `stop` param the manifest records — kept honest by one -/// constant instead of a hand-synced literal across the function boundary. -pub(crate) const R_SMA_STOP_LENGTH: i64 = 3; - -/// The r-sma vol-stop multiplier (1R = `k`·σ). Single source for the embedded -/// `StopRule::Vol` and its manifest record, like [`R_SMA_STOP_LENGTH`]. -pub(crate) const R_SMA_STOP_K: f64 = 2.0; - -/// Short-horizon realized-range window for vol-scaled slippage. Deliberately -/// distinct from `R_SMA_STOP_LENGTH` (3): scaling slippage by the stop's own -/// vol would collapse cost-in-R to a constant (spec 0082). Short enough to warm -/// within the synthetic smoke fixture so the run path exercises non-zero slippage. -const SLIP_VOL_LENGTH: i64 = 5; - -/// The optional cost leg of the R scaffolding (#234): the resolved cost-node -/// builders (from `campaign_run::cost_nodes_for`, fully bound — they add no -/// open param, so the wrapped `param_space` is cost-invariant) plus the two -/// recording channels — the aggregate 3-field cost record (`tx_cost`, the -/// `summarize_r` join input) and the `net_r_equity` curve (`tx_net`, recorded -/// only in `!reduce` trace mode). -struct CostLeg { - nodes: Vec, - tx_cost: mpsc::Sender<(Timestamp, Vec)>, - tx_net: mpsc::Sender<(Timestamp, Vec)>, -} - -/// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1 -/// close bars for a vetted symbol over an optional window. -#[derive(Debug)] -enum RunData { - Synthetic, - Real { symbol: String, from: Option, to: Option }, -} - -/// A rise-fall-rise synthetic stream for the r-sma smoke run: long enough to warm -/// the `vol_stop(length=3)` and flip the SMA(2)/SMA(4) cross at least once, so the -/// RiskExecutor opens and closes at least one trade. Deterministic (C1). -fn r_sma_prices() -> Vec<(Timestamp, Scalar)> { - [ - 1.0000_f64, 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, - ] - .iter() - .enumerate() - .map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p))) - .collect() -} - -/// Interned `col[i]` recorder-port names, built once. The `GraphBuilder::input` API -/// wants `&'static str`; interning here (instead of `format!(...).leak()` per field) -/// means reusing the r-sma harness in a sweep / Monte-Carlo loop reuses these -/// strings rather than leaking 14 fresh ones per build (#132). -static COL_PORTS: LazyLock> = - LazyLock::new(|| (0..PM_FIELD_NAMES.len()).map(|i| format!("col[{i}]")).collect()); - /// SHA256 (hex) of a canonical (#164) blueprint JSON string — the content id (#158). /// The single hashing primitive, shared by [`topology_hash`] (from a live `Composite`) /// and the op-script `graph introspect --content-id` path (`crate::content_id`), so the @@ -1485,420 +802,6 @@ fn topology_hash(signal: &Composite) -> String { content_id(&blueprint_to_json(signal).expect("a buildable signal serializes")) } -/// Wrap a `signal` composite (a roles→`bias` leg) in the R run -/// scaffolding: pip broker, the per-tap recorders, the vol-stop RiskExecutor, the -/// r_equity / cost legs. The signal is nested; its root roles come from the -/// resolved `binding` (one per entry, canonical column order — the same order the -/// callers open real columns in, so role i receives source i), and the binding's -/// guaranteed close entry always feeds the broker/executor pair. A serialized -/// signal loaded via `blueprint_from_json` runs through exactly the scaffolding -/// the Rust-built signal does. -#[allow(clippy::type_complexity, clippy::too_many_arguments)] -fn wrap_r( - signal: Composite, - tx_eq: mpsc::Sender<(Timestamp, Vec)>, - tx_ex: mpsc::Sender<(Timestamp, Vec)>, - tx_r: mpsc::Sender<(Timestamp, Vec)>, - tx_req: mpsc::Sender<(Timestamp, Vec)>, - stop: StopRule, - reduce: bool, - pip_size: f64, - binding: &binding::ResolvedBinding, - cost: Option, -) -> Composite { - let mut g = GraphBuilder::new("r_sma"); - // The strategy signal → Bias, nested as a serializable roles→`bias` leg. - let sig = g.add(BlueprintNode::Composite(signal)); - // pip branch (verbatim from the retired `sample_blueprint_with_sinks`, #159). - let broker = g.add(SimBroker::builder(pip_size)); - // R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. The stop is a - // fixed `StopRule` — the pinned default constants, or an arbitrary per-regime rule - // the caller resolved. - // In `reduce` mode the per-cycle taps fold online: SeriesReducer folds the eq/ex - // f64 series to one summary row, GatedRecorder retains only the gated R rows — the - // O(cycles)→O(trades) memory win. The raw `Recorder`s (and the r_equity tap) are the - // `--trace` path, where the full per-cycle series is persisted. - let gate_col = PM_FIELD_NAMES - .iter() - .position(|&n| n == "closed_this_cycle") - .expect("PM record has a closed_this_cycle column"); - let eq = if reduce { - g.add(SeriesReducer::builder(Firing::Any, tx_eq)) - } else { - g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)) - }; - let ex = if reduce { - g.add(SeriesReducer::builder(Firing::Any, tx_ex)) - } else { - g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)) - }; - let exec = g.add(risk_executor(stop, 1.0)); - let rrec = if reduce { - g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r)) - } else { - g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r)) - }; - // Root roles come from the resolved binding, one per entry in canonical - // column order (the C4 merge tie-break order the callers open columns in). - // The binding guarantees a close entry, which always feeds the broker and - // the executor — "price" below is their node-schema PORT name, not a role - // name. For a single-`price` signal this declares exactly the old weld: - // one role, targets [sig, broker, exec] (feed calls append targets). - let mut close_handle = None; - for entry in binding.entries() { - let role = g.source_role(&entry.role, binding::column_kind(entry.column)); - if entry.feeds_signal { - // `NodeHandle::input` takes a `&'static str` port name (the fluent - // `GraphBuilder`'s authoring-time contract, builder.rs) but the - // binding's role names are dynamic (loaded from a blueprint at - // runtime). Vocabulary names use their static form; only an - // override-renamed role leaks its name (once per graph build, - // never per-tick). - let port: &'static str = binding::static_role_name(&entry.role) - .unwrap_or_else(|| Box::leak(entry.role.clone().into_boxed_str())); - g.feed(role, [sig.input(port)]); - } - if entry.column == aura_ingest::M1Field::Close && close_handle.is_none() { - close_handle = Some(role); - } - } - let close = close_handle.expect("ResolvedBinding guarantees a close entry"); - g.feed(close, [broker.input("price"), exec.input("price")]); - g.connect(sig.output("bias"), broker.input("exposure")); - g.connect(sig.output("bias"), ex.input("col[0]")); - g.connect(sig.output("bias"), exec.input("bias")); - g.connect(broker.output("equity"), eq.input("col[0]")); - for (i, field) in PM_FIELD_NAMES.iter().enumerate() { - g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str())); - } - if !reduce { - // r_equity = cum_realized_r + unrealized_r — one tapped series for charting. - let r_equity = g.add( - LinComb::builder(2) - .bind("weights[0]", Scalar::f64(1.0)) - .bind("weights[1]", Scalar::f64(1.0)), - ); - let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req)); - g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]")); - g.connect(exec.output("unrealized_r"), r_equity.input("term[1]")); - g.connect(r_equity.output("value"), req.input("col[0]")); - } - // The optional cost leg (#234 — the #221-deleted wiring rebuilt as a - // campaign-documented feature): a `cost_graph` fed by the executor's four - // geometry outputs, its aggregate record recorded co-temporally with the R - // record (the `summarize_r` positional-join input), and — in `!reduce` - // trace mode — the LinComb(4) net-equity curve into `tx_net`. Absent leg - // (`None`) == today's graph, byte-identical. - if let Some(leg) = cost { - // Components taking a `volatility` extra input (the vol_slippage - // shape), discovered from each builder's own schema past the geometry - // prefix — no second vocabulary of "which node needs the proxy". - let vol_slots: Vec = leg - .nodes - .iter() - .enumerate() - .filter(|(_, n)| { - n.schema().inputs[GEOMETRY_WIDTH..].iter().any(|p| p.name == "volatility") - }) - .map(|(k, _)| k) - .collect(); - // The short-horizon realized-range vol proxy, fed from the close role, - // shared by every vol-scaled component (the #221-deleted arm, verbatim - // wiring; built in BOTH modes — the reduce-mode member run charges - // slippage too). - let vol_range = if vol_slots.is_empty() { - None - } else { - let vhi = g.add(RollingMax::builder().named("slip_vol_hi").bind("length", Scalar::i64(SLIP_VOL_LENGTH))); - let vlo = g.add(RollingMin::builder().named("slip_vol_lo").bind("length", Scalar::i64(SLIP_VOL_LENGTH))); - let vrange = g.add(Sub::builder().named("slip_vol_range")); - g.connect(vhi.output("value"), vrange.input("lhs")); - g.connect(vlo.output("value"), vrange.input("rhs")); - g.feed(close, [vhi.input("series"), vlo.input("series")]); - Some(vrange) - }; - // One cost_graph composite fans the shared PM-geometry into the - // components and sums their per-field charges (C10). - let cg = g.add(cost_graph(leg.nodes)); - g.connect(exec.output("closed_this_cycle"), cg.input("closed")); - g.connect(exec.output("open"), cg.input("open")); - g.connect(exec.output("entry_price"), cg.input("entry_price")); - g.connect(exec.output("stop_price"), cg.input("stop_price")); - for k in vol_slots { - let vrange = vol_range.as_ref().expect("proxy is built whenever a vol slot exists"); - g.connect(vrange.output("value"), cg.input(cost_port(k, "volatility"))); - } - if reduce { - // The aggregate cost record, gated on the SAME closed flag as the R - // GatedRecorder and flushed once at finalize — so the cost rows stay - // positionally 1:1 with the gated R rows (`summarize_r`'s join). The - // gate rides as an APPENDED col 3: cols 0..2 keep the aura-analysis - // `cost_col` contract (cost_in_r = 0, open_cost_in_r = 2). - let crec = g.add(GatedRecorder::builder( - vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::Bool], - 3, - Firing::Any, - leg.tx_cost, - )); - g.connect(cg.output("cost_in_r"), crec.input("col[0]")); - g.connect(cg.output("cum_cost_in_r"), crec.input("col[1]")); - g.connect(cg.output("open_cost_in_r"), crec.input("col[2]")); - g.connect(exec.output("closed_this_cycle"), crec.input("col[3]")); - } else { - // The full per-cycle aggregate cost record (col 0 per-close, col 2 - // window-end — what summarize_r folds on the trace path). - let crec = g.add(Recorder::builder( - vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64], - Firing::Any, - leg.tx_cost, - )); - g.connect(cg.output("cost_in_r"), crec.input("col[0]")); - g.connect(cg.output("cum_cost_in_r"), crec.input("col[1]")); - g.connect(cg.output("open_cost_in_r"), crec.input("col[2]")); - // net_r_equity = cum_realized_r + unrealized_r − Σcum_cost_in_r - // − Σopen_cost_in_r (the #221-deleted LinComb(4), weights 1,1,-1,-1). - let net_eq = g.add( - LinComb::builder(4) - .bind("weights[0]", Scalar::f64(1.0)) - .bind("weights[1]", Scalar::f64(1.0)) - .bind("weights[2]", Scalar::f64(-1.0)) - .bind("weights[3]", Scalar::f64(-1.0)), - ); - g.connect(exec.output("cum_realized_r"), net_eq.input("term[0]")); - g.connect(exec.output("unrealized_r"), net_eq.input("term[1]")); - g.connect(cg.output("cum_cost_in_r"), net_eq.input("term[2]")); - g.connect(cg.output("open_cost_in_r"), net_eq.input("term[3]")); - let net_rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, leg.tx_net)); - g.connect(net_eq.output("value"), net_rec.input("col[0]")); - } - } - g.build().expect("r_sma wiring resolves") -} - -/// Pair each opened source with its role name from the resolved binding, forming -/// the keyed supply `run_bound` resolves by name. `sources` are opened in -/// `binding.columns()` order (== `binding.entries()` order), so entry `i`'s role -/// names source `i` — the one place open-order and role-order meet, made explicit -/// so `bind_sources` verifies the wiring↔supply role match by name (#275). -fn key_supply( - binding: &binding::ResolvedBinding, - sources: Vec>, -) -> Vec<(String, Box)> { - binding - .entries() - .iter() - .map(|e| e.role.clone()) - .zip(sources) - .collect() -} - -/// Resolve a `RunData` selector to the `(sources, window, pip_size)` triple the -/// run paths feed to the harness: the built-in synthetic R stream (close-only — -/// the caller guards the binding shape), or the lazily-streamed real sources of -/// the binding's resolved columns (with the sidecar pip + probed window). -/// Single definition used by `run_signal_r`. -#[allow(clippy::type_complexity)] -fn resolve_run_data( - data: &RunData, - env: &project::Env, - binding: &binding::ResolvedBinding, -) -> ( - Vec>, - (Timestamp, Timestamp), - f64, -) { - match data { - RunData::Synthetic => { - let sources: Vec> = - vec![Box::new(VecSource::new(r_sma_prices()))]; - let window = window_of(&sources).expect("non-empty synthetic stream"); - (sources, window, SYNTHETIC_PIP_SIZE) - } - RunData::Real { symbol, from, to } => { - open_real_source(symbol, *from, *to, env, &binding.columns()) - } - } -} - -/// Run a signal blueprint through the R scaffolding: hash the signal, -/// wrap it (broker + equity/exposure/R sinks), compile with `params`, bootstrap, -/// run over `data`, and build the RunReport (manifest carries topology_hash). -/// The single construction+run path shared by the `aura run ` CLI -/// arm and its bit-identical test. -#[allow(clippy::type_complexity)] -fn run_signal_r( - signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &project::Env, -) -> RunReport { - let topo = topology_hash(&signal); // before signal is consumed - let run_name = signal.name().to_string(); // before signal is consumed by `wrap_r` - // The default binding (name defaults; `aura run` carries no campaign - // overrides). Refusals are the established `aura: ` + exit-1 register. - let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); - if matches!(data, RunData::Synthetic) && !binding.close_only() { - eprintln!("aura: {}", binding::synthetic_refusal(signal.name(), &binding)); - std::process::exit(1); - } - let names: Vec = signal - .param_space() - .iter() - .map(|p| p.name.clone()) - .collect(); - let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - // The req tap (r_equity recorder) is wired but not persisted on this path; keep the - // receiver alive so the sink's sends do not fail, but do not drain it. - let (tx_req, _rx_req) = mpsc::channel(); - let (sources, window, pip_size) = resolve_run_data(&data, env, &binding); - let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, pip_size, &binding, None); - let mut flat = wrapped.compile_with_params(params).unwrap_or_else(|e| { - eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}"); - std::process::exit(1); - }); - // Bind each declared tap to a fresh `Recorder` + channel, before bootstrap - // — `flat.taps` already carries the signal's declared taps hoisted to the - // root. Dedup is the caller's per `TapBindError::DuplicateBind`'s doc (the - // engine keeps no cross-call state). - let mut seen: BTreeSet = BTreeSet::new(); - let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec)>)> = Vec::new(); - let declared: Vec = flat.taps.clone(); - for tap in &declared { - if !seen.insert(tap.name.clone()) { - eprintln!("aura: {}", aura_engine::TapBindError::DuplicateBind { name: tap.name.clone() }); - std::process::exit(1); - } - let kind = flat.signatures[tap.node].output[tap.field].kind; - let (tx, rx) = mpsc::channel(); - let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone(); - flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig) - .expect("declared tap binds (name from flat.taps, kind from its own signature)"); - tap_drains.push((tap.name.clone(), kind, rx)); - } - let mut h = Harness::bootstrap(flat).expect("valid r-sma harness"); - // `sources` were opened via `resolve_run_data(&data, env, &binding)` against - // this SAME `binding`, and `key_supply` keys them by that binding's own role - // names — a `SourceBindError` here can only mean the wiring↔supply pairing - // this function builds is internally inconsistent, never a user input - // mistake. Panic like the adjacent bootstrap invariants above, not a - // process exit dressed as a refusal message. - h.run_bound(key_supply(&binding, sources)) - .expect("sources opened against `binding` key-match that binding's own roles by construction"); - let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); - let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let named_params: Vec<(String, Scalar)> = - names.into_iter().zip(params.iter().copied()).collect(); - let mut manifest = sim_optimal_manifest(named_params, window, seed, pip_size); - manifest.defaults = defaults; - manifest.broker = r_sma_broker_label(pip_size); - manifest.topology_hash = Some(topo); - manifest.project = env.provenance(); - let mut metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); - metrics.r = Some(summarize_r(&r_rows, &[])); - // Drain + persist each declared tap's series, guarded so a tap-free - // `aura run` stays byte-identical to today (no `runs/` write at all). - // `manifest` is built above so the persisted `index.json` carries this - // run's own provenance. - if !tap_drains.is_empty() { - let tap_traces: Vec = tap_drains - .into_iter() - .map(|(name, kind, rx)| { - let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - ColumnarTrace::from_rows(&name, &[kind], &rows) - }) - .collect(); - env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| { - eprintln!("aura: writing tap traces failed: {e}"); - std::process::exit(1); - }); - } - RunReport { manifest, metrics } -} - -/// The measurement-run manifest: the honest subset of [`sim_optimal_manifest`] -/// with no broker/R specifics. `broker` carries the interim `"measurement"` -/// sentinel — `RunManifest.broker` is a required `String` today; the honest -/// `Option` model is deferred to the #147 metric-genericity block so this -/// phase stays #147-free and the strategy-path C18 goldens byte-identical. -fn measurement_manifest( - params: Vec<(String, Scalar)>, - window: (Timestamp, Timestamp), - seed: u64, -) -> RunManifest { - RunManifest { - commit: ENGINE_COMMIT.to_string(), - params, - defaults: Vec::new(), - window, - seed, - broker: "measurement".to_string(), - selection: None, - instrument: None, - topology_hash: None, - project: None, - } -} - -/// The IC measurement payload: the scalar plus its in-memory null inputs -/// (the aligned pairs ride #[serde(skip)], mirroring RMetrics.net_trade_rs). -/// The second production implementor of `MetricVocabulary` (#147) — the -/// registry's deflation machinery dispatches to ITS permutation null. -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] -struct IcMetrics { - information_coefficient: f64, - #[serde(skip)] - sigs: Vec, - #[serde(skip)] - frs: Vec, -} - -/// The IC vocabulary's single resolved key. -#[derive(Clone, Copy)] -struct IcKey; - -impl MetricVocabulary for IcMetrics { - type Key = IcKey; - - fn resolve(name: &str) -> Option { - (name == "information_coefficient").then_some(IcKey) - } - fn known() -> &'static [&'static str] { - &["information_coefficient"] - } - fn higher_is_better(_: IcKey) -> bool { - true - } - fn value(&self, _: IcKey) -> f64 { - self.information_coefficient - } - fn has_resampling_null(_: IcKey) -> bool { - true - } - fn null_draw(&self, _: IcKey, _block_len: usize, rng: &mut SplitMix64) -> Option { - if self.sigs.len() < 2 { - return None; // consumes no rng - } - let mut perm = self.sigs.clone(); // independent draw, not a chained shuffle - permute(&mut perm, rng); - Some(pearson_corr(&perm, &self.frs)) - } -} - -/// The pure IC reduction result (no CLI context) — unit-testable in isolation. -/// Exercised by the in-crate `ic_tests` module and consumed by the run/command-path -/// wiring in `dispatch_measure_ic`, which constructs `IcReport` from a live -/// measurement run's recorded taps. -struct IcOutcome { - information_coefficient: f64, - overfit_probability: f64, - n_pairs: usize, -} - /// One measurement run's IC scalar + its permutation-null significance, for stdout. #[derive(serde::Serialize)] struct IcReport { @@ -1919,397 +822,6 @@ impl IcReport { } } -/// `corr(signal_t, forward_return_{t+h})` over two recorded tap series, with a -/// permutation null. `forward_return[i] = (price[i+h] - price[i]) / price[i]` on the -/// price ts-spine (a post-run array shift over recorded data — C2 governs in-graph -/// nodes, not a completed run's trace); signal is aligned to it by EXACT timestamp -/// match (unmatched dropped). `< 2` aligned pairs or zero variance → the degenerate -/// floor `(0.0, 1.0)`. One-sided permutation null via the shared -/// `MetricVocabulary::null_draw` + `one_sided_p_laplace` building blocks (#147); -/// draws are independent permutations, deterministic given `seed`. -fn information_coefficient( - signal: &[(Timestamp, f64)], - price: &[(Timestamp, f64)], - horizon: usize, - permutations: usize, - seed: u64, -) -> IcOutcome { - use std::collections::HashMap; - // signal value by timestamp-i64 (`Timestamp` is a tuple struct over epoch-ns; a tap - // fires at most once per ts). `t.0` is the epoch-ns i64. - let sig_at: HashMap = signal.iter().map(|(t, v)| (t.0, *v)).collect(); - // forward returns on the price spine, paired with the signal at the SAME ts - let (mut sigs, mut frs) = (Vec::new(), Vec::new()); - if horizon >= 1 && price.len() > horizon { - for i in 0..price.len() - horizon { - let (t, p0) = (price[i].0, price[i].1); - if p0 == 0.0 { - continue; // undefined return - } - let fr = (price[i + horizon].1 - p0) / p0; - if let Some(&s) = sig_at.get(&t.0) { - sigs.push(s); - frs.push(fr); - } - } - } - let n_pairs = sigs.len(); - if n_pairs < 2 { - return IcOutcome { information_coefficient: 0.0, overfit_probability: 1.0, n_pairs }; - } - let raw = pearson_corr(&sigs, &frs); - let member = IcMetrics { information_coefficient: raw, sigs, frs }; - let mut rng = SplitMix64::new(seed); - let mut ge = 0usize; - for _ in 0..permutations { - let draw = member - .null_draw(IcKey, 0, &mut rng) - .expect("n_pairs >= 2 checked above"); - if draw >= raw { - ge += 1; - } - } - let overfit_probability = one_sided_p_laplace(ge, permutations); - IcOutcome { information_coefficient: raw, overfit_probability, n_pairs } -} - -/// The bare measurement run (C28 phase 3): `run_signal_r` MINUS `wrap_r` and the -/// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27). -/// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this -/// is where the measured O(cycles) retention is removed. The tap machinery is -/// duplicated (not extracted) so `run_signal_r` stays byte-identical. -#[allow(clippy::type_complexity)] -fn run_measurement( - signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &project::Env, -) -> MeasurementReport { - let topo = topology_hash(&signal); - let run_name = signal.name().to_string(); - let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); - if matches!(data, RunData::Synthetic) && !binding.close_only() { - eprintln!("aura: {}", binding::synthetic_refusal(signal.name(), &binding)); - std::process::exit(1); - } - let names: Vec = signal.param_space().iter().map(|p| p.name.clone()).collect(); - let defaults = wrapped_bound_defaults(&signal); - let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding); - - // Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks. - let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| { - eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}"); - std::process::exit(1); - }); - - // Bind each declared tap to a Recorder (mirrors run_signal_r:1759-1773). - let mut seen: BTreeSet = BTreeSet::new(); - let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec)>)> = Vec::new(); - let declared: Vec = flat.taps.clone(); - for tap in &declared { - if !seen.insert(tap.name.clone()) { - eprintln!("aura: {}", aura_engine::TapBindError::DuplicateBind { name: tap.name.clone() }); - std::process::exit(1); - } - let kind = flat.signatures[tap.node].output[tap.field].kind; - let (tx, rx) = mpsc::channel(); - let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone(); - flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig) - .expect("declared tap binds (name from flat.taps, kind from its own signature)"); - tap_drains.push((tap.name.clone(), kind, rx)); - } - - let mut h = Harness::bootstrap(flat).expect("valid measurement harness"); - h.run_bound(key_supply(&binding, sources)) - .expect("sources opened against `binding` key-match that binding's own roles by construction"); - - let named_params: Vec<(String, Scalar)> = - names.into_iter().zip(params.iter().copied()).collect(); - let mut manifest = measurement_manifest(named_params, window, seed); - manifest.defaults = defaults; - manifest.topology_hash = Some(topo); - manifest.project = env.provenance(); - - // Drain + persist each declared tap (mirrors run_signal_r:1799-1811). - let tap_names: Vec = tap_drains.iter().map(|(n, _, _)| n.clone()).collect(); - if !tap_drains.is_empty() { - let tap_traces: Vec = tap_drains - .into_iter() - .map(|(name, kind, rx)| { - let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); - ColumnarTrace::from_rows(&name, &[kind], &rows) - }) - .collect(); - env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| { - eprintln!("aura: writing tap traces failed: {e}"); - std::process::exit(1); - }); - } - MeasurementReport { manifest, taps: tap_names } -} - -/// #260: the r-sma sugar/MC paths below run with either no cost model (empty -/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params` -/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never -/// originate on these paths and the instrument context is genuinely inert. -/// Named once so every such call site states its intent by reference instead -/// of repeating the justifying comment. -const NO_INSTRUMENT_CONTEXT: &str = ""; - -/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path -/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the -/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a -/// reproduced member re-derives bit-identically (C1). `signal` is a freshly -/// reloaded blueprint (`Composite` is `!Clone`); `point` is the member's bound -/// cells; `space` gives the by-name manifest params; `topo` the shared signal hash. -#[allow(clippy::too_many_arguments)] -fn run_blueprint_member( - signal: Composite, - point: &[Cell], - space: &[ParamSpec], - sources: Vec>, - window: (Timestamp, Timestamp), - seed: u64, - pip: f64, - topo: &str, - env: &project::Env, - stop: StopRule, - binding: &binding::ResolvedBinding, - cost: &[aura_research::CostSpec], - instrument: &str, -) -> RunReport { - let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below - let (tx_eq, rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - // The doc's cost model as an optional wrap leg (#234): empty = no leg, - // exactly the pre-cost graph. The net curve is a !reduce trace concern; - // its sender is wired but unread here (the r_equity tap precedent above). - let (tx_cost, rx_cost) = mpsc::channel(); - let (tx_net, _rx_net) = mpsc::channel(); - let cost_leg = (!cost.is_empty()).then(|| CostLeg { - nodes: campaign_run::cost_nodes_for(cost, instrument), - tx_cost, - tx_net, - }); - let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip, binding, cost_leg) - .bootstrap_with_cells(point) - .expect("member bootstraps (point kind-checked against param_space)"); - // Called from inside `CliMemberRunner::run_member`'s `catch_unwind` - // containment (#272 — that impl's doc contract: "never a process exit - // inside a sweep worker, every refusal a member fault"). A - // `SourceBindError` here is an internal wiring/supply mismatch, not user - // input (see the sibling `.expect` two lines above) — panic so the - // containing `catch_unwind` records it as a per-cell `MemberFault::Panic` - // instead of `process::exit` aborting the whole campaign. - h.run_bound(key_supply(binding, sources)) - .expect("sources opened against `binding` key-match that binding's own roles by construction"); - let mut named = zip_params(space, point); // by-name params for the manifest record - // `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant, - // which stamps no vol knobs. The campaign/single-run paths only pass `Vol` - // or `VolTf`, so the `Fixed` arm is inert here. - match stop { - StopRule::Vol { length, k } => { - named.push(("stop_length".to_string(), Scalar::i64(length))); - named.push(("stop_k".to_string(), Scalar::f64(k))); - } - StopRule::VolTf { period_minutes, length, k } => { - named.push(("stop_period_minutes".to_string(), Scalar::i64(period_minutes))); - named.push(("stop_length".to_string(), Scalar::i64(length))); - named.push(("stop_k".to_string(), Scalar::f64(k))); - } - StopRule::Fixed(_) => {} - } - // Stamp the cost model the member ran under, beside the stop knobs (#234, - // the #233 pattern): one `cost[k].` param per component, in - // component order — the knob name discriminates the variant (each shipped - // cost node has exactly one, distinctly named knob). The name is read off - // `campaign_run::cost_knob`, the same function `cost_nodes_for` binds - // through, so the stamp key cannot drift from the bind key: the manifest - // carries enough to re-derive the exact model later. `reproduce_family_in` - // reads this stamp back via `cost_specs_from_params` (the #233 stop-regime - // pattern), so a costed family reproduces net, not gross. - for (k, spec) in cost.iter().enumerate() { - let (knob, v) = campaign_run::cost_knob(spec, instrument); - named.push((format!("cost[{k}].{knob}"), Scalar::f64(v))); - } - let mut manifest = sim_optimal_manifest(named, window, seed, pip); - manifest.defaults = defaults; - manifest.broker = r_sma_broker_label(pip); - manifest.topology_hash = Some(topo.to_string()); - manifest.project = env.provenance(); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - // The member's cost rows (empty when no cost model) join the R reduction: - // net_expectancy_r diverges from gross by exactly the modelled costs. - let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); - let (total_pips, max_drawdown) = rx_eq - .try_iter() - .next() - .map(|(_, row)| (row[0].as_f64(), row[1].as_f64())) - .unwrap_or((0.0, 0.0)); - let bias_sign_flips = - rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); - let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; - m.r = Some(summarize_r(&r_rows, &cost_rows)); - RunReport { manifest, metrics: m } -} - -/// The exact wrapped probe the loaded-blueprint sweep resolves its axes -/// against: the loaded signal wrapped in the r-sma scaffolding (stop bound, -/// reduce, no cost), taps discarded. `param_space()` on it is the axis -/// namespace `--axis` binds; `.axis()` consumes it to seed a sweep. Single -/// source for the sweep terminal, the MC closed-check, AND `--list-axes`, so -/// the listed names track the swept names by construction (incl. across #159's -/// harness retirement). The reload is infallible under the SAME -/// dispatch-boundary contract the callers already rely on: the doc is -/// `blueprint_from_json`-validated at the `["sweep", ..]` / `["mc", ..]` -/// boundary before this runs, so a malformed doc has already exited 2 and -/// never reaches the `.expect`. -fn blueprint_axis_probe(doc: &str, env: &project::Env) -> Composite { - blueprint_axis_probe_reopened(doc, env, &[]) -} - -/// The axis probe with a #246 override set re-opened on the strategy BEFORE -/// wrapping — probe and per-member reloads must re-open identically so points -/// resolve against one space. The empty-set form is byte-equal to the old -/// probe (mc/run closed-checks and the open `--list-axes` lines read that). -fn blueprint_axis_probe_reopened(doc: &str, env: &project::Env, overrides: &[String]) -> Composite { - let signal = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"); - let signal = reopen_all(signal, overrides); - // The PROBE binding is lenient (unresolvable roles fall back to close), - // like the probe's synthetic pip: the wrap is built for its param_space - // only, never run over data — strict resolution lives on the run paths. - let probe = binding::probe_binding(signal.input_roles()); - let (tx_eq, _) = mpsc::channel(); - let (tx_ex, _) = mpsc::channel(); - let (tx_r, _) = mpsc::channel(); - let (tx_req, _) = mpsc::channel(); - wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, SYNTHETIC_PIP_SIZE, &probe, None) -} - -/// The WRAPPED-coordinate BOUND param name set of `signal` (#246): every -/// `bound_param_space()` entry prefixed with `.` — the same -/// coordinate `param_space()`'s OPEN entries live in on the wrapped graph. -/// Extracted because `wrapped_bound_overrides_of`, `override_paths`, and -/// `validate_and_register_axes` each independently rebuilt this exact set -/// (the rule-of-three the neighbouring doc comment itself cites). -fn wrapped_bound_names(signal: &Composite) -> HashSet { - let prefix = format!("{}.", signal.name()); - signal - .bound_param_space() - .into_iter() - .map(|b| format!("{prefix}{}", b.name)) - .collect() -} - -/// The override subset of `names` (#246): every WRAPPED-coordinate name -/// missing the un-reopened wrapped OPEN space but naming a BOUND param of the -/// strategy — returned in STRATEGY coordinates (the wrap prefix -/// `.` stripped) for `Composite::reopen`. Names matching -/// neither space are skipped here; the caller decides whether that is an -/// error (sweep boundary) or falls through to its existing resolution -/// errors. The silent variant `reproduce_family_in` uses over the RECORDED -/// manifest param names (the sweep boundary uses the stricter -/// `override_paths`, which errors on an unmatched name instead). Contrast -/// `campaign_run::raw_bound_overrides_of`, whose `names` are already RAW -/// (a campaign document's own namespace, #203) and needs no such stripping. -fn wrapped_bound_overrides_of( - names: &[String], - open_space: &[ParamSpec], - signal: &Composite, -) -> Vec { - let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect(); - let prefix = format!("{}.", signal.name()); - let bound = wrapped_bound_names(signal); - names - .iter() - .filter(|n| !open.contains(n.as_str()) && bound.contains(*n)) - .map(|n| n[prefix.len()..].to_string()) - .collect() -} - -/// The sweep-boundary variant (#246): like `wrapped_bound_overrides_of`, but -/// an axis matching NEITHER the open nor the bound space is the error — the -/// honest replacement of the retired "fully bound; nothing to sweep" refusal. -fn override_paths( - axes: &[(String, Vec)], - open_space: &[ParamSpec], - signal: &Composite, -) -> Result, String> { - let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect(); - let prefix = format!("{}.", signal.name()); - let bound = wrapped_bound_names(signal); - let mut overrides = Vec::new(); - for (name, _) in axes { - if open.contains(name.as_str()) { - continue; - } - if bound.contains(name) { - overrides.push(name[prefix.len()..].to_string()); - } else { - return Err(format!( - "axis {name}: names no param of this blueprint (open or bound) — \ - see `aura sweep --list-axes`" - )); - } - } - Ok(overrides) -} - -/// Renders a [`BindError`] as one-line prose in `override_paths`' sibling -/// register above (#247) — never the raw Rust `Debug` struct name -/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep -/// terminal actually raises past `override_paths`' own pre-flight (which -/// already rejects an unresolvable axis name as prose before either call -/// site below ever reaches the terminal). `UnknownKnob` already carries a -/// fully-prosed message string (wrapped from `override_paths`' own -/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so -/// the walkforward path's rejection reaches stderr as bare prose too. -fn render_bind_error(e: &BindError) -> String { - match e { - BindError::MissingKnob(name) => format!( - "axis {name}: an open param with no axis and no bound default — \ - bind it with `--axis {name}=` — see `aura sweep --list-axes`" - ), - BindError::KindMismatch { knob, expected, got } => format!( - "axis {knob}: expected {expected:?}, supplied {got:?} — \ - see `aura sweep --list-axes`" - ), - BindError::UnknownKnob(msg) => msg.clone(), - BindError::DuplicateBinding(name) => format!( - "axis {name}: bound twice — each param takes exactly one axis — \ - see `aura sweep --list-axes`" - ), - BindError::EmptyAxis(name) => format!( - "axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`" - ), - BindError::EmptyRange(name) => format!( - "axis {name}: the named range is empty — give it at least one value" - ), - // A blueprint defect, not an axis usage error: the point passed name - // resolution but its bootstrap failed. Prose frame with the compile - // detail explicitly labelled as internal — per-variant prose for - // CompileError belongs to the graph-build surface, not this boundary. - BindError::Compile(e) => format!( - "the resolved axis point failed to bootstrap — the blueprint is \ - defective at this point, re-validate it with `aura graph build` \ - (internal detail: {e:?})" - ), - } -} - -/// Apply a validated override set to a freshly loaded strategy (#246). -/// Infallible by contract: the set was derived against this same document at -/// the family boundary. -fn reopen_all(signal: Composite, overrides: &[String]) -> Composite { - overrides.iter().fold(signal, |s, p| { - s.reopen(p).expect("override set validated at the family boundary") - }) -} - /// `aura sweep --list-axes`: one `:` line per open /// sweepable knob, in `param_space()` order (byte-identical to before #246), /// followed by one `: default=` line per BOUND param — @@ -2319,7 +831,7 @@ fn reopen_all(signal: Composite, overrides: &[String]) -> Composite { /// accepts a bound name regardless of how many knobs happen to be open /// alongside it, so the discovery surface must list it regardless too. Names /// are exactly what `--axis` binds. -fn list_blueprint_axes(doc: &str, env: &project::Env) { +fn list_blueprint_axes(doc: &str, env: &aura_runner::project::Env) { let space = blueprint_axis_probe(doc, env).param_space(); for p in &space { println!("{}:{:?}", p.name, p.kind); // ScalarKind Debug -> I64/F64/Bool/Timestamp @@ -2332,351 +844,6 @@ fn list_blueprint_axes(doc: &str, env: &project::Env) { } } -/// Sweep a serialized signal `doc` over user-named param-space axes. Structurally it -/// keeps the shape of the retired `r_sma_sweep_family` demo builder (#159), with three -/// deviations. (1) The signal source is -/// `wrap_r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built -/// r-sma graph. (2) The signal is RE-loaded from `doc` per member (a `Composite` is -/// `!Clone`, so the throwaway param-space probe and each grid point each reload). (3) -/// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs): -/// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a -/// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message -/// string — a named error, never a panic. An axis naming a bound param re-opens it -/// (#246: bound value = default); an axis matching neither space is refused by -/// `override_paths` before any run. Every member manifest carries the shared -/// `topology_hash` of the loaded signal; reduce-mode fold, identical to the retired -/// mirror's default (no-trace) arm. -fn blueprint_sweep_family( - doc: &str, - axes: &[(String, Vec)], - data: &DataSource, - env: &project::Env, -) -> Result { - // Identity + binding read the AUTHORED doc, raw (no override re-open): - // topology and the resolved role plan are properties of the document, not - // of any one sweep's axis choices. - let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"); - let topo = topology_hash(&probe_signal); - // Strict binding resolution (name defaults — the verb path carries no - // campaign overrides): the family's open plan and wrap plan in one value. - let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?; - if matches!(data, DataSource::Synthetic) && !binding.close_only() { - return Err(binding::synthetic_refusal(probe_signal.name(), &binding)); - } - let pip = data.pip_size(); - let window = data.full_window(env); - // The un-reopened wrapped OPEN space (#246): derives the override set (which - // named axes re-open a bound param) before the real, reopened probe is built — - // probe and per-member reloads must re-open identically so points resolve - // against one space. A name matching neither space is the error here. - let raw_space = blueprint_axis_probe(doc, env).param_space(); - let overrides = override_paths(axes, &raw_space, &probe_signal)?; - let probe = blueprint_axis_probe_reopened(doc, env, &overrides); - let space = probe.param_space(); - // The doc is parse-validated at the dispatch boundary (with file-path context), - // so every reload here is infallible: the builder has a single error contract — - // the `BindError` returned by the sweep terminal — and no hidden process exit. - // Member reloads re-open the SAME override set derived above, so every member - // resolves its axes against the identical (reopened) param space the probe used. - let reload = |d: &str| { - reopen_all( - blueprint_from_json(d, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"), - &overrides, - ) - }; - // seed the named axes verbatim: the first via Composite::axis (consumes the probe), - // the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the - // sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked. - let mut iter = axes.iter(); - let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis"); - let mut binder = probe.axis(first_name, first_vals.clone()); - for (n, vals) in iter { - binder = binder.axis(n, vals.clone()); - } - binder - .sweep(|point| { - // fresh per-member graph (Composite is !Clone, reload per member) run through - // the shared reduce-mode member path — the same fn reproduction re-runs. - run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) - }) - // render the sweep terminal's BindError to prose (#247), the fn's String error - // contract — never the raw Debug struct. - .map_err(|e| render_bind_error(&e)) -} - -/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window -/// `[from,to]` — the windowed, lattice-carrying twin of -/// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select -/// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the -/// sweep terminal (no panic, no hidden exit) for the caller to render. An axis -/// naming a bound param re-opens it (#246: bound value = default, same -/// `override_paths`/`reopen_all` recipe as `blueprint_sweep_family` — this is -/// its walk-forward in-sample twin); an axis matching neither space is refused -/// (wrapped as `BindError::UnknownKnob`, the honest replacement for the retired -/// "fully bound; nothing to sweep" refusal) before any member runs. -fn blueprint_sweep_over( - doc: &str, axes: &[(String, Vec)], from: Timestamp, to: Timestamp, data: &DataSource, - env: &project::Env, binding: &binding::ResolvedBinding, -) -> Result<(SweepFamily, Vec), BindError> { - let reload = |d: &str| { - blueprint_from_json(d, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible") - }; - let pip = data.pip_size(); - let probe_signal = reload(doc); - let topo = topology_hash(&probe_signal); - // The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy - // load: derives the override set (which named axes re-open a bound param) before - // the reopened probe is built — probe and per-member reloads must re-open - // identically so points resolve against one space, exactly like - // `blueprint_sweep_family`. - let raw_space = blueprint_axis_probe(doc, env).param_space(); - let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?; - let probe = blueprint_axis_probe_reopened(doc, env, &overrides); - let space = probe.param_space(); - let mut iter = axes.iter(); - let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis"); - let mut binder = probe.axis(first_name, first_vals.clone()); - for (n, vals) in iter { - binder = binder.axis(n, vals.clone()); - } - binder.sweep_with_lattice(|point| { - let sources = data.windowed_sources(from, to, env, &binding.columns()); - let window = window_of(&sources).expect("non-empty in-sample window"); - run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT) - }) -} - -/// Run the winner params over an out-of-sample window `[from,to]` on the loaded -/// blueprint. The reduce-mode member -/// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching -/// segment is empty (an empty segment leaves the stitched curve unbroken). `overrides` -/// (#246) is the SAME family-wide set `blueprint_walkforward_family` derived once and -/// resolved `space`/`params` against — the OOS reload must re-open it too, or a -/// bound-param axis's winner point (kind-checked against the REOPENED space) fails -/// `bootstrap_with_cells`'s arity check against this still-closed reload. -#[allow(clippy::too_many_arguments)] -fn run_oos_blueprint( - doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp, - topo: &str, data: &DataSource, env: &project::Env, binding: &binding::ResolvedBinding, - overrides: &[String], -) -> (Vec<(Timestamp, f64)>, RunReport) { - let reload = reopen_all( - blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"), - overrides, - ); - let pip = data.pip_size(); - let sources = data.windowed_sources(from, to, env, &binding.columns()); - let window = window_of(&sources).expect("non-empty out-of-sample window"); - let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT); - (Vec::new(), report) -} - -/// A constant, zero-compute `RunReport` for [`validate_axis_grid`]'s sweep-terminal -/// probe. `SweepBinder::sweep_with_lattice`'s own `resolve_axes`/arity/kind checks all -/// run BEFORE this closure is invoked per grid point, so its body never influences the -/// validation outcome — only its signature (`Fn(&[Cell]) -> RunReport`) needs to -/// satisfy the terminal, at O(1) cost per point instead of a full member run. -fn axis_grid_probe_report() -> RunReport { - RunReport { - manifest: RunManifest { - commit: String::new(), - params: Vec::new(), - defaults: Vec::new(), - window: (Timestamp(0), Timestamp(0)), - seed: 0, - broker: "wf-axis-preflight-placeholder".to_string(), - selection: None, - instrument: None, - topology_hash: None, - project: None, - }, - metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None }, - } -} - -/// Validate the `--axis` grid against `doc`'s wrapped param space WITHOUT running any -/// member (#253). Reuses the SAME strict, erroring axis-name check -/// `blueprint_sweep_over` performs (`override_paths` — single-sourced, so the two -/// paths cannot drift to differently-worded rejections) to derive the #246 override -/// set, then drives the sweep terminal's own `resolve_axes`/arity/kind checks -/// (`SweepBinder::sweep_with_lattice`) with [`axis_grid_probe_report`] standing in for -/// the run closure — no data access, no sim engine tick. Axis resolution is -/// window-agnostic, so the caller derives or passes no window at all. -fn validate_axis_grid( - doc: &str, axes: &[(String, Vec)], raw_space: &[ParamSpec], probe_signal: &Composite, - env: &project::Env, -) -> Result<(), BindError> { - let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?; - let probe = blueprint_axis_probe_reopened(doc, env, &overrides); - let mut iter = axes.iter(); - let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis"); - let mut binder = probe.axis(first_name, first_vals.clone()); - for (n, vals) in iter { - binder = binder.axis(n, vals.clone()); - } - binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ()) -} - -/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the -/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the -/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`; -/// only the per-window sweep/OOS source the loaded blueprint. In-closure errors -/// (a bad `--axis`) `exit(2)` with the sweep terminal's message. -fn blueprint_walkforward_family( - doc: &str, axes: &[(String, Vec)], data: &DataSource, select: Selection, - env: &project::Env, -) -> WalkForwardResult { - let span = data.wf_full_span(env); - let (is_len, oos_len, step) = data.wf_window_sizes(); - let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) { - Ok(r) => r, - Err(e) => { - eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}"); - std::process::exit(2); - } - }; - let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"); - let topo = topology_hash(&probe_signal); - // The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy - // load: derives the override set ONCE for the whole family — every per-window - // sweep AND the OOS reload re-open the SAME set, mirroring - // `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant - // (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis - // matching neither space is simply not an override here — the strict check + its - // established error message stay single-sourced in `blueprint_sweep_over`'s own - // pre-flight call below, so this derivation cannot double-validate with a - // differently-worded rejection. - let axis_names: Vec = axes.iter().map(|(n, _)| n.clone()).collect(); - let raw_space = blueprint_axis_probe(doc, env).param_space(); - let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal); - let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space(); - // Strict binding resolution, once per family; refusal is the established - // `aura: ` + exit-1 register (the roller's usage refusals stay exit 2). - let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new()) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); - if matches!(data, DataSource::Synthetic) && !binding.close_only() { - eprintln!("aura: {}", binding::synthetic_refusal(probe_signal.name(), &binding)); - std::process::exit(1); - } - // Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep` - // (which resolves its axes a single time before any member runs). `walk_forward` fans - // the per-window closure out across the windows in parallel, so a `BindError` raised - // *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one - // exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic - // (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without - // running a single member — no IS window (or a second roller) is needed here at all. - if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) { - eprintln!("aura: {}", render_bind_error(&e)); - std::process::exit(2); - } - walk_forward(roller, space.clone(), |w: WindowBounds| { - let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding) - .expect("axes validated in the dispatch-boundary pre-flight"); - let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) { - Ok(v) => v, - Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } - }; - let (oos_equity, mut oos_report) = - run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides); - oos_report.manifest.selection = Some(selection); - WindowRun { chosen_params: best.params, oos_equity, oos_report } - }) -} - -/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s -/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the -/// `aura mc ` persist path AND the reproduce MonteCarlo branch, so the -/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded -/// r-sma graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades. -fn synthetic_walk_sources(seed: u64) -> Vec> { - let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 }; - vec![Box::new(spec.source(seed))] -} - -/// Build a Monte-Carlo family from a loaded CLOSED signal blueprint: run the fixed -/// blueprint across `n_seeds` seeds, each seed drawing a distinct synthetic walk. The -/// blueprint must be CLOSED (empty wrapped `param_space`) — MC binds no axis, so a free -/// knob has no binder; an OPEN blueprint yields a named `Err` (exit-free like the sibling -/// [`blueprint_sweep_family`]: the IO wrapper [`run_blueprint_mc`] renders it to stderr + -/// exit 2) before any run, pre-empting the `compile_with_params` arity panic. Each draw -/// runs the shared reduce-mode member path (`run_blueprint_member`, the same fn reproduce -/// re-runs), so reproduction is bit-identical (C1); every member carries the shared -/// `topology_hash`. -fn blueprint_mc_family( - doc: &str, n_seeds: u64, data: &DataSource, env: &project::Env, -) -> Result { - let reload = |d: &str| { - blueprint_from_json(d, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible") - }; - let probe_signal = reload(doc); - let topo = topology_hash(&probe_signal); - // Strict binding resolution (name defaults — mc's synthetic family binds - // no campaign overrides); the exit-free Err contract of this builder. - let binding = binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?; - if !binding.close_only() { - // MC draws ALWAYS run the seeded synthetic close walk (real-data mc - // routes through the campaign sugar and never reaches this builder). - return Err(binding::synthetic_refusal(probe_signal.name(), &binding)); - } - let pip = data.pip_size(); - // probe the wrapped param_space (the same probe the sweep resolves against); - // MC needs it empty. `blueprint_axis_probe` is the single source of that wrap. - let space = blueprint_axis_probe(doc, env).param_space(); - if !space.is_empty() { - // Exit-free like blueprint_sweep_family: the builder's single error contract is this - // returned message (no hidden process exit), so the rejection is unit-testable; the IO - // wrapper run_blueprint_mc renders it to stderr + exit 2 at the boundary. - return Err(format!( - "mc requires a closed blueprint (no free parameters); {} free knob(s) — \ - bind them or use `aura sweep --axis`", - space.len() - )); - } - // Closed blueprint -> an empty base point (as `aura run `); the MC - // draws vary the SEED, not a tuning param (C12 axis 4). Delegate the disjoint C1 draws - // to the shared `monte_carlo` helper — it runs them in parallel across sims (invariant 1), - // deterministic in seed-input order. Each draw - // re-runs the shared reduce-mode member path over its own seeded synthetic walk. - let seeds: Vec = (1..=n_seeds).collect(); - let base_point: Vec = Vec::new(); - let family = monte_carlo(&base_point, &seeds, |seed, _base| { - let sources = synthetic_walk_sources(seed); - let window = window_of(&sources).expect("non-empty synthetic walk"); - run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) - }); - // Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's - // metrics are bit-identical to the first, no seed reached a distinguishable realization — - // the strategy never warmed over the fixed synthetic walk (e.g. a lookback as deep as the - // walk is long), so the "distribution" is a single point masquerading as a family: a wrong - // result with no error. Compare `metrics`, not the whole `RunReport` — the manifest's - // `seed` differs per draw by construction, so a whole-report compare could never detect the - // collapse; the metrics are the realization the seed is meant to move. A single-draw MC - // (n == 1) is trivially "all identical" and is NOT this cross-seed condition, so it passes. - if family.draws.len() >= 2 - && family - .draws - .iter() - .all(|d| d.report.metrics == family.draws[0].report.metrics) - { - return Err( - "mc is vacuous: every seed produced an identical result — the strategy never warmed \ - over the synthetic walk, so no seed reached a distinguishable realization; use a \ - shallower-lookback blueprint or a longer walk" - .to_string(), - ); - } - Ok(family) -} - /// `aura sweep --axis = …`: sweep a loaded signal over its /// named param-space axes (the cycle-2 World/C21 verb). Builds the family via /// [`blueprint_sweep_family`], surfaces an unknown / kind-mismatched axis as a named @@ -2698,7 +865,7 @@ fn blueprint_mc_family( /// this fn. fn run_blueprint_sweep( doc: &str, axes: &[(String, Vec)], name: &str, persist: bool, data: DataSource, - env: &project::Env, + env: &aura_runner::project::Env, ) { let _ = persist; // reserved for the deferred per-member trace path; the family record below is unconditional let family = blueprint_sweep_family(doc, axes, &data, env).unwrap_or_else(|e| { @@ -2745,7 +912,7 @@ fn run_blueprint_sweep( /// Mirrors `run_blueprint_sweep` (content-addressed family verb; no ensure_name_free). fn run_blueprint_walkforward( doc: &str, axes: &[(String, Vec)], name: &str, data: DataSource, select: Selection, - env: &project::Env, + env: &aura_runner::project::Env, ) { let result = blueprint_walkforward_family(doc, axes, &data, select, env); let reg = env.registry(); @@ -2776,7 +943,7 @@ fn run_blueprint_walkforward( /// `topology_hash` (the 0094 hook, so `aura reproduce` re-derives it), record it as a /// `FamilyKind::MonteCarlo` family (C18/C21 lineage), and print each draw's member line /// (carrying the seed) plus the aggregate — mirroring `run_blueprint_sweep`. -fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: &project::Env) { +fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: &aura_runner::project::Env) { let family = blueprint_mc_family(doc, n_seeds, &data, env).unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(2); @@ -2811,184 +978,6 @@ fn run_blueprint_mc(doc: &str, n_seeds: u64, name: &str, data: DataSource, env: println!("{}", mc_aggregate_json(&family.aggregate)); } -/// The Donchian channel length for the canonical closed r_breakout example. Single -/// source for the emitter and the three proof tests that must all agree with the -/// baked `examples/r_breakout.json` — kept honest by one constant instead of a -/// hand-synced `Some(3)` literal recurring across those call sites. -#[cfg(test)] -const R_BREAKOUT_CHANNEL: i64 = 3; - -/// The Donchian breakout signal leg, a pure `price→bias` Composite so it serialises -/// as blueprint data (#159 cut 2). The signal computation matches the retired fused -/// builder's leg (same nodes, same wiring); the pip/R harness is the generic -/// `wrap_r` wrapper, not part of the signal. `channel = Some(n)` binds both rolling -/// nodes (closed); `None` leaves them open, ganged into the single `channel_length` -/// knob (#61) — the channel is structurally ONE parameter. This carve has no production -/// (non-test) caller — it is exercised only by the regeneration + proof tests below; -/// production code loads the shipped JSON, not this builder — hence `#[cfg(test)]`. -#[cfg(test)] -fn r_breakout_signal(channel: Option) -> Composite { - let mut g = GraphBuilder::new("r_breakout_signal"); - let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); - let mut mx_b = RollingMax::builder().named("channel_hi"); - let mut mn_b = RollingMin::builder().named("channel_lo"); - if let Some(n) = channel { - mx_b = mx_b.bind("length", Scalar::i64(n)); - mn_b = mn_b.bind("length", Scalar::i64(n)); - } - let mx = g.add(mx_b); - let mn = g.add(mn_b); - if channel.is_none() { - g.gang("channel_length", [mx.param("length"), mn.param("length")]); - } - let gt_up = g.add(Gt::builder()); - let gt_down = g.add(Gt::builder()); - let up_latch = g.add(Latch::builder()); - let down_latch = g.add(Latch::builder()); - let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1} - let price = g.source_role("price", ScalarKind::F64); - g.feed(price, [delay.input("series"), gt_up.input("a"), gt_down.input("b")]); - g.connect(delay.output("value"), mx.input("series")); - g.connect(delay.output("value"), mn.input("series")); - g.connect(mx.output("value"), gt_up.input("b")); - g.connect(mn.output("value"), gt_down.input("a")); - g.connect(gt_up.output("value"), up_latch.input("set")); - g.connect(gt_down.output("value"), up_latch.input("reset")); - g.connect(gt_down.output("value"), down_latch.input("set")); - g.connect(gt_up.output("value"), down_latch.input("reset")); - g.connect(up_latch.output("value"), exposure.input("lhs")); - g.connect(down_latch.output("value"), exposure.input("rhs")); - g.expose(exposure.output("value"), "bias"); - g.build().expect("r_breakout signal wiring resolves") -} - -/// The EWMA window for the canonical closed r_meanrev example (ganged mean/var Ema -/// length; the open form gangs them structurally via the single `window` knob, #61). -/// Single source for the emitter and the proof tests that must all agree with the -/// baked `examples/r_meanrev.json`. -#[cfg(test)] -const R_MEANREV_WINDOW: i64 = 3; - -/// The Bollinger band half-width (in sigma) for the canonical closed r_meanrev -/// example. Single source alongside [`R_MEANREV_WINDOW`]. -#[cfg(test)] -const R_MEANREV_BAND_K: f64 = 2.0; - -/// The EWMA Bollinger-band mean-reversion signal leg, carved out of the retired fused -/// builder as a pure `price→bias` Composite so it serialises as blueprint data (#159 -/// cut 3). Verbatim signal computation vs that retired fused builder, except the band -/// half-width `band_k*sigma` uses `Scale` (a rosterable multiply) in place of -/// `LinComb(1)`. `#[cfg(test)]`: its only role is regenerating + pinning the examples; -/// production loads the shipped JSON. -#[cfg(test)] -fn r_meanrev_signal(window: Option, band_k: Option) -> Composite { - let mut g = GraphBuilder::new("r_meanrev_signal"); - let (mut mean_b, mut var_b) = - (Ema::builder().named("mean_window"), Ema::builder().named("var_window")); - if let Some(n) = window { - mean_b = mean_b.bind("length", Scalar::i64(n)); - var_b = var_b.bind("length", Scalar::i64(n)); - } - let mean = g.add(mean_b); - let dev = g.add(Sub::builder()); // price - mean - let sq = g.add(Mul::builder()); // dev * dev - let var = g.add(var_b); // EWMA variance - if window.is_none() { - g.gang("window", [mean.param("length"), var.param("length")]); - } - let sigma = g.add(Sqrt::builder()); - let mut band_b = Scale::builder().named("band"); - if let Some(k) = band_k { - band_b = band_b.bind("factor", Scalar::f64(k)); - } - let band = g.add(band_b); - let upper = g.add(Add::builder()); - let lower = g.add(Sub::builder()); - let gt_hi = g.add(Gt::builder()); - let gt_lo = g.add(Gt::builder()); - let short_latch = g.add(Latch::builder()); - let long_latch = g.add(Latch::builder()); - let exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias - let price = g.source_role("price", ScalarKind::F64); - g.feed(price, [mean.input("series"), dev.input("lhs"), gt_hi.input("a"), gt_lo.input("b")]); - g.connect(mean.output("value"), dev.input("rhs")); - g.connect(dev.output("value"), sq.input("lhs")); - g.connect(dev.output("value"), sq.input("rhs")); - g.connect(sq.output("value"), var.input("series")); - g.connect(var.output("value"), sigma.input("value")); - g.connect(sigma.output("value"), band.input("signal")); - g.connect(mean.output("value"), upper.input("lhs")); - g.connect(band.output("value"), upper.input("rhs")); - g.connect(mean.output("value"), lower.input("lhs")); - g.connect(band.output("value"), lower.input("rhs")); - g.connect(upper.output("value"), gt_hi.input("b")); - g.connect(lower.output("value"), gt_lo.input("a")); - g.connect(gt_hi.output("value"), short_latch.input("set")); - g.connect(gt_lo.output("value"), short_latch.input("reset")); - g.connect(gt_lo.output("value"), long_latch.input("set")); - g.connect(gt_hi.output("value"), long_latch.input("reset")); - g.connect(long_latch.output("value"), exposure.input("lhs")); - g.connect(short_latch.output("value"), exposure.input("rhs")); - g.expose(exposure.output("value"), "bias"); - g.build().expect("r_meanrev signal wiring resolves") -} - -/// The channel length for the canonical closed r_channel example. Single -/// source for the emitter and the proof tests that must agree with the baked -/// `examples/r_channel.json` (mirrors [`R_BREAKOUT_CHANNEL`]). -#[cfg(test)] -const R_CHANNEL_LENGTH: i64 = 3; - -/// The OHLC high/low-channel (Donchian-shape) signal leg — the harness-input- -/// binding acceptance strategy (#231): bias goes long when the CLOSE breaks -/// the rolling max of the previous `n` HIGHS, short when it breaks the rolling -/// min of the previous `n` LOWS. Three input roles (`high`, `low`, `close` — -/// the role names ARE the column binding), declared in canonical column order. -/// `channel = Some(n)` binds both rolling nodes (closed); `None` leaves them -/// open, ganged into the single `channel_length` knob (#61). `#[cfg(test)]`: -/// production loads the shipped JSON; this builder only regenerates + pins it -/// (the r_breakout/r_meanrev carve pattern). -#[cfg(test)] -fn r_channel_signal(channel: Option) -> Composite { - let mut g = GraphBuilder::new("hl_channel"); - let delay_hi = g.add(Delay::builder().named("prev_high").bind("lag", Scalar::i64(1))); - let delay_lo = g.add(Delay::builder().named("prev_low").bind("lag", Scalar::i64(1))); - let mut mx_b = RollingMax::builder().named("channel_hi"); - let mut mn_b = RollingMin::builder().named("channel_lo"); - if let Some(n) = channel { - mx_b = mx_b.bind("length", Scalar::i64(n)); - mn_b = mn_b.bind("length", Scalar::i64(n)); - } - let mx = g.add(mx_b); - let mn = g.add(mn_b); - if channel.is_none() { - g.gang("channel_length", [mx.param("length"), mn.param("length")]); - } - let gt_up = g.add(Gt::builder()); - let gt_down = g.add(Gt::builder()); - let up_latch = g.add(Latch::builder()); - let down_latch = g.add(Latch::builder()); - let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1} - let high = g.source_role("high", ScalarKind::F64); - let low = g.source_role("low", ScalarKind::F64); - let close = g.source_role("close", ScalarKind::F64); - g.feed(high, [delay_hi.input("series")]); - g.feed(low, [delay_lo.input("series")]); - g.feed(close, [gt_up.input("a"), gt_down.input("b")]); - g.connect(delay_hi.output("value"), mx.input("series")); - g.connect(delay_lo.output("value"), mn.input("series")); - g.connect(mx.output("value"), gt_up.input("b")); - g.connect(mn.output("value"), gt_down.input("a")); - g.connect(gt_up.output("value"), up_latch.input("set")); - g.connect(gt_down.output("value"), up_latch.input("reset")); - g.connect(gt_down.output("value"), down_latch.input("set")); - g.connect(gt_up.output("value"), down_latch.input("reset")); - g.connect(up_latch.output("value"), exposure.input("lhs")); - g.connect(down_latch.output("value"), exposure.input("rhs")); - g.expose(exposure.output("value"), "bias"); - g.build().expect("hl_channel signal wiring resolves") -} - /// Parse the `--params` value: a JSON array of externally-tagged `Scalar` cells /// (`[{"I64":2},{"F64":0.5}]`, the #155 wire form `Scalar` derives via serde). The cells /// bind positionally against the loaded signal's `param_space`. A malformed array is @@ -3458,44 +1447,6 @@ fn run_data_from(real: Option<&str>, from: Option, to: Option) -> RunD } } -/// 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). -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. -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) -} - /// Resolve a single optional stop knob (`--stop-length`/`--stop-k`): an absent flag /// defaults to `default`; a present one parses via [`parse_csv_list`] and refuses /// unless it holds exactly one value (the stop is a risk regime, not a swept axis). @@ -3699,7 +1650,7 @@ enum AxisRegisterError { /// every campaign-path dispatcher (sweep/generalize/walkforward/mc all landed /// the identical block; #220 slice-1 deferred this dedup to "once wf/mc land /// the same block", rule-of-three now exceeded 4x). The override set itself is -/// re-derived downstream (`CliMemberRunner::run_member`, +/// re-derived downstream (`DefaultMemberRunner::run_member`, /// `verb_sugar::validate_before_register`) rather than threaded through this /// return value — this preflight only decides go/no-go on the NAME. #[allow(clippy::type_complexity)] @@ -3707,7 +1658,7 @@ fn validate_and_register_axes( verb: &str, doc: &str, axes: &[(String, Vec)], - env: &project::Env, + env: &aura_runner::project::Env, ) -> Result<(String, Vec<(String, Vec)>), AxisRegisterError> { let space = blueprint_axis_probe(doc, env).param_space(); let blueprint = blueprint_from_json(doc, &|t| env.resolve(t)) @@ -3738,7 +1689,7 @@ fn validate_and_register_axes( .map_err(|e| AxisRegisterError::Registry(e.to_string()))?; let raw_axes: Vec<(String, Vec)> = axes .iter() - .map(|(n, v)| (campaign_run::wrapped_to_raw_axis(n).to_string(), v.clone())) + .map(|(n, v)| (aura_runner::axes::wrapped_to_raw_axis(n).to_string(), v.clone())) .collect(); Ok((canonical, raw_axes)) } @@ -3793,7 +1744,7 @@ pub(crate) fn exit_on_campaign_result(r: Result) { /// no-explicit-window fallback, and intersects the per-symbol results into the /// one shared window (#213); an explicit `--from`+`--to` pair passes through /// unclipped there BY DESIGN. -fn campaign_window_ms(choice: DataChoice, env: &project::Env) -> (i64, i64) { +fn campaign_window_ms(choice: DataChoice, env: &aura_runner::project::Env) -> (i64, i64) { let source = DataSource::from_choice(choice, env); let (from_ts, to_ts) = source.full_window(env); ( @@ -3802,33 +1753,9 @@ fn campaign_window_ms(choice: DataChoice, env: &project::Env) -> (i64, i64) { ) } -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. -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)) - } -} - /// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or /// the built-in harness-kind dispatch. -fn dispatch_run(a: RunCmd, env: &project::Env) { +fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) { match is_blueprint_file(&a.blueprint) { Some(path) => { // The loaded-blueprint grammar takes only --params/--seed/--real/--from/--to; @@ -3925,7 +1852,7 @@ fn dispatch_run(a: RunCmd, env: &project::Env) { } } -fn dispatch_chart(a: ChartCmd, env: &project::Env) { +fn dispatch_chart(a: ChartCmd, env: &aura_runner::project::Env) { emit_chart( &a.name, a.tap.as_deref(), @@ -3934,7 +1861,7 @@ fn dispatch_chart(a: ChartCmd, env: &project::Env) { ); } -fn dispatch_graph(a: GraphCmd, env: &project::Env) { +fn dispatch_graph(a: GraphCmd, env: &aura_runner::project::Env) { match a.sub { None => match is_blueprint_file(&a.blueprint) { Some(path) => { @@ -3971,7 +1898,7 @@ fn dispatch_graph(a: GraphCmd, env: &project::Env) { } } -fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { +fn dispatch_generalize(a: GeneralizeCmd, env: &aura_runner::project::Env) { let usage = || format!("Usage: aura generalize --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--metric ] [--name ] [--from ] [--to ]"); let Some(path) = is_blueprint_file(&a.blueprint) else { eprintln!("aura: {}", usage()); @@ -4064,7 +1991,7 @@ fn dispatch_generalize(a: GeneralizeCmd, env: &project::Env) { exit_on_campaign_result(verb_sugar::run_generalize_sugar(&inv, &metric, env)); } -fn dispatch_runs(a: RunsCmd, env: &project::Env) { +fn dispatch_runs(a: RunsCmd, env: &aura_runner::project::Env) { match a.sub { RunsSub::Families => runs_families(env), RunsSub::Family { id, rank_kw, metric } => match (rank_kw.as_deref(), metric) { @@ -4078,11 +2005,21 @@ fn dispatch_runs(a: RunsCmd, env: &project::Env) { } } -fn dispatch_reproduce(a: ReproduceCmd, env: &project::Env) { - reproduce_family(&a.id, env); +fn dispatch_reproduce(a: ReproduceCmd, env: &aura_runner::project::Env) { + // `reproduce_family` (#295) returns a `RunnerError` instead of exiting + // the process itself; this is the one place that prints its message + // (empty for the "not every member reproduced" refusal, since no stderr + // line accompanies it — only the stdout summary `reproduce_family` + // already emitted) and exits with its code. + if let Err(e) = aura_runner::reproduce::reproduce_family(&a.id, env) { + if !e.message.is_empty() { + eprintln!("aura: {}", e.message); + } + std::process::exit(e.exit_code); + } } -fn dispatch_new(a: NewCmd, _env: &project::Env) { +fn dispatch_new(a: NewCmd, _env: &aura_runner::project::Env) { let cwd = std::env::current_dir().unwrap_or_else(|e| { eprintln!("aura: {e}"); std::process::exit(1); @@ -4112,14 +2049,14 @@ fn dispatch_nodes_new(a: NodesNewCmd) { eprintln!("aura: {e}"); std::process::exit(1); }); - let Some(root) = project::discover_from(&cwd) else { + let Some(root) = aura_runner::project::discover_from(&cwd) else { eprintln!( "aura: `aura nodes new` needs a project (no Aura.toml found up from {})", cwd.display() ); std::process::exit(1); }; - let toml = match project::read_aura_toml(&root) { + let toml = match aura_runner::project::read_aura_toml(&root) { Ok(t) => t, Err(e) => { eprintln!("aura: {e}"); @@ -4165,20 +2102,20 @@ fn dispatch_nodes_new(a: NodesNewCmd) { ); } -fn dispatch_data(cmd: DataCmd, env: &project::Env) { +fn dispatch_data(cmd: DataCmd, env: &aura_runner::project::Env) { match cmd.command { DataCommand::Coverage(a) => dispatch_data_coverage(a, env), DataCommand::List => dispatch_data_list(env), } } -fn dispatch_measure(cmd: MeasureCmd, env: &project::Env) { +fn dispatch_measure(cmd: MeasureCmd, env: &aura_runner::project::Env) { match cmd.command { MeasureCommand::Ic(a) => dispatch_measure_ic(a, env), } } -fn dispatch_measure_ic(a: MeasureIcCmd, env: &project::Env) { +fn dispatch_measure_ic(a: MeasureIcCmd, env: &aura_runner::project::Env) { let traces = env.trace_store().read(&a.run).unwrap_or_else(|e| { eprintln!("aura: reading run '{}' traces failed: {e}", a.run); std::process::exit(1); @@ -4221,7 +2158,7 @@ fn dispatch_measure_ic(a: MeasureIcCmd, env: &project::Env) { /// directory scan. An empty or absent archive is informational absence, not a /// fault: a `no symbols` prose line, exit 0 (the verb corpus's established /// empty-result register — cf. `runs family`'s unknown-id empty exit 0). -fn dispatch_data_list(env: &project::Env) { +fn dispatch_data_list(env: &aura_runner::project::Env) { let data_path = std::path::PathBuf::from(env.data_path()); let server = aura_ingest::DataServer::new(&data_path); for line in data_list_report(&server.symbols()) { @@ -4246,10 +2183,13 @@ fn data_list_report(symbols: &[std::sync::Arc]) -> Vec { /// Resolves the archive root through the normal project path (`Env::data_path` /// — a project-local `[paths] data` override when set, the data-server default /// otherwise), so a project-scoped archive is inventoried, not the host one. -fn dispatch_data_coverage(a: DataCoverageCmd, env: &project::Env) { +fn dispatch_data_coverage(a: DataCoverageCmd, env: &aura_runner::project::Env) { let data_path = std::path::PathBuf::from(env.data_path()); let months = aura_ingest::list_m1_months(&data_path, &a.symbol); - match data_coverage_report(&a.symbol, &months) { + // The report body is single-sourced with a campaign cell's own coverage + // annotation (#295 Task 7: `aura_runner::coverage::interior_gap_months` + // is the one gap walk both consumers call). + match aura_runner::coverage::data_coverage_report(&a.symbol, &months) { Ok(lines) => { for line in lines { println!("{line}"); @@ -4262,51 +2202,9 @@ fn dispatch_data_coverage(a: DataCoverageCmd, env: &project::Env) { } } -/// Pure: render `symbol`'s coverage report from its sorted `(year, month)` file -/// list — one `span:` line framing the first/last present month, then either a -/// `no gaps` line or one `missing: YYYY-MM..YYYY-MM` line per interior -/// contiguous gap (a ten-month hole is one line, not ten). `Err` exactly when -/// `months` is empty — no archive file exists for `symbol` at all (the -/// caller's "unknown symbol" refusal, stderr + exit 1). -fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result, String> { - let Some(&first) = months.first() else { - return Err(format!("no archive files found for symbol \"{symbol}\"")); - }; - let last = *months.last().expect("non-empty checked above"); - let mut lines = vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))]; - let gaps: Vec<((u16, u8), (u16, u8))> = months - .windows(2) - .filter_map(|pair| { - let (prev, next) = (pair[0], pair[1]); - let expected = next_year_month(prev); - (expected != next).then(|| (expected, prev_year_month(next))) - }) - .collect(); - if gaps.is_empty() { - lines.push(format!("{symbol} no gaps")); - } else { - for (from, to) in gaps { - lines.push(format!("{symbol} missing: {}..{}", fmt_year_month(from), fmt_year_month(to))); - } - } - Ok(lines) -} - -fn fmt_year_month((y, m): (u16, u8)) -> String { - format!("{y:04}-{m:02}") -} - -fn next_year_month((y, m): (u16, u8)) -> (u16, u8) { - if m == 12 { (y + 1, 1) } else { (y, m + 1) } -} - -fn prev_year_month((y, m): (u16, u8)) -> (u16, u8) { - if m == 1 { (y - 1, 12) } else { (y, m - 1) } -} - /// `aura sweep`: loaded-blueprint by-name axis sweep (or `--list-axes` probe) when the /// first-positional is an existing `.json`, else the built-in `--strategy` grid sweep. -fn dispatch_sweep(a: SweepCmd, env: &project::Env) { +fn dispatch_sweep(a: SweepCmd, env: &aura_runner::project::Env) { // Single-sourced: the blueprint grammar and the no-blueprint usage error must // stay in lockstep, so both arms below read this one closure. let usage = || "Usage: aura sweep --axis = [--axis …] [--name | --trace ] [--real [--from ] [--to ]]".to_string(); @@ -4424,7 +2322,7 @@ fn dispatch_sweep(a: SweepCmd, env: &project::Env) { /// `aura walkforward`: IS-refit walk-forward over a loaded blueprint — the /// synthetic in-process family (no `--real`) or the campaign path (`--real`, /// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). -fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { +fn dispatch_walkforward(a: WalkforwardCmd, env: &aura_runner::project::Env) { // Single-sourced: both arms below read this one closure (house style, #179). let usage = || format!("Usage: aura walkforward --axis = [--axis …] [--select ] [--name ] | aura walkforward --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--from ] [--to ] [--name | --trace ]"); match is_blueprint_file(&a.blueprint) { @@ -4543,7 +2441,7 @@ fn dispatch_walkforward(a: WalkforwardCmd, env: &project::Env) { /// `aura mc`: Monte-Carlo over a loaded blueprint — the synthetic seed family /// (`--seeds`, closed blueprint) or the R-bootstrap campaign path (`--real`, /// #220: arbitrary user blueprint + axes, formerly the welded r-sma branch). -fn dispatch_mc(a: McCmd, env: &project::Env) { +fn dispatch_mc(a: McCmd, env: &aura_runner::project::Env) { // Single-sourced: every arm below reads this one closure (house style, #179). let usage = || format!("Usage: aura mc --seeds [--name ] | aura mc --real --axis = [--axis …] [--stop-length (default {R_SMA_STOP_LENGTH})] [--stop-k (default {R_SMA_STOP_K:.1})] [--block-len ] [--resamples ] [--seed ] [--from ] [--to ]"); match is_blueprint_file(&a.blueprint) { @@ -4667,20 +2565,20 @@ fn main() { // `aura new`/`aura nodes new` scaffold; they must not require a // loadable project even when invoked inside one (e.g. an unbuilt tree). let env = if matches!(cli.command, Command::New(_) | Command::Nodes(_)) { - project::Env::std() + aura_runner::project::Env::std() } else { match std::env::current_dir() .ok() - .and_then(|d| project::discover_from(&d)) + .and_then(|d| aura_runner::project::discover_from(&d)) { - Some(root) => match project::load(&root, cli.release) { - Ok(p) => project::Env::with_project(p), + Some(root) => match aura_runner::project::load(&root, cli.release) { + Ok(p) => aura_runner::project::Env::with_project(p), Err(e) => { eprintln!("aura: {e}"); std::process::exit(1); } }, - None => project::Env::std(), + None => aura_runner::project::Env::std(), } }; match cli.command { @@ -4706,24 +2604,6 @@ fn main() { 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))); - } - /// Audit follow-up to #247/#269: `render_bind_error` is exhaustive — the /// axis-usage variants the old catch-all Debug-framed render as prose /// naming the axis, with no Rust identifier on the user's stderr. @@ -4753,39 +2633,6 @@ mod tests { assert!(!msg.starts_with("Compile"), "the frame must lead with prose: {msg}"); } - /// An unknown symbol (no archive files at all — an empty month list) - /// refuses rather than reporting a bogus empty-span coverage (#264): the - /// caller eprintln's the message and exits 1. - #[test] - fn data_coverage_report_refuses_an_unknown_symbol() { - let err = data_coverage_report("GHOST", &[]).unwrap_err(); - assert!(err.contains("GHOST"), "names the unknown symbol: {err}"); - } - - /// A symbol with a fully contiguous file index reports its span plus an - /// explicit `no gaps` line — never a bare span with no gap-status line at - /// all, which would leave "no gaps" indistinguishable from "gaps not yet - /// checked" (#264). - #[test] - fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() { - let months = [(2024, 1), (2024, 2), (2024, 3)]; - let lines = data_coverage_report("SYMA", &months).expect("known symbol"); - assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]); - } - - /// The Copper failure shape itself: one interior gap collapses to a single - /// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not - /// one line per missing month (#264). - #[test] - fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() { - let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)]; - let lines = data_coverage_report("GAPSYM", &months).expect("known symbol"); - assert_eq!( - lines, - vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"] - ); - } - /// An empty (or absent) archive is informational absence, not a fault /// (#264): `data_list_report` returns the single `no symbols` prose line /// rather than an empty `Vec`, so the caller's exit-0 println always has @@ -4806,554 +2653,6 @@ mod tests { assert_eq!(data_list_report(&symbols), vec!["SYMA".to_string(), "SYMB".to_string()]); } - /// 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))]), - ); - } - - /// Regenerates the shipped r_breakout examples from the carved signal. Run by hand: - /// `cargo test --bin aura emit_r_breakout_examples -- --ignored`. The examples are - /// green-by-construction (serialised from the builder, never hand-authored). - #[test] - #[ignore = "regenerates crates/aura-cli/examples/r_breakout{,_open}.json; run by hand"] - fn emit_r_breakout_examples() { - std::fs::create_dir_all("examples").expect("examples dir"); - std::fs::write( - "examples/r_breakout.json", - blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed r_breakout"), - ) - .expect("write examples/r_breakout.json"); - std::fs::write( - "tests/fixtures/r_breakout_open.json", - blueprint_to_json(&r_breakout_signal(None)).expect("serialize open r_breakout"), - ) - .expect("write tests/fixtures/r_breakout_open.json"); - } - - /// The shipped examples are a faithful serialisation of the carved signal (#159 cut 2): - /// re-serialising the carve equals the checked-in bytes. Green-by-construction with the - /// emitter; a drift between builder and file breaks it. Survives the fused builder's - /// retirement (it references `r_breakout_signal`, the carve, not the retired builder). - #[test] - fn shipped_r_breakout_examples_serialize_the_carved_signal() { - assert_eq!( - blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed"), - include_str!("../examples/r_breakout.json"), - ); - assert_eq!( - blueprint_to_json(&r_breakout_signal(None)).expect("serialize open"), - include_str!("../tests/fixtures/r_breakout_open.json"), - ); - } - - /// The shipped closed example, reloaded through the data plane and run, grades - /// bit-identically to running the carved signal directly (#159 cut 2). This is - /// r_breakout's durable equivalence anchor after the fused builder retires: it pins - /// that `examples/r_breakout.json` still produces the carve's grade, with no - /// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice. - #[test] - fn r_breakout_example_loaded_runs_identically_to_the_carved_signal() { - let env = project::Env::std(); - let loaded = blueprint_from_json(include_str!("../examples/r_breakout.json"), &|t| std_vocabulary(t)) - .expect("shipped r_breakout example loads"); - let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env); - let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env); - assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); - } - - /// Regenerates the shipped r_meanrev examples from the carved signal. Run by hand: - /// `cargo test --bin aura emit_r_meanrev_examples -- --ignored`. The examples are - /// green-by-construction (serialised from the builder, never hand-authored). - #[test] - #[ignore = "regenerates crates/aura-cli/examples/r_meanrev{,_open}.json; run by hand"] - fn emit_r_meanrev_examples() { - std::fs::create_dir_all("examples").expect("examples dir"); - std::fs::write( - "examples/r_meanrev.json", - blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K))) - .expect("serialize closed r_meanrev"), - ) - .expect("write examples/r_meanrev.json"); - std::fs::write( - "tests/fixtures/r_meanrev_open.json", - blueprint_to_json(&r_meanrev_signal(None, None)).expect("serialize open r_meanrev"), - ) - .expect("write tests/fixtures/r_meanrev_open.json"); - } - - /// Regenerates the shipped r_channel examples from the carved signal. Run by hand: - /// `cargo test --bin aura emit_r_channel_examples -- --ignored`. The examples are - /// green-by-construction (serialised from the builder, never hand-authored). - #[test] - #[ignore = "regenerates crates/aura-cli/examples/r_channel{,_open}.json; run by hand"] - fn emit_r_channel_examples() { - std::fs::create_dir_all("examples").expect("examples dir"); - std::fs::write( - "examples/r_channel.json", - blueprint_to_json(&r_channel_signal(Some(R_CHANNEL_LENGTH))).expect("serialize closed r_channel"), - ) - .expect("write examples/r_channel.json"); - std::fs::write( - "tests/fixtures/r_channel_open.json", - blueprint_to_json(&r_channel_signal(None)).expect("serialize open r_channel"), - ) - .expect("write tests/fixtures/r_channel_open.json"); - } - - /// The shipped examples are a faithful serialisation of the carved signal - /// (the r_breakout pin pattern): re-serialising the carve equals the - /// checked-in bytes; a drift between builder and file breaks it. - #[test] - fn shipped_r_channel_examples_serialize_the_carved_signal() { - assert_eq!( - blueprint_to_json(&r_channel_signal(Some(R_CHANNEL_LENGTH))).expect("serialize closed"), - include_str!("../examples/r_channel.json"), - ); - assert_eq!( - blueprint_to_json(&r_channel_signal(None)).expect("serialize open"), - include_str!("../tests/fixtures/r_channel_open.json"), - ); - } - - /// A 10-bar high/low/close fixture (canonical column order: high, low, - /// close) that warms the hl_channel graph (Delay(1) + Rolling(3)) and - /// breaks out upward then downward. - fn hlc_sources() -> Vec> { - let high = [10.5_f64, 10.6, 10.4, 10.8, 11.5, 12.0, 12.2, 11.0, 10.2, 9.8]; - let low = [9.5_f64, 9.7, 9.6, 9.9, 10.8, 11.4, 11.6, 10.1, 9.4, 9.0]; - let close = [10.0_f64, 10.2, 10.0, 10.5, 11.3, 11.8, 12.0, 10.4, 9.6, 9.2]; - [&high[..], &low[..], &close[..]] - .into_iter() - .map(|col| { - let series: Vec<(Timestamp, Scalar)> = col - .iter() - .enumerate() - .map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v))) - .collect(); - Box::new(VecSource::new(series)) as Box - }) - .collect() - } - - /// The shipped closed example, reloaded through the data plane and run - /// through the MULTI-COLUMN wrap, emits bit-identical bias rows to the - /// carved signal — r_channel's durable equivalence anchor (the r_breakout - /// idiom, over three VecSource columns instead of RunData::Synthetic, - /// which honestly refuses multi-column signals). `wrap_r`'s ex tap reads - /// `sig.output("bias")` directly, so `rx_ex` carries the raw signal bias. - #[test] - fn r_channel_example_loaded_runs_identically_to_the_carved_signal() { - let run = |signal: Composite| -> Vec<(Timestamp, Vec)> { - let binding = - binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .expect("high/low/close roles resolve"); - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, _rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - let flat = wrap_r( - signal, - tx_eq, - tx_ex, - tx_r, - tx_req, - StopRule::Vol { length: 3, k: 2.0 }, - false, - SYNTHETIC_PIP_SIZE, - &binding, - None, - ) - .compile_with_params(&[]) - .expect("closed hl_channel wraps to a valid harness"); - let mut h = Harness::bootstrap(flat).expect("hl_channel harness bootstraps"); - h.run(hlc_sources()); - rx_ex.try_iter().collect() - }; - let loaded = blueprint_from_json(include_str!("../examples/r_channel.json"), &|t| std_vocabulary(t)) - .expect("shipped r_channel example loads"); - let via_file = run(loaded); - let via_carve = run(r_channel_signal(Some(R_CHANNEL_LENGTH))); - assert!(!via_carve.is_empty(), "the channel must emit bias rows over the fixture"); - assert!( - via_carve.iter().any(|(_, row)| row.iter().any(|s| s.as_f64() != 0.0)), - "the fixture must drive a non-zero bias (an all-zero run would make \ - the equivalence vacuous)" - ); - assert_eq!(via_file, via_carve, "loaded example emits the carve's bias rows"); - } - - /// The shipped examples are a faithful serialisation of the carved signal (#159 cut 3): - /// re-serialising the carve equals the checked-in bytes. Green-by-construction with the - /// emitter; a drift between builder and file breaks it. - #[test] - fn shipped_r_meanrev_examples_serialize_the_carved_signal() { - assert_eq!( - blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K))).expect("closed"), - include_str!("../examples/r_meanrev.json"), - ); - assert_eq!( - blueprint_to_json(&r_meanrev_signal(None, None)).expect("open"), - include_str!("../tests/fixtures/r_meanrev_open.json"), - ); - } - - /// The shipped closed example, reloaded through the data plane and run, grades - /// bit-identically to running the carved signal directly (#159 cut 3). This is - /// r_meanrev's durable equivalence anchor after the fused builder retires: it pins - /// that `examples/r_meanrev.json` still produces the carve's grade, with no - /// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice. - #[test] - fn r_meanrev_example_loaded_runs_identically_to_the_carved_signal() { - let env = project::Env::std(); - let loaded = blueprint_from_json(include_str!("../examples/r_meanrev.json"), &|t| std_vocabulary(t)) - .expect("shipped r_meanrev example loads"); - let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env); - let via_carve = run_signal_r( - r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)), - &[], - RunData::Synthetic, - 0, - &env, - ); - assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); - } - - /// Independently pins the shipped `r_meanrev_signal` carve's FADE direction — - /// short (-1) above the band, long (+1) below — using the Scale-based band it - /// actually ships with. The equivalence anchor above only proves the loaded - /// example agrees with the carve; both sides run the same `r_meanrev_signal`, so - /// it is tautological on carve correctness (a mis-wired or sign-inverted carve - /// would fail identically on both sides and still pass). The retired - /// `r_meanrev_graph` builder (LinComb-based) that once graded against this exact - /// polarity is deleted (#159 cut 3); `aura-engine`'s `r_meanrev_e2e` survives but - /// still hand-builds the band with `LinComb(1)`, not `Scale`, so it does not - /// exercise the node this carve actually ships. `k = 0` collapses the band to the - /// lagging EWMA mean, isolating direction + latch from the sigma threshold (same - /// idiom as `r_meanrev_e2e`'s own k=0 case); window 3 (alpha = 0.5) lags the level - /// clearly. `wrap_r`'s `ex` tap reads `sig.output("bias")` directly (before any - /// broker/exec/cost machinery), so `rx_ex` carries the raw signal bias. - #[test] - fn r_meanrev_signal_fades_short_above_the_band_and_long_below() { - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, rx_ex) = mpsc::channel(); - let (tx_r, _rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - let signal = r_meanrev_signal(Some(3), Some(0.0)); - let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .expect("the price role resolves"); - let flat = wrap_r( - signal, - tx_eq, - tx_ex, - tx_r, - tx_req, - StopRule::Vol { length: 3, k: 2.0 }, - false, - SYNTHETIC_PIP_SIZE, - &binding, - None, - ) - .compile_with_params(&[]) - .expect("r-meanrev signal wraps to a valid harness"); - let mut h = Harness::bootstrap(flat).expect("r-meanrev harness bootstraps"); - // calm (price == lagging mean -> no fade) | sustained UP (price > mean -> - // fade SHORT) | sustained DOWN (price < mean -> fade LONG). - let closes = [ - 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0, - ]; - let prices: Vec<(Timestamp, Scalar)> = - closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect(); - let src: Vec> = vec![Box::new(VecSource::new(prices))]; - h.run(src); - let bias: Vec = - rx_ex.try_iter().map(|(_, row): (Timestamp, Vec)| row[0].as_f64()).collect(); - assert!(!bias.is_empty(), "the meanrev exposure tap must emit once warmed up"); - assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade: {bias:?}"); - let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)"); - let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)"); - assert!(first_short < first_long, "short (up-fade) must precede long (down-fade): {bias:?}"); - assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end: {bias:?}"); - } - - /// #234: the optional cost leg records an aggregate cost stream positionally - /// 1:1 with the R record (`summarize_r`'s positional-join contract) plus a - /// per-cycle net_r_equity curve, in non-reduce trace mode. The cost-less - /// side (`cost: None` == today's graph, byte-identical) is pinned by the - /// suite's untouched equivalence anchors. - #[test] - fn wrap_r_cost_leg_records_cost_rows_co_temporal_with_the_r_record() { - let signal = load_closed_r_sma(); - let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .expect("the price role resolves"); - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, _rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - let (tx_cost, rx_cost) = mpsc::channel(); - let (tx_net, rx_net) = mpsc::channel(); - let leg = CostLeg { - nodes: vec![ConstantCost::builder().bind("cost_per_trade", Scalar::f64(0.0005))], - tx_cost, - tx_net, - }; - let flat = wrap_r( - signal, - tx_eq, - tx_ex, - tx_r, - tx_req, - StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, - false, - SYNTHETIC_PIP_SIZE, - &binding, - Some(leg), - ) - .compile_with_params(&[]) - .expect("costed wrap builds"); - let mut h = Harness::bootstrap(flat).expect("costed harness bootstraps"); - let src: Vec> = vec![Box::new(VecSource::new(r_sma_prices()))]; - h.run(src); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); - let net_rows: Vec<(Timestamp, Vec)> = rx_net.try_iter().collect(); - assert!(!r_rows.is_empty(), "the fixture must warm the executor"); - assert_eq!( - cost_rows.len(), - r_rows.len(), - "the cost stream is positionally 1:1 with the R record (co-temporality)" - ); - assert_eq!(net_rows.len(), cost_rows.len(), "the net curve emits per cost row"); - assert!( - cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0), - "at least one close must charge a non-zero cost (non-vacuous)" - ); - } - - /// #234: the reduce-mode cost branch (a `GatedRecorder` with the appended - /// `closed_this_cycle` gate column) stays co-temporal with the gated R - /// record too — the same 1:1 join contract as the trace-mode leg above, - /// but over the member/sweep run path `summarize_r` actually consumes. - #[test] - fn wrap_r_cost_leg_in_reduce_mode_stays_co_temporal_with_the_gated_r_record() { - let signal = load_closed_r_sma(); - let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .expect("the price role resolves"); - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, _rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - let (tx_cost, rx_cost) = mpsc::channel(); - let (tx_net, _rx_net) = mpsc::channel(); - let leg = CostLeg { - nodes: vec![ConstantCost::builder().bind("cost_per_trade", Scalar::f64(0.0005))], - tx_cost, - tx_net, - }; - let flat = wrap_r( - signal, - tx_eq, - tx_ex, - tx_r, - tx_req, - StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, - true, - SYNTHETIC_PIP_SIZE, - &binding, - Some(leg), - ) - .compile_with_params(&[]) - .expect("costed reduce-mode wrap builds"); - let mut h = Harness::bootstrap(flat).expect("costed reduce-mode harness bootstraps"); - let src: Vec> = vec![Box::new(VecSource::new(r_sma_prices()))]; - h.run(src); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); - assert!(!r_rows.is_empty(), "the fixture must warm and close at least one trade"); - assert_eq!( - cost_rows.len(), - r_rows.len(), - "the reduce-mode gated cost stream is positionally 1:1 with the gated R record" - ); - assert!( - cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0), - "at least one gated close must charge a non-zero cost (non-vacuous)" - ); - } - - /// #234: a cost component that declares an extra `volatility` port (the - /// `VolSlippageCost` shape) is wired to a live realized-range proxy built - /// from the close role (`RollingMax`/`RollingMin`/`Sub` over - /// `SLIP_VOL_LENGTH`), discovered purely from the component's own schema - /// past the geometry prefix (`vol_slots`) — never a disconnected or - /// hardwired-zero input. If that wiring regresses, `ctx.f64_in(GEOMETRY_WIDTH)` - /// inside the component stays permanently empty and every charge is exactly - /// 0.0 (`vol_not_yet_warm_emits_zero_cost_co_temporally`'s withhold case), so a - /// non-zero charge over a genuinely moving price series is a direct witness - /// that the proxy reached the component. The two tests above use - /// `ConstantCost`, which never touches this wiring at all. - #[test] - fn wrap_r_cost_leg_wires_the_vol_slippage_proxy_from_the_close_role() { - let signal = load_closed_r_sma(); - let binding = binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) - .expect("the price role resolves"); - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, _rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - let (tx_cost, rx_cost) = mpsc::channel(); - let (tx_net, _rx_net) = mpsc::channel(); - let leg = CostLeg { - nodes: vec![VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(0.5))], - tx_cost, - tx_net, - }; - let flat = wrap_r( - signal, - tx_eq, - tx_ex, - tx_r, - tx_req, - StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, - false, - SYNTHETIC_PIP_SIZE, - &binding, - Some(leg), - ) - .compile_with_params(&[]) - .expect("vol-slippage-costed wrap builds"); - let mut h = Harness::bootstrap(flat).expect("vol-slippage-costed harness bootstraps"); - let src: Vec> = vec![Box::new(VecSource::new(r_sma_prices()))]; - h.run(src); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); - assert!(!r_rows.is_empty(), "the fixture must warm and close at least one trade"); - assert_eq!( - cost_rows.len(), - r_rows.len(), - "the vol-slippage cost stream stays positionally 1:1 with the R record" - ); - assert!( - cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0), - "the volatility proxy must actually feed the component — a disconnected \ - proxy leaves cost_in_r at exactly 0.0 on every close: {cost_rows:?}" - ); - } - - /// #234: a member run under a constant cost model nets the hand-computed - /// per-trade cost — `net_expectancy_r ≈ expectancy_r − mean(cost_per_trade - /// / |entry − stop|)` over summarize_r's ledger (closed rows + the - /// window-end open row; the CostRunner R-normalization contract), while - /// every gross field stays byte-identical to the cost-less run; and the - /// manifest stamps the component for reproduce to re-derive. - #[test] - fn run_blueprint_member_joins_a_constant_cost_model_into_net_metrics() { - let env = project::Env::std(); - let data = DataSource::Synthetic; - let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); - let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); - let space = blueprint_axis_probe(&doc, &env).param_space(); - let binding = binding::resolve_binding("costnet", reload().input_roles(), &BTreeMap::new()) - .expect("the price role resolves"); - let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }; - let window = data.full_window(&env); - let pip = data.pip_size(); - const CPT: f64 = 0.0005; // price units; the synthetic stream trades near 1.0 - let run = |cost: &[aura_research::CostSpec]| { - run_blueprint_member( - reload(), - &[], - &space, - data.run_sources(&env, &binding.columns()), - window, - 0, - pip, - "topo", - &env, - stop, - &binding, - cost, - "GER40", - ) - }; - let gross = run(&[]); - let netted = run(&[aura_research::CostSpec::Constant { - cost_per_trade: aura_research::CostValue::Scalar(CPT), - }]); - - // The trade geometry, independently: a non-reduce cost-less wrap over - // the same realization, draining the dense R record whose ledger - // summarize_r reads. - let (tx_eq, _rx_eq) = mpsc::channel(); - let (tx_ex, _rx_ex) = mpsc::channel(); - let (tx_r, rx_r) = mpsc::channel(); - let (tx_req, _rx_req) = mpsc::channel(); - let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, None) - .compile_with_params(&[]) - .expect("wraps"); - let mut h = Harness::bootstrap(flat).expect("bootstraps"); - h.run(data.run_sources(&env, &binding.columns())); - let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); - - // summarize_r's ledger: closed rows, plus the final row if open — each - // charged cost_per_trade / |entry − stop| (0 when the latched distance - // is 0). PM record cols: closed=0, entry_price=6, stop_price=7, - // open=11 (the aura-analysis r_col contract). - let mut costs: Vec = Vec::new(); - for (i, (_, row)) in r_rows.iter().enumerate() { - let is_last = i == r_rows.len() - 1; - if row[0].as_bool() || (is_last && row[11].as_bool()) { - let latched = (row[6].as_f64() - row[7].as_f64()).abs(); - costs.push(if latched > 0.0 { CPT / latched } else { 0.0 }); - } - } - let g = gross.metrics.r.as_ref().expect("gross member carries R metrics"); - let n = netted.metrics.r.as_ref().expect("netted member carries R metrics"); - assert_eq!( - costs.len() as u64, g.n_trades, - "the independent ledger walk must see the member's trades" - ); - assert!(costs.iter().any(|&c| c > 0.0), "at least one trade must charge (non-vacuous)"); - let mean_cost = costs.iter().sum::() / costs.len() as f64; - - assert_eq!(gross.metrics.total_pips, netted.metrics.total_pips, "gross pips unchanged"); - assert_eq!(g.expectancy_r, n.expectancy_r, "gross R stays byte-identical under cost"); - assert_eq!(g.n_trades, n.n_trades); - assert_eq!(g.net_expectancy_r, g.expectancy_r, "cost-less: net == gross"); - assert!( - (n.net_expectancy_r - (g.expectancy_r - mean_cost)).abs() < 1e-12, - "net = gross − mean per-trade cost: net {} gross {} mean_cost {mean_cost}", - n.net_expectancy_r, - g.expectancy_r - ); - // The manifest stamps the component (the reproduce re-derivation carrier)... - assert!( - netted - .manifest - .params - .iter() - .any(|(k, v)| k == "cost[0].cost_per_trade" && *v == Scalar::f64(CPT)), - "member manifest stamps the cost component: {:?}", - netted.manifest.params - ); - // ...and a cost-less member stamps nothing (content/label stability). - assert!( - !gross.manifest.params.iter().any(|(k, _)| k.starts_with("cost[")), - "a cost-less member stamps no cost params" - ); - } - /// #234: `campaign_run::persist_campaign_traces`'s C1 drift alarm re-runs a /// costed member in `!reduce` mode (fresh per-cycle taps + a `CostLeg` /// bound the same way `cost_nodes_for` binds it) and compares the result @@ -5366,13 +2665,13 @@ mod tests { /// costed campaign. #[test] fn persist_side_nonreduce_rerun_matches_reduce_mode_net_metrics_under_cost() { - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let data = DataSource::Synthetic; let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); let space = blueprint_axis_probe(&doc, &env).param_space(); let binding = - binding::resolve_binding("persistnet", reload().input_roles(), &BTreeMap::new()) + aura_runner::binding::resolve_binding("persistnet", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }; let window = data.full_window(&env); @@ -5382,7 +2681,7 @@ mod tests { }]; // The recorded member: reduce-mode, cost-bound — `run_blueprint_member`, - // the exact path `CliMemberRunner::run_member` calls. + // the exact path `DefaultMemberRunner::run_member` calls. let recorded = run_blueprint_member( reload(), &[], @@ -5400,7 +2699,7 @@ mod tests { ); // The persist-side re-run, verbatim structure: `!reduce` + a `CostLeg` - // built through the SAME `campaign_run::cost_nodes_for`, then + // built through the SAME `aura_runner::translate::cost_nodes_for`, then // `summarize` + `summarize_r(&r_rows, &cost_rows)` — exactly // `persist_campaign_traces`'s C1 drift-alarm computation. let (tx_eq, rx_eq) = mpsc::channel(); @@ -5410,7 +2709,7 @@ mod tests { let (tx_cost, rx_cost) = mpsc::channel(); let (tx_net, _rx_net) = mpsc::channel(); let cost_leg = - Some(CostLeg { nodes: campaign_run::cost_nodes_for(&cost, "GER40"), tx_cost, tx_net }); + Some(CostLeg { nodes: aura_runner::translate::cost_nodes_for(&cost, "GER40"), tx_cost, tx_net }); let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, cost_leg) .compile_with_params(&[]) .expect("the persist-side wrap builds"); @@ -5435,67 +2734,6 @@ mod tests { ); } - /// Property: the pip a run resolves and stamps into its manifest must be the - /// pip the in-graph `SimBroker` divides by — the resolved (real, per-instrument) - /// pip has to reach the graph, not just the manifest label. The `SimBroker` - /// integrates `exposure * (price - prev_price) / pip`, so the raw price-move - /// total `total_pips * pip` is pip-invariant: the SAME signal + prices run at - /// two different pips must agree on that product. If `pip` only decorates the - /// label and the graph always divides by the synthetic default, both runs - /// yield the identical `total_pips` and the invariant breaks (and real-data - /// pips are inflated by `1 / 0.0001 = 10^4`). - #[test] - fn run_blueprint_member_computes_pips_at_the_resolved_pip_not_a_hardwired_default() { - let env = project::Env::std(); - // A price series that drives the meanrev signal into a definite non-flat - // exposure (short an up-move, long a down-move), so broker equity != 0 — - // the same idiom as the fade test above. - let closes = [ - 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0, - ]; - let make_source = || -> Vec> { - let prices: Vec<(Timestamp, Scalar)> = closes - .iter() - .enumerate() - .map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))) - .collect(); - vec![Box::new(VecSource::new(prices))] - }; - let window = (Timestamp(0), Timestamp(closes.len() as i64 - 1)); - let stop = StopRule::Vol { length: 3, k: 2.0 }; - let space: Vec = vec![]; - // Two per-instrument pips, an order of magnitude apart and both distinct - // from the synthetic 0.0001 — i.e. the real-data condition. - let pip_a = 1.0_f64; - let pip_b = 0.1_f64; - let signal_a = r_meanrev_signal(Some(3), Some(0.0)); - let binding = binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new()) - .expect("the price role resolves"); - let report_a = run_blueprint_member( - signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], "GER40", - ); - let report_b = run_blueprint_member( - r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], "GER40", - ); - // Guard: the run must have actually traded, else the invariant is vacuous. - assert!( - report_a.metrics.total_pips.abs() > 0.0, - "test needs a non-flat run to be meaningful: {:?}", - report_a.metrics - ); - // The pip-invariant raw price-move total must agree across the two pips. - let raw_a = report_a.metrics.total_pips * pip_a; - let raw_b = report_b.metrics.total_pips * pip_b; - assert!( - (raw_a - raw_b).abs() < 1e-9, - "total_pips must be computed at the resolved pip: pip={pip_a} gave total_pips={} \ - (raw price-move {raw_a}), pip={pip_b} gave total_pips={} (raw price-move {raw_b}) \ - — the resolved pip never reached the in-graph SimBroker", - report_a.metrics.total_pips, - report_b.metrics.total_pips, - ); - } - /// Loads the shipped closed r-sma example (fast=2, slow=4 bound) through the /// public `blueprint_from_json` path — the single call site so a fixture /// rename or vocabulary change is one edit, not fourteen. @@ -5794,7 +3032,7 @@ mod tests { #[test] fn data_source_synthetic_pip_and_window_match_the_built_ins() { - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let d = DataSource::Synthetic; assert_eq!(d.pip_size(), SYNTHETIC_PIP_SIZE); assert!(!d.run_sources(&env, &[aura_ingest::M1Field::Close]).is_empty()); @@ -5825,7 +3063,7 @@ mod tests { #[test] fn synthetic_data_refuses_a_multi_column_blueprint() { - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let err = blueprint_mc_family(OHLC_REFUSAL_BLUEPRINT, 2, &DataSource::Synthetic, &env) .expect_err("a high/low blueprint cannot run over the synthetic close walk"); assert_eq!( @@ -5854,41 +3092,6 @@ mod tests { assert_eq!(WF_REAL_STEP_NS, 30 * day_ns); } - /// 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"); - } - - #[test] - fn sim_optimal_manifest_renders_per_instrument_pip() { - let m = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 1.0); - assert_eq!(m.broker, "sim-optimal(pip_size=1)"); - let m2 = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 0.0001); - assert_eq!(m2.broker, "sim-optimal(pip_size=0.0001)"); - } - // Note (#159 cut 4 collateral): this also removes the `member_key_*` unit // tests, the `pair()` fixture helper they shared, and // `momentum_param_space_is_ema_exposure_longonly` / @@ -5910,7 +3113,7 @@ mod tests { fn blueprint_sweep_member_equals_single_run_and_shares_topology_hash() { // An OPEN signal (both SMA knobs free) so the sweep can bind them by name; the // serialized doc round-trips to the topology the single run hashes. - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); let data = DataSource::Synthetic; @@ -5949,7 +3152,7 @@ mod tests { // The open fixture's two SMA lengths are the sweepable knobs; the probe // wraps the signal (name "sma_signal") so the names are prefixed — // exactly what `--axis` binds. - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let open = include_str!("../tests/fixtures/r_sma_open.json"); let space = blueprint_axis_probe(open, &env).param_space(); let names: Vec<&str> = space.iter().map(|p| p.name.as_str()).collect(); @@ -5965,7 +3168,7 @@ mod tests { fn blueprint_walkforward_family_refits_each_window() { // The closed blueprint's two bound SMA lengths are re-fit per IS window over // a 2x2 grid via the #246 bound-override reopen path (bound = overridable default). - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let doc = include_str!("../examples/r_sma.json"); let axes = vec![ ("sma_signal.fast.length".to_string(), vec![Scalar::i64(2), Scalar::i64(3)]), @@ -5988,7 +3191,7 @@ mod tests { // topology_hash, and DIFFERING metrics — the seed reaches the DATA (a distinct // synthetic walk per draw), not just the manifest label. The anti-degenerate guard: // a regression to seed-as-label-only would make the three draws identical. - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let family = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env).expect("closed blueprint"); @@ -6013,7 +3216,7 @@ mod tests { // distribution. A CLOSED deep-lookback signal whose slow SMA length (60) equals the // fixed 60-bar synthetic walk never warms, so every seed yields zero trades and thus // identical metrics; that is a wrong result with no error (C10 refuse-don't-guess). - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); // slow len (60) == walk len -> never warms let mut g = GraphBuilder::new("deep_probe"); let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2))); @@ -6043,7 +3246,7 @@ mod tests { // is unit-testable (the IO wrapper renders it to stderr + exit 2, mirroring the sibling // blueprint_sweep_family). MC binds no axis, so a free knob would have no binder; the // rejection pre-empts the downstream compile_with_params arity panic. - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); // both SMA knobs free -> non-empty param_space let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); @@ -6061,7 +3264,7 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let open = load_open_r_sma(); let doc = blueprint_to_json(&open).expect("serializes"); let data = DataSource::Synthetic; @@ -6082,7 +3285,7 @@ mod tests { .expect("append"); // reproduce: every member re-derives bit-identically (incl the open-at-end member). - let rep = reproduce_family_in(®, &id, &data, &env); + let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 2, "two members reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), @@ -6107,7 +3310,7 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let data = DataSource::Synthetic; // A CLOSED blueprint (both SMA knobs bound): its wrapped param_space is empty, so // the stop regime is the ONLY dimension that can differ between mint and reproduce @@ -6123,7 +3326,7 @@ mod tests { // Mint one member under a NON-default vol-stop regime (default is length=3, k=2.0). // run_blueprint_member stamps stop_length=8/stop_k=4.0 into the manifest params. let non_default = StopRule::Vol { length: 8, k: 4.0 }; - let binding = binding::resolve_binding("stoprepro", reload().input_roles(), &BTreeMap::new()) + let binding = aura_runner::binding::resolve_binding("stoprepro", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let report = run_blueprint_member( reload(), @@ -6151,7 +3354,7 @@ mod tests { // reproduce: the member re-derives bit-identically only if reproduce honours the // manifest's recorded stop regime — currently DIVERGED because reproduce hardcodes // the default Vol{3,2.0} for both the param-space probe and the member re-run. - let rep = reproduce_family_in(®, &id, &data, &env); + let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 1, "one member reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), @@ -6177,7 +3380,7 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let data = DataSource::Synthetic; let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); @@ -6185,7 +3388,7 @@ mod tests { let space = blueprint_axis_probe(&doc, &env).param_space(); let pip = data.pip_size(); let window = data.full_window(&env); - let binding = binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new()) + let binding = aura_runner::binding::resolve_binding("costrepro", reload().input_roles(), &BTreeMap::new()) .expect("the price role resolves"); let cost = vec![ aura_research::CostSpec::Constant { @@ -6222,7 +3425,7 @@ mod tests { reg.put_blueprint(&topo, &canonical).expect("store blueprint"); let id = reg.append_family("costrepro", FamilyKind::Sweep, &[report]).expect("append"); - let rep = reproduce_family_in(®, &id, &data, &env); + let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 1, "one member reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), @@ -6241,7 +3444,7 @@ mod tests { let reg = Registry::open(dir.join("runs.jsonl")); // a CLOSED signal (both SMA knobs bound) — MC binds no axis. - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let data = DataSource::Synthetic; @@ -6258,7 +3461,7 @@ mod tests { // reproduce: every MC member re-derives bit-identically (its seed-driven walk is // reconstructed from manifest.seed — the realization branch). - let rep = reproduce_family_in(®, &id, &data, &env); + let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 3, "three MC members reproduced"); assert!( rep.outcomes.iter().all(|(_, ok)| *ok), @@ -6275,7 +3478,7 @@ mod tests { /// clear boundary message (not a terse `UnknownKnob` debug leak). #[test] fn blueprint_sweep_family_overrides_a_bound_param_and_names_unknown_axes() { - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); // same fixture the retired test swept: both SMA knobs bound. let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); @@ -6306,7 +3509,7 @@ mod tests { /// out of `defaults` entirely. #[test] fn sweep_override_excludes_the_reopened_default_from_the_manifest() { - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); let axes = vec![("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)])]; @@ -6342,7 +3545,7 @@ mod tests { std::fs::create_dir_all(&dir).expect("temp dir"); let reg = Registry::open(dir.join("runs.jsonl")); - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); // MC binds no axis -> closed blueprint let closed = load_closed_r_sma(); let doc = blueprint_to_json(&closed).expect("serializes"); @@ -6357,7 +3560,7 @@ mod tests { .append_family("mcseed", FamilyKind::MonteCarlo, &mc_member_reports(&family)) .expect("append"); - let rep = reproduce_family_in(®, &id, &data, &env); + let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family"); assert_eq!(rep.outcomes.len(), 3, "three MC members"); for (label, _) in &rep.outcomes { assert!( @@ -6526,7 +3729,7 @@ mod tests { #[test] fn identity_id_bridges_the_rust_builder_and_op_script_paths() { let rust_built = load_closed_r_sma(); - let env = project::Env::std(); + let env = aura_runner::project::Env::std(); let json = crate::graph_construct::build_from_str(IDENTITY_TWIN_DOC, &env) .expect("op-script twin builds"); assert_ne!( @@ -6565,77 +3768,6 @@ mod tests { assert!(obj["e_r"]["mean"].is_number(), "e_r should nest the MetricStats block: {line}"); } - /// #262 round-trip: a manifest carrying stop_period_minutes re-derives - /// the VolTf stop; one without it keeps the Vol path — a stored VolTf - /// member must never silently reproduce under a default Vol stop. - #[test] - fn stop_rule_from_params_round_trips_the_vol_tf_stamp() { - let vol_tf = vec![ - ("stop_period_minutes".to_string(), Scalar::i64(60)), - ("stop_length".to_string(), Scalar::i64(14)), - ("stop_k".to_string(), Scalar::f64(2.0)), - ]; - assert_eq!( - stop_rule_from_params(&vol_tf), - StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 } - ); - let vol = vec![ - ("stop_length".to_string(), Scalar::i64(3)), - ("stop_k".to_string(), Scalar::f64(2.0)), - ]; - assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 }); - } - - /// #262 write-side: `run_blueprint_member` given `StopRule::VolTf` actually - /// bootstraps and runs the member (the `risk_executor`/`VolTfStop` arm, - /// end-to-end over a real synthetic realization, not a hand-built value) - /// and stamps all three knobs into the manifest under the keys - /// `stop_rule_from_params`'s round-trip above reads back — the write half - /// of the manifest round-trip pair. - #[test] - fn run_blueprint_member_stamps_the_vol_tf_stop_knobs() { - let env = project::Env::std(); - let data = DataSource::Synthetic; - let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); - let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); - let space = blueprint_axis_probe(&doc, &env).param_space(); - let binding = binding::resolve_binding("voltfstamp", reload().input_roles(), &BTreeMap::new()) - .expect("the price role resolves"); - let stop = StopRule::VolTf { period_minutes: 60, length: 3, k: 2.0 }; - let window = data.full_window(&env); - let pip = data.pip_size(); - let run = crate::run_blueprint_member( - reload(), - &[], - &space, - data.run_sources(&env, &binding.columns()), - window, - 0, - pip, - "topo", - &env, - stop, - &binding, - &[], - "GER40", - ); - assert!( - run.manifest.params.contains(&("stop_period_minutes".to_string(), Scalar::i64(60))), - "manifest must stamp stop_period_minutes: {:?}", - run.manifest.params - ); - assert!( - run.manifest.params.contains(&("stop_length".to_string(), Scalar::i64(3))), - "manifest must stamp stop_length: {:?}", - run.manifest.params - ); - assert!( - run.manifest.params.contains(&("stop_k".to_string(), Scalar::f64(2.0))), - "manifest must stamp stop_k: {:?}", - run.manifest.params - ); - } - /// A bare `McCmd` with every optional field defaulted to `None`/empty, so each /// `mc_args_from` refusal test below only sets the fields its scenario needs. fn bare_mc_cmd() -> McCmd { @@ -6731,112 +3863,6 @@ mod tests { #[cfg(test)] mod ic_tests { use super::*; - use aura_core::Timestamp; - - fn ts(i: i64) -> Timestamp { - Timestamp(i) // Timestamp is a tuple struct over epoch-ns i64 (pub field, per report.rs) - } - - #[test] - fn ic_engineered_signal_is_significant() { - // price path; forward return fr[i] = (p[i+1]-p[i])/p[i]. Engineer signal_t = fr[i] - // exactly (a hand-built OFFLINE series — a look-ahead signal is impossible in a - // causal run, C2, so this property lives at the unit level, not E2E). - let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5, 104.0] - .iter() - .enumerate() - .map(|(i, &p)| (ts(i as i64), p)) - .collect(); - let signal: Vec<(Timestamp, f64)> = (0..price.len() - 1) - .map(|i| (ts(i as i64), (price[i + 1].1 - price[i].1) / price[i].1)) - .collect(); - let out = information_coefficient(&signal, &price, 1, 1000, 0); - assert!(out.information_coefficient > 0.99, "ic = {}", out.information_coefficient); - assert!(out.overfit_probability < 0.05, "p = {}", out.overfit_probability); - assert!(out.n_pairs >= 6); - } - - #[test] - fn ic_constant_signal_is_not_significant() { - let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0] - .iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect(); - let signal: Vec<(Timestamp, f64)> = - (0..price.len()).map(|i| (ts(i as i64), 1.0)).collect(); // constant → zero variance - let out = information_coefficient(&signal, &price, 1, 1000, 0); - assert_eq!(out.information_coefficient, 0.0); - assert_eq!(out.overfit_probability, 1.0); - } - - #[test] - fn ic_varying_uncorrelated_signal_is_not_significant() { - // The false-positive control on a NON-degenerate null: a VARYING signal (unlike the - // constant case above, whose null is degenerate) that is exactly uncorrelated with the - // forward returns. price is chosen so forward returns are exactly [0.01, 0.02, 0.03, - // 0.04]; signal is a permutation of those values arranged orthogonal to them - // (Σ centred products = 0 → IC = 0), so the permutation null genuinely varies yet the - // raw IC sits in its bulk (~half the permutations exceed it), not its tail → not - // significant. This is the core guarantee of a significance test. - let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 103.02, 106.1106, 110.355024] - .iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect(); - let signal: Vec<(Timestamp, f64)> = [0.03, 0.01, 0.04, 0.02] - .iter().enumerate().map(|(i, &s)| (ts(i as i64), s)).collect(); - let out = information_coefficient(&signal, &price, 1, 1000, 0); - assert_eq!(out.n_pairs, 4); - assert!(out.information_coefficient.abs() < 1e-6, "ic ~ 0 expected, got {}", out.information_coefficient); - assert!( - out.overfit_probability > 0.1, - "an uncorrelated signal must not be significant: p = {}", - out.overfit_probability - ); - } - - #[test] - fn ic_is_deterministic_given_seed() { - let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5] - .iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect(); - let signal: Vec<(Timestamp, f64)> = - [0.3, -0.1, 0.4, -0.2, 0.1, 0.5, -0.3].iter().enumerate() - .map(|(i, &s)| (ts(i as i64), s)).collect(); - let a = information_coefficient(&signal, &price, 1, 500, 7); - let b = information_coefficient(&signal, &price, 1, 500, 7); - assert_eq!(a.information_coefficient, b.information_coefficient); - assert_eq!(a.overfit_probability, b.overfit_probability); - assert_eq!(a.n_pairs, b.n_pairs); - } - - #[test] - fn ic_degenerate_floor_on_too_few_pairs() { - let price = vec![(ts(0), 100.0)]; // one row → no forward return → 0 pairs - let signal = vec![(ts(0), 1.0)]; - let out = information_coefficient(&signal, &price, 1, 1000, 0); - assert_eq!(out.information_coefficient, 0.0); - assert_eq!(out.overfit_probability, 1.0); - assert_eq!(out.n_pairs, 0); - } - - #[test] - fn ic_pairs_only_on_exact_timestamp_overlap() { - // Property: alignment is by EXACT ts match, unmatched dropped (the cross-cadence - // case). A price ts carrying no signal is dropped, and a signal ts absent from the - // price spine is ignored — so n_pairs is the OVERLAP count, never the price length, - // and the dropped rows never enter the correlation. - // - // price spine ts 0..8 → horizon-1 forward returns exist at ts 0..7. Signal is placed - // ONLY at ts {1,2,4,5} (leaving price ts {0,3,6} without a signal → dropped) plus two - // decoy ts {50,60} absent from the price spine (→ never looked up). Where present, the - // signal equals the forward return exactly, so the 4 matched pairs correlate perfectly. - let price: Vec<(Timestamp, f64)> = - [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5, 104.0] - .iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect(); - let fr = |i: usize| (price[i + 1].1 - price[i].1) / price[i].1; - let mut signal: Vec<(Timestamp, f64)> = - [1usize, 2, 4, 5].iter().map(|&i| (ts(i as i64), fr(i))).collect(); - signal.push((ts(50), 999.0)); // decoy ts not on the price spine → unmatched, dropped - signal.push((ts(60), -999.0)); - let out = information_coefficient(&signal, &price, 1, 1000, 0); - assert_eq!(out.n_pairs, 4, "only the 4 overlapping ts pair; price ts 0/3/6 and the decoys drop"); - assert!(out.information_coefficient > 0.99, "ic = {}", out.information_coefficient); - } fn ic_report(sigs: Vec, frs: Vec) -> aura_engine::RunReport { aura_engine::RunReport { diff --git a/crates/aura-cli/src/render.rs b/crates/aura-cli/src/render.rs index 2a8b78c..de4bbd9 100644 --- a/crates/aura-cli/src/render.rs +++ b/crates/aura-cli/src/render.rs @@ -234,7 +234,7 @@ mod tests { /// against the prototype. #[test] fn render_html_is_self_contained_and_embeds_the_model() { - let env = crate::project::Env::std(); + let env = aura_runner::project::Env::std(); let bp = aura_engine::blueprint_from_json( include_str!("../examples/r_sma.json"), &|t| env.resolve(t), diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index 3504575..16a314c 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -13,7 +13,7 @@ use aura_research::{ }; use aura_registry::RefFault; -use crate::project::Env; +use aura_runner::project::Env; #[derive(clap::Args)] pub struct ProcessCmd { diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index 8f6ca31..0c4b8ff 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -53,13 +53,15 @@ pub(crate) struct SugarInvocation<'a> { /// by construction, so the net channel is never producible there; requesting /// it anyway would make `campaign_run`'s executor print an unreachable "add a /// cost block to the campaign document" remedy (#240). The exclusion reuses -/// `campaign_run`'s own tap/channel classification (`tap_channel`/ +/// `aura_runner::runner`'s own tap/channel classification (`tap_channel`/ /// `TapChannel::Net`) rather than duplicating the net-tap name here. fn persist_taps_from(trace: bool) -> Vec { if trace { tap_vocabulary() .iter() - .filter(|t| crate::campaign_run::tap_channel(t) != Some(crate::campaign_run::TapChannel::Net)) + .filter(|t| { + aura_runner::runner::tap_channel(t) != Some(aura_runner::runner::TapChannel::Net) + }) .map(|t| t.to_string()) .collect() } else { @@ -285,8 +287,8 @@ pub(crate) fn register_generated_g( /// (campaign_run.rs) — the referential shape the dispatch-boundary name /// preflight (main.rs) does not catch: a subset of axes that leaves an open /// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the -/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses -/// per member (campaign_run.rs) — a bound-param axis passes this +/// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member` +/// uses per member (aura-runner) — a bound-param axis passes this /// executable-shape preflight exactly like an already-open one; an axis /// matching neither space still fails `bind_axes`'s own named check below. /// `probe_params` is any one grid value per axis (checks NAME @@ -298,7 +300,7 @@ fn validate_before_register( campaign: &CampaignDoc, blueprint_canonical: &str, probe_params: &[(String, Scalar)], - env: &crate::project::Env, + env: &aura_runner::project::Env, ) -> Result<(), String> { let doc_faults = aura_research::validate_campaign(campaign); if !doc_faults.is_empty() { @@ -324,10 +326,10 @@ 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 = probe_params.iter().map(|(n, _)| n.clone()).collect(); - let overrides = crate::campaign_run::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal); + let overrides = aura_runner::axes::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal); let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space(); let strategy_id = content_id_of(blueprint_canonical); - crate::campaign_run::bind_axes(&space, &strategy_id, probe_params) + aura_runner::axes::bind_axes(&space, &strategy_id, probe_params) .map(|_| ()) .map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f))) } @@ -338,7 +340,7 @@ fn validate_before_register( /// one campaign executor. pub(crate) fn run_sweep_sugar( inv: &SugarInvocation, - env: &crate::project::Env, + env: &aura_runner::project::Env, ) -> Result { let generated = translate_sweep(inv)?; let probe_params = probe_params_from(inv.axes); @@ -384,7 +386,7 @@ fn cross_instrument_members( pub(crate) fn run_generalize_sugar( inv: &SugarInvocation, metric: &str, - env: &crate::project::Env, + env: &aura_runner::project::Env, ) -> Result { let generated = translate_generalize(inv, metric)?; let probe_params = probe_params_from(inv.axes); @@ -510,7 +512,7 @@ pub(crate) fn run_walkforward_sugar( metric: &str, w: WfWindows, select: SelectRule, - env: &crate::project::Env, + env: &aura_runner::project::Env, ) -> Result { let generated = translate_walkforward(inv, metric, w, select)?; let probe_params = probe_params_from(inv.axes); @@ -563,7 +565,7 @@ pub(crate) fn run_walkforward_sugar( // always `None`. `run`/`mc --trace` stay refused by design (#224), not by // this tail being skipped. if let Some(trace_name) = &run.outcome.record.trace_name { - crate::campaign_run::persist_campaign_traces( + aura_runner::runner::persist_campaign_traces( trace_name, &run.campaign.presentation.persist_taps, &run.outcome, @@ -674,7 +676,7 @@ pub(crate) fn run_mc_sugar( metric: &str, w: WfWindows, mc: McKnobs, - env: &crate::project::Env, + env: &aura_runner::project::Env, ) -> Result { let generated = translate_mc(inv, metric, w, mc)?; let probe_params = probe_params_from(inv.axes); diff --git a/crates/aura-measurement/Cargo.toml b/crates/aura-measurement/Cargo.toml new file mode 100644 index 0000000..07723e3 --- /dev/null +++ b/crates/aura-measurement/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "aura-measurement" +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true + +# C28 rung 3 (measurement): descriptive statistics over market streams. +# Seeded by #295 with the IC. Depends only inward: the scalar vocabulary +# and the domain-free statistics foundation. +[dependencies] +aura-core = { path = "../aura-core" } +aura-analysis = { path = "../aura-analysis" } +# serde (de)serializes IcMetrics for the run registry (C18), mirroring the +# R-vocabulary metric types (aura-backtest, aura-engine); the aligned pairs +# ride #[serde(skip)] (in-memory null inputs only, never persisted). +serde = { workspace = true } diff --git a/crates/aura-measurement/src/ic.rs b/crates/aura-measurement/src/ic.rs new file mode 100644 index 0000000..73c4c97 --- /dev/null +++ b/crates/aura-measurement/src/ic.rs @@ -0,0 +1,224 @@ +//! The Information Coefficient — the measurement rung's first deflatable +//! metric (#295). + +use aura_analysis::{one_sided_p_laplace, pearson_corr, permute, MetricVocabulary, SplitMix64}; +use aura_core::Timestamp; + +/// The IC measurement payload: the scalar plus its in-memory null inputs +/// (the aligned pairs ride #[serde(skip)], mirroring RMetrics.net_trade_rs). +/// The second production implementor of `MetricVocabulary` (#147) — the +/// registry's deflation machinery dispatches to ITS permutation null. +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +pub struct IcMetrics { + pub information_coefficient: f64, + #[serde(skip)] + pub sigs: Vec, + #[serde(skip)] + pub frs: Vec, +} + +/// The IC vocabulary's single resolved key. +#[derive(Clone, Copy)] +pub struct IcKey; + +impl MetricVocabulary for IcMetrics { + type Key = IcKey; + + fn resolve(name: &str) -> Option { + (name == "information_coefficient").then_some(IcKey) + } + fn known() -> &'static [&'static str] { + &["information_coefficient"] + } + fn higher_is_better(_: IcKey) -> bool { + true + } + fn value(&self, _: IcKey) -> f64 { + self.information_coefficient + } + fn has_resampling_null(_: IcKey) -> bool { + true + } + fn null_draw(&self, _: IcKey, _block_len: usize, rng: &mut SplitMix64) -> Option { + if self.sigs.len() < 2 { + return None; // consumes no rng + } + let mut perm = self.sigs.clone(); // independent draw, not a chained shuffle + permute(&mut perm, rng); + Some(pearson_corr(&perm, &self.frs)) + } +} + +/// The pure IC reduction result (no CLI context) — unit-testable in isolation. +/// Exercised by the in-crate `tests` module and consumed by the run/command-path +/// wiring in `dispatch_measure_ic`, which constructs `IcReport` from a live +/// measurement run's recorded taps. +pub struct IcOutcome { + pub information_coefficient: f64, + pub overfit_probability: f64, + pub n_pairs: usize, +} + +/// `corr(signal_t, forward_return_{t+h})` over two recorded tap series, with a +/// permutation null. `forward_return[i] = (price[i+h] - price[i]) / price[i]` on the +/// price ts-spine (a post-run array shift over recorded data — C2 governs in-graph +/// nodes, not a completed run's trace); signal is aligned to it by EXACT timestamp +/// match (unmatched dropped). `< 2` aligned pairs or zero variance → the degenerate +/// floor `(0.0, 1.0)`. One-sided permutation null via the shared +/// `MetricVocabulary::null_draw` + `one_sided_p_laplace` building blocks (#147); +/// draws are independent permutations, deterministic given `seed`. +pub fn information_coefficient( + signal: &[(Timestamp, f64)], + price: &[(Timestamp, f64)], + horizon: usize, + permutations: usize, + seed: u64, +) -> IcOutcome { + use std::collections::HashMap; + // signal value by timestamp-i64 (`Timestamp` is a tuple struct over epoch-ns; a tap + // fires at most once per ts). `t.0` is the epoch-ns i64. + let sig_at: HashMap = signal.iter().map(|(t, v)| (t.0, *v)).collect(); + // forward returns on the price spine, paired with the signal at the SAME ts + let (mut sigs, mut frs) = (Vec::new(), Vec::new()); + if horizon >= 1 && price.len() > horizon { + for i in 0..price.len() - horizon { + let (t, p0) = (price[i].0, price[i].1); + if p0 == 0.0 { + continue; // undefined return + } + let fr = (price[i + horizon].1 - p0) / p0; + if let Some(&s) = sig_at.get(&t.0) { + sigs.push(s); + frs.push(fr); + } + } + } + let n_pairs = sigs.len(); + if n_pairs < 2 { + return IcOutcome { information_coefficient: 0.0, overfit_probability: 1.0, n_pairs }; + } + let raw = pearson_corr(&sigs, &frs); + let member = IcMetrics { information_coefficient: raw, sigs, frs }; + let mut rng = SplitMix64::new(seed); + let mut ge = 0usize; + for _ in 0..permutations { + let draw = member + .null_draw(IcKey, 0, &mut rng) + .expect("n_pairs >= 2 checked above"); + if draw >= raw { + ge += 1; + } + } + let overfit_probability = one_sided_p_laplace(ge, permutations); + IcOutcome { information_coefficient: raw, overfit_probability, n_pairs } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(i: i64) -> Timestamp { + Timestamp(i) // Timestamp is a tuple struct over epoch-ns i64 (pub field, per report.rs) + } + + #[test] + fn ic_engineered_signal_is_significant() { + // price path; forward return fr[i] = (p[i+1]-p[i])/p[i]. Engineer signal_t = fr[i] + // exactly (a hand-built OFFLINE series — a look-ahead signal is impossible in a + // causal run, C2, so this property lives at the unit level, not E2E). + let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5, 104.0] + .iter() + .enumerate() + .map(|(i, &p)| (ts(i as i64), p)) + .collect(); + let signal: Vec<(Timestamp, f64)> = (0..price.len() - 1) + .map(|i| (ts(i as i64), (price[i + 1].1 - price[i].1) / price[i].1)) + .collect(); + let out = information_coefficient(&signal, &price, 1, 1000, 0); + assert!(out.information_coefficient > 0.99, "ic = {}", out.information_coefficient); + assert!(out.overfit_probability < 0.05, "p = {}", out.overfit_probability); + assert!(out.n_pairs >= 6); + } + + #[test] + fn ic_constant_signal_is_not_significant() { + let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0] + .iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect(); + let signal: Vec<(Timestamp, f64)> = + (0..price.len()).map(|i| (ts(i as i64), 1.0)).collect(); // constant → zero variance + let out = information_coefficient(&signal, &price, 1, 1000, 0); + assert_eq!(out.information_coefficient, 0.0); + assert_eq!(out.overfit_probability, 1.0); + } + + #[test] + fn ic_varying_uncorrelated_signal_is_not_significant() { + // The false-positive control on a NON-degenerate null: a VARYING signal (unlike the + // constant case above, whose null is degenerate) that is exactly uncorrelated with the + // forward returns. price is chosen so forward returns are exactly [0.01, 0.02, 0.03, + // 0.04]; signal is a permutation of those values arranged orthogonal to them + // (Σ centred products = 0 → IC = 0), so the permutation null genuinely varies yet the + // raw IC sits in its bulk (~half the permutations exceed it), not its tail → not + // significant. This is the core guarantee of a significance test. + let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 103.02, 106.1106, 110.355024] + .iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect(); + let signal: Vec<(Timestamp, f64)> = [0.03, 0.01, 0.04, 0.02] + .iter().enumerate().map(|(i, &s)| (ts(i as i64), s)).collect(); + let out = information_coefficient(&signal, &price, 1, 1000, 0); + assert_eq!(out.n_pairs, 4); + assert!(out.information_coefficient.abs() < 1e-6, "ic ~ 0 expected, got {}", out.information_coefficient); + assert!( + out.overfit_probability > 0.1, + "an uncorrelated signal must not be significant: p = {}", + out.overfit_probability + ); + } + + #[test] + fn ic_is_deterministic_given_seed() { + let price: Vec<(Timestamp, f64)> = [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5] + .iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect(); + let signal: Vec<(Timestamp, f64)> = + [0.3, -0.1, 0.4, -0.2, 0.1, 0.5, -0.3].iter().enumerate() + .map(|(i, &s)| (ts(i as i64), s)).collect(); + let a = information_coefficient(&signal, &price, 1, 500, 7); + let b = information_coefficient(&signal, &price, 1, 500, 7); + assert_eq!(a.information_coefficient, b.information_coefficient); + assert_eq!(a.overfit_probability, b.overfit_probability); + assert_eq!(a.n_pairs, b.n_pairs); + } + + #[test] + fn ic_degenerate_floor_on_too_few_pairs() { + let price = vec![(ts(0), 100.0)]; // one row → no forward return → 0 pairs + let signal = vec![(ts(0), 1.0)]; + let out = information_coefficient(&signal, &price, 1, 1000, 0); + assert_eq!(out.information_coefficient, 0.0); + assert_eq!(out.overfit_probability, 1.0); + assert_eq!(out.n_pairs, 0); + } + + #[test] + fn ic_pairs_only_on_exact_timestamp_overlap() { + // Property: alignment is by EXACT ts match, unmatched dropped (the cross-cadence + // case). A price ts carrying no signal is dropped, and a signal ts absent from the + // price spine is ignored — so n_pairs is the OVERLAP count, never the price length, + // and the dropped rows never enter the correlation. + // + // price spine ts 0..8 → horizon-1 forward returns exist at ts 0..7. Signal is placed + // ONLY at ts {1,2,4,5} (leaving price ts {0,3,6} without a signal → dropped) plus two + // decoy ts {50,60} absent from the price spine (→ never looked up). Where present, the + // signal equals the forward return exactly, so the 4 matched pairs correlate perfectly. + let price: Vec<(Timestamp, f64)> = + [100.0, 101.0, 100.5, 102.0, 101.0, 103.0, 102.5, 104.0] + .iter().enumerate().map(|(i, &p)| (ts(i as i64), p)).collect(); + let fr = |i: usize| (price[i + 1].1 - price[i].1) / price[i].1; + let mut signal: Vec<(Timestamp, f64)> = + [1usize, 2, 4, 5].iter().map(|&i| (ts(i as i64), fr(i))).collect(); + signal.push((ts(50), 999.0)); // decoy ts not on the price spine → unmatched, dropped + signal.push((ts(60), -999.0)); + let out = information_coefficient(&signal, &price, 1, 1000, 0); + assert_eq!(out.n_pairs, 4, "only the 4 overlapping ts pair; price ts 0/3/6 and the decoys drop"); + assert!(out.information_coefficient > 0.99, "ic = {}", out.information_coefficient); + } +} diff --git a/crates/aura-measurement/src/lib.rs b/crates/aura-measurement/src/lib.rs new file mode 100644 index 0000000..7babcea --- /dev/null +++ b/crates/aura-measurement/src/lib.rs @@ -0,0 +1,9 @@ +//! aura-measurement — the C28 measurement rung (rung 3). +//! +//! Descriptive statistics over market streams. Seeded (#295) with the +//! Information Coefficient: the `IcMetrics`/`IcKey` metric vocabulary and +//! the `information_coefficient` reduction. + +pub mod ic; + +pub use ic::{information_coefficient, IcKey, IcMetrics, IcOutcome}; diff --git a/crates/aura-runner/Cargo.toml b/crates/aura-runner/Cargo.toml new file mode 100644 index 0000000..1302449 --- /dev/null +++ b/crates/aura-runner/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "aura-runner" +edition.workspace = true +version.workspace = true +license.workspace = true +publish.workspace = true + +# C28 assembly position (#295): composes the ladder rungs, the ingestion +# edge, and the process column into the canonical member-run recipe — the +# harness assembly, input binding (C26), the param<->config translators, and +# the shipped default MemberRunner. Imported by the shell and by downstream +# World programs; imported by no ladder or column crate. +[dependencies] +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" } +aura-composites = { path = "../aura-composites" } +aura-ingest = { path = "../aura-ingest" } +aura-registry = { path = "../aura-registry" } +aura-research = { path = "../aura-research" } +aura-campaign = { path = "../aura-campaign" } +aura-vocabulary = { path = "../aura-vocabulary" } +data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +# sha2: the project.rs dylib load boundary's identity stamp (dylib_sha256) — +# a vetted RustCrypto crate (per-case C16 review, SHA256 user-settled). +sha2 = "0.10" +libloading = "0.8" +toml = "0.8" diff --git a/crates/aura-runner/build.rs b/crates/aura-runner/build.rs new file mode 100644 index 0000000..1f7915e --- /dev/null +++ b/crates/aura-runner/build.rs @@ -0,0 +1,62 @@ +//! Capture the git HEAD sha at compile time and expose it as `AURA_COMMIT`, so +//! `member::sim_optimal_manifest`'s `RunManifest.commit` is self-identifying +//! (C8/C18 audit trail: this run = this commit) even when aura-runner is +//! consumed by a downstream World program with no `aura` binary involved. +//! `cargo:rustc-env` is per-compilation-unit (aura-cli's own `build.rs` does +//! not propagate its env to this crate's `option_env!("AURA_COMMIT")`), so +//! aura-runner needs this identical capture rather than inheriting the +//! shell's. When there is no git (e.g. a packaged source tree), `AURA_COMMIT` +//! is left unset and `member.rs`'s `option_env!(...)` fallback to `"unknown"` +//! holds. +//! +//! Zero runtime/build dependency by commitment (C14/C18): we shell out to the +//! `git` already in PATH rather than pulling in a libgit crate. Verbatim twin +//! of `aura-cli/build.rs` (#295) — same repo-root depth (`crates/aura-runner` +//! sits beside `crates/aura-cli`), same capture logic, so the two crates' +//! `AURA_COMMIT` cannot disagree within one build. + +use std::path::Path; +use std::process::Command; + +fn main() { + // build.rs runs with CWD = the crate dir (crates/aura-runner); the repo + // root, which owns `.git`, is two levels up. + let repo_root = Path::new("../.."); + let git_dir = repo_root.join(".git"); + + if !git_dir.exists() { + // No git: emit nothing; the `"unknown"` fallback in member.rs holds. + return; + } + + // Re-run when HEAD moves or its working tree changes so AURA_COMMIT never + // goes stale across commits. `.git/logs/HEAD` grows on every HEAD move + // (commit, checkout, reset), covering branch switches without resolving the + // current branch's ref file by hand. + println!("cargo:rerun-if-changed=../../.git/HEAD"); + println!("cargo:rerun-if-changed=../../.git/logs/HEAD"); + + let head = run_git(repo_root, &["rev-parse", "HEAD"]); + let Some(head) = head else { return }; + + // Append `-dirty` when the working tree has uncommitted changes, so a run + // built off an unclean tree is not silently attributed to the clean HEAD. + let dirty = run_git(repo_root, &["status", "--porcelain"]) + .map(|s| !s.is_empty()) + .unwrap_or(false); + + let commit = if dirty { format!("{head}-dirty") } else { head }; + println!("cargo:rustc-env=AURA_COMMIT={commit}"); +} + +/// Run `git ` in `dir`, returning trimmed stdout on success, `None` on any +/// failure (git missing, non-zero exit, non-utf8). Never panics: a build must +/// not fail because git is unavailable. +fn run_git(dir: &Path, args: &[&str]) -> Option { + let out = Command::new("git").current_dir(dir).args(args).output().ok()?; + if !out.status.success() { + return None; + } + let s = String::from_utf8(out.stdout).ok()?; + Some(s.trim().to_string()) +} diff --git a/crates/aura-runner/examples/world_member_run.rs b/crates/aura-runner/examples/world_member_run.rs new file mode 100644 index 0000000..5ced99b --- /dev/null +++ b/crates/aura-runner/examples/world_member_run.rs @@ -0,0 +1,43 @@ +//! A World program driving the canonical member-run recipe as a library — +//! no `aura` binary involved (#295 acceptance evidence: builds against +//! library crates only). Runs one member backtest of the shipped breakout +//! blueprint over real archive data when present; skips cleanly otherwise. +use std::collections::BTreeMap; + +use aura_campaign::{CellSpec, MemberRunner}; +use aura_ingest::default_data_server; +use aura_runner::{DefaultMemberRunner, Env}; + +const SYMBOL: &str = "GER40"; +// September 2024, inclusive epoch-ms — the same month the ger40 examples use. +const WINDOW_MS: (i64, i64) = (1_725_148_800_000, 1_727_740_799_999); + +fn main() { + let server = default_data_server(); + if !server.has_symbol(SYMBOL) { + println!("skip: no local archive — nothing to demonstrate."); + return; + } + let blueprint_json = std::fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../aura-cli/examples/r_breakout.json" + )) + .expect("shipped example blueprint"); + + let env = Env::std(); + let runner = DefaultMemberRunner::new(&env, server, BTreeMap::new(), Vec::new()); + let cell = CellSpec { + strategy_ordinal: 0, + strategy_id: aura_research::content_id_of(&blueprint_json), + blueprint_json, + axes: BTreeMap::new(), + instrument: SYMBOL.to_string(), + window_ms: WINDOW_MS, + regime: None, + regime_ordinal: 0, + }; + match runner.run_member(&cell, &[], WINDOW_MS) { + Ok(report) => println!("member ran: {:?}", report.metrics), + Err(fault) => println!("member fault (data-dependent): {fault:?}"), + } +} diff --git a/crates/aura-runner/src/axes.rs b/crates/aura-runner/src/axes.rs new file mode 100644 index 0000000..6c76ec6 --- /dev/null +++ b/crates/aura-runner/src/axes.rs @@ -0,0 +1,249 @@ +//! Axis / risk-regime conventions. +//! +//! The RAW campaign-axis namespace (a campaign document's own, #203) and the +//! WRAPPED param-space namespace (`blueprint_axis_probe`'s output, one node +//! segment prefixed) are two coordinate systems over the same open params; +//! this module owns the one translation between them (`wrapped_to_raw_axis` +//! <-> `raw_matches_wrapped`) and the pure axis-binding resolution +//! (`bind_axes`) built on it, plus the content-id shape predicate +//! (`is_content_id`) shared by every FILE-or-id CLI surface. + +use std::collections::HashSet; + +use aura_core::{Cell, ParamSpec, Scalar}; +use aura_campaign::MemberFault; +use aura_engine::Composite; + +/// A bare store address: exactly 64 lowercase hex chars (the content-id key +/// shape). Shared by every FILE-or-id CLI surface (`aura campaign run`'s +/// target resolution, `graph_construct`'s `--params ` resolution) +/// so the two cannot drift apart on the id shape a second time. +pub fn is_content_id(s: &str) -> bool { + s.len() == 64 && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +/// Strip the wrapper's exactly-one leading node segment from a WRAPPED param +/// path, yielding the RAW campaign-axis name the doc speaks (#203): the bind +/// convention prefixes a strategy's own param paths with one root-composite +/// segment before they enter the runnable (wrapped) param space, so the raw +/// axis is everything after that segment's dot. Returns `name` unchanged if +/// there is no dot (defensive; every wrapped param space produced by +/// `blueprint_axis_probe` has one). This is the single definition of "the +/// wrapper segment" — [`raw_matches_wrapped`] is built on it so a wrap-depth +/// change cannot desync the two directions of the #203 convention. +pub fn wrapped_to_raw_axis(name: &str) -> &str { + name.split_once('.').map(|(_, rest)| rest).unwrap_or(name) +} + +/// Does a RAW campaign-axis name (the doc's own namespace) address this ONE +/// wrapped param path? True for an exact match (an unwrapped param, if one +/// ever exists) or when stripping the wrapper's one node segment +/// ([`wrapped_to_raw_axis`]) yields exactly `raw`. Inverse of +/// `wrapped_to_raw_axis`, co-located and built on it (#203): the two are the +/// only two places the wrapper-segment convention is expressed. +pub fn raw_matches_wrapped(raw: &str, wrapped: &str) -> bool { + wrapped == raw || wrapped_to_raw_axis(wrapped) == raw +} + +/// Suffix-join each raw campaign axis onto exactly one wrapped param +/// ([`raw_matches_wrapped`] — wrapped == raw, or stripping the wrapper's one +/// node segment yields raw), then require every wrapped slot to be covered. +/// Pure and independent of the env/data seam so the three fault arms are +/// unit-testable without a project, a store, or a loaded blueprint. +pub fn bind_axes( + space: &[ParamSpec], + strategy_id: &str, + params: &[(String, Scalar)], +) -> Result, MemberFault> { + let mut slots: Vec> = vec![None; space.len()]; + for (raw, value) in params { + let hits: Vec = space + .iter() + .enumerate() + .filter(|(_, p)| raw_matches_wrapped(raw, &p.name)) + .map(|(i, _)| i) + .collect(); + match hits.as_slice() { + [i] => slots[*i] = Some(*value), + [] => { + return Err(MemberFault::Bind(format!( + "axis \"{raw}\" matches no open param of the wrapped strategy {strategy_id}" + ))); + } + _ => { + let names: Vec<&str> = hits.iter().map(|&i| space[i].name.as_str()).collect(); + return Err(MemberFault::Bind(format!( + "axis \"{raw}\" is ambiguous in the wrapped param space of \ + strategy {strategy_id}: matches {}", + names.join(", ") + ))); + } + } + } + let mut point: Vec = Vec::with_capacity(space.len()); + for (spec, slot) in space.iter().zip(&slots) { + match slot { + Some(v) => point.push(v.cell()), + None => { + // Speak the RAW campaign-axis namespace (the doc's own): the + // wrapped space prefixes the signal's params with one node + // segment, so the raw path is everything after it (#203). + let raw = wrapped_to_raw_axis(&spec.name); + return Err(MemberFault::Bind(format!( + "open param \"{raw}\" of strategy {strategy_id} is bound by no \ + campaign axis (wrapped path: {})", + spec.name + ))); + } + } + } + Ok(point) +} + +/// The #246 override subset of one member's campaign-axis names (already RAW +/// — a campaign document speaks the raw namespace, #203, hence the `raw_` +/// name prefix distinguishing this from `member`'s WRAPPED-input siblings): +/// every name that matches no entry of the un-reopened wrapped OPEN +/// `open_space` but names a BOUND param of `raw_signal` (`bound_param_space()`'s +/// `.name` is already path-qualified in strategy/RAW coordinates, exactly +/// like a raw campaign axis — no wrap-prefix stripping needed, unlike +/// `member::wrapped_bound_overrides_of`/`member::override_paths`, which start +/// from WRAPPED CLI axis names). Silent like `member::wrapped_bound_overrides_of` +/// (skips a name matching neither space), NOT the validating `member::override_paths`: +/// `run_member` runs inside a sweep worker and must never call +/// `std::process::exit` — an unmatched name still surfaces as `bind_axes`'s +/// own named `MemberFault::Bind` once the (reopened) space below is built. +pub fn raw_bound_overrides_of( + param_names: &[String], + open_space: &[ParamSpec], + raw_signal: &Composite, +) -> Vec { + let bound: HashSet = + raw_signal.bound_param_space().into_iter().map(|b| b.name).collect(); + param_names + .iter() + .filter(|raw| { + !open_space.iter().any(|p| raw_matches_wrapped(raw, &p.name)) + && bound.contains(raw.as_str()) + }) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_core::ScalarKind; + + fn spec(name: &str) -> ParamSpec { + ParamSpec { name: name.to_string(), kind: ScalarKind::I64 } + } + + #[test] + /// The only shape treated as a direct store address is a bare 64-char + /// lowercase-hex token; anything else (wrong length, uppercase, non-hex) + /// falls through to `run_campaign`'s file-path branch instead. + fn is_content_id_accepts_only_64_lowercase_hex() { + assert!(is_content_id(&"a".repeat(64))); + assert!(!is_content_id(&"A".repeat(64))); + assert!(!is_content_id(&"a".repeat(63))); + assert!(!is_content_id(&format!("{}g", "a".repeat(63)))); + } + + #[test] + /// #203: `wrapped_to_raw_axis` and `raw_matches_wrapped` are the inverse + /// pair of one wrapper-segment convention — stripping the wrapper off a + /// wrapped name must satisfy the match predicate against that SAME + /// wrapped name, and a raw name from a DIFFERENT wrapped param must not. + fn raw_matches_wrapped_and_wrapped_to_raw_axis_are_inverses() { + let wrapped = "sma_signal.fast.length"; + assert_eq!(wrapped_to_raw_axis(wrapped), "fast.length"); + assert!(raw_matches_wrapped(wrapped_to_raw_axis(wrapped), wrapped)); + assert!(!raw_matches_wrapped("fast.length", "sma_signal.slow.length")); + } + + #[test] + /// bind_axes resolves a raw axis name against the ONE wrapped param whose + /// path ends with ".{raw}" (or equals it), producing a co-indexed point. + fn bind_axes_resolves_a_unique_suffix_match() { + let space = vec![spec("sma.length")]; + let params = vec![("length".to_string(), Scalar::i64(14))]; + let point = bind_axes(&space, "strat", ¶ms).unwrap(); + assert_eq!(point, vec![Scalar::i64(14).cell()]); + } + + #[test] + /// A raw campaign axis that matches no open param of the wrapped + /// strategy is a named Bind fault naming the axis, never a silently + /// dropped binding. + fn bind_axes_refuses_an_unmatched_axis() { + let space = vec![spec("sma.length")]; + let params = vec![("period".to_string(), Scalar::i64(14))]; + let err = bind_axes(&space, "strat", ¶ms).unwrap_err(); + let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") }; + assert!(m.contains("period") && m.contains("matches no open param")); + } + + #[test] + /// A raw axis matching MORE THAN ONE wrapped param is a named Bind fault + /// that lists every ambiguous candidate, never a silent first-match pick. + fn bind_axes_refuses_an_ambiguous_axis() { + let space = vec![spec("fast.length"), spec("slow.length")]; + let params = vec![("length".to_string(), Scalar::i64(14))]; + let err = bind_axes(&space, "strat", ¶ms).unwrap_err(); + let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") }; + assert!(m.contains("ambiguous") && m.contains("fast.length") && m.contains("slow.length")); + } + + #[test] + /// An open wrapped param left unbound by any campaign axis is a named + /// Bind fault naming the param, not an implicit default. + fn bind_axes_refuses_an_uncovered_param() { + let space = vec![spec("sma.length"), spec("sma.threshold")]; + let params = vec![("length".to_string(), Scalar::i64(14))]; + let err = bind_axes(&space, "strat", ¶ms).unwrap_err(); + let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") }; + // The refusal speaks the RAW campaign-axis namespace (what the doc + // must say), with the wrapped bind-time path as a parenthetical (#203). + assert!( + m.contains("open param \"threshold\"") && m.contains("(wrapped path: sma.threshold)"), + "raw-namespace prose expected: {m}" + ); + } + + #[test] + /// #246: `raw_bound_overrides_of` names exactly the raw axis names that + /// miss the un-reopened wrapped OPEN space but match a BOUND param of the + /// raw signal (in strategy/RAW coordinates, per its own doc comment) — an + /// axis already covered by the open space is not re-flagged as an + /// override, and an axis matching neither space is silently skipped here + /// (bind_axes surfaces THAT case as its own named fault once the + /// reopened space is built downstream). + fn raw_bound_overrides_of_selects_axes_naming_a_bound_param() { + use aura_strategy::Bias; + use aura_std::Sma; + let raw_signal = Composite::new( + "fixture", + vec![ + Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(), + Bias::builder().named("exp").into(), + ], + vec![], + vec![], + vec![], + ); + // The un-reopened wrapped OPEN space carries one extra wrap segment + // (the CLI's own outer wrap, mirroring `blueprint_axis_probe`'s + // output shape) ahead of the composite's own "exp.scale" path. + let open_space = vec![ParamSpec { name: "wrap.exp.scale".to_string(), kind: ScalarKind::F64 }]; + let param_names = + vec!["exp.scale".to_string(), "fast.length".to_string(), "nope.thing".to_string()]; + let overrides = raw_bound_overrides_of(¶m_names, &open_space, &raw_signal); + assert_eq!( + overrides, + vec!["fast.length".to_string()], + "only the bound-param axis is selected; the already-open axis and the \ + unmatched axis are excluded" + ); + } +} diff --git a/crates/aura-cli/src/binding.rs b/crates/aura-runner/src/binding.rs similarity index 95% rename from crates/aura-cli/src/binding.rs rename to crates/aura-runner/src/binding.rs index 88fb621..254a0ba 100644 --- a/crates/aura-cli/src/binding.rs +++ b/crates/aura-runner/src/binding.rs @@ -9,12 +9,6 @@ //! 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; @@ -35,7 +29,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(crate) fn static_role_name(name: &str) -> Option<&'static str> { +pub fn static_role_name(name: &str) -> Option<&'static str> { match name { "open" => Some("open"), "high" => Some("high"), @@ -76,7 +70,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(crate) fn column_kind(column: M1Field) -> ScalarKind { +pub fn column_kind(column: M1Field) -> ScalarKind { match column { M1Field::Volume => ScalarKind::I64, _ => ScalarKind::F64, @@ -100,7 +94,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(crate) struct BindingEntry { +pub struct BindingEntry { pub role: String, pub column: M1Field, pub feeds_signal: bool, @@ -110,25 +104,25 @@ pub(crate) struct BindingEntry { /// openers (column opening) share: entries in canonical column order, with a /// close entry guaranteed (the broker/executor price feed). #[derive(Debug)] -pub(crate) struct ResolvedBinding { +pub struct ResolvedBinding { entries: Vec, } impl ResolvedBinding { - pub(crate) fn entries(&self) -> &[BindingEntry] { + pub 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(crate) fn columns(&self) -> Vec { + pub fn columns(&self) -> Vec { 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(crate) fn close_only(&self) -> bool { + pub fn close_only(&self) -> bool { self.columns() == [M1Field::Close] } } @@ -153,7 +147,7 @@ fn finish(mut entries: Vec) -> 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(crate) fn resolve_binding( +pub fn resolve_binding( strategy: &str, roles: &[Role], overrides: &BTreeMap, @@ -229,7 +223,7 @@ fn first_duplicate_role(roles: &[Role]) -> Option<&str> { /// 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(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding { +pub fn probe_binding(roles: &[Role]) -> ResolvedBinding { let entries: Vec = roles .iter() .map(|role| BindingEntry { @@ -243,7 +237,7 @@ pub(crate) 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(crate) fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String { +pub fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String { let beyond: Vec<&str> = binding .entries() .iter() diff --git a/crates/aura-runner/src/coverage.rs b/crates/aura-runner/src/coverage.rs new file mode 100644 index 0000000..a21a13e --- /dev/null +++ b/crates/aura-runner/src/coverage.rs @@ -0,0 +1,161 @@ +//! Coverage reporting: the single interior-gap-month walk, shared by a +//! campaign cell's coverage annotation +//! (`DefaultMemberRunner::window_coverage`) and `aura data coverage`'s +//! archive-wide report — two independent walk implementations before this +//! module existed (#295 dedup). + +/// `"YYYY-MM"` rendering of a `(year, month)` pair. +pub fn fmt_year_month((y, m): (u16, u8)) -> String { + format!("{y:04}-{m:02}") +} + +/// The `(year, month)` immediately after `(y, m)`. +pub fn next_year_month((y, m): (u16, u8)) -> (u16, u8) { + if m == 12 { (y + 1, 1) } else { (y, m + 1) } +} + +/// The whole `(year, month)`s missing INSIDE `months`' own span that also +/// fall inside `window_ms` (Unix-ms) — one `"YYYY-MM"` entry per missing +/// month, never a collapsed range: the registry's `CellCoverage::gap_months` +/// is a flat list an aggregate counts directly. `months` is assumed sorted +/// ([`aura_ingest::list_m1_months`]'s own contract). The single gap-walk +/// implementation (#295): both `DefaultMemberRunner::window_coverage` +/// (a swept cell's own window) and [`data_coverage_report`] (the full +/// archive span, via [`FULL_ARCHIVE_WINDOW_MS`]) call this one walk instead +/// of maintaining independent copies. +pub fn interior_gap_months(months: &[(u16, u8)], window_ms: (i64, i64)) -> Vec { + let from_ym = data_server::records::unix_ms_to_year_month(window_ms.0); + let to_ym = data_server::records::unix_ms_to_year_month(window_ms.1); + let mut out = Vec::new(); + for pair in months.windows(2) { + let (prev, next) = (pair[0], pair[1]); + let mut ym = next_year_month(prev); + while ym != next { + if ym >= from_ym && ym <= to_ym { + out.push(fmt_year_month(ym)); + } + ym = next_year_month(ym); + } + } + out +} + +/// A Unix-ms window that decodes (via `unix_ms_to_year_month`) to year/month +/// bounds — 0001-01 and 9999-01, exact epoch-ms for those UTC instants, not +/// an approximation — comfortably outside any realistic archive month, so an +/// unbounded, archive-wide [`interior_gap_months`] walk never ms-filters out +/// a real gap. Safely inside `chrono`'s valid calendar range, so the +/// conversion inside `interior_gap_months` never panics. +pub const FULL_ARCHIVE_WINDOW_MS: (i64, i64) = (-62_135_596_800_000, 253_370_764_800_000); + +/// `aura data coverage `'s pure report body (#264): render `symbol`'s +/// coverage report from its sorted `(year, month)` file list — one `span:` +/// line framing the first/last present month, then either a `no gaps` line +/// or one `missing: YYYY-MM..YYYY-MM` line per interior contiguous gap (a +/// ten-month hole is one line, not ten). `Err` exactly when `months` is +/// empty — no archive file exists for `symbol` at all (the caller's "unknown +/// symbol" refusal, stderr + exit 1). The gap detection itself is +/// [`interior_gap_months`] over the full archive span +/// ([`FULL_ARCHIVE_WINDOW_MS`]); collapsing its flat per-month list into +/// contiguous ranges is presentation-only regrouping, done here since it +/// stays byte-identical to the pre-dedup report. +pub fn data_coverage_report(symbol: &str, months: &[(u16, u8)]) -> Result, String> { + let Some(&first) = months.first() else { + return Err(format!("no archive files found for symbol \"{symbol}\"")); + }; + let last = *months.last().expect("non-empty checked above"); + let mut lines = + vec![format!("{symbol} span: {}..{}", fmt_year_month(first), fmt_year_month(last))]; + let gap_months = interior_gap_months(months, FULL_ARCHIVE_WINDOW_MS); + if gap_months.is_empty() { + lines.push(format!("{symbol} no gaps")); + } else { + for (from, to) in collapse_contiguous_year_months(&gap_months) { + lines.push(format!("{symbol} missing: {from}..{to}")); + } + } + Ok(lines) +} + +/// Collapse [`interior_gap_months`]' flat, ascending `"YYYY-MM"` list into +/// contiguous inclusive `(from, to)` ranges — a ten-month hole collapses to +/// one pair, not ten. Presentation-only regrouping: the gap WALK itself +/// already ran in `interior_gap_months`. +fn collapse_contiguous_year_months(months: &[String]) -> Vec<(String, String)> { + let mut out: Vec<(String, String)> = Vec::new(); + for m in months { + let ym = parse_year_month(m); + match out.last_mut() { + Some((_, to)) if next_year_month(parse_year_month(to)) == ym => { + *to = m.clone(); + } + _ => out.push((m.clone(), m.clone())), + } + } + out +} + +/// Inverse of [`fmt_year_month`] — parses back the `"YYYY-MM"` shape +/// [`interior_gap_months`] always emits. +fn parse_year_month(s: &str) -> (u16, u8) { + let (y, m) = s.split_once('-').expect("interior_gap_months emits YYYY-MM"); + (y.parse().expect("YYYY digits"), m.parse().expect("MM digits")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + /// #272: `interior_gap_months` names a whole calendar month missing + /// BETWEEN two present archive months (the Copper failure shape) when the + /// requested window spans across it — not just when the window's own + /// bounds fall short of full coverage. GAPSYM-shaped input (files at + /// 2024-01..02 and 2024-05..06, absent 03..04) over a Jan-through-June + /// window must name exactly the two missing interior months. + fn interior_gap_months_names_a_whole_missing_month_spanned_by_the_window() { + let months = [(2024u16, 1u8), (2024, 2), (2024, 5), (2024, 6)]; + // 2024-01-01T00:00:00Z .. 2024-07-01T00:00:00Z (Unix ms) — spans every + // present month plus the March/April hole. + let window_ms = (1_704_067_200_000i64, 1_719_792_000_000i64); + let gaps = interior_gap_months(&months, window_ms); + assert_eq!( + gaps, + vec!["2024-03".to_string(), "2024-04".to_string()], + "both interior gap months must be named, in order" + ); + } + + /// An unknown symbol (no archive files at all — an empty month list) + /// refuses rather than reporting a bogus empty-span coverage (#264): the + /// caller eprintln's the message and exits 1. + #[test] + fn data_coverage_report_refuses_an_unknown_symbol() { + let err = data_coverage_report("GHOST", &[]).unwrap_err(); + assert!(err.contains("GHOST"), "names the unknown symbol: {err}"); + } + + /// A symbol with a fully contiguous file index reports its span plus an + /// explicit `no gaps` line — never a bare span with no gap-status line at + /// all, which would leave "no gaps" indistinguishable from "gaps not yet + /// checked" (#264). + #[test] + fn data_coverage_report_of_a_gapless_symbol_is_span_plus_no_gaps() { + let months = [(2024, 1), (2024, 2), (2024, 3)]; + let lines = data_coverage_report("SYMA", &months).expect("known symbol"); + assert_eq!(lines, vec!["SYMA span: 2024-01..2024-03", "SYMA no gaps"]); + } + + /// The Copper failure shape itself: one interior gap collapses to a single + /// `missing: YYYY-MM..YYYY-MM` line naming the whole contiguous hole, not + /// one line per missing month (#264). + #[test] + fn data_coverage_report_collapses_an_interior_gap_to_one_range_line() { + let months = [(2024, 1), (2024, 2), (2024, 5), (2024, 6)]; + let lines = data_coverage_report("GAPSYM", &months).expect("known symbol"); + assert_eq!( + lines, + vec!["GAPSYM span: 2024-01..2024-06", "GAPSYM missing: 2024-03..2024-04"] + ); + } +} diff --git a/crates/aura-runner/src/family.rs b/crates/aura-runner/src/family.rs new file mode 100644 index 0000000..c3a3fcd --- /dev/null +++ b/crates/aura-runner/src/family.rs @@ -0,0 +1,669 @@ +//! Family assembly and orchestration (#295). +//! +//! The blueprint sweep / walk-forward / Monte-Carlo family builders — pure +//! member-run recipes driven off a [`DataSource`] (the shared synthetic/real +//! data provider every family builder threads, plus the `--select` objective +//! [`Selection`] walk-forward resolves its winner under) — together with the +//! winner-selection (`select_winner`) and axis-grid validation +//! (`validate_axis_grid`) machinery they share. No `aura` binary is needed to +//! drive a family: the shell (`aura-cli`) wraps these builders with +//! persistence + stdout rendering (`run_blueprint_sweep`, +//! `run_blueprint_walkforward`, `run_blueprint_mc`), which stay in the shell, +//! along with the `--select`/`--real` argv grammar (`parse_select`, +//! `select_rule_of`) and the `topology_hash`/`content_id` naming primitive +//! (inlined here instead, mirroring `member::run_signal_r`'s own copy — the +//! CLI shell still needs its own for call sites outside any family builder). + +use std::collections::BTreeMap; +use std::sync::Arc; + +use aura_composites::StopRule; +use aura_core::{Cell, ParamSpec, Scalar, Timestamp}; +use aura_engine::{ + blueprint_from_json, walk_forward, window_of, BindError, Composite, FamilySelection, + RollMode, RunManifest, SyntheticSpec, VecSource, WindowBounds, WindowRoller, +}; +use aura_registry::{ + optimize_deflated, optimize_plateau, PlateauMode, DEFLATION_BLOCK_LEN, DEFLATION_N_RESAMPLES, +}; +use aura_backtest::{ + monte_carlo, McFamily, RunMetrics, RunReport, SweepFamily, SweepPoint, WalkForwardResult, + WindowRun, WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS, +}; + +use crate::binding::ResolvedBinding; +use crate::member::{ + blueprint_axis_probe, blueprint_axis_probe_reopened, no_real_data, override_paths, + pip_or_refuse, probe_window, reopen_all, run_blueprint_member, wrapped_bound_overrides_of, + SYNTHETIC_PIP_SIZE, +}; +use crate::project::Env; +use crate::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH}; + +/// The in-sample winner-selection objective for walk-forward's per-window IS +/// refit (cycle 0077). `Argmax` is the bare-best pick deflated for trials +/// (#144, the default); `Plateau` argmaxes the neighbourhood-smoothed surface +/// instead (opt-in via `--select`). The CLI shell's `--select` grammar +/// (`parse_select`) and campaign-rule mapping (`select_rule_of`) build this +/// value; they stay in the shell since only this type crosses the boundary. +#[derive(Clone, Copy)] +pub enum Selection { + Argmax, + Plateau(PlateauMode), +} + +/// A warm-up-adequate synthetic stream (~18 ticks rising, falling, then rising +/// again) used as `DataSource::Synthetic`'s full-window stream for the built-in +/// blueprint/campaign sweep, walk-forward, and MC families. Deterministic and +/// fixed (C1). +pub fn showcase_prices() -> Vec<(Timestamp, Scalar)> { + [ + 1.0000_f64, 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, + ] + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p))) + .collect() +} + +/// A longer deterministic stream than `showcase_prices` — enough for several +/// IS/OOS windows with SMA warm-up. Seed-determined via `SyntheticSpec` (C1). +pub fn walkforward_prices() -> Vec<(Timestamp, Scalar)> { + let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 }; + let mut src = spec.source(7); + let mut out = Vec::new(); + while let Some(item) = aura_engine::Source::next(&mut src) { + out.push(item); + } + out +} + +/// The in-memory windowed source the synthetic path uses (the firewall mapping to +/// `DataServer::stream_m1_windowed` is the real-data path; synthetic stays in-memory, +/// mirroring `run_blueprint_sweep`'s `showcase_prices`). Inclusive `[from, to]`. +pub fn walkforward_window_source(from: Timestamp, to: Timestamp) -> VecSource { + VecSource::new( + walkforward_prices() + .into_iter() + .filter(|&(t, _)| t >= from && t <= to) + .collect(), + ) +} + +/// What `--real` parsing yields: the synthetic default, or a real symbol + an +/// optional window (parsed, not yet opened). Pure, so the grammar is unit-testable. +#[derive(Debug, Clone, PartialEq)] +pub enum DataChoice { + Synthetic, + Real { symbol: String, from_ms: Option, to_ms: Option }, +} + +/// The source provider threaded into the family builders: synthetic built-in +/// streams, or real M1 close bars from the data-server archive. Replaces the +/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come +/// from one place (Fork B/D/F). +/// +/// `Synthetic` denotes a *consumer-dependent* built-in stream, not one fixed +/// series: the full-window consumers (`full_window` / `run_sources`, used by +/// sweep / MC) draw the 18-bar `showcase_prices()`, while the windowed consumers +/// (`windowed_sources` / `wf_window_sizes`, used by walk-forward) draw the 60-bar +/// `walkforward_prices()` so the `(24,12,12)`-bar roller fits its span. The two +/// faces never reach one consumer (a family is either full-window or windowed), so +/// the split is invisible per call site but real across the type — read both +/// family builders to see it whole. +pub enum DataSource { + Synthetic, + Real { + server: Arc, + symbol: String, + from_ms: Option, + to_ms: Option, + pip: f64, + }, +} + +impl DataSource { + /// Build a provider from a parsed choice, or refuse (stderr + exit 1) on a symbol + /// with no recorded geometry / absent data — both BEFORE any member runs (Fork C/G), + /// via the same `pip_or_refuse` / `no_real_data` helpers `open_real_source` uses. + pub fn from_choice(choice: DataChoice, env: &Env) -> DataSource { + match choice { + DataChoice::Synthetic => DataSource::Synthetic, + DataChoice::Real { symbol, from_ms, to_ms } => { + let server = Arc::new(data_server::DataServer::new(env.data_path())); + let pip = pip_or_refuse(&server, &symbol, env); + if !server.has_symbol(&symbol) { + no_real_data(&symbol, env); + } + DataSource::Real { server, symbol, from_ms, to_ms, pip } + } + } + } + + pub fn pip_size(&self) -> f64 { + match self { + DataSource::Synthetic => SYNTHETIC_PIP_SIZE, + DataSource::Real { pip, .. } => *pip, + } + } + + /// The full run window, probed once. Synthetic: the showcase span. Real: + /// `probe_window` drains a separate single-pass probe source for first/last ts + /// (the same helper `open_real_source` uses for its manifest window). + pub fn full_window(&self, env: &Env) -> (Timestamp, Timestamp) { + match self { + DataSource::Synthetic => { + let s: Vec> = vec![Box::new(VecSource::new(showcase_prices()))]; + window_of(&s).expect("non-empty showcase stream") + } + DataSource::Real { server, symbol, from_ms, to_ms, .. } => { + probe_window(server, symbol, *from_ms, *to_ms, env) + } + } + } + + /// The full walk-forward span. Synthetic draws the 60-bar `walkforward_prices` + /// span — NOT `showcase_prices` (which `full_window` uses): walk-forward is a + /// *windowed* consumer whose roller `(24,12,12)` needs 36 bars, so it uses the + /// longer built-in stream (byte-unchanged from the retired pre-`DataSource` + /// `walkforward_family`, which derived its span the same way). Real: the same + /// probed `--from..--to` window as `full_window`. + pub fn wf_full_span(&self, env: &Env) -> (Timestamp, Timestamp) { + match self { + DataSource::Synthetic => { + let s: Vec> = vec![Box::new(VecSource::new(walkforward_prices()))]; + window_of(&s).expect("non-empty walkforward stream") + } + DataSource::Real { server, symbol, from_ms, to_ms, .. } => { + probe_window(server, symbol, *from_ms, *to_ms, env) + } + } + } + + /// A fresh full-window source set per member (single-pass): the synthetic + /// showcase close stream, or one real source per resolved binding column + /// in canonical order (callers guard the synthetic arm to `{close}`). + pub fn run_sources(&self, env: &Env, fields: &[aura_ingest::M1Field]) -> Vec> { + match self { + DataSource::Synthetic => vec![Box::new(VecSource::new(showcase_prices()))], + DataSource::Real { server, symbol, from_ms, to_ms, .. } => { + aura_ingest::open_columns(server, symbol, *from_ms, *to_ms, fields) + .unwrap_or_else(|| no_real_data(symbol, env)) + } + } + } + + /// A fresh windowed source set for an IS/OOS sub-window (walk-forward). + /// Synthetic: `walkforward_window_source`. Real: one source per resolved + /// binding column over the ns-native window. + pub fn windowed_sources( + &self, from: Timestamp, to: Timestamp, env: &Env, fields: &[aura_ingest::M1Field], + ) -> Vec> { + match self { + DataSource::Synthetic => vec![Box::new(walkforward_window_source(from, to))], + DataSource::Real { server, symbol, .. } => { + aura_ingest::open_columns_window(server, symbol, Some(from), Some(to), fields) + .unwrap_or_else(|| no_real_data(symbol, env)) + } + } + } + + /// WindowRoller sizes per data kind (Fork F): bar-index for synthetic (24/12/12 + /// over the 60-bar span), calendar-ns for real. + pub fn wf_window_sizes(&self) -> (i64, i64, i64) { + match self { + DataSource::Synthetic => (24, 12, 12), + DataSource::Real { .. } => (WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS), + } + } +} + +/// #260: the r-sma sugar/MC paths below run with either no cost model (empty +/// `cost` slice) or CLI-flag cost specs (scalar-only — `cost_specs_from_params` +/// only ever wraps `CostValue::Scalar`), so an instrument-keyed map can never +/// originate on these paths and the instrument context is genuinely inert. +/// Named once so every such call site states its intent by reference instead +/// of repeating the justifying comment. +const NO_INSTRUMENT_CONTEXT: &str = ""; + +/// The winner-selection objective for walk-forward's per-window IS refit — the +/// deflation-aware SQN variant (#144 default). Named once so `select_winner` +/// and `blueprint_walkforward_family` (the only two family-builder-side call +/// sites) cannot drift apart on the token; the CLI shell's campaign-sugar +/// bridge keeps its own copy of this token (main.rs `WINNER_SELECTION_METRIC`) +/// since it is not itself a family builder. +const WINNER_SELECTION_METRIC: &str = "sqn_normalized"; + +/// Fixed RNG seed for the trials-deflation reality-check bootstrap in walk-forward +/// winner selection. Recorded on each winner's manifest (so `overfit_probability` +/// is reproducible by re-run); a CLI flag for it is a deferred refinement. The +/// resample count and block length are the shared `aura_registry::DEFLATION_*`. +const DEFLATION_SEED: u64 = 0xDEF1_A7ED; + +/// Renders a [`BindError`] as one-line prose in `member::override_paths`' sibling +/// register above (#247) — never the raw Rust `Debug` struct name +/// (`KindMismatch { .. }` / `MissingKnob("..")`), the two variants the sweep +/// terminal actually raises past `override_paths`' own pre-flight (which +/// already rejects an unresolvable axis name as prose before either call +/// site below ever reaches the terminal). `UnknownKnob` already carries a +/// fully-prosed message string (wrapped from `override_paths`' own +/// `Result<_, String>`) — unwrapped here rather than Debug-framed (#269), so +/// the walkforward path's rejection reaches stderr as bare prose too. +pub fn render_bind_error(e: &BindError) -> String { + match e { + BindError::MissingKnob(name) => format!( + "axis {name}: an open param with no axis and no bound default — \ + bind it with `--axis {name}=` — see `aura sweep --list-axes`" + ), + BindError::KindMismatch { knob, expected, got } => format!( + "axis {knob}: expected {expected:?}, supplied {got:?} — \ + see `aura sweep --list-axes`" + ), + BindError::UnknownKnob(msg) => msg.clone(), + BindError::DuplicateBinding(name) => format!( + "axis {name}: bound twice — each param takes exactly one axis — \ + see `aura sweep --list-axes`" + ), + BindError::EmptyAxis(name) => format!( + "axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`" + ), + BindError::EmptyRange(name) => format!( + "axis {name}: the named range is empty — give it at least one value" + ), + // A blueprint defect, not an axis usage error: the point passed name + // resolution but its bootstrap failed. Prose frame with the compile + // detail explicitly labelled as internal — per-variant prose for + // CompileError belongs to the graph-build surface, not this boundary. + BindError::Compile(e) => format!( + "the resolved axis point failed to bootstrap — the blueprint is \ + defective at this point, re-validate it with `aura graph build` \ + (internal detail: {e:?})" + ), + } +} + +/// Resolve the in-sample winner under the chosen selection objective. `Argmax` +/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid +/// surface — it needs the grid lattice, so a sweep with no lattice (a future random +/// walk-forward producer) is refused rather than silently argmaxed. The metric is +/// always known at the call sites, so a metric error is unreachable (`expect`); the +/// only fallible outcome is the plateau-without-lattice refusal, returned as +/// `Err(message)` for the caller to print and exit 2. +pub fn select_winner( + family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>, +) -> Result<(SweepPoint, FamilySelection), String> { + match select { + Selection::Argmax => Ok(optimize_deflated( + family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED, + ).expect("walk-forward metrics are known")), + Selection::Plateau(mode) => match lattice { + Some(lens) => Ok(optimize_plateau(family, lens, metric, mode) + .expect("walk-forward metrics are known")), + None => Err( + "--select plateau requires a grid sweep; a random sweep has no parameter lattice" + .to_string(), + ), + }, + } +} + +/// A constant, zero-compute `RunReport` for [`validate_axis_grid`]'s sweep-terminal +/// probe. `SweepBinder::sweep_with_lattice`'s own `resolve_axes`/arity/kind checks all +/// run BEFORE this closure is invoked per grid point, so its body never influences the +/// validation outcome — only its signature (`Fn(&[Cell]) -> RunReport`) needs to +/// satisfy the terminal, at O(1) cost per point instead of a full member run. +fn axis_grid_probe_report() -> RunReport { + RunReport { + manifest: RunManifest { + commit: String::new(), + params: Vec::new(), + defaults: Vec::new(), + window: (Timestamp(0), Timestamp(0)), + seed: 0, + broker: "wf-axis-preflight-placeholder".to_string(), + selection: None, + instrument: None, + topology_hash: None, + project: None, + }, + metrics: RunMetrics { total_pips: 0.0, max_drawdown: 0.0, bias_sign_flips: 0, r: None }, + } +} + +/// Validate the `--axis` grid against `doc`'s wrapped param space WITHOUT running any +/// member (#253). Reuses the SAME strict, erroring axis-name check +/// `blueprint_sweep_over` performs (`override_paths` — single-sourced, so the two +/// paths cannot drift to differently-worded rejections) to derive the #246 override +/// set, then drives the sweep terminal's own `resolve_axes`/arity/kind checks +/// (`SweepBinder::sweep_with_lattice`) with [`axis_grid_probe_report`] standing in for +/// the run closure — no data access, no sim engine tick. Axis resolution is +/// window-agnostic, so the caller derives or passes no window at all. +fn validate_axis_grid( + doc: &str, axes: &[(String, Vec)], raw_space: &[ParamSpec], probe_signal: &Composite, + env: &Env, +) -> Result<(), BindError> { + let overrides = override_paths(axes, raw_space, probe_signal).map_err(BindError::UnknownKnob)?; + let probe = blueprint_axis_probe_reopened(doc, env, &overrides); + let mut iter = axes.iter(); + let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis"); + let mut binder = probe.axis(first_name, first_vals.clone()); + for (n, vals) in iter { + binder = binder.axis(n, vals.clone()); + } + binder.sweep_with_lattice(|_| axis_grid_probe_report()).map(|_| ()) +} + +/// Sweep a serialized signal `doc` over user-named param-space axes. Structurally it +/// keeps the shape of the retired `r_sma_sweep_family` demo builder (#159), with three +/// deviations. (1) The signal source is +/// `wrap_r(blueprint_from_json(doc))` — a loaded blueprint, not the Rust-built +/// r-sma graph. (2) The signal is RE-loaded from `doc` per member (a `Composite` is +/// `!Clone`, so the throwaway param-space probe and each grid point each reload). (3) +/// The axes are taken verbatim BY NAME (not the four suffix-resolved r-sma knobs): +/// each `(name, vals)` is fed straight to the `SweepBinder`, so an unknown name or a +/// kind mismatch surfaces as the sweep terminal's [`BindError`], rendered to a message +/// string — a named error, never a panic. An axis naming a bound param re-opens it +/// (#246: bound value = default); an axis matching neither space is refused by +/// `override_paths` before any run. Every member manifest carries the shared +/// `topology_hash` of the loaded signal; reduce-mode fold, identical to the retired +/// mirror's default (no-trace) arm. +pub fn blueprint_sweep_family( + doc: &str, + axes: &[(String, Vec)], + data: &DataSource, + env: &Env, +) -> Result { + // Identity + binding read the AUTHORED doc, raw (no override re-open): + // topology and the resolved role plan are properties of the document, not + // of any one sweep's axis choices. + let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"); + // topology_hash's own two-line body, inlined (mirrors member::run_signal_r): + // `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's + // `topology_hash` helper is the same primitive, kept single-sourced at + // `aura_research`. + let topo = aura_research::content_id_of( + &aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"), + ); + // Strict binding resolution (name defaults — the verb path carries no + // campaign overrides): the family's open plan and wrap plan in one value. + let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?; + if matches!(data, DataSource::Synthetic) && !binding.close_only() { + return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding)); + } + let pip = data.pip_size(); + let window = data.full_window(env); + // The un-reopened wrapped OPEN space (#246): derives the override set (which + // named axes re-open a bound param) before the real, reopened probe is built — + // probe and per-member reloads must re-open identically so points resolve + // against one space. A name matching neither space is the error here. + let raw_space = blueprint_axis_probe(doc, env).param_space(); + let overrides = override_paths(axes, &raw_space, &probe_signal)?; + let probe = blueprint_axis_probe_reopened(doc, env, &overrides); + let space = probe.param_space(); + // The doc is parse-validated at the dispatch boundary (with file-path context), + // so every reload here is infallible: the builder has a single error contract — + // the `BindError` returned by the sweep terminal — and no hidden process exit. + // Member reloads re-open the SAME override set derived above, so every member + // resolves its axes against the identical (reopened) param space the probe used. + let reload = |d: &str| { + reopen_all( + blueprint_from_json(d, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"), + &overrides, + ) + }; + // seed the named axes verbatim: the first via Composite::axis (consumes the probe), + // the rest via SweepBinder::axis. resolve_axes name- and kind-checks them at the + // sweep terminal, so an UnknownKnob / KindMismatch is returned, not panicked. + let mut iter = axes.iter(); + let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis"); + let mut binder = probe.axis(first_name, first_vals.clone()); + for (n, vals) in iter { + binder = binder.axis(n, vals.clone()); + } + binder + .sweep(|point| { + // fresh per-member graph (Composite is !Clone, reload per member) run through + // the shared reduce-mode member path — the same fn reproduction re-runs. + run_blueprint_member(reload(doc), point, &space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) + }) + // render the sweep terminal's BindError to prose (#247), the fn's String error + // contract — never the raw Debug struct. + .map_err(|e| render_bind_error(&e)) +} + +/// Sweep the LOADED blueprint over the user `--axis` grid on an in-sample window +/// `[from,to]` — the windowed, lattice-carrying twin of +/// `blueprint_sweep_family`. `sweep_with_lattice` gives the grid lattice `--select +/// plateau` needs. An unknown/kind-mismatched axis surfaces as `BindError` at the +/// sweep terminal (no panic, no hidden exit) for the caller to render. An axis +/// naming a bound param re-opens it (#246: bound value = default, same +/// `override_paths`/`reopen_all` recipe as `blueprint_sweep_family` — this is +/// its walk-forward in-sample twin); an axis matching neither space is refused +/// (wrapped as `BindError::UnknownKnob`, the honest replacement for the retired +/// "fully bound; nothing to sweep" refusal) before any member runs. +pub fn blueprint_sweep_over( + doc: &str, axes: &[(String, Vec)], from: Timestamp, to: Timestamp, data: &DataSource, + env: &Env, binding: &ResolvedBinding, +) -> Result<(SweepFamily, Vec), BindError> { + let reload = |d: &str| { + blueprint_from_json(d, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible") + }; + let pip = data.pip_size(); + let probe_signal = reload(doc); + // topology_hash's own two-line body, inlined (see `blueprint_sweep_family`). + let topo = aura_research::content_id_of( + &aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"), + ); + // The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy + // load: derives the override set (which named axes re-open a bound param) before + // the reopened probe is built — probe and per-member reloads must re-open + // identically so points resolve against one space, exactly like + // `blueprint_sweep_family`. + let raw_space = blueprint_axis_probe(doc, env).param_space(); + let overrides = override_paths(axes, &raw_space, &probe_signal).map_err(BindError::UnknownKnob)?; + let probe = blueprint_axis_probe_reopened(doc, env, &overrides); + let space = probe.param_space(); + let mut iter = axes.iter(); + let (first_name, first_vals) = iter.next().expect("a blueprint walk-forward declares >= 1 axis"); + let mut binder = probe.axis(first_name, first_vals.clone()); + for (n, vals) in iter { + binder = binder.axis(n, vals.clone()); + } + binder.sweep_with_lattice(|point| { + let sources = data.windowed_sources(from, to, env, &binding.columns()); + let window = window_of(&sources).expect("non-empty in-sample window"); + run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT) + }) +} + +/// Run the winner params over an out-of-sample window `[from,to]` on the loaded +/// blueprint. The reduce-mode member +/// (`run_blueprint_member`) retains R-metrics, not a raw pip curve, so the stitching +/// segment is empty (an empty segment leaves the stitched curve unbroken). `overrides` +/// (#246) is the SAME family-wide set `blueprint_walkforward_family` derived once and +/// resolved `space`/`params` against — the OOS reload must re-open it too, or a +/// bound-param axis's winner point (kind-checked against the REOPENED space) fails +/// `bootstrap_with_cells`'s arity check against this still-closed reload. +#[allow(clippy::too_many_arguments)] +pub fn run_oos_blueprint( + doc: &str, params: &[Cell], space: &[ParamSpec], from: Timestamp, to: Timestamp, + topo: &str, data: &DataSource, env: &Env, binding: &ResolvedBinding, + overrides: &[String], +) -> (Vec<(Timestamp, f64)>, RunReport) { + let reload = reopen_all( + blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"), + overrides, + ); + let pip = data.pip_size(); + let sources = data.windowed_sources(from, to, env, &binding.columns()); + let window = window_of(&sources).expect("non-empty out-of-sample window"); + let report = run_blueprint_member(reload, params, space, sources, window, 0, pip, topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, binding, &[], NO_INSTRUMENT_CONTEXT); + (Vec::new(), report) +} + +/// The loaded-blueprint IS-refit walk-forward: per IS window, re-optimize the +/// blueprint over the user `--axis` grid, select by `sqn_normalized`, run the +/// winner OOS, reusing the generic `walk_forward` driver + `select_winner`; +/// only the per-window sweep/OOS source the loaded blueprint. In-closure errors +/// (a bad `--axis`) `exit(2)` with the sweep terminal's message. +pub fn blueprint_walkforward_family( + doc: &str, axes: &[(String, Vec)], data: &DataSource, select: Selection, + env: &Env, +) -> WalkForwardResult { + let span = data.wf_full_span(env); + let (is_len, oos_len, step) = data.wf_window_sizes(); + let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) { + Ok(r) => r, + Err(e) => { + eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}"); + std::process::exit(2); + } + }; + let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"); + // topology_hash's own two-line body, inlined (see `blueprint_sweep_family`). + let topo = aura_research::content_id_of( + &aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"), + ); + // The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy + // load: derives the override set ONCE for the whole family — every per-window + // sweep AND the OOS reload re-open the SAME set, mirroring + // `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant + // (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis + // matching neither space is simply not an override here — the strict check + its + // established error message stay single-sourced in `blueprint_sweep_over`'s own + // pre-flight call below, so this derivation cannot double-validate with a + // differently-worded rejection. + let axis_names: Vec = axes.iter().map(|(n, _)| n.clone()).collect(); + let raw_space = blueprint_axis_probe(doc, env).param_space(); + let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal); + let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space(); + // Strict binding resolution, once per family; refusal is the established + // `aura: ` + exit-1 register (the roller's usage refusals stay exit 2). + let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new()) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); + if matches!(data, DataSource::Synthetic) && !binding.close_only() { + eprintln!("aura: {}", crate::binding::synthetic_refusal(probe_signal.name(), &binding)); + std::process::exit(1); + } + // Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep` + // (which resolves its axes a single time before any member runs). `walk_forward` fans + // the per-window closure out across the windows in parallel, so a `BindError` raised + // *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one + // exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic + // (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without + // running a single member — no IS window (or a second roller) is needed here at all. + if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) { + eprintln!("aura: {}", render_bind_error(&e)); + std::process::exit(2); + } + walk_forward(roller, space.clone(), |w: WindowBounds| { + let (is_family, lattice) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding) + .expect("axes validated in the dispatch-boundary pre-flight"); + let (best, selection) = match select_winner(&is_family, WINNER_SELECTION_METRIC, select, Some(&lattice)) { + Ok(v) => v, + Err(msg) => { eprintln!("aura: {msg}"); std::process::exit(2); } + }; + let (oos_equity, mut oos_report) = + run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides); + oos_report.manifest.selection = Some(selection); + WindowRun { chosen_params: best.params, oos_equity, oos_report } + }) +} + +/// A fresh seeded synthetic price walk for one Monte-Carlo draw — `blueprint_mc_family`'s +/// pattern (a distinct realization per seed). A FIXED `SyntheticSpec` shared by the +/// `aura mc ` persist path AND the reproduce MonteCarlo branch, so the +/// seed->walk reconstruction is bit-exact (C1). Length 60 comfortably warms the loaded +/// r-sma graph (SMA slow=4 + the len-3 vol stop) so draws produce differing trades. +pub fn synthetic_walk_sources(seed: u64) -> Vec> { + let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 }; + vec![Box::new(spec.source(seed))] +} + +/// Build a Monte-Carlo family from a loaded CLOSED signal blueprint: run the fixed +/// blueprint across `n_seeds` seeds, each seed drawing a distinct synthetic walk. The +/// blueprint must be CLOSED (empty wrapped `param_space`) — MC binds no axis, so a free +/// knob has no binder; an OPEN blueprint yields a named `Err` (exit-free like the sibling +/// [`blueprint_sweep_family`]: the IO wrapper `run_blueprint_mc` renders it to stderr + +/// exit 2) before any run, pre-empting the `compile_with_params` arity panic. Each draw +/// runs the shared reduce-mode member path (`run_blueprint_member`, the same fn reproduce +/// re-runs), so reproduction is bit-identical (C1); every member carries the shared +/// `topology_hash`. +pub fn blueprint_mc_family( + doc: &str, n_seeds: u64, data: &DataSource, env: &Env, +) -> Result { + let reload = |d: &str| { + blueprint_from_json(d, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible") + }; + let probe_signal = reload(doc); + // topology_hash's own two-line body, inlined (see `blueprint_sweep_family`). + let topo = aura_research::content_id_of( + &aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"), + ); + // Strict binding resolution (name defaults — mc's synthetic family binds + // no campaign overrides); the exit-free Err contract of this builder. + let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?; + if !binding.close_only() { + // MC draws ALWAYS run the seeded synthetic close walk (real-data mc + // routes through the campaign sugar and never reaches this builder). + return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding)); + } + let pip = data.pip_size(); + // probe the wrapped param_space (the same probe the sweep resolves against); + // MC needs it empty. `blueprint_axis_probe` is the single source of that wrap. + let space = blueprint_axis_probe(doc, env).param_space(); + if !space.is_empty() { + // Exit-free like blueprint_sweep_family: the builder's single error contract is this + // returned message (no hidden process exit), so the rejection is unit-testable; the IO + // wrapper run_blueprint_mc renders it to stderr + exit 2 at the boundary. + return Err(format!( + "mc requires a closed blueprint (no free parameters); {} free knob(s) — \ + bind them or use `aura sweep --axis`", + space.len() + )); + } + // Closed blueprint -> an empty base point (as `aura run `); the MC + // draws vary the SEED, not a tuning param (C12 axis 4). Delegate the disjoint C1 draws + // to the shared `monte_carlo` helper — it runs them in parallel across sims (invariant 1), + // deterministic in seed-input order. Each draw + // re-runs the shared reduce-mode member path over its own seeded synthetic walk. + let seeds: Vec = (1..=n_seeds).collect(); + let base_point: Vec = Vec::new(); + let family = monte_carlo(&base_point, &seeds, |seed, _base| { + let sources = synthetic_walk_sources(seed); + let window = window_of(&sources).expect("non-empty synthetic walk"); + run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], NO_INSTRUMENT_CONTEXT) + }); + // Silent-vacuous MC guard (refuse-don't-guess, C10): with >= 2 seeds, if every draw's + // metrics are bit-identical to the first, no seed reached a distinguishable realization — + // the strategy never warmed over the fixed synthetic walk (e.g. a lookback as deep as the + // walk is long), so the "distribution" is a single point masquerading as a family: a wrong + // result with no error. Compare `metrics`, not the whole `RunReport` — the manifest's + // `seed` differs per draw by construction, so a whole-report compare could never detect the + // collapse; the metrics are the realization the seed is meant to move. A single-draw MC + // (n == 1) is trivially "all identical" and is NOT this cross-seed condition, so it passes. + if family.draws.len() >= 2 + && family + .draws + .iter() + .all(|d| d.report.metrics == family.draws[0].report.metrics) + { + return Err( + "mc is vacuous: every seed produced an identical result — the strategy never warmed \ + over the synthetic walk, so no seed reached a distinguishable realization; use a \ + shallower-lookback blueprint or a longer walk" + .to_string(), + ); + } + Ok(family) +} diff --git a/crates/aura-runner/src/lib.rs b/crates/aura-runner/src/lib.rs new file mode 100644 index 0000000..0b46b83 --- /dev/null +++ b/crates/aura-runner/src/lib.rs @@ -0,0 +1,35 @@ +//! aura-runner — the C28 assembly position (#295). +//! +//! The canonical member-run recipe as a library: harness assembly, input +//! binding (C26), the C1-load-bearing param<->config translators, and the +//! axis/risk-regime conventions. [`DefaultMemberRunner`] is the shipped +//! `aura_campaign::MemberRunner` implementation over that recipe, plus the +//! campaign trace-persistence disk-layout writers (`runner` module) and the +//! shared coverage-report walk (`coverage` module). A downstream World +//! program reaches the entire family/validation machinery through this +//! crate plus `aura-campaign`, with no `aura` binary involved. + +pub mod axes; +pub mod binding; +pub mod coverage; +pub mod family; +pub mod measure; +pub mod member; +pub mod project; +pub mod reproduce; +pub mod runner; +pub mod translate; + +pub use project::Env; +pub use runner::DefaultMemberRunner; + +/// A refusal a library function reports instead of exiting the process +/// itself. The shell (`aura-cli`'s `dispatch_reproduce`) is the single place +/// that maps it back to the identical stderr bytes + exit code, so the +/// binary's observable behaviour stays byte-unchanged (#295, spec §Error +/// handling). +#[derive(Debug)] +pub struct RunnerError { + pub exit_code: i32, + pub message: String, +} diff --git a/crates/aura-runner/src/measure.rs b/crates/aura-runner/src/measure.rs new file mode 100644 index 0000000..9bcd38b --- /dev/null +++ b/crates/aura-runner/src/measure.rs @@ -0,0 +1,135 @@ +//! The measurement run path (#295). +//! +//! `run_measurement` is `member::run_signal_r` MINUS `wrap_r` and the +//! eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist +//! (C27) — see the fn doc for the full rationale. The `aura measure ic` +//! reduction (reading an already-recorded run's tap traces and folding them +//! into an `IcReport`) is unrelated to this run path and stays in the CLI +//! shell (`dispatch_measure_ic`), which assembles and prints that DTO. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::mpsc; + +use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; +use aura_engine::{ColumnarTrace, Composite, Harness, MeasurementReport, RunManifest, TapBindError}; +use aura_std::Recorder; + +use crate::member::{key_supply, resolve_run_data, wrapped_bound_defaults, RunData}; +use crate::project::Env; + +/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`, +/// falling back to `"unknown"`) — `measurement_manifest`'s `RunManifest.commit` +/// reads this one const, mirroring `member::ENGINE_COMMIT` and the CLI shell's +/// own copy (all three resolve the same process-wide env var at compile time, +/// so they cannot disagree). +const ENGINE_COMMIT: &str = match option_env!("AURA_COMMIT") { + Some(c) => c, + None => "unknown", +}; + +/// The measurement-run manifest: the honest subset of +/// `member::sim_optimal_manifest` with no broker/R specifics. +/// `broker` carries the interim `"measurement"` +/// sentinel — `RunManifest.broker` is a required `String` today; the honest +/// `Option` model is deferred to the #147 metric-genericity block so this +/// phase stays #147-free and the strategy-path C18 goldens byte-identical. +pub fn measurement_manifest( + params: Vec<(String, Scalar)>, + window: (Timestamp, Timestamp), + seed: u64, +) -> RunManifest { + RunManifest { + commit: ENGINE_COMMIT.to_string(), + params, + defaults: Vec::new(), + window, + seed, + broker: "measurement".to_string(), + selection: None, + instrument: None, + topology_hash: None, + project: None, + } +} + +/// The bare measurement run (C28 phase 3): `run_signal_r` MINUS `wrap_r` and the +/// eq/ex/r R-evaluation, KEEPING the declared-tap bind → drain → persist (C27). +/// No broker, no risk executor, no per-cycle equity/exposure/r recorders — this +/// is where the measured O(cycles) retention is removed. The tap machinery is +/// duplicated (not extracted) so `run_signal_r` stays byte-identical. +#[allow(clippy::type_complexity)] +pub fn run_measurement( + signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env, +) -> MeasurementReport { + // topology_hash's own two-line body, inlined (mirrors member::run_signal_r): + // `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's + // `topology_hash` helper is the same primitive, kept single-sourced at + // `aura_research`. + let topo = aura_research::content_id_of( + &aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"), + ); // before signal is consumed + let run_name = signal.name().to_string(); + let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); + if matches!(data, RunData::Synthetic) && !binding.close_only() { + eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding)); + std::process::exit(1); + } + let names: Vec = signal.param_space().iter().map(|p| p.name.clone()).collect(); + let defaults = wrapped_bound_defaults(&signal); + let (sources, window, _pip_size) = resolve_run_data(&data, env, &binding); + + // Compile the signal DIRECTLY — no wrap_r, no broker/executor/eq-ex-r sinks. + let mut flat = signal.compile_with_params(params).unwrap_or_else(|e| { + eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}"); + std::process::exit(1); + }); + + // Bind each declared tap to a Recorder (mirrors run_signal_r). + let mut seen: BTreeSet = BTreeSet::new(); + let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec)>)> = Vec::new(); + let declared: Vec = flat.taps.clone(); + for tap in &declared { + if !seen.insert(tap.name.clone()) { + eprintln!("aura: {}", TapBindError::DuplicateBind { name: tap.name.clone() }); + std::process::exit(1); + } + let kind = flat.signatures[tap.node].output[tap.field].kind; + let (tx, rx) = mpsc::channel(); + let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone(); + flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig) + .expect("declared tap binds (name from flat.taps, kind from its own signature)"); + tap_drains.push((tap.name.clone(), kind, rx)); + } + + let mut h = Harness::bootstrap(flat).expect("valid measurement harness"); + h.run_bound(key_supply(&binding, sources)) + .expect("sources opened against `binding` key-match that binding's own roles by construction"); + + let named_params: Vec<(String, Scalar)> = + names.into_iter().zip(params.iter().copied()).collect(); + let mut manifest = measurement_manifest(named_params, window, seed); + manifest.defaults = defaults; + manifest.topology_hash = Some(topo); + manifest.project = env.provenance(); + + // Drain + persist each declared tap (mirrors run_signal_r). + let tap_names: Vec = tap_drains.iter().map(|(n, _, _)| n.clone()).collect(); + if !tap_drains.is_empty() { + let tap_traces: Vec = tap_drains + .into_iter() + .map(|(name, kind, rx)| { + let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); + ColumnarTrace::from_rows(&name, &[kind], &rows) + }) + .collect(); + env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| { + eprintln!("aura: writing tap traces failed: {e}"); + std::process::exit(1); + }); + } + MeasurementReport { manifest, taps: tap_names } +} diff --git a/crates/aura-runner/src/member.rs b/crates/aura-runner/src/member.rs new file mode 100644 index 0000000..02ab45b --- /dev/null +++ b/crates/aura-runner/src/member.rs @@ -0,0 +1,1649 @@ +//! The canonical member-run recipe: harness assembly. +//! +//! The R-scaffolding wrap (`wrap_r`), the single-run and per-member execution +//! paths built on it (`run_signal_r`, `run_blueprint_member`), the loaded- +//! blueprint axis probe (`blueprint_axis_probe`/`blueprint_axis_probe_reopened`) +//! and its #246 override-set machinery (`wrapped_bound_names`, +//! `wrapped_bound_overrides_of`, `override_paths`, `reopen_all`) — the shipped +//! recipe `aura_campaign::MemberRunner` implementors (the CLI's +//! `CliMemberRunner`, and any downstream World program) drive real data +//! through. + +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::sync::{mpsc, Arc, LazyLock}; + +use aura_composites::{cost_graph, risk_executor, StopRule}; +use aura_core::{zip_params, Cell, Firing, ParamSpec, PrimitiveBuilder, Scalar, ScalarKind, Timestamp}; +use aura_engine::{ + blueprint_from_json, window_of, BlueprintNode, ColumnarTrace, Composite, GraphBuilder, Harness, + RunManifest, VecSource, +}; +use aura_backtest::{ + summarize, summarize_r, RunMetrics, RunReport, SimBroker, PM_FIELD_NAMES, PM_RECORD_KINDS, +}; +use aura_std::{GatedRecorder, LinComb, Recorder, RollingMax, RollingMin, SeriesReducer, Sub}; +use aura_strategy::{cost_port, GEOMETRY_WIDTH}; + +use crate::binding::ResolvedBinding; +use crate::project::Env; +use crate::translate::{R_SMA_STOP_LENGTH, R_SMA_STOP_K}; + +/// The single build-time commit provenance (`option_env!("AURA_COMMIT")`, +/// falling back to `"unknown"`) — `sim_optimal_manifest`'s `RunManifest.commit` +/// reads this one const, mirroring the CLI shell's own copy (both resolve the +/// same process-wide env var at compile time, so the two cannot disagree). +const ENGINE_COMMIT: &str = match option_env!("AURA_COMMIT") { + Some(c) => c, + None => "unknown", +}; + +/// The pip size the built-in *synthetic* families (sweep/walkforward/mc over a +/// blueprint) run at: a 5-decimal FX major (`EURUSD`-shaped). The synthetic streams +/// carry no instrument, so there is no recorded geometry sidecar to thread; a +/// single named source keeps the broker's divisor (`SimBroker::builder`) and the +/// recorded broker label (`sim_optimal_manifest`) in lockstep, so they cannot +/// silently drift apart. The real path threads the sidecar's looked-up `pip_size` +/// instead. +pub const SYNTHETIC_PIP_SIZE: f64 = 0.0001; + +/// Which data a `run` drives a harness on: the built-in synthetic stream, or real M1 +/// close bars for a vetted symbol over an optional window. +#[derive(Debug)] +pub enum RunData { + Synthetic, + Real { symbol: String, from: Option, to: Option }, +} + +/// Build the sim-optimal `RunManifest`: the engine-external descriptor fields +/// (commit, seed-free synthetic run, broker label) are constant across the CLI's +/// built-in harnesses — only `params` and `window` vary. Centralizing the broker +/// label keeps it a single source (and a single edit point for per-asset pip +/// work): the `pip_size` it renders is the one its caller ran the broker at. +pub fn sim_optimal_manifest( + params: Vec<(String, Scalar)>, + window: (Timestamp, Timestamp), + seed: u64, + pip_size: f64, +) -> RunManifest { + // Typed params pass straight through: the manifest carries self-describing + // Scalars, so a length stays `i64` and a scale `f64` in the record. + RunManifest { + commit: ENGINE_COMMIT.to_string(), + params, + defaults: Vec::new(), + window, + seed, + broker: format!("sim-optimal(pip_size={pip_size})"), + selection: None, + instrument: None, + topology_hash: None, + project: None, + } +} + +/// The wrap-prefixed BOUND-param defaults of `signal` (#249): every +/// `bound_param_space()` entry as a `(., value)` pair, in +/// `bound_param_space()` order — the `RunManifest.defaults` field. Must be read +/// off `signal` BEFORE it is consumed by `wrap_r`/reopening: an axis-reopened +/// bound param has already left `bound_param_space()` by construction +/// (`Composite::reopen` forgets the bound value), so a caller that reopens +/// overrides before calling this naturally excludes them — no separate filter +/// needed. Same prefixing rule as `wrapped_bound_names`, kept separate because +/// that helper discards the value this one needs. +pub fn wrapped_bound_defaults(signal: &Composite) -> Vec<(String, Scalar)> { + let prefix = format!("{}.", signal.name()); + signal + .bound_param_space() + .into_iter() + .map(|b| (format!("{prefix}{}", b.name), b.value)) + .collect() +} + +/// The honest broker label for the dual-tap r-sma harness: it runs a RiskExecutor +/// branch alongside the SimBroker, so the plain "sim-optimal" label would under-report +/// it (#132). Shared by the single run and the sweep so the two cannot drift. +fn r_sma_broker_label(pip_size: f64) -> String { + format!("sim-optimal+risk-executor(pip_size={pip_size})") +} + +/// A rise-fall-rise synthetic stream for the r-sma smoke run: long enough to warm +/// the `vol_stop(length=3)` and flip the SMA(2)/SMA(4) cross at least once, so the +/// RiskExecutor opens and closes at least one trade. Deterministic (C1). +pub fn r_sma_prices() -> Vec<(Timestamp, Scalar)> { + [ + 1.0000_f64, 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, + ] + .iter() + .enumerate() + .map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p))) + .collect() +} + +/// No-local-data refusal — stderr + exit(1). +pub fn no_real_data(symbol: &str, env: &Env) -> ! { + eprintln!("aura: no local data for symbol '{symbol}' at {}", env.data_path()); + std::process::exit(1) +} + +/// Empty-in-window refusal — stderr + exit(1). Distinct from `no_real_data`: +/// used only inside `probe_window`, whose callers have already proven the +/// symbol present via `has_symbol` — an empty probe result there is a fact +/// about the requested `--from`/`--to` window, not about symbol absence, so +/// it must not reuse the symbol-absence message (#242). +fn no_data_in_window(symbol: &str, from_ms: Option, to_ms: Option, env: &Env) -> ! { + let bound = |b: Option| b.map_or_else(|| "unbounded".to_string(), |v| v.to_string()); + eprintln!( + "aura: no data for symbol '{symbol}' in the requested window [{}, {}] at {}", + bound(from_ms), + bound(to_ms), + env.data_path() + ); + std::process::exit(1) +} + +/// Resolve the per-instrument pip from the recorded geometry sidecar, or refuse +/// (stderr + exit 1) when the symbol has no recorded geometry — the single home of +/// the guessed-pip refusal, shared by `open_real_source` and the CLI shell's +/// `DataSource::from_choice`. Reads the symbol's geometry metadata (not bar data) +/// before the run source opens, so an instrument with no recorded geometry +/// refuses without a guessed pip; the pip is the provider's recorded value, +/// honest by construction. +pub fn pip_or_refuse(server: &Arc, symbol: &str, env: &Env) -> f64 { + match aura_ingest::instrument_geometry(server, symbol) { + Some(geo) => geo.pip_size, + None => { + eprintln!( + "aura: no recorded geometry for symbol '{symbol}' at {} — \ + refusing to run a real instrument with a guessed pip", + env.data_path() + ); + std::process::exit(1); + } + } +} + +/// Probe the full data window: open a single-pass probe source through the +/// shared opener, drain it for the first/last timestamp, and return +/// `(first, last)`. Refuses (via `no_data_in_window`) when the symbol/window +/// yields no source or no bars — callers have already proven the symbol +/// present, so an empty result here is a window fact, not symbol absence. +/// Shared by `open_real_source` (which needs the manifest window from a probe +/// separate from the run sources) and the CLI shell's `DataSource::full_window`. +/// The probe drains the CLOSE column regardless of the strategy's binding: the +/// bounds are field-independent (every archived bar carries all six fields at +/// one timestamp), and file-level absence is field-independent too. +pub fn probe_window( + server: &Arc, + symbol: &str, + from_ms: Option, + to_ms: Option, + env: &Env, +) -> (Timestamp, Timestamp) { + aura_ingest::archive_extent(server, std::path::Path::new(&env.data_path()), symbol, from_ms, to_ms) + .unwrap_or_else(|| no_data_in_window(symbol, from_ms, to_ms, env)) +} + +/// Open the real M1 sources for a recorded symbol over an optional window — one +/// source per resolved binding column, in canonical order — returning the run +/// sources paired with the manifest `window` and the per-instrument `pip_size`. +/// Single home of the real-source construction the single-run handlers share — the +/// sidecar-pip lookup, the `DataServer` `has_symbol` refusal, the probe-window pass, and +/// the run-source open (each refusal an stderr + exit 1). Pre-data refusals keep the +/// pip honest by construction. Used by `resolve_run_data`. +fn open_real_source( + symbol: &str, + from_ms: Option, + to_ms: Option, + env: &Env, + fields: &[aura_ingest::M1Field], +) -> (Vec>, (Timestamp, Timestamp), f64) { + // Per-instrument pip from the recorded sidecar; resolved BEFORE bar-data access + // so an instrument with no geometry refuses without touching the archive. + let server = Arc::new(data_server::DataServer::new(env.data_path())); + let pip = pip_or_refuse(&server, symbol, env); + if !server.has_symbol(symbol) { + no_real_data(symbol, env); + } + // Manifest window: drain a separate probe (single-pass Source) for first/last ts. + let window = probe_window(&server, symbol, from_ms, to_ms, env); + let sources = aura_ingest::open_columns(&server, symbol, from_ms, to_ms, fields) + .unwrap_or_else(|| no_real_data(symbol, env)); + (sources, window, pip) +} + +/// Short-horizon realized-range window for vol-scaled slippage. Deliberately +/// distinct from `R_SMA_STOP_LENGTH` (3): scaling slippage by the stop's own +/// vol would collapse cost-in-R to a constant (spec 0082). Short enough to warm +/// within the synthetic smoke fixture so the run path exercises non-zero slippage. +const SLIP_VOL_LENGTH: i64 = 5; + +/// The optional cost leg of the R scaffolding (#234): the resolved cost-node +/// builders (from `translate::cost_nodes_for`, fully bound — they add no +/// open param, so the wrapped `param_space` is cost-invariant) plus the two +/// recording channels — the aggregate 3-field cost record (`tx_cost`, the +/// `summarize_r` join input) and the `net_r_equity` curve (`tx_net`, recorded +/// only in `!reduce` trace mode). +pub struct CostLeg { + pub nodes: Vec, + pub tx_cost: mpsc::Sender<(Timestamp, Vec)>, + pub tx_net: mpsc::Sender<(Timestamp, Vec)>, +} + +/// Interned `col[i]` recorder-port names, built once. The `GraphBuilder::input` API +/// wants `&'static str`; interning here (instead of `format!(...).leak()` per field) +/// means reusing the r-sma harness in a sweep / Monte-Carlo loop reuses these +/// strings rather than leaking 14 fresh ones per build (#132). +static COL_PORTS: LazyLock> = + LazyLock::new(|| (0..PM_FIELD_NAMES.len()).map(|i| format!("col[{i}]")).collect()); + +/// Wrap a `signal` composite (a roles→`bias` leg) in the R run +/// scaffolding: pip broker, the per-tap recorders, the vol-stop RiskExecutor, the +/// r_equity / cost legs. The signal is nested; its root roles come from the +/// resolved `binding` (one per entry, canonical column order — the same order the +/// callers open real columns in, so role i receives source i), and the binding's +/// guaranteed close entry always feeds the broker/executor pair. A serialized +/// signal loaded via `blueprint_from_json` runs through exactly the scaffolding +/// the Rust-built signal does. +#[allow(clippy::type_complexity, clippy::too_many_arguments)] +pub fn wrap_r( + signal: Composite, + tx_eq: mpsc::Sender<(Timestamp, Vec)>, + tx_ex: mpsc::Sender<(Timestamp, Vec)>, + tx_r: mpsc::Sender<(Timestamp, Vec)>, + tx_req: mpsc::Sender<(Timestamp, Vec)>, + stop: StopRule, + reduce: bool, + pip_size: f64, + binding: &ResolvedBinding, + cost: Option, +) -> Composite { + let mut g = GraphBuilder::new("r_sma"); + // The strategy signal → Bias, nested as a serializable roles→`bias` leg. + let sig = g.add(BlueprintNode::Composite(signal)); + // pip branch (verbatim from the retired `sample_blueprint_with_sinks`, #159). + let broker = g.add(SimBroker::builder(pip_size)); + // R branch: bias + price → RiskExecutor(vol_stop) → dense R-record. The stop is a + // fixed `StopRule` — the pinned default constants, or an arbitrary per-regime rule + // the caller resolved. + // In `reduce` mode the per-cycle taps fold online: SeriesReducer folds the eq/ex + // f64 series to one summary row, GatedRecorder retains only the gated R rows — the + // O(cycles)→O(trades) memory win. The raw `Recorder`s (and the r_equity tap) are the + // `--trace` path, where the full per-cycle series is persisted. + let gate_col = PM_FIELD_NAMES + .iter() + .position(|&n| n == "closed_this_cycle") + .expect("PM record has a closed_this_cycle column"); + let eq = if reduce { + g.add(SeriesReducer::builder(Firing::Any, tx_eq)) + } else { + g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq)) + }; + let ex = if reduce { + g.add(SeriesReducer::builder(Firing::Any, tx_ex)) + } else { + g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex)) + }; + let exec = g.add(risk_executor(stop, 1.0)); + let rrec = if reduce { + g.add(GatedRecorder::builder(PM_RECORD_KINDS.to_vec(), gate_col, Firing::Any, tx_r)) + } else { + g.add(Recorder::builder(PM_RECORD_KINDS.to_vec(), Firing::Any, tx_r)) + }; + // Root roles come from the resolved binding, one per entry in canonical + // column order (the C4 merge tie-break order the callers open columns in). + // The binding guarantees a close entry, which always feeds the broker and + // the executor — "price" below is their node-schema PORT name, not a role + // name. For a single-`price` signal this declares exactly the old weld: + // one role, targets [sig, broker, exec] (feed calls append targets). + let mut close_handle = None; + for entry in binding.entries() { + let role = g.source_role(&entry.role, crate::binding::column_kind(entry.column)); + if entry.feeds_signal { + // `NodeHandle::input` takes a `&'static str` port name (the fluent + // `GraphBuilder`'s authoring-time contract, builder.rs) but the + // binding's role names are dynamic (loaded from a blueprint at + // runtime). Vocabulary names use their static form; only an + // override-renamed role leaks its name (once per graph build, + // never per-tick). + let port: &'static str = crate::binding::static_role_name(&entry.role) + .unwrap_or_else(|| Box::leak(entry.role.clone().into_boxed_str())); + g.feed(role, [sig.input(port)]); + } + if entry.column == aura_ingest::M1Field::Close && close_handle.is_none() { + close_handle = Some(role); + } + } + let close = close_handle.expect("ResolvedBinding guarantees a close entry"); + g.feed(close, [broker.input("price"), exec.input("price")]); + g.connect(sig.output("bias"), broker.input("exposure")); + g.connect(sig.output("bias"), ex.input("col[0]")); + g.connect(sig.output("bias"), exec.input("bias")); + g.connect(broker.output("equity"), eq.input("col[0]")); + for (i, field) in PM_FIELD_NAMES.iter().enumerate() { + g.connect(exec.output(field), rrec.input(COL_PORTS[i].as_str())); + } + if !reduce { + // r_equity = cum_realized_r + unrealized_r — one tapped series for charting. + let r_equity = g.add( + LinComb::builder(2) + .bind("weights[0]", Scalar::f64(1.0)) + .bind("weights[1]", Scalar::f64(1.0)), + ); + let req = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_req)); + g.connect(exec.output("cum_realized_r"), r_equity.input("term[0]")); + g.connect(exec.output("unrealized_r"), r_equity.input("term[1]")); + g.connect(r_equity.output("value"), req.input("col[0]")); + } + // The optional cost leg (#234 — the #221-deleted wiring rebuilt as a + // campaign-documented feature): a `cost_graph` fed by the executor's four + // geometry outputs, its aggregate record recorded co-temporally with the R + // record (the `summarize_r` positional-join input), and — in `!reduce` + // trace mode — the LinComb(4) net-equity curve into `tx_net`. Absent leg + // (`None`) == today's graph, byte-identical. + if let Some(leg) = cost { + // Components taking a `volatility` extra input (the vol_slippage + // shape), discovered from each builder's own schema past the geometry + // prefix — no second vocabulary of "which node needs the proxy". + let vol_slots: Vec = leg + .nodes + .iter() + .enumerate() + .filter(|(_, n)| { + n.schema().inputs[GEOMETRY_WIDTH..].iter().any(|p| p.name == "volatility") + }) + .map(|(k, _)| k) + .collect(); + // The short-horizon realized-range vol proxy, fed from the close role, + // shared by every vol-scaled component (the #221-deleted arm, verbatim + // wiring; built in BOTH modes — the reduce-mode member run charges + // slippage too). + let vol_range = if vol_slots.is_empty() { + None + } else { + let vhi = g.add(RollingMax::builder().named("slip_vol_hi").bind("length", Scalar::i64(SLIP_VOL_LENGTH))); + let vlo = g.add(RollingMin::builder().named("slip_vol_lo").bind("length", Scalar::i64(SLIP_VOL_LENGTH))); + let vrange = g.add(Sub::builder().named("slip_vol_range")); + g.connect(vhi.output("value"), vrange.input("lhs")); + g.connect(vlo.output("value"), vrange.input("rhs")); + g.feed(close, [vhi.input("series"), vlo.input("series")]); + Some(vrange) + }; + // One cost_graph composite fans the shared PM-geometry into the + // components and sums their per-field charges (C10). + let cg = g.add(cost_graph(leg.nodes)); + g.connect(exec.output("closed_this_cycle"), cg.input("closed")); + g.connect(exec.output("open"), cg.input("open")); + g.connect(exec.output("entry_price"), cg.input("entry_price")); + g.connect(exec.output("stop_price"), cg.input("stop_price")); + for k in vol_slots { + let vrange = vol_range.as_ref().expect("proxy is built whenever a vol slot exists"); + g.connect(vrange.output("value"), cg.input(cost_port(k, "volatility"))); + } + if reduce { + // The aggregate cost record, gated on the SAME closed flag as the R + // GatedRecorder and flushed once at finalize — so the cost rows stay + // positionally 1:1 with the gated R rows (`summarize_r`'s join). The + // gate rides as an APPENDED col 3: cols 0..2 keep the aura-analysis + // `cost_col` contract (cost_in_r = 0, open_cost_in_r = 2). + let crec = g.add(GatedRecorder::builder( + vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64, ScalarKind::Bool], + 3, + Firing::Any, + leg.tx_cost, + )); + g.connect(cg.output("cost_in_r"), crec.input("col[0]")); + g.connect(cg.output("cum_cost_in_r"), crec.input("col[1]")); + g.connect(cg.output("open_cost_in_r"), crec.input("col[2]")); + g.connect(exec.output("closed_this_cycle"), crec.input("col[3]")); + } else { + // The full per-cycle aggregate cost record (col 0 per-close, col 2 + // window-end — what summarize_r folds on the trace path). + let crec = g.add(Recorder::builder( + vec![ScalarKind::F64, ScalarKind::F64, ScalarKind::F64], + Firing::Any, + leg.tx_cost, + )); + g.connect(cg.output("cost_in_r"), crec.input("col[0]")); + g.connect(cg.output("cum_cost_in_r"), crec.input("col[1]")); + g.connect(cg.output("open_cost_in_r"), crec.input("col[2]")); + // net_r_equity = cum_realized_r + unrealized_r − Σcum_cost_in_r + // − Σopen_cost_in_r (the #221-deleted LinComb(4), weights 1,1,-1,-1). + let net_eq = g.add( + LinComb::builder(4) + .bind("weights[0]", Scalar::f64(1.0)) + .bind("weights[1]", Scalar::f64(1.0)) + .bind("weights[2]", Scalar::f64(-1.0)) + .bind("weights[3]", Scalar::f64(-1.0)), + ); + g.connect(exec.output("cum_realized_r"), net_eq.input("term[0]")); + g.connect(exec.output("unrealized_r"), net_eq.input("term[1]")); + g.connect(cg.output("cum_cost_in_r"), net_eq.input("term[2]")); + g.connect(cg.output("open_cost_in_r"), net_eq.input("term[3]")); + let net_rec = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, leg.tx_net)); + g.connect(net_eq.output("value"), net_rec.input("col[0]")); + } + } + g.build().expect("r_sma wiring resolves") +} + +/// Pair each opened source with its role name from the resolved binding, forming +/// the keyed supply `run_bound` resolves by name. `sources` are opened in +/// `binding.columns()` order (== `binding.entries()` order), so entry `i`'s role +/// names source `i` — the one place open-order and role-order meet, made explicit +/// so `bind_sources` verifies the wiring↔supply role match by name (#275). +pub fn key_supply( + binding: &ResolvedBinding, + sources: Vec>, +) -> Vec<(String, Box)> { + binding + .entries() + .iter() + .map(|e| e.role.clone()) + .zip(sources) + .collect() +} + +/// Resolve a `RunData` selector to the `(sources, window, pip_size)` triple the +/// run paths feed to the harness: the built-in synthetic R stream (close-only — +/// the caller guards the binding shape), or the lazily-streamed real sources of +/// the binding's resolved columns (with the sidecar pip + probed window). +/// Single definition used by `run_signal_r`. +#[allow(clippy::type_complexity)] +pub fn resolve_run_data( + data: &RunData, + env: &Env, + binding: &ResolvedBinding, +) -> ( + Vec>, + (Timestamp, Timestamp), + f64, +) { + match data { + RunData::Synthetic => { + let sources: Vec> = + vec![Box::new(VecSource::new(r_sma_prices()))]; + let window = window_of(&sources).expect("non-empty synthetic stream"); + (sources, window, SYNTHETIC_PIP_SIZE) + } + RunData::Real { symbol, from, to } => { + open_real_source(symbol, *from, *to, env, &binding.columns()) + } + } +} + +/// Run a signal blueprint through the R scaffolding: hash the signal, +/// wrap it (broker + equity/exposure/R sinks), compile with `params`, bootstrap, +/// run over `data`, and build the RunReport (manifest carries topology_hash). +/// The single construction+run path shared by the `aura run ` CLI +/// arm and its bit-identical test. +#[allow(clippy::type_complexity)] +pub fn run_signal_r( + signal: Composite, params: &[Scalar], data: RunData, seed: u64, env: &Env, +) -> RunReport { + // topology_hash's own two-line body, inlined: `content_id_of` over the + // canonical (#164) blueprint JSON — the CLI shell's `topology_hash` + // helper is the same primitive, kept single-sourced at `aura_research`. + let topo = aura_research::content_id_of( + &aura_engine::blueprint_to_json(&signal).expect("a buildable signal serializes"), + ); // before signal is consumed + let run_name = signal.name().to_string(); // before signal is consumed by `wrap_r` + // The default binding (name defaults; `aura run` carries no campaign + // overrides). Refusals are the established `aura: ` + exit-1 register. + let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .unwrap_or_else(|m| { + eprintln!("aura: {m}"); + std::process::exit(1); + }); + if matches!(data, RunData::Synthetic) && !binding.close_only() { + eprintln!("aura: {}", crate::binding::synthetic_refusal(signal.name(), &binding)); + std::process::exit(1); + } + let names: Vec = signal + .param_space() + .iter() + .map(|p| p.name.clone()) + .collect(); + let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + // The req tap (r_equity recorder) is wired but not persisted on this path; keep the + // receiver alive so the sink's sends do not fail, but do not drain it. + let (tx_req, _rx_req) = mpsc::channel(); + let (sources, window, pip_size) = resolve_run_data(&data, env, &binding); + let wrapped = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, false, pip_size, &binding, None); + let mut flat = wrapped.compile_with_params(params).unwrap_or_else(|e| { + eprintln!("aura: this blueprint does not compile to a runnable harness: {e:?}"); + std::process::exit(1); + }); + // Bind each declared tap to a fresh `Recorder` + channel, before bootstrap + // — `flat.taps` already carries the signal's declared taps hoisted to the + // root. Dedup is the caller's per `TapBindError::DuplicateBind`'s doc (the + // engine keeps no cross-call state). + let mut seen: BTreeSet = BTreeSet::new(); + let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec)>)> = Vec::new(); + let declared: Vec = flat.taps.clone(); + for tap in &declared { + if !seen.insert(tap.name.clone()) { + eprintln!("aura: {}", aura_engine::TapBindError::DuplicateBind { name: tap.name.clone() }); + std::process::exit(1); + } + let kind = flat.signatures[tap.node].output[tap.field].kind; + let (tx, rx) = mpsc::channel(); + let sig = Recorder::builder(vec![kind], Firing::Any, tx.clone()).schema().clone(); + flat.bind_tap(&tap.name, Box::new(Recorder::new(&[kind], Firing::Any, tx)), sig) + .expect("declared tap binds (name from flat.taps, kind from its own signature)"); + tap_drains.push((tap.name.clone(), kind, rx)); + } + let mut h = Harness::bootstrap(flat).expect("valid r-sma harness"); + // `sources` were opened via `resolve_run_data(&data, env, &binding)` against + // this SAME `binding`, and `key_supply` keys them by that binding's own role + // names — a `SourceBindError` here can only mean the wiring↔supply pairing + // this function builds is internally inconsistent, never a user input + // mistake. Panic like the adjacent bootstrap invariants above, not a + // process exit dressed as a refusal message. + h.run_bound(key_supply(&binding, sources)) + .expect("sources opened against `binding` key-match that binding's own roles by construction"); + let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); + let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let named_params: Vec<(String, Scalar)> = + names.into_iter().zip(params.iter().copied()).collect(); + let mut manifest = sim_optimal_manifest(named_params, window, seed, pip_size); + manifest.defaults = defaults; + manifest.broker = r_sma_broker_label(pip_size); + manifest.topology_hash = Some(topo); + manifest.project = env.provenance(); + let mut metrics = summarize(&aura_engine::f64_field(&eq_rows, 0), &aura_engine::f64_field(&ex_rows, 0)); + metrics.r = Some(summarize_r(&r_rows, &[])); + // Drain + persist each declared tap's series, guarded so a tap-free + // `aura run` stays byte-identical to today (no `runs/` write at all). + // `manifest` is built above so the persisted `index.json` carries this + // run's own provenance. + if !tap_drains.is_empty() { + let tap_traces: Vec = tap_drains + .into_iter() + .map(|(name, kind, rx)| { + let rows: Vec<(Timestamp, Vec)> = rx.try_iter().collect(); + ColumnarTrace::from_rows(&name, &[kind], &rows) + }) + .collect(); + env.trace_store().write(&run_name, &manifest, &tap_traces).unwrap_or_else(|e| { + eprintln!("aura: writing tap traces failed: {e}"); + std::process::exit(1); + }); + } + RunReport { manifest, metrics } +} + +/// Run one bootstrapped member of a loaded-signal sweep: the reduce-mode path +/// (`SeriesReducer` folds eq/ex, `GatedRecorder` retains the gated R rows — the +/// O(cycles)→O(trades) fold), shared by the live sweep AND reproduction so a +/// reproduced member re-derives bit-identically (C1). `signal` is a freshly +/// reloaded blueprint (`Composite` is `!Clone`); `point` is the member's bound +/// cells; `space` gives the by-name manifest params; `topo` the shared signal hash. +#[allow(clippy::too_many_arguments)] +pub fn run_blueprint_member( + signal: Composite, + point: &[Cell], + space: &[ParamSpec], + sources: Vec>, + window: (Timestamp, Timestamp), + seed: u64, + pip: f64, + topo: &str, + env: &Env, + stop: StopRule, + binding: &ResolvedBinding, + cost: &[aura_research::CostSpec], + instrument: &str, +) -> RunReport { + let defaults = wrapped_bound_defaults(&signal); // read before `signal` is consumed below + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + // The doc's cost model as an optional wrap leg (#234): empty = no leg, + // exactly the pre-cost graph. The net curve is a !reduce trace concern; + // its sender is wired but unread here (the r_equity tap precedent above). + let (tx_cost, rx_cost) = mpsc::channel(); + let (tx_net, _rx_net) = mpsc::channel(); + let cost_leg = (!cost.is_empty()).then(|| CostLeg { + nodes: crate::translate::cost_nodes_for(cost, instrument), + tx_cost, + tx_net, + }); + let mut h = wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, true, pip, binding, cost_leg) + .bootstrap_with_cells(point) + .expect("member bootstraps (point kind-checked against param_space)"); + // Called from inside `CliMemberRunner::run_member`'s `catch_unwind` + // containment (#272 — that impl's doc contract: "never a process exit + // inside a sweep worker, every refusal a member fault"). A + // `SourceBindError` here is an internal wiring/supply mismatch, not user + // input (see the sibling `.expect` two lines above) — panic so the + // containing `catch_unwind` records it as a per-cell `MemberFault::Panic` + // instead of `process::exit` aborting the whole campaign. + h.run_bound(key_supply(binding, sources)) + .expect("sources opened against `binding` key-match that binding's own roles by construction"); + let mut named = zip_params(space, point); // by-name params for the manifest record + // `match` (not an irrefutable `let`): `StopRule` also has a `Fixed` variant, + // which stamps no vol knobs. The campaign/single-run paths only pass `Vol` + // or `VolTf`, so the `Fixed` arm is inert here. + match stop { + StopRule::Vol { length, k } => { + named.push(("stop_length".to_string(), Scalar::i64(length))); + named.push(("stop_k".to_string(), Scalar::f64(k))); + } + StopRule::VolTf { period_minutes, length, k } => { + named.push(("stop_period_minutes".to_string(), Scalar::i64(period_minutes))); + named.push(("stop_length".to_string(), Scalar::i64(length))); + named.push(("stop_k".to_string(), Scalar::f64(k))); + } + StopRule::Fixed(_) => {} + } + // Stamp the cost model the member ran under, beside the stop knobs (#234, + // the #233 pattern): one `cost[k].` param per component, in + // component order — the knob name discriminates the variant (each shipped + // cost node has exactly one, distinctly named knob). The name is read off + // `translate::cost_knob`, the same function `cost_nodes_for` binds + // through, so the stamp key cannot drift from the bind key: the manifest + // carries enough to re-derive the exact model later. `reproduce_family_in` + // reads this stamp back via `translate::cost_specs_from_params` (the #233 + // stop-regime pattern), so a costed family reproduces net, not gross. + for (k, spec) in cost.iter().enumerate() { + let (knob, v) = crate::translate::cost_knob(spec, instrument); + named.push((format!("cost[{k}].{knob}"), Scalar::f64(v))); + } + let mut manifest = sim_optimal_manifest(named, window, seed, pip); + manifest.defaults = defaults; + manifest.broker = r_sma_broker_label(pip); + manifest.topology_hash = Some(topo.to_string()); + manifest.project = env.provenance(); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + // The member's cost rows (empty when no cost model) join the R reduction: + // net_expectancy_r diverges from gross by exactly the modelled costs. + let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); + let (total_pips, max_drawdown) = rx_eq + .try_iter() + .next() + .map(|(_, row)| (row[0].as_f64(), row[1].as_f64())) + .unwrap_or((0.0, 0.0)); + let bias_sign_flips = + rx_ex.try_iter().next().map(|(_, row)| row[2].as_i64() as u64).unwrap_or(0); + let mut m = RunMetrics { total_pips, max_drawdown, bias_sign_flips, r: None }; + m.r = Some(summarize_r(&r_rows, &cost_rows)); + RunReport { manifest, metrics: m } +} + +/// The exact wrapped probe the loaded-blueprint sweep resolves its axes +/// against: the loaded signal wrapped in the r-sma scaffolding (stop bound, +/// reduce, no cost), taps discarded. `param_space()` on it is the axis +/// namespace `--axis` binds; `.axis()` consumes it to seed a sweep. Single +/// source for the sweep terminal, the MC closed-check, AND `--list-axes`, so +/// the listed names track the swept names by construction (incl. across #159's +/// harness retirement). The reload is infallible under the SAME +/// dispatch-boundary contract the callers already rely on: the doc is +/// `blueprint_from_json`-validated at the `["sweep", ..]` / `["mc", ..]` +/// boundary before this runs, so a malformed doc has already exited 2 and +/// never reaches the `.expect`. +pub fn blueprint_axis_probe(doc: &str, env: &Env) -> Composite { + blueprint_axis_probe_reopened(doc, env, &[]) +} + +/// The axis probe with a #246 override set re-opened on the strategy BEFORE +/// wrapping — probe and per-member reloads must re-open identically so points +/// resolve against one space. The empty-set form is byte-equal to the old +/// probe (mc/run closed-checks and the open `--list-axes` lines read that). +pub fn blueprint_axis_probe_reopened(doc: &str, env: &Env, overrides: &[String]) -> Composite { + let signal = blueprint_from_json(doc, &|t| env.resolve(t)) + .expect("doc parse-validated at the dispatch boundary; reload is infallible"); + let signal = reopen_all(signal, overrides); + // The PROBE binding is lenient (unresolvable roles fall back to close), + // like the probe's synthetic pip: the wrap is built for its param_space + // only, never run over data — strict resolution lives on the run paths. + let probe = crate::binding::probe_binding(signal.input_roles()); + let (tx_eq, _) = mpsc::channel(); + let (tx_ex, _) = mpsc::channel(); + let (tx_r, _) = mpsc::channel(); + let (tx_req, _) = mpsc::channel(); + wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, true, SYNTHETIC_PIP_SIZE, &probe, None) +} + +/// The WRAPPED-coordinate BOUND param name set of `signal` (#246): every +/// `bound_param_space()` entry prefixed with `.` — the same +/// coordinate `param_space()`'s OPEN entries live in on the wrapped graph. +/// Extracted because `wrapped_bound_overrides_of`, `override_paths`, and the +/// CLI shell's `validate_and_register_axes` each independently rebuilt this +/// exact set (the rule-of-three the neighbouring doc comment itself cites). +pub fn wrapped_bound_names(signal: &Composite) -> HashSet { + let prefix = format!("{}.", signal.name()); + signal + .bound_param_space() + .into_iter() + .map(|b| format!("{prefix}{}", b.name)) + .collect() +} + +/// The override subset of `names` (#246): every WRAPPED-coordinate name +/// missing the un-reopened wrapped OPEN space but naming a BOUND param of the +/// strategy — returned in STRATEGY coordinates (the wrap prefix +/// `.` stripped) for `Composite::reopen`. Names matching +/// neither space are skipped here; the caller decides whether that is an +/// error (sweep boundary) or falls through to its existing resolution +/// errors. The silent variant `reproduce_family_in` uses over the RECORDED +/// manifest param names (the sweep boundary uses the stricter +/// `override_paths`, which errors on an unmatched name instead). Contrast +/// `axes::raw_bound_overrides_of`, whose `names` are already RAW (a campaign +/// document's own namespace, #203) and needs no such stripping. +pub fn wrapped_bound_overrides_of( + names: &[String], + open_space: &[ParamSpec], + signal: &Composite, +) -> Vec { + let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect(); + let prefix = format!("{}.", signal.name()); + let bound = wrapped_bound_names(signal); + names + .iter() + .filter(|n| !open.contains(n.as_str()) && bound.contains(*n)) + .map(|n| n[prefix.len()..].to_string()) + .collect() +} + +/// The sweep-boundary variant (#246): like `wrapped_bound_overrides_of`, but +/// an axis matching NEITHER the open nor the bound space is the error — the +/// honest replacement of the retired "fully bound; nothing to sweep" refusal. +pub fn override_paths( + axes: &[(String, Vec)], + open_space: &[ParamSpec], + signal: &Composite, +) -> Result, String> { + let open: HashSet<&str> = open_space.iter().map(|p| p.name.as_str()).collect(); + let prefix = format!("{}.", signal.name()); + let bound = wrapped_bound_names(signal); + let mut overrides = Vec::new(); + for (name, _) in axes { + if open.contains(name.as_str()) { + continue; + } + if bound.contains(name) { + overrides.push(name[prefix.len()..].to_string()); + } else { + return Err(format!( + "axis {name}: names no param of this blueprint (open or bound) — \ + see `aura sweep --list-axes`" + )); + } + } + Ok(overrides) +} + +/// Apply a validated override set to a freshly loaded strategy (#246). +/// Infallible by contract: the set was derived against this same document at +/// the family boundary. +pub fn reopen_all(signal: Composite, overrides: &[String]) -> Composite { + overrides.iter().fold(signal, |s, p| { + s.reopen(p).expect("override set validated at the family boundary") + }) +} + +/// The Donchian breakout signal leg, a pure `price→bias` Composite so it serialises +/// as blueprint data (#159 cut 2). The signal computation matches the retired fused +/// builder's leg (same nodes, same wiring); the pip/R harness is the generic +/// `wrap_r` wrapper, not part of the signal. `channel = Some(n)` binds both rolling +/// nodes (closed); `None` leaves them open, ganged into the single `channel_length` +/// knob (#61) — the channel is structurally ONE parameter. This carve has no +/// production caller — its only role is regenerating + pinning the shipped +/// examples via this module's own tests; production code loads the shipped +/// JSON, not this builder. `#[cfg(test)]`. +#[cfg(test)] +fn r_breakout_signal(channel: Option) -> Composite { + use aura_std::{Delay, Gt, Latch}; + let mut g = GraphBuilder::new("r_breakout_signal"); + let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1))); + let mut mx_b = RollingMax::builder().named("channel_hi"); + let mut mn_b = RollingMin::builder().named("channel_lo"); + if let Some(n) = channel { + mx_b = mx_b.bind("length", Scalar::i64(n)); + mn_b = mn_b.bind("length", Scalar::i64(n)); + } + let mx = g.add(mx_b); + let mn = g.add(mn_b); + if channel.is_none() { + g.gang("channel_length", [mx.param("length"), mn.param("length")]); + } + let gt_up = g.add(Gt::builder()); + let gt_down = g.add(Gt::builder()); + let up_latch = g.add(Latch::builder()); + let down_latch = g.add(Latch::builder()); + let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1} + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, [delay.input("series"), gt_up.input("a"), gt_down.input("b")]); + g.connect(delay.output("value"), mx.input("series")); + g.connect(delay.output("value"), mn.input("series")); + g.connect(mx.output("value"), gt_up.input("b")); + g.connect(mn.output("value"), gt_down.input("a")); + g.connect(gt_up.output("value"), up_latch.input("set")); + g.connect(gt_down.output("value"), up_latch.input("reset")); + g.connect(gt_down.output("value"), down_latch.input("set")); + g.connect(gt_up.output("value"), down_latch.input("reset")); + g.connect(up_latch.output("value"), exposure.input("lhs")); + g.connect(down_latch.output("value"), exposure.input("rhs")); + g.expose(exposure.output("value"), "bias"); + g.build().expect("r_breakout signal wiring resolves") +} + +/// The EWMA Bollinger-band mean-reversion signal leg, carved out of the retired fused +/// builder as a pure `price→bias` Composite so it serialises as blueprint data (#159 +/// cut 3). Verbatim signal computation vs that retired fused builder, except the band +/// half-width `band_k*sigma` uses `Scale` (a rosterable multiply) in place of +/// `LinComb(1)`. `#[cfg(test)]`: this module's own `wrap_r`/`run_blueprint_member` +/// unit tests below use it as a realistic non-flat-bias fixture, and its only +/// other role is regenerating + pinning the shipped examples; production loads +/// the shipped JSON, not this builder. +#[cfg(test)] +fn r_meanrev_signal(window: Option, band_k: Option) -> Composite { + use aura_std::{Add, Ema, Gt, Latch, Mul, Scale, Sqrt}; + let mut g = GraphBuilder::new("r_meanrev_signal"); + let (mut mean_b, mut var_b) = + (Ema::builder().named("mean_window"), Ema::builder().named("var_window")); + if let Some(n) = window { + mean_b = mean_b.bind("length", Scalar::i64(n)); + var_b = var_b.bind("length", Scalar::i64(n)); + } + let mean = g.add(mean_b); + let dev = g.add(Sub::builder()); // price - mean + let sq = g.add(Mul::builder()); // dev * dev + let var = g.add(var_b); // EWMA variance + if window.is_none() { + g.gang("window", [mean.param("length"), var.param("length")]); + } + let sigma = g.add(Sqrt::builder()); + let mut band_b = Scale::builder().named("band"); + if let Some(k) = band_k { + band_b = band_b.bind("factor", Scalar::f64(k)); + } + let band = g.add(band_b); + let upper = g.add(Add::builder()); + let lower = g.add(Sub::builder()); + let gt_hi = g.add(Gt::builder()); + let gt_lo = g.add(Gt::builder()); + let short_latch = g.add(Latch::builder()); + let long_latch = g.add(Latch::builder()); + let exposure = g.add(Sub::builder()); // long_latch - short_latch -> bias + let price = g.source_role("price", ScalarKind::F64); + g.feed(price, [mean.input("series"), dev.input("lhs"), gt_hi.input("a"), gt_lo.input("b")]); + g.connect(mean.output("value"), dev.input("rhs")); + g.connect(dev.output("value"), sq.input("lhs")); + g.connect(dev.output("value"), sq.input("rhs")); + g.connect(sq.output("value"), var.input("series")); + g.connect(var.output("value"), sigma.input("value")); + g.connect(sigma.output("value"), band.input("signal")); + g.connect(mean.output("value"), upper.input("lhs")); + g.connect(band.output("value"), upper.input("rhs")); + g.connect(mean.output("value"), lower.input("lhs")); + g.connect(band.output("value"), lower.input("rhs")); + g.connect(upper.output("value"), gt_hi.input("b")); + g.connect(lower.output("value"), gt_lo.input("a")); + g.connect(gt_hi.output("value"), short_latch.input("set")); + g.connect(gt_lo.output("value"), short_latch.input("reset")); + g.connect(gt_lo.output("value"), long_latch.input("set")); + g.connect(gt_hi.output("value"), long_latch.input("reset")); + g.connect(long_latch.output("value"), exposure.input("lhs")); + g.connect(short_latch.output("value"), exposure.input("rhs")); + g.expose(exposure.output("value"), "bias"); + g.build().expect("r_meanrev signal wiring resolves") +} + +/// The OHLC high/low-channel (Donchian-shape) signal leg — the harness-input- +/// binding acceptance strategy (#231): bias goes long when the CLOSE breaks +/// the rolling max of the previous `n` HIGHS, short when it breaks the rolling +/// min of the previous `n` LOWS. Three input roles (`high`, `low`, `close` — +/// the role names ARE the column binding), declared in canonical column order. +/// `channel = Some(n)` binds both rolling nodes (closed); `None` leaves them +/// open, ganged into the single `channel_length` knob (#61). `#[cfg(test)]` +/// (see `r_breakout_signal`'s doc): its only role is regenerating + pinning +/// the shipped examples via this module's own tests; production loads the +/// shipped JSON, not this builder. +#[cfg(test)] +fn r_channel_signal(channel: Option) -> Composite { + use aura_std::{Delay, Gt, Latch}; + let mut g = GraphBuilder::new("hl_channel"); + let delay_hi = g.add(Delay::builder().named("prev_high").bind("lag", Scalar::i64(1))); + let delay_lo = g.add(Delay::builder().named("prev_low").bind("lag", Scalar::i64(1))); + let mut mx_b = RollingMax::builder().named("channel_hi"); + let mut mn_b = RollingMin::builder().named("channel_lo"); + if let Some(n) = channel { + mx_b = mx_b.bind("length", Scalar::i64(n)); + mn_b = mn_b.bind("length", Scalar::i64(n)); + } + let mx = g.add(mx_b); + let mn = g.add(mn_b); + if channel.is_none() { + g.gang("channel_length", [mx.param("length"), mn.param("length")]); + } + let gt_up = g.add(Gt::builder()); + let gt_down = g.add(Gt::builder()); + let up_latch = g.add(Latch::builder()); + let down_latch = g.add(Latch::builder()); + let exposure = g.add(Sub::builder()); // up_latch - down_latch -> bias in {-1,0,+1} + let high = g.source_role("high", ScalarKind::F64); + let low = g.source_role("low", ScalarKind::F64); + let close = g.source_role("close", ScalarKind::F64); + g.feed(high, [delay_hi.input("series")]); + g.feed(low, [delay_lo.input("series")]); + g.feed(close, [gt_up.input("a"), gt_down.input("b")]); + g.connect(delay_hi.output("value"), mx.input("series")); + g.connect(delay_lo.output("value"), mn.input("series")); + g.connect(mx.output("value"), gt_up.input("b")); + g.connect(mn.output("value"), gt_down.input("a")); + g.connect(gt_up.output("value"), up_latch.input("set")); + g.connect(gt_down.output("value"), up_latch.input("reset")); + g.connect(gt_down.output("value"), down_latch.input("set")); + g.connect(gt_up.output("value"), down_latch.input("reset")); + g.connect(up_latch.output("value"), exposure.input("lhs")); + g.connect(down_latch.output("value"), exposure.input("rhs")); + g.expose(exposure.output("value"), "bias"); + g.build().expect("hl_channel signal wiring resolves") +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_engine::blueprint_to_json; + use aura_strategy::{ConstantCost, VolSlippageCost}; + use aura_vocabulary::std_vocabulary; + + #[test] + /// Independently pins the shipped `r_meanrev_signal` carve's FADE direction — + /// short (-1) above the band, long (+1) below — using the Scale-based band it + /// actually ships with. `k = 0` collapses the band to the lagging EWMA mean, + /// isolating direction + latch from the sigma threshold; window 3 (alpha = + /// 0.5) lags the level clearly. `wrap_r`'s `ex` tap reads `sig.output("bias")` + /// directly (before any broker/exec/cost machinery), so `rx_ex` carries the + /// raw signal bias. + fn r_meanrev_signal_fades_short_above_the_band_and_long_below() { + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, _rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let signal = r_meanrev_signal(Some(3), Some(0.0)); + let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let flat = wrap_r( + signal, + tx_eq, + tx_ex, + tx_r, + tx_req, + StopRule::Vol { length: 3, k: 2.0 }, + false, + SYNTHETIC_PIP_SIZE, + &binding, + None, + ) + .compile_with_params(&[]) + .expect("r-meanrev signal wraps to a valid harness"); + let mut h = Harness::bootstrap(flat).expect("r-meanrev harness bootstraps"); + // calm (price == lagging mean -> no fade) | sustained UP (price > mean -> + // fade SHORT) | sustained DOWN (price < mean -> fade LONG). + let closes = [ + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0, + ]; + let prices: Vec<(Timestamp, Scalar)> = + closes.iter().enumerate().map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))).collect(); + let src: Vec> = vec![Box::new(VecSource::new(prices))]; + h.run(src); + let bias: Vec = + rx_ex.try_iter().map(|(_, row): (Timestamp, Vec)| row[0].as_f64()).collect(); + assert!(!bias.is_empty(), "the meanrev exposure tap must emit once warmed up"); + assert_eq!(*bias.first().unwrap(), 0.0, "calm bars (price == mean) must not fade: {bias:?}"); + let first_short = bias.iter().position(|&b| b == -1.0).expect("an up-move must fade SHORT (-1)"); + let first_long = bias.iter().position(|&b| b == 1.0).expect("a down-move must fade LONG (+1)"); + assert!(first_short < first_long, "short (up-fade) must precede long (down-fade): {bias:?}"); + assert_eq!(*bias.last().unwrap(), 1.0, "the down-fade long must hold to the end: {bias:?}"); + } + + #[test] + /// Property: the pip a run resolves and stamps into its manifest must be the + /// pip the in-graph `SimBroker` divides by — the resolved (real, per-instrument) + /// pip has to reach the graph, not just the manifest label. The `SimBroker` + /// integrates `exposure * (price - prev_price) / pip`, so the raw price-move + /// total `total_pips * pip` is pip-invariant: the SAME signal + prices run at + /// two different pips must agree on that product. If `pip` only decorates the + /// label and the graph always divides by the synthetic default, both runs + /// yield the identical `total_pips` and the invariant breaks (and real-data + /// pips are inflated by `1 / 0.0001 = 10^4`). + fn run_blueprint_member_computes_pips_at_the_resolved_pip_not_a_hardwired_default() { + let env = Env::std(); + // A price series that drives the meanrev signal into a definite non-flat + // exposure (short an up-move, long a down-move), so broker equity != 0 — + // the same idiom as the fade test above. + let closes = [ + 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 130.0, 130.0, 130.0, 70.0, 70.0, 70.0, 70.0, + ]; + let make_source = || -> Vec> { + let prices: Vec<(Timestamp, Scalar)> = closes + .iter() + .enumerate() + .map(|(i, &c)| (Timestamp(i as i64), Scalar::f64(c))) + .collect(); + vec![Box::new(VecSource::new(prices))] + }; + let window = (Timestamp(0), Timestamp(closes.len() as i64 - 1)); + let stop = StopRule::Vol { length: 3, k: 2.0 }; + let space: Vec = vec![]; + // Two per-instrument pips, an order of magnitude apart and both distinct + // from the synthetic 0.0001 — i.e. the real-data condition. + let pip_a = 1.0_f64; + let pip_b = 0.1_f64; + let signal_a = r_meanrev_signal(Some(3), Some(0.0)); + let binding = crate::binding::resolve_binding(signal_a.name(), signal_a.input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let report_a = run_blueprint_member( + signal_a, &[], &space, make_source(), window, 0, pip_a, "topo", &env, stop, &binding, &[], "GER40", + ); + let report_b = run_blueprint_member( + r_meanrev_signal(Some(3), Some(0.0)), &[], &space, make_source(), window, 0, pip_b, "topo", &env, stop, &binding, &[], "GER40", + ); + // Guard: the run must have actually traded, else the invariant is vacuous. + assert!( + report_a.metrics.total_pips.abs() > 0.0, + "test needs a non-flat run to be meaningful: {:?}", + report_a.metrics + ); + // The pip-invariant raw price-move total must agree across the two pips. + let raw_a = report_a.metrics.total_pips * pip_a; + let raw_b = report_b.metrics.total_pips * pip_b; + assert!( + (raw_a - raw_b).abs() < 1e-9, + "total_pips must be computed at the resolved pip: pip={pip_a} gave total_pips={} \ + (raw price-move {raw_a}), pip={pip_b} gave total_pips={} (raw price-move {raw_b}) \ + — the resolved pip never reached the in-graph SimBroker", + report_a.metrics.total_pips, + report_b.metrics.total_pips, + ); + } + + #[test] + fn sim_optimal_manifest_renders_per_instrument_pip() { + let m = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 1.0); + assert_eq!(m.broker, "sim-optimal(pip_size=1)"); + let m2 = sim_optimal_manifest(vec![], (Timestamp(1), Timestamp(2)), 0, 0.0001); + assert_eq!(m2.broker, "sim-optimal(pip_size=0.0001)"); + } + + /// The Donchian channel length for the canonical closed r_breakout example. + /// Single source for the emitter and the three proof tests that must all + /// agree with the baked `examples/r_breakout.json` fixture — kept honest + /// by one constant instead of a hand-synced `Some(3)` literal recurring + /// across those call sites. `examples/r_breakout.json` is CLI-owned + /// (`crates/aura-cli/examples/`); these tests read it cross-crate (#295). + const R_BREAKOUT_CHANNEL: i64 = 3; + + /// The EWMA window for the canonical closed r_meanrev example (ganged + /// mean/var Ema length; the open form gangs them structurally via the + /// single `window` knob, #61). Single source for the emitter and the + /// proof tests that must all agree with the baked `examples/r_meanrev.json`. + const R_MEANREV_WINDOW: i64 = 3; + + /// The Bollinger band half-width (in sigma) for the canonical closed + /// r_meanrev example. Single source alongside [`R_MEANREV_WINDOW`]. + const R_MEANREV_BAND_K: f64 = 2.0; + + /// The channel length for the canonical closed r_channel example. Single + /// source for the emitter and the proof tests that must agree with the + /// baked `examples/r_channel.json` (mirrors [`R_BREAKOUT_CHANNEL`]). + const R_CHANNEL_LENGTH: i64 = 3; + + /// Loads the shipped closed r-sma example (fast=2, slow=4 bound) through + /// the public `blueprint_from_json` path. `examples/r_sma.json` is + /// CLI-owned (`crates/aura-cli/examples/`, also read directly by `aura + /// graph`'s no-argument default); this reads the identical file + /// cross-crate (#295). aura-cli's own `mod tests` in `main.rs` keeps its + /// own verbatim copy of this loader for the tests that stayed there. + fn load_closed_r_sma() -> Composite { + blueprint_from_json(include_str!("../../aura-cli/examples/r_sma.json"), &|t| std_vocabulary(t)) + .expect("loads") + } + + /// Regenerates the shipped r_breakout examples from the carved signal. Run by hand: + /// `cargo test -p aura-runner emit_r_breakout_examples -- --ignored`. The examples are + /// green-by-construction (serialised from the builder, never hand-authored). + #[test] + #[ignore = "regenerates crates/aura-cli/examples/r_breakout{,_open}.json; run by hand"] + fn emit_r_breakout_examples() { + // CLI-owned target paths (#295: this builder lives in aura-runner, the + // fixture it regenerates lives in aura-cli) — anchored on + // `CARGO_MANIFEST_DIR` (this crate's own root), not process cwd, the + // same pattern `project.rs`'s tmp-dir tests already use. + let examples_dir = concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples"); + std::fs::create_dir_all(examples_dir).expect("examples dir"); + std::fs::write( + concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples/r_breakout.json"), + blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed r_breakout"), + ) + .expect("write examples/r_breakout.json"); + std::fs::write( + concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/tests/fixtures/r_breakout_open.json"), + blueprint_to_json(&r_breakout_signal(None)).expect("serialize open r_breakout"), + ) + .expect("write tests/fixtures/r_breakout_open.json"); + } + + /// The shipped examples are a faithful serialisation of the carved signal (#159 cut 2): + /// re-serialising the carve equals the checked-in bytes. Green-by-construction with the + /// emitter; a drift between builder and file breaks it. Survives the fused builder's + /// retirement (it references `r_breakout_signal`, the carve, not the retired builder). + #[test] + fn shipped_r_breakout_examples_serialize_the_carved_signal() { + assert_eq!( + blueprint_to_json(&r_breakout_signal(Some(R_BREAKOUT_CHANNEL))).expect("serialize closed"), + include_str!("../../aura-cli/examples/r_breakout.json"), + ); + assert_eq!( + blueprint_to_json(&r_breakout_signal(None)).expect("serialize open"), + include_str!("../../aura-cli/tests/fixtures/r_breakout_open.json"), + ); + } + + /// The shipped closed example, reloaded through the data plane and run, grades + /// bit-identically to running the carved signal directly (#159 cut 2). This is + /// r_breakout's durable equivalence anchor after the fused builder retires: it pins + /// that `examples/r_breakout.json` still produces the carve's grade, with no + /// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice. + #[test] + fn r_breakout_example_loaded_runs_identically_to_the_carved_signal() { + let env = Env::std(); + let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_breakout.json"), &|t| std_vocabulary(t)) + .expect("shipped r_breakout example loads"); + let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env); + let via_carve = run_signal_r(r_breakout_signal(Some(R_BREAKOUT_CHANNEL)), &[], RunData::Synthetic, 0, &env); + assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); + } + + /// Regenerates the shipped r_meanrev examples from the carved signal. Run by hand: + /// `cargo test -p aura-runner emit_r_meanrev_examples -- --ignored`. The examples are + /// green-by-construction (serialised from the builder, never hand-authored). + #[test] + #[ignore = "regenerates crates/aura-cli/examples/r_meanrev{,_open}.json; run by hand"] + fn emit_r_meanrev_examples() { + std::fs::create_dir_all(concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples")) + .expect("examples dir"); + std::fs::write( + concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples/r_meanrev.json"), + blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K))) + .expect("serialize closed r_meanrev"), + ) + .expect("write examples/r_meanrev.json"); + std::fs::write( + concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/tests/fixtures/r_meanrev_open.json"), + blueprint_to_json(&r_meanrev_signal(None, None)).expect("serialize open r_meanrev"), + ) + .expect("write tests/fixtures/r_meanrev_open.json"); + } + + /// Regenerates the shipped r_channel examples from the carved signal. Run by hand: + /// `cargo test -p aura-runner emit_r_channel_examples -- --ignored`. The examples are + /// green-by-construction (serialised from the builder, never hand-authored). + #[test] + #[ignore = "regenerates crates/aura-cli/examples/r_channel{,_open}.json; run by hand"] + fn emit_r_channel_examples() { + std::fs::create_dir_all(concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples")) + .expect("examples dir"); + std::fs::write( + concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/examples/r_channel.json"), + blueprint_to_json(&r_channel_signal(Some(R_CHANNEL_LENGTH))).expect("serialize closed r_channel"), + ) + .expect("write examples/r_channel.json"); + std::fs::write( + concat!(env!("CARGO_MANIFEST_DIR"), "/../aura-cli/tests/fixtures/r_channel_open.json"), + blueprint_to_json(&r_channel_signal(None)).expect("serialize open r_channel"), + ) + .expect("write tests/fixtures/r_channel_open.json"); + } + + /// The shipped examples are a faithful serialisation of the carved signal + /// (the r_breakout pin pattern): re-serialising the carve equals the + /// checked-in bytes; a drift between builder and file breaks it. + #[test] + fn shipped_r_channel_examples_serialize_the_carved_signal() { + assert_eq!( + blueprint_to_json(&r_channel_signal(Some(R_CHANNEL_LENGTH))).expect("serialize closed"), + include_str!("../../aura-cli/examples/r_channel.json"), + ); + assert_eq!( + blueprint_to_json(&r_channel_signal(None)).expect("serialize open"), + include_str!("../../aura-cli/tests/fixtures/r_channel_open.json"), + ); + } + + /// A 10-bar high/low/close fixture (canonical column order: high, low, + /// close) that warms the hl_channel graph (Delay(1) + Rolling(3)) and + /// breaks out upward then downward. + fn hlc_sources() -> Vec> { + let high = [10.5_f64, 10.6, 10.4, 10.8, 11.5, 12.0, 12.2, 11.0, 10.2, 9.8]; + let low = [9.5_f64, 9.7, 9.6, 9.9, 10.8, 11.4, 11.6, 10.1, 9.4, 9.0]; + let close = [10.0_f64, 10.2, 10.0, 10.5, 11.3, 11.8, 12.0, 10.4, 9.6, 9.2]; + [&high[..], &low[..], &close[..]] + .into_iter() + .map(|col| { + let series: Vec<(Timestamp, Scalar)> = col + .iter() + .enumerate() + .map(|(i, &v)| (Timestamp(i as i64 + 1), Scalar::f64(v))) + .collect(); + Box::new(VecSource::new(series)) as Box + }) + .collect() + } + + /// The shipped closed example, reloaded through the data plane and run + /// through the MULTI-COLUMN wrap, emits bit-identical bias rows to the + /// carved signal — r_channel's durable equivalence anchor (the r_breakout + /// idiom, over three VecSource columns instead of RunData::Synthetic, + /// which honestly refuses multi-column signals). `wrap_r`'s ex tap reads + /// `sig.output("bias")` directly, so `rx_ex` carries the raw signal bias. + #[test] + fn r_channel_example_loaded_runs_identically_to_the_carved_signal() { + let run = |signal: Composite| -> Vec<(Timestamp, Vec)> { + let binding = + crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .expect("high/low/close roles resolve"); + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, _rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let flat = wrap_r( + signal, + tx_eq, + tx_ex, + tx_r, + tx_req, + StopRule::Vol { length: 3, k: 2.0 }, + false, + SYNTHETIC_PIP_SIZE, + &binding, + None, + ) + .compile_with_params(&[]) + .expect("closed hl_channel wraps to a valid harness"); + let mut h = Harness::bootstrap(flat).expect("hl_channel harness bootstraps"); + h.run(hlc_sources()); + rx_ex.try_iter().collect() + }; + let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_channel.json"), &|t| std_vocabulary(t)) + .expect("shipped r_channel example loads"); + let via_file = run(loaded); + let via_carve = run(r_channel_signal(Some(R_CHANNEL_LENGTH))); + assert!(!via_carve.is_empty(), "the channel must emit bias rows over the fixture"); + assert!( + via_carve.iter().any(|(_, row)| row.iter().any(|s| s.as_f64() != 0.0)), + "the fixture must drive a non-zero bias (an all-zero run would make \ + the equivalence vacuous)" + ); + assert_eq!(via_file, via_carve, "loaded example emits the carve's bias rows"); + } + + /// The shipped examples are a faithful serialisation of the carved signal (#159 cut 3): + /// re-serialising the carve equals the checked-in bytes. Green-by-construction with the + /// emitter; a drift between builder and file breaks it. + #[test] + fn shipped_r_meanrev_examples_serialize_the_carved_signal() { + assert_eq!( + blueprint_to_json(&r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K))).expect("closed"), + include_str!("../../aura-cli/examples/r_meanrev.json"), + ); + assert_eq!( + blueprint_to_json(&r_meanrev_signal(None, None)).expect("open"), + include_str!("../../aura-cli/tests/fixtures/r_meanrev_open.json"), + ); + } + + /// The shipped closed example, reloaded through the data plane and run, grades + /// bit-identically to running the carved signal directly (#159 cut 3). This is + /// r_meanrev's durable equivalence anchor after the fused builder retires: it pins + /// that `examples/r_meanrev.json` still produces the carve's grade, with no + /// hardcoded golden. `Composite` is `!Clone`, so the example is loaded twice. + #[test] + fn r_meanrev_example_loaded_runs_identically_to_the_carved_signal() { + let env = Env::std(); + let loaded = blueprint_from_json(include_str!("../../aura-cli/examples/r_meanrev.json"), &|t| std_vocabulary(t)) + .expect("shipped r_meanrev example loads"); + let via_file = run_signal_r(loaded, &[], RunData::Synthetic, 0, &env); + let via_carve = run_signal_r( + r_meanrev_signal(Some(R_MEANREV_WINDOW), Some(R_MEANREV_BAND_K)), + &[], + RunData::Synthetic, + 0, + &env, + ); + assert_eq!(via_file.metrics, via_carve.metrics, "loaded example grades identically to the carve"); + } + + /// #234: the optional cost leg records an aggregate cost stream positionally + /// 1:1 with the R record (`summarize_r`'s positional-join contract) plus a + /// per-cycle net_r_equity curve, in non-reduce trace mode. The cost-less + /// side (`cost: None` == today's graph, byte-identical) is pinned by the + /// suite's untouched equivalence anchors. + #[test] + fn wrap_r_cost_leg_records_cost_rows_co_temporal_with_the_r_record() { + let signal = load_closed_r_sma(); + let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, _rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let (tx_cost, rx_cost) = mpsc::channel(); + let (tx_net, rx_net) = mpsc::channel(); + let leg = CostLeg { + nodes: vec![ConstantCost::builder().bind("cost_per_trade", Scalar::f64(0.0005))], + tx_cost, + tx_net, + }; + let flat = wrap_r( + signal, + tx_eq, + tx_ex, + tx_r, + tx_req, + StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, + false, + SYNTHETIC_PIP_SIZE, + &binding, + Some(leg), + ) + .compile_with_params(&[]) + .expect("costed wrap builds"); + let mut h = Harness::bootstrap(flat).expect("costed harness bootstraps"); + let src: Vec> = vec![Box::new(VecSource::new(r_sma_prices()))]; + h.run(src); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); + let net_rows: Vec<(Timestamp, Vec)> = rx_net.try_iter().collect(); + assert!(!r_rows.is_empty(), "the fixture must warm the executor"); + assert_eq!( + cost_rows.len(), + r_rows.len(), + "the cost stream is positionally 1:1 with the R record (co-temporality)" + ); + assert_eq!(net_rows.len(), cost_rows.len(), "the net curve emits per cost row"); + assert!( + cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0), + "at least one close must charge a non-zero cost (non-vacuous)" + ); + } + + /// #234: the reduce-mode cost branch (a `GatedRecorder` with the appended + /// `closed_this_cycle` gate column) stays co-temporal with the gated R + /// record too — the same 1:1 join contract as the trace-mode leg above, + /// but over the member/sweep run path `summarize_r` actually consumes. + #[test] + fn wrap_r_cost_leg_in_reduce_mode_stays_co_temporal_with_the_gated_r_record() { + let signal = load_closed_r_sma(); + let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, _rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let (tx_cost, rx_cost) = mpsc::channel(); + let (tx_net, _rx_net) = mpsc::channel(); + let leg = CostLeg { + nodes: vec![ConstantCost::builder().bind("cost_per_trade", Scalar::f64(0.0005))], + tx_cost, + tx_net, + }; + let flat = wrap_r( + signal, + tx_eq, + tx_ex, + tx_r, + tx_req, + StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, + true, + SYNTHETIC_PIP_SIZE, + &binding, + Some(leg), + ) + .compile_with_params(&[]) + .expect("costed reduce-mode wrap builds"); + let mut h = Harness::bootstrap(flat).expect("costed reduce-mode harness bootstraps"); + let src: Vec> = vec![Box::new(VecSource::new(r_sma_prices()))]; + h.run(src); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); + assert!(!r_rows.is_empty(), "the fixture must warm and close at least one trade"); + assert_eq!( + cost_rows.len(), + r_rows.len(), + "the reduce-mode gated cost stream is positionally 1:1 with the gated R record" + ); + assert!( + cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0), + "at least one gated close must charge a non-zero cost (non-vacuous)" + ); + } + + /// #234: a cost component that declares an extra `volatility` port (the + /// `VolSlippageCost` shape) is wired to a live realized-range proxy built + /// from the close role (`RollingMax`/`RollingMin`/`Sub` over + /// `SLIP_VOL_LENGTH`), discovered purely from the component's own schema + /// past the geometry prefix (`vol_slots`) — never a disconnected or + /// hardwired-zero input. If that wiring regresses, `ctx.f64_in(GEOMETRY_WIDTH)` + /// inside the component stays permanently empty and every charge is exactly + /// 0.0 (`vol_not_yet_warm_emits_zero_cost_co_temporally`'s withhold case), so a + /// non-zero charge over a genuinely moving price series is a direct witness + /// that the proxy reached the component. The two tests above use + /// `ConstantCost`, which never touches this wiring at all. + #[test] + fn wrap_r_cost_leg_wires_the_vol_slippage_proxy_from_the_close_role() { + let signal = load_closed_r_sma(); + let binding = crate::binding::resolve_binding(signal.name(), signal.input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, _rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let (tx_cost, rx_cost) = mpsc::channel(); + let (tx_net, _rx_net) = mpsc::channel(); + let leg = CostLeg { + nodes: vec![VolSlippageCost::builder().bind("slip_vol_mult", Scalar::f64(0.5))], + tx_cost, + tx_net, + }; + let flat = wrap_r( + signal, + tx_eq, + tx_ex, + tx_r, + tx_req, + StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, + false, + SYNTHETIC_PIP_SIZE, + &binding, + Some(leg), + ) + .compile_with_params(&[]) + .expect("vol-slippage-costed wrap builds"); + let mut h = Harness::bootstrap(flat).expect("vol-slippage-costed harness bootstraps"); + let src: Vec> = vec![Box::new(VecSource::new(r_sma_prices()))]; + h.run(src); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); + assert!(!r_rows.is_empty(), "the fixture must warm and close at least one trade"); + assert_eq!( + cost_rows.len(), + r_rows.len(), + "the vol-slippage cost stream stays positionally 1:1 with the R record" + ); + assert!( + cost_rows.iter().any(|(_, row)| row[0].as_f64() != 0.0), + "the volatility proxy must actually feed the component — a disconnected \ + proxy leaves cost_in_r at exactly 0.0 on every close: {cost_rows:?}" + ); + } + + /// #234: a member run under a constant cost model nets the hand-computed + /// per-trade cost — `net_expectancy_r ≈ expectancy_r − mean(cost_per_trade + /// / |entry − stop|)` over summarize_r's ledger (closed rows + the + /// window-end open row; the CostRunner R-normalization contract), while + /// every gross field stays byte-identical to the cost-less run; and the + /// manifest stamps the component for reproduce to re-derive. + #[test] + fn run_blueprint_member_joins_a_constant_cost_model_into_net_metrics() { + let env = Env::std(); + let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); + let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); + let space = blueprint_axis_probe(&doc, &env).param_space(); + let binding = crate::binding::resolve_binding("costnet", reload().input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let stop = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }; + // The CLI shell's `DataSource::Synthetic` doesn't cross into aura-runner + // (it's dispatch-layer, #295 out of scope here): this module's own + // `RunData::Synthetic` + `resolve_run_data` stands in, sourcing + // `r_sma_prices()` instead of the CLI's `showcase_prices()` — a + // different but equally non-flat synthetic fixture. Every assertion + // below is computed from the actual run (never a fixture-specific + // literal), so the substitution preserves the property under test. + let sources = || resolve_run_data(&RunData::Synthetic, &env, &binding).0; + let (_, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding); + const CPT: f64 = 0.0005; // price units; the synthetic stream trades near 1.0 + let run = |cost: &[aura_research::CostSpec]| { + run_blueprint_member( + reload(), + &[], + &space, + sources(), + window, + 0, + pip, + "topo", + &env, + stop, + &binding, + cost, + "GER40", + ) + }; + let gross = run(&[]); + let netted = run(&[aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(CPT), + }]); + + // The trade geometry, independently: a non-reduce cost-less wrap over + // the same realization, draining the dense R record whose ledger + // summarize_r reads. + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, _rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, _rx_req) = mpsc::channel(); + let flat = wrap_r(reload(), tx_eq, tx_ex, tx_r, tx_req, stop, false, pip, &binding, None) + .compile_with_params(&[]) + .expect("wraps"); + let mut h = Harness::bootstrap(flat).expect("bootstraps"); + h.run(sources()); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + + // summarize_r's ledger: closed rows, plus the final row if open — each + // charged cost_per_trade / |entry − stop| (0 when the latched distance + // is 0). PM record cols: closed=0, entry_price=6, stop_price=7, + // open=11 (the aura-analysis r_col contract). + let mut costs: Vec = Vec::new(); + for (i, (_, row)) in r_rows.iter().enumerate() { + let is_last = i == r_rows.len() - 1; + if row[0].as_bool() || (is_last && row[11].as_bool()) { + let latched = (row[6].as_f64() - row[7].as_f64()).abs(); + costs.push(if latched > 0.0 { CPT / latched } else { 0.0 }); + } + } + let g = gross.metrics.r.as_ref().expect("gross member carries R metrics"); + let n = netted.metrics.r.as_ref().expect("netted member carries R metrics"); + assert_eq!( + costs.len() as u64, g.n_trades, + "the independent ledger walk must see the member's trades" + ); + assert!(costs.iter().any(|&c| c > 0.0), "at least one trade must charge (non-vacuous)"); + let mean_cost = costs.iter().sum::() / costs.len() as f64; + + assert_eq!(gross.metrics.total_pips, netted.metrics.total_pips, "gross pips unchanged"); + assert_eq!(g.expectancy_r, n.expectancy_r, "gross R stays byte-identical under cost"); + assert_eq!(g.n_trades, n.n_trades); + assert_eq!(g.net_expectancy_r, g.expectancy_r, "cost-less: net == gross"); + assert!( + (n.net_expectancy_r - (g.expectancy_r - mean_cost)).abs() < 1e-12, + "net = gross − mean per-trade cost: net {} gross {} mean_cost {mean_cost}", + n.net_expectancy_r, + g.expectancy_r + ); + // The manifest stamps the component (the reproduce re-derivation carrier)... + assert!( + netted + .manifest + .params + .iter() + .any(|(k, v)| k == "cost[0].cost_per_trade" && *v == Scalar::f64(CPT)), + "member manifest stamps the cost component: {:?}", + netted.manifest.params + ); + // ...and a cost-less member stamps nothing (content/label stability). + assert!( + !gross.manifest.params.iter().any(|(k, _)| k.starts_with("cost[")), + "a cost-less member stamps no cost params" + ); + } + + /// #262 write-side: `run_blueprint_member` given `StopRule::VolTf` actually + /// bootstraps and runs the member (the `risk_executor`/`VolTfStop` arm, + /// end-to-end over a real synthetic realization, not a hand-built value) + /// and stamps all three knobs into the manifest under the keys + /// `stop_rule_from_params`'s round-trip above reads back — the write half + /// of the manifest round-trip pair. + #[test] + fn run_blueprint_member_stamps_the_vol_tf_stop_knobs() { + let env = Env::std(); + let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes"); + let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads"); + let space = blueprint_axis_probe(&doc, &env).param_space(); + let binding = crate::binding::resolve_binding("voltfstamp", reload().input_roles(), &BTreeMap::new()) + .expect("the price role resolves"); + let stop = StopRule::VolTf { period_minutes: 60, length: 3, k: 2.0 }; + // See `run_blueprint_member_joins_a_constant_cost_model_into_net_metrics`'s + // note: `RunData::Synthetic` + `resolve_run_data` stands in for the + // CLI-only `DataSource::Synthetic` this test used before the #295 move. + let (sources, window, pip) = resolve_run_data(&RunData::Synthetic, &env, &binding); + let run = run_blueprint_member( + reload(), + &[], + &space, + sources, + window, + 0, + pip, + "topo", + &env, + stop, + &binding, + &[], + "GER40", + ); + assert!( + run.manifest.params.contains(&("stop_period_minutes".to_string(), Scalar::i64(60))), + "manifest must stamp stop_period_minutes: {:?}", + run.manifest.params + ); + assert!( + run.manifest.params.contains(&("stop_length".to_string(), Scalar::i64(3))), + "manifest must stamp stop_length: {:?}", + run.manifest.params + ); + assert!( + run.manifest.params.contains(&("stop_k".to_string(), Scalar::f64(2.0))), + "manifest must stamp stop_k: {:?}", + run.manifest.params + ); + } + +} diff --git a/crates/aura-cli/src/project.rs b/crates/aura-runner/src/project.rs similarity index 100% rename from crates/aura-cli/src/project.rs rename to crates/aura-runner/src/project.rs diff --git a/crates/aura-runner/src/reproduce.rs b/crates/aura-runner/src/reproduce.rs new file mode 100644 index 0000000..5ba134c --- /dev/null +++ b/crates/aura-runner/src/reproduce.rs @@ -0,0 +1,234 @@ +//! Bit-identical reproduction (`aura reproduce`) — #295. +//! +//! A library function here reports a refusal as a returned +//! [`crate::RunnerError`] rather than ending the process. The shell dispatch +//! arm (`aura-cli`'s `dispatch_reproduce`) is the single place that prints +//! the error's message to stderr and calls `std::process::exit` on its code, +//! keeping the observable stderr/exit bytes unchanged (C18). + +use std::collections::BTreeMap; +use std::sync::mpsc; + +use aura_engine::{blueprint_from_json, window_of}; +use aura_registry::{group_families, Family, FamilyKind, Registry}; +use aura_backtest::point_from_params; + +use crate::family::{synthetic_walk_sources, DataChoice, DataSource}; +use crate::member::{reopen_all, run_blueprint_member, wrapped_bound_overrides_of, SYNTHETIC_PIP_SIZE}; +use crate::project::Env; +use crate::runner::render_value; +use crate::translate::{cost_specs_from_params, stop_rule_from_params}; +use crate::RunnerError; + +/// The outcome of reproducing one persisted family: per member, whether its re-run +/// metrics are bit-identical to the stored metrics (C1). +pub struct ReproduceReport { + pub outcomes: Vec<(String, bool)>, +} + +/// Look up a persisted family by id, or refuse (exit code 1: unknown id / registry +/// load failure) — the single place `reproduce_family` and `reproduce_family_in` +/// resolve a family, so the two exit-1 error phrasings can't drift out of sync +/// between the call sites. +pub fn load_family(reg: &Registry, id: &str) -> Result { + let members = reg.load_family_members().map_err(|e| RunnerError { + exit_code: 1, + message: format!("{e}"), + })?; + group_families(members) + .into_iter() + .find(|f| f.id == id) + // reproduce is an action, not a lookup: an unknown id is a hard error (distinct + // from `runs family `'s treat-as-empty exit 0). + .ok_or_else(|| RunnerError { exit_code: 1, message: format!("no such family '{id}'") }) +} + +/// Re-derive every member of a persisted sweep family from the content-addressed store +/// and compare to the stored result, against an explicit registry (testable seam). +pub fn reproduce_family_in( + reg: &Registry, + id: &str, + data: &DataSource, + env: &Env, +) -> Result { + let family = load_family(reg, id)?; + let pip = data.pip_size(); + let mut outcomes = Vec::new(); + for member in &family.members { + let stored = &member.report; + let hash = stored.manifest.topology_hash.clone().ok_or_else(|| RunnerError { + exit_code: 1, + message: "family member has no topology_hash; not a generated run".to_string(), + })?; + let doc = reg + .get_blueprint(&hash) + .map_err(|e| RunnerError { exit_code: 1, message: format!("{e}") })? + .ok_or_else(|| RunnerError { + exit_code: 1, + message: format!("blueprint {hash} missing from store"), + })?; + // The #246 override set (silent variant): re-derived from the RECORDED + // manifest param names, against a raw probe space + raw strategy load — a + // name matching neither space is simply not an override (falls through to + // `point_from_params`'s existing missing-knob refusal below), unlike the + // sweep boundary's `override_paths`, which errors on it. + let recorded: Vec = + stored.manifest.params.iter().map(|(n, _)| n.clone()).collect(); + let raw_space = crate::member::blueprint_axis_probe(&doc, env).param_space(); + let raw_signal = blueprint_from_json(&doc, &|t| env.resolve(t)).map_err(|e| RunnerError { + exit_code: 1, + message: format!("stored blueprint {hash} does not parse: {e:?}"), + })?; + let overrides = wrapped_bound_overrides_of(&recorded, &raw_space, &raw_signal); + // Reload the stored blueprint per use: a Composite is !Clone, and both the + // param-space probe (below) and the re-run each consume one. The doc was + // canonical-serialized at store time, so every reload is infallible. Every + // reload re-opens the same override set derived above, so the recorded + // param names resolve against the space they were minted under (#246). + let reload = || -> Result<_, RunnerError> { + Ok(reopen_all( + blueprint_from_json(&doc, &|t| env.resolve(t)).map_err(|e| RunnerError { + exit_code: 1, + message: format!("stored blueprint {hash} does not parse: {e:?}"), + })?, + &overrides, + )) + }; + // The param_space of the WRAPPED signal — its knobs carry the `r_sma` + // wrapper's `sma_signal.` node-path prefix, exactly the names the manifest + // recorded at write time. Mirrors `blueprint_sweep_family`'s probe so the + // reproduce-side space name-matches the stored params (raw `signal.param_space()` + // would drop the prefix and `point_from_params` could not find the knobs). + let (tx_eq, _) = mpsc::channel(); + let (tx_ex, _) = mpsc::channel(); + let (tx_r, _) = mpsc::channel(); + let (tx_req, _) = mpsc::channel(); + let stop = stop_rule_from_params(&stored.manifest.params); + // The member's cost model, re-derived from its manifest (the #233 + // stop pattern): a costed family re-runs under the exact components it + // was minted with, so `net_expectancy_r` reproduces bit-identically. + let cost = cost_specs_from_params(&stored.manifest.params) + .map_err(|m| RunnerError { exit_code: 1, message: m })?; + // The member's binding, re-derived from the stored blueprint's own + // input roles (name defaults — family manifests carry no overrides). + let binding = crate::binding::resolve_binding(&hash, reload()?.input_roles(), &BTreeMap::new()) + .map_err(|m| RunnerError { exit_code: 1, message: m })?; + if matches!(data, DataSource::Synthetic) && !binding.close_only() { + return Err(RunnerError { + exit_code: 1, + message: crate::binding::synthetic_refusal(&hash, &binding), + }); + } + let space = crate::member::wrap_r(reload()?, tx_eq, tx_ex, tx_r, tx_req, stop, true, SYNTHETIC_PIP_SIZE, &binding, None).param_space(); + let point = point_from_params(&space, &stored.manifest.params) + .map_err(|m| RunnerError { exit_code: 1, message: m })?; + // A MonteCarlo member carries no tuning params (the params-join is empty), so its + // reproduce line would print a BLANK member label; the seed IS its realization + // identity, so surface `seed=` instead. Sweep / walk-forward members echo their + // tuning params (the params-join), unchanged. + let label = match family.kind { + FamilyKind::MonteCarlo => format!("seed={}", stored.manifest.seed), + _ => stored + .manifest + .params + .iter() + .map(|(n, v)| format!("{n}={}", render_value(v))) + .collect::>() + .join(", "), + }; + // Realization-aware: a MonteCarlo member ran over a seed-driven synthetic walk, + // not the showcase — reconstruct it from manifest.seed so the re-run matches (C1). + // The seed is manifest-carried and identical across realizations; only the sources + // and window vary by kind. + let seed = stored.manifest.seed; + let (sources, member_window) = match family.kind { + FamilyKind::MonteCarlo => { + let s = synthetic_walk_sources(seed); + let w = window_of(&s).expect("non-empty synthetic walk"); + (s, w) + } + FamilyKind::WalkForward => { + // each member is one OOS window: rebuild its windowed slice from the + // stored window bounds; the winner params come from the shared + // manifest->cells recovery below (as Sweep members do). + let (from, to) = stored.manifest.window; + let s = data.windowed_sources(from, to, env, &binding.columns()); + let w = window_of(&s).expect("non-empty OOS window"); + (s, w) + } + // Sweep / plain-run members: a Real source reopens the exact per-member + // window the manifest recorded, the same `windowed_sources` loader + // WalkForward uses above (and `DefaultMemberRunner` uses at mint time, #229) + // — never a fresh full-archive probe, which need not match the window the + // family was minted over. Synthetic keeps the pre-#229 full-window path + // (byte-identical: `data.full_window` is a pure, no-IO computation). + _ => match data { + DataSource::Real { .. } => { + let (from, to) = stored.manifest.window; + (data.windowed_sources(from, to, env, &binding.columns()), (from, to)) + } + DataSource::Synthetic => (data.run_sources(env, &binding.columns()), data.full_window(env)), + }, + }; + let rerun = run_blueprint_member( + reload()?, + &point, + &space, + sources, + member_window, + seed, + pip, + &hash, + env, + stop, + &binding, + &cost, + // The re-run specs come from the stamp and are scalar by + // construction (`cost_specs_from_params`), so the instrument is + // inert; the fallback is never resolved against a map. + stored.manifest.instrument.as_deref().unwrap_or(""), + ); + outcomes.push((label, rerun.metrics == stored.metrics)); + } + Ok(ReproduceReport { outcomes }) +} + +/// `aura reproduce `: re-derive a persisted sweep family from the +/// content-addressed blueprint store and verify each member reproduces bit-identically +/// (C18 "re-derives full results on demand"). The data source is reconstructed from +/// the family's own manifest (#229) — a hardcoded `DataSource::Synthetic` here would +/// re-derive a real-data family over the wrong stream; see `reproduce_family_in`'s +/// per-member window loader for how the reconstructed source is actually used. +/// +/// Prints its own per-member + summary lines to stdout (unchanged from the +/// pre-#295 shell function — this is the report's normal output, not an error +/// path); only the final "not every member reproduced" outcome is a refusal, +/// returned with an EMPTY message (there is no accompanying stderr line today, +/// so the shell dispatch arm must not synthesize one either — see +/// `dispatch_reproduce`). +pub fn reproduce_family(id: &str, env: &Env) -> Result<(), RunnerError> { + let reg = env.registry(); + let family = load_family(®, id)?; + // Reconstruct the DataSource the family was minted over: `None` instrument + // (every synthetic-family member, pre-#229 lines) stays the built-in synthetic + // stream; a real instrument reopens the same local archive `--real` runs use, + // via the same named-data refusal (exit 1) on a missing sidecar/archive. + let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) { + None => DataSource::Synthetic, + Some(symbol) => { + DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env) + } + }; + let rep = reproduce_family_in(®, id, &data, env)?; + let total = rep.outcomes.len(); + let ok = rep.outcomes.iter().filter(|(_, b)| *b).count(); + for (label, identical) in &rep.outcomes { + let verdict = if *identical { "bit-identical" } else { "DIVERGED" }; + println!("{id} member {label} reproduced: {verdict}"); + } + println!("reproduced {ok}/{total} members bit-identically"); + if ok != total { + return Err(RunnerError { exit_code: 1, message: String::new() }); + } + Ok(()) +} diff --git a/crates/aura-runner/src/runner.rs b/crates/aura-runner/src/runner.rs new file mode 100644 index 0000000..c791c15 --- /dev/null +++ b/crates/aura-runner/src/runner.rs @@ -0,0 +1,756 @@ +//! [`DefaultMemberRunner`]: the shipped `aura_campaign::MemberRunner` +//! implementation over the loaded-blueprint machinery, plus the disk-layout +//! writers that persist a campaign run's traces (#295). + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::{mpsc, Arc}; + +use aura_backtest::{point_from_params, summarize, summarize_r, RunReport}; +use aura_campaign::{ + CampaignOutcome, CellOutcome, CellSpec, MemberFault, MemberRunner, +}; +use aura_core::{Scalar, ScalarKind, Timestamp}; +use aura_engine::{blueprint_from_json, f64_field, ColumnarTrace}; +use aura_ingest::{instrument_geometry, open_columns_window, unix_ms_to_epoch_ns}; +use aura_registry::WriteKind; +use aura_research::{CampaignDoc, CostSpec}; + +use crate::axes::{bind_axes, raw_bound_overrides_of}; +use crate::coverage::interior_gap_months; +use crate::member::{ + blueprint_axis_probe, blueprint_axis_probe_reopened, key_supply, reopen_all, + run_blueprint_member, wrap_r, wrapped_bound_overrides_of, CostLeg, +}; +use crate::project::Env; +use crate::translate::{cost_nodes_for, stop_rule_for_regime}; + +/// Map any byte outside the portable directory-name charset `[A-Za-z0-9._-]` +/// to `_`. The single source of filesystem-portability for an on-disk path +/// component (valid on Linux / Windows / macOS, also URL-path- and +/// cloud-sync-safe). Used by the campaign trace layout's per-cell/per-member +/// keys ([`campaign_cell_key`], [`member_trace_key`]) — a durable disk-layout +/// contract, not stdout presentation. +pub fn sanitize_component(s: &str) -> String { + s.chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' }) + .collect() +} + +/// Render a scalar value case-lessly: integers/timestamps as decimal digits, +/// bool as `true`/`false`, f64 via Rust's `Display` (decimal, shortest +/// round-trip, NO scientific notation). Case-less rendering is what keeps two +/// members of one family from ever differing only by letter case +/// (case-insensitive-FS safety). The single source for the trace layout's +/// member-key label AND the shell's `aura reproduce` rendering — the two must +/// stay byte-identical (#224). +pub fn render_value(v: &Scalar) -> String { + match v { + Scalar::I64(n) => n.to_string(), + Scalar::F64(x) => x.to_string(), + Scalar::Bool(b) => b.to_string(), + Scalar::Timestamp(t) => t.0.to_string(), + } +} + +/// The shipped harness/data binding seam for `aura_campaign::execute`: members +/// run through the shipped loaded-blueprint machinery (`wrap_r` reduce-mode +/// via `run_blueprint_member`) over windowed real M1 close bars +/// (`M1FieldSource::open_window` — the ms→ns crossing happens at exactly this +/// seam, via `unix_ms_to_epoch_ns`). All refusals are member faults for the +/// library to surface; never a process exit inside a sweep worker. +pub struct DefaultMemberRunner<'a> { + env: &'a Env, + server: Arc, + /// The campaign's `data.bindings` overrides (role -> column), threaded + /// into per-member binding resolution (#231: rebind per campaign without + /// touching the blueprint's content id). + bindings: BTreeMap, + /// The campaign's cost model (#234), threaded into every member run via + /// `cost_nodes_for` (empty = zero costs, today's graph). + cost: Vec, +} + +impl<'a> DefaultMemberRunner<'a> { + pub fn new( + env: &'a Env, + server: Arc, + bindings: BTreeMap, + cost: Vec, + ) -> Self { + Self { env, server, bindings, cost } + } + + /// The runner's own data server, shared with the trace-persistence tail + /// (`persist_campaign_traces` re-runs members over the same archive). + pub fn server(&self) -> Arc { + Arc::clone(&self.server) + } +} + +impl MemberRunner for DefaultMemberRunner<'_> { + fn run_member( + &self, + cell: &CellSpec, + params: &[(String, Scalar)], + window_ms: (i64, i64), + ) -> Result { + // The member's blueprint, loaded RAW (no re-open yet) — the SAME + // reload the probe below wraps, so `bound_param_space()` here and + // the probe's `param_space()` after `blueprint_axis_probe_reopened` + // resolve against one topology. + let signal = blueprint_from_json(&cell.blueprint_json, &|t| self.env.resolve(t)) + .expect("stored blueprint passed the referential gate; reload is infallible"); + + // The un-reopened wrapped OPEN space (#246) — the SAME probe the + // sweep verbs resolve against (single-sourced in main.rs; identical + // `false, true, None` wrap) — derives which of this cell's RAW axis + // names re-open a bound param before the real, reopened probe/reload + // are built: probe and member reload must re-open identically so + // `params` resolves against one space. + let raw_space = blueprint_axis_probe(&cell.blueprint_json, self.env).param_space(); + let param_names: Vec = params.iter().map(|(n, _)| n.clone()).collect(); + let overrides = raw_bound_overrides_of(¶m_names, &raw_space, &signal); + let space = + blueprint_axis_probe_reopened(&cell.blueprint_json, self.env, &overrides) + .param_space(); + let point = bind_axes(&space, &cell.strategy_id, params)?; + let signal = reopen_all(signal, &overrides); + + // The member's resolved input binding (campaign data.bindings + // overrides win over name defaults). A refusal is a member fault, + // never a process exit. + let binding = + crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &self.bindings) + .map_err(MemberFault::Bind)?; + + // Real windowed data — geometry BEFORE bar data (the shipped pre-data + // refusal order of `open_real_source`), both as member faults. + let geo = instrument_geometry(&self.server, &cell.instrument).ok_or_else(|| { + MemberFault::Run(format!( + "no recorded geometry for symbol '{}' at {} — refusing to run a \ + real instrument with a guessed pip", + cell.instrument, + self.env.data_path() + )) + })?; + let from = unix_ms_to_epoch_ns(window_ms.0); + let to = unix_ms_to_epoch_ns(window_ms.1); + // One source per resolved binding column, canonical order (the same + // order `wrap_r` declares the roles in — the shared plan). + let sources = match open_columns_window( + &self.server, + &cell.instrument, + Some(from), + Some(to), + &binding.columns(), + ) { + Some(s) => s, + // No archived file overlaps the window at all. + None => { + return Err(MemberFault::NoData { + instrument: cell.instrument.clone(), + window_ms, + }); + } + }; + // A window that overlaps a file but holds zero matching bars yields + // sources whose first peek is None (open_window's documented contract) + // — the same no-data condition (all columns decode the same bars, so + // probing the first source covers the set). + if aura_engine::Source::peek(sources[0].as_ref()).is_none() { + return Err(MemberFault::NoData { + instrument: cell.instrument.clone(), + window_ms, + }); + } + + // The shipped member recipe (wrap, bind, run, summarize): + // `run_blueprint_member` verbatim. Seed 0 (seed-free real-data runs), + // topology_hash = the strategy's content id, project provenance + // stamped inside the helper. + let stop = stop_rule_for_regime(cell.regime); + let mut report = run_blueprint_member( + signal, + &point, + &space, + sources, + (from, to), + 0, + geo.pip_size, + &cell.strategy_id, + self.env, + stop, + &binding, + &self.cost, + &cell.instrument, + ); + report.manifest.instrument = Some(cell.instrument.clone()); + Ok(report) + } + + /// #272: derive the cell's archive coverage from the #264 primitives — + /// `None` when the archive has no file for the instrument at all (the + /// `NoData` member fault's job, not coverage) or when the effective + /// evaluated span covers the requested window exactly with no interior + /// gap month. One call per cell (not per member): coverage is a property + /// of the cell's (instrument, window), never a swept param point. + fn window_coverage(&self, cell: &CellSpec) -> Option { + let data_path = PathBuf::from(self.env.data_path()); + let months = aura_ingest::list_m1_months(&data_path, &cell.instrument); + if months.is_empty() { + return None; // no archive at all is the NoData fault's job, not coverage + } + let (eff_from_ts, eff_to_ts) = aura_ingest::archive_extent( + &self.server, + &data_path, + &cell.instrument, + Some(cell.window_ms.0), + Some(cell.window_ms.1), + )?; + let eff_from = aura_ingest::epoch_ns_to_unix_ms(eff_from_ts); + let eff_to = aura_ingest::epoch_ns_to_unix_ms(eff_to_ts); + let gap_months = interior_gap_months(&months, cell.window_ms); + if eff_from == cell.window_ms.0 && eff_to == cell.window_ms.1 && gap_months.is_empty() { + return None; // fully covered — nothing to annotate + } + Some(aura_registry::CellCoverage { + effective_from_ms: eff_from, + effective_to_ms: eff_to, + gap_months, + }) + } +} + +/// Which drained wrap-convention channel a requested tap persists from on a +/// campaign trace re-run. The routing follows the wrap-convention channels: +/// equity <- the SimBroker equity recorder (tx_eq), exposure <- the Bias +/// recorder (tx_ex), r_equity <- the cum_realized_r + unrealized_r recorder +/// (tx_req), net_r_equity <- the cost leg's LinComb(4) net-curve recorder +/// (tx_net, #234) — produced only when the campaign document carries a cost +/// block (the caller's producibility check). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TapChannel { + Equity, + Exposure, + REquity, + Net, +} + +/// Route one requested tap of the closed vocabulary to its drained channel; +/// `None` marks a name outside the vocabulary (unreachable — `validate_campaign` +/// refuses it — and mapped defensively rather than guessed at). +pub fn tap_channel(tap: &str) -> Option { + // The emit_vocabulary debug_assert's twin: every vocabulary tap must be + // routable here — a future fifth vocabulary entry fails loudly instead of + // silently skipping. + debug_assert!( + aura_research::tap_vocabulary() + .iter() + .all(|t| matches!(*t, "equity" | "exposure" | "r_equity" | "net_r_equity")), + "tap_vocabulary drifted from the channels persist_campaign_traces routes" + ); + match tap { + "equity" => Some(TapChannel::Equity), + "exposure" => Some(TapChannel::Exposure), + "r_equity" => Some(TapChannel::REquity), + "net_r_equity" => Some(TapChannel::Net), + _ => None, + } +} + +/// The content-derived on-disk key of one campaign cell under +/// `traces//` — the `member_key` discipline (content, never a +/// runtime ordinal): strategy content-id prefix + instrument + +/// doc-positional window ordinal, sanitized to one portable path component. +/// `regime_ordinal` (#219/#212) discriminates two risk regimes over the same +/// (strategy, instrument, window) cell so their traces never collide: the +/// default regime (ordinal 0) keeps the pre-#219 key unchanged (no stored +/// trace dir is renamed by this change), a non-default regime appends +/// `-r{ordinal}`. +pub fn campaign_cell_key( + strategy: &str, + instrument: &str, + window_ordinal: usize, + regime_ordinal: usize, +) -> String { + let strategy8 = strategy.get(..8).unwrap_or(strategy); + let base = format!("{strategy8}-{instrument}-w{window_ordinal}"); + let base = + if regime_ordinal == 0 { base } else { format!("{base}-r{regime_ordinal}") }; + sanitize_component(&base) +} + +/// The per-member subdirectory key under a swept cell's trace dir (#224): +/// the member's own manifest params label — the exact `"name=value, ..."` +/// join `aura reproduce` prints — sanitized for filesystem use (a raw label +/// carries `=`/`, ` which `sanitize_component` maps to `_`). The member's +/// ordinal into the family is the fallback when the label is empty (a closed +/// blueprint / a monte-carlo seed member carries no tuning params). +pub fn member_trace_key(report: &RunReport, ordinal: usize) -> String { + let label = report + .manifest + .params + .iter() + .map(|(n, v)| format!("{n}={}", render_value(v))) + .collect::>() + .join(", "); + let key = if label.is_empty() { ordinal.to_string() } else { label }; + sanitize_component(&key) +} + +/// The per-cell member fan-out (#224): a nominee cell writes its one trace +/// directly at `/` (`None` subdir key, unchanged since 0109); a +/// no-nominee cell with a completed terminal family (the selection-free sweep +/// shape) writes every one of that family's members under its own +/// `//` subdirectory instead of silently dropping every +/// member but a (non-existent) nominee. An empty return (no nominee AND no +/// non-empty terminal family) is the caller's cue to skip the cell loudly. +/// Pure over the executed outcome's own `CellOutcome`, so it is unit-testable +/// without booting a real archive run. +pub fn cell_member_fanout(cell_out: &CellOutcome) -> Vec<(Option, &RunReport)> { + match &cell_out.nominee { + Some((_, nominee_report)) => vec![(None, nominee_report)], + None => match cell_out.families.last() { + Some(fam) if !fam.reports.is_empty() => fam + .reports + .iter() + .enumerate() + .map(|(i, r)| (Some(member_trace_key(r, i)), r)) + .collect(), + _ => Vec::new(), + }, + } +} + +/// Persist the requested `persist_taps` for every cell under +/// `traces///` (0109, #201 d5; #224): a cell with a +/// nominee (walkforward/generalize/mc — selection-bearing pipelines) writes +/// its single trace directly at `/`, unchanged since 0109. A cell +/// with NO nominee but a completed terminal family (a selection-free sweep, +/// #224's headline: "each swept member's tap series") writes EVERY member of +/// that family under its own `//` subdirectory — never +/// narrowing to one nominated member, which would silently drop the others +/// the sweep actually produced. Each written member is independently re-run +/// once in non-reduce trace mode over its own recorded `manifest.window`, +/// asserting the re-run METRICS equal the recorded member metrics (the C1 +/// drift alarm — manifest fields are fresh-context and not compared), and +/// writes the requested-AND-producible taps through the sweep verbs' +/// member-dir mechanism: a slash-joined `"{name}/{key}"` handed to +/// `TraceStore::write` (the slash-joined member-dir layout the sweep verbs +/// established). The written index manifest is the RECORDED +/// member's — the trace documents that run's provenance, not a re-derived +/// fresh-context one. Loud stderr per no-candidate cell (neither a nominee +/// nor a non-empty terminal family) and ONCE per unproducible requested tap +/// (producibility is run-configuration-level, not per-cell); one summary +/// line at the end. Every `Err` is a refusal `campaign_cmd` renders as +/// `aura: {msg}` + exit 1. +pub fn persist_campaign_traces( + trace_name: &str, + taps: &[String], + outcome: &CampaignOutcome, + campaign: &CampaignDoc, + strategies: &[(String, String)], + server: &Arc, + env: &Env, +) -> Result<(), String> { + let store = env.trace_store(); + store + .ensure_name_free(trace_name, WriteKind::Family) + .map_err(|e| e.to_string())?; + + // Requested ∩ producible, in request order. `net_r_equity` is producible + // exactly when the document carries a cost block (#234); the skip notice + // names the remedy — the tap is optional presentation, not a refusal. + // When nothing producible was requested, no member dir is written and the + // summary honestly reports 0 tap(s). + let mut routed: Vec<(&str, TapChannel)> = Vec::new(); + for tap in taps { + match tap_channel(tap) { + Some(TapChannel::Net) if campaign.cost.is_empty() => eprintln!( + "aura: tap \"{tap}\" needs a cost model; add a cost block to the campaign document; skipped" + ), + Some(ch) => routed.push((tap.as_str(), ch)), + None => eprintln!("aura: tap \"{tap}\" is not produced by this run; skipped"), + } + } + + // `outcome.cells` is index-aligned with `outcome.record.cells` by + // construction: `execute` pushes both in the same cell-loop iteration. + let mut persisted_cells = 0usize; + for (cell_rec, cell_out) in outcome.record.cells.iter().zip(&outcome.cells) { + // A nominee cell writes its one trace directly at `/` + // (`None` subdir key, unchanged since 0109); a no-nominee cell with a + // completed terminal family (the selection-free sweep shape, #224) + // writes every one of that family's members under its own + // `//` subdirectory instead of silently + // dropping every member but a (non-existent) nominee. + let members = cell_member_fanout(cell_out); + if members.is_empty() { + eprintln!( + "aura: cell {}/{}/[{}, {}]: no nominee; no traces persisted", + cell_rec.strategy, cell_rec.instrument, cell_rec.window_ms.0, cell_rec.window_ms.1 + ); + continue; + } + if routed.is_empty() { + continue; + } + + // The cell key is doc-derived: the window ordinal is the POSITION + // of the cell's window in campaign.data.windows (mirroring the + // family-name convention), never a loop counter re-derived here. + let window_ordinal = campaign + .data + .windows + .iter() + .position(|w| (w.from_ms, w.to_ms) == cell_rec.window_ms) + .ok_or_else(|| { + format!( + "cell window [{}, {}] is not one of campaign.data.windows", + cell_rec.window_ms.0, cell_rec.window_ms.1 + ) + })?; + let cell_key = campaign_cell_key( + &cell_rec.strategy, + &cell_rec.instrument, + window_ordinal, + cell_rec.regime_ordinal, + ); + + // The cell's blueprint: the run's own index-aligned resolution, by + // content id (exactly the bytes the executor's members ran). + let (_, blueprint_json) = strategies + .iter() + .find(|(id, _)| *id == cell_rec.strategy) + .ok_or_else(|| { + format!( + "cell strategy {} is not among the run's resolved strategies", + cell_rec.strategy + ) + })?; + // The #246 override set (silent variant, `reproduce_family_in`'s + // recipe): re-derived from the cell's own recorded manifest param + // names, against a raw probe space + raw strategy load — the members + // of one cell share the same knob NAMES (only values differ), so this + // is safe to compute once, cell-invariant, from the first member. + let raw_space = blueprint_axis_probe(blueprint_json, env).param_space(); + let raw_signal = blueprint_from_json(blueprint_json, &|t| env.resolve(t)) + .expect("stored blueprint passed the referential gate; reload is infallible"); + let recorded: Vec = members + .first() + .map(|(_, r)| r.manifest.params.iter().map(|(n, _)| n.clone()).collect()) + .unwrap_or_default(); + let overrides = wrapped_bound_overrides_of(&recorded, &raw_space, &raw_signal); + // Cell-invariant across every member of this cell (the wrapped param + // space depends only on the cell's blueprint; the resolved geometry + // only on the cell's instrument) — computed once here rather than + // redundantly inside the member loop below. + let space = + blueprint_axis_probe_reopened(blueprint_json, env, &overrides).param_space(); + // Same resolved pip the member ran at (`DefaultMemberRunner::run_member`'s + // `geo.pip_size`), else the C1 drift-alarm equality below would trip + // spuriously on a re-run computed at a different (default) pip. + let geo = instrument_geometry(server, &cell_rec.instrument).ok_or_else(|| { + format!( + "no recorded geometry for symbol '{}' at {} — refusing to re-run a \ + real instrument with a guessed pip", + cell_rec.instrument, + env.data_path() + ) + })?; + + for (member_subdir, member_report) in members.iter().cloned() { + // Re-run the member, non-reduce: the SAME member the executor ran + // (same wrapped space, same params, same window, seed-free real + // data), mirroring `DefaultMemberRunner::run_member` with the + // reduce fold off so the per-cycle tap streams exist. The window + // is the member report's own `manifest.window` — already + // epoch-ns (`run_blueprint_member` stamped the post-seam + // bounds), so no second ms->ns crossing here. + let point = point_from_params(&space, &member_report.manifest.params)?; + let (from, to) = member_report.manifest.window; + let no_data = || { + format!( + "no data for instrument {} in the member window [{}, {}] (epoch-ns)", + cell_rec.instrument, from.0, to.0 + ) + }; + let signal = reopen_all( + blueprint_from_json(blueprint_json, &|t| env.resolve(t)) + .expect("stored blueprint passed the referential gate; reload is infallible"), + &overrides, + ); + // Campaign data.bindings overrides win over name defaults — the + // SAME resolution the member ran under, so the C1 drift alarm + // compares like with like. + let binding = crate::binding::resolve_binding( + &cell_rec.strategy, + signal.input_roles(), + &campaign.data.bindings, + )?; + let sources = open_columns_window( + server, + &cell_rec.instrument, + Some(from), + Some(to), + &binding.columns(), + ) + .ok_or_else(no_data)?; + if aura_engine::Source::peek(sources[0].as_ref()).is_none() { + return Err(no_data()); + } + // The cell's OWN regime (#219/#212), not the hardcoded default: the + // recorded member ran under `cell_rec.regime`, bound through the same + // `stop_rule_for_regime` helper `DefaultMemberRunner::run_member` + // uses, so the two sites cannot drift apart again (the #219 + // divergence class) and the re-run binds the same stop the C1 + // drift alarm below relies on. + let stop = stop_rule_for_regime(cell_rec.regime); + let (tx_eq, rx_eq) = mpsc::channel(); + let (tx_ex, rx_ex) = mpsc::channel(); + let (tx_r, rx_r) = mpsc::channel(); + let (tx_req, rx_req) = mpsc::channel(); + // The campaign's OWN cost model (#234), bound the same way + // `DefaultMemberRunner::run_member` binds it (via + // `cost_nodes_for`): empty = no leg, so a cost-less campaign's + // re-run is unchanged. A non-empty model MUST be threaded here + // too — the recorded member ran net (#295's + // `run_blueprint_member`); leaving this re-run gross would trip + // the C1 drift alarm below on every legitimate costed campaign, + // not just on a real divergence. + let (tx_cost, rx_cost) = mpsc::channel(); + let (tx_net, rx_net) = mpsc::channel(); + let cost_leg = (!campaign.cost.is_empty()).then(|| CostLeg { + nodes: cost_nodes_for(&campaign.cost, &cell_rec.instrument), + tx_cost, + tx_net, + }); + let mut h = + wrap_r(signal, tx_eq, tx_ex, tx_r, tx_req, stop, false, geo.pip_size, &binding, cost_leg) + .bootstrap_with_cells(&point) + .expect("the member's point re-bootstraps (it already ran this realization)"); + // The recorded member already ran this realization once (see the + // `.expect` immediately above); `sources` are re-opened against the + // SAME `binding` this trace re-run resolved, so a `SourceBindError` + // here can only be an internal wiring inconsistency, not a fresh + // user input mistake. Panic like the sibling bootstrap invariant + // instead of a process-exit dressed as a refusal message. + h.run_bound(key_supply(&binding, sources)) + .expect("sources re-opened against `binding` key-match that binding's own roles by construction"); + // Drain ALL SIX channels (`run_signal_r` leaves req undrained; the + // trace path must not): eq/ex/req/net feed the taps, r+cost feed + // the metrics. + let eq_rows: Vec<(Timestamp, Vec)> = rx_eq.try_iter().collect(); + let ex_rows: Vec<(Timestamp, Vec)> = rx_ex.try_iter().collect(); + let r_rows: Vec<(Timestamp, Vec)> = rx_r.try_iter().collect(); + let req_rows: Vec<(Timestamp, Vec)> = rx_req.try_iter().collect(); + let cost_rows: Vec<(Timestamp, Vec)> = rx_cost.try_iter().collect(); + let net_rows: Vec<(Timestamp, Vec)> = rx_net.try_iter().collect(); + + // The C1 drift alarm: metrics equality against the recorded + // member. The reduce-mode fold shares its arithmetic with this + // non-reduce reduction (SeriesFold via `summarize`; GatedRecorder + // emits exactly the rows `summarize_r`'s ledger reads), so equality + // is bit-exact. + let mut rerun_metrics = summarize(&f64_field(&eq_rows, 0), &f64_field(&ex_rows, 0)); + rerun_metrics.r = Some(summarize_r(&r_rows, &cost_rows)); + if rerun_metrics != member_report.metrics { + return Err(format!( + "trace re-run diverged from the recorded member (C1 violation): cell \ + {cell_key} of trace {trace_name} does not reproduce its recorded \ + metrics; refusing to persist a silently-wrong trace" + )); + } + + let traces: Vec = routed + .iter() + .map(|&(tap, ch)| { + let rows: &[(Timestamp, Vec)] = match ch { + TapChannel::Equity => &eq_rows, + TapChannel::Exposure => &ex_rows, + TapChannel::REquity => &req_rows, + TapChannel::Net => &net_rows, + }; + ColumnarTrace::from_rows(tap, &[ScalarKind::F64], rows) + }) + .collect(); + let write_key = match &member_subdir { + Some(sub) => format!("{trace_name}/{cell_key}/{sub}"), + None => format!("{trace_name}/{cell_key}"), + }; + store + .write(&write_key, &member_report.manifest, &traces) + .map_err(|e| e.to_string())?; + } + persisted_cells += 1; + } + + eprintln!( + "aura: traces persisted: {trace_name} ({} tap(s) x {persisted_cells} cell(s))", + routed.len() + ); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use aura_campaign::StageFamily; + use aura_engine::RunManifest; + + /// A minimal `RunReport` fixture carrying exactly the manifest params + /// `member_trace_key`/`cell_member_fanout` read; the metrics are an empty + /// `summarize`, irrelevant to either function under test. + fn report_with_params(params: Vec<(String, Scalar)>) -> RunReport { + RunReport { + manifest: RunManifest { + commit: "test".to_string(), + params, + defaults: Vec::new(), + window: (Timestamp(0), Timestamp(0)), + seed: 0, + broker: "test".to_string(), + selection: None, + instrument: None, + topology_hash: None, + project: None, + }, + metrics: summarize(&[], &[]), + } + } + + #[test] + /// 0109: the campaign cell key is content-derived (the `member_key` + /// discipline — never a runtime ordinal): strategy content-id prefix + + /// instrument + doc-positional window ordinal, one portable component. + fn campaign_cell_key_is_content_derived() { + let strategy = "bb34aa55".repeat(8); // a 64-hex content id + assert_eq!(campaign_cell_key(&strategy, "GER40", 0, 0), "bb34aa55-GER40-w0"); + } + + #[test] + /// A non-portable instrument byte maps to `_` (the shared + /// `sanitize_component` charset) — one path component, never a nested + /// path or an invalid directory name. + fn campaign_cell_key_sanitizes_non_portable_bytes() { + let strategy = "0123abcd".repeat(8); + assert_eq!(campaign_cell_key(&strategy, "GER/40", 3, 0), "0123abcd-GER_40-w3"); + } + + #[test] + /// A non-default regime_ordinal (#219/#212) appends `-r{ordinal}` so two + /// regimes over the same (strategy, instrument, window) cell land in + /// distinct trace dirs; ordinal 0 (the default regime) is covered above + /// and stays unsuffixed for pre-#219 key stability. + fn campaign_cell_key_appends_regime_suffix_for_non_default_ordinal() { + let strategy = "bb34aa55".repeat(8); + assert_eq!(campaign_cell_key(&strategy, "GER40", 0, 2), "bb34aa55-GER40-w0-r2"); + } + + #[test] + /// #224: `member_trace_key` renders the SAME `"name=value, ..."` join + /// `aura reproduce` prints for a member's params (single-sourced label + /// format), then sanitizes it for filesystem use — a raw label's `=` and + /// `, ` separators are hostile to a path component and must map to `_` + /// (the shared `sanitize_component` charset), never survive verbatim. + fn member_trace_key_mirrors_reproduce_label_and_sanitizes_it() { + let report = report_with_params(vec![ + ("fast".to_string(), Scalar::i64(2)), + ("slow".to_string(), Scalar::i64(4)), + ]); + // The raw reproduce-format label is "fast=2, slow=4"; sanitizing maps + // each of `=`, `,`, ` ` individually to `_` (so the ", " separator + // becomes two underscores, not one). + assert_eq!(member_trace_key(&report, 0), "fast_2__slow_4"); + } + + #[test] + /// #224: a member with no tuning params (a closed blueprint, or a + /// monte-carlo seed member) renders an empty reproduce label, so + /// `member_trace_key` falls back to the member's own ordinal into the + /// family — never an empty (invalid) path component. + fn member_trace_key_falls_back_to_the_ordinal_when_params_are_empty() { + let report = report_with_params(Vec::new()); + assert_eq!(member_trace_key(&report, 3), "3"); + } + + #[test] + /// #224: two members with different params must land at distinct keys — + /// the whole point of per-member subdirectories is that the sweep's + /// members never collide into one on-disk slot. + fn member_trace_key_differs_across_members_with_different_params() { + let a = report_with_params(vec![("length".to_string(), Scalar::i64(10))]); + let b = report_with_params(vec![("length".to_string(), Scalar::i64(20))]); + assert_ne!(member_trace_key(&a, 0), member_trace_key(&b, 1)); + } + + /// A no-nominee `CellOutcome` whose terminal family holds two members + /// (a plain selection-free sweep, #224's headline shape). + fn two_member_family_cell() -> CellOutcome { + CellOutcome { + families: vec![StageFamily { + stage: 0, + block: "std::sweep", + family_id: "fam".to_string(), + reports: vec![ + report_with_params(vec![("length".to_string(), Scalar::i64(10))]), + report_with_params(vec![("length".to_string(), Scalar::i64(20))]), + ], + }], + selections: Vec::new(), + nominee: None, + } + } + + #[test] + /// #224: a no-nominee cell with a non-empty terminal family fans out to + /// ONE (subdir, report) pair PER member — never narrowing to a single + /// nominated member, which would silently drop every other member the + /// sweep actually produced. Each pair carries a distinct `Some(key)` + /// subdir (never `None`, which is reserved for the nominee case). + fn cell_member_fanout_yields_one_entry_per_family_member() { + let cell = two_member_family_cell(); + let members = cell_member_fanout(&cell); + assert_eq!(members.len(), 2, "one dir per member, not one nominee"); + let Some(key0) = &members[0].0 else { panic!("member 0 must carry a subdir key") }; + let Some(key1) = &members[1].0 else { panic!("member 1 must carry a subdir key") }; + assert_ne!(key0, key1, "distinct members must land at distinct subdirs"); + } + + #[test] + /// #224: a nominee cell (walkforward/generalize/mc) still writes its one + /// trace directly at `/` — a `None` subdir key, unchanged since + /// 0109 — regardless of how many stage families it also carries. + fn cell_member_fanout_prefers_the_nominee_with_no_subdir() { + let mut cell = two_member_family_cell(); + let nominee_report = report_with_params(vec![("length".to_string(), Scalar::i64(30))]); + cell.nominee = Some((vec![("length".to_string(), Scalar::i64(30))], nominee_report)); + let members = cell_member_fanout(&cell); + assert_eq!(members.len(), 1, "the nominee is the cell's single trace"); + assert!(members[0].0.is_none(), "the nominee writes directly at /, no subdir"); + } + + #[test] + /// #224: a cell with neither a nominee nor a non-empty terminal family + /// (every family is empty, or there are none) fans out to nothing — the + /// caller's cue to skip the cell loudly rather than write an empty dir. + fn cell_member_fanout_is_empty_with_no_nominee_and_no_members() { + let cell = CellOutcome { families: Vec::new(), selections: Vec::new(), nominee: None }; + assert!(cell_member_fanout(&cell).is_empty()); + } + + #[test] + /// #234 — a DELIBERATE pin move (was: `net_r_equity` routes to `None` on + /// the cost-free wrap): the full closed tap vocabulary now routes, + /// `net_r_equity` to the cost leg's net-curve channel. Producibility + /// (does THIS run carry a cost model?) is the caller's check, not the + /// routing's. + fn tap_channel_routes_the_full_vocabulary() { + assert_eq!(tap_channel("equity"), Some(TapChannel::Equity)); + assert_eq!(tap_channel("exposure"), Some(TapChannel::Exposure)); + assert_eq!(tap_channel("r_equity"), Some(TapChannel::REquity)); + assert_eq!(tap_channel("net_r_equity"), Some(TapChannel::Net)); + assert_eq!(tap_channel("bogus"), None); + } +} diff --git a/crates/aura-runner/src/translate.rs b/crates/aura-runner/src/translate.rs new file mode 100644 index 0000000..578c12c --- /dev/null +++ b/crates/aura-runner/src/translate.rs @@ -0,0 +1,249 @@ +//! The C1-load-bearing param<->config translators. +//! +//! Every value that rides OUTSIDE the wrapped param_space (the stop regime, +//! the cost model) must still round-trip through a manifest, so a stored +//! member (family, reproduce) re-derives bit-identically. These translators +//! are the single source for each such write<->read pair — the manifest +//! `stop_length`/`stop_k`/`stop_period_minutes` <-> [`StopRule`] binding, and +//! the `cost[k].` <-> [`aura_research::CostSpec`] binding — so the two +//! halves of each pair cannot drift out of sync between the run/campaign +//! paths and reproduce/persist. + +use aura_composites::StopRule; +use aura_core::{PrimitiveBuilder, Scalar}; +use aura_strategy::{CarryCost, ConstantCost, VolSlippageCost}; + +/// The r-sma vol-stop EWMA length (cycles). Single source for the `StopRule::Vol` +/// the blueprint embeds and the `stop` param the manifest records — kept honest by one +/// constant instead of a hand-synced literal across the function boundary. +pub const R_SMA_STOP_LENGTH: i64 = 3; + +/// The r-sma vol-stop multiplier (1R = `k`·σ). Single source for the embedded +/// `StopRule::Vol` and its manifest record, like [`R_SMA_STOP_LENGTH`]. +pub const R_SMA_STOP_K: f64 = 2.0; + +/// Re-derive the `StopRule` a member was minted under from its manifest params +/// (`stop_length`/`stop_k`/`stop_period_minutes`, stamped by `run_blueprint_member`): +/// if `stop_period_minutes` is present alongside `stop_length`/`stop_k`, this +/// re-derives `VolTf`; otherwise falls back to `Vol`, and to the default +/// vol-stop regime when the manifest carries no stop knobs at all (pre-#233 +/// members), mirroring [`stop_rule_for_regime`]'s `None` arm for the same +/// one-directional widening `point_from_params` already applies to missing +/// manifest params. +pub fn stop_rule_from_params(params: &[(String, Scalar)]) -> StopRule { + let period_minutes = + params.iter().find(|(n, _)| n == "stop_period_minutes").map(|(_, s)| s.as_i64()); + let length = params.iter().find(|(n, _)| n == "stop_length").map(|(_, s)| s.as_i64()); + let k = params.iter().find(|(n, _)| n == "stop_k").map(|(_, s)| s.as_f64()); + match (period_minutes, length, k) { + (Some(period_minutes), Some(length), Some(k)) => { + StopRule::VolTf { period_minutes, length, k } + } + (None, Some(length), Some(k)) => StopRule::Vol { length, k }, + _ => StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, + } +} + +/// Re-derive the cost model a member was minted under from its manifest params +/// (`cost[k].`, stamped by `run_blueprint_member`) — the #233 stop-regime +/// pattern: like the stop, the cost model rides OUTSIDE the wrapped +/// param_space, so `point_from_params` cannot recover it. Components are read +/// in index order; the builder knob name discriminates the variant (each +/// shipped cost node has exactly one, distinctly named knob). No `cost[0].*` +/// param = the empty (gross) model — every pre-cost member widens to it, +/// mirroring `stop_rule_from_params`'s default arm. +/// +/// `Err` carries the exact refusal message a corrupted-on-disk cost param has +/// always produced (#295: a library function reports a refusal, it does not +/// end the process itself — the caller turns this into `aura:` + exit 1, +/// byte-identical to before). +pub fn cost_specs_from_params( + params: &[(String, Scalar)], +) -> Result, String> { + let mut specs = Vec::new(); + for k in 0.. { + let prefix = format!("cost[{k}]."); + let Some((knob, v)) = params + .iter() + .find_map(|(n, s)| n.strip_prefix(&prefix).map(|rest| (rest, *s))) + else { + break; + }; + specs.push(match knob { + "cost_per_trade" => aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(v.as_f64()), + }, + "slip_vol_mult" => aura_research::CostSpec::VolSlippage { + slip_vol_mult: aura_research::CostValue::Scalar(v.as_f64()), + }, + "carry_per_cycle" => aura_research::CostSpec::Carry { + carry_per_cycle: aura_research::CostValue::Scalar(v.as_f64()), + }, + other => { + return Err(format!( + "manifest cost param cost[{k}].{other} names no cost component" + )); + } + }); + } + Ok(specs) +} + +/// The one regime->StopRule binding, shared by `CliMemberRunner::run_member` +/// and `persist_campaign_traces`'s drift-alarm re-run (#219): `None` is the +/// default vol-stop, `Some(RiskRegime::Vol { .. })` binds that regime's own +/// params. Single-sourced so the persist-side re-run structurally cannot +/// diverge from the run-side binding again (the #219 divergence class). +pub fn stop_rule_for_regime(regime: Option) -> aura_composites::StopRule { + match regime { + None => aura_composites::StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, + Some(aura_research::RiskRegime::Vol { length, k }) => { + aura_composites::StopRule::Vol { length, k } + } + Some(aura_research::RiskRegime::VolTf { period_minutes, length, k }) => { + aura_composites::StopRule::VolTf { period_minutes, length, k } + } + } +} + +/// The one CostSpec -> cost-node binding (#234), beside its risk sibling +/// `stop_rule_for_regime` and shared the same way: `CliMemberRunner::run_member` +/// (via `run_blueprint_member`) and `persist_campaign_traces`'s drift-alarm +/// re-run both bind through here, so the persist-side re-run structurally +/// cannot diverge from the run-side cost model (the #219 divergence class, +/// cost edition). The bound knob names are the builders' own `ParamSpec` names +/// — the `CostSpec` serde vocabulary conforms to them. +pub fn cost_nodes_for(specs: &[aura_research::CostSpec], instrument: &str) -> Vec { + specs + .iter() + .map(|s| { + let (knob, v) = cost_knob(s, instrument); + match s { + aura_research::CostSpec::Constant { .. } => { + ConstantCost::builder().bind(knob, Scalar::f64(v)) + } + aura_research::CostSpec::VolSlippage { .. } => { + VolSlippageCost::builder().bind(knob, Scalar::f64(v)) + } + aura_research::CostSpec::Carry { .. } => { + CarryCost::builder().bind(knob, Scalar::f64(v)) + } + } + }) + .collect() +} + +/// The one CostSpec-variant -> (knob name, value) mapping, shared by +/// `cost_nodes_for`'s bind above and `run_blueprint_member`'s manifest stamp: +/// the stamp key must equal the bind key for reproduce to re-derive a +/// `CostSpec` from a stored manifest, so both sites read the name off this +/// single function rather than each carrying its own literal. +pub fn cost_knob(spec: &aura_research::CostSpec, instrument: &str) -> (&'static str, f64) { + let (knob, value) = match spec { + aura_research::CostSpec::Constant { cost_per_trade } => ("cost_per_trade", cost_per_trade), + aura_research::CostSpec::VolSlippage { slip_vol_mult } => ("slip_vol_mult", slip_vol_mult), + aura_research::CostSpec::Carry { carry_per_cycle } => ("carry_per_cycle", carry_per_cycle), + }; + let v = value.resolve(instrument).unwrap_or_else(|| { + // Unreachable after intrinsic validation (map keys ≡ instruments); + // refuse loudly rather than charging 0 silently if it ever surfaces. + eprintln!("aura: cost {knob}: no entry for instrument {instrument}"); + std::process::exit(1); + }); + (knob, v) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + /// #262 round-trip: a manifest carrying stop_period_minutes re-derives + /// the VolTf stop; one without it keeps the Vol path — a stored VolTf + /// member must never silently reproduce under a default Vol stop. + fn stop_rule_from_params_round_trips_the_vol_tf_stamp() { + let vol_tf = vec![ + ("stop_period_minutes".to_string(), Scalar::i64(60)), + ("stop_length".to_string(), Scalar::i64(14)), + ("stop_k".to_string(), Scalar::f64(2.0)), + ]; + assert_eq!( + stop_rule_from_params(&vol_tf), + StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 } + ); + let vol = vec![ + ("stop_length".to_string(), Scalar::i64(3)), + ("stop_k".to_string(), Scalar::f64(2.0)), + ]; + assert_eq!(stop_rule_from_params(&vol), StopRule::Vol { length: 3, k: 2.0 }); + } + + #[test] + /// #262: `stop_rule_for_regime` binds `RiskRegime::VolTf` to + /// `StopRule::VolTf` field-for-field — the resolve-side half of the + /// write/resolve pair the manifest round-trip test above covers from + /// the stamp side; together the two arms this regime touches + /// (this module's bind and `run_blueprint_member`'s stamp) are both exercised. + fn stop_rule_for_regime_binds_vol_tf_field_for_field() { + let regime = + Some(aura_research::RiskRegime::VolTf { period_minutes: 60, length: 14, k: 2.0 }); + assert_eq!( + stop_rule_for_regime(regime), + aura_composites::StopRule::VolTf { period_minutes: 60, length: 14, k: 2.0 } + ); + } + + #[test] + /// #234: the one CostSpec -> builder binding maps each component to its + /// shipped cost node with the knob BOUND — a bound component adds no open + /// param, so the wrapped param_space stays cost-invariant (the probe/member + /// space equivalence the campaign path relies on). + fn cost_nodes_for_maps_each_component_to_its_bound_builder() { + let nodes = cost_nodes_for( + &[ + aura_research::CostSpec::Constant { + cost_per_trade: aura_research::CostValue::Scalar(2.0), + }, + aura_research::CostSpec::VolSlippage { + slip_vol_mult: aura_research::CostValue::Scalar(0.5), + }, + aura_research::CostSpec::Carry { + carry_per_cycle: aura_research::CostValue::Scalar(0.1), + }, + ], + "GER40", + ); + let labels: Vec = nodes.iter().map(|n| n.label()).collect(); + assert_eq!(labels, ["ConstantCost", "VolSlippageCost", "CarryCost"]); + assert!( + nodes.iter().all(|n| n.params().is_empty()), + "every component must be fully bound (no open param leaks into param_space)" + ); + assert!(cost_nodes_for(&[], "GER40").is_empty(), "an empty model binds no nodes"); + } + + #[test] + /// #234: the manifest-stamp/reproduce round-trip (`cost[k].` -> + /// `cost_specs_from_params`) reconstructs a component from its knob NAME — + /// sound only while every shipped cost node declares exactly one, + /// distinctly-named knob. A second knob (or a name collision) would break + /// variant reconstruction with no compiler error; this pin makes it loud. + fn every_cost_builder_declares_exactly_one_distinct_knob() { + let knobs: Vec = [ + aura_strategy::ConstantCost::builder(), + aura_strategy::VolSlippageCost::builder(), + aura_strategy::CarryCost::builder(), + ] + .iter() + .map(|b| { + let params = b.params(); + assert_eq!(params.len(), 1, "one knob per cost node: {}", b.label()); + params[0].name.clone() + }) + .collect(); + let mut dedup = knobs.clone(); + dedup.sort(); + dedup.dedup(); + assert_eq!(dedup.len(), knobs.len(), "knob names must be pairwise distinct: {knobs:?}"); + } +} diff --git a/crates/aura-vocabulary/tests/c28_layering.rs b/crates/aura-vocabulary/tests/c28_layering.rs index aacd916..eef77aa 100644 --- a/crates/aura-vocabulary/tests/c28_layering.rs +++ b/crates/aura-vocabulary/tests/c28_layering.rs @@ -36,6 +36,7 @@ fn node_crates_obey_the_c28_import_direction() { // (crate, the intra-workspace [dependencies] C28 permits for its layer) let allowed: &[(&str, &[&str])] = &[ ("aura-analysis", &[]), // foundation: no aura-* deps + ("aura-measurement", &["aura-core", "aura-analysis"]), ("aura-std", &["aura-core"]), ("aura-market", &["aura-core"]), ("aura-strategy", &["aura-core"]),