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()) {