refactor: extract the member-run recipe into library crates (#295 part 1)

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
This commit is contained in:
2026-07-21 05:20:27 +02:00
parent 2cf4574e33
commit 5c2ac982bc
29 changed files with 5048 additions and 4370 deletions
Generated
+34 -2
View File
@@ -160,19 +160,19 @@ dependencies = [
"aura-core", "aura-core",
"aura-engine", "aura-engine",
"aura-ingest", "aura-ingest",
"aura-measurement",
"aura-registry", "aura-registry",
"aura-research", "aura-research",
"aura-runner",
"aura-std", "aura-std",
"aura-strategy", "aura-strategy",
"aura-vocabulary", "aura-vocabulary",
"clap", "clap",
"data-server", "data-server",
"libc", "libc",
"libloading",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"toml",
"zip", "zip",
] ]
@@ -239,6 +239,15 @@ dependencies = [
"chrono-tz", "chrono-tz",
] ]
[[package]]
name = "aura-measurement"
version = "0.1.0"
dependencies = [
"aura-analysis",
"aura-core",
"serde",
]
[[package]] [[package]]
name = "aura-registry" name = "aura-registry"
version = "0.1.0" version = "0.1.0"
@@ -265,6 +274,29 @@ dependencies = [
"sha2", "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]] [[package]]
name = "aura-std" name = "aura-std"
version = "0.1.0" version = "0.1.0"
+2
View File
@@ -13,6 +13,7 @@ members = [
"crates/aura-std", "crates/aura-std",
"crates/aura-vocabulary", "crates/aura-vocabulary",
"crates/aura-market", "crates/aura-market",
"crates/aura-measurement",
"crates/aura-strategy", "crates/aura-strategy",
"crates/aura-backtest", "crates/aura-backtest",
"crates/aura-engine", "crates/aura-engine",
@@ -22,6 +23,7 @@ members = [
"crates/aura-ingest", "crates/aura-ingest",
"crates/aura-registry", "crates/aura-registry",
"crates/aura-research", "crates/aura-research",
"crates/aura-runner",
"crates/aura-campaign", "crates/aura-campaign",
"crates/aura-bench", "crates/aura-bench",
] ]
+5
View File
@@ -9,6 +9,7 @@
mod mc; mod mc;
mod metrics; mod metrics;
mod position_management; mod position_management;
pub mod scaffold;
mod sim_broker; mod sim_broker;
// `assemble_mc` stays module-private (it was never `pub` in the pre-#291 engine // `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, ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement,
RECORD_KINDS as PM_RECORD_KINDS, WIDTH as PM_WIDTH, 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; pub use sim_broker::SimBroker;
/// The trading instantiation of the engine's metric-generic run record (C28 /// The trading instantiation of the engine's metric-generic run record (C28
+166
View File
@@ -0,0 +1,166 @@
//! The pure per-run scaffold: bootstrap-adjacent arithmetic that has no
//! dependency on the CLI shell — reconstructing a bootstrap point from
//! recorded params, sizing the real walk-forward roller, and intersecting
//! per-symbol data windows. Relocated verbatim from `aura-cli::main` (#295)
//! so it is reachable (and unit-testable) without the shell.
use aura_core::{Cell, ParamSpec, Scalar};
/// Reconstruct a member's bootstrap point from its recorded named params — the inverse
/// of `zip_params(space, point)`. Walks the reloaded signal's `param_space` in order
/// (deterministic for the same blueprint) and reads each knob's value from the manifest.
///
/// `Err` carries the exact refusal message a manifest missing a param the reloaded
/// space expects has always produced (corrupted-on-disk data): this crate has no
/// process-exit register of its own (#295 — a pure library function reports a
/// refusal, it does not end the process), so the caller is the one that turns this
/// into `aura:` + exit 1, byte-identical to before.
pub fn point_from_params(space: &[ParamSpec], params: &[(String, Scalar)]) -> Result<Vec<Cell>, String> {
space
.iter()
.map(|ps| {
params
.iter()
.find(|(n, _)| n == &ps.name)
.map(|(_, s)| s.cell())
.ok_or_else(|| format!("manifest is missing param {}", ps.name))
})
.collect()
}
/// Real walk-forward roller sizes (Fork D/F). `WindowRoller` takes sizes in the
/// stream's epoch-unit; for real M1 that is nanoseconds. A classic 3-month
/// in-sample / 1-month out-of-sample / 1-month step (contiguous OOS tiling).
const WF_DAY_NS: i64 = 86_400_000_000_000;
pub const WF_REAL_IS_NS: i64 = 90 * WF_DAY_NS;
pub const WF_REAL_OOS_NS: i64 = 30 * WF_DAY_NS;
pub const WF_REAL_STEP_NS: i64 = 30 * WF_DAY_NS;
/// The real walk-forward roller sizes in ms (the campaign wf stage works in ms:
/// span `cell.window_ms`, sizes the `StageBlock` `_ms` fields). `WF_REAL_*_NS` are
/// nanoseconds; divide by 1e6. The ms sizes over the doc window reproduce the same
/// calendar windows the inline ns roller produces (anchor-gated).
pub fn wf_ms_sizes() -> (u64, u64, u64) {
(
(WF_REAL_IS_NS / 1_000_000) as u64,
(WF_REAL_OOS_NS / 1_000_000) as u64,
(WF_REAL_STEP_NS / 1_000_000) as u64,
)
}
/// Fit the fixed real-archive walk-forward roller (`wf_ms_sizes`, 90/30/30 days)
/// to a resolved campaign window `(from_ms, to_ms)` (#239). When the fixed
/// IS+OOS span fits inside the window, the sizes come back byte-identical (every
/// existing anchor/e2e over a year-plus window pins this branch). When the window
/// is shorter than IS+OOS, the roller scales DOWN to the window, preserving the
/// fixed 3:1 IS:OOS ratio, with `step_ms == oos_ms` (a window of exactly IS+OOS
/// then yields exactly one roll — never zero). Pure and unit-testable; both
/// `dispatch_walkforward`/`dispatch_mc` consume it in place of the unconditional
/// `wf_ms_sizes()` stamp. The executor's own fit check (`campaign_run.rs`) stays
/// untouched — it still validates an AUTHORED document's declared window as-is.
pub fn fit_wf_ms_sizes(from_ms: i64, to_ms: i64) -> (u64, u64, u64) {
let (is_ms, oos_ms, step_ms) = wf_ms_sizes();
let span_ms = to_ms.saturating_sub(from_ms).max(0) as u64;
if is_ms + oos_ms <= span_ms {
return (is_ms, oos_ms, step_ms);
}
// Preserve the fixed IS:OOS ratio derived from the constants themselves (not a
// bare literal): OOS = span/(ratio+1) (floor), IS = ratio*OOS, so
// IS+OOS = (ratio+1)*OOS <= span always holds, and step == OOS (one roll over
// the fit window, matching the fixed-size branch's `step_ms == oos_ms`).
let ratio = is_ms / oos_ms;
let oos_fit = span_ms / (ratio + 1);
let is_fit = oos_fit * ratio;
(is_fit, oos_fit, oos_fit)
}
type SymbolSpans<'a> = Vec<(&'a str, (i64, i64))>;
/// The pure decision `generalize`'s no-explicit-window fallback reduces to once
/// every listed symbol's full window is resolved (#213): the shared window is
/// the intersection (latest start, earliest end); `Err` carries each symbol
/// paired with its own resolved window when the intersection is empty (the
/// archives never overlap), for the caller's eprintln-then-exit(1) refusal.
/// Kept separate from `dispatch_generalize` so the intersect-or-refuse
/// arithmetic is unit-testable on synthetic windows — real archives on this
/// host never disjoint (every symbol's tail reaches the live present), so the
/// refusal branch has no reachable e2e fixture.
pub fn intersect_shared_window<'a>(
symbols: &'a [String],
windows: &[(i64, i64)],
) -> Result<(i64, i64), SymbolSpans<'a>> {
let shared_from = windows.iter().map(|w| w.0).max().expect("caller passes at least one window");
let shared_to = windows.iter().map(|w| w.1).min().expect("caller passes at least one window");
if shared_from > shared_to {
Err(symbols.iter().map(String::as_str).zip(windows.iter().copied()).collect())
} else {
Ok((shared_from, shared_to))
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A single listed symbol degenerates to its own full window unchanged
/// (#213) — the intersection of one window with itself is that window.
#[test]
fn intersect_shared_window_of_a_single_symbol_is_its_own_window() {
let symbols = vec!["GER40".to_string()];
let windows = vec![(100, 200)];
assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200)));
}
/// Overlapping multi-symbol windows resolve to the intersection — the
/// latest start, earliest end (#213) — never `symbols[0]`'s own window.
#[test]
fn intersect_shared_window_of_overlapping_windows_is_latest_start_earliest_end() {
let symbols = vec!["AAPL.US".to_string(), "GER40".to_string()];
let windows = vec![(0, 300), (100, 200)];
assert_eq!(intersect_shared_window(&symbols, &windows), Ok((100, 200)));
}
/// Disjoint archives (no shared instant across all listed symbols) refuse
/// rather than silently pooling a floor over different periods per
/// instrument (#213); the error names each symbol next to its own span,
/// which the caller eprintln's before exiting 1 — the boundary this
/// covers is otherwise unreachable in an e2e fixture on this host, since
/// every archived symbol's tail reaches the live present (no two windows
/// are ever genuinely disjoint here).
#[test]
fn intersect_shared_window_of_disjoint_windows_refuses_with_every_span() {
let symbols = vec!["A".to_string(), "B".to_string()];
let windows = vec![(0, 100), (200, 300)];
assert_eq!(
intersect_shared_window(&symbols, &windows),
Err(vec![("A", (0, 100)), ("B", (200, 300))]),
);
}
/// Property: a window that already fits the fixed 90/30/30-day roller passes
/// it through byte-identical (the year-plus anchor/e2e grade pins rely on this
/// branch never perturbing the fixed sizes).
#[test]
fn fit_wf_ms_sizes_passes_through_when_the_window_already_fits() {
let day_ms: i64 = 24 * 60 * 60 * 1_000;
let from_ms = 0;
let to_ms = 121 * day_ms; // > 90 + 30 days
assert_eq!(fit_wf_ms_sizes(from_ms, to_ms), wf_ms_sizes());
}
/// Property: a window shorter than IS+OOS scales the roller DOWN to the
/// window, preserving the fixed 3:1 IS:OOS ratio, with `step_ms == oos_ms`
/// (one roll over the fit window) and `is_ms + oos_ms <= span_ms` always.
#[test]
fn fit_wf_ms_sizes_scales_down_to_a_short_window_at_3_to_1() {
let day_ms: i64 = 24 * 60 * 60 * 1_000;
let from_ms = 0;
let to_ms = 30 * day_ms; // far shorter than the fixed 90+30-day roller
let span_ms = (to_ms - from_ms) as u64;
let (is_ms, oos_ms, step_ms) = fit_wf_ms_sizes(from_ms, to_ms);
assert_eq!(is_ms, oos_ms * 3, "the fit preserves the fixed 3:1 IS:OOS ratio");
assert_eq!(step_ms, oos_ms, "one roll over the fit window: step == oos");
assert!(is_ms + oos_ms <= span_ms, "the fit roller must fit inside the window");
assert!(oos_ms > 0, "a 30-day window must yield a non-degenerate fit");
}
}
+20 -11
View File
@@ -12,9 +12,6 @@ path = "src/main.rs"
[dependencies] [dependencies]
aura-core = { path = "../aura-core" } aura-core = { path = "../aura-core" }
aura-engine = { path = "../aura-engine" } 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-registry = { path = "../aura-registry" }
aura-research = { path = "../aura-research" } aura-research = { path = "../aura-research" }
# aura-campaign: campaign-execution semantics (preflight, cell loop, stage # aura-campaign: campaign-execution semantics (preflight, cell loop, stage
@@ -24,9 +21,15 @@ aura-campaign = { path = "../aura-campaign" }
aura-std = { path = "../aura-std" } aura-std = { path = "../aura-std" }
aura-strategy = { path = "../aura-strategy" } aura-strategy = { path = "../aura-strategy" }
aura-backtest = { path = "../aura-backtest" } aura-backtest = { path = "../aura-backtest" }
aura-analysis = { path = "../aura-analysis" }
aura-vocabulary = { path = "../aura-vocabulary" } aura-vocabulary = { path = "../aura-vocabulary" }
aura-ingest = { path = "../aura-ingest" } 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 # 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). # 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" } 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 # EPIPE. The standard vetted crate for signal disposition, already transitive in
# Cargo.lock; admitted under the per-case dependency policy. # Cargo.lock; admitted under the per-case dependency policy.
libc = "0.2" 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 # clap: the vetted standard argument parser. Adopted (C16 per-case review) to
# replace the hand-rolled argv parser + ten duplicated help surfaces with one # replace the hand-rolled argv parser + ten duplicated help surfaces with one
# declarative source, giving scoped --help, --version, --flag=value, and # 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 # data-server dependency above; declared directly here so test code may
# `use zip::...` (#250, no new dependency actually enters the build graph). # `use zip::...` (#250, no new dependency actually enters the build graph).
zip = "2" 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"
File diff suppressed because it is too large Load Diff
+29 -29
View File
@@ -13,7 +13,7 @@ use aura_engine::{
}; };
use serde::Deserialize; 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. // production code; tests still exercise it directly.
#[cfg(test)] #[cfg(test)]
use aura_vocabulary::std_vocabulary; 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, /// 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 /// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
/// past the last op). /// past the last op).
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite, String> { fn composite_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect(); let labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).collect();
let ops: Vec<Op> = docs.into_iter().map(Op::from).collect(); let ops: Vec<Op> = docs.into_iter().map(Op::from).collect();
@@ -162,14 +162,14 @@ fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<Composite,
/// Parse a JSON op-list document, replay it through the env's vocabulary, and /// Parse a JSON op-list document, replay it through the env's vocabulary, and
/// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`. /// return the emitted #155 blueprint JSON — fault shapes as in `composite_from_str`.
pub fn build_from_str(doc: &str, env: &crate::project::Env) -> Result<String, String> { pub fn build_from_str(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let composite = composite_from_str(doc, env)?; let composite = composite_from_str(doc, env)?;
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}")) 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 /// `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. /// 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; use std::io::Read;
let mut doc = String::new(); let mut doc = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut doc) { 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 <T>`: a type's ports + kinds + param paths, /// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the /// read off the pre-build schema (no graph built). `Err` if `T` is not in the
/// closed vocabulary. /// closed vocabulary.
pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<String, String> { pub fn introspect_node(type_id: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?; let builder = env.resolve(type_id).ok_or_else(|| format!("unknown node type {type_id:?}"))?;
let schema = builder.schema(); let schema = builder.schema();
let mut out = format!("{}\n", builder.label()); let mut out = format!("{}\n", builder.label());
@@ -210,7 +210,7 @@ pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<Strin
/// `aura graph introspect --unwired`: the still-open interior slots of a partial /// `aura graph introspect --unwired`: the still-open interior slots of a partial
/// op-list document, by-identifier (applies the ops, does NOT finalize). /// op-list document, by-identifier (applies the ops, does NOT finalize).
pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String, String> { pub fn introspect_unwired(doc: &str, env: &aura_runner::project::Env) -> Result<String, String> {
let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?; let docs: Vec<OpDoc> = serde_json::from_str(doc).map_err(|e| format!("invalid op-list: {e}"))?;
let resolver = |t: &str| env.resolve(t); let resolver = |t: &str| env.resolve(t);
let mut session = GraphSession::new("introspect", &resolver); let mut session = GraphSession::new("introspect", &resolver);
@@ -229,7 +229,7 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
/// group must be set; zero or more than one is the usage error (exit 2). The id /// group must be set; zero or more than one is the usage error (exit 2). The id
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may /// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
/// combine (one build, both ids, content id first). /// combine (one build, both ids, content id first).
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) { pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
let count = cmd.vocabulary as usize let count = cmd.vocabulary as usize
+ cmd.node.is_some() as usize + cmd.node.is_some() as usize
+ cmd.unwired as usize + cmd.unwired as usize
@@ -404,7 +404,7 @@ fn gang_fault_prose(e: &CompileError) -> String {
/// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph /// (`unresolved_namespace_hint`, over `LoadError`) and the op-script `graph
/// build` path (`composite_from_str`, over `OpError`) call this same helper, /// build` path (`composite_from_str`, over `OpError`) call this same helper,
/// so the tier texts cannot drift between the two error families. /// so the tier texts cannot drift between the two error families.
fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<String> { fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option<String> {
if !type_id.contains("::") { if !type_id.contains("::") {
return None; return None;
} }
@@ -423,7 +423,7 @@ fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<Str
/// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the /// (#185/#241) See [`tier_hint_for_type_id`] for the tier rules; this is the
/// blueprint LOAD path's entry point over `LoadError`. /// blueprint LOAD path's entry point over `LoadError`.
pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env) -> Option<String> { pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &aura_runner::project::Env) -> Option<String> {
match e { match e {
LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env), LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env),
_ => None, _ => 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 /// `Ok(())` means the document loaded cleanly; callers that only need the
/// validation (not the `Composite`) re-parse `doc` themselves afterward /// validation (not the `Composite`) re-parse `doc` themselves afterward
/// (`blueprint_from_json` is cheap and infallible at that point). /// (`blueprint_from_json` is cheap and infallible at that point).
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> { pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> {
if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) { if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
return Err( return Err(
"this is an op-script (an op array), not a built blueprint — run \ "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 /// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type, /// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
/// each canonicalized by its own rules. /// each canonicalized by its own rules.
pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> { pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
let value: serde_json::Value = let value: serde_json::Value =
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?; serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
match value { 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 /// 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). /// 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 /// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
/// cannot drift apart on what counts as a store address. /// cannot drift apart on what counts as a store address.
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<String, String> { fn resolve_blueprint_text(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
// A CLI arg tolerates the `content:` display prefix (#194); doc ref // A CLI arg tolerates the `content:` display prefix (#194); doc ref
// fields stay bare-only. // fields stay bare-only.
let target = target let target = target
.strip_prefix("content:") .strip_prefix("content:")
.filter(|t| crate::campaign_run::is_content_id(t)) .filter(|t| aura_runner::axes::is_content_id(t))
.unwrap_or(target); .unwrap_or(target);
let path = Path::new(target); let path = Path::new(target);
if path.is_file() { if path.is_file() {
return std::fs::read_to_string(path) return std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display())); .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) { return match env.registry().get_blueprint(target) {
Ok(Some(json)) => Ok(json), Ok(Some(json)) => Ok(json),
Ok(None) => Err(format!("no blueprint {target} in the project store")), 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<Str
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The /// campaign-axis namespace `validate_campaign_refs` checks axes against. The
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`), /// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
/// the same form `introspect --node` already uses for param kinds. /// the same form `introspect --node` already uses for param kinds.
fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, String> { fn params_lines(target: &str, env: &aura_runner::project::Env) -> Result<String, String> {
use std::fmt::Write as _; use std::fmt::Write as _;
let text = resolve_blueprint_text(target, env)?; let text = resolve_blueprint_text(target, env)?;
// Shape-discriminated like file-mode --content-id: an op-script (array) // 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<String, Strin
/// the project vocabulary, canonicalize, content-address, and store — the /// the project vocabulary, canonicalize, content-address, and store — the
/// `process register` pattern, printing the store path so the trail is /// `process register` pattern, printing the store path so the trail is
/// followable. Prose to stderr + exit 1 on any refusal. /// followable. Prose to stderr + exit 1 on any refusal.
pub fn register_cmd(file: &Path, env: &crate::project::Env) { pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
match register_blueprint(file, env) { match register_blueprint(file, env) {
Ok(line) => println!("{line}"), Ok(line) => println!("{line}"),
Err(m) => { 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<String, String> { fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<String, String> {
let text = std::fs::read_to_string(file) let text = std::fs::read_to_string(file)
.map_err(|e| format!("cannot read {}: {e}", file.display()))?; .map_err(|e| format!("cannot read {}: {e}", file.display()))?;
// Shape-discriminated like file-mode --content-id (#202): either shape // 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":"connect","from":"sub.value","to":"bias.signal"},
{"op":"expose","from":"bias.bias","as":"bias"} {"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("\"format_version\":1"), "emits the #155 envelope: {json}");
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}"); assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
} }
@@ -621,14 +621,14 @@ mod tests {
{"op":"feed","role":"price","into":["fast.series"]}, {"op":"feed","role":"price","into":["fast.series"]},
{"op":"expose","from":"fast.value","as":"out"} {"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}"); assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
} }
#[test] #[test]
fn build_from_str_reports_unknown_node_at_its_op_index() { 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 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\""); 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":"feed","role":"price","into":["fast.series","slow.series"]},
{"op":"connect","from":"fast.value","to":"sub.lhs"} {"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.starts_with("finalize: "), "finalize fault, got: {err}");
assert!(err.contains("sub.rhs") && err.contains("is unconnected"), assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
"names the unconnected slot by-identifier, got: {err}"); "names the unconnected slot by-identifier, got: {err}");
@@ -652,11 +652,11 @@ mod tests {
#[test] #[test]
fn introspect_node_lists_ports_kinds_and_params() { 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("series"), "lists the input port: {out}");
assert!(out.contains("value"), "lists the output field: {out}"); assert!(out.contains("value"), "lists the output field: {out}");
assert!(out.contains("length"), "lists the param path: {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 /// A bind whose value-kind mismatches the param reads as prose, like the connect
@@ -664,7 +664,7 @@ mod tests {
#[test] #[test]
fn build_from_str_bad_bind_kind_reads_as_prose() { 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 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"); assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
} }
@@ -673,7 +673,7 @@ mod tests {
#[test] #[test]
fn build_from_str_unknown_bind_param_reads_as_prose() { fn build_from_str_unknown_bind_param_reads_as_prose() {
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#; 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\""); 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": <v>}` wrapping. /// does not have to read source to learn the `{"I64": <v>}` wrapping.
#[test] #[test]
fn introspect_node_shows_the_bind_form() { fn introspect_node_shows_the_bind_form() {
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary"); let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}"); assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
} }
@@ -705,7 +705,7 @@ mod tests {
{"op":"add","type":"Sub"}, {"op":"add","type":"Sub"},
{"op":"connect","from":"fast.value","to":"sub.lhs"} {"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("sub.rhs"), "sub.rhs is still open: {out}");
assert!(out.contains("fast.series"), "fast.series 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}"); assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
@@ -732,7 +732,7 @@ mod tests {
assert_eq!(docs[7].kind_label(), "tap"); assert_eq!(docs[7].kind_label(), "tap");
// and the built blueprint carries the tap (an un-tapped composite emits no // 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) // `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("\"taps\""), "blueprint carries the taps section: {json}");
assert!(json.contains("fast_ma"), "the tap names the wire: {json}"); assert!(json.contains("fast_ma"), "the tap names the wire: {json}");
} }
+207 -3181
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -234,7 +234,7 @@ mod tests {
/// against the prototype. /// against the prototype.
#[test] #[test]
fn render_html_is_self_contained_and_embeds_the_model() { 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( let bp = aura_engine::blueprint_from_json(
include_str!("../examples/r_sma.json"), include_str!("../examples/r_sma.json"),
&|t| env.resolve(t), &|t| env.resolve(t),
+1 -1
View File
@@ -13,7 +13,7 @@ use aura_research::{
}; };
use aura_registry::RefFault; use aura_registry::RefFault;
use crate::project::Env; use aura_runner::project::Env;
#[derive(clap::Args)] #[derive(clap::Args)]
pub struct ProcessCmd { pub struct ProcessCmd {
+14 -12
View File
@@ -53,13 +53,15 @@ pub(crate) struct SugarInvocation<'a> {
/// by construction, so the net channel is never producible there; requesting /// by construction, so the net channel is never producible there; requesting
/// it anyway would make `campaign_run`'s executor print an unreachable "add a /// it anyway would make `campaign_run`'s executor print an unreachable "add a
/// cost block to the campaign document" remedy (#240). The exclusion reuses /// 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. /// `TapChannel::Net`) rather than duplicating the net-tap name here.
fn persist_taps_from(trace: bool) -> Vec<String> { fn persist_taps_from(trace: bool) -> Vec<String> {
if trace { if trace {
tap_vocabulary() tap_vocabulary()
.iter() .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()) .map(|t| t.to_string())
.collect() .collect()
} else { } else {
@@ -285,8 +287,8 @@ pub(crate) fn register_generated_g(
/// (campaign_run.rs) — the referential shape the dispatch-boundary name /// (campaign_run.rs) — the referential shape the dispatch-boundary name
/// preflight (main.rs) does not catch: a subset of axes that leaves an open /// 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 /// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the
/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses /// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member`
/// per member (campaign_run.rs) — a bound-param axis passes this /// uses per member (aura-runner) — a bound-param axis passes this
/// executable-shape preflight exactly like an already-open one; an axis /// executable-shape preflight exactly like an already-open one; an axis
/// matching neither space still fails `bind_axes`'s own named check below. /// matching neither space still fails `bind_axes`'s own named check below.
/// `probe_params` is any one grid value per axis (checks NAME /// `probe_params` is any one grid value per axis (checks NAME
@@ -298,7 +300,7 @@ fn validate_before_register(
campaign: &CampaignDoc, campaign: &CampaignDoc,
blueprint_canonical: &str, blueprint_canonical: &str,
probe_params: &[(String, Scalar)], probe_params: &[(String, Scalar)],
env: &crate::project::Env, env: &aura_runner::project::Env,
) -> Result<(), String> { ) -> Result<(), String> {
let doc_faults = aura_research::validate_campaign(campaign); let doc_faults = aura_research::validate_campaign(campaign);
if !doc_faults.is_empty() { 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)) 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"); .expect("a generated sugar campaign's strategy ref reloads its own canonical bytes");
let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect(); let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect();
let overrides = crate::campaign_run::raw_bound_overrides_of(&param_names, &raw_space, &raw_signal); let overrides = aura_runner::axes::raw_bound_overrides_of(&param_names, &raw_space, &raw_signal);
let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space(); let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space();
let strategy_id = content_id_of(blueprint_canonical); 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(|_| ())
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f))) .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. /// one campaign executor.
pub(crate) fn run_sweep_sugar( pub(crate) fn run_sweep_sugar(
inv: &SugarInvocation, inv: &SugarInvocation,
env: &crate::project::Env, env: &aura_runner::project::Env,
) -> Result<usize, String> { ) -> Result<usize, String> {
let generated = translate_sweep(inv)?; let generated = translate_sweep(inv)?;
let probe_params = probe_params_from(inv.axes); let probe_params = probe_params_from(inv.axes);
@@ -384,7 +386,7 @@ fn cross_instrument_members(
pub(crate) fn run_generalize_sugar( pub(crate) fn run_generalize_sugar(
inv: &SugarInvocation, inv: &SugarInvocation,
metric: &str, metric: &str,
env: &crate::project::Env, env: &aura_runner::project::Env,
) -> Result<usize, String> { ) -> Result<usize, String> {
let generated = translate_generalize(inv, metric)?; let generated = translate_generalize(inv, metric)?;
let probe_params = probe_params_from(inv.axes); let probe_params = probe_params_from(inv.axes);
@@ -510,7 +512,7 @@ pub(crate) fn run_walkforward_sugar(
metric: &str, metric: &str,
w: WfWindows, w: WfWindows,
select: SelectRule, select: SelectRule,
env: &crate::project::Env, env: &aura_runner::project::Env,
) -> Result<usize, String> { ) -> Result<usize, String> {
let generated = translate_walkforward(inv, metric, w, select)?; let generated = translate_walkforward(inv, metric, w, select)?;
let probe_params = probe_params_from(inv.axes); 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 // always `None`. `run`/`mc --trace` stay refused by design (#224), not by
// this tail being skipped. // this tail being skipped.
if let Some(trace_name) = &run.outcome.record.trace_name { if let Some(trace_name) = &run.outcome.record.trace_name {
crate::campaign_run::persist_campaign_traces( aura_runner::runner::persist_campaign_traces(
trace_name, trace_name,
&run.campaign.presentation.persist_taps, &run.campaign.presentation.persist_taps,
&run.outcome, &run.outcome,
@@ -674,7 +676,7 @@ pub(crate) fn run_mc_sugar(
metric: &str, metric: &str,
w: WfWindows, w: WfWindows,
mc: McKnobs, mc: McKnobs,
env: &crate::project::Env, env: &aura_runner::project::Env,
) -> Result<usize, String> { ) -> Result<usize, String> {
let generated = translate_mc(inv, metric, w, mc)?; let generated = translate_mc(inv, metric, w, mc)?;
let probe_params = probe_params_from(inv.axes); let probe_params = probe_params_from(inv.axes);
+17
View File
@@ -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 }
+224
View File
@@ -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<f64>,
#[serde(skip)]
pub frs: Vec<f64>,
}
/// 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<IcKey> {
(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<f64> {
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<i64, f64> = 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);
}
}
+9
View File
@@ -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};
+32
View File
@@ -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"
+62
View File
@@ -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 <args>` 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<String> {
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())
}
@@ -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:?}"),
}
}
+249
View File
@@ -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 <FILE|ID>` 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<Vec<Cell>, MemberFault> {
let mut slots: Vec<Option<Scalar>> = vec![None; space.len()];
for (raw, value) in params {
let hits: Vec<usize> = 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<Cell> = 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<String> {
let bound: HashSet<String> =
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", &params).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", &params).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", &params).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", &params).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(&param_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"
);
}
}
@@ -9,12 +9,6 @@
//! root roles (`wrap_r`) — in the same canonical `M1Field` declaration order, //! 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 //! 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). //! (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; 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 /// 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 needs no per-build leak; only an override-renamed (non-vocabulary)
/// role falls back to leaking its name. /// 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 { match name {
"open" => Some("open"), "open" => Some("open"),
"high" => Some("high"), "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 scalar kind a column streams (mirrors `aura_ingest::decode`: Volume is
/// the one `i64` column; everything else is `f64`). /// 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 { match column {
M1Field::Volume => ScalarKind::I64, M1Field::Volume => ScalarKind::I64,
_ => ScalarKind::F64, _ => ScalarKind::F64,
@@ -100,7 +94,7 @@ fn rank(column: M1Field) -> usize {
/// name), the archive column it binds, and whether it feeds the signal /// name), the archive column it binds, and whether it feeds the signal
/// (`false` only for the synthesized broker/executor-only close entry). /// (`false` only for the synthesized broker/executor-only close entry).
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct BindingEntry { pub struct BindingEntry {
pub role: String, pub role: String,
pub column: M1Field, pub column: M1Field,
pub feeds_signal: bool, pub feeds_signal: bool,
@@ -110,25 +104,25 @@ pub(crate) struct BindingEntry {
/// openers (column opening) share: entries in canonical column order, with a /// openers (column opening) share: entries in canonical column order, with a
/// close entry guaranteed (the broker/executor price feed). /// close entry guaranteed (the broker/executor price feed).
#[derive(Debug)] #[derive(Debug)]
pub(crate) struct ResolvedBinding { pub struct ResolvedBinding {
entries: Vec<BindingEntry>, entries: Vec<BindingEntry>,
} }
impl ResolvedBinding { impl ResolvedBinding {
pub(crate) fn entries(&self) -> &[BindingEntry] { pub fn entries(&self) -> &[BindingEntry] {
&self.entries &self.entries
} }
/// The columns to open, one per entry, in canonical order — exactly the /// The columns to open, one per entry, in canonical order — exactly the
/// order the entries' roles are declared in, so source index i always /// order the entries' roles are declared in, so source index i always
/// feeds role i. /// feeds role i.
pub(crate) fn columns(&self) -> Vec<M1Field> { pub fn columns(&self) -> Vec<M1Field> {
self.entries.iter().map(|e| e.column).collect() self.entries.iter().map(|e| e.column).collect()
} }
/// True iff the strategy consumes the close column only — the one shape /// True iff the strategy consumes the close column only — the one shape
/// the synthetic walk (a single close series) can drive. /// 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] self.columns() == [M1Field::Close]
} }
} }
@@ -153,7 +147,7 @@ fn finish(mut entries: Vec<BindingEntry>) -> ResolvedBinding {
/// override wins over the name default; a role that is neither /// override wins over the name default; a role that is neither
/// vocabulary-named nor overridden is a refusal naming the vocabulary and the /// vocabulary-named nor overridden is a refusal naming the vocabulary and the
/// override remedy. /// override remedy.
pub(crate) fn resolve_binding( pub fn resolve_binding(
strategy: &str, strategy: &str,
roles: &[Role], roles: &[Role],
overrides: &BTreeMap<String, String>, overrides: &BTreeMap<String, String>,
@@ -229,7 +223,7 @@ fn first_duplicate_role(roles: &[Role]) -> Option<&str> {
/// fallback is a placeholder exactly like the probe's synthetic pip, so /// fallback is a placeholder exactly like the probe's synthetic pip, so
/// probing never refuses a blueprint the (strictly resolved) run path may /// probing never refuses a blueprint the (strictly resolved) run path may
/// still bind via campaign overrides. /// still bind via campaign overrides.
pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding { pub fn probe_binding(roles: &[Role]) -> ResolvedBinding {
let entries: Vec<BindingEntry> = roles let entries: Vec<BindingEntry> = roles
.iter() .iter()
.map(|role| BindingEntry { .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 /// The refusal for a multi-column binding on synthetic data (which generates
/// a close series only). Callers invoke it only when `!binding.close_only()`. /// 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 let beyond: Vec<&str> = binding
.entries() .entries()
.iter() .iter()
+161
View File
@@ -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<String> {
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 <SYMBOL>`'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<Vec<String>, 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"]
);
}
}
+669
View File
@@ -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<i64>, to_ms: Option<i64> },
}
/// 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<data_server::DataServer>,
symbol: String,
from_ms: Option<i64>,
to_ms: Option<i64>,
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<Box<dyn aura_engine::Source>> = 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<Box<dyn aura_engine::Source>> = 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<Box<dyn aura_engine::Source>> {
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<Box<dyn aura_engine::Source>> {
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}=<value>` — see `aura sweep <bp> --list-axes`"
),
BindError::KindMismatch { knob, expected, got } => format!(
"axis {knob}: expected {expected:?}, supplied {got:?}\
see `aura sweep <bp> --list-axes`"
),
BindError::UnknownKnob(msg) => msg.clone(),
BindError::DuplicateBinding(name) => format!(
"axis {name}: bound twice — each param takes exactly one axis — \
see `aura sweep <bp> --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<Scalar>)], 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<Scalar>)],
data: &DataSource,
env: &Env,
) -> Result<SweepFamily, String> {
// 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<Scalar>)], from: Timestamp, to: Timestamp, data: &DataSource,
env: &Env, binding: &ResolvedBinding,
) -> Result<(SweepFamily, Vec<usize>), 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<Scalar>)], 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<String> = 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 <blueprint.json>` 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<Box<dyn aura_engine::Source>> {
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<McFamily, String> {
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 <blueprint.json>`); 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<u64> = (1..=n_seeds).collect();
let base_point: Vec<Scalar> = 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)
}
+35
View File
@@ -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,
}
+135
View File
@@ -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<String>` 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<String> = 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<String> = BTreeSet::new();
let mut tap_drains: Vec<(String, ScalarKind, mpsc::Receiver<(Timestamp, Vec<Scalar>)>)> = Vec::new();
let declared: Vec<aura_engine::FlatTap> = 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<String> = tap_drains.iter().map(|(n, _, _)| n.clone()).collect();
if !tap_drains.is_empty() {
let tap_traces: Vec<ColumnarTrace> = tap_drains
.into_iter()
.map(|(name, kind, rx)| {
let rows: Vec<(Timestamp, Vec<Scalar>)> = 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 }
}
File diff suppressed because it is too large Load Diff
+234
View File
@@ -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<Family, RunnerError> {
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 <id>`'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<ReproduceReport, RunnerError> {
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<String> =
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=<N>` 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::<Vec<_>>()
.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 <family-id>`: 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(&reg, 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(&reg, 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(())
}
+756
View File
@@ -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<data_server::DataServer>,
/// 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<String, String>,
/// The campaign's cost model (#234), threaded into every member run via
/// `cost_nodes_for` (empty = zero costs, today's graph).
cost: Vec<CostSpec>,
}
impl<'a> DefaultMemberRunner<'a> {
pub fn new(
env: &'a Env,
server: Arc<data_server::DataServer>,
bindings: BTreeMap<String, String>,
cost: Vec<CostSpec>,
) -> 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<data_server::DataServer> {
Arc::clone(&self.server)
}
}
impl MemberRunner for DefaultMemberRunner<'_> {
fn run_member(
&self,
cell: &CellSpec,
params: &[(String, Scalar)],
window_ms: (i64, i64),
) -> Result<RunReport, MemberFault> {
// 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<String> = params.iter().map(|(n, _)| n.clone()).collect();
let overrides = raw_bound_overrides_of(&param_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<aura_registry::CellCoverage> {
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<TapChannel> {
// 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/<trace_name>/` — 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::<Vec<_>>()
.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 `<cell_key>/` (`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
/// `<cell_key>/<member_key>/` 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<String>, &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/<trace_name>/<cell_key>/` (0109, #201 d5; #224): a cell with a
/// nominee (walkforward/generalize/mc — selection-bearing pipelines) writes
/// its single trace directly at `<cell_key>/`, 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 `<cell_key>/<member_key>/` 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<data_server::DataServer>,
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 `<cell_key>/`
// (`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
// `<cell_key>/<member_key>/` 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<String> = 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<Scalar>)> = rx_eq.try_iter().collect();
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
let r_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_r.try_iter().collect();
let req_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_req.try_iter().collect();
let cost_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_cost.try_iter().collect();
let net_rows: Vec<(Timestamp, Vec<Scalar>)> = 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<ColumnarTrace> = routed
.iter()
.map(|&(tap, ch)| {
let rows: &[(Timestamp, Vec<Scalar>)] = 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 `<cell_key>/` — 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 <cell_key>/, 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);
}
}
+249
View File
@@ -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].<knob>` <-> [`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].<knob>`, 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<Vec<aura_research::CostSpec>, 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_research::RiskRegime>) -> 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<PrimitiveBuilder> {
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<String> = 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].<knob>` ->
/// `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<String> = [
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:?}");
}
}
@@ -36,6 +36,7 @@ fn node_crates_obey_the_c28_import_direction() {
// (crate, the intra-workspace [dependencies] C28 permits for its layer) // (crate, the intra-workspace [dependencies] C28 permits for its layer)
let allowed: &[(&str, &[&str])] = &[ let allowed: &[(&str, &[&str])] = &[
("aura-analysis", &[]), // foundation: no aura-* deps ("aura-analysis", &[]), // foundation: no aura-* deps
("aura-measurement", &["aura-core", "aura-analysis"]),
("aura-std", &["aura-core"]), ("aura-std", &["aura-core"]),
("aura-market", &["aura-core"]), ("aura-market", &["aura-core"]),
("aura-strategy", &["aura-core"]), ("aura-strategy", &["aura-core"]),