feat(aura-cli): exec --override on the campaign leg + zero-trade note migration

Slice 3 of the #319 retirement. The campaign leg's --override injects a
single-value axis over the named bound param into every strategy entry —
after intrinsic validation, before the referential gate, so an unknown
path speaks validate_campaign_refs' existing did-you-mean prose and a
kind mismatch its existing fault; collision with a document-declared axis
refuses (an override overrides a bound value, never an axis, exit 2).
The stored bytes and the recorded campaign_id stay those of the original
document; the audit record of an override is the raw member manifests.
The walk-forward zero-trade aura: note: diagnostic now has a campaign-
path producer call (present_campaign per-cell), so it survives the
quintet's removal at today's strength.

refs #319
This commit is contained in:
2026-07-25 18:56:56 +02:00
parent da19e27b6a
commit 696d7fe59a
4 changed files with 322 additions and 31 deletions
+52 -6
View File
@@ -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 <target>`: 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<usize, String> {
// 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<usize, String> {
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<CampaignRun, String> {
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
+43 -25
View File
@@ -1079,6 +1079,38 @@ fn parse_scalar_csv(csv: &str) -> Option<Vec<Scalar>> {
}).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::<i64>() {
Ok(i) => Scalar::i64(i),
Err(_) => match val_s.trim().parse::<f64>() {
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::<i64>() {
Ok(i) => Scalar::i64(i),
Err(_) => match val_s.trim().parse::<f64>() {
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}");
+4
View File
@@ -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()) {
+223
View File
@@ -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<serde_json::Value> = 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}");
}