Compare commits
6 Commits
2cf4574e33
...
4ed6455b64
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ed6455b64 | |||
| 78e68e6002 | |||
| 9df217d868 | |||
| 5006766579 | |||
| 170c6c82dc | |||
| 5c2ac982bc |
Generated
+37
-2
@@ -160,19 +160,19 @@ dependencies = [
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-measurement",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"aura-runner",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"clap",
|
||||
"data-server",
|
||||
"libc",
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
@@ -239,6 +239,17 @@ dependencies = [
|
||||
"chrono-tz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-measurement"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-analysis",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-registry",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-registry"
|
||||
version = "0.1.0"
|
||||
@@ -265,6 +276,30 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-runner"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-backtest",
|
||||
"aura-campaign",
|
||||
"aura-composites",
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-ingest",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-vocabulary",
|
||||
"data-server",
|
||||
"libloading",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"toml",
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aura-std"
|
||||
version = "0.1.0"
|
||||
|
||||
@@ -13,6 +13,7 @@ members = [
|
||||
"crates/aura-std",
|
||||
"crates/aura-vocabulary",
|
||||
"crates/aura-market",
|
||||
"crates/aura-measurement",
|
||||
"crates/aura-strategy",
|
||||
"crates/aura-backtest",
|
||||
"crates/aura-engine",
|
||||
@@ -22,6 +23,7 @@ members = [
|
||||
"crates/aura-ingest",
|
||||
"crates/aura-registry",
|
||||
"crates/aura-research",
|
||||
"crates/aura-runner",
|
||||
"crates/aura-campaign",
|
||||
"crates/aura-bench",
|
||||
]
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
mod mc;
|
||||
mod metrics;
|
||||
mod position_management;
|
||||
pub mod scaffold;
|
||||
mod sim_broker;
|
||||
|
||||
// `assemble_mc` stays module-private (it was never `pub` in the pre-#291 engine
|
||||
@@ -23,6 +24,10 @@ pub use position_management::{
|
||||
ExitReason, FIELD_NAMES as PM_FIELD_NAMES, PositionManagement,
|
||||
RECORD_KINDS as PM_RECORD_KINDS, WIDTH as PM_WIDTH,
|
||||
};
|
||||
pub use scaffold::{
|
||||
fit_wf_ms_sizes, intersect_shared_window, point_from_params, wf_ms_sizes, WF_REAL_IS_NS,
|
||||
WF_REAL_OOS_NS, WF_REAL_STEP_NS,
|
||||
};
|
||||
pub use sim_broker::SimBroker;
|
||||
|
||||
/// The trading instantiation of the engine's metric-generic run record (C28
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,10 @@ pub struct CellSpec {
|
||||
/// them to its harness convention and runs the member over `cell.instrument`
|
||||
/// restricted to `window_ms` (inclusive epoch-ms, a sub-range of
|
||||
/// `cell.window_ms` — a walk-forward stage passes sub-windows).
|
||||
///
|
||||
/// The shipped default implementation is `aura_runner::DefaultMemberRunner`
|
||||
/// (the C28 assembly crate); this crate deliberately does not depend on it —
|
||||
/// consumers pass the runner in (#295).
|
||||
pub trait MemberRunner: Sync {
|
||||
fn run_member(
|
||||
&self,
|
||||
|
||||
+20
-11
@@ -12,9 +12,6 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
aura-core = { path = "../aura-core" }
|
||||
aura-engine = { path = "../aura-engine" }
|
||||
# aura-composites: the RiskExecutor / vol_stop composite-builders the
|
||||
# r-sma harness wires (kept out of aura-engine so the engine stays domain-free).
|
||||
aura-composites = { path = "../aura-composites" }
|
||||
aura-registry = { path = "../aura-registry" }
|
||||
aura-research = { path = "../aura-research" }
|
||||
# aura-campaign: campaign-execution semantics (preflight, cell loop, stage
|
||||
@@ -24,9 +21,15 @@ aura-campaign = { path = "../aura-campaign" }
|
||||
aura-std = { path = "../aura-std" }
|
||||
aura-strategy = { path = "../aura-strategy" }
|
||||
aura-backtest = { path = "../aura-backtest" }
|
||||
aura-analysis = { path = "../aura-analysis" }
|
||||
aura-vocabulary = { path = "../aura-vocabulary" }
|
||||
aura-ingest = { path = "../aura-ingest" }
|
||||
# aura-measurement: the C28 measurement rung's IC vocabulary + reduction
|
||||
# (#295), the shell re-exposes it as the `aura measure ic` verb.
|
||||
aura-measurement = { path = "../aura-measurement" }
|
||||
# aura-runner: the C28 assembly position (#295) — harness assembly, input
|
||||
# binding (C26), and the param<->config translators the shell wraps into
|
||||
# verbs.
|
||||
aura-runner = { path = "../aura-runner" }
|
||||
# data-server: the local M1 archive `aura run --real` streams from. Mirrors the
|
||||
# git line in crates/aura-ingest/Cargo.toml verbatim (same source of truth).
|
||||
data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" }
|
||||
@@ -44,13 +47,6 @@ serde_json = { workspace = true }
|
||||
# EPIPE. The standard vetted crate for signal disposition, already transitive in
|
||||
# Cargo.lock; admitted under the per-case dependency policy.
|
||||
libc = "0.2"
|
||||
# sha2: the run's topology_hash (#158) is the SHA256 of the canonical signal
|
||||
# blueprint serialization. A vetted RustCrypto crate (per-case C16 review,
|
||||
# SHA256 user-settled); lives in the research-side CLI, off the frozen engine
|
||||
# (invariant 8).
|
||||
sha2 = "0.10"
|
||||
libloading = "0.8"
|
||||
toml = "0.8"
|
||||
# clap: the vetted standard argument parser. Adopted (C16 per-case review) to
|
||||
# replace the hand-rolled argv parser + ten duplicated help surfaces with one
|
||||
# declarative source, giving scoped --help, --version, --flag=value, and
|
||||
@@ -64,3 +60,16 @@ clap = { version = "4", features = ["derive"] }
|
||||
# data-server dependency above; declared directly here so test code may
|
||||
# `use zip::...` (#250, no new dependency actually enters the build graph).
|
||||
zip = "2"
|
||||
# aura-composites: the RiskExecutor / vol_stop composite-builders — production
|
||||
# use moved to aura-runner (#295); the shell's own use is test-only
|
||||
# (#[cfg(test)] use aura_composites::StopRule in main.rs's ic_tests module),
|
||||
# so this rides the same dev-only-edge idiom as `aura-analysis`/`sha2` below.
|
||||
aura-composites = { path = "../aura-composites" }
|
||||
# aura-analysis: pearson_corr, used only by the ic_tests unit-test fixtures
|
||||
# (mod ic_tests in main.rs) — a test-only edge, not a production one.
|
||||
aura-analysis = { path = "../aura-analysis" }
|
||||
# sha2: production topology_hash / dylib_sha256 hashing now lives entirely in
|
||||
# aura-runner (#295); the shell's own use is test-only (tests/cli_run.rs
|
||||
# re-hashes a stored blueprint to check a golden), so this rides the same
|
||||
# dev-only-edge idiom as `zip`/`aura-analysis` above.
|
||||
sha2 = "0.10"
|
||||
|
||||
+30
-1113
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ use aura_engine::{
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
// `std_vocabulary` is now only reached through `crate::project::Env::resolve` in
|
||||
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in
|
||||
// production code; tests still exercise it directly.
|
||||
#[cfg(test)]
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
@@ -136,7 +136,7 @@ fn format_op_error(e: &OpError) -> String {
|
||||
/// a built `Composite` — or a `op N (kind): cause` message (a per-op fault,
|
||||
/// attributed by the retained op-kind list) / `finalize: cause` (a holistic fault
|
||||
/// past the last op).
|
||||
fn composite_from_str(doc: &str, env: &crate::project::Env) -> Result<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 labels: Vec<&'static str> = docs.iter().map(OpDoc::kind_label).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
|
||||
/// 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)?;
|
||||
blueprint_to_json(&composite).map_err(|e| format!("serialize error: {e:?}"))
|
||||
}
|
||||
|
||||
/// `aura graph build`: read the op-list document from stdin, build, and print the
|
||||
/// blueprint to stdout — or the cause to stderr and exit non-zero.
|
||||
pub fn build_cmd(env: &crate::project::Env) {
|
||||
pub fn build_cmd(env: &aura_runner::project::Env) {
|
||||
use std::io::Read;
|
||||
let mut doc = String::new();
|
||||
if let Err(e) = std::io::stdin().read_to_string(&mut doc) {
|
||||
@@ -192,7 +192,7 @@ pub fn build_cmd(env: &crate::project::Env) {
|
||||
/// `aura graph introspect --node <T>`: a type's ports + kinds + param paths,
|
||||
/// read off the pre-build schema (no graph built). `Err` if `T` is not in the
|
||||
/// closed vocabulary.
|
||||
pub fn introspect_node(type_id: &str, env: &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 schema = builder.schema();
|
||||
let mut out = format!("{}\n", builder.label());
|
||||
@@ -210,7 +210,7 @@ pub fn introspect_node(type_id: &str, env: &crate::project::Env) -> Result<Strin
|
||||
|
||||
/// `aura graph introspect --unwired`: the still-open interior slots of a partial
|
||||
/// op-list document, by-identifier (applies the ops, does NOT finalize).
|
||||
pub fn introspect_unwired(doc: &str, env: &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 resolver = |t: &str| env.resolve(t);
|
||||
let mut session = GraphSession::new("introspect", &resolver);
|
||||
@@ -229,7 +229,7 @@ pub fn introspect_unwired(doc: &str, env: &crate::project::Env) -> Result<String
|
||||
/// group must be set; zero or more than one is the usage error (exit 2). The id
|
||||
/// group is `--content-id [FILE]` and/or `--identity-id` — the two id flags may
|
||||
/// combine (one build, both ids, content id first).
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &crate::project::Env) {
|
||||
pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project::Env) {
|
||||
let count = cmd.vocabulary as usize
|
||||
+ cmd.node.is_some() 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
|
||||
/// build` path (`composite_from_str`, over `OpError`) call this same helper,
|
||||
/// so the tier texts cannot drift between the two error families.
|
||||
fn tier_hint_for_type_id(type_id: &str, env: &crate::project::Env) -> Option<String> {
|
||||
fn tier_hint_for_type_id(type_id: &str, env: &aura_runner::project::Env) -> Option<String> {
|
||||
if !type_id.contains("::") {
|
||||
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
|
||||
/// 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 {
|
||||
LoadError::UnknownNodeType(t) => tier_hint_for_type_id(t, env),
|
||||
_ => None,
|
||||
@@ -447,7 +447,7 @@ pub(crate) fn unresolved_namespace_hint(e: &LoadError, env: &crate::project::Env
|
||||
/// `Ok(())` means the document loaded cleanly; callers that only need the
|
||||
/// validation (not the `Composite`) re-parse `doc` themselves afterward
|
||||
/// (`blueprint_from_json` is cheap and infallible at that point).
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Result<(), String> {
|
||||
pub(crate) fn blueprint_slot_prose(doc: &str, env: &aura_runner::project::Env) -> Result<(), String> {
|
||||
if matches!(serde_json::from_str::<serde_json::Value>(doc), Ok(serde_json::Value::Array(_))) {
|
||||
return Err(
|
||||
"this is an op-script (an op array), not a built blueprint — run \
|
||||
@@ -469,7 +469,7 @@ pub(crate) fn blueprint_slot_prose(doc: &str, env: &crate::project::Env) -> Resu
|
||||
/// envelope (a JSON object: `format_version` + `blueprint`) OR a construction
|
||||
/// op-list (a JSON array) — shape-discriminated on the top-level JSON type,
|
||||
/// each canonicalized by its own rules.
|
||||
pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Result<Composite, String> {
|
||||
pub(crate) fn composite_from_any(text: &str, env: &aura_runner::project::Env) -> Result<Composite, String> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| format!("invalid document: {e}"))?;
|
||||
match value {
|
||||
@@ -488,22 +488,22 @@ pub(crate) fn composite_from_any(text: &str, env: &crate::project::Env) -> Resul
|
||||
|
||||
/// Resolve a blueprint document's bytes from a file path or a 64-hex content
|
||||
/// id in the project store (the campaign-run target-addressing convention).
|
||||
/// The id shape is `crate::campaign_run::is_content_id` — the same predicate
|
||||
/// The id shape is `aura_runner::axes::is_content_id` — the same predicate
|
||||
/// `campaign run`'s target resolution uses, so the two FILE-or-id surfaces
|
||||
/// cannot drift apart on what counts as a store address.
|
||||
fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<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
|
||||
// fields stay bare-only.
|
||||
let target = target
|
||||
.strip_prefix("content:")
|
||||
.filter(|t| crate::campaign_run::is_content_id(t))
|
||||
.filter(|t| aura_runner::axes::is_content_id(t))
|
||||
.unwrap_or(target);
|
||||
let path = Path::new(target);
|
||||
if path.is_file() {
|
||||
return std::fs::read_to_string(path)
|
||||
.map_err(|e| format!("cannot read {}: {e}", path.display()));
|
||||
}
|
||||
if crate::campaign_run::is_content_id(target) {
|
||||
if aura_runner::axes::is_content_id(target) {
|
||||
return match env.registry().get_blueprint(target) {
|
||||
Ok(Some(json)) => Ok(json),
|
||||
Ok(None) => Err(format!("no blueprint {target} in the project store")),
|
||||
@@ -518,7 +518,7 @@ fn resolve_blueprint_text(target: &str, env: &crate::project::Env) -> Result<Str
|
||||
/// campaign-axis namespace `validate_campaign_refs` checks axes against. The
|
||||
/// kind renders via `ScalarKind`'s `Debug` (`I64`/`F64`/`Bool`/`Timestamp`),
|
||||
/// the same form `introspect --node` already uses for param kinds.
|
||||
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 _;
|
||||
let text = resolve_blueprint_text(target, env)?;
|
||||
// Shape-discriminated like file-mode --content-id: an op-script (array)
|
||||
@@ -535,7 +535,7 @@ fn params_lines(target: &str, env: &crate::project::Env) -> Result<String, Strin
|
||||
/// the project vocabulary, canonicalize, content-address, and store — the
|
||||
/// `process register` pattern, printing the store path so the trail is
|
||||
/// followable. Prose to stderr + exit 1 on any refusal.
|
||||
pub fn register_cmd(file: &Path, env: &crate::project::Env) {
|
||||
pub fn register_cmd(file: &Path, env: &aura_runner::project::Env) {
|
||||
match register_blueprint(file, env) {
|
||||
Ok(line) => println!("{line}"),
|
||||
Err(m) => {
|
||||
@@ -545,7 +545,7 @@ pub fn register_cmd(file: &Path, env: &crate::project::Env) {
|
||||
}
|
||||
}
|
||||
|
||||
fn register_blueprint(file: &Path, env: &crate::project::Env) -> Result<String, String> {
|
||||
fn register_blueprint(file: &Path, env: &aura_runner::project::Env) -> Result<String, String> {
|
||||
let text = std::fs::read_to_string(file)
|
||||
.map_err(|e| format!("cannot read {}: {e}", file.display()))?;
|
||||
// Shape-discriminated like file-mode --content-id (#202): either shape
|
||||
@@ -605,7 +605,7 @@ mod tests {
|
||||
{"op":"connect","from":"sub.value","to":"bias.signal"},
|
||||
{"op":"expose","from":"bias.bias","as":"bias"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("valid document builds");
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("valid document builds");
|
||||
assert!(json.contains("\"format_version\":1"), "emits the #155 envelope: {json}");
|
||||
assert!(json.contains("\"SMA\""), "carries the SMA nodes: {json}");
|
||||
}
|
||||
@@ -621,14 +621,14 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series"]},
|
||||
{"op":"expose","from":"fast.value","as":"out"}
|
||||
]"#;
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("name-keyed add builds");
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("name-keyed add builds");
|
||||
assert!(json.contains("\"name\":\"fast\""), "the blueprint carries the node name: {json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_from_str_reports_unknown_node_at_its_op_index() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast"},{"op":"add","type":"Nope"}]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 1 (add): unknown node type \"Nope\"");
|
||||
}
|
||||
|
||||
@@ -643,7 +643,7 @@ mod tests {
|
||||
{"op":"feed","role":"price","into":["fast.series","slow.series"]},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert!(err.starts_with("finalize: "), "finalize fault, got: {err}");
|
||||
assert!(err.contains("sub.rhs") && err.contains("is unconnected"),
|
||||
"names the unconnected slot by-identifier, got: {err}");
|
||||
@@ -652,11 +652,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn introspect_node_lists_ports_kinds_and_params() {
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("series"), "lists the input port: {out}");
|
||||
assert!(out.contains("value"), "lists the output field: {out}");
|
||||
assert!(out.contains("length"), "lists the param path: {out}");
|
||||
assert!(super::introspect_node("Nope", &crate::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
assert!(super::introspect_node("Nope", &aura_runner::project::Env::std()).is_err(), "rejects an unknown type");
|
||||
}
|
||||
|
||||
/// A bind whose value-kind mismatches the param reads as prose, like the connect
|
||||
@@ -664,7 +664,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_bad_bind_kind_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"length":{"F64":2.0}}}]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): param fast.length expects I64 but got F64");
|
||||
}
|
||||
|
||||
@@ -673,7 +673,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_from_str_unknown_bind_param_reads_as_prose() {
|
||||
let doc = r#"[{"op":"add","type":"SMA","name":"fast","bind":{"window":{"I64":2}}}]"#;
|
||||
let err = super::build_from_str(doc, &crate::project::Env::std()).unwrap_err();
|
||||
let err = super::build_from_str(doc, &aura_runner::project::Env::std()).unwrap_err();
|
||||
assert_eq!(err, "op 0 (add): node fast has no param \"window\"");
|
||||
}
|
||||
|
||||
@@ -694,7 +694,7 @@ mod tests {
|
||||
/// does not have to read source to learn the `{"I64": <v>}` wrapping.
|
||||
#[test]
|
||||
fn introspect_node_shows_the_bind_form() {
|
||||
let out = super::introspect_node("SMA", &crate::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
let out = super::introspect_node("SMA", &aura_runner::project::Env::std()).expect("SMA is in the vocabulary");
|
||||
assert!(out.contains("(bind {\"I64\": <v>})"), "shows the bind-value form: {out}");
|
||||
}
|
||||
|
||||
@@ -705,7 +705,7 @@ mod tests {
|
||||
{"op":"add","type":"Sub"},
|
||||
{"op":"connect","from":"fast.value","to":"sub.lhs"}
|
||||
]"#;
|
||||
let out = super::introspect_unwired(doc, &crate::project::Env::std()).expect("partial document introspects");
|
||||
let out = super::introspect_unwired(doc, &aura_runner::project::Env::std()).expect("partial document introspects");
|
||||
assert!(out.contains("sub.rhs"), "sub.rhs is still open: {out}");
|
||||
assert!(out.contains("fast.series"), "fast.series is still open: {out}");
|
||||
assert!(!out.contains("sub.lhs"), "sub.lhs is covered: {out}");
|
||||
@@ -732,7 +732,7 @@ mod tests {
|
||||
assert_eq!(docs[7].kind_label(), "tap");
|
||||
// and the built blueprint carries the tap (an un-tapped composite emits no
|
||||
// `taps` key, so its presence is the proof the op threaded through)
|
||||
let json = super::build_from_str(doc, &crate::project::Env::std()).expect("tap op-doc builds");
|
||||
let json = super::build_from_str(doc, &aura_runner::project::Env::std()).expect("tap op-doc builds");
|
||||
assert!(json.contains("\"taps\""), "blueprint carries the taps section: {json}");
|
||||
assert!(json.contains("fast_ma"), "the tap names the wire: {json}");
|
||||
}
|
||||
|
||||
+206
-3282
File diff suppressed because it is too large
Load Diff
@@ -234,7 +234,7 @@ mod tests {
|
||||
/// against the prototype.
|
||||
#[test]
|
||||
fn render_html_is_self_contained_and_embeds_the_model() {
|
||||
let env = crate::project::Env::std();
|
||||
let env = aura_runner::project::Env::std();
|
||||
let bp = aura_engine::blueprint_from_json(
|
||||
include_str!("../examples/r_sma.json"),
|
||||
&|t| env.resolve(t),
|
||||
|
||||
@@ -13,7 +13,7 @@ use aura_research::{
|
||||
};
|
||||
use aura_registry::RefFault;
|
||||
|
||||
use crate::project::Env;
|
||||
use aura_runner::project::Env;
|
||||
|
||||
#[derive(clap::Args)]
|
||||
pub struct ProcessCmd {
|
||||
|
||||
@@ -53,13 +53,15 @@ pub(crate) struct SugarInvocation<'a> {
|
||||
/// by construction, so the net channel is never producible there; requesting
|
||||
/// it anyway would make `campaign_run`'s executor print an unreachable "add a
|
||||
/// cost block to the campaign document" remedy (#240). The exclusion reuses
|
||||
/// `campaign_run`'s own tap/channel classification (`tap_channel`/
|
||||
/// `aura_runner::runner`'s own tap/channel classification (`tap_channel`/
|
||||
/// `TapChannel::Net`) rather than duplicating the net-tap name here.
|
||||
fn persist_taps_from(trace: bool) -> Vec<String> {
|
||||
if trace {
|
||||
tap_vocabulary()
|
||||
.iter()
|
||||
.filter(|t| crate::campaign_run::tap_channel(t) != Some(crate::campaign_run::TapChannel::Net))
|
||||
.filter(|t| {
|
||||
aura_runner::runner::tap_channel(t) != Some(aura_runner::runner::TapChannel::Net)
|
||||
})
|
||||
.map(|t| t.to_string())
|
||||
.collect()
|
||||
} else {
|
||||
@@ -285,8 +287,8 @@ pub(crate) fn register_generated_g(
|
||||
/// (campaign_run.rs) — the referential shape the dispatch-boundary name
|
||||
/// preflight (main.rs) does not catch: a subset of axes that leaves an open
|
||||
/// param unbound (probe P3). Probe P3's space is the REOPENED one (#246): the
|
||||
/// same `raw_bound_overrides_of` derivation `CliMemberRunner::run_member` uses
|
||||
/// per member (campaign_run.rs) — a bound-param axis passes this
|
||||
/// same `raw_bound_overrides_of` derivation `DefaultMemberRunner::run_member`
|
||||
/// uses per member (aura-runner) — a bound-param axis passes this
|
||||
/// executable-shape preflight exactly like an already-open one; an axis
|
||||
/// matching neither space still fails `bind_axes`'s own named check below.
|
||||
/// `probe_params` is any one grid value per axis (checks NAME
|
||||
@@ -298,7 +300,7 @@ fn validate_before_register(
|
||||
campaign: &CampaignDoc,
|
||||
blueprint_canonical: &str,
|
||||
probe_params: &[(String, Scalar)],
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<(), String> {
|
||||
let doc_faults = aura_research::validate_campaign(campaign);
|
||||
if !doc_faults.is_empty() {
|
||||
@@ -324,10 +326,10 @@ fn validate_before_register(
|
||||
let raw_signal = aura_engine::blueprint_from_json(blueprint_canonical, &|t| env.resolve(t))
|
||||
.expect("a generated sugar campaign's strategy ref reloads its own canonical bytes");
|
||||
let param_names: Vec<String> = probe_params.iter().map(|(n, _)| n.clone()).collect();
|
||||
let overrides = crate::campaign_run::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let overrides = aura_runner::axes::raw_bound_overrides_of(¶m_names, &raw_space, &raw_signal);
|
||||
let space = crate::blueprint_axis_probe_reopened(blueprint_canonical, env, &overrides).param_space();
|
||||
let strategy_id = content_id_of(blueprint_canonical);
|
||||
crate::campaign_run::bind_axes(&space, &strategy_id, probe_params)
|
||||
aura_runner::axes::bind_axes(&space, &strategy_id, probe_params)
|
||||
.map(|_| ())
|
||||
.map_err(|f| crate::campaign_run::exec_fault_prose(&aura_campaign::ExecFault::Member(f)))
|
||||
}
|
||||
@@ -338,7 +340,7 @@ fn validate_before_register(
|
||||
/// one campaign executor.
|
||||
pub(crate) fn run_sweep_sugar(
|
||||
inv: &SugarInvocation,
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_sweep(inv)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
@@ -384,7 +386,7 @@ fn cross_instrument_members(
|
||||
pub(crate) fn run_generalize_sugar(
|
||||
inv: &SugarInvocation,
|
||||
metric: &str,
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_generalize(inv, metric)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
@@ -510,7 +512,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
metric: &str,
|
||||
w: WfWindows,
|
||||
select: SelectRule,
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_walkforward(inv, metric, w, select)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
@@ -563,7 +565,7 @@ pub(crate) fn run_walkforward_sugar(
|
||||
// always `None`. `run`/`mc --trace` stay refused by design (#224), not by
|
||||
// this tail being skipped.
|
||||
if let Some(trace_name) = &run.outcome.record.trace_name {
|
||||
crate::campaign_run::persist_campaign_traces(
|
||||
aura_runner::runner::persist_campaign_traces(
|
||||
trace_name,
|
||||
&run.campaign.presentation.persist_taps,
|
||||
&run.outcome,
|
||||
@@ -674,7 +676,7 @@ pub(crate) fn run_mc_sugar(
|
||||
metric: &str,
|
||||
w: WfWindows,
|
||||
mc: McKnobs,
|
||||
env: &crate::project::Env,
|
||||
env: &aura_runner::project::Env,
|
||||
) -> Result<usize, String> {
|
||||
let generated = translate_mc(inv, metric, w, mc)?;
|
||||
let probe_params = probe_params_from(inv.axes);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
[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 }
|
||||
|
||||
# The #147 registry-dispatch acceptance tests (tests/registry_dispatch.rs)
|
||||
# exercise the generic deflation machinery over the IC vocabulary — pure
|
||||
# library properties relocated from the shell with #295. Dev-only edges,
|
||||
# C28-layering-exempt.
|
||||
[dev-dependencies]
|
||||
aura-engine = { path = "../aura-engine" }
|
||||
aura-registry = { path = "../aura-registry" }
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -0,0 +1,101 @@
|
||||
//! #147 acceptance at the library boundary: the registry's generic
|
||||
//! deflation machinery dispatches to the IC vocabulary's OWN permutation
|
||||
//! null, deterministically (C1), and the C10 wall refuses cross-vocabulary
|
||||
//! metric names both ways. Relocated from the shell's test module with #295
|
||||
//! (these are pure library properties — a library-only consumer must be able
|
||||
//! to run them; the registry/engine edges are dev-only, layering-exempt).
|
||||
|
||||
use aura_measurement::IcMetrics;
|
||||
|
||||
fn ic_report(sigs: Vec<f64>, frs: Vec<f64>) -> aura_engine::RunReport<IcMetrics> {
|
||||
aura_engine::RunReport {
|
||||
manifest: aura_engine::RunManifest {
|
||||
commit: "c".to_string(),
|
||||
params: vec![("p".to_string(), aura_core::Scalar::f64(1.0))],
|
||||
defaults: vec![],
|
||||
window: (aura_core::Timestamp(1), aura_core::Timestamp(2)),
|
||||
seed: 0,
|
||||
broker: "b".to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
},
|
||||
metrics: IcMetrics {
|
||||
information_coefficient: aura_analysis::pearson_corr(&sigs, &frs),
|
||||
sigs,
|
||||
frs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn ic_family(members: Vec<aura_engine::RunReport<IcMetrics>>) -> aura_engine::SweepFamily<IcMetrics> {
|
||||
aura_engine::SweepFamily {
|
||||
space: vec![],
|
||||
points: members
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, report)| aura_engine::SweepPoint {
|
||||
params: vec![aura_core::Cell::from_f64(i as f64)],
|
||||
report,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// #147 acceptance: the registry's deflation dispatches to the IC
|
||||
/// vocabulary's OWN permutation null — a genuinely predictive family
|
||||
/// deflates to a low overfit probability, a noise family to a high one.
|
||||
#[test]
|
||||
fn ic_family_deflation_dispatches_the_permutation_null() {
|
||||
let ramp: Vec<f64> = (0..24).map(|i| i as f64).collect();
|
||||
let engineered = ic_family(vec![
|
||||
ic_report(ramp.clone(), ramp.clone()), // corr = 1.0: a real edge
|
||||
ic_report(ramp.clone(), ramp.iter().rev().cloned().collect()), // corr = -1.0
|
||||
]);
|
||||
let (winner, sel) =
|
||||
aura_registry::optimize_deflated(&engineered, "information_coefficient", 500, 5, 0)
|
||||
.expect("ic family deflates");
|
||||
assert!((winner.report.metrics.information_coefficient - 1.0).abs() < 1e-12);
|
||||
let p = sel.overfit_probability.expect("resampling-null arm sets a probability");
|
||||
assert!(p < 0.05, "perfectly predictive family must not look like overfit: p={p}");
|
||||
|
||||
// orthogonal signal — IC exactly 0.0; permuted draws beat it ~half the time.
|
||||
let noise = ic_family(vec![ic_report(
|
||||
vec![0.03, 0.01, 0.04, 0.02, 0.05, 0.00, 0.06, -0.01],
|
||||
vec![0.01, 0.02, 0.03, 0.04, -0.04, -0.03, -0.02, -0.01],
|
||||
)]);
|
||||
let (_, sel) =
|
||||
aura_registry::optimize_deflated(&noise, "information_coefficient", 500, 5, 0)
|
||||
.expect("noise family deflates");
|
||||
let p = sel.overfit_probability.expect("probability present");
|
||||
assert!(p > 0.1, "a noise family must carry a high overfit probability: p={p}");
|
||||
}
|
||||
|
||||
/// Same seed → identical selection provenance (C1 through the generic path).
|
||||
#[test]
|
||||
fn ic_family_deflation_is_deterministic_given_seed() {
|
||||
let ramp: Vec<f64> = (0..16).map(|i| i as f64).collect();
|
||||
let fam = ic_family(vec![ic_report(ramp.clone(), ramp)]);
|
||||
let a = aura_registry::optimize_deflated(&fam, "information_coefficient", 300, 5, 7)
|
||||
.expect("deflate a");
|
||||
let b = aura_registry::optimize_deflated(&fam, "information_coefficient", 300, 5, 7)
|
||||
.expect("deflate b");
|
||||
assert_eq!(a.1, b.1, "same seed must yield identical FamilySelection");
|
||||
}
|
||||
|
||||
/// C10 guard: cross-vocabulary names are refused, and the refusal names
|
||||
/// the family's REAL vocabulary.
|
||||
#[test]
|
||||
fn cross_vocabulary_names_are_refused_both_ways() {
|
||||
let ramp: Vec<f64> = (0..8).map(|i| i as f64).collect();
|
||||
let fam = ic_family(vec![ic_report(ramp.clone(), ramp)]);
|
||||
let err = aura_registry::optimize_deflated(&fam, "sqn", 100, 5, 0).unwrap_err();
|
||||
let prose = err.to_string();
|
||||
assert!(
|
||||
prose.contains("unknown metric 'sqn'") && prose.contains("information_coefficient"),
|
||||
"refusal must name the IC vocabulary: {prose}"
|
||||
);
|
||||
// and the R side still refuses the IC name with ITS roster:
|
||||
assert!(aura_registry::check_r_metric("information_coefficient").is_err());
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
[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"
|
||||
|
||||
[dev-dependencies]
|
||||
# zip: hand-packs a tiny synthetic M1 archive for the library-reachability E2E
|
||||
# (tests/world_member_run_e2e.rs) — already transitive via the data-server
|
||||
# dependency above (a normal, non-dev dependency there); declared directly
|
||||
# here so test code may `use zip::...` (mirrors aura-ingest's Cargo.toml and
|
||||
# aura-cli's tests/common/mod.rs, no new dependency actually enters the build
|
||||
# graph).
|
||||
zip = "2"
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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", ¶ms).unwrap();
|
||||
assert_eq!(point, vec![Scalar::i64(14).cell()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A raw campaign axis that matches no open param of the wrapped
|
||||
/// strategy is a named Bind fault naming the axis, never a silently
|
||||
/// dropped binding.
|
||||
fn bind_axes_refuses_an_unmatched_axis() {
|
||||
let space = vec![spec("sma.length")];
|
||||
let params = vec![("period".to_string(), Scalar::i64(14))];
|
||||
let err = bind_axes(&space, "strat", ¶ms).unwrap_err();
|
||||
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
|
||||
assert!(m.contains("period") && m.contains("matches no open param"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// A raw axis matching MORE THAN ONE wrapped param is a named Bind fault
|
||||
/// that lists every ambiguous candidate, never a silent first-match pick.
|
||||
fn bind_axes_refuses_an_ambiguous_axis() {
|
||||
let space = vec![spec("fast.length"), spec("slow.length")];
|
||||
let params = vec![("length".to_string(), Scalar::i64(14))];
|
||||
let err = bind_axes(&space, "strat", ¶ms).unwrap_err();
|
||||
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
|
||||
assert!(m.contains("ambiguous") && m.contains("fast.length") && m.contains("slow.length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// An open wrapped param left unbound by any campaign axis is a named
|
||||
/// Bind fault naming the param, not an implicit default.
|
||||
fn bind_axes_refuses_an_uncovered_param() {
|
||||
let space = vec![spec("sma.length"), spec("sma.threshold")];
|
||||
let params = vec![("length".to_string(), Scalar::i64(14))];
|
||||
let err = bind_axes(&space, "strat", ¶ms).unwrap_err();
|
||||
let MemberFault::Bind(m) = err else { panic!("expected Bind, got {err:?}") };
|
||||
// The refusal speaks the RAW campaign-axis namespace (what the doc
|
||||
// must say), with the wrapped bind-time path as a parenthetical (#203).
|
||||
assert!(
|
||||
m.contains("open param \"threshold\"") && m.contains("(wrapped path: sma.threshold)"),
|
||||
"raw-namespace prose expected: {m}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// #246: `raw_bound_overrides_of` names exactly the raw axis names that
|
||||
/// miss the un-reopened wrapped OPEN space but match a BOUND param of the
|
||||
/// raw signal (in strategy/RAW coordinates, per its own doc comment) — an
|
||||
/// axis already covered by the open space is not re-flagged as an
|
||||
/// override, and an axis matching neither space is silently skipped here
|
||||
/// (bind_axes surfaces THAT case as its own named fault once the
|
||||
/// reopened space is built downstream).
|
||||
fn raw_bound_overrides_of_selects_axes_naming_a_bound_param() {
|
||||
use aura_strategy::Bias;
|
||||
use aura_std::Sma;
|
||||
let raw_signal = Composite::new(
|
||||
"fixture",
|
||||
vec![
|
||||
Sma::builder().named("fast").bind("length", Scalar::i64(2)).into(),
|
||||
Bias::builder().named("exp").into(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
// The un-reopened wrapped OPEN space carries one extra wrap segment
|
||||
// (the CLI's own outer wrap, mirroring `blueprint_axis_probe`'s
|
||||
// output shape) ahead of the composite's own "exp.scale" path.
|
||||
let open_space = vec![ParamSpec { name: "wrap.exp.scale".to_string(), kind: ScalarKind::F64 }];
|
||||
let param_names =
|
||||
vec!["exp.scale".to_string(), "fast.length".to_string(), "nope.thing".to_string()];
|
||||
let overrides = raw_bound_overrides_of(¶m_names, &open_space, &raw_signal);
|
||||
assert_eq!(
|
||||
overrides,
|
||||
vec!["fast.length".to_string()],
|
||||
"only the bound-param axis is selected; the already-open axis and the \
|
||||
unmatched axis are excluded"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,6 @@
|
||||
//! root roles (`wrap_r`) — in the same canonical `M1Field` declaration order,
|
||||
//! so the two halves of the old single-price weld can no longer drift apart
|
||||
//! (C4 merge tie-break = role declaration order = source open order).
|
||||
//!
|
||||
//! `dead_code` is allowed module-wide only while nothing outside this
|
||||
//! module consumes it; the allow is scaffolding to REMOVE with the first
|
||||
//! consumer (the wrap seam), at which point genuinely unused items must
|
||||
//! surface again.
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
@@ -35,7 +29,7 @@ pub(crate) const COLUMN_VOCABULARY: &str =
|
||||
/// common case at the wrap seam, so declaring a port for a vocabulary-named
|
||||
/// role needs no per-build leak; only an override-renamed (non-vocabulary)
|
||||
/// role falls back to leaking its name.
|
||||
pub(crate) fn static_role_name(name: &str) -> Option<&'static str> {
|
||||
pub fn static_role_name(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"open" => Some("open"),
|
||||
"high" => Some("high"),
|
||||
@@ -76,7 +70,7 @@ pub(crate) fn column_name(column: M1Field) -> &'static str {
|
||||
|
||||
/// The scalar kind a column streams (mirrors `aura_ingest::decode`: Volume is
|
||||
/// the one `i64` column; everything else is `f64`).
|
||||
pub(crate) fn column_kind(column: M1Field) -> ScalarKind {
|
||||
pub fn column_kind(column: M1Field) -> ScalarKind {
|
||||
match column {
|
||||
M1Field::Volume => ScalarKind::I64,
|
||||
_ => ScalarKind::F64,
|
||||
@@ -100,7 +94,7 @@ fn rank(column: M1Field) -> usize {
|
||||
/// name), the archive column it binds, and whether it feeds the signal
|
||||
/// (`false` only for the synthesized broker/executor-only close entry).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct BindingEntry {
|
||||
pub struct BindingEntry {
|
||||
pub role: String,
|
||||
pub column: M1Field,
|
||||
pub feeds_signal: bool,
|
||||
@@ -110,25 +104,25 @@ pub(crate) struct BindingEntry {
|
||||
/// openers (column opening) share: entries in canonical column order, with a
|
||||
/// close entry guaranteed (the broker/executor price feed).
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ResolvedBinding {
|
||||
pub struct ResolvedBinding {
|
||||
entries: Vec<BindingEntry>,
|
||||
}
|
||||
|
||||
impl ResolvedBinding {
|
||||
pub(crate) fn entries(&self) -> &[BindingEntry] {
|
||||
pub fn entries(&self) -> &[BindingEntry] {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// The columns to open, one per entry, in canonical order — exactly the
|
||||
/// order the entries' roles are declared in, so source index i always
|
||||
/// feeds role i.
|
||||
pub(crate) fn columns(&self) -> Vec<M1Field> {
|
||||
pub fn columns(&self) -> Vec<M1Field> {
|
||||
self.entries.iter().map(|e| e.column).collect()
|
||||
}
|
||||
|
||||
/// True iff the strategy consumes the close column only — the one shape
|
||||
/// the synthetic walk (a single close series) can drive.
|
||||
pub(crate) fn close_only(&self) -> bool {
|
||||
pub fn close_only(&self) -> bool {
|
||||
self.columns() == [M1Field::Close]
|
||||
}
|
||||
}
|
||||
@@ -153,7 +147,7 @@ fn finish(mut entries: Vec<BindingEntry>) -> ResolvedBinding {
|
||||
/// override wins over the name default; a role that is neither
|
||||
/// vocabulary-named nor overridden is a refusal naming the vocabulary and the
|
||||
/// override remedy.
|
||||
pub(crate) fn resolve_binding(
|
||||
pub fn resolve_binding(
|
||||
strategy: &str,
|
||||
roles: &[Role],
|
||||
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
|
||||
/// probing never refuses a blueprint the (strictly resolved) run path may
|
||||
/// still bind via campaign overrides.
|
||||
pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding {
|
||||
pub fn probe_binding(roles: &[Role]) -> ResolvedBinding {
|
||||
let entries: Vec<BindingEntry> = roles
|
||||
.iter()
|
||||
.map(|role| BindingEntry {
|
||||
@@ -243,7 +237,7 @@ pub(crate) fn probe_binding(roles: &[Role]) -> ResolvedBinding {
|
||||
|
||||
/// The refusal for a multi-column binding on synthetic data (which generates
|
||||
/// a close series only). Callers invoke it only when `!binding.close_only()`.
|
||||
pub(crate) fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String {
|
||||
pub fn synthetic_refusal(strategy: &str, binding: &ResolvedBinding) -> String {
|
||||
let beyond: Vec<&str> = binding
|
||||
.entries()
|
||||
.iter()
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,360 @@
|
||||
//! 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.
|
||||
///
|
||||
/// `id` is tried first against the derived `family_id` handle (`"{family}-{run}"`,
|
||||
/// exact match, first precedence). If that misses, it falls back to the bare
|
||||
/// enumeration `family` name (`FamilyRunRecord::family` / `Registry::
|
||||
/// load_family_members()`'s natural list-then-reproduce workflow, #298): a name
|
||||
/// that names exactly one stored run resolves to it, but a name with more than
|
||||
/// one stored run is ambiguous and refused rather than guessed, listing the
|
||||
/// candidate handles.
|
||||
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}"),
|
||||
})?;
|
||||
let families = group_families(members);
|
||||
if let Some(family) = families.iter().find(|f| f.id == id) {
|
||||
return Ok(family.clone());
|
||||
}
|
||||
let by_name: Vec<&Family> =
|
||||
families.iter().filter(|f| f.members.iter().any(|m| m.family == id)).collect();
|
||||
match by_name.as_slice() {
|
||||
[only] => Ok((*only).clone()),
|
||||
// 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).
|
||||
[] => Err(RunnerError { exit_code: 1, message: format!("no such family '{id}'") }),
|
||||
many => {
|
||||
let candidates =
|
||||
many.iter().map(|f| f.id.clone()).collect::<Vec<_>>().join(", ");
|
||||
Err(RunnerError {
|
||||
exit_code: 1,
|
||||
message: format!("ambiguous family '{id}': candidates {candidates}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(®, id)?;
|
||||
// Reconstruct the DataSource the family was minted over: `None` instrument
|
||||
// (every synthetic-family member, pre-#229 lines) stays the built-in synthetic
|
||||
// stream; a real instrument reopens the same local archive `--real` runs use,
|
||||
// via the same named-data refusal (exit 1) on a missing sidecar/archive.
|
||||
let data = match family.members.first().and_then(|m| m.report.manifest.instrument.clone()) {
|
||||
None => DataSource::Synthetic,
|
||||
Some(symbol) => {
|
||||
DataSource::from_choice(DataChoice::Real { symbol, from_ms: None, to_ms: None }, env)
|
||||
}
|
||||
};
|
||||
let rep = reproduce_family_in(®, id, &data, env)?;
|
||||
let total = rep.outcomes.len();
|
||||
let ok = rep.outcomes.iter().filter(|(_, b)| *b).count();
|
||||
for (label, identical) in &rep.outcomes {
|
||||
let verdict = if *identical { "bit-identical" } else { "DIVERGED" };
|
||||
println!("{id} member {label} reproduced: {verdict}");
|
||||
}
|
||||
println!("reproduced {ok}/{total} members bit-identically");
|
||||
if ok != total {
|
||||
return Err(RunnerError { exit_code: 1, message: String::new() });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use aura_backtest::{RunMetrics, RunReport};
|
||||
use aura_core::{Scalar, Timestamp};
|
||||
use aura_engine::RunManifest;
|
||||
use aura_registry::{FamilyKind, Registry};
|
||||
|
||||
use super::load_family;
|
||||
|
||||
/// A registry over a fresh per-test directory (the #258 tag-keyed pattern:
|
||||
/// fixed name under the build-tree tmp anchor, pre-create wipe).
|
||||
fn temp_registry(tag: &str) -> Registry {
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
.join(format!("aura-runner-reproduce-{tag}"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("create temp registry dir");
|
||||
Registry::open(dir.join("runs.jsonl"))
|
||||
}
|
||||
|
||||
/// One minimal persisted member (the aura-registry test fixture shape).
|
||||
fn minimal_report() -> RunReport {
|
||||
RunReport {
|
||||
manifest: RunManifest {
|
||||
commit: "c".to_string(),
|
||||
params: vec![("p".to_string(), Scalar::f64(1.0))],
|
||||
defaults: vec![],
|
||||
window: (Timestamp(1), Timestamp(2)),
|
||||
seed: 0,
|
||||
broker: "b".to_string(),
|
||||
selection: None,
|
||||
instrument: None,
|
||||
topology_hash: None,
|
||||
project: None,
|
||||
},
|
||||
metrics: RunMetrics { total_pips: 1.0, max_drawdown: 0.5, bias_sign_flips: 0, r: None },
|
||||
}
|
||||
}
|
||||
|
||||
/// One id vocabulary across enumeration and reproduction (C18, #298): the
|
||||
/// family-identity string a consumer lifts off the registry's own member
|
||||
/// enumeration (`FamilyRunRecord.family`) resolves through the reproduce
|
||||
/// lookup when it is unambiguous (a single stored run for the name), and
|
||||
/// reaches the same family the canonical derived handle
|
||||
/// (`"{family}-{run}"`, C18) names. Today the lookup exact-matches the
|
||||
/// derived handle only, so the natural list-then-reproduce library
|
||||
/// workflow dead-ends on `no such family`.
|
||||
///
|
||||
/// Deliberately NOT pinned here: the ambiguous case (a name with more
|
||||
/// than one stored run) and name/handle collision precedence — those
|
||||
/// semantics are the fix's to settle (refuse-don't-guess).
|
||||
#[test]
|
||||
fn load_family_accepts_the_enumerated_family_identity() {
|
||||
let reg = temp_registry("enumerated-id");
|
||||
reg.append_family("f", FamilyKind::Sweep, &[minimal_report()]).expect("append family");
|
||||
|
||||
let members = reg.load_family_members().expect("enumerate members");
|
||||
let enumerated = members[0].family.clone();
|
||||
let canonical = members[0].family_id();
|
||||
|
||||
let family = load_family(®, &enumerated).unwrap_or_else(|e| {
|
||||
panic!("the enumerated family identity '{enumerated}' must resolve: {}", e.message)
|
||||
});
|
||||
assert_eq!(family.id, canonical);
|
||||
}
|
||||
|
||||
/// A name shared by more than one stored run must refuse rather than guess
|
||||
/// (#298 fix): `load_family` lists every candidate handle in the refusal
|
||||
/// message so the caller can pick the intended one, instead of silently
|
||||
/// resolving to whichever run happens first.
|
||||
#[test]
|
||||
fn load_family_refuses_a_name_with_more_than_one_stored_run() {
|
||||
let reg = temp_registry("ambiguous-name");
|
||||
reg.append_family("f", FamilyKind::Sweep, &[minimal_report()])
|
||||
.expect("append family f, run 0");
|
||||
reg.append_family("f", FamilyKind::Sweep, &[minimal_report()])
|
||||
.expect("append family f, run 1");
|
||||
|
||||
let err = load_family(®, "f")
|
||||
.expect_err("a name naming two stored runs must not resolve silently");
|
||||
assert!(err.message.contains("ambiguous family 'f'"), "message: {}", err.message);
|
||||
assert!(err.message.contains("f-0"), "message: {}", err.message);
|
||||
assert!(err.message.contains("f-1"), "message: {}", err.message);
|
||||
}
|
||||
|
||||
/// The derived handle keeps first precedence over the name fallback (#298
|
||||
/// fix): when a family named `"f-0"` collides with the canonical handle of
|
||||
/// another family `"f"`'s run 0, looking up `"f-0"` resolves to the family
|
||||
/// whose HANDLE is `"f-0"`, not the family whose NAME happens to match.
|
||||
#[test]
|
||||
fn load_family_exact_handle_precedes_a_name_collision() {
|
||||
let reg = temp_registry("handle-name-collision");
|
||||
reg.append_family("f", FamilyKind::Sweep, &[minimal_report()])
|
||||
.expect("append family f, run 0 -> handle f-0");
|
||||
reg.append_family("f-0", FamilyKind::Sweep, &[minimal_report()])
|
||||
.expect("append family f-0, run 0 -> handle f-0-0, name f-0");
|
||||
|
||||
let family = load_family(®, "f-0").unwrap_or_else(|e| {
|
||||
panic!("the exact handle 'f-0' must resolve: {}", e.message)
|
||||
});
|
||||
assert_eq!(family.id, "f-0");
|
||||
assert_eq!(family.members[0].family, "f");
|
||||
}
|
||||
}
|
||||
@@ -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(¶m_names, &raw_space, &signal);
|
||||
let space =
|
||||
blueprint_axis_probe_reopened(&cell.blueprint_json, self.env, &overrides)
|
||||
.param_space();
|
||||
let point = bind_axes(&space, &cell.strategy_id, params)?;
|
||||
let signal = reopen_all(signal, &overrides);
|
||||
|
||||
// The member's resolved input binding (campaign data.bindings
|
||||
// overrides win over name defaults). A refusal is a member fault,
|
||||
// never a process exit.
|
||||
let binding =
|
||||
crate::binding::resolve_binding(&cell.strategy_id, signal.input_roles(), &self.bindings)
|
||||
.map_err(MemberFault::Bind)?;
|
||||
|
||||
// Real windowed data — geometry BEFORE bar data (the shipped pre-data
|
||||
// refusal order of `open_real_source`), both as member faults.
|
||||
let geo = instrument_geometry(&self.server, &cell.instrument).ok_or_else(|| {
|
||||
MemberFault::Run(format!(
|
||||
"no recorded geometry for symbol '{}' at {} — refusing to run a \
|
||||
real instrument with a guessed pip",
|
||||
cell.instrument,
|
||||
self.env.data_path()
|
||||
))
|
||||
})?;
|
||||
let from = unix_ms_to_epoch_ns(window_ms.0);
|
||||
let to = unix_ms_to_epoch_ns(window_ms.1);
|
||||
// One source per resolved binding column, canonical order (the same
|
||||
// order `wrap_r` declares the roles in — the shared plan).
|
||||
let sources = match open_columns_window(
|
||||
&self.server,
|
||||
&cell.instrument,
|
||||
Some(from),
|
||||
Some(to),
|
||||
&binding.columns(),
|
||||
) {
|
||||
Some(s) => s,
|
||||
// No archived file overlaps the window at all.
|
||||
None => {
|
||||
return Err(MemberFault::NoData {
|
||||
instrument: cell.instrument.clone(),
|
||||
window_ms,
|
||||
});
|
||||
}
|
||||
};
|
||||
// A window that overlaps a file but holds zero matching bars yields
|
||||
// sources whose first peek is None (open_window's documented contract)
|
||||
// — the same no-data condition (all columns decode the same bars, so
|
||||
// probing the first source covers the set).
|
||||
if aura_engine::Source::peek(sources[0].as_ref()).is_none() {
|
||||
return Err(MemberFault::NoData {
|
||||
instrument: cell.instrument.clone(),
|
||||
window_ms,
|
||||
});
|
||||
}
|
||||
|
||||
// The shipped member recipe (wrap, bind, run, summarize):
|
||||
// `run_blueprint_member` verbatim. Seed 0 (seed-free real-data runs),
|
||||
// topology_hash = the strategy's content id, project provenance
|
||||
// stamped inside the helper.
|
||||
let stop = stop_rule_for_regime(cell.regime);
|
||||
let mut report = run_blueprint_member(
|
||||
signal,
|
||||
&point,
|
||||
&space,
|
||||
sources,
|
||||
(from, to),
|
||||
0,
|
||||
geo.pip_size,
|
||||
&cell.strategy_id,
|
||||
self.env,
|
||||
stop,
|
||||
&binding,
|
||||
&self.cost,
|
||||
&cell.instrument,
|
||||
);
|
||||
report.manifest.instrument = Some(cell.instrument.clone());
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// #272: derive the cell's archive coverage from the #264 primitives —
|
||||
/// `None` when the archive has no file for the instrument at all (the
|
||||
/// `NoData` member fault's job, not coverage) or when the effective
|
||||
/// evaluated span covers the requested window exactly with no interior
|
||||
/// gap month. One call per cell (not per member): coverage is a property
|
||||
/// of the cell's (instrument, window), never a swept param point.
|
||||
fn window_coverage(&self, cell: &CellSpec) -> Option<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);
|
||||
}
|
||||
}
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Property (#295): the canonical member-run recipe — [`DefaultMemberRunner`]
|
||||
//! driven through [`aura_campaign::CellSpec`] — is reachable as a **pure
|
||||
//! library call**: no `aura` binary, no `aura-cli` crate in the dependency
|
||||
//! graph (this test binary links only `aura-runner` + its own deps). This is
|
||||
//! the acceptance evidence `examples/world_member_run.rs` demonstrates by
|
||||
//! printing; here it is pinned by assertion instead of eyeballed output, and
|
||||
//! runs over a tiny synthetic archive rather than skipping when no local host
|
||||
//! archive is mounted.
|
||||
//!
|
||||
//! A second property rides along for free at no extra fixture cost (C1,
|
||||
//! determinism): two independently constructed runners (fresh `DataServer`,
|
||||
//! fresh `DefaultMemberRunner`) driven over byte-identical input produce a
|
||||
//! byte-identical `RunReport` — same input, same run, reproducibly.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use aura_campaign::{CellSpec, MemberRunner};
|
||||
use aura_ingest::DataServer;
|
||||
use aura_runner::{DefaultMemberRunner, Env};
|
||||
|
||||
const SYMBOL: &str = "TESTSYM";
|
||||
// 2024-06-03T08:00:00Z (a real Monday) — arbitrary, fixed, deterministic.
|
||||
const BASE_MS: i64 = 1_717_401_600_000;
|
||||
const N_BARS: i64 = 60;
|
||||
|
||||
/// A unique scratch directory under the OS temp root, removed on drop —
|
||||
/// mirrors `aura-ingest`'s own `tests::ScratchDir` (pid + atomic counter, no
|
||||
/// `tempfile` dev-dependency needed for one throwaway fixture dir).
|
||||
struct ScratchDir(PathBuf);
|
||||
|
||||
impl ScratchDir {
|
||||
fn new() -> Self {
|
||||
static COUNTER: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
|
||||
let n = COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir()
|
||||
.join(format!("aura-runner-e2e-{}-{n}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("create scratch dir");
|
||||
Self(dir)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ScratchDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Packs one `RawM1Record`-shaped 48-byte little-endian record — the exact
|
||||
/// on-disk format `data-server` reads (mirrors `aura-ingest`'s and
|
||||
/// `aura-cli`'s own synthetic-archive test helpers).
|
||||
fn pack_record(unix_ms_time: i64, open: f64, high: f64, low: f64, close: f64) -> [u8; 48] {
|
||||
const DELPHI_EPOCH_OFFSET_DAYS: f64 = 25569.0;
|
||||
const MS_PER_DAY: f64 = 86_400_000.0;
|
||||
let dt = unix_ms_time as f64 / MS_PER_DAY + DELPHI_EPOCH_OFFSET_DAYS;
|
||||
let mut rec = [0u8; 48];
|
||||
rec[0..8].copy_from_slice(&dt.to_le_bytes());
|
||||
rec[8..16].copy_from_slice(&open.to_le_bytes());
|
||||
rec[16..24].copy_from_slice(&high.to_le_bytes());
|
||||
rec[24..32].copy_from_slice(&low.to_le_bytes());
|
||||
rec[32..40].copy_from_slice(&close.to_le_bytes());
|
||||
rec[40..44].copy_from_slice(&0.5_f32.to_le_bytes());
|
||||
rec[44..48].copy_from_slice(&100_i32.to_le_bytes());
|
||||
rec
|
||||
}
|
||||
|
||||
/// Writes a tiny, deterministic one-month M1 archive (`N_BARS` consecutive
|
||||
/// 1-minute bars, strictly ascending close so the shipped Donchian breakout
|
||||
/// blueprint actually latches a non-flat bias) plus its geometry sidecar,
|
||||
/// under `dir`. Returns the inclusive `(first_ms, last_ms)` bar span.
|
||||
fn write_tiny_archive(dir: &Path) -> (i64, i64) {
|
||||
let path = dir.join(format!("{SYMBOL}_2024_06.m1"));
|
||||
let file = std::fs::File::create(&path).expect("create synthetic m1 zip");
|
||||
let mut zip = zip::ZipWriter::new(file);
|
||||
zip.start_file::<String, ()>(format!("{SYMBOL}.bin"), Default::default())
|
||||
.expect("start synthetic m1 zip entry");
|
||||
for i in 0..N_BARS {
|
||||
let ms = BASE_MS + i * 60_000;
|
||||
let close = 100.0 + i as f64 * 2.0; // strictly ascending -> a real breakout
|
||||
let open = close - 1.0;
|
||||
let high = close + 0.5;
|
||||
let low = open - 0.5;
|
||||
zip.write_all(&pack_record(ms, open, high, low, close)).expect("write record");
|
||||
}
|
||||
zip.finish().expect("finish synthetic m1 zip");
|
||||
std::fs::write(
|
||||
dir.join(format!("{SYMBOL}.meta.json")),
|
||||
"{\"schemaVersion\":2,\"symbol\":\"TESTSYM\",\"digits\":1,\"pipSize\":1,\
|
||||
\"tickSize\":0.1,\"lotSize\":1,\"baseAsset\":\"TESTSYM\",\"quoteAsset\":\"EUR\"}",
|
||||
)
|
||||
.expect("write synthetic geometry sidecar");
|
||||
(BASE_MS, BASE_MS + (N_BARS - 1) * 60_000)
|
||||
}
|
||||
|
||||
/// One fresh `DefaultMemberRunner` over its own `DataServer` instance pointed
|
||||
/// at `data_dir`, running `cell` end to end.
|
||||
fn run_once(
|
||||
data_dir: &Path,
|
||||
cell: &CellSpec,
|
||||
window_ms: (i64, i64),
|
||||
) -> Result<aura_backtest::RunReport, aura_campaign::MemberFault> {
|
||||
let env = Env::std();
|
||||
let server = std::sync::Arc::new(DataServer::new(data_dir));
|
||||
let runner = DefaultMemberRunner::new(&env, server, BTreeMap::new(), Vec::new());
|
||||
runner.run_member(cell, &[], window_ms)
|
||||
}
|
||||
|
||||
#[test]
|
||||
/// Property: `DefaultMemberRunner::run_member` — the shipped C28 assembly
|
||||
/// recipe — runs a real member backtest to completion through nothing but
|
||||
/// `aura-runner` + `aura-campaign` library calls, and does so deterministically:
|
||||
/// two independently constructed runners over byte-identical archive input
|
||||
/// produce a byte-identical `RunReport` (C1).
|
||||
fn default_member_runner_is_reachable_as_a_pure_library_call_and_is_deterministic() {
|
||||
let scratch = ScratchDir::new();
|
||||
let (first_ms, last_ms) = write_tiny_archive(scratch.path());
|
||||
|
||||
let blueprint_json =
|
||||
include_str!("../../aura-cli/examples/r_breakout.json").to_string();
|
||||
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: (first_ms, last_ms),
|
||||
regime: None,
|
||||
regime_ordinal: 0,
|
||||
};
|
||||
|
||||
let report_a = run_once(scratch.path(), &cell, (first_ms, last_ms))
|
||||
.expect("the member run must succeed over its own tiny archive");
|
||||
let report_b = run_once(scratch.path(), &cell, (first_ms, last_ms))
|
||||
.expect("a second, independently constructed runner must succeed identically");
|
||||
|
||||
assert_eq!(
|
||||
report_a, report_b,
|
||||
"same cell, same archive, two independent DefaultMemberRunner instances: \
|
||||
the RunReport must be byte-identical (C1 determinism)"
|
||||
);
|
||||
assert!(
|
||||
report_a.metrics.total_pips.abs() > 0.0,
|
||||
"the fixture's strictly-ascending closes must drive a non-flat run, else \
|
||||
the determinism check above is vacuous (both sides equally empty): {:?}",
|
||||
report_a.metrics
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,27 @@ fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join("..")
|
||||
}
|
||||
|
||||
/// Every `crates/*` directory that has its own `Cargo.toml` — the actual workspace
|
||||
/// membership, read from disk rather than duplicated by hand, so the `allowed` table
|
||||
/// below can be checked for completeness instead of silently omitting a crate.
|
||||
fn workspace_crate_names() -> Vec<String> {
|
||||
let crates_dir = workspace_root().join("crates");
|
||||
let mut names: Vec<String> = std::fs::read_dir(&crates_dir)
|
||||
.unwrap_or_else(|e| panic!("read {}: {e}", crates_dir.display()))
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.expect("dir entry");
|
||||
let path = entry.path();
|
||||
if path.join("Cargo.toml").is_file() {
|
||||
Some(entry.file_name().into_string().expect("utf8 dir name"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
/// The `aura-*` keys under `[dependencies]` (NOT `[dev-dependencies]`) of a crate.
|
||||
fn prod_intra_workspace_deps(crate_name: &str) -> Vec<String> {
|
||||
let manifest = workspace_root()
|
||||
@@ -35,7 +56,9 @@ fn prod_intra_workspace_deps(crate_name: &str) -> Vec<String> {
|
||||
fn node_crates_obey_the_c28_import_direction() {
|
||||
// (crate, the intra-workspace [dependencies] C28 permits for its layer)
|
||||
let allowed: &[(&str, &[&str])] = &[
|
||||
("aura-core", &[]), // foundation: no aura-* deps
|
||||
("aura-analysis", &[]), // foundation: no aura-* deps
|
||||
("aura-measurement", &["aura-core", "aura-analysis"]),
|
||||
("aura-std", &["aura-core"]),
|
||||
("aura-market", &["aura-core"]),
|
||||
("aura-strategy", &["aura-core"]),
|
||||
@@ -45,7 +68,74 @@ fn node_crates_obey_the_c28_import_direction() {
|
||||
"aura-vocabulary",
|
||||
&["aura-core", "aura-std", "aura-market", "aura-strategy", "aura-backtest"],
|
||||
),
|
||||
(
|
||||
"aura-composites",
|
||||
&["aura-core", "aura-engine", "aura-std", "aura-strategy", "aura-backtest"],
|
||||
),
|
||||
("aura-ingest", &["aura-core", "aura-engine"]),
|
||||
("aura-research", &["aura-core"]),
|
||||
(
|
||||
"aura-registry",
|
||||
&["aura-core", "aura-engine", "aura-backtest", "aura-analysis", "aura-research"],
|
||||
),
|
||||
(
|
||||
"aura-campaign",
|
||||
&[
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-backtest",
|
||||
"aura-analysis",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
],
|
||||
),
|
||||
(
|
||||
"aura-runner",
|
||||
&[
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-backtest",
|
||||
"aura-composites",
|
||||
"aura-ingest",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"aura-campaign",
|
||||
"aura-vocabulary",
|
||||
],
|
||||
),
|
||||
("aura-bench", &["aura-core", "aura-engine", "aura-std", "aura-ingest"]),
|
||||
(
|
||||
"aura-cli",
|
||||
&[
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-std",
|
||||
"aura-strategy",
|
||||
"aura-backtest",
|
||||
"aura-registry",
|
||||
"aura-research",
|
||||
"aura-campaign",
|
||||
"aura-vocabulary",
|
||||
"aura-ingest",
|
||||
"aura-measurement",
|
||||
"aura-runner",
|
||||
],
|
||||
),
|
||||
];
|
||||
|
||||
let mut table_names: Vec<&str> = allowed.iter().map(|(name, _)| *name).collect();
|
||||
table_names.sort();
|
||||
let disk_names = workspace_crate_names();
|
||||
assert_eq!(
|
||||
table_names,
|
||||
disk_names.iter().map(String::as_str).collect::<Vec<_>>(),
|
||||
"C28 full-workspace: `allowed` must enumerate exactly the crates under `crates/` \
|
||||
(add the new crate's permitted set here, even a foundation crate with no aura-* \
|
||||
deps) — otherwise it escapes the C28 guard silently"
|
||||
);
|
||||
|
||||
for (crate_name, permitted) in allowed {
|
||||
for dep in prod_intra_workspace_deps(crate_name) {
|
||||
assert!(
|
||||
@@ -55,4 +145,74 @@ fn node_crates_obey_the_c28_import_direction() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (crate_name, permitted) in allowed {
|
||||
for shell_or_assembly in ["aura-cli", "aura-runner"] {
|
||||
let exempt = *crate_name == "aura-cli" && shell_or_assembly == "aura-runner";
|
||||
assert!(
|
||||
exempt || !permitted.contains(&shell_or_assembly),
|
||||
"C28 assembly violation: `{crate_name}`'s permitted set names \
|
||||
`{shell_or_assembly}`, but the shell/assembly crates are imported \
|
||||
by nothing except the shell itself"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_shell_defines_no_domain_modules_and_no_lib_target() {
|
||||
let root = workspace_root();
|
||||
let manifest_text =
|
||||
std::fs::read_to_string(root.join("crates/aura-cli/Cargo.toml")).unwrap();
|
||||
let manifest: toml::Value =
|
||||
toml::from_str(&manifest_text).expect("Cargo.toml parses as TOML");
|
||||
assert!(
|
||||
manifest.get("lib").is_none(),
|
||||
"C28: the shell exports nothing — aura-cli must stay a pure binary crate"
|
||||
);
|
||||
let mut modules: Vec<String> = std::fs::read_dir(root.join("crates/aura-cli/src"))
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().into_string().unwrap())
|
||||
.collect();
|
||||
modules.sort();
|
||||
assert_eq!(
|
||||
modules,
|
||||
[
|
||||
"campaign_run.rs",
|
||||
"graph_construct.rs",
|
||||
"main.rs",
|
||||
"render.rs",
|
||||
"research_docs.rs",
|
||||
"scaffold.rs",
|
||||
"verb_sugar.rs",
|
||||
],
|
||||
"C28 (#295): the shell holds argv/dispatch, argv->document translation, \
|
||||
and presentation only — a new module needs a library home"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_external_data_server_tree_enters_at_pinned_points_only() {
|
||||
// C28 (#295 audit): the external `data-server` tree is a production
|
||||
// dependency of exactly the ingestion edge (External components), the
|
||||
// assembly position, and the shell. Any further crate pulling it in is
|
||||
// a new, undecided workspace entry point for the external tree — the
|
||||
// aura-*-only direction table above cannot see it, so it is pinned here.
|
||||
let permitted = ["aura-cli", "aura-ingest", "aura-runner"];
|
||||
for name in workspace_crate_names() {
|
||||
let manifest = workspace_root().join("crates").join(&name).join("Cargo.toml");
|
||||
let text = std::fs::read_to_string(&manifest)
|
||||
.unwrap_or_else(|e| panic!("read {}: {e}", manifest.display()));
|
||||
let doc: toml::Value = toml::from_str(&text).expect("Cargo.toml parses as TOML");
|
||||
let has = doc
|
||||
.get("dependencies")
|
||||
.and_then(|d| d.as_table())
|
||||
.is_some_and(|deps| deps.contains_key("data-server"));
|
||||
assert_eq!(
|
||||
has,
|
||||
permitted.contains(&name.as_str()),
|
||||
"C28: `data-server` [dependencies] edge on `{name}` — the external \
|
||||
tree enters the workspace only via {permitted:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+79
-22
@@ -1253,6 +1253,13 @@ manifest stamps the untouched bound defaults (`defaults`, one-directionally
|
||||
widened beside `params`, which keeps its "what varied" reproduce semantics) so
|
||||
a raw reader of a fully-bound run no longer sees a misleading `"params": []`;
|
||||
the blueprint itself carries no such duplication.
|
||||
**Amendment (2026-07-20, #295 — the programmatic face is an executor).** The
|
||||
member-run recipe — the definition of what a standard aura backtest is — is
|
||||
library content (`aura-runner`/`aura-backtest`/`aura-measurement`, C28
|
||||
assembly position), not CLI content: the binary is argv → document →
|
||||
executor → presentation, and a downstream World program reaches the same
|
||||
recipe with no binary involved. See C25's control-surface amendment for the
|
||||
projection rule.
|
||||
|
||||
### C15 — Resampling-as-node; sessions/calendars
|
||||
**Guarantee.** A resampler is a node (finer stream → coarser bar stream),
|
||||
@@ -1505,7 +1512,12 @@ the integrity check). `aura reproduce <family-id>` re-derives every persisted me
|
||||
the member's blueprint by its `topology_hash`, reconstruct the sweep point from the
|
||||
recorded params, re-run through the **same** `run_blueprint_member` the live sweep uses
|
||||
(so bit-identity is by construction, C1), and compare metrics — refuse-don't-guess on an
|
||||
unknown id / missing stored blueprint (exit 2), DIVERGED → exit 1. The manifest + the
|
||||
unknown id / missing stored blueprint (exit 1 — recorded state is missing, C14's
|
||||
runtime-failure class; this paragraph over-claimed exit 2 until #298 recorded the code's
|
||||
actual, C14-consistent behaviour), DIVERGED → exit 1. The id resolves first as the
|
||||
derived `{family}-{run}` handle; a bare enumeration name naming exactly one stored run
|
||||
resolves as fallback, an ambiguous name refuses listing the candidate handles (#298 —
|
||||
the list-then-reproduce seam). The manifest + the
|
||||
content-addressed store + the commit are the complete re-derivation recipe (C18). One
|
||||
blueprint is stored per family (all members share the signal `topology_hash` — C11/C12
|
||||
dedup). `aura graph introspect --content-id` exposes the same id for an op-script, via the
|
||||
@@ -2508,6 +2520,15 @@ throughput lives. Realization: cycle 0106 (#189) shipped roles 5/6b their
|
||||
artifacts (process/campaign documents, C18 realization note); roles 4/7/8 remain
|
||||
technically present but faceless (no addressed verb families yet); role homes in
|
||||
the project layout and docs-by-role are open (#192 context).
|
||||
**Amendment (2026-07-20, #295 — control surfaces are projections).** The
|
||||
text-first invariant fixes the control-surface layering: the text artifact
|
||||
vocabulary (blueprints, process/campaign documents, registry records) is the
|
||||
canonical layer, and every control surface — the one-shot CLI's executor
|
||||
verbs, any future long-running host, any MCP face, a World program — is a
|
||||
projection/executor over those artifacts, never a second home for intent.
|
||||
Which projection is built next (document-first flag-vocabulary completion, a
|
||||
typed-protocol host, an MCP face, or none until demand) is deliberately open
|
||||
— recorded as an open direction fork on #295.
|
||||
|
||||
### C26 — Harness input binding: role names bind archive columns
|
||||
**Guarantee.** A strategy blueprint declares WHICH data it consumes as the NAMES
|
||||
@@ -2518,10 +2539,11 @@ the campaign cell's (or run invocation's) instrument. A campaign document may
|
||||
**override** the name default per role via the additive `data.bindings` block
|
||||
(`role name → column name`; serde `default` + skip-if-empty, so binding-less
|
||||
documents keep their content ids) — the 6b rebind seam: the blueprint's content
|
||||
identity never changes. Resolution (`aura-cli`'s `binding` module,
|
||||
`resolve_binding`) produces ONE ordered plan consumed by BOTH halves of the old
|
||||
weld: the columns are opened (`aura_ingest::open_columns`, the generalization of
|
||||
`open_ohlc`/#92) and the wrapped root roles declared (`wrap_r`) in the **same
|
||||
identity never changes. Resolution (`aura-runner`'s `binding` module,
|
||||
`resolve_binding` — moved out of the shell with #295) produces ONE ordered
|
||||
plan consumed by BOTH halves of the old weld: the columns are opened
|
||||
(`aura_ingest::open_columns`, the generalization of `open_ohlc`/#92) and the
|
||||
wrapped root roles declared (`aura-runner::member`'s `wrap_r`) in the **same
|
||||
canonical order** — `M1Field` declaration order (open, high, low, close, spread,
|
||||
volume), filtered to the consumed set — which is the C4 merge tie-break order,
|
||||
so role i receives source i by construction. A close column is always part of
|
||||
@@ -2544,7 +2566,7 @@ the binding VALUE-space grows additively from archive columns to recorded-stream
|
||||
references — a root role like `sentiment` becomes bindable to a recorded stream
|
||||
then, and until then refuses with the vocabulary-naming message. The role-name
|
||||
key-space and the campaign `bindings` carrier are unchanged by that growth.
|
||||
**Why.** The single-price weld (`wrap_r`'s hard-wired `price`←close and the six
|
||||
**Why.** The single-price weld (`aura-runner::member`'s `wrap_r`'s hard-wired `price`←close and the six
|
||||
`M1Field::Close`-only open sites) made every strategy monocular regardless of
|
||||
what its blueprint declared; binding by role name keeps the blueprint the single
|
||||
source of its own data needs (C24: topology-as-data carries its input contract),
|
||||
@@ -2625,19 +2647,40 @@ outer:
|
||||
Beside the ladder stands the **process column** — the research-process machinery
|
||||
(the run registry C18, the process/campaign document types, campaign execution).
|
||||
It consumes run *artifacts*, not market streams, and orchestrates runs of any
|
||||
ladder layer; it stands beside the ladder, not on a rung. The **shell** (the
|
||||
`aura` CLI) imports everything and is imported by nothing.
|
||||
ladder layer; it stands beside the ladder, not on a rung. Between ladder/column
|
||||
and the shell stands the **assembly** position (`aura-runner`, #295): it
|
||||
composes the rungs into the canonical member-run recipe — harness assembly,
|
||||
input binding (C26), the C1-load-bearing param↔config translators, and the
|
||||
shipped default `MemberRunner` — importing the ladder rungs, `aura-composites`,
|
||||
`aura-ingest`, and the process column, and imported only by the shell and by
|
||||
downstream World programs. It also carries a direct production dependency on
|
||||
the external `data-server` tree (inherited from the shell with the recipe: the
|
||||
runner constructs archive servers itself). The external tree therefore enters
|
||||
the workspace at the ingestion edge (`aura-ingest`, External components), the
|
||||
assembly position (`aura-runner`), and the shell (which imports everything by
|
||||
definition) — and at no ladder or column crate beyond those; the
|
||||
`c28_layering` guard pins the exact set. The **shell** (the `aura` CLI) imports everything,
|
||||
is imported by nothing, exports nothing, and holds no domain logic:
|
||||
argv/dispatch, argv→document translation (including the op-script construction
|
||||
front-end, `graph_construct`), presentation, and the `aura new` project
|
||||
scaffolder (authoring-tooling template emission — shell-resident, like
|
||||
rendering, until a second consumer wants it). Rendering stays in the shell
|
||||
until the C22 web face provides its second consumer (#295 deferral).
|
||||
|
||||
**Import rule.** An outer ladder layer may import inner layers, never the
|
||||
reverse; *measurement* and *strategy* are siblings (no import either way);
|
||||
*backtest* may import *strategy*. The process column imports the engine layer
|
||||
plus the run-artifact and metric interfaces, and is imported by no ladder crate;
|
||||
column-internal edges are free. The shell imports all; nothing imports the
|
||||
shell. **`dev-dependencies` are exempt** — tests may cross layers (the existing
|
||||
`engine`/`ingest`/`registry` → `std` edges are dev-only and stay). The rule
|
||||
binds **crate structure, not graph composition**: a blueprint may feed a
|
||||
measurement statistic into a bias input — data flow inside a graph is free (C24);
|
||||
layers govern imports, graphs compose freely.
|
||||
plus the run-artifact and metric interfaces — realized since #291/#292 as a
|
||||
production dependency on `aura-backtest` (the trading instantiation
|
||||
`M = RunMetrics`) — and is imported by no ladder crate; column-internal edges
|
||||
are free. The assembly position imports ladder and column alike and is
|
||||
imported by neither; the shell imports all; nothing imports the shell — both
|
||||
now enforced by the full-workspace `c28_layering` table plus its shell-content
|
||||
check (#295). **`dev-dependencies` are exempt** — tests may cross layers (the
|
||||
existing `engine`/`ingest`/`registry` → `std` edges are dev-only and stay).
|
||||
The rule binds **crate structure, not graph composition**: a blueprint may
|
||||
feed a measurement statistic into a bias input — data flow inside a graph is
|
||||
free (C24); layers govern imports, graphs compose freely.
|
||||
|
||||
**Forbids.** An inner layer importing an outer one — most sharply the engine
|
||||
layer importing backtest reductions, or market importing strategy. A ladder crate
|
||||
@@ -2708,10 +2751,11 @@ cleanly by layer, so the layering is realized only partially:
|
||||
interweave is resolved (phase 5, #291): the backtest reductions live in
|
||||
`aura-backtest::metrics` beside their `position_management` producer, and
|
||||
`aura-analysis` is reduced to domain-free statistics + selection provenance
|
||||
(`[dependencies]` = serde only). No crate exists yet for *measurement* (its run
|
||||
verb is the additive shape dispatch, #286) or *execution* (unbuilt — the C10/C13
|
||||
edge only). Under this model the `aura-registry → aura-research` edge is
|
||||
column-internal and legal.
|
||||
(`[dependencies]` = serde only). The *measurement* rung is seeded (#295):
|
||||
`aura-measurement` carries the IC vocabulary + reduction (its run verb remains
|
||||
the additive shape dispatch, #286). No crate exists yet for *execution*
|
||||
(unbuilt — the C10/C13 edge only). Under this model the
|
||||
`aura-registry → aura-research` edge is column-internal and legal.
|
||||
- Phased realization (each independently shippable; behaviour byte-identical
|
||||
except the purely additive shape dispatch): (1) this contract; (2) cut the
|
||||
engine's backtest-metrics edge via `RunReport<M>` — **done** (#292:
|
||||
@@ -2719,9 +2763,16 @@ cleanly by layer, so the layering is realized only partially:
|
||||
`aura-backtest`, the statistics kernel to `aura-analysis`, dependency
|
||||
inversion, ladder enforced by the structural test; realizes item 1 of #147);
|
||||
(3) the #286
|
||||
shape dispatch plus the per-run scaffold as a library (seeds an `aura-backtest`
|
||||
crate, gives *measurement* its run verb, removes the measured O(cycles) run-time
|
||||
retention); (4) split the `aura-std` roster along engine / market / strategy /
|
||||
shape dispatch plus the per-run scaffold as a library — **done** (#295: the
|
||||
pure per-run scaffold pieces live in `aura-backtest::scaffold`; the composed
|
||||
recipe — harness assembly, binding, translators, the default `MemberRunner`
|
||||
— is the assembly crate `aura-runner`; the measurement rung is seeded as
|
||||
`aura-measurement` with the IC; the shell is reduced to
|
||||
argv/translation/presentation, enforced structurally. Residual: ~20 refusal
|
||||
sites inside `aura-runner`'s single-run verb paths still terminate the
|
||||
process; their conversion to `RunnerError` propagation is tracked as #297 —
|
||||
the campaign path already refuses via `MemberFault`, never a process exit);
|
||||
(4) split the `aura-std` roster along engine / market / strategy /
|
||||
backtest — **done** (#288: four `aura-core`-only node crates, the closed roster
|
||||
moved to `aura-vocabulary`, the ladder direction enforced by a structural test);
|
||||
(5) split `aura-analysis` into generic statistics and backtest metrics —
|
||||
@@ -2731,6 +2782,12 @@ cleanly by layer, so the layering is realized only partially:
|
||||
names now realized (`aura-market`/`aura-strategy`/`aura-backtest`/
|
||||
`aura-vocabulary`); full evidence on #288, #286 and the milestone.
|
||||
|
||||
Provenance note (#295): the shell-boundary cut closes a ratified structural
|
||||
debt (this contract's own phase plan) and un-blocks the future World program;
|
||||
it was not a demonstrated downstream blocker — the first downstream project's
|
||||
observed friction was document-vocabulary gaps, never Rust-level recipe
|
||||
reachability.
|
||||
|
||||
---
|
||||
|
||||
## Open architectural threads not yet resolved
|
||||
|
||||
+1294
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
# Standalone downstream-consumer crate for the shell-boundary (#295) fieldtest.
|
||||
#
|
||||
# The cycle's claim to substantiate empirically: a downstream World program can
|
||||
# run the canonical member backtest and drive the family/validation machinery
|
||||
# through the LIBRARY ALONE — no `aura` binary, no `aura-cli` in the link graph.
|
||||
#
|
||||
# Like the prior fixtures, this is NOT a member of the aura workspace (empty
|
||||
# [workspace] table below = own workspace root); it path-deps the library crates
|
||||
# exactly as a real C16 research project would, and drives the public surface
|
||||
# discovered from the design ledger + glossary + authoring guide + `cargo doc`
|
||||
# rustdoc + the shipped worked example (no crates/*/src was read).
|
||||
#
|
||||
# NB: aura-cli is deliberately ABSENT from [dependencies] — the link graph is the
|
||||
# acceptance evidence. Run e.g.:
|
||||
# cargo run --manifest-path fieldtests/cycle-295-shell-boundary/Cargo.toml --bin sb_1_member_run
|
||||
# so HEAD source is always what runs.
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "sb295-fieldtest"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
aura-core = { path = "../../crates/aura-core" }
|
||||
aura-runner = { path = "../../crates/aura-runner" }
|
||||
aura-campaign = { path = "../../crates/aura-campaign" }
|
||||
aura-research = { path = "../../crates/aura-research" }
|
||||
aura-registry = { path = "../../crates/aura-registry" }
|
||||
aura-ingest = { path = "../../crates/aura-ingest" }
|
||||
aura-measurement = { path = "../../crates/aura-measurement" }
|
||||
# Reached for directly: DefaultMemberRunner::new / DataSource::Real want an
|
||||
# Arc<DataServer>, and aura-ingest re-exports only the constructed default_data_server().
|
||||
data-server = { git = "http://192.168.178.103:3000/Brummel/data-server.git", branch = "main" }
|
||||
|
||||
[[bin]]
|
||||
name = "sb_1_member_run"
|
||||
path = "sb_1_member_run.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "sb_2_campaign"
|
||||
path = "sb_2_campaign.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "sb_3_ic"
|
||||
path = "sb_3_ic.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "sb_4_reproduce"
|
||||
path = "sb_4_reproduce.rs"
|
||||
@@ -0,0 +1,4 @@
|
||||
running member: strategy_id=884b1ba0640b2f5bb661c8d47e169db85ac2bb729ff5aa1ff3f9c930085da6d9 instrument=GER40
|
||||
member ran OK. metrics:
|
||||
total_pips=79.35 max_drawdown=862.4750 bias_sign_flips=7253
|
||||
R: n_trades=6604 expectancy_r=0.0042 win_rate=0.363 sqn=0.255 sqn_normalized=0.031 net_expectancy_r=0.0042
|
||||
@@ -0,0 +1,6 @@
|
||||
runs root: runs
|
||||
campaign_id=7c892fcdced6a808bf036564a350e1fd1fd2d47324abbadb5dcd39e02f13d1aa
|
||||
executing campaign (1 cell, sweep over fast.length in [2,3])…
|
||||
campaign ran OK. run#=0 cells=1
|
||||
cell 0: 1 stage-families
|
||||
stage 0 block std::sweep family_id=7c892fcd-0-GER40-w0-r0-s0-0 members=2
|
||||
@@ -0,0 +1,9 @@
|
||||
informed signal:
|
||||
information_coefficient = 0.8909
|
||||
overfit_probability = 0.0020
|
||||
n_pairs = 399
|
||||
noise signal:
|
||||
information_coefficient = -0.0191
|
||||
overfit_probability = 0.6507
|
||||
n_pairs = 399
|
||||
deterministic re-run identical: true
|
||||
@@ -0,0 +1,22 @@
|
||||
### Case A — auto-discover the family id via registry.load_family_members() (the natural 'list then reproduce' path)
|
||||
runs root: runs
|
||||
reproducing family: 7c892fcd-0-GER40-w0-r0-s0
|
||||
load_family failed: exit=1 msg=no such family '7c892fcd-0-GER40-w0-r0-s0'
|
||||
--- reproduce_family(id, env) ---
|
||||
reproduce_family failed: exit=1 msg=no such family '7c892fcd-0-GER40-w0-r0-s0'
|
||||
--- reproduce_family_in(reg, id, data, env) -> ReproduceReport ---
|
||||
reproduce_family_in failed: exit=1 msg=no such family '7c892fcd-0-GER40-w0-r0-s0'
|
||||
|
||||
### Case B — the family id as returned by execute()'s StageFamily.family_id (with the stage '-0' suffix)
|
||||
runs root: runs
|
||||
reproducing family: 7c892fcd-0-GER40-w0-r0-s0-0
|
||||
load_family OK: kind=Sweep members=2
|
||||
--- reproduce_family(id, env) ---
|
||||
7c892fcd-0-GER40-w0-r0-s0-0 member sma_signal.fast.length=2, stop_length=3, stop_k=2 reproduced: bit-identical
|
||||
7c892fcd-0-GER40-w0-r0-s0-0 member sma_signal.fast.length=3, stop_length=3, stop_k=2 reproduced: bit-identical
|
||||
reproduced 2/2 members bit-identically
|
||||
reproduce_family returned Ok(()) — bit-identical (no per-member detail returned).
|
||||
--- reproduce_family_in(reg, id, data, env) -> ReproduceReport ---
|
||||
ReproduceReport: 2/2 members bit-identical
|
||||
IDENTICAL sma_signal.fast.length=2, stop_length=3, stop_k=2
|
||||
IDENTICAL sma_signal.fast.length=3, stop_length=3, stop_k=2
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Axis 1 — a single member backtest driven through `DefaultMemberRunner`, the
|
||||
//! shipped `aura_campaign::MemberRunner` implementor, over a shipped example
|
||||
//! blueprint (r_sma) and real GER40 archive data. No `aura` binary involved;
|
||||
//! this program links the library crates only (see Cargo.toml).
|
||||
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 window the shipped 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 {SYMBOL} archive — nothing to demonstrate.");
|
||||
return;
|
||||
}
|
||||
|
||||
let blueprint_json = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../crates/aura-cli/examples/r_sma.json"
|
||||
))
|
||||
.expect("shipped example blueprint r_sma.json");
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
println!("running member: strategy_id={} instrument={SYMBOL}", cell.strategy_id);
|
||||
match runner.run_member(&cell, &[], WINDOW_MS) {
|
||||
Ok(report) => {
|
||||
let m = &report.metrics;
|
||||
println!("member ran OK. metrics:");
|
||||
println!(" total_pips={:.2} max_drawdown={:.4} bias_sign_flips={}", m.total_pips, m.max_drawdown, m.bias_sign_flips);
|
||||
match &m.r {
|
||||
Some(r) => println!(
|
||||
" R: n_trades={} expectancy_r={:.4} win_rate={:.3} sqn={:.3} sqn_normalized={:.3} net_expectancy_r={:.4}",
|
||||
r.n_trades, r.expectancy_r, r.win_rate, r.sqn, r.sqn_normalized, r.net_expectancy_r
|
||||
),
|
||||
None => println!(" R: (none)"),
|
||||
}
|
||||
}
|
||||
Err(fault) => println!("member fault (data-dependent): {fault:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Axis 2 — campaign execution driven through `aura_campaign::execute` with the
|
||||
//! default runner over a tiny hand-authored campaign + process document. Author
|
||||
//! the two closed-vocabulary documents as JSON, canonicalize + content-address
|
||||
//! them via aura-research, register the artifacts, then run the whole family/
|
||||
//! validation machinery. Library crates only; no `aura` binary.
|
||||
use std::collections::BTreeMap;
|
||||
use std::num::NonZeroUsize;
|
||||
|
||||
use aura_campaign::{execute, MemberRunner};
|
||||
use aura_ingest::default_data_server;
|
||||
use aura_research::{
|
||||
campaign_to_json, content_id_of, parse_campaign, parse_process, process_to_json,
|
||||
};
|
||||
use aura_runner::{DefaultMemberRunner, Env};
|
||||
|
||||
const SYMBOL: &str = "GER40";
|
||||
const FROM_MS: i64 = 1_725_148_800_000; // 2024-09-01
|
||||
const TO_MS: i64 = 1_727_740_800_000; // 2024-10-01
|
||||
|
||||
fn main() {
|
||||
let server = default_data_server();
|
||||
if !server.has_symbol(SYMBOL) {
|
||||
println!("skip: no local {SYMBOL} archive — nothing to demonstrate.");
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 1. the strategy blueprint (shipped example) + its content id ------------
|
||||
let blueprint_json = std::fs::read_to_string(concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../../crates/aura-cli/examples/r_sma.json"
|
||||
))
|
||||
.expect("shipped example blueprint r_sma.json");
|
||||
let bp_id = content_id_of(&blueprint_json);
|
||||
|
||||
// --- 2. a minimal process document: one selection-free sweep -----------------
|
||||
let process_src = r#"{
|
||||
"format_version": 1,
|
||||
"kind": "process",
|
||||
"name": "sb-explore-only-sweep",
|
||||
"pipeline": [ { "block": "std::sweep" } ]
|
||||
}"#;
|
||||
let process_doc = parse_process(process_src).expect("process parses");
|
||||
let process_canon = process_to_json(&process_doc);
|
||||
let process_id = content_id_of(&process_canon);
|
||||
|
||||
// --- 3. a minimal campaign document referencing both by content id -----------
|
||||
let campaign_src = format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "sb-ger40-sma-min",
|
||||
"seed": 7,
|
||||
"data": {{
|
||||
"instruments": ["{SYMBOL}"],
|
||||
"windows": [ {{ "from_ms": {FROM_MS}, "to_ms": {TO_MS} }} ]
|
||||
}},
|
||||
"strategies": [
|
||||
{{
|
||||
"ref": {{ "content_id": "{bp_id}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [2, 3] }} }}
|
||||
}}
|
||||
],
|
||||
"process": {{ "ref": {{ "content_id": "{process_id}" }} }},
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#
|
||||
);
|
||||
let campaign_doc = parse_campaign(&campaign_src).expect("campaign parses");
|
||||
let campaign_canon = campaign_to_json(&campaign_doc);
|
||||
let campaign_id = content_id_of(&campaign_canon);
|
||||
|
||||
// --- 4. wire the runner + registry, register the artifacts -------------------
|
||||
let env = Env::std();
|
||||
println!("runs root: {}", env.runs_root().display());
|
||||
let reg = env.registry();
|
||||
reg.put_blueprint(&bp_id, &blueprint_json).expect("store blueprint");
|
||||
reg.put_process(&process_canon).expect("store process");
|
||||
reg.put_campaign(&campaign_canon).expect("store campaign");
|
||||
|
||||
let runner = DefaultMemberRunner::new(&env, server, BTreeMap::new(), Vec::new());
|
||||
let strategies = vec![(bp_id.clone(), blueprint_json.clone())];
|
||||
|
||||
println!("campaign_id={campaign_id}");
|
||||
println!("executing campaign (1 cell, sweep over fast.length in [2,3])…");
|
||||
match execute(
|
||||
&campaign_id,
|
||||
&campaign_doc,
|
||||
&process_doc,
|
||||
&strategies,
|
||||
&runner as &dyn MemberRunner,
|
||||
®,
|
||||
NonZeroUsize::new(1).unwrap(),
|
||||
) {
|
||||
Ok(outcome) => {
|
||||
println!("campaign ran OK. run#={} cells={}", outcome.run, outcome.cells.len());
|
||||
for (ci, cell) in outcome.cells.iter().enumerate() {
|
||||
println!(" cell {ci}: {} stage-families", cell.families.len());
|
||||
for fam in &cell.families {
|
||||
println!(
|
||||
" stage {} block {} family_id={} members={}",
|
||||
fam.stage,
|
||||
fam.block,
|
||||
fam.family_id,
|
||||
fam.reports.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(fault) => println!("campaign fault: {fault:?}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Axis 3 — the Information Coefficient as a pure library call
|
||||
//! (`aura_measurement::information_coefficient`), including its permutation-null
|
||||
//! deflation (the returned `overfit_probability`). No data archive, no `aura`
|
||||
//! binary: just two in-memory (timestamp, value) series a downstream measurement
|
||||
//! World program would hold. Runs an *informed* signal (correlated with the
|
||||
//! forward return) against a *noise* signal to show the deflation discriminates.
|
||||
use aura_core::Timestamp;
|
||||
use aura_measurement::information_coefficient;
|
||||
|
||||
const N: usize = 400;
|
||||
const HORIZON: usize = 1;
|
||||
const PERMUTATIONS: usize = 500;
|
||||
const SEED: u64 = 12_345;
|
||||
|
||||
// A tiny deterministic LCG so the fixture is reproducible without an rng dep.
|
||||
struct Lcg(u64);
|
||||
impl Lcg {
|
||||
fn next_unit(&mut self) -> f64 {
|
||||
self.0 = self.0.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407);
|
||||
// top 53 bits -> [0,1)
|
||||
((self.0 >> 11) as f64) / ((1u64 << 53) as f64)
|
||||
}
|
||||
fn next_signed(&mut self) -> f64 {
|
||||
self.next_unit() * 2.0 - 1.0
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let mut rng = Lcg(0x1234_5678_9abc_def0);
|
||||
|
||||
// A random-walk price and its forward one-step return.
|
||||
let mut price = Vec::with_capacity(N);
|
||||
let mut level = 100.0f64;
|
||||
for _ in 0..N {
|
||||
level += rng.next_signed();
|
||||
price.push(level);
|
||||
}
|
||||
let price_series: Vec<(Timestamp, f64)> =
|
||||
price.iter().enumerate().map(|(i, &p)| (Timestamp(i as i64 * 60_000), p)).collect();
|
||||
|
||||
// Informed signal: the forward return plus noise (so it genuinely forecasts).
|
||||
let mut informed = Vec::with_capacity(N);
|
||||
for i in 0..N {
|
||||
let fwd = if i + HORIZON < N { price[i + HORIZON] - price[i] } else { 0.0 };
|
||||
informed.push(fwd + 0.5 * rng.next_signed());
|
||||
}
|
||||
let informed_series: Vec<(Timestamp, f64)> =
|
||||
informed.iter().enumerate().map(|(i, &s)| (Timestamp(i as i64 * 60_000), s)).collect();
|
||||
|
||||
// Noise signal: uncorrelated with the forward return.
|
||||
let noise_series: Vec<(Timestamp, f64)> =
|
||||
(0..N).map(|i| (Timestamp(i as i64 * 60_000), rng.next_signed())).collect();
|
||||
|
||||
let informed_ic = information_coefficient(&informed_series, &price_series, HORIZON, PERMUTATIONS, SEED);
|
||||
let noise_ic = information_coefficient(&noise_series, &price_series, HORIZON, PERMUTATIONS, SEED);
|
||||
|
||||
println!("informed signal:");
|
||||
println!(" information_coefficient = {:.4}", informed_ic.information_coefficient);
|
||||
println!(" overfit_probability = {:.4}", informed_ic.overfit_probability);
|
||||
println!(" n_pairs = {}", informed_ic.n_pairs);
|
||||
println!("noise signal:");
|
||||
println!(" information_coefficient = {:.4}", noise_ic.information_coefficient);
|
||||
println!(" overfit_probability = {:.4}", noise_ic.overfit_probability);
|
||||
println!(" n_pairs = {}", noise_ic.n_pairs);
|
||||
|
||||
// Determinism check (C1/C18 for a measurement): same inputs -> same bytes.
|
||||
let again = information_coefficient(&informed_series, &price_series, HORIZON, PERMUTATIONS, SEED);
|
||||
let deterministic = again.information_coefficient == informed_ic.information_coefficient
|
||||
&& again.overfit_probability == informed_ic.overfit_probability;
|
||||
println!("deterministic re-run identical: {deterministic}");
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//! Axis 4 — project loading + bit-identical reproduction (C18/C1) as a library
|
||||
//! consumer. Opens the default `Env`, discovers a persisted family in the run
|
||||
//! registry (written by the sb_2_campaign fixture), inspects it via `load_family`,
|
||||
//! and re-derives it via `reproduce_family`. Also probes `reproduce_family_in`,
|
||||
//! the only variant that returns a programmatic `ReproduceReport`. No `aura`
|
||||
//! binary; library crates only.
|
||||
//!
|
||||
//! Usage: `… --bin sb_4_reproduce [<family-id>]`
|
||||
//! (omit the id to auto-pick the first family the registry holds).
|
||||
use aura_ingest::default_data_server;
|
||||
use aura_runner::family::DataSource;
|
||||
use aura_runner::reproduce::{load_family, reproduce_family, reproduce_family_in};
|
||||
use aura_runner::Env;
|
||||
|
||||
const SYMBOL: &str = "GER40";
|
||||
const FROM_MS: i64 = 1_725_148_800_000;
|
||||
const TO_MS: i64 = 1_727_740_800_000;
|
||||
|
||||
fn main() {
|
||||
let env = Env::std();
|
||||
let reg = env.registry();
|
||||
println!("runs root: {}", env.runs_root().display());
|
||||
|
||||
// Discover a persisted family id (or take one from argv).
|
||||
let arg_id = std::env::args().nth(1);
|
||||
let family_id = match arg_id {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let members = match reg.load_family_members() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
println!("could not read registry: {e:?}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
match members.first() {
|
||||
Some(rec) => rec.family.clone(),
|
||||
None => {
|
||||
println!("no persisted families — run the sb_2_campaign fixture first.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
println!("reproducing family: {family_id}");
|
||||
|
||||
// 1. Inspect the persisted family programmatically.
|
||||
match load_family(®, &family_id) {
|
||||
Ok(fam) => println!(" load_family OK: kind={:?} members={}", fam.kind, fam.members.len()),
|
||||
Err(e) => println!(" load_family failed: exit={} msg={}", e.exit_code, e.message),
|
||||
}
|
||||
|
||||
// 2. The ergonomic path: reproduce_family(id, env) -> Result<(), RunnerError>.
|
||||
// Auto-derives the data source from the stored manifest.
|
||||
println!(" --- reproduce_family(id, env) ---");
|
||||
match reproduce_family(&family_id, &env) {
|
||||
Ok(()) => println!(" reproduce_family returned Ok(()) — bit-identical (no per-member detail returned)."),
|
||||
Err(e) => println!(" reproduce_family failed: exit={} msg={}", e.exit_code, e.message),
|
||||
}
|
||||
|
||||
// 3. The only variant that returns a ReproduceReport: reproduce_family_in,
|
||||
// which forces the consumer to re-specify the DataSource (symbol/window/pip)
|
||||
// that the family manifest already records. pip re-specified by hand here.
|
||||
println!(" --- reproduce_family_in(reg, id, data, env) -> ReproduceReport ---");
|
||||
let data = DataSource::Real {
|
||||
server: default_data_server(),
|
||||
symbol: SYMBOL.to_string(),
|
||||
from_ms: Some(FROM_MS),
|
||||
to_ms: Some(TO_MS),
|
||||
pip: 1.0,
|
||||
};
|
||||
match reproduce_family_in(®, &family_id, &data, &env) {
|
||||
Ok(report) => {
|
||||
let n = report.outcomes.len();
|
||||
let identical = report.outcomes.iter().filter(|(_, ok)| *ok).count();
|
||||
println!(" ReproduceReport: {identical}/{n} members bit-identical");
|
||||
for (member, ok) in &report.outcomes {
|
||||
println!(" {} {member}", if *ok { "IDENTICAL" } else { "DIVERGED " });
|
||||
}
|
||||
}
|
||||
Err(e) => println!(" reproduce_family_in failed: exit={} msg={}", e.exit_code, e.message),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user