diff --git a/crates/aura-engine/src/graph_model.rs b/crates/aura-engine/src/graph_model.rs new file mode 100644 index 0000000..589ebd8 --- /dev/null +++ b/crates/aura-engine/src/graph_model.rs @@ -0,0 +1,463 @@ +//! 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 "`. +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 +} + +/// 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) -> 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))); + format!( + r#"{{"prim":{{"type":{},"role":{},"params":[{params}],"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. + 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")) + })); + // 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 (mirrors aura-cli's `collect_distinct_composites`). +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, ParamAlias, Role, Target}; + use aura_core::{ + FieldSpec, Node, NodeSchema, PortSpec, PrimitiveBuilder, Scalar, + }; + use aura_std::{Exposure, 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<&[Scalar]> { + 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 }, + PortSpec { kind: ScalarKind::F64, firing: Firing::Barrier(0) }, + ], + output: vec![FieldSpec { name: "diff", 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().into(), Sma::builder().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![ + ParamAlias { name: "fast".into(), node: 0, slot: 0 }, + ParamAlias { name: "slow".into(), node: 1, slot: 0 }, + ], + 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 (`Exposure`), and a + /// sink (`Recorder`, `output: vec![]`). Mirrors `build_sample()` + /// (aura-cli/src/main.rs:161-190); 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()), + Exposure::builder().into(), + Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx).into(), + ], + vec![ + Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure + 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![], // params: the interior sma_cross carries the aliases + 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![], + 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 `Exposure` 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()), + Exposure::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![], + 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"],["f64","barrier 0"]],"outs":[["diff","f64"]]}}"# + ); + } + + // -- 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"]]"#), "{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":"Exposure","role":"node","params":[["scale","f64"]],"ins":[["f64","any"]],"outs":[["exposure","f64"]]}},"2":{"prim":{"type":"Recorder","role":"sink","params":[],"ins":[["f64","any"]],"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"]],"outputs":[["out","f64"]],"nodes":{"0":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any"]],"outs":[["value","f64"]]}},"1":{"prim":{"type":"SMA","role":"node","params":[["length","i64"]],"ins":[["f64","any"]],"outs":[["value","f64"]]}},"2":{"prim":{"type":"Sub","role":"node","params":[],"ins":[["f64","any"],["f64","any"]],"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"]],"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"],["f64","any"]],"#), "{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); + } +} diff --git a/crates/aura-engine/src/lib.rs b/crates/aura-engine/src/lib.rs index de8f622..0c4e6a5 100644 --- a/crates/aura-engine/src/lib.rs +++ b/crates/aura-engine/src/lib.rs @@ -32,12 +32,14 @@ //! Visualization is never here: it is a downstream consumer node on the streams. mod blueprint; +mod graph_model; mod harness; mod report; pub use blueprint::{ aliases_on, signature_of, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role, }; +pub use graph_model::model_to_json; pub use harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target}; pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport}; // #29: re-export the core scalar vocabulary a Blueprint builder needs