//! 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`. The model JSON is //! hand-rolled (no serde) — small, closed, deterministic (C14). Since cycle 0033 //! moved `RunReport::to_json` to serde, this is the engine's last hand-rolled //! JSON writer. use crate::{BlueprintNode, Composite}; use aura_core::{Firing, ScalarKind}; /// A scalar kind as the lowercase type string the model + C4 colour palette use. /// The `Debug` form is PascalCase, so this lowercase mapping is spelled out. fn kind_str(k: ScalarKind) -> &'static str { match k { ScalarKind::I64 => "i64", ScalarKind::F64 => "f64", ScalarKind::Bool => "bool", ScalarKind::Timestamp => "timestamp", } } /// A scalar VALUE as a canonical, deterministic string for the model (C14). `f64` /// uses the shortest round-trippable debug form, which keeps a decimal point on /// whole values (`1.0`, not `1`) so a bound f64 is never mistaken for an i64 in /// the render. The value side of `kind_str`. fn scalar_str(s: aura_core::Scalar) -> String { use aura_core::ScalarKind; match s.kind() { ScalarKind::I64 => s.as_i64().to_string(), ScalarKind::F64 => format!("{:?}", s.as_f64()), ScalarKind::Bool => s.as_bool().to_string(), ScalarKind::Timestamp => s.as_ts().0.to_string(), } } /// One input port's firing policy as a model string: `"any"` or `"barrier "`. 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 /// sufficient here — 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 } /// A `[name, kind]` JSON pair. fn named_kind(name: &str, kind: ScalarKind) -> String { format!("[{},{}]", json_str(name), json_str(kind_str(kind))) } /// One input port as a model tuple: `["","",""]`. The name is /// the port's non-load-bearing label (`PortSpec.name`); for a composite boundary /// it is the `Role.name` carried through `derive_signature`. fn port_json(p: &aura_core::PortSpec) -> String { format!( "[{},{},{}]", json_str(kind_str(p.kind)), json_str(&firing_str(p.firing)), json_str(&p.name) ) } /// Comma-join with no surrounding brackets. fn join(items: impl IntoIterator) -> String { items.into_iter().collect::>().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_parts`.) fn prim_record(b: &aura_core::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))); // explicit instance name only — an unnamed node carries no "name" field // (node_name()'s lowercased-type default would be a redundant type-duplicate) let name = match b.instance_name() { Some(n) => format!(r#""name":{},"#, json_str(n)), None => String::new(), }; // bound params: present only when the node has binds (mirrors the conditional // "name" field). each entry: [pos,"name","kind","value"]. let bound = if b.bound_params().is_empty() { String::new() } else { let items = join(b.bound_params().iter().map(|bp| { format!( "[{},{},{},{}]", bp.pos, json_str(&bp.name), json_str(kind_str(bp.kind)), json_str(&scalar_str(bp.value)), ) })); format!(r#","bound":[{items}]"#) }; format!( r#"{{"prim":{{{name}"type":{},"role":{},"params":[{params}]{bound},"ins":[{ins}],"outs":[{outs}]}}}}"#, json_str(&b.label()), json_str(role), ) } /// 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. `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, Vec) { let mut nodes: Vec = 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 = 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(",")) } /// 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 the interior slot it feeds, name // from the role (the boundary `Role.name`, mirroring `derive_signature`). let inputs = join(c.input_roles().iter().map(|r| { let kind = r .source .or_else(|| r.targets.first().and_then(|t| input_slot_kind(c, t.node, t.slot))) .unwrap_or(ScalarKind::F64); format!("[{},{},{}]", json_str(kind_str(kind)), json_str("any"), json_str(&r.name)) })); // 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 input slot `slot`, read from its signature. fn input_slot_kind(c: &Composite, node: usize, slot: usize) -> Option { c.nodes().get(node)?.signature().inputs.get(slot).map(|p| p.kind) } /// The scalar kind of node `node`'s output field `field`, read from its schema. fn field_kind(c: &Composite, node: usize, field: usize) -> Option { 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. 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 && !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 } /// Serialize the harness root composite + every distinct composite type into the /// canonical graph model (`{ "root": …, "composites": … }`). Read-only (C9): walks /// structure + declared schemas only, never `eval`/`compile`/`bootstrap`. 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, ) } #[cfg(test)] mod tests { use super::*; use crate::{Edge, OutField, Role, Target}; use aura_core::{ Cell, FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, }; use aura_std::{Bias, Recorder, Sma, Sub}; use std::sync::mpsc; /// A bare node whose only purpose is to back a `PrimitiveBuilder` in a test. struct Bare; impl Node for Bare { fn lookbacks(&self) -> Vec { vec![] } fn eval(&mut self, _c: aura_core::Ctx<'_>) -> Option<&[Cell]> { None } } /// A `Sub`-like builder with two f64 inputs (one `Any`, one `Barrier(0)`) and a /// single `diff` output — exercises the param/ins/outs serialization shapes. fn sub_builder() -> PrimitiveBuilder { PrimitiveBuilder::new( "Sub", NodeSchema { inputs: vec![ PortSpec { kind: ScalarKind::F64, firing: Firing::Any, name: "lhs".into() }, PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0), name: "rhs".into() }, ], output: vec![FieldSpec { name: "diff".into(), kind: ScalarKind::F64 }], params: vec![], }, |_| Box::new(Bare), ) } /// The SMA-cross signal as a reusable composite: one bound-free input role /// (`price`), one output (the fast-minus-slow spread). Mirrors aura-cli's /// `sma_cross` / the engine's `blueprint.rs` fixture. fn sma_cross() -> Composite { Composite::new( "sma_cross", vec![ Sma::builder().named("fast").into(), Sma::builder().named("slow").into(), Sub::builder().into(), ], vec![ Edge { from: 0, to: 2, slot: 0, from_field: 0 }, Edge { from: 1, to: 2, slot: 1, from_field: 0 }, ], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], source: None, }], vec![OutField { node: 2, field: 0, name: "out".into() }], ) } /// A small, stable root harness exercising the four model elements: a bound /// source role (`price`, → a synthetic source node), a composite reference /// (`sma_cross`, → a `composites` entry), a plain node (`Bias`), and a /// sink (`Recorder`, `output: vec![]`). A deliberately minimal serializer /// fixture, independent of the CLI's built-in sample (`build_sample` in /// aura-cli — a richer nested `signals` graph since cycle 0036), kept small so /// this golden stays stable; the Recorder receiver is dropped because the /// model serializer never runs the graph. fn sample_root() -> Composite { let (tx, _rx) = mpsc::channel(); Composite::new( "sample", vec![ BlueprintNode::Composite(sma_cross()), Bias::builder().into(), Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), ], vec![ Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Bias Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> sink ], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], // price -> sma_cross role 0 source: Some(ScalarKind::F64), }], vec![], // output: the root ends in a sink, no re-export ) } /// A composite with exactly two f64 input roles (each firing `Any`): two `Sma` /// leaves, role `a` → leaf 0, role `b` → leaf 1, re-exporting leaf 0. fn two_input_composite() -> Composite { Composite::new( "two_in", vec![Sma::builder().into(), Sma::builder().into()], vec![], vec![ Role { name: "a".into(), targets: vec![Target { node: 0, slot: 0 }], source: None }, Role { name: "b".into(), targets: vec![Target { node: 1, slot: 0 }], source: None }, ], vec![OutField { node: 0, field: 0, name: "v".into() }], ) } /// `sample_root` with one interior edge re-targeted to a different valid node: /// the `exposure -> sink` edge instead feeds the `Bias` node's own input /// slot (`to: 1`). Same node set, distinct wiring — the model must differ. fn miswired_root() -> Composite { let (tx, _rx) = mpsc::channel(); Composite::new( "sample", vec![ BlueprintNode::Composite(sma_cross()), Bias::builder().into(), Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), ], vec![ Edge { from: 0, to: 1, slot: 0, from_field: 0 }, Edge { from: 1, to: 1, slot: 0, from_field: 0 }, // re-targeted: -> node 1, not the sink ], vec![Role { name: "price".into(), targets: vec![Target { node: 0, slot: 0 }], source: Some(ScalarKind::F64), }], vec![], ) } // -- Task 2: primitive record ------------------------------------------------ #[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","lhs"],["f64","barrier 0","rhs"]],"outs":[["diff","f64"]]}}"# ); } #[test] fn prim_record_emits_name_for_explicit_only() { // explicitly named → a leading "name" field, ahead of "type" let named = prim_record(&sub_builder().named("fast")); assert!( named.starts_with(r#"{"prim":{"name":"fast","type":"Sub""#), "{named}" ); // unnamed → no "name" field at all let unnamed = prim_record(&sub_builder()); assert!(!unnamed.contains(r#""name""#), "{unnamed}"); } #[test] fn prim_record_emits_bound_only_when_bound() { // a bound i64 param → a "bound" field carrying [pos,"name","kind","value"] let bound = prim_record(&Sma::builder().bind("length", Scalar::i64(5))); assert!(bound.contains(r#""bound":[[0,"length","i64","5"]]"#), "{bound}"); // the bound slot also leaves the tunable "params" surface (bind's tuning side) assert!(bound.contains(r#""params":[]"#), "{bound}"); // a bound f64 value renders canonically (shortest round-trip), e.g. 0.5 let f = prim_record(&Bias::builder().bind("scale", Scalar::f64(0.5))); assert!(f.contains(r#""bound":[[0,"scale","f64","0.5"]]"#), "{f}"); // an unbound builder → no "bound" field at all let unbound = prim_record(&Sma::builder()); assert!(!unbound.contains(r#""bound""#), "{unbound}"); } // -- Task 3: scope (index keys + synthetic sources + edges) ------------------ #[test] fn scope_emits_index_keyed_nodes_synthetic_sources_and_edges() { let root = sample_root(); let got = scope_json(&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}"); } // -- Task 4: composite-def + distinct collection ----------------------------- #[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","price"]]"#), "{got}"); // an interior input role becomes an @role endpoint: assert!(got.contains(r#""@price""#), "{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}"); } // -- Task 5: top-level assembly + determinism + byte golden ------------------ #[test] fn model_is_deterministic() { let a = model_to_json(&sample_root()); let b = model_to_json(&sample_root()); assert_eq!(a, b); } #[test] fn model_golden() { // Re-capture if an intended model-shape change lands: temporarily add a // `println!("{}", model_to_json(&sample_root()))` test, run with // `-- --nocapture`, and paste the fresh bytes below. let expected = r##"{"root":{"nodes":{"0":{"comp":"sma_cross"},"1":{"prim":{"type":"Bias","role":"node","params":[["scale","f64"]],"ins":[["f64","any","signal"]],"outs":[["bias","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]}},"src_price":{"prim":{"type":"price","role":"source","params":[],"ins":[],"outs":[["price","f64"]]}}},"edges":[["0.o0","1.i0"],["1.o0","2.i0"],["src_price.o0","0.i0"]]},"composites":{"sma_cross":{"inputs":[["f64","any","price"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"name":"fast","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"1":{"prim":{"name":"slow","type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any","series"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any","lhs"],["f64","any","rhs"]],"outs":[["value","f64"]]}}},"edges":[["0.o0","2.i0"],["1.o0","2.i1"],["@price","0.i0"],["@price","1.i0"],["2.o0","#0"]]}}}"##; assert_eq!(model_to_json(&sample_root()), expected); } // -- Task 6: structural assertions + mis-wire-differs + read-only ------------ #[test] fn sink_has_empty_outs() { // the Recorder node in the root is a sink with no outputs. let model = model_to_json(&sample_root()); assert!( model.contains(r#""role":"sink","params":[],"ins":[["f64","any","col[0]"]],"outs":[]"#), "{model}" ); } #[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(); let def = composite_def_json(&c); assert!(def.starts_with(r#"{"inputs":[["f64","any","a"],["f64","any","b"]],"#), "{def}"); } #[test] fn structural_miswire_changes_the_model() { let ok = model_to_json(&sample_root()); let bad = model_to_json(&miswired_root()); 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. Documents the C9 contract; // passes by construction. fn assert_sig(f: fn(&Composite) -> String) { let _ = f; } assert_sig(model_to_json); } }