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:
@@ -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, ¶ms, 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),
|
||||
|
||||
@@ -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}");
|
||||
|
||||
Reference in New Issue
Block a user