feat(engine,registry): SweepPoint carries RunReport; add aura-registry (cycle D / iter 2)
Iteration 2 of the run-registry cycle (#33): make the sweep family self-describing and add the registry store. Engine. SweepPoint.metrics (RunMetrics) becomes SweepPoint.report (RunReport), and the sweep closure bound is now Fn(&[Scalar]) -> RunReport. This closes the manifest-per-point gap cycle C explicitly deferred to #33 — each swept point now carries its full (manifest, metrics), the unit the registry indexes (C18). All engine-internal callers, the engine test fixture run_point, and the three sweep tests were rethreaded in the same iteration so the crate stays green; the CLI caller followed in the same change so `cargo test --workspace` is green again. CLI. The sweep_report closure builds a per-point RunReport (manifest params = param_space names zipped onto the point via scalar_as_param_f64; commit/window/ seed/broker as run_sample builds them) and prints via RunReport::to_json. The hand-rolled sweep_point_to_json/json_string/scalar_token are retired (the report renders itself), and the orphaned ParamSpec/SweepPoint imports dropped. Net: aura run, aura sweep, and (next iteration) aura runs all print the same RunReport shape. The two aura sweep goldens now pin the per-point manifest params, NOT the commit (it is the real git HEAD, volatile). Registry. New crate aura-registry: Registry::{open, append, load} over an append-only runs/runs.jsonl (one serde_json line per RunReport), free rank_by (best-first per metric — total_pips desc, max_drawdown/exposure_sign_flips asc), and RegistryError (Io / Parse{line} / UnknownMetric). Five unit tests cover append->load round-trip, missing-file = empty, corrupt-line = Parse{line}, per-metric ranking, and unknown-metric error. Built and unit-tested; NOT yet CLI-wired (aura sweep persistence + aura runs list/rank are iteration 3). Verified: cargo test --workspace green (aura-registry 5, aura-engine 93, aura-cli 7+8, others unchanged); clippy --workspace --all-targets -D warnings clean; aura run stdout shape unchanged; aura sweep now emits full RunReports. refs #33
This commit is contained in:
Generated
+9
@@ -83,6 +83,15 @@ dependencies = [
|
|||||||
"data-server",
|
"data-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aura-registry"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"aura-core",
|
||||||
|
"aura-engine",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aura-std"
|
name = "aura-std"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ members = [
|
|||||||
"crates/aura-engine",
|
"crates/aura-engine",
|
||||||
"crates/aura-cli",
|
"crates/aura-cli",
|
||||||
"crates/aura-ingest",
|
"crates/aura-ingest",
|
||||||
|
"crates/aura-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
|
|||||||
+41
-91
@@ -8,10 +8,10 @@
|
|||||||
|
|
||||||
mod render;
|
mod render;
|
||||||
|
|
||||||
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
||||||
use aura_engine::{
|
use aura_engine::{
|
||||||
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness,
|
f64_field, summarize, sweep, BlueprintNode, Composite, Edge, FlatGraph, GridSpace, Harness,
|
||||||
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, SweepPoint, Target,
|
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target,
|
||||||
};
|
};
|
||||||
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
|
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
|
||||||
use std::sync::mpsc::{self, Receiver};
|
use std::sync::mpsc::{self, Receiver};
|
||||||
@@ -206,57 +206,13 @@ fn sample_blueprint() -> Composite {
|
|||||||
build_sample()
|
build_sample()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render one swept point as one canonical JSON object (C14, hand-rolled — the
|
/// Coerce a sweep point's `Scalar` value to the manifest's `f64` param type.
|
||||||
/// engine's `json_str` is private, so the rules of `RunReport::to_json` are
|
/// A tuning grid carries only numeric params (`I64` lengths, `F64` scales).
|
||||||
/// mirrored here, not called): `{"params":{name:value,…},"metrics":{…}}`. Param
|
fn scalar_as_param_f64(s: &Scalar) -> f64 {
|
||||||
/// names come from `param_space()` zipped onto the point vector; `f64` fields use
|
match s {
|
||||||
/// the round-trippable shortest form, so a whole-valued float renders without a
|
Scalar::I64(n) => *n as f64,
|
||||||
/// fractional part (`12.0` -> `12`).
|
Scalar::F64(f) => *f,
|
||||||
fn sweep_point_to_json(pt: &SweepPoint, space: &[ParamSpec]) -> String {
|
other => unreachable!("non-numeric sweep param: {other:?}"),
|
||||||
let mut params = String::from("{");
|
|
||||||
for (i, (ps, v)) in space.iter().zip(&pt.params).enumerate() {
|
|
||||||
if i > 0 {
|
|
||||||
params.push(',');
|
|
||||||
}
|
|
||||||
params.push_str(&json_string(&ps.name));
|
|
||||||
params.push(':');
|
|
||||||
params.push_str(&scalar_token(*v));
|
|
||||||
}
|
|
||||||
params.push('}');
|
|
||||||
format!(
|
|
||||||
"{{\"params\":{params},\"metrics\":{{\"total_pips\":{tp},\"max_drawdown\":{dd},\"exposure_sign_flips\":{fl}}}}}",
|
|
||||||
tp = pt.metrics.total_pips,
|
|
||||||
dd = pt.metrics.max_drawdown,
|
|
||||||
fl = pt.metrics.exposure_sign_flips,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Minimal JSON string rendering: wrap in quotes, escape `"` and `\` (mirrors the
|
|
||||||
/// engine's private `json_str`; our param names contain neither, but the escape
|
|
||||||
/// keeps the helper honest).
|
|
||||||
fn json_string(s: &str) -> String {
|
|
||||||
let mut out = String::with_capacity(s.len() + 2);
|
|
||||||
out.push('"');
|
|
||||||
for c in s.chars() {
|
|
||||||
match c {
|
|
||||||
'"' => out.push_str("\\\""),
|
|
||||||
'\\' => out.push_str("\\\\"),
|
|
||||||
_ => out.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.push('"');
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One scalar as its JSON value token: an integer for `I64`, the shortest
|
|
||||||
/// round-trippable form for `F64`, `true`/`false` for `Bool`, the epoch-ns
|
|
||||||
/// integer for a `Timestamp`.
|
|
||||||
fn scalar_token(v: Scalar) -> String {
|
|
||||||
match v {
|
|
||||||
Scalar::I64(n) => n.to_string(),
|
|
||||||
Scalar::F64(f) => f.to_string(),
|
|
||||||
Scalar::Bool(b) => b.to_string(),
|
|
||||||
Scalar::Ts(t) => t.0.to_string(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,14 +238,33 @@ fn sweep_report() -> String {
|
|||||||
let mut h = bp
|
let mut h = bp
|
||||||
.bootstrap_with_params(point.to_vec())
|
.bootstrap_with_params(point.to_vec())
|
||||||
.expect("grid points are kind-checked against param_space");
|
.expect("grid points are kind-checked against param_space");
|
||||||
h.run(vec![synthetic_prices()]);
|
let prices = synthetic_prices();
|
||||||
|
let window = (
|
||||||
|
prices.first().expect("non-empty stream").0,
|
||||||
|
prices.last().expect("non-empty stream").0,
|
||||||
|
);
|
||||||
|
h.run(vec![prices]);
|
||||||
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
||||||
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
||||||
summarize(&equity, &exposure)
|
let params = space
|
||||||
|
.iter()
|
||||||
|
.zip(point)
|
||||||
|
.map(|(ps, v)| (ps.name.clone(), scalar_as_param_f64(v)))
|
||||||
|
.collect();
|
||||||
|
RunReport {
|
||||||
|
manifest: RunManifest {
|
||||||
|
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
|
||||||
|
params,
|
||||||
|
window,
|
||||||
|
seed: 0,
|
||||||
|
broker: "sim-optimal(pip_size=0.0001)".to_string(),
|
||||||
|
},
|
||||||
|
metrics: summarize(&equity, &exposure),
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for pt in &family.points {
|
for pt in &family.points {
|
||||||
out.push_str(&sweep_point_to_json(pt, &space));
|
out.push_str(&pt.report.to_json());
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
@@ -488,46 +463,21 @@ mod tests {
|
|||||||
assert!(!rx_ex.try_iter().collect::<Vec<_>>().is_empty(), "exposure sink drained empty");
|
assert!(!rx_ex.try_iter().collect::<Vec<_>>().is_empty(), "exposure sink drained empty");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sweep_point_to_json_renders_canonical_form() {
|
|
||||||
let space = vec![
|
|
||||||
ParamSpec { name: "sma_cross.fast".into(), kind: ScalarKind::I64 },
|
|
||||||
ParamSpec { name: "sma_cross.slow".into(), kind: ScalarKind::I64 },
|
|
||||||
ParamSpec { name: "scale".into(), kind: ScalarKind::F64 },
|
|
||||||
];
|
|
||||||
let pt = SweepPoint {
|
|
||||||
params: vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)],
|
|
||||||
// fully-qualified: RunMetrics is needed only by this test, so it is not
|
|
||||||
// imported into production (where it would be an unused import — the
|
|
||||||
// production code reads `pt.metrics` by field, never naming the type).
|
|
||||||
metrics: aura_engine::RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 },
|
|
||||||
};
|
|
||||||
assert_eq!(
|
|
||||||
sweep_point_to_json(&pt, &space),
|
|
||||||
r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}}"#,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sweep_report_renders_four_points_in_odometer_order() {
|
fn sweep_report_renders_four_points_in_odometer_order() {
|
||||||
let out = sweep_report();
|
let out = sweep_report();
|
||||||
let lines: Vec<&str> = out.lines().collect();
|
let lines: Vec<&str> = out.lines().collect();
|
||||||
assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}");
|
assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}");
|
||||||
// params byte-pinned per line: odometer order (last axis fastest) + the
|
// each line is a full RunReport; the commit is the real git HEAD
|
||||||
// param-name and value-token rules. Metric *values* are left to the
|
// (volatile), so pin the per-point manifest params (odometer order, last
|
||||||
// determinism check below (they are computed f64s, not predictable here).
|
// axis fastest) + the metric keys, not the commit value.
|
||||||
assert!(lines[0].starts_with(
|
for line in &lines {
|
||||||
r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"#
|
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}");
|
||||||
));
|
}
|
||||||
assert!(lines[1].starts_with(
|
assert!(lines[0].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}"#), "line0: {}", lines[0]);
|
||||||
r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5},"metrics":{"#
|
assert!(lines[1].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5}"#), "line1: {}", lines[1]);
|
||||||
));
|
assert!(lines[2].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5}"#), "line2: {}", lines[2]);
|
||||||
assert!(lines[2].starts_with(
|
assert!(lines[3].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5}"#), "line3: {}", lines[3]);
|
||||||
r#"{"params":{"sma_cross.fast":3,"sma_cross.slow":4,"scale":0.5},"metrics":{"#
|
|
||||||
));
|
|
||||||
assert!(lines[3].starts_with(
|
|
||||||
r#"{"params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5},"metrics":{"#
|
|
||||||
));
|
|
||||||
for line in &lines {
|
for line in &lines {
|
||||||
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
|
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
|
||||||
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");
|
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");
|
||||||
|
|||||||
@@ -197,11 +197,12 @@ fn sweep_prints_four_json_lines_and_exits_zero() {
|
|||||||
// one JSON line per grid point (4-point built-in grid), each terminated by a
|
// one JSON line per grid point (4-point built-in grid), each terminated by a
|
||||||
// newline from `sweep_report`.
|
// newline from `sweep_report`.
|
||||||
assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
|
assert_eq!(stdout.lines().count(), 4, "stdout was: {stdout:?}");
|
||||||
// the first line is the first odometer point (fast=2, slow=4), params pinned.
|
let first = stdout.lines().next().unwrap();
|
||||||
|
// each line is a full RunReport; commit is the real git HEAD (volatile), so
|
||||||
|
// pin the first odometer point's manifest params, not the commit value.
|
||||||
|
assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}");
|
||||||
assert!(
|
assert!(
|
||||||
stdout.lines().next().unwrap().starts_with(
|
first.contains("\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5}"),
|
||||||
"{\"params\":{\"sma_cross.fast\":2,\"sma_cross.slow\":4,\"scale\":0.5},\"metrics\":{"
|
|
||||||
),
|
|
||||||
"got: {stdout}"
|
"got: {stdout}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! the engine cannot generically own — C8/C18).
|
//! the engine cannot generically own — C8/C18).
|
||||||
|
|
||||||
use aura_core::{ParamSpec, Scalar, ScalarKind};
|
use aura_core::{ParamSpec, Scalar, ScalarKind};
|
||||||
use crate::RunMetrics;
|
use crate::RunReport;
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
/// A validated cartesian grid over a blueprint's param-space: one discrete
|
/// A validated cartesian grid over a blueprint's param-space: one discrete
|
||||||
@@ -91,12 +91,14 @@ pub enum SweepError {
|
|||||||
EmptyAxis { slot: usize },
|
EmptyAxis { slot: usize },
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One enumerated point and the metrics its run produced. Self-describing: the
|
/// One enumerated point and the full `RunReport` its run produced.
|
||||||
/// `params` vector is the point's coordinate in `param_space()` order.
|
/// Self-describing: `params` is the point's coordinate in `param_space()` order,
|
||||||
|
/// and `report` carries the run's `(manifest, metrics)` — the unit the run
|
||||||
|
/// registry indexes (C18).
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct SweepPoint {
|
pub struct SweepPoint {
|
||||||
pub params: Vec<Scalar>,
|
pub params: Vec<Scalar>,
|
||||||
pub metrics: RunMetrics,
|
pub report: RunReport,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The ordered result family of a sweep — one `SweepPoint` per grid point, in
|
/// The ordered result family of a sweep — one `SweepPoint` per grid point, in
|
||||||
@@ -110,10 +112,10 @@ pub struct SweepFamily {
|
|||||||
/// the family in enumeration order. `run_one` builds + bootstraps + runs +
|
/// the family in enumeration order. `run_one` builds + bootstraps + runs +
|
||||||
/// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs
|
/// summarizes one point; it shares nothing mutable, so it is `Sync` and the runs
|
||||||
/// are lock-free. Parallelism is `available_parallelism()` workers — `std` only,
|
/// are lock-free. Parallelism is `available_parallelism()` workers — `std` only,
|
||||||
/// no external dependency (C16).
|
/// via `std::thread::scope`.
|
||||||
pub fn sweep<F>(space: &GridSpace, run_one: F) -> SweepFamily
|
pub fn sweep<F>(space: &GridSpace, run_one: F) -> SweepFamily
|
||||||
where
|
where
|
||||||
F: Fn(&[Scalar]) -> RunMetrics + Sync,
|
F: Fn(&[Scalar]) -> RunReport + Sync,
|
||||||
{
|
{
|
||||||
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
let nthreads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
||||||
sweep_with_threads(space, nthreads, run_one)
|
sweep_with_threads(space, nthreads, run_one)
|
||||||
@@ -128,7 +130,7 @@ where
|
|||||||
/// the completion order. Only the cursor is shared; the results side is lock-free.
|
/// the completion order. Only the cursor is shared; the results side is lock-free.
|
||||||
fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily
|
fn sweep_with_threads<F>(space: &GridSpace, nthreads: usize, run_one: F) -> SweepFamily
|
||||||
where
|
where
|
||||||
F: Fn(&[Scalar]) -> RunMetrics + Sync,
|
F: Fn(&[Scalar]) -> RunReport + Sync,
|
||||||
{
|
{
|
||||||
let points = space.points();
|
let points = space.points();
|
||||||
// `points.len() >= 1` always (GridSpace rejects empty axes), so the clamp
|
// `points.len() >= 1` always (GridSpace rejects empty axes), so the clamp
|
||||||
@@ -136,11 +138,11 @@ where
|
|||||||
let nthreads = nthreads.clamp(1, points.len());
|
let nthreads = nthreads.clamp(1, points.len());
|
||||||
let cursor = AtomicUsize::new(0);
|
let cursor = AtomicUsize::new(0);
|
||||||
|
|
||||||
let mut results: Vec<(usize, RunMetrics)> = std::thread::scope(|scope| {
|
let mut results: Vec<(usize, RunReport)> = std::thread::scope(|scope| {
|
||||||
let handles: Vec<_> = (0..nthreads)
|
let handles: Vec<_> = (0..nthreads)
|
||||||
.map(|_| {
|
.map(|_| {
|
||||||
scope.spawn(|| {
|
scope.spawn(|| {
|
||||||
let mut local: Vec<(usize, RunMetrics)> = Vec::new();
|
let mut local: Vec<(usize, RunReport)> = Vec::new();
|
||||||
loop {
|
loop {
|
||||||
let i = cursor.fetch_add(1, Ordering::Relaxed);
|
let i = cursor.fetch_add(1, Ordering::Relaxed);
|
||||||
if i >= points.len() {
|
if i >= points.len() {
|
||||||
@@ -159,7 +161,7 @@ where
|
|||||||
SweepFamily {
|
SweepFamily {
|
||||||
points: results
|
points: results
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|(i, metrics)| SweepPoint { params: points[i].clone(), metrics })
|
.map(|(i, report)| SweepPoint { params: points[i].clone(), report })
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -169,7 +171,7 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::{
|
use crate::{
|
||||||
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role,
|
f64_field, summarize, BlueprintNode, Composite, Edge, OutField, ParamAlias, Role,
|
||||||
RunMetrics, Target,
|
RunManifest, Target,
|
||||||
};
|
};
|
||||||
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
|
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
|
||||||
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
||||||
@@ -327,10 +329,12 @@ mod tests {
|
|||||||
(bp, rx_eq, rx_ex)
|
(bp, rx_eq, rx_ex)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build + bootstrap + run + drain + summarize one grid point. A free `fn`
|
/// Build + bootstrap + run + drain + summarize one grid point into a
|
||||||
/// (Copy + Sync) so it serves as the `sweep` closure AND a direct reference
|
/// `RunReport`. A free `fn` (Copy + Sync) so it serves as the `sweep` closure
|
||||||
/// for the "sweep == N independent runs" comparison.
|
/// AND a direct reference for the "sweep == N independent runs" comparison.
|
||||||
fn run_point(point: &[Scalar]) -> RunMetrics {
|
/// The manifest is a minimal fixed fixture — the metrics are the run's, and
|
||||||
|
/// determinism makes `run_point` reproduce a point's report exactly.
|
||||||
|
fn run_point(point: &[Scalar]) -> RunReport {
|
||||||
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
|
let (bp, rx_eq, rx_ex) = composite_sma_cross_harness();
|
||||||
let mut h = bp
|
let mut h = bp
|
||||||
.bootstrap_with_params(point.to_vec())
|
.bootstrap_with_params(point.to_vec())
|
||||||
@@ -338,7 +342,16 @@ mod tests {
|
|||||||
h.run(vec![synthetic_prices()]);
|
h.run(vec![synthetic_prices()]);
|
||||||
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
let equity = f64_field(&rx_eq.try_iter().collect::<Vec<_>>(), 0);
|
||||||
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
let exposure = f64_field(&rx_ex.try_iter().collect::<Vec<_>>(), 0);
|
||||||
summarize(&equity, &exposure)
|
RunReport {
|
||||||
|
manifest: RunManifest {
|
||||||
|
commit: "test".to_string(),
|
||||||
|
params: Vec::new(),
|
||||||
|
window: (Timestamp(0), Timestamp(0)),
|
||||||
|
seed: 0,
|
||||||
|
broker: "test".to_string(),
|
||||||
|
},
|
||||||
|
metrics: summarize(&equity, &exposure),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sma_cross_grid() -> GridSpace {
|
fn sma_cross_grid() -> GridSpace {
|
||||||
@@ -371,8 +384,8 @@ mod tests {
|
|||||||
// each point's metrics equal a direct, independent run of the same point:
|
// each point's metrics equal a direct, independent run of the same point:
|
||||||
// the sweep adds enumeration + execution, never a metrics change (C1).
|
// the sweep adds enumeration + execution, never a metrics change (C1).
|
||||||
for pt in &family.points {
|
for pt in &family.points {
|
||||||
assert_eq!(pt.metrics, run_point(&pt.params));
|
assert_eq!(pt.report, run_point(&pt.params));
|
||||||
assert!(pt.metrics.total_pips.is_finite());
|
assert!(pt.report.metrics.total_pips.is_finite());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,9 +404,9 @@ mod tests {
|
|||||||
fn distinct_points_produce_distinct_metrics() {
|
fn distinct_points_produce_distinct_metrics() {
|
||||||
let family = sweep(&sma_cross_grid(), run_point);
|
let family = sweep(&sma_cross_grid(), run_point);
|
||||||
// not a constant family: differing SMA lengths produce differing equity
|
// not a constant family: differing SMA lengths produce differing equity
|
||||||
let first = family.points[0].metrics.total_pips;
|
let first = family.points[0].report.metrics.total_pips;
|
||||||
assert!(
|
assert!(
|
||||||
family.points.iter().any(|p| p.metrics.total_pips != first),
|
family.points.iter().any(|p| p.report.metrics.total_pips != first),
|
||||||
"a 4-point SMA-length grid must not collapse to one metric",
|
"a 4-point SMA-length grid must not collapse to one metric",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "aura-registry"
|
||||||
|
edition.workspace = true
|
||||||
|
version.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
publish.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
# the run registry's typed read-path: serde_json parses stored RunReport records
|
||||||
|
# back (admitted under the amended C16 per-case policy, INDEX.md). RunReport
|
||||||
|
# derives serde in aura-engine.
|
||||||
|
aura-engine = { path = "../aura-engine" }
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
# fixtures construct RunReport/RunManifest literals, whose window needs Timestamp
|
||||||
|
aura-core = { path = "../aura-core" }
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
//! The run registry (C18): an append-only store of run records — one
|
||||||
|
//! `(manifest, metrics)` `RunReport` per line in a JSONL file — with a typed
|
||||||
|
//! read-path (serde) for listing and ranking runs across invocations ("compare
|
||||||
|
//! experiments over time", which has no home in git or Gitea). Storage is
|
||||||
|
//! serde_json; display is the caller's concern (the CLI prints via
|
||||||
|
//! `RunReport::to_json`).
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use aura_engine::RunReport;
|
||||||
|
|
||||||
|
/// An append-only run registry over a JSONL file: one serde_json line per
|
||||||
|
/// `RunReport`.
|
||||||
|
pub struct Registry {
|
||||||
|
path: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Registry {
|
||||||
|
/// Bind to a JSONL path. No I/O — the file is created lazily on first append.
|
||||||
|
pub fn open(path: impl AsRef<Path>) -> Registry {
|
||||||
|
Registry { path: path.as_ref().to_path_buf() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one record as a single JSON line, creating the file (and its parent
|
||||||
|
/// directory) if absent.
|
||||||
|
pub fn append(&self, report: &RunReport) -> Result<(), RegistryError> {
|
||||||
|
if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
// a RunReport is finite by construction (its f64 fields are finite), so
|
||||||
|
// serialization is infallible here.
|
||||||
|
let line = serde_json::to_string(report).expect("a finite RunReport serializes");
|
||||||
|
let mut file = fs::OpenOptions::new().create(true).append(true).open(&self.path)?;
|
||||||
|
writeln!(file, "{line}")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse every non-empty line back into a typed `RunReport`, in file order. A
|
||||||
|
/// missing file is an empty registry (`Ok(vec![])`), not an error.
|
||||||
|
pub fn load(&self) -> Result<Vec<RunReport>, RegistryError> {
|
||||||
|
let text = match fs::read_to_string(&self.path) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||||
|
Err(e) => return Err(RegistryError::Io(e)),
|
||||||
|
};
|
||||||
|
let mut reports = Vec::new();
|
||||||
|
for (i, raw) in text.lines().enumerate() {
|
||||||
|
if raw.trim().is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let report =
|
||||||
|
serde_json::from_str(raw).map_err(|source| RegistryError::Parse { line: i + 1, source })?;
|
||||||
|
reports.push(report);
|
||||||
|
}
|
||||||
|
Ok(reports)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sort `reports` **best-first** by a named metric. "Best" is per-metric, fixed
|
||||||
|
/// by each metric's meaning: `total_pips` higher-is-better (descending);
|
||||||
|
/// `max_drawdown` and `exposure_sign_flips` lower-is-better (ascending). A stable
|
||||||
|
/// sort keeps file (insertion) order among ties; `f64` keys use `partial_cmp`
|
||||||
|
/// (total, since metrics are finite by construction). An unknown metric name is a
|
||||||
|
/// `RegistryError::UnknownMetric`.
|
||||||
|
pub fn rank_by(mut reports: Vec<RunReport>, metric: &str) -> Result<Vec<RunReport>, RegistryError> {
|
||||||
|
match metric {
|
||||||
|
"total_pips" => {
|
||||||
|
reports.sort_by(|a, b| b.metrics.total_pips.partial_cmp(&a.metrics.total_pips).unwrap())
|
||||||
|
}
|
||||||
|
"max_drawdown" => {
|
||||||
|
reports.sort_by(|a, b| a.metrics.max_drawdown.partial_cmp(&b.metrics.max_drawdown).unwrap())
|
||||||
|
}
|
||||||
|
"exposure_sign_flips" => {
|
||||||
|
reports.sort_by(|a, b| a.metrics.exposure_sign_flips.cmp(&b.metrics.exposure_sign_flips))
|
||||||
|
}
|
||||||
|
other => return Err(RegistryError::UnknownMetric(other.to_string())),
|
||||||
|
}
|
||||||
|
Ok(reports)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What can go wrong reading or ranking the registry.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum RegistryError {
|
||||||
|
/// An I/O error reading or writing the JSONL file.
|
||||||
|
Io(std::io::Error),
|
||||||
|
/// A stored line did not parse as a `RunReport` (1-based line number).
|
||||||
|
Parse { line: usize, source: serde_json::Error },
|
||||||
|
/// `rank_by` was given a metric name it does not know.
|
||||||
|
UnknownMetric(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for RegistryError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
RegistryError::Io(e) => write!(f, "registry i/o: {e}"),
|
||||||
|
RegistryError::Parse { line, source } => {
|
||||||
|
write!(f, "registry parse error at line {line}: {source}")
|
||||||
|
}
|
||||||
|
RegistryError::UnknownMetric(m) => write!(
|
||||||
|
f,
|
||||||
|
"unknown metric '{m}' (known: total_pips, max_drawdown, exposure_sign_flips)"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for RegistryError {}
|
||||||
|
|
||||||
|
impl From<std::io::Error> for RegistryError {
|
||||||
|
fn from(e: std::io::Error) -> Self {
|
||||||
|
RegistryError::Io(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use aura_core::Timestamp;
|
||||||
|
use aura_engine::{RunManifest, RunMetrics, RunReport};
|
||||||
|
|
||||||
|
fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport {
|
||||||
|
RunReport {
|
||||||
|
manifest: RunManifest {
|
||||||
|
commit: "c".to_string(),
|
||||||
|
params: vec![("p".to_string(), 1.0)],
|
||||||
|
window: (Timestamp(1), Timestamp(2)),
|
||||||
|
seed: 0,
|
||||||
|
broker: "b".to_string(),
|
||||||
|
},
|
||||||
|
metrics: RunMetrics { total_pips, max_drawdown, exposure_sign_flips: flips },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn temp_path(name: &str) -> PathBuf {
|
||||||
|
// unique per test + per process; no external tempfile dependency
|
||||||
|
std::env::temp_dir().join(format!("aura-registry-{}-{}.jsonl", std::process::id(), name))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn append_then_load_round_trips_in_order() {
|
||||||
|
let path = temp_path("roundtrip");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let reg = Registry::open(&path);
|
||||||
|
let a = report_with(1.0, 0.5, 1);
|
||||||
|
let b = report_with(2.0, 0.3, 2);
|
||||||
|
reg.append(&a).expect("append a");
|
||||||
|
reg.append(&b).expect("append b");
|
||||||
|
assert_eq!(reg.load().expect("load"), vec![a, b]);
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn load_missing_file_is_empty() {
|
||||||
|
let path = temp_path("missing");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let reg = Registry::open(&path);
|
||||||
|
assert_eq!(reg.load().expect("load missing"), Vec::<RunReport>::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_line_is_a_parse_error_with_line_number() {
|
||||||
|
let path = temp_path("corrupt");
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
let reg = Registry::open(&path);
|
||||||
|
reg.append(&report_with(1.0, 0.5, 1)).expect("append");
|
||||||
|
let mut f = fs::OpenOptions::new().append(true).open(&path).expect("open");
|
||||||
|
writeln!(f, "not json").expect("write corrupt line");
|
||||||
|
drop(f);
|
||||||
|
match reg.load() {
|
||||||
|
Err(RegistryError::Parse { line, .. }) => assert_eq!(line, 2),
|
||||||
|
other => panic!("expected Parse at line 2, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_by_orders_best_first_per_metric() {
|
||||||
|
let reports = vec![
|
||||||
|
report_with(1.0, 0.9, 5),
|
||||||
|
report_with(3.0, 0.1, 1),
|
||||||
|
report_with(2.0, 0.5, 3),
|
||||||
|
];
|
||||||
|
let by_pips = rank_by(reports.clone(), "total_pips").expect("rank pips");
|
||||||
|
assert_eq!(by_pips[0].metrics.total_pips, 3.0); // higher-is-better -> desc
|
||||||
|
let by_dd = rank_by(reports.clone(), "max_drawdown").expect("rank dd");
|
||||||
|
assert_eq!(by_dd[0].metrics.max_drawdown, 0.1); // lower-is-better -> asc
|
||||||
|
let by_flips = rank_by(reports.clone(), "exposure_sign_flips").expect("rank flips");
|
||||||
|
assert_eq!(by_flips[0].metrics.exposure_sign_flips, 1); // lower-is-better -> asc
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rank_by_unknown_metric_is_an_error() {
|
||||||
|
let reports = vec![report_with(1.0, 0.5, 1)];
|
||||||
|
match rank_by(reports, "sharpe") {
|
||||||
|
Err(RegistryError::UnknownMetric(m)) => assert_eq!(m, "sharpe"),
|
||||||
|
other => panic!("expected UnknownMetric, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user