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:
2026-06-10 20:28:31 +02:00
parent 5fa003da64
commit fe11cfea8a
7 changed files with 308 additions and 115 deletions
+41 -91
View File
@@ -8,10 +8,10 @@
mod render;
use aura_core::{Firing, ParamSpec, Scalar, ScalarKind, Timestamp};
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, SweepPoint, Target,
OutField, ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target,
};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
@@ -206,57 +206,13 @@ fn sample_blueprint() -> Composite {
build_sample()
}
/// Render one swept point as one canonical JSON object (C14, hand-rolled — the
/// engine's `json_str` is private, so the rules of `RunReport::to_json` are
/// mirrored here, not called): `{"params":{name:value,…},"metrics":{…}}`. Param
/// names come from `param_space()` zipped onto the point vector; `f64` fields use
/// the round-trippable shortest form, so a whole-valued float renders without a
/// fractional part (`12.0` -> `12`).
fn sweep_point_to_json(pt: &SweepPoint, space: &[ParamSpec]) -> String {
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(),
/// Coerce a sweep point's `Scalar` value to the manifest's `f64` param type.
/// A tuning grid carries only numeric params (`I64` lengths, `F64` scales).
fn scalar_as_param_f64(s: &Scalar) -> f64 {
match s {
Scalar::I64(n) => *n as f64,
Scalar::F64(f) => *f,
other => unreachable!("non-numeric sweep param: {other:?}"),
}
}
@@ -282,14 +238,33 @@ fn sweep_report() -> String {
let mut h = bp
.bootstrap_with_params(point.to_vec())
.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 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();
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
@@ -488,46 +463,21 @@ mod tests {
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]
fn sweep_report_renders_four_points_in_odometer_order() {
let out = sweep_report();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 4, "one JSON line per grid point; got: {out:?}");
// params byte-pinned per line: odometer order (last axis fastest) + the
// param-name and value-token rules. Metric *values* are left to the
// determinism check below (they are computed f64s, not predictable here).
assert!(lines[0].starts_with(
r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5},"metrics":{"#
));
assert!(lines[1].starts_with(
r#"{"params":{"sma_cross.fast":2,"sma_cross.slow":5,"scale":0.5},"metrics":{"#
));
assert!(lines[2].starts_with(
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":{"#
));
// each line is a full RunReport; the commit is the real git HEAD
// (volatile), so pin the per-point manifest params (odometer order, last
// axis fastest) + the metric keys, not the commit value.
for line in &lines {
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}");
}
assert!(lines[0].contains(r#""params":{"sma_cross.fast":2,"sma_cross.slow":4,"scale":0.5}"#), "line0: {}", lines[0]);
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[3].contains(r#""params":{"sma_cross.fast":3,"sma_cross.slow":5,"scale":0.5}"#), "line3: {}", lines[3]);
for line in &lines {
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");