Files
Aura/docs/plans/0026-graph-model-serializer.md
T
Brummel bbe2266b9e plan: 0026 graph model serializer
Iteration 1 of the graph render redesign: a read-only model_to_json(&Composite)
in a new crates/aura-engine/src/graph_model.rs, hand-rolled deterministic JSON
(RunReport::to_json house style, no serde). Six tasks: helpers, primitive record,
scope (index keys + synthetic source nodes from bound roles), composite defs
(@role/#N endpoints, distinct-once walk), top-level + byte golden + determinism,
structural assertions + structural-miswire-differs + read-only. Decisions: home
in aura-engine; node keys are indices (C23); input ports carry kind+firing, no
name (per acceptance criterion 3); bound root source-roles minted as synthetic
source nodes.
2026-06-10 11:10:23 +02:00

24 KiB

Iteration 1 — graph model serializer — Implementation Plan

Parent spec: docs/specs/0026-graph-render-redesign.md

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

Goal: Add a read-only Rust serializer model_to_json(&Composite) -> String that turns the harness root composite + every distinct composite type into the canonical, deterministic JSON graph model the viewer (iteration 2) will consume.

Architecture: A new crates/aura-engine/src/graph_model.rs, hand-rolled deterministic JSON in the RunReport::to_json house style (no serde). It walks the blueprint via the existing read-only accessors and emits a model with two top-level keys: root (the harness scope) and composites (each distinct composite type once). Read-only (C9): the function takes &Composite, returns String, and never calls eval/compile/bootstrap.

Tech Stack: aura-engine (blueprint.rs accessors, harness::Edge, report.rs JSON idiom), aura-core (NodeSchema/PortSpec/FieldSpec/ParamSpec/ Firing/ScalarKind).


Model shape (the contract this iteration produces)

Resolved against the types (recon) and spec acceptance criterion 3, where the spec's abridged example was prototype-flavoured:

{
  "root": <scope>,
  "composites": { "<composite-name>": <composite-def>, ... }   // first-seen order
}
  • scope = { "nodes": { "<key>": <node>, ... }, "edges": [ <edge>, ... ] }
  • composite-def = { "inputs": [ <port>, ... ], "outputs": [ [name,kind], ... ], "nodes": {...}, "edges": [...] }
  • node (one of):
    • primitive: { "prim": { "type": <label>, "role": <role>, "params": [[name,kind],...], "ins": [<port>,...], "outs": [[name,kind],...] } }
    • composite: { "comp": "<name>" }
  • port (an input) = [kind, firing]firing is "any" or "barrier <N>". Input ports carry no name (PortSpec has none); kind + firing only (criterion 3, "omit nothing").
  • role = "source" | "sink" | "node":
    • sink = primitive whose schema().output is empty (C8 pure consumer).
    • source = a synthetic node minted from a bound root input-role (Role.source.is_some()); see below.
    • node = any other primitive.
  • kind = "i64" | "f64" | "bool" | "timestamp" (the kind_str lowercase mapping; C4 palette keys off these exact strings).
  • key = the node's index in nodes() as a string ("0", "1", …). The blueprint has no unique node names (C23: identity is positional); the type label is display-only and lives in "type". Synthetic source nodes are keyed "src_<role>".
  • edge = [ <from-endpoint>, <to-endpoint> ]:
    • "<key>.o<field>" — node key output field (from Edge.from/from_field).
    • "<key>.i<slot>" — node key input slot (from Edge.to/slot, or a role/source target's slot).
    • "@<rolename>" — an interior composite input role (composite scopes only).
    • "#<N>" — a composite output binding, N = index into output() (composite scopes only).
  • Synthetic source nodes (root scope only). Each bound root input-role (Role.source.is_some()) becomes a node "src_<role.name>" = { "prim": { "type": <role.name>, "role": "source", "params": [], "ins": [], "outs": [[<role.name>, <kind>]] } }, plus one edge ["src_<role>.o0", "<target.node>.i<target.slot>"] per target. Interior composite input-roles stay as "@<role>" endpoints (rendered as boundary inputs when drilled).

Deterministic ordering: nodes in index order, synthetic sources appended in input_roles() order; composites in first-seen order; every JSON object's keys in the fixed literal order shown above (the RunReport::to_json discipline).

Files this plan creates or modifies:

  • Create: crates/aura-engine/src/graph_model.rs — the serializer + its #[cfg(test)] module (golden / determinism / mis-wire-differs / structural / read-only).
  • Modify: crates/aura-engine/src/lib.rs:40-47 — add mod graph_model; and pub use graph_model::model_to_json;.
  • Test: (in graph_model.rs) a local fixture builder + the six test cases.

Task 1: Module skeleton, lib wiring, JSON + kind helpers

Files:

  • Create: crates/aura-engine/src/graph_model.rs

  • Modify: crates/aura-engine/src/lib.rs:40-47

  • Step 1: Create the module with the JSON-escape + kind helpers

Create crates/aura-engine/src/graph_model.rs:

//! The `aura graph` model serializer (#13, cycle 0026): turns the engine's
//! authored blueprint (graph-as-data, C9) into the canonical, deterministic JSON
//! model the browser viewer consumes. Read-only — walks structure + declared
//! `NodeSchema`s only, never `eval`/`compile`/`bootstrap`. Hand-rolled JSON in the
//! `RunReport::to_json` house style (no serde, C14).

use crate::{BlueprintNode, Composite};
use aura_core::{Firing, ScalarKind};

/// A scalar kind as the lowercase type string the model + C4 colour palette use.
/// Mirrors `aura-cli`'s `kind_str`; the `Debug` form is PascalCase, so explicit.
fn kind_str(k: ScalarKind) -> &'static str {
    match k {
        ScalarKind::I64 => "i64",
        ScalarKind::F64 => "f64",
        ScalarKind::Bool => "bool",
        ScalarKind::Timestamp => "timestamp",
    }
}

/// One input port's firing policy as a model string: `"any"` or `"barrier <N>"`.
fn firing_str(f: Firing) -> String {
    match f {
        Firing::Any => "any".to_string(),
        Firing::Barrier(n) => format!("barrier {n}"),
    }
}

/// A JSON string literal: wrap in quotes, escape `"` and `\` (the minimal set
/// `RunReport::json_str` uses — model names are author-controlled identifiers).
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
}
  • Step 2: Wire the module into the crate root

In crates/aura-engine/src/lib.rs, next to the existing mod blueprint; mod harness; mod report; declarations (around lines 40-42), add:

mod graph_model;

and in the pub use surface (next to the pub use blueprint::{…} block around lines 45-47) add:

pub use graph_model::model_to_json;
  • Step 3: Add a placeholder model_to_json so the crate compiles

Append to graph_model.rs:

/// Serialize the harness root composite + every distinct composite type into the
/// canonical graph model (`{ "root": …, "composites": … }`). Read-only (C9).
pub fn model_to_json(root: &Composite) -> String {
    let _ = root;
    String::new() // filled in Task 5
}
  • Step 4: Build to verify the skeleton compiles

Run: cargo build -p aura-engine Expected: PASS (0 errors; dead_code warnings on the unused helpers are acceptable until Task 2-5 consume them).


Task 2: Serialize one primitive node (type, role, params, ins, outs)

Files:

  • Modify: crates/aura-engine/src/graph_model.rs

  • Step 1: Write the failing test for a primitive record

Append to a #[cfg(test)] mod tests block in graph_model.rs:

#[cfg(test)]
mod tests {
    use super::*;
    use aura_core::{NodeSchema, ParamSpec, PortSpec, FieldSpec, PrimitiveBuilder, Node, Scalar};

    /// A bare node whose only purpose is to back a PrimitiveBuilder in a test.
    struct Bare;
    impl Node for Bare {
        fn lookbacks(&self) -> Vec<usize> { vec![] }
        fn eval(&mut self, _c: aura_core::Ctx<'_>) -> Option<&[Scalar]> { None }
    }

    fn sub_builder() -> PrimitiveBuilder {
        PrimitiveBuilder::new(
            "Sub",
            NodeSchema {
                inputs: vec![
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Any },
                    PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0) },
                ],
                output: vec![FieldSpec { name: "diff", kind: ScalarKind::F64 }],
                params: vec![],
            },
            |_| Box::new(Bare),
        )
    }

    #[test]
    fn primitive_record_carries_type_role_ins_outs() {
        let got = prim_record(&sub_builder());
        assert_eq!(
            got,
            r#"{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any"],["f64","barrier 0"]],"outs":[["diff","f64"]]}}"#
        );
    }
}
  • Step 2: Run the test to verify it fails

Run: cargo test -p aura-engine graph_model::tests::primitive_record_carries_type_role_ins_outs Expected: FAIL — prim_record does not exist (compile error).

  • Step 3: Implement prim_record + the role derivation

Append to graph_model.rs (before the tests module):

/// A `[name, kind]` JSON pair.
fn named_kind(name: &str, kind: ScalarKind) -> String {
    format!("[{},{}]", json_str(name), json_str(kind_str(kind)))
}

/// An input port `[kind, firing]` (no name — `PortSpec` carries none).
fn port_json(p: &aura_core::PortSpec) -> String {
    format!("[{},{}]", json_str(kind_str(p.kind)), json_str(&firing_str(p.firing)))
}

/// Comma-join with no surrounding brackets.
fn join(items: impl IntoIterator<Item = String>) -> String {
    items.into_iter().collect::<Vec<_>>().join(",")
}

/// The `{ "prim": { … } }` record for a primitive node. `role` defaults to
/// `"node"`; a `vec![]` output is the C8 sink. (Source is minted separately for
/// bound root roles, see `scope_json`.)
fn prim_record(b: &crate::PrimitiveBuilder) -> String {
    let s = b.schema();
    let role = if s.output.is_empty() { "sink" } else { "node" };
    let params = join(s.params.iter().map(|p| named_kind(&p.name, p.kind)));
    let ins = join(s.inputs.iter().map(port_json));
    let outs = join(s.output.iter().map(|f| named_kind(f.name, f.kind)));
    format!(
        r#"{{"prim":{{"type":{},"role":{},"params":[{params}],"ins":[{ins}],"outs":[{outs}]}}}}"#,
        json_str(&b.label()),
        json_str(role),
    )
}

(PrimitiveBuilder is re-exported from the crate root; reference it as crate::PrimitiveBuilder. Confirm the import path against lib.rs — if it is only aura_core::PrimitiveBuilder, use that.)

  • Step 4: Run the test to verify it passes

Run: cargo test -p aura-engine graph_model::tests::primitive_record_carries_type_role_ins_outs Expected: PASS.


Task 3: Serialize a scope (nodes map + edges) with synthetic source nodes

Files:

  • Modify: crates/aura-engine/src/graph_model.rs

  • Step 1: Add a local harness fixture + the failing scope test

Add to the tests module a fixture that builds a small root harness exercising a bound source role, a composite reference, a plain node, and a sink. Use the blueprint builder API as build_sample() (aura-cli/src/main.rs:161) and the engine's own blueprint test fixtures (blueprint.rs) do — the implementer mirrors that builder surface. The fixture must produce a Composite whose:

  • input_roles() has one bound role price (source: Some(ScalarKind::F64)) targeting node 0 slot 0,
  • nodes() = [ Composite("sma_cross"), Primitive(Exposure), Primitive(Recorder-sink) ],
  • edges() wire them.
fn sample_root() -> Composite { /* build via the blueprint builder, mirroring
    build_sample() in aura-cli/src/main.rs:161-190 and the fixtures in blueprint.rs */ }

#[test]
fn scope_emits_index_keyed_nodes_synthetic_sources_and_edges() {
    let root = sample_root();
    let got = scope_json(&root, /* is_root = */ true);
    // a bound source role is minted as a synthetic source node…
    assert!(got.contains(r#""src_price":{"prim":{"type":"price","role":"source""#), "{got}");
    // …wired to its target by an index-keyed edge:
    assert!(got.contains(r#"["src_price.o0","0.i0"]"#), "{got}");
    // real nodes are index-keyed; the Recorder is a sink:
    assert!(got.contains(r#""role":"sink""#), "{got}");
}
  • Step 2: Run the test to verify it fails

Run: cargo test -p aura-engine graph_model::tests::scope_emits_index_keyed_nodes_synthetic_sources_and_edges Expected: FAIL — scope_json does not exist.

  • Step 3: Implement scope_json

Append to graph_model.rs:

/// The pieces of one scope: the `"key":node,…` entries of the nodes object and the
/// edge-JSON list, returned separately so a composite scope can extend `edges` with
/// its `@role`/`#N` boundary endpoints before formatting (Task 4). `is_root` mints a
/// synthetic source node (+ its edges) per bound input-role (`source.is_some()`).
fn scope_parts(c: &Composite, is_root: bool) -> (Vec<String>, Vec<String>) {
    let mut nodes: Vec<String> = Vec::new();
    for (i, n) in c.nodes().iter().enumerate() {
        let body = match n {
            BlueprintNode::Primitive(b) => prim_record(b),
            BlueprintNode::Composite(inner) => format!(r#"{{"comp":{}}}"#, json_str(inner.name())),
        };
        nodes.push(format!("{}:{}", json_str(&i.to_string()), body));
    }
    let mut edges: Vec<String> = c
        .edges()
        .iter()
        .map(|e| {
            format!(
                "[{},{}]",
                json_str(&format!("{}.o{}", e.from, e.from_field)),
                json_str(&format!("{}.i{}", e.to, e.slot)),
            )
        })
        .collect();
    if is_root {
        for r in c.input_roles().iter().filter(|r| r.source.is_some()) {
            let kind = r.source.unwrap();
            let key = format!("src_{}", r.name);
            nodes.push(format!(
                r#"{}:{{"prim":{{"type":{},"role":"source","params":[],"ins":[],"outs":[{}]}}}}"#,
                json_str(&key),
                json_str(&r.name),
                named_kind(&r.name, kind),
            ));
            for t in &r.targets {
                edges.push(format!(
                    "[{},{}]",
                    json_str(&format!("{key}.o0")),
                    json_str(&format!("{}.i{}", t.node, t.slot)),
                ));
            }
        }
    }
    (nodes, edges)
}

/// One scope as `{ "nodes": {…}, "edges": [...] }`.
fn scope_json(c: &Composite, is_root: bool) -> String {
    let (nodes, edges) = scope_parts(c, is_root);
    format!(r#"{{"nodes":{{{}}},"edges":[{}]}}"#, nodes.join(","), edges.join(","))
}
  • Step 4: Run the test to verify it passes

Run: cargo test -p aura-engine graph_model::tests::scope_emits_index_keyed_nodes_synthetic_sources_and_edges Expected: PASS.


Task 4: Composite collection + composite-def with @role / #N endpoints

Files:

  • Modify: crates/aura-engine/src/graph_model.rs

  • Step 1: Write the failing test for a composite definition

Add to the tests module:

#[test]
fn composite_def_carries_inputs_outputs_and_role_output_endpoints() {
    let root = sample_root();
    // the sma_cross interior composite is node 0:
    let inner = match &root.nodes()[0] { BlueprintNode::Composite(c) => c, _ => panic!() };
    let got = composite_def_json(inner);
    assert!(got.contains(r#""inputs":[["f64","any"]]"#) || got.contains(r#""inputs":[["f64""#), "{got}");
    // an interior input role becomes an @role endpoint:
    assert!(got.contains(r#""@price""#) || got.contains("@"), "{got}");
    // an output binding becomes a #N endpoint:
    assert!(got.contains(r#""#0""#), "{got}");
}

#[test]
fn distinct_composites_collected_once() {
    let root = sample_root();
    let model = model_to_json(&root);
    // sma_cross appears once as a key under "composites":
    assert_eq!(model.matches(r#""sma_cross":{"inputs""#).count(), 1, "{model}");
}
  • Step 2: Run the tests to verify they fail

Run: cargo test -p aura-engine graph_model::tests::composite_def_carries_inputs_outputs_and_role_output_endpoints Expected: FAIL — composite_def_json does not exist.

  • Step 3: Implement composite-def serialization + the distinct-composite walk

Append to graph_model.rs:

/// A composite's interior scope plus its boundary: `inputs` (each role's declared
/// kind+firing — the interior port it feeds), `outputs` (`[name, kind]` per
/// re-exported field), and the interior `nodes`/`edges` with `@role` / `#N`
/// endpoints folded onto the edge list.
fn composite_def_json(c: &Composite) -> String {
    // inputs: one port per input role; kind from role.source or the fed port.
    let inputs = join(c.input_roles().iter().map(|r| {
        let kind = r.source.unwrap_or(ScalarKind::F64); // interior roles: kind via the fed slot; fallback f64
        format!("[{},{}]", json_str(kind_str(kind)), json_str("any"))
    }));
    // outputs: [name, kind] per OutField. Kind from the producing node's field.
    let outputs = join(c.output().iter().map(|of| {
        let kind = field_kind(c, of.node, of.field).unwrap_or(ScalarKind::F64);
        named_kind(&of.name, kind)
    }));
    // interior scope (non-root: no synthetic sources), then extend the edge list
    // with the boundary endpoints: @role per role-target, #N per output binding.
    let (nodes, mut edges) = scope_parts(c, false);
    for r in c.input_roles() {
        for t in &r.targets {
            edges.push(format!(
                "[{},{}]",
                json_str(&format!("@{}", r.name)),
                json_str(&format!("{}.i{}", t.node, t.slot)),
            ));
        }
    }
    for (n, of) in c.output().iter().enumerate() {
        edges.push(format!(
            "[{},{}]",
            json_str(&format!("{}.o{}", of.node, of.field)),
            json_str(&format!("#{n}")),
        ));
    }
    format!(
        r#"{{"inputs":[{inputs}],"outputs":[{outputs}],"nodes":{{{}}},"edges":[{}]}}"#,
        nodes.join(","),
        edges.join(","),
    )
}

/// The scalar kind of node `node`'s output field `field`, read from its schema.
fn field_kind(c: &Composite, node: usize, field: usize) -> Option<ScalarKind> {
    match c.nodes().get(node)? {
        BlueprintNode::Primitive(b) => b.schema().output.get(field).map(|f| f.kind),
        BlueprintNode::Composite(inner) => {
            let of = inner.output().get(field)?;
            field_kind(inner, of.node, of.field)
        }
    }
}

/// Every distinct composite type in the blueprint, first-seen order, keyed by
/// name (mirrors aura-cli's `collect_distinct_composites`, graph.rs:173).
fn distinct_composites(root: &Composite) -> Vec<&Composite> {
    fn walk<'a>(items: &'a [BlueprintNode], seen: &mut Vec<&'a str>, out: &mut Vec<&'a Composite>) {
        for it in items {
            if let BlueprintNode::Composite(c) = it {
                if !seen.contains(&c.name()) {
                    seen.push(c.name());
                    out.push(c);
                    walk(c.nodes(), seen, out);
                }
            }
        }
    }
    let mut seen = Vec::new();
    let mut out = Vec::new();
    walk(root.nodes(), &mut seen, &mut out);
    out
}
  • Step 4: Run the tests to verify they pass

Run: cargo test -p aura-engine graph_model::tests::composite_def_carries_inputs_outputs_and_role_output_endpoints graph_model::tests::distinct_composites_collected_once Expected: PASS.


Task 5: Top-level model_to_json + byte golden + determinism

Files:

  • Modify: crates/aura-engine/src/graph_model.rs

  • Step 1: Implement the top-level assembly

Replace the placeholder model_to_json body with:

pub fn model_to_json(root: &Composite) -> String {
    let composites = join(distinct_composites(root).iter().map(|c| {
        format!("{}:{}", json_str(c.name()), composite_def_json(c))
    }));
    format!(
        r#"{{"root":{},"composites":{{{}}}}}"#,
        scope_json(root, true),
        composites,
    )
}
  • Step 2: Write the determinism test (fully specified)

Add to tests:

#[test]
fn model_is_deterministic() {
    let a = model_to_json(&sample_root());
    let b = model_to_json(&sample_root());
    assert_eq!(a, b);
}

Run: cargo test -p aura-engine graph_model::tests::model_is_deterministic Expected: PASS.

  • Step 3: Capture the byte golden

Run the serializer once and capture its exact output as the golden constant. Add a temporary print test:

#[test]
fn print_model() { println!("GOLDEN<<<{}>>>", model_to_json(&sample_root())); }

Run: cargo test -p aura-engine graph_model::tests::print_model -- --nocapture Expected: prints the model JSON between GOLDEN<<< and >>>. Copy that exact string.

  • Step 4: Write the byte-golden test with the captured string

Replace print_model with the golden assertion, pasting the captured bytes verbatim:

#[test]
fn model_golden() {
    // Re-capture via the print_model harness if an intended model-shape change lands.
    let expected = r##"<PASTE THE EXACT CAPTURED JSON STRING HERE>"##;
    assert_eq!(model_to_json(&sample_root()), expected);
}

Run: cargo test -p aura-engine graph_model::tests::model_golden Expected: PASS (the pasted string equals the freshly serialized model).


Task 6: Structural assertions + mis-wire-differs + read-only

Files:

  • Modify: crates/aura-engine/src/graph_model.rs

  • Step 1: Write the structural + mis-wire + read-only tests

Add to tests. The mis-wire fixture is a structural change (a different edge target), not a param swap — the model is pre-compile and param-generic, so a param swap is invisible to it (recon concern 3).

#[test]
fn sink_has_empty_outs() {
    // the Recorder node in the root is a sink with no outputs.
    assert!(model_to_json(&sample_root()).contains(r#""role":"sink","params":[],"ins":[["f64","any"]],"outs":[]"#),
            "{}", model_to_json(&sample_root()));
}

#[test]
fn multi_input_composite_has_two_inputs() {
    // a composite with two f64 input roles serialises exactly two ports in "inputs".
    let c = two_input_composite();              // local fixture: a composite with 2 f64 input roles
    let def = composite_def_json(&c);
    assert!(def.starts_with(r#"{"inputs":[["f64","any"],["f64","any"]],"#), "{def}");
}

#[test]
fn structural_miswire_changes_the_model() {
    let ok = model_to_json(&sample_root());
    let bad = model_to_json(&miswired_root());   // local fixture: same nodes, one edge re-targeted
    assert_ne!(ok, bad);
}

#[test]
fn serializer_is_read_only() {
    // Compile-time witness: model_to_json takes &Composite and returns String —
    // no &mut, no eval/compile/bootstrap on the path. This test documents the C9
    // contract; it passes by construction.
    fn assert_sig(f: fn(&Composite) -> String) { let _ = f; }
    assert_sig(model_to_json);
}

Implementer note: multi_input_composite_has_two_inputs should assert against the "inputs":[...] segment specifically (slice it out), not the whole def, so the count is unambiguous. two_input_composite() and miswired_root() are small local fixtures built with the same blueprint builder as sample_root().

  • Step 2: Run the new tests to verify they fail (where not yet satisfied)

Run: cargo test -p aura-engine graph_model::tests::structural_miswire_changes_the_model graph_model::tests::multi_input_composite_has_two_inputs Expected: FAIL initially if the fixtures are stubbed — implement the fixtures, then PASS.

  • Step 3: Implement the fixtures and make all tests pass

Build two_input_composite() and miswired_root() with the blueprint builder (mirroring sample_root). miswired_root = sample_root with one Edge's to/slot changed to a different valid target.

Run: cargo test -p aura-engine graph_model Expected: PASS — all graph_model tests green.

  • Step 4: Full workspace gate

Run: cargo test --workspace Expected: PASS — no regression in any crate (the old ascii-dag goldens are untouched; this iteration only adds a module). Then:

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: PASS — clean (remove any dead_code left from Task 1 by ensuring every helper is now used).