diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 69ac68f..3a59b7e 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -527,10 +527,10 @@ mod tests { for line in &lines { assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}"); } - assert!(lines[0].contains(r#""params":{"sma_cross.fast.length":2,"sma_cross.slow.length":4,"exposure.scale":0.5}"#), "line0: {}", lines[0]); - assert!(lines[1].contains(r#""params":{"sma_cross.fast.length":2,"sma_cross.slow.length":5,"exposure.scale":0.5}"#), "line1: {}", lines[1]); - assert!(lines[2].contains(r#""params":{"sma_cross.fast.length":3,"sma_cross.slow.length":4,"exposure.scale":0.5}"#), "line2: {}", lines[2]); - assert!(lines[3].contains(r#""params":{"sma_cross.fast.length":3,"sma_cross.slow.length":5,"exposure.scale":0.5}"#), "line3: {}", lines[3]); + assert!(lines[0].contains(r#""params":[["sma_cross.fast.length",2.0],["sma_cross.slow.length",4.0],["exposure.scale",0.5]]"#), "line0: {}", lines[0]); + assert!(lines[1].contains(r#""params":[["sma_cross.fast.length",2.0],["sma_cross.slow.length",5.0],["exposure.scale",0.5]]"#), "line1: {}", lines[1]); + assert!(lines[2].contains(r#""params":[["sma_cross.fast.length",3.0],["sma_cross.slow.length",4.0],["exposure.scale",0.5]]"#), "line2: {}", lines[2]); + assert!(lines[3].contains(r#""params":[["sma_cross.fast.length",3.0],["sma_cross.slow.length",5.0],["exposure.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}"); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 54ccf65..b024c3c 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -211,7 +211,7 @@ fn sweep_prints_four_json_lines_and_exits_zero() { // pin the first odometer point's manifest params, not the commit value. assert!(first.starts_with("{\"manifest\":{\"commit\":\""), "got: {stdout}"); assert!( - first.contains("\"params\":{\"sma_cross.fast.length\":2,\"sma_cross.slow.length\":4,\"exposure.scale\":0.5}"), + first.contains("\"params\":[[\"sma_cross.fast.length\",2.0],[\"sma_cross.slow.length\",4.0],[\"exposure.scale\",0.5]]"), "got: {stdout}" ); let _ = std::fs::remove_dir_all(&cwd); diff --git a/crates/aura-engine/Cargo.toml b/crates/aura-engine/Cargo.toml index c10ed0b..4fcc1d9 100644 --- a/crates/aura-engine/Cargo.toml +++ b/crates/aura-engine/Cargo.toml @@ -11,7 +11,9 @@ aura-core = { path = "../aura-core" } # it gives the run-report types a typed (de)serialization path for the run # registry (cycle 0029). serde's output is deterministic (C1-safe). serde = { workspace = true } +# serde_json renders RunReport JSON for both the registry (disk) and stdout +# (RunReport::to_json) — one shape, no hand-rolled writer (amended C16). +serde_json = { workspace = true } [dev-dependencies] aura-std = { path = "../aura-std" } -serde_json = { workspace = true } diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 5ee7325..838a8b0 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -4,8 +4,9 @@ //! reduce end-of-run (C8 caps a node at one record per `eval`, with no terminal //! `eval`), so the World drains its recording sinks after [`Harness::run`](crate::Harness::run) //! and folds them here. Output is canonical JSON (C14): the schema is tiny, -//! closed, and flat. `to_json` is a hand-rolled writer; the report types also -//! derive serde (cycle 0029) for the run registry's typed read-path. +//! closed, and flat. `to_json` renders via serde (the report types derive it, +//! cycle 0029) — the same encoder the run registry uses, so a record's stdout +//! and on-disk shapes coincide. use aura_core::{Scalar, Timestamp}; @@ -56,50 +57,14 @@ pub struct RunReport { } impl RunReport { - /// Render canonical, machine-readable JSON (C14). Hand-rolled — the schema - /// is tiny, closed, and flat. (The same types also derive serde for the run - /// registry's typed read-path, cycle 0029; this writer is kept for stdout.) - /// - /// # Shape - /// - /// The JSON keys mirror the struct field names, in fixed order, nested as - /// `{"manifest": {…}, "metrics": {…}}`. `window` is a 2-element - /// `[from, to]` array of epoch-ns integers; `params` is a JSON object of - /// `name: value` pairs in insertion order. A run renders as, e.g.: - /// - /// ```text - /// {"manifest":{"commit":"abc123","params":{"sma_fast":2,"sma_slow":4},"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=0.0001)"},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}} - /// ``` - /// - /// `f64` fields use the round-trippable `{}` shortest form (finite values - /// only — pip equity and exposure are finite by construction), so a - /// whole-valued float renders **without** a fractional part (`12.0` → `12`): - /// one `f64` field may appear as an integer token in one run and a decimal - /// token in another. Consumers must parse the value as a number, never key - /// off the token shape. + /// Render the canonical, machine-readable JSON (C14) via serde — the same + /// encoder the run registry uses on disk, so a record's stdout shape and its + /// `runs.jsonl` shape are byte-identical. `params` is an array of + /// `[name, value]` pairs; finite `f64` fields carry a fractional part + /// (`2.0`). Consumers must parse values as numbers, never key off the token + /// shape. pub fn to_json(&self) -> String { - let m = &self.manifest; - let mut params = String::from("{"); - for (i, (name, value)) in m.params.iter().enumerate() { - if i > 0 { - params.push(','); - } - params.push_str(&json_str(name)); - params.push(':'); - params.push_str(&value.to_string()); - } - params.push('}'); - format!( - "{{\"manifest\":{{\"commit\":{commit},\"params\":{params},\"window\":[{from},{to}],\"seed\":{seed},\"broker\":{broker}}},\"metrics\":{{\"total_pips\":{pips},\"max_drawdown\":{dd},\"exposure_sign_flips\":{flips}}}}}", - commit = json_str(&m.commit), - from = m.window.0.0, - to = m.window.1.0, - seed = m.seed, - broker = json_str(&m.broker), - pips = self.metrics.total_pips, - dd = self.metrics.max_drawdown, - flips = self.metrics.exposure_sign_flips, - ) + serde_json::to_string(self).expect("a finite RunReport always serializes") } } @@ -156,24 +121,6 @@ fn sign0(v: f64) -> f64 { } } -/// Minimal JSON string rendering: wrap in quotes, escape `"` and `\`. Our -/// caller-supplied labels (commit hash, param names, broker label) contain -/// neither control characters nor other JSON-significant bytes, so these two -/// escapes are sufficient. -fn json_str(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 -} - /// Bridge a recording sink's recorded `(ts, row)` stream to [`summarize`]: /// extract one `f64` field of each row into `(ts, f64)` samples. Panics if a /// row has no such field or the field is not an `f64` scalar — a wiring bug (a @@ -407,10 +354,31 @@ mod tests { }; assert_eq!( report.to_json(), - r#"{"manifest":{"commit":"abc123","params":{"sma_fast":2,"sma_slow":4,"exposure_scale":1},"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12,"max_drawdown":1,"exposure_sign_flips":1}}"#, + r#"{"manifest":{"commit":"abc123","params":[["sma_fast",2.0],["sma_slow",4.0],["exposure_scale",1.0]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}}"#, ); } + #[test] + fn to_json_equals_serde_disk_shape() { + // the same RunReport value the canonical-form test builds. + let report = RunReport { + manifest: RunManifest { + commit: "abc123".to_string(), + params: vec![ + ("sma_fast".to_string(), 2.0), + ("sma_slow".to_string(), 4.0), + ("exposure_scale".to_string(), 1.0), + ], + window: (Timestamp(1), Timestamp(6)), + seed: 0, + broker: "sim-optimal(pip_size=1.0)".to_string(), + }, + metrics: RunMetrics { total_pips: 12.0, max_drawdown: 1.0, exposure_sign_flips: 1 }, + }; + // stdout (to_json) and disk (serde_json::to_string) are now the same bytes. + assert_eq!(report.to_json(), serde_json::to_string(&report).unwrap()); + } + #[test] fn runreport_serde_round_trips() { let report = RunReport {