feat(aura-cli): aura runs registry — persist on sweep, list + rank (cycle D / iter 3)
Final iteration of the run-registry cycle (#33): the read surface that makes a sweep family — and runs across separate invocations — comparable over time (C18's "compare experiments over time", which has no home in git or Gitea). - `aura sweep` now persists each point's RunReport to runs/runs.jsonl (append- only, one serde_json line per run) in addition to printing it. - `aura runs list` prints every stored record, in store order. - `aura runs rank <metric>` prints the stored runs best-first by the metric (total_pips desc; max_drawdown / exposure_sign_flips asc); an unknown metric is a usage error (exit 2), like every other aura arg error. sweep_report splits into a production sweep_family() (the pure run) and a #[cfg(test)] renderer kept for the in-bin goldens; the dispatch gains run_sweep / runs_list / runs_rank over default_registry() (runs/runs.jsonl under cwd). Process goldens run the binary in a temp cwd so persistence never dirties the repo; /runs/ is git-ignored as a backstop. Verified end-to-end: two `aura sweep` invocations accumulate 8 records; `aura runs list` shows all 8; `aura runs rank total_pips` orders them best-first; `aura runs rank bogus` exits 2 with the known-metrics hint. cargo test --workspace green (cli_run 11, registry 5, engine 93, …); clippy --workspace --all-targets -D warnings clean; no stray runs/ in the work tree. refs #33
This commit is contained in:
@@ -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/
|
||||
|
||||
Generated
+1
@@ -52,6 +52,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"aura-core",
|
||||
"aura-engine",
|
||||
"aura-registry",
|
||||
"aura-std",
|
||||
]
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 <metric>`: 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<RunReport> {
|
||||
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 <metric>";
|
||||
|
||||
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}");
|
||||
|
||||
@@ -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<f64> = 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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user