750ee93180
Iteration 3 of the self-description plan, tasks 1-3 (checkpoint commit
mid-iteration; the register e2e, the op-script doc slot, and the full
gates follow):
- aura-research: DocFault::BadDescription { subject, fault }; a present
document description passes doc_gate in validate_process /
validate_campaign (absent is never a fault and stays byte-identical);
prose arm in doc_fault_prose; tests incl. content-id participation.
- aura-registry: the store boundary gates admission -- the public
put_blueprint parses the canonical bytes and walks root + named
nested composite docs (None refuses like ""), refusing via
UndescribedComposite { name, fault } / MalformedBlueprint; the raw
write survives as private put_blueprint_unchecked (store-mechanics
tests + the never-retroactive witness: doc-less pre-C29 entries stay
readable). Registry-internal seeders triaged junk-vs-real.
- authored root docs: the 4 example blueprints + their 4 open-fixture
twins, the scaffold starter template, the scaled_signal and hl_signal
inline fixtures, and the three programmatic member.rs builders
(breakout, meanrev, hl_channel) via GraphBuilder::doc -- mechanical
propagation of the authored lines.
Recovery note: the implement run's task 3 discarded tasks 1+2's edits
in three files via a file-level checkout (the issue-#23 guard shape);
the loop's boundary snapshot caught it and the content was restored
from recovery snapshot 11a6ae2, then re-verified (research 81 green,
registry 51 green, cli 58/59).
Known-open at this checkpoint: the op-script register test is red --
op-built composites have no doc surface (#125 scope cut); the op
vocabulary grows the anticipated doc slot in the next task of this
iteration (decision logged on the issue).
refs #316
932 lines
38 KiB
Rust
932 lines
38 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",
|
|
};
|
|
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().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));
|
|
}
|
|
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);
|
|
}
|
|
}
|