d3b1a1aead
closes #272 A member fault (no-data, bind, run, or a caught panic) is now a recorded per-cell outcome instead of aborting the whole campaign and discarding every already-computed cell. The incident that motivated this (a 22-instrument campaign lost ~36 healthy cells ~6.7 min in because Copper had an archive gap) now completes: the healthy cells persist, the gap cell is recorded as failed, and the run exits 3. Direction (owner decision 2026-07-14): run to completion and report compromised results; no coverage preflight, no window synthesis. Containment granularity: - The CELL for a sweep-stage member fault (a grid hole structurally compromises winner selection, so the whole cell fails). - The FOLD for a walk_forward member fault (independent time windows): the surviving folds pool into the family, failed folds are recorded as StageRealization.window_faults, and the summary names the ratio. - aura-registry: additive CellFault / CellFaultKind (closed: no_data|bind|run|panic|window) / WindowFault / CellCoverage, plus fault/coverage fields on CellRealization and window_faults on StageRealization — all serde-default-skipped, so pre-#272 campaign_runs lines parse and round-trip byte-identical. - aura-campaign: run_cell returns a fault-annotated CellRealization instead of Err (execute's accumulate-then-append-once tail is unchanged and now persists every healthy cell + the one run record); a `contain` split keeps ExecFault::Registry and doc-shape preflight faults global while Member/Window become per-cell/per-fold. Member panics are caught with catch_unwind(AssertUnwindSafe) at all three member-run sites (sweep IS/OOS) and recorded as MemberFault::Panic — a member panic no longer aborts the process. The wf stage partitions Registry faults (global) from Member/Window (per-fold) and filters faulted-fold placeholders (the "faulted-member-placeholder" broker sentinel) out of the persisted family. - aura-cli: exec_fault_prose gains the Panic arm; CliMemberRunner::window_coverage derives effective bounds + interior gap months from the #264 archive primitives; present_campaign prints per-cell failure notes + a completion summary and threads the failed-cell count; a run with >=1 failed cell exits 3 ("completed with failed cells") uniformly across `aura campaign run` and the dissolved sweep/walkforward/mc/generalize verbs (exit_on_campaign_result). Usage stays 2, refused-before-running stays 1, clean stays 0. Tests: the global-abort pins flip to containment (execute + the two wf fault tests → fold-containment + all-folds-fail-the-cell); new panic-containment tests on both the sweep path (PanicRunner) and the wf path (this commit adds the wf mirror the loop left uncovered); a new gapped-archive e2e (one covered cell + one gap cell → exit 3); the ~14 CLI exit-1 pins move to the exit-3 register; a pre-#272-line byte-identical round-trip guard. Suite: cargo test --workspace green (1309 tests, 0 failed); clippy clean. Decision log: #272 comments (fork rationale, the fold Registry/Member split, the placeholder sentinel, uniform exit-3). Follow-up (minor, not blocking): the plan under-scoped Task 1 to aura-registry though the additive fields also touch aura-campaign's exec.rs literals — the loop absorbed it mechanically; a future plan for a cross-crate additive-field change should scope every crate's construction sites in the first task.
694 lines
27 KiB
Rust
694 lines
27 KiB
Rust
//! CLI surface for the research-artifact documents: the `aura process` and
|
|
//! `aura campaign` verb families. Presentation layer only —
|
|
//! parsing/validation/introspection live in aura-research; stores and the
|
|
//! referential tier in aura-registry.
|
|
|
|
use std::path::PathBuf;
|
|
|
|
use aura_research::{
|
|
binding_column_vocabulary_display, campaign_to_json, campaign_vocabulary, describe_block,
|
|
open_slots_campaign, open_slots_process, parse_campaign, parse_process, process_to_json,
|
|
process_vocabulary, slot_kind_label, tap_vocabulary, validate_campaign, validate_process,
|
|
CampaignDoc, DocError, DocFault, DocKind, ProcessDoc, StageBlock,
|
|
};
|
|
use aura_registry::RefFault;
|
|
|
|
use crate::project::Env;
|
|
|
|
#[derive(clap::Args)]
|
|
pub struct ProcessCmd {
|
|
#[command(subcommand)]
|
|
pub sub: ProcessSub,
|
|
}
|
|
|
|
#[derive(clap::Subcommand)]
|
|
pub enum ProcessSub {
|
|
/// Validate a process document (intrinsic tier).
|
|
Validate { file: PathBuf },
|
|
/// Introspect the process vocabulary or a document.
|
|
Introspect(DocIntrospectCmd),
|
|
/// Register a valid process document into the store under the runs root.
|
|
Register { file: PathBuf },
|
|
}
|
|
|
|
#[derive(clap::Args)]
|
|
pub struct DocIntrospectCmd {
|
|
/// List the block vocabulary
|
|
#[arg(long)]
|
|
pub vocabulary: bool,
|
|
/// Describe one block's typed slots
|
|
#[arg(long, value_name = "ID")]
|
|
pub block: Option<String>,
|
|
/// List the open slots of a (partial) document — start from a bare {} to see the whole envelope
|
|
#[arg(long, value_name = "FILE")]
|
|
pub unwired: Option<PathBuf>,
|
|
/// Print the content id of a valid document
|
|
#[arg(long, value_name = "FILE")]
|
|
pub content_id: Option<PathBuf>,
|
|
/// List the metric vocabulary with applicability tags
|
|
#[arg(long)]
|
|
pub metrics: bool,
|
|
}
|
|
|
|
impl DocIntrospectCmd {
|
|
fn selected_modes(&self) -> usize {
|
|
usize::from(self.vocabulary)
|
|
+ usize::from(self.block.is_some())
|
|
+ usize::from(self.unwired.is_some())
|
|
+ usize::from(self.content_id.is_some())
|
|
+ usize::from(self.metrics)
|
|
}
|
|
}
|
|
|
|
/// Exactly one introspect mode (the graph-introspect guard idiom: usage
|
|
/// error on stderr, exit 2).
|
|
fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
|
|
if cmd.selected_modes() != 1 {
|
|
eprintln!(
|
|
"aura: Usage: aura {family} introspect --vocabulary | --block <ID> | --unwired <FILE> | --content-id <FILE> | --metrics"
|
|
);
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
|
|
/// `--metrics`: the 17-name metric vocabulary, tagged by where a name may be
|
|
/// used — `rankable` (sweep/walk_forward `select` metrics), `gate`
|
|
/// (per-member gate predicates), `annotation` (selection annotations,
|
|
/// neither). The tags derive from aura-campaign's existing pub rosters, so
|
|
/// this mode adds no fourth roster site (#190); metrics are process-side
|
|
/// vocabulary, but the mode rides the shared introspect struct and answers
|
|
/// for both families.
|
|
fn print_metric_roster() {
|
|
for name in aura_research::metric_vocabulary() {
|
|
let rankable = aura_campaign::RANKABLE_METRICS.contains(name);
|
|
let gate = aura_campaign::PER_MEMBER_METRICS.contains(name);
|
|
// The generalize applicability is the registry's own R-expectancy
|
|
// predicate — no fourth roster site (#190/#207).
|
|
let generalize = aura_registry::check_r_metric(name).is_ok();
|
|
let base = match (rankable, gate) {
|
|
(true, true) => "rankable | gate",
|
|
(false, true) => "gate",
|
|
// Unreachable today (the rankable roster is a subset of the
|
|
// per-member roster) — kept total so a roster shift stays honest.
|
|
(true, false) => "rankable",
|
|
(false, false) => "annotation",
|
|
};
|
|
if generalize {
|
|
println!("{name:<24} {base} | generalize");
|
|
} else {
|
|
println!("{name:<24} {base}");
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) fn doc_error_prose(what: &str, e: &DocError) -> String {
|
|
match e {
|
|
DocError::NotJson(msg) => format!("{what} is not JSON: {msg}"),
|
|
DocError::BadFormatVersion(v) => {
|
|
format!("{what}: unsupported format_version {v} (expected 1)")
|
|
}
|
|
DocError::WrongKind { expected } => {
|
|
let want = match expected {
|
|
DocKind::Process => "process",
|
|
DocKind::Campaign => "campaign",
|
|
};
|
|
format!("{what}: the \"kind\" key must be \"{want}\"")
|
|
}
|
|
DocError::Malformed(msg) => format!("{what}: {msg}"),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
|
|
match f {
|
|
DocFault::EmptyPipeline => "the pipeline is empty — a process needs at least one stage".into(),
|
|
DocFault::UnknownMetric { stage, metric } => {
|
|
format!(
|
|
"stage {stage}: unknown metric \"{metric}\" (see: aura process introspect --metrics)"
|
|
)
|
|
}
|
|
DocFault::EmptyConjunction { stage } => {
|
|
format!("stage {stage}: a gate needs at least one predicate")
|
|
}
|
|
DocFault::GateFirst => "stage 0: a gate cannot open the pipeline (nothing to filter yet)".into(),
|
|
DocFault::GateAfterTerminalStage { stage } => {
|
|
format!("stage {stage}: a gate cannot follow a terminal stage (monte_carlo/generalize)")
|
|
}
|
|
DocFault::ZeroWalkForwardLength { stage, field } => {
|
|
format!("pipeline[{stage}]: walk_forward {field} must be > 0")
|
|
}
|
|
DocFault::EmptyInstruments => "data.instruments is empty".into(),
|
|
DocFault::NoWindow => "data.windows is empty".into(),
|
|
DocFault::BadWindow { index } => {
|
|
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
|
|
}
|
|
DocFault::BadRegime { index } => {
|
|
format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
|
|
}
|
|
DocFault::BadCost { index } => {
|
|
format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
|
|
}
|
|
DocFault::CostInstrumentKeys { index, missing, extra } => {
|
|
let mut parts = Vec::new();
|
|
if !missing.is_empty() {
|
|
parts.push(format!("misses campaign instrument(s) {}", missing.join(", ")));
|
|
}
|
|
if !extra.is_empty() {
|
|
parts.push(format!("names no campaign instrument(s): {}", extra.join(", ")));
|
|
}
|
|
format!("cost[{index}]: instrument map {}", parts.join("; "))
|
|
}
|
|
DocFault::NoStrategy => "strategies is empty".into(),
|
|
DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
|
|
DocFault::EmptyAxis { strategy, axis } => {
|
|
format!("strategies[{strategy}].axes.{axis}: an axis is a non-empty finite set")
|
|
}
|
|
DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
|
|
DocFault::UnknownTap { index, tap } => format!(
|
|
"presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
|
|
tap_vocabulary().join(" | ")
|
|
),
|
|
DocFault::UnknownBindingColumn { role, column } => format!(
|
|
"data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
|
|
binding_column_vocabulary_display()
|
|
),
|
|
DocFault::ProcessRefMustBeContentId => {
|
|
"process.ref: a process is referenced by content id in this version".into()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn read_doc(file: &PathBuf) -> Result<String, String> {
|
|
std::fs::read_to_string(file).map_err(|e| format!("cannot read {}: {e}", file.display()))
|
|
}
|
|
|
|
pub(crate) fn fault_block(header: &str, lines: Vec<String>) -> String {
|
|
format!("{header}\n {}", lines.join("\n "))
|
|
}
|
|
|
|
/// `aura process …` — the dispatch entry main.rs calls.
|
|
pub fn process_cmd(cmd: ProcessCmd, env: &Env) {
|
|
let result = match &cmd.sub {
|
|
ProcessSub::Validate { file } => validate_process_file(file),
|
|
ProcessSub::Introspect(i) => {
|
|
guard_one_mode(i, "process");
|
|
introspect_process(i)
|
|
}
|
|
ProcessSub::Register { file } => register_process(file, env),
|
|
};
|
|
if let Err(m) = result {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn parse_valid_process(file: &PathBuf) -> Result<ProcessDoc, String> {
|
|
let text = read_doc(file)?;
|
|
let doc = parse_process(&text).map_err(|e| doc_error_prose("process document", &e))?;
|
|
let faults = validate_process(&doc);
|
|
if !faults.is_empty() {
|
|
return Err(fault_block(
|
|
"process document invalid:",
|
|
faults.iter().map(doc_fault_prose).collect(),
|
|
));
|
|
}
|
|
Ok(doc)
|
|
}
|
|
|
|
fn validate_process_file(file: &PathBuf) -> Result<(), String> {
|
|
let doc = parse_valid_process(file)?;
|
|
let gates: usize = doc
|
|
.pipeline
|
|
.iter()
|
|
.map(|s| match s {
|
|
StageBlock::Gate { all } => all.len(),
|
|
_ => 0,
|
|
})
|
|
.sum();
|
|
println!(
|
|
"process document valid (intrinsic): {} pipeline blocks, {} gate predicates",
|
|
doc.pipeline.len(),
|
|
gates
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
|
|
let doc = parse_valid_process(file)
|
|
.map_err(|m| format!("refusing to register: {m}"))?;
|
|
let canonical = process_to_json(&doc);
|
|
let registry = env.registry();
|
|
let id = registry.put_process(&canonical).map_err(|e| e.to_string())?;
|
|
println!(
|
|
"registered process {id} ({})",
|
|
registry.process_path(&id).display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> {
|
|
if cmd.metrics {
|
|
print_metric_roster();
|
|
return Ok(());
|
|
}
|
|
if cmd.vocabulary {
|
|
for b in process_vocabulary() {
|
|
println!("{:<18} {}", b.id, b.doc);
|
|
}
|
|
return Ok(());
|
|
}
|
|
if let Some(id) = &cmd.block {
|
|
return describe_one_block(id);
|
|
}
|
|
if let Some(file) = &cmd.unwired {
|
|
let text = read_doc(file)?;
|
|
let slots =
|
|
open_slots_process(&text).map_err(|e| doc_error_prose("process document", &e))?;
|
|
print_open_slots(&slots);
|
|
return Ok(());
|
|
}
|
|
if let Some(file) = &cmd.content_id {
|
|
let doc = parse_valid_process(file)
|
|
.map_err(|m| format!("an invalid document has no canonical form — {m}"))?;
|
|
println!("{}", aura_research::content_id_of(&process_to_json(&doc)));
|
|
return Ok(());
|
|
}
|
|
unreachable!("guard_one_mode enforces one mode")
|
|
}
|
|
|
|
fn describe_one_block(id: &str) -> Result<(), String> {
|
|
let schema = describe_block(id).ok_or_else(|| format!("unknown block \"{id}\""))?;
|
|
println!("{} — {}", schema.id, schema.doc);
|
|
for s in schema.slots {
|
|
let req = if s.required { "required" } else { "optional" };
|
|
println!(" {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn print_open_slots(slots: &[aura_research::OpenSlot]) {
|
|
for s in slots {
|
|
println!("open slot: {} ({})", s.path, s.hint);
|
|
for note in &s.notes {
|
|
println!(" {note}");
|
|
}
|
|
}
|
|
if slots.is_empty() {
|
|
println!("no open slots");
|
|
}
|
|
}
|
|
|
|
#[derive(clap::Args)]
|
|
pub struct CampaignCmd {
|
|
#[command(subcommand)]
|
|
pub sub: CampaignSub,
|
|
}
|
|
|
|
#[derive(clap::Subcommand)]
|
|
pub enum CampaignSub {
|
|
/// Validate a campaign document (intrinsic + referential inside a project).
|
|
Validate { file: PathBuf },
|
|
/// Introspect the campaign vocabulary or a document.
|
|
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 },
|
|
/// 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> },
|
|
}
|
|
|
|
/// The one authoring trap the referential tier can name outright: a ref that
|
|
/// carries the `content:` DISPLAY prefix (doc ref fields are bare-only —
|
|
/// canonical-form byte stability, #194).
|
|
fn prefix_hint(id: &str) -> &'static str {
|
|
if id.starts_with("content:") {
|
|
" (refs use the bare 64-hex id — drop the 'content:' prefix)"
|
|
} else {
|
|
""
|
|
}
|
|
}
|
|
|
|
pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
|
|
match f {
|
|
RefFault::ProcessNotFound(id) => {
|
|
format!("process {id} not found in the project store{}", prefix_hint(id))
|
|
}
|
|
RefFault::StrategyNotFound(id) => {
|
|
format!("strategy {id} not found in the blueprint store{}", prefix_hint(id))
|
|
}
|
|
RefFault::IdentityUnmatched(id) => format!("identity id {id} matches no stored blueprint"),
|
|
RefFault::StrategyUnloadable { id, error } => {
|
|
format!("strategy {id} cannot be loaded: {error}")
|
|
}
|
|
RefFault::AxisNotInParamSpace { strategy, axis } => {
|
|
format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
|
|
}
|
|
RefFault::AxisKindMismatch { strategy, axis } => {
|
|
format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
|
|
}
|
|
RefFault::ParamNotCovered { strategy, param } => {
|
|
format!("strategy {strategy}: open param \"{param}\" is bound by no campaign axis")
|
|
}
|
|
RefFault::BindingRoleUnknown { role, roles } => format!(
|
|
"data.bindings: key \"{role}\" names no input role of any campaign strategy (roles: {})",
|
|
roles.join(", ")
|
|
),
|
|
}
|
|
}
|
|
|
|
/// `aura campaign …` — the dispatch entry main.rs calls.
|
|
/// `aura campaign runs [CAMPAIGN-ID] [RUN]` (#206): the read-back for stored
|
|
/// realizations. Without an id: one scannable line per record. With an id
|
|
/// (optionally narrowed to one run counter): the BARE stored record line(s)
|
|
/// — the store form, never the `{"campaign_run": …}` run-emit wrapper (the
|
|
/// 0108 fieldtest F10 parsing trap). A lookup, not an action: an empty store
|
|
/// or unmatched id notes it and exits 0 (the `runs family` convention).
|
|
fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Result<(), String> {
|
|
if env.provenance().is_none() {
|
|
let cwd = std::env::current_dir()
|
|
.map(|d| d.display().to_string())
|
|
.unwrap_or_default();
|
|
return Err(format!(
|
|
"campaign runs needs a project: the realization store lives under the \
|
|
project runs root (no Aura.toml found up from {cwd})"
|
|
));
|
|
}
|
|
let records = env.registry().load_campaign_runs().map_err(|e| e.to_string())?;
|
|
match campaign {
|
|
None => {
|
|
if records.is_empty() {
|
|
println!("no campaign runs recorded");
|
|
return Ok(());
|
|
}
|
|
for r in &records {
|
|
let signature: Vec<&str> = r
|
|
.cells
|
|
.first()
|
|
.map(|c| c.stages.iter().map(|s| s.block.as_str()).collect())
|
|
.unwrap_or_default();
|
|
println!(
|
|
"{} run {} — {} cell(s), {}",
|
|
r.campaign,
|
|
r.run,
|
|
r.cells.len(),
|
|
if signature.is_empty() { "no stages".to_string() } else { signature.join(" -> ") },
|
|
);
|
|
}
|
|
}
|
|
Some(id) => {
|
|
let matched: Vec<_> = records
|
|
.iter()
|
|
.filter(|r| r.campaign == id && run.is_none_or(|n| r.run == n))
|
|
.collect();
|
|
if matched.is_empty() {
|
|
println!("no campaign runs recorded for {id}");
|
|
return Ok(());
|
|
}
|
|
for r in matched {
|
|
// serde round-trip == the stored bytes (compact, declaration
|
|
// order, sparse fields skipped) — the bare store form.
|
|
println!("{}", serde_json::to_string(r).map_err(|e| e.to_string())?);
|
|
}
|
|
}
|
|
}
|
|
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 } = &cmd.sub {
|
|
crate::exit_on_campaign_result(crate::campaign_run::run_campaign(target, env));
|
|
return;
|
|
}
|
|
let result = match &cmd.sub {
|
|
CampaignSub::Validate { file } => validate_campaign_file(file, env),
|
|
CampaignSub::Introspect(i) => {
|
|
guard_one_mode(i, "campaign");
|
|
introspect_campaign(i)
|
|
}
|
|
CampaignSub::Register { file } => register_campaign(file, env),
|
|
CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
|
|
CampaignSub::Run { .. } => unreachable!("handled above"),
|
|
};
|
|
if let Err(m) = result {
|
|
eprintln!("aura: {m}");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn parse_valid_campaign(file: &PathBuf) -> Result<CampaignDoc, String> {
|
|
let text = read_doc(file)?;
|
|
let doc = parse_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?;
|
|
let faults = validate_campaign(&doc);
|
|
if !faults.is_empty() {
|
|
return Err(fault_block(
|
|
"campaign document invalid:",
|
|
faults.iter().map(doc_fault_prose).collect(),
|
|
));
|
|
}
|
|
Ok(doc)
|
|
}
|
|
|
|
fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
|
|
let doc = parse_valid_campaign(file)?;
|
|
let axes: usize = doc.strategies.iter().map(|s| s.axes.len()).sum();
|
|
let points: usize = doc
|
|
.strategies
|
|
.iter()
|
|
.map(|s| s.axes.values().map(|a| a.values.len()).product::<usize>())
|
|
.sum();
|
|
let regimes = doc.risk.len().max(1);
|
|
let regime_marker = if doc.risk.is_empty() { " (default)" } else { "" };
|
|
let cells =
|
|
doc.strategies.len() * doc.data.instruments.len() * doc.data.windows.len() * regimes;
|
|
println!(
|
|
"campaign document valid (intrinsic): {} strategy(ies), {} axes ({} points), {} instrument(s), {} window(s), {} regime(s){} — {} cell(s)",
|
|
doc.strategies.len(),
|
|
axes,
|
|
points,
|
|
doc.data.instruments.len(),
|
|
doc.data.windows.len(),
|
|
regimes,
|
|
regime_marker,
|
|
cells
|
|
);
|
|
if env.provenance().is_some() {
|
|
let resolve = |t: &str| env.resolve(t);
|
|
let faults = env
|
|
.registry()
|
|
.validate_campaign_refs(&doc, &resolve)
|
|
.map_err(|e| e.to_string())?;
|
|
if !faults.is_empty() {
|
|
return Err(fault_block(
|
|
"campaign references do not resolve:",
|
|
faults.iter().map(ref_fault_prose).collect(),
|
|
));
|
|
}
|
|
println!("campaign document valid (referential): all references resolve, axes are in the param space");
|
|
// Third tier (#205): the executor's data-free static preflight, so
|
|
// "valid" means "runnable" for every pure document/campaign property.
|
|
// The referential tier just proved the process ref resolves, so the
|
|
// fetch cannot miss short of a store race.
|
|
let proc_id = match &doc.process.r#ref {
|
|
aura_research::DocRef::ContentId(id) | aura_research::DocRef::IdentityId(id) => {
|
|
id.clone()
|
|
}
|
|
};
|
|
let proc_text = env
|
|
.registry()
|
|
.get_process(&proc_id)
|
|
.map_err(|e| e.to_string())?
|
|
.ok_or_else(|| format!("no process {proc_id} in the project store"))?;
|
|
let process = parse_process(&proc_text)
|
|
.map_err(|e| doc_error_prose("stored process document", &e))?;
|
|
if let Err(f) = aura_campaign::preflight(&process, &doc) {
|
|
return Err(fault_block(
|
|
"campaign is not executable:",
|
|
vec![crate::campaign_run::exec_fault_prose(&f)],
|
|
));
|
|
}
|
|
println!("campaign document valid (executable): pipeline shape and static guards pass");
|
|
} else {
|
|
let cwd = std::env::current_dir()
|
|
.map(|d| d.display().to_string())
|
|
.unwrap_or_default();
|
|
println!("referential checks skipped (no Aura.toml found up from {cwd})");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
|
|
let doc = parse_valid_campaign(file)
|
|
.map_err(|m| format!("refusing to register: {m}"))?;
|
|
let canonical = campaign_to_json(&doc);
|
|
let registry = env.registry();
|
|
let id = registry.put_campaign(&canonical).map_err(|e| e.to_string())?;
|
|
println!(
|
|
"registered campaign {id} ({})",
|
|
registry.campaign_path(&id).display()
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> {
|
|
if cmd.metrics {
|
|
print_metric_roster();
|
|
return Ok(());
|
|
}
|
|
if cmd.vocabulary {
|
|
for b in campaign_vocabulary() {
|
|
println!("{:<18} {}", b.id, b.doc);
|
|
}
|
|
return Ok(());
|
|
}
|
|
if let Some(id) = &cmd.block {
|
|
return describe_one_block(id);
|
|
}
|
|
if let Some(file) = &cmd.unwired {
|
|
let text = read_doc(file)?;
|
|
let slots =
|
|
open_slots_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?;
|
|
print_open_slots(&slots);
|
|
return Ok(());
|
|
}
|
|
if let Some(file) = &cmd.content_id {
|
|
let doc = parse_valid_campaign(file)
|
|
.map_err(|m| format!("an invalid document has no canonical form — {m}"))?;
|
|
println!("{}", aura_research::content_id_of(&campaign_to_json(&doc)));
|
|
return Ok(());
|
|
}
|
|
unreachable!("guard_one_mode enforces one mode")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// Exact prose pin for the binding-key referential refusal (#231): the
|
|
/// message is path-addressed, names the offending key, and lists the
|
|
/// strategies' actual roles.
|
|
#[test]
|
|
fn binding_role_unknown_prose_names_key_and_roles() {
|
|
let msg = ref_fault_prose(&RefFault::BindingRoleUnknown {
|
|
role: "sentiment".into(),
|
|
roles: vec!["price".into(), "high".into()],
|
|
});
|
|
assert_eq!(
|
|
msg,
|
|
"data.bindings: key \"sentiment\" names no input role of any campaign \
|
|
strategy (roles: price, high)"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn ref_fault_prose_is_debug_free() {
|
|
let cases = [
|
|
ref_fault_prose(&RefFault::ProcessNotFound("4e2d".into())),
|
|
ref_fault_prose(&RefFault::StrategyNotFound("9f3a".into())),
|
|
ref_fault_prose(&RefFault::IdentityUnmatched("7b1c".into())),
|
|
ref_fault_prose(&RefFault::StrategyUnloadable {
|
|
id: "9f3a".into(),
|
|
error: "UnknownKnob(\"fast\")".into(),
|
|
}),
|
|
ref_fault_prose(&RefFault::AxisNotInParamSpace {
|
|
strategy: "9f3a".into(),
|
|
axis: "nope".into(),
|
|
}),
|
|
ref_fault_prose(&RefFault::AxisKindMismatch {
|
|
strategy: "9f3a".into(),
|
|
axis: "fast".into(),
|
|
}),
|
|
];
|
|
assert_eq!(cases[0], "process 4e2d not found in the project store");
|
|
assert_eq!(cases[1], "strategy 9f3a not found in the blueprint store");
|
|
assert_eq!(cases[2], "identity id 7b1c matches no stored blueprint");
|
|
assert_eq!(cases[3], "strategy 9f3a cannot be loaded: UnknownKnob(\"fast\")");
|
|
assert_eq!(cases[4], "strategy 9f3a: axis \"nope\" is not in the param space");
|
|
assert!(cases[5].contains("declares a kind that is not the param's kind"));
|
|
for c in &cases {
|
|
assert!(
|
|
!c.contains("NotFound") && !c.contains("Mismatch") && !c.contains("Unmatched") && !c.contains("NotInParamSpace"),
|
|
"Debug leak: {c}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
/// #194 (the prefix trap): a doc ref that carries the display `content:`
|
|
/// prefix (a copy-paste from register/introspect output) is bare-only in
|
|
/// canonical documents — the referential refusal names the prefix as the
|
|
/// cause instead of failing mute. A bare id keeps the plain refusal, so the
|
|
/// hint is conditional, not always-on.
|
|
fn ref_fault_prose_hints_when_a_ref_carries_the_content_prefix() {
|
|
let hash = "a".repeat(64);
|
|
let proc = ref_fault_prose(&RefFault::ProcessNotFound(format!("content:{hash}")));
|
|
let strat = ref_fault_prose(&RefFault::StrategyNotFound(format!("content:{hash}")));
|
|
assert!(proc.contains("drop the 'content:' prefix"), "process ref hint missing: {proc}");
|
|
assert!(strat.contains("drop the 'content:' prefix"), "strategy ref hint missing: {strat}");
|
|
let bare = ref_fault_prose(&RefFault::ProcessNotFound(hash));
|
|
assert!(!bare.contains("drop the 'content:' prefix"), "bare id must not carry the hint: {bare}");
|
|
}
|
|
|
|
#[test]
|
|
/// #197 (fieldtest 0107 F9): the intrinsic `unknown metric` refusal points
|
|
/// the author at the verb that enumerates the valid names, instead of
|
|
/// leaving the 17-name vocabulary discoverable only via the glossary. The
|
|
/// name itself is still quoted (the existing contract, pinned by the e2e
|
|
/// `process_validate_refuses_unknown_metric_*` test); only a discovery
|
|
/// pointer is appended.
|
|
fn unknown_metric_prose_points_at_the_metrics_introspection() {
|
|
let prose = doc_fault_prose(&DocFault::UnknownMetric { stage: 0, metric: "netto_r".into() });
|
|
assert!(prose.contains("unknown metric \"netto_r\""), "name still quoted: {prose}");
|
|
assert!(
|
|
prose.contains("aura process introspect --metrics"),
|
|
"the refusal must point at the enumeration verb: {prose}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn zero_walk_forward_length_prose_is_path_addressed_and_debug_free() {
|
|
let prose = doc_fault_prose(&DocFault::ZeroWalkForwardLength { stage: 2, field: "step_ms" });
|
|
assert_eq!(prose, "pipeline[2]: walk_forward step_ms must be > 0");
|
|
assert!(!prose.contains("ZeroWalkForwardLength"), "Debug leak: {prose}");
|
|
}
|
|
|
|
#[test]
|
|
/// #201 decision 1 (spec 0109): the unknown-tap refusal is path-addressed
|
|
/// (`presentation.persist_taps[i]`) and enumerates the closed vocabulary
|
|
/// inline — four names are small enough to print, so no introspection
|
|
/// pointer is needed (contrast the 17-name metric roster, which points at
|
|
/// `aura process introspect --metrics` instead).
|
|
fn unknown_tap_prose_is_path_addressed_and_enumerates_the_vocabulary() {
|
|
let prose = doc_fault_prose(&DocFault::UnknownTap { index: 0, tap: "bias".into() });
|
|
assert_eq!(
|
|
prose,
|
|
"presentation.persist_taps[0]: unknown tap \"bias\" \
|
|
(taps: equity | exposure | r_equity | net_r_equity)"
|
|
);
|
|
assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
|
|
}
|
|
|
|
/// #260: the instrument-map key-mismatch fault is path-addressed and names
|
|
/// both directions — the campaign instruments the map misses AND the map
|
|
/// keys naming no campaign instrument — in one line, no Debug leak. The
|
|
/// extra-keys branch is otherwise unpinned (the e2e covers only missing).
|
|
#[test]
|
|
fn cost_instrument_keys_prose_names_both_directions() {
|
|
let prose = doc_fault_prose(&DocFault::CostInstrumentKeys {
|
|
index: 1,
|
|
missing: vec!["EURUSD".into()],
|
|
extra: vec!["GER40.cash".into()],
|
|
});
|
|
assert_eq!(
|
|
prose,
|
|
"cost[1]: instrument map misses campaign instrument(s) EURUSD; \
|
|
names no campaign instrument(s): GER40.cash"
|
|
);
|
|
assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}");
|
|
}
|
|
}
|