//! `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 `: 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 { // 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, } /// 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 { 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 { 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, ®istry, 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 { 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 = 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" ); } } }