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:
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user