Files
Aura/crates/aura-cli/src/campaign_run.rs
T
claude 34987be389 feat(cli): stderr class markers — diag module, campaign/verb-sugar retags
Iteration stderr-markers-1, tasks 1-2 of the stderr-class-markers plan
(spec signed via grounding-check PASS, decisions logged on #278).

The two stderr diagnostic classes become machine-separable (refs #278):
a new crate-internal diag module owns the grammar as note!/warning!
macros (`aura: note: <text>` benign continuing-run diagnostic,
`aura: warning: <text>` recorded fault the run survives); the four
undifferentiated sites are retagged — gate-emptied cell -> note,
failed cell / walkforward cell / mc cell -> warning — and the four
scaffold literals migrate onto the shared macro (output bytes
unchanged). The always-on record summary line gains the bare `aura: `
namespace it was missing; bare-prefix stays the vocabulary for error
lines accompanying a non-zero exit (C14 partition) and plain info
lines. Exit codes unchanged throughout.

Verification: RED->GREEN warning pin on the per-cell-fault e2e; new
gate-emptied e2e (impossible n_trades threshold) pins the note marker,
exit 0, and the summary-line prefix; scaffold e2es and the full
workspace suite green; clippy -D warnings clean on aura-cli. The C28
shell-module roster test gained the diag.rs entry.

Known residue for the next slices: aura-runner literals (stale-dylib
warning, three tap/no-nominee notes) and the #313 zero-trade notice
follow in tasks 3-5. The verb_sugar warning call sites remain e2e-
uncovered (a hostless walkforward fault fixture is structurally
unreachable — single-instrument validation refuses before the cell
loop); their grammar is pinned via the shared macro and the campaign
warning e2e.

refs #278
2026-07-23 22:52:46 +02:00

532 lines
23 KiB
Rust

//! `aura campaign run` — the driver that turns a stored campaign document into
//! a realized run-set (#198). The execution *semantics* (preflight, cell loop,
//! stage sequencing, selection, registry writes) live in `aura-campaign`; the
//! [`MemberRunner`](aura_campaign::MemberRunner) implementation over the shipped loaded-blueprint machinery
//! and the trace-persistence disk-layout writers live in `aura-runner`'s
//! `runner` module (#295 Task 7 — `DefaultMemberRunner` +
//! `persist_campaign_traces`). This module owns what is CLI-specific: target
//! resolution (file sugar vs content id), the project + referential gates,
//! fault prose (`exec_fault_prose` lives HERE, beside its consumer, not in
//! `research_docs.rs` — it phrases `aura_campaign` types the doc-tier module
//! deliberately does not import), and stdout/stderr emission.
//!
//! The member-run recipe (`blueprint_axis_probe`, `run_blueprint_member`,
//! `wrap_r`, …) and the axis/translate helpers live in `aura-runner` (#295)
//! and are reached by plain name via the `use` imports below. Remaining
//! main.rs-root items (`family_member_line`, …) are still reached via
//! `crate::` — the `graph_construct` submodule idiom: main.rs is the crate
//! root, so its private items are visible to child modules without promotion.
use std::num::NonZeroUsize;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use aura_campaign::ExecFault;
use aura_core::Scalar;
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,
};
use aura_runner::axes::is_content_id;
use aura_runner::project::Env;
use aura_runner::runner::{persist_campaign_traces, DefaultMemberRunner};
use crate::research_docs::{
doc_error_prose, doc_fault_prose, fault_block, parse_valid_campaign, ref_fault_prose,
};
/// Phrase an [`ExecFault`] for stderr — Debug-leak-free, path-addressed
/// (stage index + block id), the `doc_fault_prose`/`ref_fault_prose` register.
pub(crate) fn exec_fault_prose(f: &ExecFault) -> String {
match f {
ExecFault::PipelineShape { detail } => {
format!("process pipeline is not executable: {detail}")
}
ExecFault::UnrankableMetric { stage, metric } => format!(
"process stage {stage}: metric \"{metric}\" is not rankable (winner \
selection needs one of the registry's rankable metrics; see: aura \
process introspect --metrics)"
),
ExecFault::GateMetricNotPerMember { stage, metric } => format!(
"process stage {stage}: gate metric \"{metric}\" is not a per-member \
scalar (selection annotations cannot gate members; see: aura process \
introspect --metrics)"
),
ExecFault::PlateauInWalkForward { stage } => format!(
"process stage {stage}: walk_forward cannot use a plateau select (a \
gated survivor subset has no parameter lattice)"
),
ExecFault::DeflatePlateauConflict { stage } => format!(
"process stage {stage}: sweep deflate: true composes only with select \"argmax\""
),
ExecFault::SelectionFreeSweepNotTerminal { stage } => format!(
"process stage {stage}: a sweep without a selection group (metric + \
select) must be the last stage of its process"
),
ExecFault::GeneralizeNeedsInstruments { available } => format!(
"campaign has {available} instrument(s); std::generalize needs at least 2"
),
ExecFault::GeneralizeNonRMetric { metric } => format!(
"std::generalize metric \"{metric}\" is not in the rankable R-expectancy \
family (expectancy_r, net_expectancy_r, sqn, sqn_normalized generalize; \
see: aura process introspect --metrics)"
),
ExecFault::ZeroBootstrapParam { stage, field } => format!(
"process stage {stage}: monte_carlo {field} must be > 0"
),
ExecFault::Window { stage, detail } => format!(
"process stage {stage}: walk_forward windows do not fit the campaign \
window: {detail}"
),
ExecFault::Member(m) => aura_campaign::member_fault_prose(m),
ExecFault::Registry(e) => e.to_string(),
}
}
/// stdout wire form of one selection-bearing stage. Field order is the wire
/// contract (serde derives declaration order).
#[derive(serde::Serialize)]
struct SelectionReportLine<'a> {
selection_report: SelectionReportBody<'a>,
}
#[derive(serde::Serialize)]
struct SelectionReportBody<'a> {
family_id: &'a str,
stage: usize,
block: &'a str,
winner_ordinal: usize,
params: &'a [(String, Scalar)],
selection: &'a FamilySelection,
}
/// The always-on final line: the stored realization record under one key.
#[derive(serde::Serialize)]
struct CampaignRunLine<'a> {
campaign_run: &'a CampaignRunRecord,
}
/// Stdout shape of a campaign run. `Full` is `aura campaign run` (emit-gated
/// member/selection lines + the always-on final `campaign_run` record line).
/// `MemberLinesOnly` is dissolved-verb sugar: the generated document's
/// `emit: ["family_table"]` already limits emission to member lines; the mode
/// additionally suppresses the final record line so the verb's stdout
/// contract is reproduced byte-for-byte. The record append is identical in
/// both modes — presentation changes, the record does not.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum RunPresentation {
Full,
MemberLinesOnly,
}
/// `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`.
pub(crate) fn run_campaign(
target: &str,
env: &Env,
parallel_instruments: NonZeroUsize,
) -> Result<usize, String> {
// Project gate FIRST: nothing (not even the file-sugar registration)
// touches a store outside a project.
if env.provenance().is_none() {
let cwd = std::env::current_dir()
.map(|d| d.display().to_string())
.unwrap_or_default();
return Err(format!(
"campaign run needs a project: strategies resolve against the project \
store and vocabulary (no Aura.toml found up from {cwd})"
));
}
let registry = env.registry();
// Target resolution: a readable file is register-then-run sugar; a bare
// 64-hex token addresses the store directly; anything else refuses naming
// both readings. A CLI arg is ephemeral input, so a leading `content:`
// display prefix is tolerated here (#194) — doc ref FIELDS stay bare-only
// (canonical-form byte stability).
let target = target.strip_prefix("content:").filter(|t| is_content_id(t)).unwrap_or(target);
let campaign_id = if Path::new(target).is_file() {
let doc = parse_valid_campaign(&PathBuf::from(target))?;
registry
.put_campaign(&campaign_to_json(&doc))
.map_err(|e| e.to_string())?
} else if is_content_id(target) {
target.to_string()
} else {
return Err(format!(
"'{target}' is neither a readable .json file nor a 64-hex content id"
));
};
run_campaign_by_id(&campaign_id, env, RunPresentation::Full, parallel_instruments)
}
/// An executed campaign plus the context its presenter and the dissolved-verb
/// sugar consume: `outcome` (records + per-cell realizations) and the
/// `campaign` / `strategies` / `server` the emit + trace-persistence tail need.
/// Returned by [`run_campaign_returning`] so a caller can read the outcome
/// without the stdout tail (a forthcoming dissolved-verb sugar path — the
/// generalize translator's own runner lands in a later task).
pub(crate) struct CampaignRun {
pub outcome: aura_campaign::CampaignOutcome,
pub campaign: CampaignDoc,
pub strategies: Vec<(String, String)>,
pub server: Arc<data_server::DataServer>,
}
/// The one campaign executor path from a resolved content id onward: fetch
/// the stored canonical bytes by id (so file addressing and id addressing
/// produce the same realization by construction) and re-run the intrinsic
/// tier on them, execute, and emit per `presentation`. Shared by
/// `run_campaign` (`RunPresentation::Full`) and the dissolved-verb sugar path
/// (`verb_sugar::run_sweep_sugar`, `RunPresentation::MemberLinesOnly`) — no
/// project gate here: the sugar path must work project-less exactly as the
/// inline verb it replaces did (store mechanics run against `env.registry()`
/// in both cases).
pub(crate) fn run_campaign_by_id(
campaign_id: &str,
env: &Env,
presentation: RunPresentation,
parallel_instruments: NonZeroUsize,
) -> Result<usize, String> {
let run = run_campaign_returning(campaign_id, env, parallel_instruments)?;
present_campaign(run, presentation, env)
}
/// The one campaign executor path from a resolved content id up to (not
/// including) presentation: fetch the stored canonical bytes by id (so file
/// addressing and id addressing produce the same realization by construction),
/// re-run the intrinsic tier, resolve strategies, execute — returning the
/// 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).
pub(crate) fn run_campaign_returning(
campaign_id: &str,
env: &Env,
parallel_instruments: NonZeroUsize,
) -> 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)
.map_err(|e| doc_error_prose("stored campaign document", &e))?;
let faults = validate_campaign(&campaign);
if !faults.is_empty() {
return Err(fault_block(
"campaign document invalid:",
faults.iter().map(doc_fault_prose).collect(),
));
}
// Referential gate: zero faults or refuse (the campaign-validate seam).
let resolve = |t: &str| env.resolve(t);
let ref_faults = registry
.validate_campaign_refs(&campaign, &resolve)
.map_err(|e| e.to_string())?;
if !ref_faults.is_empty() {
return Err(fault_block(
"campaign references do not resolve:",
ref_faults.iter().map(ref_fault_prose).collect(),
));
}
// Process fetch + intrinsic validation (stored text, not a file path —
// parse_valid_process is file-based, so its constituents run here).
let DocRef::ContentId(process_id) = &campaign.process.r#ref else {
// validate_campaign already refuses identity process refs; defensive.
return Err("process.ref: a process is referenced by content id in this version".into());
};
let process_text = registry
.get_process(process_id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("no process {process_id} in the project store"))?;
let process = parse_process(&process_text)
.map_err(|e| doc_error_prose("stored process document", &e))?;
let process_faults = validate_process(&process);
if !process_faults.is_empty() {
return Err(fault_block(
"process document invalid:",
process_faults.iter().map(doc_fault_prose).collect(),
));
}
// Strategies: canonical bytes from the store, index-aligned with the doc.
// The recorded id is the content id of those bytes (== the ref id for
// content refs; computed for identity refs).
let mut strategies: Vec<(String, String)> = Vec::with_capacity(campaign.strategies.len());
for entry in &campaign.strategies {
let canonical = match &entry.r#ref {
DocRef::ContentId(id) => registry
.get_blueprint(id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("strategy {id} not found in the blueprint store"))?,
DocRef::IdentityId(id) => registry
.find_blueprint_by_identity(id, &resolve)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("identity id {id} matches no stored blueprint"))?,
};
strategies.push((content_id_of(&canonical), canonical));
}
let runner = DefaultMemberRunner::new(
env,
Arc::new(data_server::DataServer::new(env.data_path())),
campaign.data.bindings.clone(),
campaign.cost.clone(),
);
let outcome = aura_campaign::execute(
campaign_id,
&campaign,
&process,
&strategies,
&runner,
&registry,
parallel_instruments,
)
.map_err(|f| exec_fault_prose(&f))?;
Ok(CampaignRun { outcome, campaign, strategies, server: runner.server() })
}
/// Emit an executed campaign's results per `presentation`: the zero-survivor
/// stderr notes, the emit-gated family/selection lines, the always-on record
/// line (Full only), then trace persistence. Split out of `run_campaign_by_id`
/// so the dissolved-verb sugar can consume the outcome without this stdout tail.
fn present_campaign(
run: CampaignRun,
presentation: RunPresentation,
env: &Env,
) -> Result<usize, String> {
let CampaignRun { outcome, campaign, strategies, server } = run;
// Zero-survivor stderr notes (exit stays 0 — a null result is a valid
// research result, #198 decision 8). Addressed by the fields the record
// already carries (strategy/instrument/window_ms), not by re-deriving a
// doc-order ordinal from the loop-nesting the executor happens to use
// today — that duplicated invariant would silently drift if the executor
// ever reorders its cell loop.
for cell in &outcome.record.cells {
for (stage_ix, st) in cell.stages.iter().enumerate() {
if matches!(&st.survivor_ordinals, Some(v) if v.is_empty()) {
crate::diag::note!(
"cell {}/{}/[{}, {}]: gate at stage {stage_ix} left no \
survivors; cell realization truncated",
cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1
);
}
}
}
// #272: failed-cell notes (the gate-empty idiom's sibling) — a failed
// cell never aborts the campaign; its fault is recorded and the run
// continues over the remaining cells.
let mut failed = 0usize;
let mut fail_labels: Vec<String> = Vec::new();
for cell in &outcome.record.cells {
if let Some(f) = &cell.fault {
failed += 1;
fail_labels.push(format!("{}: {}", cell.instrument, cell_fault_kind_label(f.kind)));
crate::diag::warning!(
"cell ({}, {}, [{}, {}]) failed at stage {}: {} — recorded, campaign continues",
cell.strategy, cell.instrument, cell.window_ms.0, cell.window_ms.1, f.stage, f.detail
);
}
}
// Emission: emit-gated family/selection lines per cell, then the
// always-on final record line. Matched by NAME against the two closed-
// vocabulary terms (self-evident at the call site, unlike positional
// indexing into the vocab slice); the debug_assert keeps the names
// honest against `aura_research::emit_vocabulary()` so a #190
// rename/extend of the closed set fails loudly here instead of silently
// misrouting emission.
debug_assert!(
aura_research::emit_vocabulary().contains(&"family_table")
&& aura_research::emit_vocabulary().contains(&"selection_report"),
"emit_vocabulary drifted from the names campaign_run matches by"
);
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 {
if emit_family {
for fam in &cell_out.families {
for report in &fam.reports {
println!("{}", crate::family_member_line(&fam.family_id, report));
}
}
}
if emit_selection {
for sel in &cell_out.selections {
let line = SelectionReportLine {
selection_report: SelectionReportBody {
family_id: &sel.family_id,
stage: sel.stage,
block: sel.block,
winner_ordinal: sel.winner_ordinal,
params: &sel.params,
selection: &sel.selection,
},
};
println!(
"{}",
serde_json::to_string(&line).expect("selection report serializes")
);
}
}
}
if presentation == RunPresentation::Full {
println!(
"{}",
serde_json::to_string(&CampaignRunLine { campaign_run: &outcome.record })
.expect("campaign run record serializes")
);
eprintln!(
"aura: campaign run {} recorded: {} cells{}",
outcome.record.run,
outcome.record.cells.len(),
if failed == 0 {
String::new()
} else {
format!(", {failed} failed ({})", fail_labels.join(", "))
}
);
}
// Trace persistence runs LAST, after the always-on record line (0109):
// stdout stays data-pure (the record line is the wire contract) and the
// trace surface is a stderr concern; the realization record is already
// stored by `execute`, so a trace failure exits 1 without un-recording it.
if let Some(trace_name) = &outcome.record.trace_name {
persist_campaign_traces(
trace_name,
&campaign.presentation.persist_taps,
&outcome,
&campaign,
&strategies,
&server,
env,
)?;
}
Ok(failed)
}
/// #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
/// frame of the enum variant).
fn cell_fault_kind_label(kind: aura_registry::CellFaultKind) -> &'static str {
use aura_registry::CellFaultKind as K;
match kind {
K::NoData => "no_data",
K::Bind => "bind",
K::Run => "run",
K::Panic => "panic",
K::Window => "window",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
/// #197 (fieldtest 0107 F9): the executor's preflight refusal for a
/// non-rankable selection metric points the author at the verb that
/// enumerates the applicable roster, so the exists-but-not-rankable split
/// (a metric emitted in every member's block yet refused for winner
/// selection) is resolvable without trial and error. The intrinsic message
/// is unchanged; only a discovery pointer is appended.
fn unrankable_metric_prose_points_at_the_metrics_introspection() {
let prose = exec_fault_prose(&ExecFault::UnrankableMetric {
stage: 0,
metric: "profit_factor".into(),
});
assert!(prose.contains("is not rankable"), "intrinsic message kept: {prose}");
assert!(
prose.contains("aura process introspect --metrics"),
"the refusal must point at the enumeration verb: {prose}"
);
}
#[test]
/// #197: the twin refusal — a gate predicate over a non-per-member scalar
/// (a selection annotation) — likewise points at the enumeration verb.
fn gate_metric_not_per_member_prose_points_at_the_metrics_introspection() {
let prose = exec_fault_prose(&ExecFault::GateMetricNotPerMember {
stage: 1,
metric: "deflated_score".into(),
});
assert!(prose.contains("is not a per-member"), "intrinsic message kept: {prose}");
assert!(
prose.contains("aura process introspect --metrics"),
"the refusal must point at the enumeration verb: {prose}"
);
}
#[test]
/// generalize's static instrument-arity refusal is Debug-free and
/// names the available count plus the required floor.
fn generalize_needs_instruments_prose_names_the_shortfall() {
let prose = exec_fault_prose(&ExecFault::GeneralizeNeedsInstruments { available: 1 });
assert_eq!(prose, "campaign has 1 instrument(s); std::generalize needs at least 2");
assert!(!prose.contains("GeneralizeNeedsInstruments"), "Debug leak: {prose}");
}
#[test]
/// #207 (fieldtest 0108 F8): generalize's refusal names the REAL rule — the
/// rankable R-expectancy family that generalizes — and points at the roster
/// verb, instead of the old "pip metrics do not" mislabel (which was wrong
/// for R-denominated-but-unranked names like max_r_drawdown). The four
/// accepted names are enumerated in the prose as a fail-safe hint; the
/// durable source is the `(see: …)` roster pointer.
fn generalize_non_r_metric_prose_names_the_metric() {
let prose = exec_fault_prose(&ExecFault::GeneralizeNonRMetric {
metric: "total_pips".into(),
});
assert_eq!(
prose,
"std::generalize metric \"total_pips\" is not in the rankable R-expectancy \
family (expectancy_r, net_expectancy_r, sqn, sqn_normalized generalize; \
see: aura process introspect --metrics)"
);
assert!(!prose.contains("GeneralizeNonRMetric"), "Debug leak: {prose}");
}
#[test]
/// the zero-bootstrap-param refusal is path-addressed (stage index)
/// and names the offending field.
fn zero_bootstrap_param_prose_names_stage_and_field() {
let prose = exec_fault_prose(&ExecFault::ZeroBootstrapParam {
stage: 3,
field: "block_len",
});
assert_eq!(prose, "process stage 3: monte_carlo block_len must be > 0");
assert!(!prose.contains("ZeroBootstrapParam"), "Debug leak: {prose}");
}
#[test]
/// #272: the summary label is a CHECKED mirror of the persisted serde form
/// — an aggregate over `campaign_runs.jsonl` counts by the serialized
/// `CellFaultKind` string, so the human-facing label must be exactly that
/// string (no second source of truth that could silently drift).
fn cell_fault_kind_label_matches_the_serialized_kind() {
use aura_registry::CellFaultKind as K;
for kind in [K::NoData, K::Bind, K::Run, K::Panic, K::Window] {
let serde_form = serde_json::to_string(&kind).expect("serialize kind");
let serde_form = serde_form.trim_matches('"');
assert_eq!(
cell_fault_kind_label(kind),
serde_form,
"the summary label must equal the persisted serde string"
);
}
}
}