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");
}
}