Files
Aura/crates/aura-cli/src/research_docs.rs
T
claude d3b1a1aead feat(campaign,registry,cli): per-cell fault isolation — a failed cell is recorded, never a global abort
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.
2026-07-14 16:51:31 +02:00

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}");
}
}