fix(aura-engine): unify RunReport stdout JSON with the on-disk serde shape

RunReport had two divergent JSON encoders: the registry serialized to disk via
serde_json (params as an array-of-pairs, floats as 2.0), while stdout rendered
through a hand-rolled to_json (params as an object, whole floats as 2). The same
record was a structurally different document on disk than on the wire, and no
test pinned their relationship — most visibly, `aura runs list` read a record
from disk via serde and reprinted it in a different shape.

Retire the hand-rolled writer: RunReport::to_json now delegates to
serde_json::to_string(self), so stdout is byte-identical to the runs.jsonl line
for the same record. The hand-rolled json_str helper is deleted (the
graph_model.rs json_str is a separate symbol, untouched — the graph-model JSON
is out of scope, #58/#28). A new pin test, to_json_equals_serde_disk_shape,
asserts to_json() == serde_json::to_string(&report) so the two encoders can
never silently diverge again.

Observable change: stdout `params` is now an array-of-pairs and finite f64
fields carry a fractional part (2 -> 2.0); the nested envelope, field order,
window [from,to], and integer-valued seed/exposure_sign_flips are unchanged. The
on-disk shape does NOT change — only stdout moves to match disk.

serde_json is promoted from a dev-dependency to a normal dependency of
aura-engine (to_json is non-test code). Under the amended C16 per-case policy
this passes: serde_json is a vetted standard crate already in the workspace
(registry, ingest), deterministic (C1-safe, already relied on for the disk
path), and is exactly "the vetted standard crate doing what the code would
otherwise hand-roll" — it removes the last hand-rolled RunReport JSON writer.

Goldens flipped to the serde shape: the engine canonical-form golden, the
aura-cli sweep golden (cli_run.rs), and the aura-cli odometer-order sweep golden
(main.rs) — the last was not in the plan's inventory; the cargo test --workspace
gate surfaced it and it took the identical transformation. Structural CLI asserts
and the r1.to_json()==r2.to_json() determinism asserts are shape-agnostic and
stay green.

Spec docs/specs/0033, plan docs/plans/0033, boss-signed (unanimous 5-lens panel).
Two stale prose cross-refs (graph_model.rs:30, docs/design/INDEX.md:729) deferred
to cycle-close audit. Gates: cargo build/test --workspace green, clippy
--all-targets -D warnings clean.

closes #54
This commit is contained in:
2026-06-11 19:43:36 +02:00
parent e2056b6436
commit f1d0bf00ec
4 changed files with 40 additions and 70 deletions
+4 -4
View File
@@ -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}");
+1 -1
View File
@@ -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);
+3 -1
View File
@@ -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 }
+32 -64
View File
@@ -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 {