From db09e5de5241675882a0e48ec87f8baf1729e38d Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 3 Jul 2026 15:36:47 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=200106=20task=2010=20=E2=80=94=20aur?= =?UTF-8?q?a=20process=20verb=20family=20(validate/introspect/register)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/aura-cli/src/main.rs | 4 + crates/aura-cli/src/research_docs.rs | 225 ++++++++++++++++++ crates/aura-cli/tests/research_docs.rs | 138 +++++++++++ crates/aura-registry/src/lib.rs | 12 + .../0106-research-artifact-vocabularies.md | 30 ++- 5 files changed, 403 insertions(+), 6 deletions(-) create mode 100644 crates/aura-cli/src/research_docs.rs create mode 100644 crates/aura-cli/tests/research_docs.rs diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 358172d..ee97f3d 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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), } } diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs new file mode 100644 index 0000000..c2e40ce --- /dev/null +++ b/crates/aura-cli/src/research_docs.rs @@ -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, + /// List the open slots of a (partial) document + #[arg(long, value_name = "FILE")] + pub unwired: Option, + /// Print the content id of a valid document + #[arg(long, value_name = "FILE")] + pub content_id: Option, +} + +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 | --unwired | --content-id " + ); + 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 { + std::fs::read_to_string(file).map_err(|e| format!("cannot read {}: {e}", file.display())) +} + +fn fault_block(header: &str, lines: Vec) -> 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 { + 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"); + } +} diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs new file mode 100644 index 0000000..eedc0a7 --- /dev/null +++ b/crates/aura-cli/tests/research_docs.rs @@ -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) { + 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) { + 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()); +} diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index fcf5a9a..4e4bea0 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -187,6 +187,18 @@ impl Registry { pub fn get_campaign(&self, content_id: &str) -> Result, 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 diff --git a/docs/plans/0106-research-artifact-vocabularies.md b/docs/plans/0106-research-artifact-vocabularies.md index bc5870c..21ee34b 100644 --- a/docs/plans/0106-research-artifact-vocabularies.md +++ b/docs/plans/0106-research-artifact-vocabularies.md @@ -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, 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(()) }