Files
Aura/docs/plans/0033-unify-runreport-json-serde.md
T
Brummel e2056b6436 plan: 0033 unify-runreport-json-serde
Two-task RED→GREEN plan for spec 0033. Task 1 (RED, test-only): flip the engine
canonical-form golden + the CLI sweep golden to the serde array-of-pairs/.0-float
shape, add the to_json_equals_serde_disk_shape pin test — all fail against the
current hand-rolled to_json. Task 2 (GREEN, atomic): promote serde_json dev-dep ->
normal dep, swap to_json's body to serde_json::to_string, delete the now-unused
json_str helper, refresh the method + module-header doc comments — one build+clippy
gate flips every Task-1 assertion green. graph_model.rs:30 + INDEX.md:729 stale
cross-refs deferred to cycle-close audit.

refs #54
2026-06-11 19:38:38 +02:00

12 KiB

Unify RunReport JSON (serde) — Implementation Plan

Parent spec: docs/specs/0033-unify-runreport-json-serde.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Render RunReport stdout JSON through serde (the same encoder the run registry uses on disk), retiring the hand-rolled to_json writer, so a record's stdout bytes equal its runs.jsonl bytes — pinned by a test.

Architecture: RunReport already derives serde; the registry already serializes via serde_json::to_string. This cycle swaps RunReport::to_json's body to delegate to serde_json::to_string, deletes the now-unused hand-rolled json_str helper, promotes serde_json from a dev-dep to a normal dep of aura-engine, and flips the two RunReport JSON goldens (engine canonical-form + CLI sweep) to the serde shape. RED-first: Task 1 moves the goldens + adds the pin test (RED against the current hand-rolled writer); Task 2 swaps the body + dep (GREEN).

Tech Stack: aura-engine (report.rs, Cargo.toml), aura-cli integration goldens (tests/cli_run.rs), serde / serde_json.

Parse-the-bytes gate (self-review #9): the profile declares no spec_validation → the gate is a no-op. All inlined bodies are Rust (caught by the implement compile gate) or TOML; the JSON goldens live inside Rust string literals. No surface-language snippet needs a parser.


Files this plan creates or modifies:

  • Modify: crates/aura-engine/src/report.rs:6-8 — module-header doc: drop the now-false "to_json is a hand-rolled writer" claim.
  • Modify: crates/aura-engine/src/report.rs:59-103RunReport::to_json doc comment + body swapped to serde.
  • Modify: crates/aura-engine/src/report.rs:159-175 — delete the json_str free function (unused after the swap).
  • Test: crates/aura-engine/src/report.rs:389-412 — canonical-form golden updated to the serde shape.
  • Test: crates/aura-engine/src/report.rs (tests module, after line 412) — new to_json_equals_serde_disk_shape pin test.
  • Modify: crates/aura-engine/Cargo.toml:8-17serde_json dev-dep → normal dep.
  • Test: crates/aura-cli/tests/cli_run.rs:213-216 — sweep golden params substring updated to the serde array-of-pairs shape.

Out of scope (deferred to cycle-close audit, per the spec's INDEX.md:729 deferral precedent): crates/aura-engine/src/graph_model.rs:30 stale cross-reference to RunReport::json_str (a different file, #58/#28 territory — not widened into here); docs/design/INDEX.md:729 ledger cross-ref.


Task 1: Move the goldens + add the pin test (RED)

All edits here are test/golden code only. serde_json is already a dev-dep, so the new pin test compiles without the Task-2 dep promotion. Against the current hand-rolled to_json (params-object / whole-int floats), all three assertions FAIL — the intended RED state proving the serde shape genuinely differs.

Files:

  • Test: crates/aura-engine/src/report.rs:389-412 (canonical-form golden)

  • Test: crates/aura-engine/src/report.rs (new pin test after line 412)

  • Test: crates/aura-cli/tests/cli_run.rs:213-216 (sweep golden)

  • Step 1: Update the engine canonical-form golden to the serde shape

In crates/aura-engine/src/report.rs, in to_json_renders_the_canonical_form, replace the expected raw-string literal (currently line 410) — only the r#"..."# argument changes, the RunReport { ... } fixture above it is untouched:

Replace:

        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}}"#,

with:

        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}}"#,
  • Step 2: Add the to_json_equals_serde_disk_shape pin test

In crates/aura-engine/src/report.rs, insert this test immediately after to_json_renders_the_canonical_form (after its closing } at line 412, before runreport_serde_round_trips). serde_json is called fully-qualified (the existing runreport_serde_round_trips does the same — no new use needed):

    #[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());
    }
  • Step 3: Update the CLI sweep golden to the serde shape

In crates/aura-cli/tests/cli_run.rs, in sweep_prints_four_json_lines_and_exits_zero, replace the params assert (lines 213-216):

Replace:

    assert!(
        first.contains("\"params\":{\"sma_cross.fast.length\":2,\"sma_cross.slow.length\":4,\"exposure.scale\":0.5}"),
        "got: {stdout}"
    );

with:

    assert!(
        first.contains("\"params\":[[\"sma_cross.fast.length\",2.0],[\"sma_cross.slow.length\",4.0],[\"exposure.scale\",0.5]]"),
        "got: {stdout}"
    );
  • Step 4: Verify the engine canonical-form golden FAILS (RED)

Run: cargo test -p aura-engine to_json_renders_the_canonical_form Expected: FAIL — assert_eq! left/right mismatch (current hand-rolled output has "params":{...} and whole-int floats; the new expected has "params":[[...]] and .0 floats).

  • Step 5: Verify the pin test FAILS (RED)

Run: cargo test -p aura-engine to_json_equals_serde_disk_shape Expected: FAIL — report.to_json() (hand-rolled, object/int) != serde_json::to_string(&report) (array-of-pairs / .0).

  • Step 6: Verify the CLI sweep golden FAILS (RED)

Run: cargo test -p aura-cli sweep_prints_four_json_lines_and_exits_zero Expected: FAIL — the spawned binary still renders the params-object form, so first.contains("\"params\":[[...]]") is false (panic with got: {stdout} showing the object form).


Task 2: Swap the body + promote the dep (GREEN)

Promote serde_json to a normal dep, swap to_json's body to serde, delete the now-unused json_str, and refresh the two doc comments. These MUST land together: the body swap references serde_json in non-test code (needs the normal dep), and deleting json_str while the hand-rolled body still calls it would not compile — so the dep move, the body swap, and the json_str deletion are one atomic task with a single build+clippy gate. This flips every Task-1 assertion to GREEN.

Files:

  • Modify: crates/aura-engine/Cargo.toml:8-17

  • Modify: crates/aura-engine/src/report.rs:6-8 (module header)

  • Modify: crates/aura-engine/src/report.rs:59-103 (to_json doc + body)

  • Modify: crates/aura-engine/src/report.rs:159-175 (delete json_str)

  • Step 1: Promote serde_json to a normal dependency

In crates/aura-engine/Cargo.toml, move serde_json from [dev-dependencies] to [dependencies]. The two blocks (lines 8-17) become:

[dependencies]
aura-core = { path = "../aura-core" }
# serde is admitted under the amended C16 per-case dependency policy (INDEX.md):
# 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" }
  • Step 2: Swap to_json's doc comment + body to serde

In crates/aura-engine/src/report.rs, replace the whole to_json doc comment + method (lines 59-103 — from the /// Render canonical… doc block through the method's closing }) with:

    /// 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 {
        serde_json::to_string(self).expect("a finite RunReport always serializes")
    }
  • Step 3: Delete the now-unused json_str helper

In crates/aura-engine/src/report.rs, delete the entire json_str free function (lines 159-175, the /// Minimal JSON string rendering… doc through the fn's closing }). Its only callers were inside the to_json body just replaced, so nothing references it now (leaving it would trip clippy -D warnings as dead_code). The independent json_str in graph_model.rs is untouched.

  • Step 4: Refresh the module-header doc

In crates/aura-engine/src/report.rs, replace the module-header lines 6-8:

Replace:

//! 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.

with:

//! and folds them here. Output is canonical JSON (C14): the schema is tiny,
//! 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.
  • Step 5: Build the workspace (compile gate)

Run: cargo build --workspace Expected: success — aura-engine compiles with serde_json as a normal dep and the serde-backed to_json; no unused/dead_code from the removed json_str.

  • Step 6: Verify the engine canonical-form golden PASSES (GREEN)

Run: cargo test -p aura-engine to_json_renders_the_canonical_form Expected: PASS — to_json() now emits the serde shape matching the updated expected string.

  • Step 7: Verify the pin test PASSES (GREEN)

Run: cargo test -p aura-engine to_json_equals_serde_disk_shape Expected: PASS — to_json() and serde_json::to_string(&report) are now the same bytes.

  • Step 8: Verify the CLI sweep golden + structural golden PASS (GREEN)

Run: cargo test -p aura-cli sweep_prints_four_json_lines_and_exits_zero Expected: PASS — the binary now renders params as the array-of-pairs serde form.

Run: cargo test -p aura-cli run_prints_json_and_exits_zero Expected: PASS — the structural asserts (commit prefix, window:[1,7], total_pips: key, exposure_sign_flips:1}}) are shape-agnostic and stay green.

  • Step 9: Full workspace gate

Run: cargo test --workspace Expected: PASS — all tests green, including the determinism asserts (report_is_deterministic_end_to_end, run_sample_is_deterministic_and_non_trivial, run_macd_compiles_from_nested_composite_and_is_deterministic, sweep_report_is_deterministic) which compare two serde outputs and stay green.

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: clean — no dead_code (json_str gone), no warnings.