diff --git a/crates/aura-cli/src/campaign_run.rs b/crates/aura-cli/src/campaign_run.rs index 2a8aa99..de3026c 100644 --- a/crates/aura-cli/src/campaign_run.rs +++ b/crates/aura-cli/src/campaign_run.rs @@ -27,7 +27,7 @@ use aura_engine::FamilySelection; use aura_registry::CampaignRunRecord; use aura_research::{ campaign_to_json, content_id_of, parse_campaign, parse_process, validate_campaign, - validate_process, CampaignDoc, DocRef, + validate_process, Axis, CampaignDoc, DocRef, }; use aura_runner::axes::is_content_id; @@ -124,11 +124,14 @@ pub(crate) enum RunPresentation { /// `aura campaign run `: resolve, gate, execute, emit. Every `Err` /// is a refusal `campaign_cmd` renders as `aura: {msg}` + exit 1; an `Ok` /// carries the failed-cell count (#272) so the caller can exit 3 on a -/// completed-with-failures run via `exit_on_campaign_result`. +/// completed-with-failures run via `exit_on_campaign_result`. `overrides` +/// (#319 Task 4, the `exec --override` campaign leg) is threaded through +/// unchanged to `run_campaign_returning`, the one injection seam. pub(crate) fn run_campaign( target: &str, env: &Env, parallel_instruments: NonZeroUsize, + overrides: &[(String, Scalar)], ) -> Result { // Project gate FIRST: nothing (not even the file-sugar registration) // touches a store outside a project. @@ -162,7 +165,7 @@ pub(crate) fn run_campaign( )); }; - run_campaign_by_id(&campaign_id, env, RunPresentation::Full, parallel_instruments) + run_campaign_by_id(&campaign_id, env, RunPresentation::Full, parallel_instruments, overrides) } /// An executed campaign plus the context its presenter and the dissolved-verb @@ -192,8 +195,9 @@ pub(crate) fn run_campaign_by_id( env: &Env, presentation: RunPresentation, parallel_instruments: NonZeroUsize, + overrides: &[(String, Scalar)], ) -> Result { - let run = run_campaign_returning(campaign_id, env, parallel_instruments)?; + let run = run_campaign_returning(campaign_id, env, parallel_instruments, overrides)?; present_campaign(run, presentation, env) } @@ -204,18 +208,23 @@ pub(crate) fn run_campaign_by_id( /// outcome bundled with the context the presenter needs. Shared by /// `run_campaign_by_id` (which then presents) and a forthcoming dissolved-verb /// sugar path that reads the outcome and self-prints (lands in a later task, -/// alongside the generalize translator's own runner). +/// alongside the generalize translator's own runner). `overrides` (#319 +/// Task 4) is injected into the parsed document AFTER intrinsic validation +/// and BEFORE the referential gate — the stored bytes and the recorded +/// `campaign_id` stay those of the ORIGINAL document; only the executed +/// run-set changes, its audit record being the raw member manifests. pub(crate) fn run_campaign_returning( campaign_id: &str, env: &Env, parallel_instruments: NonZeroUsize, + overrides: &[(String, Scalar)], ) -> Result { let registry = env.registry(); let campaign_text = registry .get_campaign(campaign_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("no campaign {campaign_id} in the project store"))?; - let campaign = parse_campaign(&campaign_text) + let mut campaign = parse_campaign(&campaign_text) .map_err(|e| doc_error_prose("stored campaign document", &e))?; let faults = validate_campaign(&campaign); if !faults.is_empty() { @@ -225,6 +234,25 @@ pub(crate) fn run_campaign_returning( )); } + // --override NODE.PARAM=VALUE (#246 residue, #319 Task 4): inject as a + // single-value axis over the named bound param into EVERY strategy + // entry. Collision with a document-declared axis refuses (an override + // overrides a bound value, never an axis); an unknown path then falls + // out of `validate_campaign_refs` below with its existing did-you-mean + // prose — no `RefFault` change, no registry change. + for (path, val) in overrides { + for entry in &mut campaign.strategies { + if entry.axes.contains_key(path) { + eprintln!( + "aura: exec: --override `{path}` collides with a declared axis \ +of the campaign; an override overrides a bound value, never an axis" + ); + std::process::exit(2); + } + entry.axes.insert(path.clone(), single_value_axis(*val)); + } + } + // Referential gate: zero faults or refuse (the campaign-validate seam). let resolve = |t: &str| env.resolve(t); let ref_faults = registry @@ -355,6 +383,17 @@ fn present_campaign( let emit_family = campaign.presentation.emit.iter().any(|e| e == "family_table"); let emit_selection = campaign.presentation.emit.iter().any(|e| e == "selection_report"); for cell_out in &outcome.cells { + // #313 zero-trade note (#319 Task 5, migrated from the retiring + // `dispatch_walkforward`/`run_walkforward_sugar` paths): every campaign + // cell's `std::walk_forward` family, if any, gets the same shared + // `note_zero_trade_windows` producer over its OOS members' trade + // counts — independent of `emit` (a stderr diagnostic, not gated + // stdout presentation). + if let Some(wf) = cell_out.families.iter().find(|f| f.block == "std::walk_forward") { + crate::diag::note_zero_trade_windows( + wf.reports.iter().map(|r| r.metrics.r.as_ref().map_or(0, |m| m.n_trades)), + ); + } if emit_family { for fam in &cell_out.families { for report in &fam.reports { @@ -417,6 +456,13 @@ fn present_campaign( Ok(failed) } +/// Build the single-value `Axis` an `exec --override` injects (#319 Task 4): +/// exactly the shape the document form `{"kind": "I64", "values": [v]}` +/// deserializes to — `Scalar::kind` names the tag, one value in the list. +fn single_value_axis(v: Scalar) -> Axis { + Axis { kind: v.kind(), values: vec![v] } +} + /// #272: the fault-kind label the failed-cell summary line names — the same /// closed `CellFaultKind` vocabulary an aggregate over `campaign_runs.jsonl` /// counts by. Debug-leak-free (a fixed lowercase-snake label, not a Debug diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 48415eb..50d0d68 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -1079,6 +1079,38 @@ fn parse_scalar_csv(csv: &str) -> Option> { }).collect() } +/// Lex `exec --override NODE.PARAM=VALUE` tokens (#319 Task 3/4): the same +/// int-then-float rule `parse_scalar_csv` uses for `--axis` values, shared by +/// both `exec` legs (the blueprint leg's reopen recipe and the campaign +/// leg's single-value-axis injection) so the wording and the parse rule +/// cannot drift between them. A malformed token refuses at the usage +/// boundary (exit 2) — intake, not a per-leg concern. +fn parse_override_tokens(tokens: &[String]) -> Vec<(String, Scalar)> { + let mut ov = Vec::new(); + for tok in tokens { + let Some((path_s, val_s)) = tok.split_once('=') else { + eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`"); + std::process::exit(2); + }; + if path_s.trim().is_empty() { + eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`"); + std::process::exit(2); + } + let val = match val_s.trim().parse::() { + Ok(i) => Scalar::i64(i), + Err(_) => match val_s.trim().parse::() { + Ok(f) => Scalar::f64(f), + Err(_) => { + eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`"); + std::process::exit(2); + } + }, + }; + ov.push((path_s.trim().to_string(), val)); + } + ov +} + // ============================== clap parser surface ============================== // The declarative argument grammar. clap owns argv tokenizing, scoped `--help`, // `--version`, `--flag=value`, `--`, and long-option abbreviation; the `dispatch_*` @@ -1968,7 +2000,13 @@ document declares its persisted taps itself" ); std::process::exit(2); } - exit_on_campaign_result(crate::campaign_run::run_campaign(&a.target, env, a.parallel_instruments)); + let overrides = parse_override_tokens(&a.r#override); + exit_on_campaign_result(crate::campaign_run::run_campaign( + &a.target, + env, + a.parallel_instruments, + &overrides, + )); } } } @@ -1987,30 +2025,10 @@ document declares its persisted taps itself" /// all (invariant 7 — a real-data or seeded single run is a one-cell /// campaign document). fn exec_blueprint_leg(path: &str, overrides: &[String], tap: &[String], env: &aura_runner::project::Env) { - // --override NODE.PARAM=VALUE (#319 Task 3): lexed with the same - // int-then-float rule `parse_scalar_csv` uses for `--axis` values. - let mut ov: Vec<(String, Scalar)> = Vec::new(); - for tok in overrides { - let Some((path_s, val_s)) = tok.split_once('=') else { - eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`"); - std::process::exit(2); - }; - if path_s.trim().is_empty() { - eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`"); - std::process::exit(2); - } - let val = match val_s.trim().parse::() { - Ok(i) => Scalar::i64(i), - Err(_) => match val_s.trim().parse::() { - Ok(f) => Scalar::f64(f), - Err(_) => { - eprintln!("aura: exec: --override expects NODE.PARAM=VALUE, got `{tok}`"); - std::process::exit(2); - } - }, - }; - ov.push((path_s.trim().to_string(), val)); - } + // --override NODE.PARAM=VALUE (#319 Task 3): lexed by the shared + // `parse_override_tokens` (the campaign leg's injection, Task 4, reuses + // the same lexer). + let ov = parse_override_tokens(overrides); let doc = std::fs::read_to_string(path).unwrap_or_else(|e| { eprintln!("aura: {path}: {e}"); diff --git a/crates/aura-cli/src/verb_sugar.rs b/crates/aura-cli/src/verb_sugar.rs index c00e759..5b93dfa 100644 --- a/crates/aura-cli/src/verb_sugar.rs +++ b/crates/aura-cli/src/verb_sugar.rs @@ -352,6 +352,7 @@ pub(crate) fn run_sweep_sugar( env, crate::campaign_run::RunPresentation::MemberLinesOnly, aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, + &[], ) } @@ -397,6 +398,7 @@ pub(crate) fn run_generalize_sugar( &campaign_id, env, aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, + &[], )?; // Reprint the verb's aggregate line from the recorded cross-instrument @@ -523,6 +525,7 @@ pub(crate) fn run_walkforward_sugar( &campaign_id, env, aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, + &[], )?; if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) { @@ -690,6 +693,7 @@ pub(crate) fn run_mc_sugar( &campaign_id, env, aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, + &[], )?; if let Some(f) = run.outcome.record.cells.iter().find_map(|c| c.fault.as_ref()) { diff --git a/crates/aura-cli/tests/exec.rs b/crates/aura-cli/tests/exec.rs index 207625c..a800d82 100644 --- a/crates/aura-cli/tests/exec.rs +++ b/crates/aura-cli/tests/exec.rs @@ -492,3 +492,226 @@ fn exec_override_unknown_path_refuses_with_the_override_prose() { "stdout/stderr: {out}" ); } + +// --------------------------------------------------------------------------- +// Task 4: `--override` on the campaign leg. +// --------------------------------------------------------------------------- + +/// A bound param not covered by the document's own axes (`bias.scale`, +/// `examples/r_sma.json`'s `Bias` node — `seed_blueprint` only reopens +/// `fast.length`/`slow.length`, so `bias.scale` stays bound at 0.5 in the +/// stored blueprint): `--override bias.scale=0.75` reaches every realized +/// member's manifest raw (recorded in `params`, dropped from `defaults`) — +/// mirrors `exec_blueprint_override_reopens_and_records_the_value_in_params` +/// on the campaign leg. +#[test] +fn exec_campaign_override_reaches_the_member_manifests_raw() { + 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("ovreach.process.json")), + ScratchPath::File(dir.join("ovreach.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "exec-override-reach-seed"); + let proc_id = register_process_doc(&dir, "ovreach.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999)); + write_doc(&dir, "ovreach.campaign.json", &doc); + + let (out, code) = + run_code_in(&dir, &["exec", "ovreach.campaign.json", "--override", "bias.scale=0.75"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let members: Vec = out + .lines() + .filter(|l| l.starts_with(r#"{"family_id":"#)) + .map(|l| serde_json::from_str(l).expect("member line parses")) + .collect(); + assert!(!members.is_empty(), "at least one member line: {out}"); + for m in &members { + let params = m["report"]["manifest"]["params"].as_array().expect("params array"); + assert!( + params + .iter() + .any(|e| e[0] == "bias.scale" && e[1] == serde_json::json!({"F64": 0.75})), + "the overridden value must land in every member's params: {m}" + ); + let defaults = m["report"]["manifest"]["defaults"].as_array().expect("defaults array"); + assert!( + !defaults.iter().any(|e| e[0] == "bias.scale"), + "an overridden bound param drops out of defaults: {m}" + ); + } +} + +/// An override path colliding with a document-declared axis (`fast.length`, +/// declared by `campaign_doc_json_for`) refuses: an override overrides a +/// bound value, never an axis. +#[test] +fn exec_campaign_override_colliding_with_a_document_axis_refuses() { + 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("ovcollide.process.json")), + ScratchPath::File(dir.join("ovcollide.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "exec-override-collide-seed"); + let proc_id = register_process_doc(&dir, "ovcollide.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999)); + write_doc(&dir, "ovcollide.campaign.json", &doc); + + let (out, code) = + run_code_in(&dir, &["exec", "ovcollide.campaign.json", "--override", "fast.length=9"]); + assert_eq!(code, Some(2), "stdout/stderr: {out}"); + assert!(out.contains("--override"), "stdout/stderr: {out}"); + assert!(out.contains("declared axis"), "stdout/stderr: {out}"); +} + +/// An override path naming no param of any strategy (a typo'd raw name) +/// falls out of `validate_campaign_refs`'s existing `AxisNotInParamSpace` +/// did-you-mean prose — pinned literally in `research_docs.rs` (:797-822); +/// here only the family of the message is asserted. +#[test] +fn exec_campaign_override_unknown_path_speaks_the_did_you_mean_family() { + 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("ovtypo.process.json")), + ScratchPath::File(dir.join("ovtypo.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "exec-override-typo-seed"); + let proc_id = register_process_doc(&dir, "ovtypo.process.json", SWEEP_ONLY_PROCESS_DOC); + let doc = campaign_doc_json_for("SYMA", &bp_id, &proc_id, (1709251200000, 1719791999999)); + write_doc(&dir, "ovtypo.campaign.json", &doc); + + let (out, code) = + run_code_in(&dir, &["exec", "ovtypo.campaign.json", "--override", "fast.lenght=9"]); + assert_ne!(code, Some(0), "stdout/stderr: {out}"); + assert!( + out.contains("did you mean") || out.contains("not in the param space"), + "stdout/stderr: {out}" + ); +} + +// --------------------------------------------------------------------------- +// Task 5: zero-trade note migration to the campaign walk-forward leg. +// --------------------------------------------------------------------------- + +/// A `[std::grid, std::walk_forward]` process — the #256 fork B shape a +/// hand-authored (non-sugar) op-script uses — copied verbatim from +/// `research_docs.rs::GRID_THEN_WF_PROCESS_DOC`. +const GRID_THEN_WF_PROCESS_DOC: &str = r#"{ + "format_version": 1, + "kind": "process", + "name": "grid-then-walkforward", + "pipeline": [ + { "block": "std::grid" }, + { "block": "std::walk_forward", "in_sample_ms": 1209600000, "out_of_sample_ms": 604800000, + "step_ms": 604800000, "mode": "rolling", "metric": "net_expectancy_r", "select": "argmax" } + ] +}"#; + +/// `campaign_doc_json_for`'s shape with the axis VALUES parameterized (Task 5 +/// needs the degenerate zero-trade point — both SMA lengths pinned equal — +/// which the fixed 2/4 and 8/16 defaults don't reach). +fn campaign_doc_json_walkforward( + instrument: &str, + bp_id: &str, + proc_id: &str, + window: (i64, i64), + fast_values: &str, + slow_values: &str, +) -> String { + format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "run-seam-wf", + "data": {{ "instruments": ["{instrument}"], "windows": [ {{ "from_ms": {from}, "to_ms": {to} }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp_id}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [{fast_values}] }}, + "slow.length": {{ "kind": "I64", "values": [{slow_values}] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc_id}" }} }}, + "seed": 7, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + from = window.0, + to = window.1, + ) +} + +/// E2E (#313, campaign path): a walk-forward whose every window records zero +/// trades emits exactly one zero-trade note on `exec`'s campaign leg — the +/// same producer `cli_run.rs::aura_walkforward_all_zero_trade_windows_emit_one_note` +/// pins on the retiring synthetic-verb path, now reached through a +/// hand-authored `[std::grid, std::walk_forward]` campaign document. Equal +/// fast/slow lengths make the SMA difference constantly zero, so no window +/// trades — the same degenerate point, over real (SYMA) rather than +/// synthetic data (the property is data-source-agnostic). +#[test] +fn exec_campaign_walkforward_all_zero_trade_windows_emit_one_note() { + 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("wfzero.process.json")), + ScratchPath::File(dir.join("wfzero.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "exec-wf-zero-seed"); + let proc_id = register_process_doc(&dir, "wfzero.process.json", GRID_THEN_WF_PROCESS_DOC); + let doc = campaign_doc_json_walkforward( + "SYMA", + &bp_id, + &proc_id, + (1709251200000, 1719791999999), + "8", + "8", + ); + write_doc(&dir, "wfzero.campaign.json", &doc); + + let (out, code) = run_code_in(&dir, &["exec", "wfzero.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert_eq!( + out.matches("recorded zero trades").count(), + 1, + "exactly one zero-trade note: {out}" + ); + assert!(out.contains("aura: note: "), "the note carries the class marker: {out}"); +} + +/// E2E (#313 negative twin, campaign path): a walk-forward with at least one +/// traded window emits no zero-trade note — the fixture is the same document +/// shape as the zero-trade test above, but with the axes campaign_doc_json_for +/// already uses (fast∈{2,4}, slow∈{8,16} — known to produce trades over the +/// synthetic SYMA archive, per `campaign_run_synthetic_e2e_cost_block_nets_the_per_survivor_bootstrap`). +#[test] +fn exec_campaign_walkforward_with_trades_emits_no_zero_trade_note() { + 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("wftrades.process.json")), + ScratchPath::File(dir.join("wftrades.campaign.json")), + ]); + let bp_id = seed_blueprint(&dir, "exec-wf-trades-seed"); + let proc_id = register_process_doc(&dir, "wftrades.process.json", GRID_THEN_WF_PROCESS_DOC); + let doc = campaign_doc_json_walkforward( + "SYMA", + &bp_id, + &proc_id, + (1709251200000, 1719791999999), + "2, 4", + "8, 16", + ); + write_doc(&dir, "wftrades.campaign.json", &doc); + + let (out, code) = run_code_in(&dir, &["exec", "wftrades.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!(!out.contains("recorded zero trades"), "a traded run emits no zero-trade note: {out}"); +}