feat(aura-cli): exec — the one executor verb, campaign legs + blueprint single run

The #319 retirement's constructive half, slice 1: a new top-level
`exec <target>` executes a campaign document (file, register-then-run, or
64-hex content id — byte-behaviour of the retired `campaign run`, which no
longer parses) or a fully-bound signal blueprint (synthetic single run,
record line byte-identical to `aura run`'s, root-name gate migrated).
Flag discipline is per-leg: --parallel-instruments on campaign targets,
--tap on blueprint targets (refused on campaign targets with prose).
File routing peeks the document's top-level "kind" key: is_blueprint_file
alone cannot tell two .json classes apart.

--override (the #246 residue) and the measurement leg follow in later
slices; run/sweep/walkforward/mc/generalize stay alive until the removal
slice.

refs #319
This commit is contained in:
2026-07-25 18:29:16 +02:00
parent 9cfe2965c0
commit 78b80ec0fd
5 changed files with 572 additions and 70 deletions
+130
View File
@@ -1151,6 +1151,9 @@ struct Cli {
#[derive(Subcommand)]
enum Command {
/// Execute a document: a campaign (file or content id) or a signal
/// blueprint (single run).
Exec(ExecCmd),
/// Run a single backtest (built-in harness or a loaded blueprint).
///
/// Part of the research sugar surface; the canonical, document-first form
@@ -1414,6 +1417,30 @@ struct ReproduceCmd {
id: String,
}
#[derive(Args)]
struct ExecCmd {
/// A campaign document (.json file or 64-hex content id) or a serialized
/// signal blueprint (.json file, single run).
target: String,
/// Bound on distinct instruments resident in parallel (campaign targets;
/// 1 reproduces the sequential loop's footprint).
#[arg(
long,
default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
value_parser = crate::research_docs::parse_parallel_instruments,
)]
parallel_instruments: std::num::NonZeroUsize,
/// Override one bound param for this execution (repeatable,
/// NODE.PARAM=VALUE — the #246 reopen mechanism; the value is recorded
/// raw in the run manifest). Both target classes.
#[arg(long = "override", value_name = "NODE.PARAM=VALUE")]
r#override: Vec<String>,
/// Subscribe a declared tap to a fold for this run (repeatable, TAP=FOLD;
/// blueprint targets). Fold roster: `aura graph introspect --folds`.
#[arg(long = "tap", value_name = "TAP=FOLD")]
tap: Vec<String>,
}
#[derive(Args)]
struct RunCmd {
/// A serialized signal blueprint (.json). An existing file selects the
@@ -1563,6 +1590,21 @@ fn is_blueprint_file(arg: &Option<String>) -> Option<&str> {
.filter(|a| a.ends_with(".json") && std::path::Path::new(a).is_file())
}
/// `exec`'s FILE-target leg discriminator, layered on top of
/// `is_blueprint_file`: a research-document envelope (process/campaign)
/// carries a required top-level `"kind"` field; a blueprint envelope
/// (`{"format_version":N,"blueprint":{…}}`) carries none. `is_blueprint_file`
/// alone only tells "an existing `.json` file" from a bare harness-kind/id
/// token — it cannot tell a campaign document ending in `.json` from a
/// blueprint file, both being ordinary readable `.json` files — so `exec`'s
/// routing peeks this one cheap key before choosing a leg.
fn is_campaign_document_file(path: &str) -> bool {
std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.is_some_and(|v| v.get("kind").is_some())
}
/// Resolve a `[blueprint].json`-branch `--real`/`--from`/`--to` into a `RunData`,
/// mirroring the old `parse_blueprint_run_args` window guard (`--from`/`--to`
/// require `--real`; empty symbol rejected). Refuses in place (stderr + exit 2).
@@ -1908,6 +1950,93 @@ fn tap_plan_from_args(args: &[String]) -> Result<TapPlan, String> {
Ok(plan)
}
/// The one executor verb (#319): a campaign document (file or content id)
/// or a signal blueprint (single run). Flag discipline is per-leg.
fn dispatch_exec(a: ExecCmd, env: &aura_runner::project::Env) {
let target = Some(a.target.clone());
let blueprint_path = is_blueprint_file(&target).filter(|p| !is_campaign_document_file(p));
match blueprint_path {
Some(path) => {
let _ = &a.r#override; // threaded in Task 3, not this task
exec_blueprint_leg(path, &a.tap, env)
}
None => {
if !a.tap.is_empty() {
eprintln!(
"aura: exec: --tap applies only to a blueprint target; a campaign \
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));
}
}
}
/// The `exec` blueprint leg (#319): a synthetic single run of a fully-bound
/// blueprint envelope, composed 1:1 from `dispatch_run`'s `.json`-branch
/// building blocks (`main.rs:1970-2029` at the time of writing) — same
/// intake gate, same closed-blueprint guard, same tap plan, same
/// `run_signal_r` call and record-line serializer, so the record line stays
/// byte-identical to `run`'s. `--override` is NOT handled here (Task 3);
/// `run`'s `--params`/`--seed`/`--real`/`--from`/`--to` do not exist on
/// `exec` at all (invariant 7 — a real-data or seeded single run is a
/// one-cell campaign document).
fn exec_blueprint_leg(path: &str, tap: &[String], env: &aura_runner::project::Env) {
let doc = std::fs::read_to_string(path).unwrap_or_else(|e| {
eprintln!("aura: {path}: {e}");
std::process::exit(2);
});
let signal = blueprint_from_json(&doc, &|t| env.resolve(t)).unwrap_or_else(|e| {
let mut msg = graph_construct::blueprint_load_prose(&e);
if let Some(hint) = graph_construct::unresolved_namespace_hint(&e, env) {
msg.push_str("");
msg.push_str(&hint);
}
eprintln!("aura: {path}: {msg}");
std::process::exit(2);
});
// Same root-name gate as `dispatch_run`'s FILE intake (#331 delta
// re-review): `signal.name()` feeds `bind_tap_plan` -> `TraceStore::begin_run`
// unsanitized (`trace_store.rs`).
if let Err(msg) = graph_construct::gate_authored_root_name(signal.name()) {
eprintln!("aura: {path}: {msg}");
std::process::exit(2);
}
let has_bias = signal.output().iter().any(|o| o.name == "bias");
let tap_plan = match tap_plan_from_args(tap) {
Ok(p) => p,
Err(m) => {
eprintln!("aura: {m}");
std::process::exit(2);
}
};
if has_bias {
// Refuse an open (free-knob) blueprint at the dispatch boundary,
// mirroring `dispatch_run`'s own closed-blueprint guard (#176): exec
// bootstraps over the EMPTY point, so a free knob would panic in
// `compile_with_params` — reject it clean instead.
let free = blueprint_axis_probe(&doc, env).param_space();
if !free.is_empty() {
eprintln!(
"aura: run requires a closed blueprint (no free parameters); {} free knob(s) — \
bind them or use `aura sweep --axis`",
free.len()
);
std::process::exit(2);
}
let params: Vec<Scalar> = Vec::new();
let report = run_signal_r(signal, &params, RunData::Synthetic, 0, env, tap_plan);
println!("{}", report.to_json());
} else {
eprintln!(
"aura: `aura exec` needs a `bias` output (a strategy); this blueprint exposes \
none — the measurement leg is not yet carried by exec"
);
std::process::exit(1);
}
}
/// `aura run`: the loaded-blueprint branch (an existing `.json` first-positional) or
/// the built-in harness-kind dispatch.
fn dispatch_run(a: RunCmd, env: &aura_runner::project::Env) {
@@ -2826,6 +2955,7 @@ fn main() {
}
};
match cli.command {
Command::Exec(a) => dispatch_exec(a, &env),
Command::Run(a) => dispatch_run(a, &env),
Command::Chart(a) => dispatch_chart(a, &env),
Command::Graph(a) => dispatch_graph(a, &env),
+1 -26
View File
@@ -65,7 +65,7 @@ impl DocIntrospectCmd {
/// Parse `--parallel-instruments` in domain terms: clap's built-in
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
/// zero for non-zero type") at exactly the moment a user needs guidance.
fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
pub(crate) fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
s.parse::<usize>().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| {
"must be a whole number of at least 1 — it bounds how many distinct \
instruments are resident in parallel"
@@ -411,19 +411,6 @@ pub enum CampaignSub {
Introspect(DocIntrospectCmd),
/// Register a valid campaign document into the store under the runs root.
Register { file: PathBuf },
/// Execute a stored campaign into a realized run-set (a .json file is
/// register-then-run sugar; the canonical address is the content id).
Run {
target: String,
/// Bound on distinct instruments resident in parallel (the RAM
/// lever; 1 reproduces the sequential loop's footprint).
#[arg(
long,
default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
value_parser = parse_parallel_instruments,
)]
parallel_instruments: std::num::NonZeroUsize,
},
/// List stored campaign realizations, or dump one campaign's records
/// (the bare store lines, not the run-emit wrapper).
Runs { campaign: Option<String>, run: Option<usize> },
@@ -542,18 +529,7 @@ fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Resul
Ok(())
}
/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
/// via `exit_on_campaign_result`), so it is pulled out of the unified
/// `Result<(), String>` match the other four subcommands still share.
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
target,
env,
*parallel_instruments,
));
return;
}
let result = match &cmd.sub {
CampaignSub::Validate { file } => validate_campaign_file(file, env),
CampaignSub::Introspect(i) => {
@@ -563,7 +539,6 @@ pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
CampaignSub::Register { file } => register_campaign(file, env),
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
CampaignSub::Show { id } => show_campaign(id, env),
CampaignSub::Run { .. } => unreachable!("handled above"),
};
if let Err(m) = result {
eprintln!("aura: {m}");