//! 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 aura_runner::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 }, /// Print a registered process document's canonical bytes to stdout. Show { id: String }, } #[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, /// 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, /// Print the content id of a valid document #[arg(long, value_name = "FILE")] pub content_id: Option, /// 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) } } /// Parse `--parallel-instruments` in domain terms: clap's built-in /// `NonZeroUsize` parser would leak the Rust type name ("number would be /// zero for non-zero type") at exactly the moment a user needs guidance. fn parse_parallel_instruments(s: &str) -> Result { s.parse::().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| { "must be a whole number of at least 1 — it bounds how many distinct \ instruments are resident in parallel" .to_string() }) } /// 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 | --unwired | --content-id | --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::DuplicateInstrument { index, instrument } => { format!("data.instruments[{index}]: \"{instrument}\" is listed more than once") } 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 { 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 { 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), ProcessSub::Show { id } => show_process(id, env), }; if let Err(m) = result { eprintln!("aura: {m}"); std::process::exit(1); } } fn parse_valid_process(file: &PathBuf) -> Result { 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 show_process(id: &str, 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!( "process show needs a project: the document store lives under the \ project store root (no Aura.toml found up from {cwd})" )); } let registry = env.registry(); match registry.get_process(id).map_err(|e| e.to_string())? { Some(bytes) => { // `print!`, not `println!`: `bytes` is the content-addressed canonical // form the store keyed `id` on — the CLI is a transport and must not // frame the canonical bytes with a trailing newline (#164, mirroring // `graph_construct::build_cmd`). print!("{bytes}"); Ok(()) } None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))), } } 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, /// Bound on distinct instruments resident in parallel (the RAM /// lever; 1 reproduces the sequential loop's footprint). #[arg( long, default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS, value_parser = parse_parallel_instruments, )] parallel_instruments: std::num::NonZeroUsize, }, /// List stored campaign realizations, or dump one campaign's records /// (the bare store lines, not the run-emit wrapper). Runs { campaign: Option, run: Option }, /// Print a registered campaign document's canonical bytes to stdout. Show { id: String }, } /// 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, 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, parallel_instruments } = &cmd.sub { crate::exit_on_campaign_result(crate::campaign_run::run_campaign( target, env, *parallel_instruments, )); return; } let result = match &cmd.sub { CampaignSub::Validate { file } => validate_campaign_file(file, env), CampaignSub::Introspect(i) => { 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::Show { id } => show_campaign(id, 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 { 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::()) .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 show_campaign(id: &str, 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 show needs a project: the document store lives under the \ project store root (no Aura.toml found up from {cwd})" )); } let registry = env.registry(); match registry.get_campaign(id).map_err(|e| e.to_string())? { Some(bytes) => { // `print!`, not `println!`: see `show_process` — the CLI must not // frame the canonical bytes with a trailing newline (#164). print!("{bytes}"); Ok(()) } None => Err(format!("campaign {id} not found in the project store{}", prefix_hint(id))), } } 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}"); } /// #300 fieldtest (df_4): outside a project (no Aura.toml → `Env::std()`), /// show's refusal names the missing project — the honest degradation /// `validate` and `campaign runs` already share ("no Aura.toml found up /// from ") — never a "project store" that does not exist. Blaming the /// id sends an author in the wrong directory chasing a registration that /// was never the problem. The refusal itself (Err → exit 1) is correct /// and stays. #[test] fn show_outside_a_project_names_the_missing_aura_toml_not_the_id() { let env = Env::std(); for (verb, result) in [ ("process show", show_process("deadbeef", &env)), ("campaign show", show_campaign("deadbeef", &env)), ] { let msg = result.expect_err("show outside a project must refuse"); assert!( msg.contains("no Aura.toml found up from"), "{verb} outside a project must name the missing Aura.toml: {msg}" ); assert!( !msg.contains("not found in the project store"), "{verb} outside a project must not blame the id against a \ store that does not exist: {msg}" ); } } }