feat(cli): 0106 task 10 — aura process verb family (validate/introspect/register)

New research_docs.rs module: role-addressed process verbs with house-style
fault prose (Display-free enums phrased at the seam), the introspect
exactly-one-of usage guard (exit 2), and content-addressed registration.
Seven binary seam tests pin intrinsic-ok/refusal prose, vocabulary/block/
unwired/content-id introspection, exit codes, and the stored file's
location under the runs root.

Quality-gate finding folded in (and back into the plan): register no
longer re-derives the store path from runs_root — aura-registry now
exposes process_path/campaign_path, the same single mapping its put/get
pair routes through, so the printed path cannot drift from the store
layout.

Verification: research_docs seam tests 7/0, registry 49/0, clippy clean.

refs #189
This commit is contained in:
2026-07-03 15:36:47 +02:00
parent ef3bec5844
commit db09e5de52
5 changed files with 403 additions and 6 deletions
+4
View File
@@ -14,6 +14,7 @@
mod render;
mod graph_construct;
mod project;
mod research_docs;
mod scaffold;
use render::{ChartData, ChartMeta, ChartMode, ReduceKind, Series};
@@ -3760,6 +3761,8 @@ enum Command {
Reproduce(ReproduceCmd),
/// Scaffold a new research project crate.
New(NewCmd),
/// Validate, introspect, and register process documents (methodology).
Process(research_docs::ProcessCmd),
}
#[derive(Args)]
@@ -4708,6 +4711,7 @@ fn main() {
Command::Runs(a) => dispatch_runs(a, &env),
Command::Reproduce(a) => dispatch_reproduce(a, &env),
Command::New(a) => dispatch_new(a, &env),
Command::Process(a) => research_docs::process_cmd(a, &env),
}
}
+225
View File
@@ -0,0 +1,225 @@
//! CLI surface for the research-artifact documents: the `aura process` verb
//! family (the `aura campaign` family lands in a later task). 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::{
describe_block, open_slots_process, parse_process, process_to_json, process_vocabulary,
slot_kind_label, validate_process, DocError, DocFault, DocKind, ProcessDoc, StageBlock,
};
use crate::project::Env;
#[derive(clap::Args)]
pub struct ProcessCmd {
#[command(subcommand)]
pub sub: ProcessSub,
}
#[derive(clap::Subcommand)]
pub enum ProcessSub {
/// Validate a process document (intrinsic tier).
Validate { file: PathBuf },
/// Introspect the process vocabulary or a document.
Introspect(DocIntrospectCmd),
/// Register a valid process document into the store under the runs root.
Register { file: PathBuf },
}
#[derive(clap::Args)]
pub struct DocIntrospectCmd {
/// List the block vocabulary
#[arg(long)]
pub vocabulary: bool,
/// Describe one block's typed slots
#[arg(long, value_name = "ID")]
pub block: Option<String>,
/// List the open slots of a (partial) document
#[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>,
}
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())
}
}
/// 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>"
);
std::process::exit(2);
}
}
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}: wrong document kind (expected \"{want}\")")
}
DocError::Malformed(msg) => format!("{what}: {msg}"),
}
}
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}\"")
}
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::EmptyInstruments => "data.instruments is empty".into(),
DocFault::NoWindow => "data.windows is empty".into(),
DocFault::BadWindow { index } => {
format!("data.windows[{index}]: from_ms must be earlier than to_ms")
}
DocFault::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::ProcessRefMustBeContentId => {
"process.ref: a process is referenced by content id in this version".into()
}
}
}
fn read_doc(file: &PathBuf) -> Result<String, String> {
std::fs::read_to_string(file).map_err(|e| format!("cannot read {}: {e}", file.display()))
}
fn fault_block(header: &str, lines: Vec<String>) -> String {
format!("{header}\n {}", lines.join("\n "))
}
/// `aura process …` — the dispatch entry main.rs calls.
pub fn process_cmd(cmd: ProcessCmd, env: &Env) {
let result = match &cmd.sub {
ProcessSub::Validate { file } => validate_process_file(file),
ProcessSub::Introspect(i) => {
guard_one_mode(i, "process");
introspect_process(i)
}
ProcessSub::Register { file } => register_process(file, env),
};
if let Err(m) = result {
eprintln!("aura: {m}");
std::process::exit(1);
}
}
fn parse_valid_process(file: &PathBuf) -> Result<ProcessDoc, String> {
let text = read_doc(file)?;
let doc = parse_process(&text).map_err(|e| doc_error_prose("process document", &e))?;
let faults = validate_process(&doc);
if !faults.is_empty() {
return Err(fault_block(
"process document invalid:",
faults.iter().map(doc_fault_prose).collect(),
));
}
Ok(doc)
}
fn validate_process_file(file: &PathBuf) -> Result<(), String> {
let doc = parse_valid_process(file)?;
let gates: usize = doc
.pipeline
.iter()
.map(|s| match s {
StageBlock::Gate { all } => all.len(),
_ => 0,
})
.sum();
println!(
"process document valid (intrinsic): {} pipeline blocks, {} gate predicates",
doc.pipeline.len(),
gates
);
Ok(())
}
fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
let doc = parse_valid_process(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = process_to_json(&doc);
let registry = env.registry();
let id = registry.put_process(&canonical).map_err(|e| e.to_string())?;
println!(
"registered process content:{id} ({})",
registry.process_path(&id).display()
);
Ok(())
}
fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> {
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!("content:{}", 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);
}
if slots.is_empty() {
println!("no open slots");
}
}
+138
View File
@@ -0,0 +1,138 @@
//! End-to-end coverage for the `aura process` verb family (#188/#189): drives
//! the built `aura` binary over argv/file, the exact surface a data-level
//! author uses.
use std::path::{Path, PathBuf};
/// A fresh, unique working directory for a process test that persists
/// content-addressed documents under `./runs/` (so `aura process register`
/// never dirties the repo). Unique per test + per process; no external
/// tempfile dependency (mirrors `cli_run.rs` / `cli_broken_pipe.rs`).
fn temp_cwd(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("aura-cli-{}-{}", std::process::id(), name));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("create temp cwd");
dir
}
fn run_code_in(dir: &Path, args: &[&str]) -> (String, Option<i32>) {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_aura"))
.args(args)
.current_dir(dir)
.output()
.expect("binary runs");
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
(text, out.status.code())
}
fn run_code(args: &[&str]) -> (String, Option<i32>) {
run_code_in(Path::new("."), args)
}
const PROCESS_DOC: &str = r#"{
"format_version": 1,
"kind": "process",
"name": "wf-deflated-screen",
"pipeline": [
{ "block": "std::sweep", "metric": "net_expectancy_r", "select": "plateau:worst", "deflate": true },
{ "block": "std::gate", "all": [
{ "metric": "net_expectancy_r", "cmp": "gt", "value": 0.0 },
{ "metric": "overfit_probability", "cmp": "lt", "value": 0.1 } ] },
{ "block": "std::walk_forward", "folds": 4, "in_sample_bars": 4000,
"out_of_sample_bars": 1000, "metric": "net_expectancy_r", "select": "argmax" }
]
}"#;
fn write_doc(dir: &Path, name: &str, text: &str) -> PathBuf {
let p = dir.join(name);
std::fs::write(&p, text).expect("write doc");
p
}
#[test]
fn process_validate_reports_intrinsic_ok() {
let dir = temp_cwd("process-validate-ok");
write_doc(&dir, "p.process.json", PROCESS_DOC);
let (out, code) = run_code_in(&dir, &["process", "validate", "p.process.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
assert!(out.contains("process document valid (intrinsic): 3 pipeline blocks, 2 gate predicates"));
}
#[test]
fn process_validate_refuses_unknown_metric_as_prose_exit_1() {
let dir = temp_cwd("process-validate-bad-metric");
let bad = PROCESS_DOC.replacen(
"net_expectancy_r\", \"select\": \"plateau:worst",
"netto_r\", \"select\": \"plateau:worst",
1,
);
write_doc(&dir, "bad.process.json", &bad);
let (out, code) = run_code_in(&dir, &["process", "validate", "bad.process.json"]);
assert_eq!(code, Some(1));
assert!(out.contains("unknown metric \"netto_r\""));
assert!(!out.contains("UnknownMetric"), "Debug leak: {out}");
}
#[test]
fn process_introspect_vocabulary_block_and_content_id() {
let (out, code) = run_code(&["process", "introspect", "--vocabulary"]);
assert_eq!(code, Some(0));
for id in ["std::sweep", "std::gate", "std::walk_forward", "std::monte_carlo", "std::generalize"] {
assert!(out.contains(id), "vocabulary misses {id}: {out}");
}
let (out, code) = run_code(&["process", "introspect", "--block", "std::sweep"]);
assert_eq!(code, Some(0));
assert!(out.contains("metric"));
assert!(out.contains("required"));
let dir = temp_cwd("process-introspect-content-id");
write_doc(&dir, "p.process.json", PROCESS_DOC);
let (out, code) = run_code_in(&dir, &["process", "introspect", "--content-id", "p.process.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
let line = out.lines().find(|l| l.starts_with("content:")).expect("id line");
assert_eq!(line.len(), "content:".len() + 64);
}
#[test]
fn process_introspect_unknown_block_is_prose_exit_1() {
let (out, code) = run_code(&["process", "introspect", "--block", "std::nope"]);
assert_eq!(code, Some(1), "stdout/stderr: {out}");
assert!(out.contains("unknown block \"std::nope\""), "stdout/stderr: {out}");
}
#[test]
fn process_introspect_unwired_lists_open_slots() {
let dir = temp_cwd("process-introspect-unwired");
let partial = r#"{ "format_version": 1, "kind": "process",
"pipeline": [ { "block": "std::sweep", "metric": "sqn" } ] }"#;
write_doc(&dir, "partial.process.json", partial);
let (out, code) = run_code_in(&dir, &["process", "introspect", "--unwired", "partial.process.json"]);
assert_eq!(code, Some(0));
assert!(out.contains("open slot: name"));
assert!(out.contains("open slot: pipeline[0].select"));
}
#[test]
fn process_introspect_no_flag_is_usage_exit_2() {
let (_out, code) = run_code(&["process", "introspect"]);
assert_eq!(code, Some(2));
}
#[test]
fn process_register_stores_content_addressed_under_runs_root() {
let dir = temp_cwd("process-register");
write_doc(&dir, "p.process.json", PROCESS_DOC);
let (out, code) = run_code_in(&dir, &["process", "register", "p.process.json"]);
assert_eq!(code, Some(0), "stdout/stderr: {out}");
let line = out.lines().find(|l| l.starts_with("registered process content:")).expect("line");
let id = line
.trim_start_matches("registered process content:")
.split(' ')
.next()
.expect("id");
assert!(dir.join("runs").join("processes").join(format!("{id}.json")).is_file());
}
+12
View File
@@ -187,6 +187,18 @@ impl Registry {
pub fn get_campaign(&self, content_id: &str) -> Result<Option<String>, RegistryError> {
self.get_doc("campaigns", content_id)
}
/// The store path a process document with this content id lives at —
/// the same single mapping the put/get pair routes through, exposed so
/// consumers never re-derive the layout.
pub fn process_path(&self, content_id: &str) -> PathBuf {
self.doc_path("processes", content_id)
}
/// The store path a campaign document with this content id lives at.
pub fn campaign_path(&self, content_id: &str) -> PathBuf {
self.doc_path("campaigns", content_id)
}
}
/// Referential-validation findings for a campaign document. By-identifier
@@ -1560,6 +1560,18 @@ The shipped store idiom (read lib.rs:98-134 first): `?` on fs calls
pub fn get_campaign(&self, content_id: &str) -> Result<Option<String>, RegistryError> {
self.get_doc("campaigns", content_id)
}
/// The store path a process document with this content id lives at —
/// the same single mapping the put/get pair routes through, exposed so
/// consumers never re-derive the layout.
pub fn process_path(&self, content_id: &str) -> PathBuf {
self.doc_path("processes", content_id)
}
/// The store path a campaign document with this content id lives at.
pub fn campaign_path(&self, content_id: &str) -> PathBuf {
self.doc_path("campaigns", content_id)
}
```
If the shipped `put_blueprint`/`get_blueprint` map errors differently than
@@ -2054,9 +2066,12 @@ fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
let doc = parse_valid_process(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = process_to_json(&doc);
let id = env.registry().put_process(&canonical).map_err(|e| e.to_string())?;
let path = env.runs_root().join("processes").join(format!("{id}.json"));
println!("registered process content:{id} ({})", path.display());
let registry = env.registry();
let id = registry.put_process(&canonical).map_err(|e| e.to_string())?;
println!(
"registered process content:{id} ({})",
registry.process_path(&id).display()
);
Ok(())
}
@@ -2382,9 +2397,12 @@ fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
let doc = parse_valid_campaign(file)
.map_err(|m| format!("refusing to register: {m}"))?;
let canonical = campaign_to_json(&doc);
let id = env.registry().put_campaign(&canonical).map_err(|e| e.to_string())?;
let path = env.runs_root().join("campaigns").join(format!("{id}.json"));
println!("registered campaign content:{id} ({})", path.display());
let registry = env.registry();
let id = registry.put_campaign(&canonical).map_err(|e| e.to_string())?;
println!(
"registered campaign content:{id} ({})",
registry.campaign_path(&id).display()
);
Ok(())
}