From a9e047eca7eaf5f5da738c127684b8fffc58a9ca Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 3 Jul 2026 16:18:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(cli):=200106=20tasks=2011-12=20=E2=80=94?= =?UTF-8?q?=20aura=20campaign=20verb=20family=20+=20workspace=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The campaign half of research_docs.rs: validate (intrinsic always; referential tier when a project env is discovered, with the explicit skip line outside one), introspect (--vocabulary/--block/--unwired/ --content-id, same exactly-one-of usage guard), register via the registry's campaign_path accessor, and RefFault prose (Display-free, unit-pinned). Five binary seam tests (skip line, empty-axis prose + exit 1, section vocabulary, spec-example open slots, content-addressed registration) reuse the file's temp_cwd helper — the plan's tempfile::tempdir() snippet deviated from the crate's zero-extra-dep test convention and was adapted, disclosed in the loop's report. The referential CLI branch inside a real loadable project stays fieldtest territory by the plan's own scope note (project::load needs a built dylib); its logic is pinned by the aura-registry tests. Gates: cargo test --workspace 916/0 (885 pre-cycle + 31 new), clippy -D warnings clean, cargo doc --no-deps 0 warnings. The worked spec example runs end-to-end headless: author both documents as text, validate with per-tier report lines, introspect vocabularies and open slots, obtain stable content ids, register into the store — no Rust compile, no run executed. closes #189 --- crates/aura-cli/src/main.rs | 3 + crates/aura-cli/src/research_docs.rs | 191 ++++++++++++++++- crates/aura-cli/tests/research_docs.rs | 283 +++++++++++++++++++++++++ 3 files changed, 471 insertions(+), 6 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index ee97f3d..662ebaa 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -3763,6 +3763,8 @@ enum Command { New(NewCmd), /// Validate, introspect, and register process documents (methodology). Process(research_docs::ProcessCmd), + /// Validate, introspect, and register campaign documents (experiment intent). + Campaign(research_docs::CampaignCmd), } #[derive(Args)] @@ -4712,6 +4714,7 @@ fn main() { Command::Reproduce(a) => dispatch_reproduce(a, &env), Command::New(a) => dispatch_new(a, &env), Command::Process(a) => research_docs::process_cmd(a, &env), + Command::Campaign(a) => research_docs::campaign_cmd(a, &env), } } diff --git a/crates/aura-cli/src/research_docs.rs b/crates/aura-cli/src/research_docs.rs index c2e40ce..79b6216 100644 --- a/crates/aura-cli/src/research_docs.rs +++ b/crates/aura-cli/src/research_docs.rs @@ -1,14 +1,17 @@ -//! 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. +//! 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::{ - describe_block, open_slots_process, parse_process, process_to_json, process_vocabulary, - slot_kind_label, validate_process, DocError, DocFault, DocKind, ProcessDoc, StageBlock, + 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, validate_campaign, validate_process, CampaignDoc, DocError, DocFault, + DocKind, ProcessDoc, StageBlock, }; +use aura_registry::RefFault; use crate::project::Env; @@ -223,3 +226,179 @@ fn print_open_slots(slots: &[aura_research::OpenSlot]) { 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 }, +} + +fn ref_fault_prose(f: &RefFault) -> String { + match f { + RefFault::ProcessNotFound(id) => format!("process {id} not found in the project store"), + RefFault::StrategyNotFound(id) => format!("strategy {id} not found in the blueprint store"), + 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") + } + } +} + +/// `aura campaign …` — the dispatch entry main.rs calls. +pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) { + 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), + }; + if let Err(m) = result { + eprintln!("aura: {m}"); + std::process::exit(1); + } +} + +fn parse_valid_campaign(file: &PathBuf) -> Result { + 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::()) + .sum(); + println!( + "campaign document valid (intrinsic): {} strategy(ies), {} axes ({} points), {} window(s)", + doc.strategies.len(), + axes, + points, + doc.data.windows.len() + ); + 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"); + } 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> { + 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 content:{id} ({})", + registry.campaign_path(&id).display() + ); + Ok(()) +} + +fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> { + 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!("content:{}", aura_research::content_id_of(&campaign_to_json(&doc))); + return Ok(()); + } + unreachable!("guard_one_mode enforces one mode") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[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}" + ); + } + } +} diff --git a/crates/aura-cli/tests/research_docs.rs b/crates/aura-cli/tests/research_docs.rs index eedc0a7..5a3f6fb 100644 --- a/crates/aura-cli/tests/research_docs.rs +++ b/crates/aura-cli/tests/research_docs.rs @@ -3,6 +3,7 @@ //! author uses. use std::path::{Path, PathBuf}; +use std::sync::OnceLock; /// A fresh, unique working directory for a process test that persists /// content-addressed documents under `./runs/` (so `aura process register` @@ -136,3 +137,285 @@ fn process_register_stores_content_addressed_under_runs_root() { .expect("id"); assert!(dir.join("runs").join("processes").join(format!("{id}.json")).is_file()); } + +/// The demo-project fixture (Aura.toml present), built once — mirrors +/// `project_load.rs`'s `built_fixture` (a separate test binary, so it needs +/// its own `OnceLock`, but `cargo build` is idempotent). +fn built_project() -> &'static PathBuf { + static BUILT: OnceLock = OnceLock::new(); + BUILT.get_or_init(|| { + let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/demo-project"); + let out = std::process::Command::new("cargo") + .arg("build") + .current_dir(&dir) + .output() + .expect("spawn cargo build for the fixture project"); + assert!( + out.status.success(), + "fixture build failed:\n{}", + String::from_utf8_lossy(&out.stderr) + ); + dir + }) +} + +/// A scratch filesystem entry this test writes under the git-tracked +/// demo-project fixture root (only `runs/` there is fixture-gitignored), +/// removed on drop — including during a mid-test panic — so a failed +/// assertion never leaks scratch docs into tracked working-tree state. +enum ScratchPath { + File(PathBuf), + Dir(PathBuf), +} + +struct ScratchGuard(Vec); + +impl Drop for ScratchGuard { + fn drop(&mut self) { + for p in &self.0 { + match p { + ScratchPath::File(p) => { + let _ = std::fs::remove_file(p); + } + ScratchPath::Dir(p) => { + let _ = std::fs::remove_dir_all(p); + } + } + } + } +} + +/// The `aura campaign validate` referential tier (the campaign family's +/// headline addition over the intrinsic-only process family), exercised at +/// the CLI seam inside a real project: a fully-resolved document prints the +/// "valid (referential)" success line, and an unresolved reference prints +/// the "do not resolve" fault block through `ref_fault_prose`. The +/// constituents (`validate_campaign_refs`, `ref_fault_prose`) are unit- +/// tested elsewhere; this pins that `aura campaign validate` actually wires +/// them together end to end. +#[test] +fn campaign_validate_in_project_reports_referential_tier_end_to_end() { + let dir = built_project(); + let runs_dir = dir.join("runs"); + std::fs::remove_dir_all(&runs_dir).ok(); + let _cleanup = ScratchGuard(vec![ + ScratchPath::Dir(runs_dir.clone()), + ScratchPath::File(dir.join("seed.process.json")), + ScratchPath::File(dir.join("ref-ok.campaign.json")), + ScratchPath::File(dir.join("ref-bad.campaign.json")), + ]); + + // Seed one real, content-addressed, open-param blueprint into the + // project's own store via a real sweep (mirrors `project_load.rs`'s + // anchor test). + let open_bp = format!("{}/tests/fixtures/sma_signal_open.json", env!("CARGO_MANIFEST_DIR")); + let (sweep_out, sweep_code) = run_code_in( + dir, + &[ + "sweep", + &open_bp, + "--axis", + "sma_signal.fast.length=2,4", + "--axis", + "sma_signal.slow.length=8,16", + "--name", + "campaign-ref-seed", + ], + ); + assert_eq!(sweep_code, Some(0), "seed sweep failed: {sweep_out}"); + let bp_id = std::fs::read_dir(runs_dir.join("blueprints")) + .expect("blueprints dir") + .next() + .expect("one stored blueprint") + .expect("dir entry") + .path() + .file_stem() + .expect("stem") + .to_string_lossy() + .into_owned(); + + // Register a valid process into the same project store. + write_doc(dir, "seed.process.json", PROCESS_DOC); + let (proc_out, proc_code) = run_code_in(dir, &["process", "register", "seed.process.json"]); + assert_eq!(proc_code, Some(0), "seed process register failed: {proc_out}"); + let proc_id = proc_out + .lines() + .find(|l| l.starts_with("registered process content:")) + .expect("register line") + .trim_start_matches("registered process content:") + .split(' ') + .next() + .expect("id") + .to_string(); + + // "fast.length": the axis name is the RAW composite's `param_space` name. + // `validate_campaign_refs` loads the stored blueprint bare, unlike the + // sweep's `wrap_r`-wrapped axis probe, so it does NOT carry the + // "sma_signal." prefix `aura sweep --axis` binds against. + let campaign = format!( + r#"{{ + "format_version": 1, + "kind": "campaign", + "name": "ref-check", + "data": {{ "instruments": ["GER40"], "windows": [ {{ "from_ms": 1, "to_ms": 2 }} ] }}, + "strategies": [ {{ "ref": {{ "content_id": "{bp}" }}, + "axes": {{ "fast.length": {{ "kind": "I64", "values": [2] }} }} }} ], + "process": {{ "ref": {{ "content_id": "{proc}" }} }}, + "seed": 1, + "presentation": {{ "persist_taps": [], "emit": ["family_table"] }} +}}"#, + bp = bp_id, + proc = proc_id + ); + write_doc(dir, "ref-ok.campaign.json", &campaign); + let (out, code) = run_code_in(dir, &["campaign", "validate", "ref-ok.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!(out.contains( + "campaign document valid (referential): all references resolve, axes are in the param space" + )); + + // An unresolved process reference: the fault block + `ref_fault_prose` + // mapping, prose exit 1, at the same CLI seam. + let bad = campaign.replace(&proc_id, "0000000000000000000000000000000000000000000000000000000000000000"); + write_doc(dir, "ref-bad.campaign.json", &bad); + let (out2, code2) = run_code_in(dir, &["campaign", "validate", "ref-bad.campaign.json"]); + assert_eq!(code2, Some(1)); + assert!(out2.contains("campaign references do not resolve:"), "stdout/stderr: {out2}"); + assert!(out2.contains("not found in the project store"), "stdout/stderr: {out2}"); + + // `_cleanup` (a `ScratchGuard`) removes the scratch docs and `runs/` on + // drop, at the end of this scope — including on a mid-test panic. +} + +const CAMPAIGN_DOC: &str = r#"{ + "format_version": 1, + "kind": "campaign", + "name": "screen", + "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, + "strategies": [ { "ref": { "content_id": "9f3a" }, + "axes": { "fast": { "kind": "I64", "values": [8] } } } ], + "process": { "ref": { "content_id": "4e2d" } }, + "seed": 1, + "presentation": { "persist_taps": [], "emit": ["family_table"] } +}"#; + +#[test] +fn campaign_validate_outside_project_skips_referential_tier() { + let dir = temp_cwd("campaign-validate-outside-project"); + write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "c.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + assert!(out.contains("campaign document valid (intrinsic):")); + assert!(out.contains("referential checks skipped (no Aura.toml found up from ")); +} + +#[test] +fn campaign_validate_refuses_empty_axis_prose_exit_1() { + let dir = temp_cwd("campaign-validate-empty-axis"); + let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1); + write_doc(&dir, "bad.campaign.json", &bad); + let (out, code) = run_code_in(&dir, &["campaign", "validate", "bad.campaign.json"]); + assert_eq!(code, Some(1)); + assert!(out.contains("axes.fast: an axis is a non-empty finite set")); + assert!(!out.contains("EmptyAxis"), "Debug leak: {out}"); +} + +#[test] +fn campaign_introspect_vocabulary_lists_sections() { + let (out, code) = run_code(&["campaign", "introspect", "--vocabulary"]); + assert_eq!(code, Some(0)); + for id in ["std::data", "std::strategy", "std::process_ref", "std::presentation"] { + assert!(out.contains(id), "vocabulary misses {id}: {out}"); + } +} + +#[test] +fn campaign_introspect_unwired_reports_the_spec_example_slots() { + let dir = temp_cwd("campaign-introspect-unwired"); + let draft = r#"{ "format_version": 1, "kind": "campaign", "name": "draft", + "data": { "instruments": ["GER40"], "windows": [ { "from_ms": 1, "to_ms": 2 } ] }, + "strategies": [ { "ref": { "content_id": "9f3a" }, + "axes": { "slow": { "kind": "I64", "values": [] } } } ], + "seed": 1, + "presentation": { "persist_taps": [], "emit": [] } }"#; + write_doc(&dir, "draft.campaign.json", draft); + let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--unwired", "draft.campaign.json"]); + assert_eq!(code, Some(0)); + assert!(out.contains("open slot: process.ref (required, one of: content_id, identity_id)")); + assert!(out.contains("open slot: strategies[0].axes.slow (axis declared empty — a P1 axis is a non-empty finite set)")); +} + +/// The campaign twin of `process_introspect_vocabulary_block_and_content_id`'s +/// `--content-id` branch: pins that `campaign introspect --content-id` wires +/// `parse_valid_campaign` + `campaign_to_json` + `content_id_of` end to end +/// (the process branch was covered; this one was not). +#[test] +fn campaign_introspect_content_id_prints_hash() { + let dir = temp_cwd("campaign-introspect-content-id"); + write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); + let (out, code) = run_code_in(&dir, &["campaign", "introspect", "--content-id", "c.campaign.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 campaign_register_stores_content_addressed_under_runs_root() { + let dir = temp_cwd("campaign-register"); + write_doc(&dir, "c.campaign.json", CAMPAIGN_DOC); + let (out, code) = run_code_in(&dir, &["campaign", "register", "c.campaign.json"]); + assert_eq!(code, Some(0), "stdout/stderr: {out}"); + let line = out.lines().find(|l| l.starts_with("registered campaign content:")).expect("line"); + let id = line + .trim_start_matches("registered campaign content:") + .split(' ') + .next() + .expect("id"); + assert!(dir.join("runs").join("campaigns").join(format!("{id}.json")).is_file()); +} + +/// `guard_one_mode` is shared, family-parameterized code (`process` and +/// `campaign` both route through it). The process family already pins the +/// zero-flags arm; this pins that the campaign family gets its own family +/// name threaded into the usage line, not a copy-pasted "process" string. +#[test] +fn campaign_introspect_no_flag_usage_names_the_campaign_family() { + let (out, code) = run_code(&["campaign", "introspect"]); + assert_eq!(code, Some(2), "stdout/stderr: {out}"); + assert!( + out.contains("Usage: aura campaign introspect --vocabulary | --block | --unwired | --content-id "), + "stdout/stderr: {out}" + ); +} + +/// `guard_one_mode` requires *exactly* one mode: the zero-flags arm is +/// pinned elsewhere, but nothing previously exercised the over-selection +/// arm (`selected_modes() != 1` is not just `== 0`). Two modes at once must +/// refuse with the same usage-exit-2 idiom, not silently pick one. +#[test] +fn campaign_introspect_two_flags_is_usage_exit_2() { + let (out, code) = run_code(&["campaign", "introspect", "--vocabulary", "--block", "std::data"]); + assert_eq!(code, Some(2), "stdout/stderr: {out}"); + assert!(out.contains("Usage: aura campaign introspect"), "stdout/stderr: {out}"); +} + +/// Register must be a gate, not a passthrough: an invalid document is +/// refused with prose (exit 1) and — the property that matters for a +/// content-addressed store — no file is ever written under `runs/campaigns/` +/// for it. A store that could contain unvalidated documents would make +/// every downstream reader re-derive the checks register was supposed to +/// have already made. +#[test] +fn campaign_register_refuses_invalid_document_and_writes_nothing() { + let dir = temp_cwd("campaign-register-invalid"); + let bad = CAMPAIGN_DOC.replacen(r#""values": [8]"#, r#""values": []"#, 1); + write_doc(&dir, "bad.campaign.json", &bad); + let (out, code) = run_code_in(&dir, &["campaign", "register", "bad.campaign.json"]); + assert_eq!(code, Some(1), "stdout/stderr: {out}"); + assert!(out.contains("refusing to register:"), "stdout/stderr: {out}"); + assert!(out.contains("axes.fast: an axis is a non-empty finite set"), "stdout/stderr: {out}"); + assert!( + !dir.join("runs").join("campaigns").exists(), + "register must not create a store entry for an invalid document" + ); +}