refactor(aura-runner, aura-cli): retire the family builders and the CLI topology_hash duplicate
Slice 7 of the #319 retirement. aura-runner's blueprint_sweep_family / blueprint_walkforward_family / blueprint_mc_family lost their only production callers with the quintet and are gone (family.rs 889 -> 199 lines) together with their zero-caller helpers; reproduce's live inputs (DataSource, showcase/walkforward sources) stay. Coverage was ported first: member-computation identity + family-shared topology_hash now pin through a campaign-document twin in tests/exec.rs, reproduce's multi-member and MC-seed-label branches re-mint their fixtures directly. The CLI-side topology_hash helper goes with them — its keep-rationale was disproven (the record line's hash is computed in the runner's member machinery; minuted on the issue) — and c28_layering's file list drops the stale verb_sugar entry. The synthetic per-seed MC family remains an intentional retirement casualty, recorded in the test dispositions. refs #319
This commit is contained in:
@@ -663,8 +663,11 @@ pub fn introspect_cmd(cmd: crate::GraphIntrospectCmd, env: &aura_runner::project
|
||||
// The content id (#158) is the SHA256 of the same `blueprint_to_json`
|
||||
// bytes `graph build` emits; the identity id (#171) the SHA256 of the
|
||||
// debug-name-blind `blueprint_identity_json` form — both via the one
|
||||
// shared `crate::content_id` primitive `topology_hash` also uses, so all
|
||||
// surfaces agree by construction.
|
||||
// shared `crate::content_id` primitive, the same hash the run
|
||||
// manifest's `topology_hash` uses (computed inline in
|
||||
// `aura_runner::member`'s `run_signal_r`/`run_blueprint_member` — #319
|
||||
// Task 9 retired the CLI-side `topology_hash` wrapper this comment
|
||||
// used to name), so all surfaces agree by construction.
|
||||
let composite = match cmd.content_id.as_ref() {
|
||||
Some(Some(file)) => {
|
||||
let text = match std::fs::read_to_string(file) {
|
||||
|
||||
+157
-397
@@ -15,26 +15,33 @@ use aura_core::{Scalar, Timestamp};
|
||||
#[cfg(test)]
|
||||
use aura_core::ScalarKind;
|
||||
#[cfg(test)]
|
||||
use aura_core::Cell;
|
||||
#[cfg(test)]
|
||||
use aura_composites::StopRule;
|
||||
use aura_engine::{
|
||||
blueprint_from_json, blueprint_to_json, f64_field, join_on_ts,
|
||||
Composite, JoinedRow,
|
||||
blueprint_from_json, f64_field, join_on_ts,
|
||||
JoinedRow,
|
||||
SelectionMode,
|
||||
};
|
||||
// `blueprint_to_json`/`Composite` lost their last production caller with
|
||||
// `topology_hash` (#319 Task 9 — the CLI-side wrapper retired, its survival
|
||||
// rationale disproven); reached only from the test module now, mirroring
|
||||
// `Bias` below.
|
||||
#[cfg(test)]
|
||||
use aura_engine::{blueprint_to_json, Composite};
|
||||
#[cfg(test)]
|
||||
use aura_engine::{window_of, ColumnarTrace, Harness, VecSource};
|
||||
use aura_registry::{group_families, rank_by, FamilyMember, NameKind, RunTraces};
|
||||
// `FamilyKind`/`mc_member_reports`/`sweep_member_reports`/`PlateauMode` are only
|
||||
// reached from the test module's family-builder reproduce-fixture tests (#319
|
||||
// Task 8 — the quintet's own production callers of these are retired); the
|
||||
// imports are test-only, mirroring `Bias` below.
|
||||
// `FamilyKind` is only reached from the test module's reproduce-fixture tests
|
||||
// (#319 Task 8 — the quintet's own production callers of these are retired);
|
||||
// the import is test-only, mirroring `Bias` below.
|
||||
#[cfg(test)]
|
||||
use aura_registry::{mc_member_reports, sweep_member_reports, FamilyKind, PlateauMode};
|
||||
use aura_registry::FamilyKind;
|
||||
#[cfg(test)]
|
||||
use aura_registry::Registry;
|
||||
use aura_backtest::RunReport;
|
||||
#[cfg(test)]
|
||||
use aura_backtest::{SweepFamily, WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS};
|
||||
use aura_backtest::{WF_REAL_IS_NS, WF_REAL_OOS_NS, WF_REAL_STEP_NS};
|
||||
use aura_measurement::information_coefficient;
|
||||
// The C28 assembly position (#295): the member-run recipe, the axis/translate
|
||||
// helpers, and the harness-assembly constants all live in `aura-runner` now;
|
||||
@@ -46,18 +53,16 @@ use aura_runner::member::{
|
||||
use aura_runner::{TapPlan, TapSubscription};
|
||||
#[cfg(test)]
|
||||
use aura_runner::member::{run_blueprint_member, wrap_r, SYNTHETIC_PIP_SIZE};
|
||||
// The family builders (blueprint sweep / walk-forward / MC) and the shared
|
||||
// `DataSource` data provider / `--select` objective `Selection` live in
|
||||
// `aura_runner::family` (#295 Task 8); with the quintet's own production
|
||||
// callers retired (#319 Task 8), they are only reached from the test
|
||||
// module's family-builder tests now (Task 9 owns their eventual removal) —
|
||||
// the imports are test-only, mirroring `Bias` below.
|
||||
// The shared `DataSource` data provider lives in `aura_runner::family`
|
||||
// (#295 Task 8) — its production caller is `aura_runner::reproduce`;
|
||||
// `showcase_prices`/`synthetic_walk_sources` are its synthetic stream
|
||||
// primitives, reached here only from the test module's reproduce-fixture
|
||||
// tests (#319 Task 9 — the family builders that used to call them in this
|
||||
// crate are retired). The imports are test-only, mirroring `Bias` below.
|
||||
#[cfg(test)]
|
||||
use aura_runner::family::{
|
||||
blueprint_mc_family, blueprint_sweep_family, blueprint_walkforward_family, DataSource, Selection,
|
||||
};
|
||||
use aura_runner::family::{synthetic_walk_sources, DataSource};
|
||||
#[cfg(test)]
|
||||
use aura_runner::family::{render_bind_error, select_winner, showcase_prices};
|
||||
use aura_runner::family::showcase_prices;
|
||||
// `reproduce_family_in` (#295) lives in `aura_runner::reproduce`, returning
|
||||
// `Result<_, aura_runner::RunnerError>`; the test module's own call sites
|
||||
// unwrap it (the production dispatch arm is `dispatch_reproduce`).
|
||||
@@ -94,19 +99,18 @@ use aura_runner::member::CostLeg;
|
||||
use aura_backtest::{summarize, summarize_r};
|
||||
#[cfg(test)]
|
||||
use aura_engine::GraphBuilder;
|
||||
#[cfg(test)]
|
||||
use aura_std::Sub;
|
||||
// `std_vocabulary` is now only reached through `aura_runner::project::Env::resolve` in production
|
||||
// code; the test module still builds reference blueprints against it directly, so
|
||||
// the import is test-only.
|
||||
#[cfg(test)]
|
||||
use aura_vocabulary::std_vocabulary;
|
||||
// `Bias`/`Sma` are only reached from the test module; the imports are test-only.
|
||||
// `Bias` is only reached from the test module; the import is test-only.
|
||||
// `Sub`/`Sma` lost their last test users with #319 Task 9's family-builder
|
||||
// unit-test retirement (they built the deleted tests' probe graphs) — no
|
||||
// longer imported here.
|
||||
#[cfg(test)]
|
||||
use aura_strategy::Bias;
|
||||
#[cfg(test)]
|
||||
use aura_std::Sma;
|
||||
#[cfg(test)]
|
||||
use std::sync::mpsc;
|
||||
#[cfg(test)]
|
||||
use std::collections::HashSet;
|
||||
@@ -574,31 +578,20 @@ fn runs_family(id: &str, rank: Option<&str>, env: &aura_runner::project::Env) {
|
||||
// --- r-sma harness (the SMA-cross signal scored in R) --------------------
|
||||
|
||||
/// SHA256 (hex) of a canonical (#164) blueprint JSON string — the content id (#158).
|
||||
/// The single hashing primitive, shared by [`topology_hash`] (from a live `Composite`)
|
||||
/// and the op-script `graph introspect --content-id` path (`crate::content_id`), so the
|
||||
/// two surfaces agree by construction over the same canonical bytes. Research-side
|
||||
/// The single hashing primitive: fed a live `Composite`'s canonical bytes
|
||||
/// (`blueprint_to_json`) this is the run manifest's `topology_hash` — the
|
||||
/// same computation `aura_runner::member`'s `run_signal_r`/
|
||||
/// `run_blueprint_member` inline on the record-line path (#319 Task 9: the
|
||||
/// CLI-side `topology_hash` wrapper that used to duplicate this call is
|
||||
/// retired, its survival rationale disproven — its only callers were the
|
||||
/// family-builder-range unit tests, now gone with the builders) — and fed the
|
||||
/// op-script `graph introspect --content-id` path's bytes (`crate::content_id`)
|
||||
/// it is the SAME hash, so the two surfaces agree by construction. Research-side
|
||||
/// (aura-cli), off the frozen engine (invariant 8).
|
||||
fn content_id(canonical_json: &str) -> String {
|
||||
aura_research::content_id_of(canonical_json)
|
||||
}
|
||||
|
||||
/// SHA256 (hex) of the canonical (#164, no-trailing-newline) serialization of a
|
||||
/// signal blueprint — the run's `topology_hash` (#158).
|
||||
///
|
||||
/// #319 Task 8 finding: this primitive's only production caller was
|
||||
/// `validate_and_register_axes` (retired with the quintet); `exec`'s own
|
||||
/// record line gets its `manifest.topology_hash` from `aura_runner::member`'s
|
||||
/// own internal computation, never through this fn. It is kept alive
|
||||
/// (production-visible, not test-gated) per the retirement plan's explicit
|
||||
/// instruction, and still exercised directly by the family-builder-range
|
||||
/// unit tests below (Task 9 territory) — hence `#[allow(dead_code)]` rather
|
||||
/// than deletion. Orchestrator: confirm whether `exec` should route through
|
||||
/// this fn after all, or whether the KEEP was based on a stale assumption.
|
||||
#[allow(dead_code)]
|
||||
fn topology_hash(signal: &Composite) -> String {
|
||||
content_id(&blueprint_to_json(signal).expect("a buildable signal serializes"))
|
||||
}
|
||||
|
||||
/// One measurement run's IC scalar + its permutation-null significance, for stdout.
|
||||
#[derive(serde::Serialize)]
|
||||
struct IcReport {
|
||||
@@ -1099,10 +1092,10 @@ fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &au
|
||||
};
|
||||
if has_bias {
|
||||
// #246 override set (Task 3): validated against the un-reopened
|
||||
// wrapped OPEN space via `override_paths` (the strict, erroring
|
||||
// variant `blueprint_sweep_family` also resolves its `--axis` grid
|
||||
// through), so an unmatched path refuses with that same shared
|
||||
// prose (`member.rs:768-771`) rather than a second wording.
|
||||
// wrapped OPEN space via `override_paths` (the single strict,
|
||||
// erroring axis-resolution path this shell drives), so an
|
||||
// unmatched path refuses with that same shared prose
|
||||
// (`member.rs:768-771`) rather than a second wording.
|
||||
let raw_space = blueprint_axis_probe(&doc, env).param_space();
|
||||
let axes: Vec<(String, Vec<Scalar>)> = ov.iter().map(|(p, v)| (p.clone(), vec![*v])).collect();
|
||||
let paths = override_paths(&axes, &raw_space, &signal).unwrap_or_else(|e| {
|
||||
@@ -1541,35 +1534,6 @@ fn main() {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Audit follow-up to #247/#269: `render_bind_error` is exhaustive — the
|
||||
/// axis-usage variants the old catch-all Debug-framed render as prose
|
||||
/// naming the axis, with no Rust identifier on the user's stderr.
|
||||
#[test]
|
||||
fn render_bind_error_prose_covers_the_axis_usage_variants() {
|
||||
let dup = render_bind_error(&aura_engine::BindError::DuplicateBinding("a.b".into()));
|
||||
assert!(dup.contains("a.b") && dup.contains("bound twice"), "{dup}");
|
||||
assert!(!dup.contains("DuplicateBinding"), "Debug leak: {dup}");
|
||||
let empty = render_bind_error(&aura_engine::BindError::EmptyAxis("a.b".into()));
|
||||
assert!(empty.contains("a.b") && empty.contains("no values"), "{empty}");
|
||||
assert!(!empty.contains("EmptyAxis"), "Debug leak: {empty}");
|
||||
let range = render_bind_error(&aura_engine::BindError::EmptyRange("a.b".into()));
|
||||
assert!(range.contains("a.b") && !range.contains("EmptyRange"), "Debug leak: {range}");
|
||||
}
|
||||
|
||||
/// Audit follow-up to #247/#269: a `Compile` fault at the sweep boundary —
|
||||
/// a blueprint defect, not an axis usage error — renders as a prose frame
|
||||
/// that names the situation and labels the embedded compile detail as
|
||||
/// internal, instead of leaking the bare Debug struct as the whole message.
|
||||
#[test]
|
||||
fn render_bind_error_frames_a_compile_fault_as_prose_with_labeled_detail() {
|
||||
let msg = render_bind_error(&aura_engine::BindError::Compile(
|
||||
aura_engine::CompileError::BadInteriorIndex,
|
||||
));
|
||||
assert!(msg.contains("failed to bootstrap"), "{msg}");
|
||||
assert!(msg.contains("internal detail"), "{msg}");
|
||||
assert!(!msg.starts_with("Compile"), "the frame must lead with prose: {msg}");
|
||||
}
|
||||
|
||||
/// An empty (or absent) archive is informational absence, not a fault
|
||||
/// (#264), but under the all-JSON namespace reshape (#273) stdout must
|
||||
/// stay pure NDJSON: `data_list_report` returns an empty `Vec` rather
|
||||
@@ -1718,17 +1682,6 @@ mod tests {
|
||||
.expect("loads")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn select_winner_refuses_plateau_without_a_lattice() {
|
||||
// A plateau request with no lattice (a random sweep would yield None) is
|
||||
// refused, never silently argmaxed. The refuse short-circuits before the
|
||||
// family is read, so an empty family is fine here.
|
||||
let fam = SweepFamily { space: vec![], points: vec![] };
|
||||
let err = select_winner(&fam, "total_pips", Selection::Plateau(PlateauMode::Mean), None)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("requires a grid sweep"), "refuse message: {err}");
|
||||
}
|
||||
|
||||
fn cmp_member(key: &str, ts: &[i64], vals: &[f64]) -> FamilyMember {
|
||||
cmp_member_win(key, ts, vals, (0, 0))
|
||||
}
|
||||
@@ -1981,45 +1934,6 @@ mod tests {
|
||||
assert_eq!(d.full_window(&env), window_of(&s).unwrap());
|
||||
}
|
||||
|
||||
/// A multi-column blueprint over synthetic data (a single close walk)
|
||||
/// refuses honestly through the builders' exit-free Err contract, naming
|
||||
/// the beyond-close columns and the --real remedy — never a panic from a
|
||||
/// source-count mismatch. High/low-consuming, closed (mc requires it),
|
||||
/// with the mandatory `bias` output.
|
||||
const OHLC_REFUSAL_BLUEPRINT: &str = r#"{
|
||||
"format_version": 1,
|
||||
"blueprint": {
|
||||
"name": "hl_range",
|
||||
"nodes": [ {"primitive":{"type":"Sub"}} ],
|
||||
"edges": [],
|
||||
"input_roles": [
|
||||
{"name":"high","targets":[{"node":0,"slot":0}],"source":"F64"},
|
||||
{"name":"low","targets":[{"node":0,"slot":1}],"source":"F64"}
|
||||
],
|
||||
"output": [{"node":0,"field":0,"name":"bias"}]
|
||||
}
|
||||
}"#;
|
||||
|
||||
#[test]
|
||||
fn synthetic_data_refuses_a_multi_column_blueprint() {
|
||||
let env = aura_runner::project::Env::std();
|
||||
let err = blueprint_mc_family(OHLC_REFUSAL_BLUEPRINT, 2, &DataSource::Synthetic, &env)
|
||||
.expect_err("a high/low blueprint cannot run over the synthetic close walk");
|
||||
assert_eq!(
|
||||
err,
|
||||
"strategy \"hl_range\" consumes columns beyond close (high, low) — synthetic \
|
||||
data generates a close series only; run with --real <SYMBOL>"
|
||||
);
|
||||
let err = blueprint_sweep_family(
|
||||
OHLC_REFUSAL_BLUEPRINT,
|
||||
&[("x".to_string(), vec![Scalar::i64(1)])],
|
||||
&DataSource::Synthetic,
|
||||
&env,
|
||||
)
|
||||
.expect_err("the synthetic sweep path refuses the same shape");
|
||||
assert!(err.contains("consumes columns beyond close"), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wf_real_roller_sizes_are_90_30_30_days_in_ns() {
|
||||
// Independent expected value: a day reconstructed from its time units
|
||||
@@ -2043,50 +1957,6 @@ mod tests {
|
||||
// below is dropped for the same reason (`member_key` no longer exists); the
|
||||
// doc comment is trimmed to match.
|
||||
|
||||
/// Property: a `blueprint_sweep_family` member built from a serialized signal is
|
||||
/// the SAME trading result as the cycle-1 single run of that signal at the same
|
||||
/// params — the loaded-blueprint sweep reuses the identical `wrap_r` run path
|
||||
/// (the keystone). Every member of one family carries the SAME `topology_hash` (the
|
||||
/// loaded signal's, the deviation from the Rust-built mirror).
|
||||
#[test]
|
||||
fn blueprint_sweep_member_equals_single_run_and_shares_topology_hash() {
|
||||
// An OPEN signal (both SMA knobs free) so the sweep can bind them by name; the
|
||||
// serialized doc round-trips to the topology the single run hashes.
|
||||
let env = aura_runner::project::Env::std();
|
||||
let open = load_open_r_sma();
|
||||
let doc = blueprint_to_json(&open).expect("serializes");
|
||||
let data = DataSource::Synthetic;
|
||||
// fast pinned at 2, slow varied over {4, 6}: a 2x1 grid, slow the varying axis.
|
||||
let axes = vec![
|
||||
("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)]),
|
||||
("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]),
|
||||
];
|
||||
let family = blueprint_sweep_family(&doc, &axes, &data, &env).expect("named axes resolve");
|
||||
assert_eq!(family.points.len(), 2, "2x1 grid -> 2 members");
|
||||
|
||||
// (b) every member carries the shared topology_hash of the loaded signal.
|
||||
let topo = topology_hash(&open);
|
||||
assert_eq!(topo.len(), 64, "topology_hash is a 64-hex SHA256");
|
||||
for pt in &family.points {
|
||||
assert_eq!(pt.report.manifest.topology_hash.as_deref(), Some(topo.as_str()));
|
||||
}
|
||||
|
||||
// (a) the slow=4 member reproduces the cycle-1 single run at fast=2, slow=4 —
|
||||
// same equity/exposure stream (total_pips/max_drawdown/bias_sign_flips) and the
|
||||
// same topology_hash, proving the loaded blueprint runs through the identical path.
|
||||
let single = run_signal_r(
|
||||
load_open_r_sma(),
|
||||
&[Scalar::i64(2), Scalar::i64(4)],
|
||||
RunData::Synthetic,
|
||||
0,
|
||||
&env,
|
||||
TapPlan::record_all(),
|
||||
);
|
||||
let member4 = &family.points[0].report; // slow=4 is the first odometer point
|
||||
assert_eq!(member4.metrics, single.metrics, "loaded sweep member == single run");
|
||||
assert_eq!(member4.manifest.topology_hash, single.manifest.topology_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_axis_probe_lists_prefixed_open_knobs() {
|
||||
// The open fixture's two SMA lengths are the sweepable knobs; the probe
|
||||
@@ -2104,97 +1974,15 @@ mod tests {
|
||||
assert!(blueprint_axis_probe(closed, &env).param_space().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_walkforward_family_refits_each_window() {
|
||||
// The closed blueprint's two bound SMA lengths are re-fit per IS window over
|
||||
// a 2x2 grid via the #246 bound-override reopen path (bound = overridable default).
|
||||
let env = aura_runner::project::Env::std();
|
||||
let doc = include_str!("../examples/r_sma.json");
|
||||
let axes = vec![
|
||||
("sma_signal.fast.length".to_string(), vec![Scalar::i64(2), Scalar::i64(3)]),
|
||||
("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]),
|
||||
];
|
||||
let result = blueprint_walkforward_family(doc, &axes, &DataSource::Synthetic, Selection::Argmax, &env);
|
||||
// 24/12/12 over the 60-bar synthetic span -> 3 rolling windows.
|
||||
assert_eq!(result.windows.len(), 3, "three rolling IS/OOS windows");
|
||||
for w in &result.windows {
|
||||
assert_eq!(w.run.chosen_params.len(), 2, "both axes re-fit each window");
|
||||
assert!(w.run.oos_report.metrics.r.is_some(), "OOS record is R-metrics");
|
||||
}
|
||||
// reduce-mode retains no raw pip curve -> the stitched pip-equity is empty.
|
||||
assert!(result.stitched_oos_equity.is_empty(), "no raw pip curve in reduce-mode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_mc_family_seeds_differ() {
|
||||
// MC over a CLOSED signal (both SMA knobs bound): 3 seeds -> 3 draws, one shared
|
||||
// topology_hash, and DIFFERING metrics — the seed reaches the DATA (a distinct
|
||||
// synthetic walk per draw), not just the manifest label. The anti-degenerate guard:
|
||||
// a regression to seed-as-label-only would make the three draws identical.
|
||||
let env = aura_runner::project::Env::std();
|
||||
let closed = load_closed_r_sma();
|
||||
let doc = blueprint_to_json(&closed).expect("serializes");
|
||||
let family = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env).expect("closed blueprint");
|
||||
|
||||
assert_eq!(family.draws.len(), 3, "one draw per seed");
|
||||
assert_eq!(family.draws.iter().map(|d| d.seed).collect::<Vec<_>>(), vec![1, 2, 3]);
|
||||
let topo = family.draws[0].report.manifest.topology_hash.clone();
|
||||
assert!(topo.is_some(), "members carry a topology_hash");
|
||||
assert!(
|
||||
family.draws.iter().all(|d| d.report.manifest.topology_hash == topo),
|
||||
"all members share one topology_hash"
|
||||
);
|
||||
let m: Vec<_> = family.draws.iter().map(|d| &d.report.metrics).collect();
|
||||
assert!(m[0] != m[1] || m[1] != m[2], "seeds must yield differing realizations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_mc_family_rejects_vacuous_deep_lookback() {
|
||||
// Property: the mc family builder REFUSES a silent-vacuous Monte-Carlo — one where
|
||||
// every per-seed draw collapses to a bit-identical realization — by RETURNING a named
|
||||
// error rather than an `Ok` family that looks like a real (but indistinguishable)
|
||||
// distribution. A CLOSED deep-lookback signal whose slow SMA length (60) equals the
|
||||
// fixed 60-bar synthetic walk never warms, so every seed yields zero trades and thus
|
||||
// identical metrics; that is a wrong result with no error (C10 refuse-don't-guess).
|
||||
let env = aura_runner::project::Env::std();
|
||||
// slow len (60) == walk len -> never warms
|
||||
let mut g = GraphBuilder::new("deep_probe");
|
||||
let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(2)));
|
||||
let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(60)));
|
||||
let spread = g.add(Sub::builder());
|
||||
let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5)));
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(price, vec![fast.input("series"), slow.input("series")]);
|
||||
g.connect(fast.output("value"), spread.input("lhs"));
|
||||
g.connect(slow.output("value"), spread.input("rhs"));
|
||||
g.connect(spread.output("value"), exposure.input("signal"));
|
||||
g.expose(exposure.output("bias"), "bias");
|
||||
let deep = g.build().expect("deep probe wiring resolves");
|
||||
let doc = blueprint_to_json(&deep).expect("serializes");
|
||||
let err = blueprint_mc_family(&doc, 3, &DataSource::Synthetic, &env)
|
||||
.expect_err("a vacuous (all-identical) Monte-Carlo is rejected, not returned");
|
||||
assert!(
|
||||
err.contains("vacuous") || err.contains("identical"),
|
||||
"names the vacuous/degenerate condition: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blueprint_mc_family_rejects_an_open_blueprint() {
|
||||
// Property: the mc family builder REFUSES an open blueprint (free knobs) by RETURNING
|
||||
// a named error — never a hidden process exit — so the closed-blueprint precondition
|
||||
// is unit-testable (the IO wrapper renders it to stderr + exit 2, mirroring the sibling
|
||||
// blueprint_sweep_family). MC binds no axis, so a free knob would have no binder; the
|
||||
// rejection pre-empts the downstream compile_with_params arity panic.
|
||||
let env = aura_runner::project::Env::std();
|
||||
// both SMA knobs free -> non-empty param_space
|
||||
let open = load_open_r_sma();
|
||||
let doc = blueprint_to_json(&open).expect("serializes");
|
||||
let err = blueprint_mc_family(&doc, 4, &DataSource::Synthetic, &env)
|
||||
.expect_err("an open blueprint is rejected, not run");
|
||||
assert!(err.contains("closed blueprint"), "names the closed-blueprint requirement: {err}");
|
||||
}
|
||||
|
||||
/// Property, ported (#319 — the family builders retired, this test's
|
||||
/// original minted its 2-member family via the now-deleted
|
||||
/// `blueprint_sweep_family`): `reproduce_family_in` re-derives EVERY
|
||||
/// member of a persisted multi-member family bit-identically (index
|
||||
/// alignment across members, not just the single-member case its
|
||||
/// stop-regime/costed-member siblings pin) — including the open-at-end
|
||||
/// member. The family is minted directly via `run_blueprint_member`
|
||||
/// (the same reduce-mode member path any minting mechanism drives),
|
||||
/// mirroring the sibling reproduce tests below.
|
||||
#[test]
|
||||
fn reproduce_family_re_derives_every_member_bit_identically() {
|
||||
// a unique temp runs store so the on-disk family + blueprint store do not collide.
|
||||
@@ -2208,20 +1996,44 @@ mod tests {
|
||||
let open = load_open_r_sma();
|
||||
let doc = blueprint_to_json(&open).expect("serializes");
|
||||
let data = DataSource::Synthetic;
|
||||
// 2x grid over slow.length {4,6} at fast=2 — slow=4 is the open-at-end member.
|
||||
let axes = vec![
|
||||
("sma_signal.fast.length".to_string(), vec![Scalar::i64(2)]),
|
||||
("sma_signal.slow.length".to_string(), vec![Scalar::i64(4), Scalar::i64(6)]),
|
||||
];
|
||||
let family = blueprint_sweep_family(&doc, &axes, &data, &env).expect("axes resolve");
|
||||
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
|
||||
// topology_hash's own two-line body, inlined (mirrors
|
||||
// `aura_runner::member::run_signal_r`'s and `run_blueprint_member`'s
|
||||
// own copy — the #319 Task 9 finding: the CLI-side `topology_hash`
|
||||
// wrapper is retired, its only production/test callers gone).
|
||||
let topo = content_id(&blueprint_to_json(&reload()).expect("a buildable signal serializes"));
|
||||
let binding = aura_runner::binding::resolve_binding("everymember", reload().input_roles(), &BTreeMap::new())
|
||||
.expect("the price role resolves");
|
||||
let window = data.full_window(&env);
|
||||
let pip = data.pip_size();
|
||||
// fast pinned at 2, slow varied over {4, 6}: two members, slow=6 the open-at-end one.
|
||||
let space = blueprint_axis_probe(&doc, &env).param_space();
|
||||
let reports: Vec<RunReport> = [4_i64, 6]
|
||||
.iter()
|
||||
.map(|&slow| {
|
||||
run_blueprint_member(
|
||||
reload(),
|
||||
&[Cell::from_i64(2), Cell::from_i64(slow)],
|
||||
&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,
|
||||
&[],
|
||||
"",
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// persist exactly as run_blueprint_sweep does: store the blueprint, append the family.
|
||||
let topo = family.points[0].report.manifest.topology_hash.clone().expect("topo");
|
||||
let canonical =
|
||||
blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap();
|
||||
// persist exactly as any family-minting mechanism does: store the blueprint, append the family.
|
||||
let canonical = blueprint_to_json(&reload()).unwrap();
|
||||
reg.put_blueprint(&topo, &canonical).expect("store blueprint");
|
||||
let id = reg
|
||||
.append_family("repro", FamilyKind::Sweep, &sweep_member_reports(&family))
|
||||
.append_family("repro", FamilyKind::Sweep, &reports)
|
||||
.expect("append");
|
||||
|
||||
// reproduce: every member re-derives bit-identically (incl the open-at-end member).
|
||||
@@ -2258,7 +2070,11 @@ mod tests {
|
||||
let closed = load_closed_r_sma();
|
||||
let doc = blueprint_to_json(&closed).expect("serializes");
|
||||
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
|
||||
let topo = topology_hash(&reload());
|
||||
// topology_hash's own two-line body, inlined (mirrors
|
||||
// `aura_runner::member::run_signal_r`'s and `run_blueprint_member`'s
|
||||
// own copy — the #319 Task 9 finding: the CLI-side `topology_hash`
|
||||
// wrapper is retired, its only production/test callers gone).
|
||||
let topo = content_id(&blueprint_to_json(&reload()).expect("a buildable signal serializes"));
|
||||
let space = blueprint_axis_probe(&doc, &env).param_space();
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window(&env);
|
||||
@@ -2324,7 +2140,11 @@ mod tests {
|
||||
let data = DataSource::Synthetic;
|
||||
let doc = blueprint_to_json(&load_closed_r_sma()).expect("serializes");
|
||||
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
|
||||
let topo = topology_hash(&reload());
|
||||
// topology_hash's own two-line body, inlined (mirrors
|
||||
// `aura_runner::member::run_signal_r`'s and `run_blueprint_member`'s
|
||||
// own copy — the #319 Task 9 finding: the CLI-side `topology_hash`
|
||||
// wrapper is retired, its only production/test callers gone).
|
||||
let topo = content_id(&blueprint_to_json(&reload()).expect("a buildable signal serializes"));
|
||||
let space = blueprint_axis_probe(&doc, &env).param_space();
|
||||
let pip = data.pip_size();
|
||||
let window = data.full_window(&env);
|
||||
@@ -2375,6 +2195,14 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// Property, ported (#319 — the family builders retired, this test's
|
||||
/// original minted its 3-draw MC family via the now-deleted
|
||||
/// `blueprint_mc_family`): `reproduce_family_in` re-derives every MC
|
||||
/// member bit-identically, each seed's synthetic walk reconstructed from
|
||||
/// `manifest.seed` (the realization branch) — minted directly via
|
||||
/// `run_blueprint_member` over `synthetic_walk_sources(seed)`, the same
|
||||
/// per-seed source `aura_runner::reproduce`'s own synthetic-MC branch
|
||||
/// reconstructs.
|
||||
#[test]
|
||||
fn reproduce_family_re_derives_every_mc_member_bit_identically() {
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
@@ -2387,16 +2215,34 @@ mod tests {
|
||||
let env = aura_runner::project::Env::std();
|
||||
let closed = load_closed_r_sma();
|
||||
let doc = blueprint_to_json(&closed).expect("serializes");
|
||||
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
|
||||
let data = DataSource::Synthetic;
|
||||
let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint");
|
||||
// topology_hash's own two-line body, inlined (mirrors
|
||||
// `aura_runner::member::run_signal_r`'s and `run_blueprint_member`'s
|
||||
// own copy — the #319 Task 9 finding: the CLI-side `topology_hash`
|
||||
// wrapper is retired, its only production/test callers gone).
|
||||
let topo = content_id(&blueprint_to_json(&reload()).expect("a buildable signal serializes"));
|
||||
let binding = aura_runner::binding::resolve_binding("everymc", reload().input_roles(), &BTreeMap::new())
|
||||
.expect("the price role resolves");
|
||||
let space = blueprint_axis_probe(&doc, &env).param_space();
|
||||
assert!(space.is_empty(), "MC binds no axis — the fixture must be closed");
|
||||
|
||||
// persist exactly as run_blueprint_mc does: store the blueprint, append the MC family.
|
||||
let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo");
|
||||
let canonical =
|
||||
blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap();
|
||||
let reports: Vec<RunReport> = (1_u64..=3)
|
||||
.map(|seed| {
|
||||
let sources = synthetic_walk_sources(seed);
|
||||
let window = window_of(&sources).expect("non-empty synthetic walk");
|
||||
run_blueprint_member(
|
||||
reload(), &[], &space, sources, window, seed, data.pip_size(), &topo, &env,
|
||||
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], "",
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// persist exactly as any MC-minting mechanism does: store the blueprint, append the family.
|
||||
let canonical = blueprint_to_json(&reload()).unwrap();
|
||||
reg.put_blueprint(&topo, &canonical).expect("store blueprint");
|
||||
let id = reg
|
||||
.append_family("mcrepro", FamilyKind::MonteCarlo, &mc_member_reports(&family))
|
||||
.append_family("mcrepro", FamilyKind::MonteCarlo, &reports)
|
||||
.expect("append");
|
||||
|
||||
// reproduce: every MC member re-derives bit-identically (its seed-driven walk is
|
||||
@@ -2411,73 +2257,13 @@ mod tests {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #246/#328: a fully-bound (closed) blueprint IS sweepable — a RAW axis
|
||||
/// naming a bound param re-opens it (the bound value is the default), so
|
||||
/// the retired "fully bound; nothing to sweep" refusal must not resurface.
|
||||
/// Inverse guard: an axis naming NEITHER an open nor a bound param gets
|
||||
/// the one clear boundary message (not a terse `UnknownKnob` debug leak).
|
||||
#[test]
|
||||
fn blueprint_sweep_family_overrides_a_bound_param_and_names_unknown_axes() {
|
||||
let env = aura_runner::project::Env::std();
|
||||
// same fixture the retired test swept: both SMA knobs bound.
|
||||
let closed = load_closed_r_sma();
|
||||
let doc = blueprint_to_json(&closed).expect("serializes");
|
||||
|
||||
// (a) override axis (RAW name, #328): two members, no refusal
|
||||
let axes = vec![(
|
||||
"fast.length".to_string(),
|
||||
vec![Scalar::i64(2), Scalar::i64(4)],
|
||||
)];
|
||||
let fam = blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env)
|
||||
.expect("a bound param is a default — the axis overrides it");
|
||||
assert_eq!(fam.points.len(), 2);
|
||||
|
||||
// (b) unknown axis: the boundary message, no UnknownKnob leak
|
||||
let bad = vec![("nope".to_string(), vec![Scalar::i64(1)])];
|
||||
let err = blueprint_sweep_family(&doc, &bad, &DataSource::Synthetic, &env)
|
||||
.expect_err("an axis matching neither space is refused");
|
||||
assert!(err.contains("names no param"), "boundary message, got: {err}");
|
||||
assert!(err.contains("--list-axes"), "must point at --list-axes: {err}");
|
||||
assert!(!err.contains("UnknownKnob"), "must not leak the debug render: {err}");
|
||||
}
|
||||
|
||||
/// #249/#328: an axis-reopened bound param flows through `params` ("what
|
||||
/// varied") and must NOT also appear in `defaults` ("what was held") — the
|
||||
/// two are disjoint by construction. Sweeping `fast.length` (RAW, #328) on
|
||||
/// the fully bound r_sma example leaves `slow.length`/`bias.scale`
|
||||
/// untouched (they stay in `defaults`), while `fast.length` moves to
|
||||
/// `params` and drops out of `defaults` entirely. Both `manifest.params`
|
||||
/// and `manifest.defaults` keys are RAW (#328, batch 2 flips defaults too).
|
||||
#[test]
|
||||
fn sweep_override_excludes_the_reopened_default_from_the_manifest() {
|
||||
let env = aura_runner::project::Env::std();
|
||||
let closed = load_closed_r_sma();
|
||||
let doc = blueprint_to_json(&closed).expect("serializes");
|
||||
let axes = vec![("fast.length".to_string(), vec![Scalar::i64(2)])];
|
||||
let fam = blueprint_sweep_family(&doc, &axes, &DataSource::Synthetic, &env)
|
||||
.expect("a bound param is a default — the axis overrides it");
|
||||
assert_eq!(fam.points.len(), 1);
|
||||
let manifest = &fam.points[0].report.manifest;
|
||||
|
||||
let param_names: Vec<&str> = manifest.params.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(param_names.contains(&"fast.length"), "swept axis is a raw param: {param_names:?}");
|
||||
|
||||
let default_names: Vec<&str> = manifest.defaults.iter().map(|(n, _)| n.as_str()).collect();
|
||||
assert!(
|
||||
!default_names.contains(&"fast.length"),
|
||||
"the reopened default must not also appear in defaults: {default_names:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
default_names,
|
||||
["slow.length", "bias.scale"],
|
||||
"the untouched bound params stay in defaults, RAW: {default_names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Property: an MC family's `aura reproduce` lines carry the member's own `seed=<N>`
|
||||
/// label. MC members hold no tuning params (the params-join is empty), so the line would
|
||||
/// otherwise print a BLANK member label; the seed is each draw's realization identity and
|
||||
/// must show. Sweep / walk-forward labels still echo their params (covered elsewhere).
|
||||
/// Property, ported (#319 — the family builders retired, same minting
|
||||
/// swap as the sibling MC-reproduce test above): an MC family's `aura
|
||||
/// reproduce` lines carry the member's own `seed=<N>` label. MC members
|
||||
/// hold no tuning params (the params-join is empty), so the line would
|
||||
/// otherwise print a BLANK member label; the seed is each draw's
|
||||
/// realization identity and must show. Sweep / walk-forward labels
|
||||
/// still echo their params (covered elsewhere).
|
||||
#[test]
|
||||
fn reproduce_mc_member_labels_carry_the_seed() {
|
||||
let dir = std::path::Path::new(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target/tmp"))
|
||||
@@ -2490,15 +2276,32 @@ mod tests {
|
||||
// MC binds no axis -> closed blueprint
|
||||
let closed = load_closed_r_sma();
|
||||
let doc = blueprint_to_json(&closed).expect("serializes");
|
||||
let reload = || blueprint_from_json(&doc, &|t| std_vocabulary(t)).expect("loads");
|
||||
let data = DataSource::Synthetic;
|
||||
let family = blueprint_mc_family(&doc, 3, &data, &env).expect("closed blueprint");
|
||||
// topology_hash's own two-line body, inlined (mirrors
|
||||
// `aura_runner::member::run_signal_r`'s and `run_blueprint_member`'s
|
||||
// own copy — the #319 Task 9 finding: the CLI-side `topology_hash`
|
||||
// wrapper is retired, its only production/test callers gone).
|
||||
let topo = content_id(&blueprint_to_json(&reload()).expect("a buildable signal serializes"));
|
||||
let binding = aura_runner::binding::resolve_binding("mcseedlabel", reload().input_roles(), &BTreeMap::new())
|
||||
.expect("the price role resolves");
|
||||
let space = blueprint_axis_probe(&doc, &env).param_space();
|
||||
|
||||
let topo = family.draws[0].report.manifest.topology_hash.clone().expect("topo");
|
||||
let canonical =
|
||||
blueprint_to_json(&blueprint_from_json(&doc, &|t| std_vocabulary(t)).unwrap()).unwrap();
|
||||
let reports: Vec<RunReport> = (1_u64..=3)
|
||||
.map(|seed| {
|
||||
let sources = synthetic_walk_sources(seed);
|
||||
let window = window_of(&sources).expect("non-empty synthetic walk");
|
||||
run_blueprint_member(
|
||||
reload(), &[], &space, sources, window, seed, data.pip_size(), &topo, &env,
|
||||
StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K }, &binding, &[], "",
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let canonical = blueprint_to_json(&reload()).unwrap();
|
||||
reg.put_blueprint(&topo, &canonical).expect("store blueprint");
|
||||
let id = reg
|
||||
.append_family("mcseed", FamilyKind::MonteCarlo, &mc_member_reports(&family))
|
||||
.append_family("mcseed", FamilyKind::MonteCarlo, &reports)
|
||||
.expect("append");
|
||||
|
||||
let rep = reproduce_family_in(®, &id, &data, &env).expect("reproduce_family_in succeeds over a well-formed persisted family");
|
||||
@@ -2552,43 +2355,10 @@ mod tests {
|
||||
assert_eq!(doc, doc2, "f64 blueprint param survives the store round-trip bit-identically");
|
||||
}
|
||||
|
||||
/// `topology_hash` is deterministic per signal and distinguishes topologies —
|
||||
/// the #158 reproducibility-anchor property.
|
||||
#[test]
|
||||
fn topology_hash_is_stable_and_distinguishes() {
|
||||
let closed = load_closed_r_sma();
|
||||
let open = load_open_r_sma();
|
||||
let h = topology_hash(&closed);
|
||||
assert_eq!(h, topology_hash(&closed), "same signal -> same hash");
|
||||
assert_ne!(h, topology_hash(&open), "distinct blueprints -> distinct hash");
|
||||
|
||||
// Same shape as the closed example, differing only in the BOUND fast-SMA
|
||||
// length (3 vs 2): the hash must still discriminate on bound param values,
|
||||
// not merely on topology shape — distinct sweep/mc members must key to
|
||||
// distinct store entries.
|
||||
let mut g = GraphBuilder::new("sma_signal");
|
||||
let fast = g.add(Sma::builder().named("fast").bind("length", Scalar::i64(3)));
|
||||
let slow = g.add(Sma::builder().named("slow").bind("length", Scalar::i64(4)));
|
||||
let spread = g.add(Sub::builder());
|
||||
let exposure = g.add(Bias::builder().named("bias").bind("scale", Scalar::f64(0.5)));
|
||||
let price = g.source_role("price", ScalarKind::F64);
|
||||
g.feed(price, vec![fast.input("series"), slow.input("series")]);
|
||||
g.connect(fast.output("value"), spread.input("lhs"));
|
||||
g.connect(slow.output("value"), spread.input("rhs"));
|
||||
g.connect(spread.output("value"), exposure.input("signal"));
|
||||
g.expose(exposure.output("bias"), "bias");
|
||||
let bound_variant = g.build().expect("bound-value probe wiring resolves");
|
||||
assert_ne!(
|
||||
h,
|
||||
topology_hash(&bound_variant),
|
||||
"same shape, different bound value -> different hash"
|
||||
);
|
||||
}
|
||||
|
||||
/// #158 acc 1 (content-id stability across the store round-trip): a blueprint's content
|
||||
/// id survives serialize -> reload -> re-serialize (#164 idempotence) — the property
|
||||
/// content-addressed reproduction rests on (the stored bytes re-hash to the members'
|
||||
/// topology_hash). `content_id` is the shared primitive `topology_hash` uses.
|
||||
/// manifest `topology_hash`, `content_id`'s own composite-facing computation).
|
||||
#[test]
|
||||
fn content_id_is_stable_across_the_store_round_trip() {
|
||||
let closed = load_closed_r_sma();
|
||||
@@ -2624,16 +2394,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// #158 acc 1 (cross-surface agreement): `topology_hash` (from a live `Composite`, the
|
||||
/// run/sweep path) and the op-script `graph introspect --content-id` surface are the
|
||||
/// SAME hash of the SAME canonical bytes — both go through the one `content_id`
|
||||
/// primitive. Pins that the two command paths cannot silently drift apart.
|
||||
#[test]
|
||||
fn topology_hash_is_the_content_id_of_the_canonical_form() {
|
||||
let sig = load_closed_r_sma();
|
||||
assert_eq!(topology_hash(&sig), content_id(&blueprint_to_json(&sig).expect("serializes")));
|
||||
}
|
||||
|
||||
/// The op-script twin of the closed r-sma example (`load_closed_r_sma`): same
|
||||
/// topology — SMA(2)/SMA(4) over one `price` role, spread, Bias with `scale`
|
||||
/// BOUND to 0.5 (boundness is identity-bearing) — differing only in debug names
|
||||
@@ -2663,7 +2423,7 @@ mod tests {
|
||||
.expect("op-script twin builds");
|
||||
assert_ne!(
|
||||
content_id(&json),
|
||||
topology_hash(&rust_built),
|
||||
content_id(&blueprint_to_json(&rust_built).expect("a buildable signal serializes")),
|
||||
"authoring paths keep distinct content ids"
|
||||
);
|
||||
let loaded = blueprint_from_json(&json, &|t| std_vocabulary(t)).expect("twin reloads");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! exec — the one executor verb (#319): campaign file, campaign id,
|
||||
//! blueprint single run. Flag discipline is per-leg.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod common;
|
||||
@@ -681,6 +682,124 @@ fn exec_campaign_walkforward_with_trades_emits_no_zero_trade_note() {
|
||||
assert!(!out.contains("recorded zero trades"), "a traded run emits no zero-trade note: {out}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 9: `aura-runner` family-builder retirement — the run-semantics half of
|
||||
// the retired `blueprint_sweep_family` unit test, ported onto the surviving
|
||||
// campaign path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// [`campaign_doc_json_for`]'s shape with exactly ONE declared axis
|
||||
/// (`fast.length`) at caller-chosen values — `slow.length`/`bias.scale` stay
|
||||
/// at their bound defaults (4 and 0.5, `examples/r_sma.json`), isolating the
|
||||
/// swept dimension for a clean member-by-member comparison.
|
||||
fn campaign_doc_json_one_axis(
|
||||
instrument: &str,
|
||||
bp_id: &str,
|
||||
proc_id: &str,
|
||||
window: (i64, i64),
|
||||
fast_values: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
r#"{{
|
||||
"format_version": 1,
|
||||
"kind": "campaign",
|
||||
"name": "member-parity",
|
||||
"data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }},
|
||||
"strategies": [ {{ "ref": {{ "content_id": "{bp}" }},
|
||||
"axes": {{ "fast.length": {{ "kind": "I64", "values": [{fast_values}] }} }} }} ],
|
||||
"process": {{ "ref": {{ "content_id": "{proc}" }} }},
|
||||
"seed": 7,
|
||||
"presentation": {{ "persist_taps": [], "emit": ["family_table"] }}
|
||||
}}"#,
|
||||
from = window.0,
|
||||
to = window.1,
|
||||
bp = bp_id,
|
||||
proc = proc_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Property, ported from the retired `blueprint_sweep_family` unit test
|
||||
/// `blueprint_sweep_member_equals_single_run_and_shares_topology_hash` (#319,
|
||||
/// the family builders retired): a campaign family member computes the
|
||||
/// IDENTICAL trading result a STANDALONE one-cell campaign at that same point
|
||||
/// computes — the sweep terminal reuses the same member-run path regardless
|
||||
/// of how many sibling points ride along — and every member of one family
|
||||
/// carries the SAME `topology_hash` (the loaded blueprint's, not a per-point
|
||||
/// value). `exec`'s blueprint leg has no `--real` (invariant 7: "a real-data
|
||||
/// single run is a one-cell campaign document"), so the "single run"
|
||||
/// comparator here IS the one-cell campaign the retirement itself designates
|
||||
/// as that replacement, run over the identical instrument+window as the
|
||||
/// multi-member family.
|
||||
#[test]
|
||||
fn exec_campaign_member_equals_a_standalone_single_cell_and_shares_topology_hash() {
|
||||
let (dir, _fixture) = fresh_project_with_data();
|
||||
let runs_dir = dir.join("runs");
|
||||
std::fs::remove_dir_all(&runs_dir).ok();
|
||||
let _cleanup = ScratchGuard(vec![
|
||||
ScratchPath::Dir(runs_dir.clone()),
|
||||
ScratchPath::File(dir.join("parity.process.json")),
|
||||
ScratchPath::File(dir.join("parity_family.campaign.json")),
|
||||
ScratchPath::File(dir.join("parity_single.campaign.json")),
|
||||
]);
|
||||
let bp_id = seed_blueprint(&dir, "exec-parity-seed");
|
||||
let proc_id = register_process_doc(&dir, "parity.process.json", SWEEP_ONLY_PROCESS_DOC);
|
||||
let window = (1709251200000, 1719791999999);
|
||||
|
||||
// (A) a 2-member family: fast.length in {2, 8}; slow.length/bias.scale
|
||||
// stay at their bound defaults.
|
||||
let doc_family = campaign_doc_json_one_axis("SYMA", &bp_id, &proc_id, window, "2, 8");
|
||||
write_doc(&dir, "parity_family.campaign.json", &doc_family);
|
||||
let (out_family, code_family) = run_code_in(&dir, &["exec", "parity_family.campaign.json"]);
|
||||
assert_eq!(code_family, Some(0), "family run: {out_family}");
|
||||
let family_members: Vec<serde_json::Value> = out_family
|
||||
.lines()
|
||||
.filter(|l| l.starts_with(r#"{"family_id":"#))
|
||||
.map(|l| serde_json::from_str(l).expect("member line parses"))
|
||||
.collect();
|
||||
assert_eq!(family_members.len(), 2, "a 2-value axis yields 2 members: {out_family}");
|
||||
let topo_values: HashSet<&str> = family_members
|
||||
.iter()
|
||||
.map(|m| m["report"]["manifest"]["topology_hash"].as_str().expect("topology_hash present"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
topo_values.len(),
|
||||
1,
|
||||
"every member of one family shares ONE topology_hash: {family_members:?}"
|
||||
);
|
||||
let member_8 = family_members
|
||||
.iter()
|
||||
.find(|m| {
|
||||
m["report"]["manifest"]["params"]
|
||||
.as_array()
|
||||
.expect("params array")
|
||||
.iter()
|
||||
.any(|e| e[0] == "fast.length" && e[1] == serde_json::json!({"I64": 8}))
|
||||
})
|
||||
.expect("the fast.length=8 member is present");
|
||||
|
||||
// (B) a standalone one-cell campaign pinned at the SAME point.
|
||||
let doc_single = campaign_doc_json_one_axis("SYMA", &bp_id, &proc_id, window, "8");
|
||||
write_doc(&dir, "parity_single.campaign.json", &doc_single);
|
||||
let (out_single, code_single) = run_code_in(&dir, &["exec", "parity_single.campaign.json"]);
|
||||
assert_eq!(code_single, Some(0), "single-cell run: {out_single}");
|
||||
let single_member: serde_json::Value = out_single
|
||||
.lines()
|
||||
.find(|l| l.starts_with(r#"{"family_id":"#))
|
||||
.map(|l| serde_json::from_str(l).expect("member line parses"))
|
||||
.expect("the one-cell campaign's single member line");
|
||||
|
||||
assert_eq!(
|
||||
member_8["report"]["metrics"], single_member["report"]["metrics"],
|
||||
"a family member computes the identical result a standalone single-cell run at \
|
||||
the same point computes"
|
||||
);
|
||||
assert_eq!(
|
||||
member_8["report"]["manifest"]["topology_hash"],
|
||||
single_member["report"]["manifest"]["topology_hash"],
|
||||
"the standalone single-cell run shares the same topology_hash"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task 8: `cli_run.rs` disposition — ports onto `exec` (retire+port half only;
|
||||
// the port-to-campaign-document half is a separate follow-up dispatch).
|
||||
|
||||
@@ -1,60 +1,23 @@
|
||||
//! 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).
|
||||
//! The blueprint sweep / walk-forward / Monte-Carlo family builders that used
|
||||
//! to live here are retired (#319 — the research-verb quintet's own
|
||||
//! machinery; the campaign document path, `aura-campaign::exec`, is the
|
||||
//! surviving family-execution surface, with its own independent axis/window/
|
||||
//! winner-selection machinery). What remains is the shared [`DataSource`]
|
||||
//! data provider — the synthetic/real price-source abstraction
|
||||
//! `aura_runner::reproduce` drives its own bit-identical re-derivation
|
||||
//! over — and its synthetic stream primitives (`showcase_prices`,
|
||||
//! `walkforward_prices`, `walkforward_window_source`, `synthetic_walk_sources`).
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
use aura_campaign::{catch_member_panic, member_fault_prose, MemberFault};
|
||||
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 aura_core::{Scalar, Timestamp};
|
||||
use aura_engine::{window_of, SyntheticSpec, VecSource};
|
||||
use aura_backtest::{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::member::{no_real_data, pip_or_refuse, probe_window, SYNTHETIC_PIP_SIZE};
|
||||
use crate::project::Env;
|
||||
use crate::translate::{R_SMA_STOP_K, R_SMA_STOP_LENGTH};
|
||||
|
||||
/// The demo default stop regime, shared by every family builder in this module.
|
||||
const DEFAULT_STOP: StopRule = StopRule::Vol { length: R_SMA_STOP_LENGTH, k: R_SMA_STOP_K };
|
||||
|
||||
/// 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
|
||||
@@ -103,19 +66,18 @@ pub enum DataChoice {
|
||||
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).
|
||||
/// The source provider `aura_runner::reproduce` threads through its
|
||||
/// re-derivation: 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
|
||||
/// series: the full-window consumers (`full_window` / `run_sources`) draw the
|
||||
/// 18-bar `showcase_prices()`, while the windowed consumers
|
||||
/// (`windowed_sources` / `wf_window_sizes`) 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.
|
||||
/// faces never reach one consumer (full-window vs. windowed reproduction), so
|
||||
/// the split is invisible per call site but real across the type.
|
||||
pub enum DataSource {
|
||||
Synthetic,
|
||||
Real {
|
||||
@@ -223,666 +185,14 @@ impl DataSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// #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.
|
||||
///
|
||||
/// #328 render-seam fix: `BindError::MissingKnob`/`KindMismatch` carry the
|
||||
/// WRAPPED knob name (the terminal's own bind-time frame — untouched by this
|
||||
/// cycle, per the minuted deviation on acceptance-criterion 2). No
|
||||
/// user-facing surface may print a wrapped axis name, so this renderer strips
|
||||
/// the one leading node segment (`wrapped_to_raw_axis`, the same convention
|
||||
/// `axes.rs` defines) before the name ever reaches prose — the fix lives at
|
||||
/// the RENDER seam only, not in the error type or the resolution machinery.
|
||||
pub fn render_bind_error(e: &BindError) -> String {
|
||||
match e {
|
||||
BindError::MissingKnob(name) => {
|
||||
let name = crate::axes::wrapped_to_raw_axis(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 } => {
|
||||
let knob = crate::axes::wrapped_to_raw_axis(knob);
|
||||
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. Reused
|
||||
/// (#278) as the discarded slot value for a member run [`catch_member_panic`]
|
||||
/// contains: the caller always resolves the captured fault and exits before this
|
||||
/// value is ever rendered or persisted, so its content is equally irrelevant there.
|
||||
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 },
|
||||
}
|
||||
}
|
||||
|
||||
/// The LOWEST enumeration index's captured member-panic message, found by
|
||||
/// walking `family.points` — guaranteed by `assemble_sweep`'s own
|
||||
/// `collect()` to be in grid-enumeration order regardless of which worker
|
||||
/// finished first (C1) — for the first point whose params match a captured
|
||||
/// fault. Thread-order-independent by construction (mirrors the lowest-
|
||||
/// index-fault convention `aura-campaign::exec::run_members` establishes for
|
||||
/// the identical #272 seam), without needing a separate numeric-index
|
||||
/// side-channel: the returned family already carries the points in order.
|
||||
fn lowest_point_fault(family: &SweepFamily, faults: Mutex<Vec<(Vec<Cell>, String)>>) -> Option<String> {
|
||||
let captured = faults.into_inner().expect("fault capture lock");
|
||||
family
|
||||
.points
|
||||
.iter()
|
||||
.find_map(|pt| captured.iter().find(|(p, _)| *p == pt.params).map(|(_, m)| m.clone()))
|
||||
}
|
||||
|
||||
/// The LOWEST seed's captured member-panic message (#278), found by walking
|
||||
/// `family.draws` — `monte_carlo`'s own contract guarantees seed-**input**
|
||||
/// order regardless of thread completion (C1) — for the first draw whose
|
||||
/// seed matches a captured fault. Thread-order-independent by construction,
|
||||
/// mirroring [`lowest_point_fault`]'s and `lowest_window_fault`'s identical
|
||||
/// convention one level over (per-point sweep / per-window walk-forward).
|
||||
fn lowest_seed_fault(family: &McFamily, faults: Mutex<Vec<(u64, String)>>) -> Option<String> {
|
||||
let captured = faults.into_inner().expect("fault capture lock");
|
||||
family
|
||||
.draws
|
||||
.iter()
|
||||
.find_map(|d| captured.iter().find(|(s, _)| *s == d.seed).map(|(_, m)| m.clone()))
|
||||
}
|
||||
|
||||
/// Render + exit(3) on a contained member panic (#278: the deliberate
|
||||
/// failed-cells exit, C14, never the raw 101 an uncontained panic would
|
||||
/// yield) — reuses `aura_campaign::member_fault_prose`'s established
|
||||
/// "a member panicked: …" wording rather than re-wording it, and the
|
||||
/// `aura: warning: ` class marker (#278 decision) the CLI's own `diag`
|
||||
/// macro would emit, hand-written here since that macro is presentation-
|
||||
/// crate-private (#295 boundary) and this fires from `aura-runner`.
|
||||
fn exit_on_member_panic(msg: &str) -> ! {
|
||||
eprintln!("aura: warning: {}", member_fault_prose(&MemberFault::Panic(msg.to_string())));
|
||||
std::process::exit(3);
|
||||
}
|
||||
|
||||
/// 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 space = probe.param_space();
|
||||
// #328: `axes` names are RAW (refused at the walkforward dispatch-boundary
|
||||
// preflight ahead of this call, mirroring the plain sweep verb) — translate
|
||||
// each onto the ONE wrapped `space` slot it suffix-matches
|
||||
// (`wrapped_name_of`, the same convention `blueprint_sweep_family` feeds its
|
||||
// own `SweepBinder`) before this terminal's exact-name resolution.
|
||||
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(&crate::axes::wrapped_name_of(first_name, &space), first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(&crate::axes::wrapped_name_of(n, &space), 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: `axes` names are RAW (#328 — refused at intake before
|
||||
// this fn ever runs), so each is translated to the ONE wrapped `space` slot
|
||||
// it suffix-matches (`raw_matches_wrapped`, the same convention
|
||||
// `override_paths` just resolved the override set through) before feeding
|
||||
// `Composite::axis`/`SweepBinder::axis`, which still key by the exact
|
||||
// wrapped `param_space()` name. `resolve_axes` name- and kind-checks the
|
||||
// translated axes at the sweep terminal, so an UnknownKnob / KindMismatch
|
||||
// is returned, not panicked (translation cannot itself introduce one: every
|
||||
// incoming name was already validated raw-or-bound at the intake / override
|
||||
// preflight above).
|
||||
let wrapped_name_of = |raw: &str| -> String {
|
||||
space
|
||||
.iter()
|
||||
.find(|p| crate::axes::raw_matches_wrapped(raw, &p.name))
|
||||
.map(|p| p.name.clone())
|
||||
.unwrap_or_else(|| raw.to_string())
|
||||
};
|
||||
let mut iter = axes.iter();
|
||||
let (first_name, first_vals) = iter.next().expect("a blueprint sweep declares >= 1 axis");
|
||||
let mut binder = probe.axis(&wrapped_name_of(first_name), first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(&wrapped_name_of(n), vals.clone());
|
||||
}
|
||||
// manifest.params records RAW names (#328): a name-only reshaping of `space`
|
||||
// (order/kind untouched) fed to `run_blueprint_member`, which only zips it
|
||||
// against `point` BY POSITION (`zip_params`) — never re-resolves by name —
|
||||
// so renaming here is purely the manifest's own namespace, with no effect
|
||||
// on member resolution.
|
||||
let manifest_space: Vec<ParamSpec> = space
|
||||
.iter()
|
||||
.map(|p| ParamSpec { name: crate::axes::wrapped_to_raw_axis(&p.name).to_string(), kind: p.kind })
|
||||
.collect();
|
||||
// #278: `run_blueprint_member` runs bare here (no #272 fault boundary of its
|
||||
// own), so a member-compile panic (e.g. an `Sma::new` length assert) would
|
||||
// otherwise unwind straight through this sweep to an uncaught exit 101 —
|
||||
// contained the same way the real/campaign path contains it, via
|
||||
// `catch_member_panic` + a per-point capture, resolved to the lowest
|
||||
// enumeration index's message after the sweep joins (thread-order-
|
||||
// independent, C1).
|
||||
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
|
||||
let family = 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.
|
||||
match catch_member_panic(|| {
|
||||
run_blueprint_member(reload(doc), point, &manifest_space, data.run_sources(env, &binding.columns()), window, 0, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
}) {
|
||||
Ok(report) => report,
|
||||
Err(msg) => {
|
||||
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
|
||||
axis_grid_probe_report()
|
||||
}
|
||||
}
|
||||
})
|
||||
// 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))?;
|
||||
if let Some(msg) = lowest_point_fault(&family, faults) {
|
||||
exit_on_member_panic(&msg);
|
||||
}
|
||||
Ok(family)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// The returned `Option<String>` (#278) is this fn's OWN resolved member-panic
|
||||
/// capture: this fn's nested sweep runs inside `blueprint_walkforward_family`'s
|
||||
/// per-window parallel closure, so a contained member panic here must NOT
|
||||
/// `eprintln!`+`exit` on the spot (that would race across windows, the exact
|
||||
/// `#177` duplicated-rejection class the dispatch-boundary axis pre-flight
|
||||
/// above already avoids) — captured internally, resolved to the lowest-index
|
||||
/// message via [`lowest_point_fault`] before returning, for the caller to push
|
||||
/// onto its own per-window fault list only after every window has joined.
|
||||
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>, Option<String>), 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();
|
||||
// #328: `axes` names are RAW (refused at the walkforward dispatch-boundary
|
||||
// preflight ahead of the caller, mirroring the plain sweep verb) —
|
||||
// translate each onto the ONE wrapped `space` slot it suffix-matches
|
||||
// (`wrapped_name_of`, the same convention `blueprint_sweep_family` feeds
|
||||
// its own `SweepBinder`) before this terminal's exact-name resolution.
|
||||
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(&crate::axes::wrapped_name_of(first_name, &space), first_vals.clone());
|
||||
for (n, vals) in iter {
|
||||
binder = binder.axis(&crate::axes::wrapped_name_of(n, &space), vals.clone());
|
||||
}
|
||||
let faults: Mutex<Vec<(Vec<Cell>, String)>> = Mutex::new(Vec::new());
|
||||
let (family, lattice) = 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");
|
||||
match catch_member_panic(|| {
|
||||
run_blueprint_member(reopen_all(reload(doc), &overrides), point, &space, sources, window, 0, pip, &topo, env, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
}) {
|
||||
Ok(report) => report,
|
||||
Err(msg) => {
|
||||
faults.lock().expect("fault capture lock").push((point.to_vec(), msg));
|
||||
axis_grid_probe_report()
|
||||
}
|
||||
}
|
||||
})?;
|
||||
let fault = lowest_point_fault(&family, faults);
|
||||
Ok((family, lattice, fault))
|
||||
}
|
||||
|
||||
/// 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, DEFAULT_STOP, binding, &[], NO_INSTRUMENT_CONTEXT);
|
||||
(Vec::new(), report)
|
||||
}
|
||||
|
||||
/// A discarded placeholder [`WindowRun`] for a window whose IS refit or OOS
|
||||
/// run was contained after a member panic (#278): `chosen_params` matches
|
||||
/// `space`'s arity with inert zero cells (the `assemble_walk_forward` arity
|
||||
/// invariant), `oos_equity` is empty (an empty segment leaves the stitched
|
||||
/// curve unbroken, same convention as a genuinely equity-less window), and
|
||||
/// the report reuses [`axis_grid_probe_report`]'s zero-cost placeholder.
|
||||
/// Never rendered: the caller always resolves the captured window fault and
|
||||
/// exits before touching a faulted window's run.
|
||||
fn placeholder_window_run(space: &[ParamSpec]) -> WindowRun {
|
||||
WindowRun {
|
||||
chosen_params: space.iter().map(|_| Cell::from_i64(0)).collect(),
|
||||
oos_equity: Vec::new(),
|
||||
oos_report: axis_grid_probe_report(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The LOWEST roll-order window's captured member-panic message (#278),
|
||||
/// found by walking `result.windows` — guaranteed by `assemble_walk_forward`
|
||||
/// to be in roll order regardless of thread completion (C1) — for the first
|
||||
/// window whose bounds match a captured fault. Thread-order-independent by
|
||||
/// construction, mirroring [`lowest_point_fault`]'s identical convention one
|
||||
/// level down (per-point within one window's IS sweep) and
|
||||
/// `aura-campaign::exec`'s own lowest-index walk-forward fault attribution.
|
||||
fn lowest_window_fault(result: &WalkForwardResult, faults: Mutex<Vec<(WindowBounds, String)>>) -> Option<String> {
|
||||
let captured = faults.into_inner().expect("fault capture lock");
|
||||
result
|
||||
.windows
|
||||
.iter()
|
||||
.find_map(|w| captured.iter().find(|(b, _)| *b == w.bounds).map(|(_, m)| m.clone()))
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
// #278: `blueprint_sweep_over`'s inner sweep and `run_oos_blueprint`'s single
|
||||
// member run both drive `run_blueprint_member` bare, so either could
|
||||
// otherwise unwind an uncaught member-compile panic straight through this
|
||||
// window closure. Both are contained (`blueprint_sweep_over` resolves its
|
||||
// own capture and returns it; `run_oos_blueprint` via `catch_member_panic`
|
||||
// directly) and captured PER WINDOW here rather than printed on the spot —
|
||||
// this closure runs across windows in parallel (`walk_forward`), so an
|
||||
// inline `eprintln!`+`exit` would race the same way the axis pre-flight
|
||||
// above was written to avoid (#177). The lowest roll-order window's fault
|
||||
// is resolved once, after every window has joined.
|
||||
let window_faults: Mutex<Vec<(WindowBounds, String)>> = Mutex::new(Vec::new());
|
||||
let result = walk_forward(roller, space.clone(), |w: WindowBounds| {
|
||||
let (is_family, lattice, is_fault) = blueprint_sweep_over(doc, axes, w.is.0, w.is.1, data, env, &binding)
|
||||
.expect("axes validated in the dispatch-boundary pre-flight");
|
||||
if let Some(msg) = is_fault {
|
||||
window_faults.lock().expect("fault capture lock").push((w, msg));
|
||||
return placeholder_window_run(&space);
|
||||
}
|
||||
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); }
|
||||
};
|
||||
match catch_member_panic(|| {
|
||||
run_oos_blueprint(doc, &best.params, &space, w.oos.0, w.oos.1, &topo, data, env, &binding, &overrides)
|
||||
}) {
|
||||
Ok((oos_equity, mut oos_report)) => {
|
||||
oos_report.manifest.selection = Some(selection);
|
||||
WindowRun { chosen_params: best.params, oos_equity, oos_report }
|
||||
}
|
||||
Err(msg) => {
|
||||
window_faults.lock().expect("fault capture lock").push((w, msg));
|
||||
placeholder_window_run(&space)
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(msg) = lowest_window_fault(&result, window_faults) {
|
||||
exit_on_member_panic(&msg);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// A fresh seeded synthetic price walk for one Monte-Carlo draw (a distinct
|
||||
/// realization per seed). A FIXED `SyntheticSpec` shared by the reproduce
|
||||
/// MonteCarlo branch (`aura_runner::reproduce`, its only remaining
|
||||
/// production caller — #319 retired the family builder that used to mint
|
||||
/// such a family), 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();
|
||||
// #278: `run_blueprint_member` ran bare here, the one family builder without
|
||||
// the #272 fault boundary its sweep/walk-forward siblings gained in 51096a3
|
||||
// — a member-compile panic (e.g. an `Sma::new` length assert) would otherwise
|
||||
// unwind straight through `monte_carlo`'s `run_indexed` to an uncaught exit
|
||||
// 101. Contained the same way: `catch_member_panic` + a per-seed capture,
|
||||
// resolved to the LOWEST seed's message after the join (thread-order-
|
||||
// independent, C1) via `lowest_seed_fault`.
|
||||
let faults: Mutex<Vec<(u64, String)>> = Mutex::new(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");
|
||||
match catch_member_panic(|| {
|
||||
run_blueprint_member(reload(doc), &[], &space, sources, window, seed, pip, &topo, env, DEFAULT_STOP, &binding, &[], NO_INSTRUMENT_CONTEXT)
|
||||
}) {
|
||||
Ok(report) => report,
|
||||
Err(msg) => {
|
||||
faults.lock().expect("fault capture lock").push((seed, msg));
|
||||
axis_grid_probe_report()
|
||||
}
|
||||
}
|
||||
});
|
||||
// The captured fault must win BEFORE the vacuous-mc guard below: a faulted
|
||||
// draw's placeholder report is metrics-identical across every faulted seed,
|
||||
// so a run with >= 2 faulted seeds (or one faulted + one real draw sharing
|
||||
// its placeholder's zero metrics) could otherwise trip the vacuous refusal
|
||||
// instead of surfacing the real member fault.
|
||||
if let Some(msg) = lowest_seed_fault(&family, faults) {
|
||||
exit_on_member_panic(&msg);
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -185,7 +185,6 @@ fn the_shell_defines_no_domain_modules_and_no_lib_target() {
|
||||
"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"
|
||||
|
||||
Reference in New Issue
Block a user