//! 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. pub(crate) 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 m in aura_research::metric_vocabulary() { let name = m.id; 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", }; // C29 (#315): the roster carries each metric's one-line meaning, not // only where it may be used. if generalize { println!("{name:<24} {base} | generalize — {}", m.doc); } else { println!("{name:<24} {base} — {}", m.doc); } } } 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, k must be > 0, and a fixed-stop distance 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().iter().map(|t| t.id).collect::>().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() } DocFault::BadDescription { subject, fault } => match fault { aura_core::DocGateFault::Empty => format!( "description: `{subject}`'s description is empty — omit the \ field or write a one-line meaning (C29)" ), aura_core::DocGateFault::RestatesName => format!( "description: `{subject}`'s description merely restates the \ document name (C29)" ), }, } } 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> { if env.provenance().is_none() { let cwd = std::env::current_dir() .map(|d| d.display().to_string()) .unwrap_or_default(); return Err(format!( "process register needs a project: the document store lives under the \ project store root (no Aura.toml found up from {cwd})" )); } 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(()) } /// Resolve `id` against a document store's full candidate content-id list — /// mirroring the exact-first / unique-match / else-refuse shape /// `aura_runner::reproduce::load_family` established for #298's family/run /// handle. Called only once the caller's own exact-match lookup has already /// missed, so a filter match here is always a strict, shorter prefix (ids /// are fixed-length hashes; a full-length miss cannot be a prefix of /// another). `Ok(None)`: no candidate matches — genuinely unknown, the /// caller's existing not-found prose applies unchanged. `Err`: two or more /// candidates share the prefix — refused by name, listing every candidate, /// rather than guessed. pub(crate) fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result, String> { let matches: Vec<&String> = candidates.iter().filter(|c| c.starts_with(prefix)).collect(); match matches.as_slice() { [] => Ok(None), [only] => Ok(Some((*only).clone())), many => { let listed = many.iter().map(|s| s.as_str()).collect::>().join(", "); Err(format!("ambiguous id \"{prefix}\": candidates {listed}")) } } } 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(); if let Some(bytes) = registry.get_process(id).map_err(|e| e.to_string())? { // `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}"); return Ok(()); } let candidates = registry.list_process_ids().map_err(|e| e.to_string())?; match resolve_id_prefix(id, &candidates)? { Some(full_id) => match registry.get_process(&full_id).map_err(|e| e.to_string())? { Some(bytes) => { print!("{bytes}"); Ok(()) } None => Err(ref_fault_prose(&RefFault::ProcessNotFound(id.to_string()))), }, 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)); // C29 (#315): the tap slot's closed value vocabulary carries its // per-entry meanings, indented under the slot line. if matches!(s.kind, aura_research::SlotKind::TapKinds) { for t in aura_research::tap_vocabulary() { println!(" {:<14} {}", t.id, t.doc); } } } 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 }, /// 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 { "" } } /// `show_campaign`'s not-found refusal prose — campaigns have no `RefFault` /// sibling (unlike processes' `ref_fault_prose`), so this is the single /// source both the exact-miss and prefix-resolved-but-then-missing arms /// route through, keeping the two copies from drifting. fn campaign_not_found_prose(id: &str) -> String { format!("campaign {id} not found in the project store{}", prefix_hint(id)) } 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, raw_candidate } => { let mut msg = format!("strategy {strategy}: axis \"{axis}\" is not in the param space"); if let Some(candidate) = raw_candidate { msg.push_str(&format!( "; axis names are raw node.param paths — did you mean \"{candidate}\"?" )); } msg } 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(()) } pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) { 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), }; 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> { if env.provenance().is_none() { let cwd = std::env::current_dir() .map(|d| d.display().to_string()) .unwrap_or_default(); return Err(format!( "campaign register needs a project: the document store lives under the \ project store root (no Aura.toml found up from {cwd})" )); } 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(); if let Some(bytes) = registry.get_campaign(id).map_err(|e| e.to_string())? { // `print!`, not `println!`: see `show_process` — the CLI must not // frame the canonical bytes with a trailing newline (#164). print!("{bytes}"); return Ok(()); } let candidates = registry.list_campaign_ids().map_err(|e| e.to_string())?; match resolve_id_prefix(id, &candidates)? { Some(full_id) => match registry.get_campaign(&full_id).map_err(|e| e.to_string())? { Some(bytes) => { print!("{bytes}"); Ok(()) } None => Err(campaign_not_found_prose(id)), }, None => Err(campaign_not_found_prose(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(), raw_candidate: None, }), 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}" ); } } /// #328: `ref_fault_prose` appends the did-you-mean clause, contiguous /// and verbatim, exactly when `raw_candidate` is present — the document- /// side refusal seam's mirror of the sweep `--axis` intake translation /// (`aura-runner`'s `axes.rs` classify predicate), sharing the same /// `axis names are raw node.param paths` clause pinned there. #[test] fn ref_fault_prose_renders_the_did_you_mean_when_a_raw_candidate_is_present() { let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace { strategy: "9f3a".into(), axis: "graph.fast.length".into(), raw_candidate: Some("fast.length".into()), }); assert_eq!( msg, "strategy 9f3a: axis \"graph.fast.length\" is not in the param space; \ axis names are raw node.param paths — did you mean \"fast.length\"?" ); } /// #328 (negative): a rejected axis with no `raw_candidate` (stripping one /// leading segment yields no param-space hit, or the axis has no leading /// segment to strip at all) renders today's prose unchanged — no /// speculative suggestion. #[test] fn ref_fault_prose_omits_the_did_you_mean_when_no_raw_candidate() { let msg = ref_fault_prose(&RefFault::AxisNotInParamSpace { strategy: "9f3a".into(), axis: "nope".into(), raw_candidate: None, }); assert_eq!(msg, "strategy 9f3a: axis \"nope\" is not in the param space"); } #[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}"); } /// C29 (#316): the description-quality fault names the subject and the /// rule (C29) for both sub-faults — an empty description and one that /// merely restates the document name — never leaking the Debug variant /// names of either `DocFault` or the nested `DocGateFault`. #[test] fn bad_description_prose_names_subject_and_rule_for_both_faults() { let empty = doc_fault_prose(&DocFault::BadDescription { subject: "ger40-momentum".into(), fault: aura_core::DocGateFault::Empty, }); assert!( empty.contains("`ger40-momentum`'s description is empty"), "subject and emptiness named: {empty}" ); assert!(empty.contains("C29"), "rule cited: {empty}"); assert!(!empty.contains("BadDescription") && !empty.contains("DocGateFault"), "Debug leak: {empty}"); let restated = doc_fault_prose(&DocFault::BadDescription { subject: "ger40-momentum".into(), fault: aura_core::DocGateFault::RestatesName, }); assert!( restated.contains("`ger40-momentum`'s description merely restates the document name"), "subject and restatement named: {restated}" ); assert!(restated.contains("C29"), "rule cited: {restated}"); assert!(!restated.contains("BadDescription") && !restated.contains("DocGateFault"), "Debug leak: {restated}"); } /// #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}" ); } } /// #302: the ambiguity arm of `resolve_id_prefix` — content ids are /// hash-derived, not choosable, so two documents sharing a printed /// prefix cannot be constructed honestly through an E2E fixture /// (`show_resolves_a_unique_prefix_to_the_full_id_bytes` pins the /// unique-match arm end-to-end instead). This unit test is the only pin /// the ambiguous-refusal arm gets: a prefix shared by more than one /// candidate refuses rather than guesses, and the refusal names every /// sharing candidate so the author can pick the intended one. #[test] fn resolve_id_prefix_refuses_an_ambiguous_prefix_listing_candidates() { let candidates = vec!["4e2d1111".to_string(), "4e2d2222".to_string(), "9f3a0000".to_string()]; let err = resolve_id_prefix("4e2d", &candidates).expect_err("shared prefix must refuse"); assert!(err.contains("4e2d1111"), "ambiguity refusal must list candidate 1: {err}"); assert!(err.contains("4e2d2222"), "ambiguity refusal must list candidate 2: {err}"); assert!(!err.contains("9f3a0000"), "ambiguity refusal must not list a non-matching id: {err}"); // Companion sanity for the two other arms at the same seam: a unique // prefix resolves, and no matching prefix reports as unresolved // rather than fabricating one. assert_eq!( resolve_id_prefix("9f3a", &candidates).expect("unique prefix must resolve"), Some("9f3a0000".to_string()) ); assert_eq!(resolve_id_prefix("dead", &candidates).expect("no match is not an error"), None); } }