From 06d7e0f30af0692daecdc26254d80f4422ce324f Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 25 Jul 2026 22:14:24 +0200 Subject: [PATCH] refactor(aura-runner, aura-cli): retire the family builders and the CLI topology_hash duplicate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/src/graph_construct.rs | 7 +- crates/aura-cli/src/main.rs | 554 ++++---------- crates/aura-cli/tests/exec.rs | 119 +++ crates/aura-runner/src/family.rs | 750 +------------------ crates/aura-vocabulary/tests/c28_layering.rs | 1 - 5 files changed, 311 insertions(+), 1120 deletions(-) diff --git a/crates/aura-cli/src/graph_construct.rs b/crates/aura-cli/src/graph_construct.rs index 3d4908a..f85981c 100644 --- a/crates/aura-cli/src/graph_construct.rs +++ b/crates/aura-cli/src/graph_construct.rs @@ -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) { diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index bc4b512..20b1b36 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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)> = 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 " - ); - 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![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 = [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 = (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=` - /// 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=` 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 = (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"); diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index 84848ec..f60026e 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -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 = 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). diff --git a/crates/aura-runner/src/family.rs b/crates/aura-runner/src/family.rs index 2080f3b..34fb584 100644 --- a/crates/aura-runner/src/family.rs +++ b/crates/aura-runner/src/family.rs @@ -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, to_ms: Option }, } -/// The source provider threaded into the family builders: synthetic built-in -/// streams, or real M1 close bars from the data-server archive. Replaces the -/// hardcoded `VecSource` so a member's source, pip, window, and roller sizes come -/// from one place (Fork B/D/F). +/// 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}=` — see `aura sweep --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 --list-axes`" - ) - } - BindError::UnknownKnob(msg) => msg.clone(), - BindError::DuplicateBinding(name) => format!( - "axis {name}: bound twice — each param takes exactly one axis — \ - see `aura sweep --list-axes`" - ), - BindError::EmptyAxis(name) => format!( - "axis {name}: supplies no values — give at least one, e.g. `--axis {name}=2,4`" - ), - BindError::EmptyRange(name) => format!( - "axis {name}: the named range is empty — give it at least one value" - ), - // A blueprint defect, not an axis usage error: the point passed name - // resolution but its bootstrap failed. Prose frame with the compile - // detail explicitly labelled as internal — per-variant prose for - // CompileError belongs to the graph-build surface, not this boundary. - BindError::Compile(e) => format!( - "the resolved axis point failed to bootstrap — the blueprint is \ - defective at this point, re-validate it with `aura graph build` \ - (internal detail: {e:?})" - ), - } -} - -/// Resolve the in-sample winner under the chosen selection objective. `Argmax` -/// defers to the trials-deflation pick (#144). `Plateau` argmaxes the smoothed grid -/// surface — it needs the grid lattice, so a sweep with no lattice (a future random -/// walk-forward producer) is refused rather than silently argmaxed. The metric is -/// always known at the call sites, so a metric error is unreachable (`expect`); the -/// only fallible outcome is the plateau-without-lattice refusal, returned as -/// `Err(message)` for the caller to print and exit 2. -pub fn select_winner( - family: &SweepFamily, metric: &str, select: Selection, lattice: Option<&[usize]>, -) -> Result<(SweepPoint, FamilySelection), String> { - match select { - Selection::Argmax => Ok(optimize_deflated( - family, metric, DEFLATION_N_RESAMPLES, DEFLATION_BLOCK_LEN, DEFLATION_SEED, - ).expect("walk-forward metrics are known")), - Selection::Plateau(mode) => match lattice { - Some(lens) => Ok(optimize_plateau(family, lens, metric, mode) - .expect("walk-forward metrics are known")), - None => Err( - "--select plateau requires a grid sweep; a random sweep has no parameter lattice" - .to_string(), - ), - }, - } -} - -/// A constant, zero-compute `RunReport` for [`validate_axis_grid`]'s sweep-terminal -/// probe. `SweepBinder::sweep_with_lattice`'s own `resolve_axes`/arity/kind checks all -/// run BEFORE this closure is invoked per grid point, so its body never influences the -/// validation outcome — only its signature (`Fn(&[Cell]) -> RunReport`) needs to -/// satisfy the terminal, at O(1) cost per point instead of a full member run. 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, String)>>) -> Option { - 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>) -> Option { - 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)], 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)], - data: &DataSource, - env: &Env, -) -> Result { - // Identity + binding read the AUTHORED doc, raw (no override re-open): - // topology and the resolved role plan are properties of the document, not - // of any one sweep's axis choices. - let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"); - // topology_hash's own two-line body, inlined (mirrors member::run_signal_r): - // `content_id_of` over the canonical (#164) blueprint JSON — the CLI shell's - // `topology_hash` helper is the same primitive, kept single-sourced at - // `aura_research`. - let topo = aura_research::content_id_of( - &aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"), - ); - // Strict binding resolution (name defaults — the verb path carries no - // campaign overrides): the family's open plan and wrap plan in one value. - let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?; - if matches!(data, DataSource::Synthetic) && !binding.close_only() { - return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding)); - } - let pip = data.pip_size(); - let window = data.full_window(env); - // The un-reopened wrapped OPEN space (#246): derives the override set (which - // named axes re-open a bound param) before the real, reopened probe is built — - // probe and per-member reloads must re-open identically so points resolve - // against one space. A name matching neither space is the error here. - let raw_space = blueprint_axis_probe(doc, env).param_space(); - let overrides = override_paths(axes, &raw_space, &probe_signal)?; - let probe = blueprint_axis_probe_reopened(doc, env, &overrides); - let space = probe.param_space(); - // The doc is parse-validated at the dispatch boundary (with file-path context), - // so every reload here is infallible: the builder has a single error contract — - // the `BindError` returned by the sweep terminal — and no hidden process exit. - // Member reloads re-open the SAME override set derived above, so every member - // resolves its axes against the identical (reopened) param space the probe used. - let reload = |d: &str| { - reopen_all( - blueprint_from_json(d, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"), - &overrides, - ) - }; - // seed the named axes: `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 = 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, 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` (#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)], from: Timestamp, to: Timestamp, data: &DataSource, - env: &Env, binding: &ResolvedBinding, -) -> Result<(SweepFamily, Vec, Option), 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, 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>) -> Option { - 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)], data: &DataSource, select: Selection, - env: &Env, -) -> WalkForwardResult { - let span = data.wf_full_span(env); - let (is_len, oos_len, step) = data.wf_window_sizes(); - let roller = match WindowRoller::new(span, is_len, oos_len, step, RollMode::Rolling) { - Ok(r) => r, - Err(e) => { - eprintln!("aura: walk-forward window too short for one IS+OOS span: {e:?}"); - std::process::exit(2); - } - }; - let probe_signal = blueprint_from_json(doc, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible"); - // topology_hash's own two-line body, inlined (see `blueprint_sweep_family`). - let topo = aura_research::content_id_of( - &aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"), - ); - // The un-reopened wrapped OPEN space (#246), against a raw probe + raw strategy - // load: derives the override set ONCE for the whole family — every per-window - // sweep AND the OOS reload re-open the SAME set, mirroring - // `blueprint_sweep_family`/`blueprint_sweep_over`. The SILENT variant - // (`wrapped_bound_overrides_of`, not the validating `override_paths`): an axis - // matching neither space is simply not an override here — the strict check + its - // established error message stay single-sourced in `blueprint_sweep_over`'s own - // pre-flight call below, so this derivation cannot double-validate with a - // differently-worded rejection. - let axis_names: Vec = axes.iter().map(|(n, _)| n.clone()).collect(); - let raw_space = blueprint_axis_probe(doc, env).param_space(); - let overrides = wrapped_bound_overrides_of(&axis_names, &raw_space, &probe_signal); - let space = blueprint_axis_probe_reopened(doc, env, &overrides).param_space(); - // Strict binding resolution, once per family; refusal is the established - // `aura: ` + exit-1 register (the roller's usage refusals stay exit 2). - let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new()) - .unwrap_or_else(|m| { - eprintln!("aura: {m}"); - std::process::exit(1); - }); - if matches!(data, DataSource::Synthetic) && !binding.close_only() { - eprintln!("aura: {}", crate::binding::synthetic_refusal(probe_signal.name(), &binding)); - std::process::exit(1); - } - // Validate the `--axis` grid ONCE at the dispatch boundary, mirroring `aura sweep` - // (which resolves its axes a single time before any member runs). `walk_forward` fans - // the per-window closure out across the windows in parallel, so a `BindError` raised - // *inside* the closure would `eprintln!`+`exit(2)` from several windows before any one - // exit lands — a racy, duplicated rejection (#177). Axis resolution is window-agnostic - // (#253): `validate_axis_grid` resolves the SAME grid the sweep terminal would, without - // running a single member — no IS window (or a second roller) is needed here at all. - if let Err(e) = validate_axis_grid(doc, axes, &raw_space, &probe_signal, env) { - eprintln!("aura: {}", render_bind_error(&e)); - std::process::exit(2); - } - // #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> = 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 ` 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> { let spec = SyntheticSpec { start: 1.0, len: 60, step: 1 }; vec![Box::new(spec.source(seed))] } - -/// Build a Monte-Carlo family from a loaded CLOSED signal blueprint: run the fixed -/// blueprint across `n_seeds` seeds, each seed drawing a distinct synthetic walk. The -/// blueprint must be CLOSED (empty wrapped `param_space`) — MC binds no axis, so a free -/// knob has no binder; an OPEN blueprint yields a named `Err` (exit-free like the sibling -/// [`blueprint_sweep_family`]: the IO wrapper `run_blueprint_mc` renders it to stderr + -/// exit 2) before any run, pre-empting the `compile_with_params` arity panic. Each draw -/// runs the shared reduce-mode member path (`run_blueprint_member`, the same fn reproduce -/// re-runs), so reproduction is bit-identical (C1); every member carries the shared -/// `topology_hash`. -pub fn blueprint_mc_family( - doc: &str, n_seeds: u64, data: &DataSource, env: &Env, -) -> Result { - let reload = |d: &str| { - blueprint_from_json(d, &|t| env.resolve(t)) - .expect("doc parse-validated at the dispatch boundary; reload is infallible") - }; - let probe_signal = reload(doc); - // topology_hash's own two-line body, inlined (see `blueprint_sweep_family`). - let topo = aura_research::content_id_of( - &aura_engine::blueprint_to_json(&probe_signal).expect("a buildable signal serializes"), - ); - // Strict binding resolution (name defaults — mc's synthetic family binds - // no campaign overrides); the exit-free Err contract of this builder. - let binding = crate::binding::resolve_binding(probe_signal.name(), probe_signal.input_roles(), &BTreeMap::new())?; - if !binding.close_only() { - // MC draws ALWAYS run the seeded synthetic close walk (real-data mc - // routes through the campaign sugar and never reaches this builder). - return Err(crate::binding::synthetic_refusal(probe_signal.name(), &binding)); - } - let pip = data.pip_size(); - // probe the wrapped param_space (the same probe the sweep resolves against); - // MC needs it empty. `blueprint_axis_probe` is the single source of that wrap. - let space = blueprint_axis_probe(doc, env).param_space(); - if !space.is_empty() { - // Exit-free like blueprint_sweep_family: the builder's single error contract is this - // returned message (no hidden process exit), so the rejection is unit-testable; the IO - // wrapper run_blueprint_mc renders it to stderr + exit 2 at the boundary. - return Err(format!( - "mc requires a closed blueprint (no free parameters); {} free knob(s) — \ - bind them or use `aura sweep --axis`", - space.len() - )); - } - // Closed blueprint -> an empty base point (as `aura run `); the MC - // draws vary the SEED, not a tuning param (C12 axis 4). Delegate the disjoint C1 draws - // to the shared `monte_carlo` helper — it runs them in parallel across sims (invariant 1), - // deterministic in seed-input order. Each draw - // re-runs the shared reduce-mode member path over its own seeded synthetic walk. - let seeds: Vec = (1..=n_seeds).collect(); - let base_point: Vec = Vec::new(); - // #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> = 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) -} diff --git a/crates/aura-vocabulary/tests/c28_layering.rs b/crates/aura-vocabulary/tests/c28_layering.rs index 7da26e5..243aa9f 100644 --- a/crates/aura-vocabulary/tests/c28_layering.rs +++ b/crates/aura-vocabulary/tests/c28_layering.rs @@ -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"