Files
Aura/crates/aura-cli/src/research_docs.rs
T
claude 829a1984e6 feat(cli, std, research): self-describing surfaces — two-layer help, per-entry meanings, op reference
The binary is the only always-present teacher (C29); the 2026-07-22 external
field test showed the help teaching nothing but the verb inventory. This
cycle makes every closed vocabulary the binary ships listable with a one-line
meaning, and opens the help with the concepts the field agent had to discover
forensically.

#315 — help + introspection:
- `aura --help` opens with the two-layer concepts paragraph (research verbs
  as sugar over registered process/campaign documents; the directly
  authorable data plane; bias-as-target-position + protective-stop execution
  model in R; how traces come to exist).
- Each document-bridged sugar verb's long help names the process shape it
  desugars to (sweep -> std::sweep; walkforward -> [std::grid,
  std::walk_forward]; mc -> + std::monte_carlo; generalize -> [std::sweep
  (selection), std::generalize]). `run` is not verb_sugar-bridged, so its
  help points at the canonical document-first form instead of claiming a
  desugaring it does not perform.
- `graph introspect --vocabulary` lists each node type with its schema doc
  (bare names taught nothing); `--node <T>`'s head line carries the meaning.
- `graph introspect --folds` (new): the tap-fold vocabulary with bind rule,
  output kind, and meaning — it existed only as source comments. The rows
  live in aura-std (`fold_vocabulary`), unit-pinned against
  `fold_binds_at`/`fold_output_kind` so the prose cannot drift from the
  executable rules, and doc_gate-checked (C29 entry seam). The discovery
  surface is graph introspect because folds bind at graph-declared taps
  (C27); the document layer still refuses a folds slot pending #310.
- `process introspect --metrics` lines carry each metric's meaning (the doc
  column existed since #316 but was never printed).
- `campaign introspect --block std::presentation` lists the four persisted
  taps' meanings under the persist_taps slot.
- The scaffolded project CLAUDE.md teaches the execution semantics (bias as
  held target, protective stop, R) and the document data plane.

#323 — C29 authoring surfaces:
- `introspect --unwired` enumerates the optional `description` envelope slot
  for both document kinds (absent-only, like risk/cost); the campaign lib
  fixture gains a description, and the risk/cost-bound e2e binds description
  too so "no open slots" keeps meaning complete.
- `graph build --help` carries the op-list reference: all nine op kinds with
  fields and a worked element each (OP_REFERENCE lives beside OpDoc so a new
  variant is one screen from its help line).
- The authoring guide's S0 worked literal is byte-pinned to the scaffold's
  LIB_RS template (rendered with the guide's my_lab namespace); the scaffold
  e2es compile that template for real, so the pin is a transitive compile
  guard. The template gains the guide's C29 comment, the guide gains the
  template's rename note — one worked example, two surfaces, zero drift.

Deliberately out of scope (ratified #319, 2026-07-24): no doc-strings for
the quintet's 48 flag fields — the sugar surface retires; only the pointer
lines above bridge toward the document layer.

Verification: full workspace suite green, clippy -D warnings clean,
aura-bench exit 0 with all fingerprints OK (help_ms +1.6% — the longer help
text, in tolerance), cargo doc warning count unchanged (7 pre-existing).
9 new e2e pins in tests/help_self_description.rs; guide + CLAUDE.md pins in
scaffold unit tests; fold-vocabulary rules + doc_gate unit-pinned in
aura-std.

closes #315
closes #323
2026-07-24 10:02:38 +02:00

941 lines
39 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 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<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)
}
}
/// 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<std::num::NonZeroUsize, String> {
s.parse::<usize>().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 <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 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 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().iter().map(|t| t.id).collect::<Vec<_>>().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<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),
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<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> {
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.
fn resolve_id_prefix(prefix: &str, candidates: &[String]) -> Result<Option<String>, 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::<Vec<_>>().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 },
/// 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<String>, run: Option<usize> },
/// 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 } => {
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, 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<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> {
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(),
}),
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}");
}
/// 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 <cwd>") — 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);
}
}