diff --git a/.gitignore b/.gitignore index 899c0a1..2ddfa15 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,7 @@ Cargo.lock.orig # Session retrospectives are local-only run telemetry, not a repo artifact. /docs/postmortems/ + +# `aura sweep` persists run records to ./runs/runs.jsonl by default; that store +# is local run telemetry, not a repo artifact. +/runs/ diff --git a/Cargo.lock b/Cargo.lock index 9b57048..467448f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -52,6 +52,7 @@ version = "0.1.0" dependencies = [ "aura-core", "aura-engine", + "aura-registry", "aura-std", ] diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index a5e4aae..9f36287 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -12,4 +12,5 @@ path = "src/main.rs" [dependencies] aura-core = { path = "../aura-core" } aura-engine = { path = "../aura-engine" } +aura-registry = { path = "../aura-registry" } aura-std = { path = "../aura-std" } diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index fc1f79f..5175e2a 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -11,8 +11,9 @@ mod render; use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{ f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness, - OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target, + OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepFamily, Target, }; +use aura_registry::{rank_by, Registry}; use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; @@ -222,7 +223,7 @@ fn scalar_as_param_f64(s: &Scalar) -> f64 { /// the same report. Each point builds a fresh blueprint (fresh sink channels), /// bootstraps it under the point vector, runs it, and folds the drained sinks to /// metrics — the per-point closure the engine `sweep` drives disjointly. -fn sweep_report() -> String { +fn sweep_family() -> SweepFamily { let space = sample_blueprint_with_sinks().0.param_space(); let grid = GridSpace::new( &space, @@ -233,7 +234,7 @@ fn sweep_report() -> String { ], ) .expect("the built-in grid matches the sample param-space"); - let family = sweep(&grid, |point| { + sweep(&grid, |point| { let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks(); let mut h = bp .bootstrap_with_params(point.to_vec()) @@ -261,15 +262,72 @@ fn sweep_report() -> String { }, metrics: summarize(&equity, &exposure), } - }); + }) +} + +/// Render a sweep family as one `RunReport` JSON line per point. Test helper: +/// production (`run_sweep`) renders *and* persists per point. +#[cfg(test)] +fn sweep_report() -> String { let mut out = String::new(); - for pt in &family.points { + for pt in &sweep_family().points { out.push_str(&pt.report.to_json()); out.push('\n'); } out } +/// The default run registry: an append-only JSONL store under the current +/// working directory. (A project-configured runs-dir via `Aura.toml` is a later +/// refinement.) +fn default_registry() -> Registry { + Registry::open("runs/runs.jsonl") +} + +/// `aura sweep`: run the built-in sweep, persist each point's `RunReport` to the +/// registry (the run record, queryable over time — C18), and print each as one +/// JSON line. +fn run_sweep() { + let reg = default_registry(); + for pt in &sweep_family().points { + if let Err(e) = reg.append(&pt.report) { + eprintln!("aura: {e}"); + std::process::exit(2); + } + println!("{}", pt.report.to_json()); + } +} + +/// `aura runs list`: print every stored run record, in store (over-time) order. +fn runs_list() { + for report in &load_runs_or_exit() { + println!("{}", report.to_json()); + } +} + +/// `aura runs rank `: print the stored runs best-first by `metric`. +fn runs_rank(metric: &str) { + match rank_by(load_runs_or_exit(), metric) { + Ok(ranked) => { + for report in &ranked { + println!("{}", report.to_json()); + } + } + Err(e) => { + eprintln!("aura: {e}"); + std::process::exit(2); + } + } +} + +/// Load the registry or exit 2 with the error. A missing registry loads empty. +fn load_runs_or_exit() -> Vec { + default_registry().load().unwrap_or_else(|e| { + eprintln!("aura: {e}"); + std::process::exit(2); + }) +} + // --- MACD proof-of-concept (a richer, nested indicator + strategy) ----------- /// The MACD signal as a named composite: price → fast/slow `Ema` → the MACD line @@ -426,7 +484,8 @@ fn run_macd() -> RunReport { } } -const USAGE: &str = "usage: aura run [--macd] | aura graph | aura sweep"; +const USAGE: &str = + "usage: aura run [--macd] | aura graph | aura sweep | aura runs list | aura runs rank "; fn main() { // Collect argv and match the whole vector: every accepted form is exhaustive, @@ -437,7 +496,9 @@ fn main() { ["run"] => println!("{}", run_sample().to_json()), ["run", "--macd"] => println!("{}", run_macd().to_json()), ["graph"] => print!("{}", render::render_html(&sample_blueprint())), - ["sweep"] => print!("{}", sweep_report()), + ["sweep"] => run_sweep(), + ["runs", "list"] => runs_list(), + ["runs", "rank", metric] => runs_rank(metric), ["--help"] | ["-h"] => println!("{USAGE}"), _ => { eprintln!("aura: {USAGE}"); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 7febbc8..5f72a51 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -7,6 +7,16 @@ use std::process::Command; /// crate; the binary is named `aura` in `Cargo.toml`). const BIN: &str = env!("CARGO_BIN_EXE_aura"); +/// A fresh, unique working directory for a process test that persists run +/// records (so `aura sweep`'s `./runs/runs.jsonl` never dirties the repo). +/// Unique per test + per process; no external tempfile dependency. +fn temp_cwd(name: &str) -> std::path::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 +} + #[test] fn run_prints_json_and_exits_zero() { let out = Command::new(BIN).arg("run").output().expect("spawn aura run"); @@ -190,12 +200,11 @@ fn graph_emits_self_contained_html_viewer() { #[test] fn sweep_prints_four_json_lines_and_exits_zero() { - let out = Command::new(BIN).arg("sweep").output().expect("spawn aura sweep"); + let cwd = temp_cwd("sweep4"); + let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn aura sweep"); assert!(out.status.success(), "exit status: {:?}", out.status); let stdout = String::from_utf8(out.stdout).expect("utf-8 stdout"); - // one JSON line per grid point (4-point built-in grid), each terminated by a - // newline from `sweep_report`. assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}"); let first = stdout.lines().next().unwrap(); // each line is a full RunReport; commit is the real git HEAD (volatile), so @@ -205,6 +214,7 @@ fn sweep_prints_four_json_lines_and_exits_zero() { first.contains("\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5}"), "got: {stdout}" ); + let _ = std::fs::remove_dir_all(&cwd); } #[test] @@ -213,3 +223,59 @@ fn sweep_with_trailing_token_is_strict_and_exits_two() { let out = Command::new(BIN).args(["sweep", "extra"]).output().expect("spawn aura"); assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); } + +#[test] +fn runs_list_and_rank_across_invocations() { + let cwd = temp_cwd("runs-flow"); + // two sweeps accumulate 8 records over time + for _ in 0..2 { + let out = Command::new(BIN).arg("sweep").current_dir(&cwd).output().expect("spawn sweep"); + assert!(out.status.success(), "sweep exit: {:?}", out.status); + } + + // `runs list` shows all 8 stored records + let list = Command::new(BIN).args(["runs", "list"]).current_dir(&cwd).output().expect("spawn list"); + assert!(list.status.success(), "list exit: {:?}", list.status); + let list_out = String::from_utf8(list.stdout).expect("utf-8"); + assert_eq!(list_out.lines().count(), 8, "list: {list_out:?}"); + assert!(list_out.lines().all(|l| l.starts_with("{\"manifest\":{")), "list shape: {list_out:?}"); + + // `runs rank total_pips`: 8 lines, highest total_pips first + let rank = Command::new(BIN).args(["runs", "rank", "total_pips"]).current_dir(&cwd).output().expect("spawn rank"); + assert!(rank.status.success(), "rank exit: {:?}", rank.status); + let rank_out = String::from_utf8(rank.stdout).expect("utf-8"); + assert_eq!(rank_out.lines().count(), 8, "rank: {rank_out:?}"); + let pips: Vec = rank_out + .lines() + .map(|l| { + let key = "\"total_pips\":"; + let start = l.find(key).expect("line has total_pips") + key.len(); + let tail = &l[start..]; + let end = tail.find([',', '}']).expect("token end"); + tail[..end].parse().expect("total_pips is an f64") + }) + .collect(); + assert!(pips.windows(2).all(|w| w[0] >= w[1]), "rank not descending: {pips:?}"); + + // unknown metric is a usage error + let bogus = Command::new(BIN).args(["runs", "rank", "bogus"]).current_dir(&cwd).output().expect("spawn bogus"); + assert_eq!(bogus.status.code(), Some(2), "bogus exit: {:?}", bogus.status); + + let _ = std::fs::remove_dir_all(&cwd); +} + +#[test] +fn runs_list_on_empty_registry_is_zero_lines_exit_zero() { + let cwd = temp_cwd("runs-empty"); + let out = Command::new(BIN).args(["runs", "list"]).current_dir(&cwd).output().expect("spawn list"); + assert!(out.status.success(), "list exit: {:?}", out.status); + assert_eq!(out.stdout.len(), 0, "expected no output, got: {:?}", out.stdout); + let _ = std::fs::remove_dir_all(&cwd); +} + +#[test] +fn runs_bare_subcommand_is_strict_and_exits_two() { + // `aura runs` with no/unknown sub-subcommand is a usage error (#16 strict). + let out = Command::new(BIN).arg("runs").output().expect("spawn aura runs"); + assert_eq!(out.status.code(), Some(2), "exit status: {:?}", out.status); +}