//! 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 control /// characters (`\n`/`\t`/`\r` named, the rest as `\u00XX`) — the composite /// `doc` (#125) is free text and the first multi-line value through here; /// identifiers pass through unchanged. 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("\\\\"), '\n' => out.push_str("\\n"), '\t' => out.push_str("\\t"), '\r' => out.push_str("\\r"), c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), _ => 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) } /// `"gangs":[…]` fragment for a scope, or empty for a gang-free scope (the /// model stays byte-identical for existing graphs). Members are /// [node,pos,"param"] triples; the name/kind are display strings — the model /// is the C23 debug surface, so names are welcome here. `kind` renders in its /// serde/Debug form (`"I64"`, matching `Gang`'s own derive) rather than /// `kind_str`'s lowercase render — the gang table is a structural record, not /// a port/palette entry. fn gangs_fragment(c: &Composite) -> String { if c.gangs().is_empty() { return String::new(); } let items = join(c.gangs().iter().map(|g| { let members = join( g.members .iter() .map(|m| format!("[{},{},{}]", m.node, m.pos, json_str(&m.name))), ); format!( r#"{{"name":{},"kind":{},"members":[{members}]}}"#, json_str(&g.name), json_str(&format!("{:?}", g.kind)), ) })); format!(r#","gangs":[{items}]"#) } /// The optional trailing `,"doc":"…"` fragment (#125): emitted LAST in a /// scope/def object so no prefix-pinned consumer (starts_with/grep tests, /// the `window.AURA_MODEL = {"root":` page pin) moves; empty when absent. fn doc_fragment(doc: Option<&str>) -> String { doc.map(|d| format!(r#","doc":{}"#, json_str(d))).unwrap_or_default() } /// 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(","), gangs_fragment(c), doc_fragment(if is_root { c.doc() } else { None }), ) } /// 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(","), gangs_fragment(c), doc_fragment(c.doc()), ) } /// 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, Gang, GangMember, 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() }], ) .with_doc("fast/slow SMA spread; the Sub leg is the crossing signal") } /// 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, 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 ) .with_doc("sample harness: price feeds sma_cross; its spread becomes a recorded bias") } /// 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![], ) } /// A primitive producer whose output record carries TWO fields of DIFFERENT /// kinds (`hi`: f64, `lo`: i64) — the smallest interior node that can back more /// than one re-exported composite output field, with distinct per-field kinds so /// the `field_kind` lookup is meaningfully exercised (not two identical kinds). fn two_field_producer() -> PrimitiveBuilder { PrimitiveBuilder::new( "Split", NodeSchema { inputs: vec![], output: vec![ FieldSpec { name: "hi".into(), kind: ScalarKind::F64 }, FieldSpec { name: "lo".into(), kind: ScalarKind::I64 }, ], params: vec![], }, |_| Box::new(Bare), ) } /// A root harness whose sole interior node is a composite (`splitter`) that /// re-exports BOTH fields of one two-field producer (node 0). Nested in a root /// so `model_to_json` renders `splitter` through the `composite_def_json` path /// (which is what emits the `outputs` list and the `#N` boundary bindings). fn multi_outfield_root() -> Composite { let splitter = Composite::new( "splitter", vec![two_field_producer().into()], vec![], vec![], vec![ OutField { node: 0, field: 0, name: "hi".into() }, OutField { node: 0, field: 1, name: "lo".into() }, ], ); Composite::new( "root", vec![BlueprintNode::Composite(splitter)], vec![], vec![], vec![], ) } /// A composite with two SMA leaves whose `length` params are fused into one /// public knob (mirrors `blueprint::tests::with_gangs_accepts_two_open_siblings`) /// — the smallest fixture exercising the model's `gangs` render. fn ganged_fixture() -> Composite { Composite::new( "ganged", vec![ BlueprintNode::Primitive(Sma::builder().named("a")), BlueprintNode::Primitive(Sma::builder().named("b")), ], vec![], vec![], vec![], ) .with_gangs(vec![Gang { name: "length".into(), kind: ScalarKind::I64, members: vec![ GangMember { node: 0, pos: 0, name: "length".into() }, GangMember { node: 1, pos: 0, name: "length".into() }, ], }]) .expect("two open siblings gang") } // -- 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"]],"doc":"sample harness: price feeds sma_cross; its spread becomes a recorded bias"},"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"]],"doc":"fast/slow SMA spread; the Sub leg is the crossing signal"}}}"##; assert_eq!(model_to_json(&sample_root()), expected); } /// #125: the doc is free text — the first multi-line value through /// `json_str`. Control characters must emit as valid JSON escapes and the /// whole def must stay machine-parseable. #[test] fn doc_with_control_characters_emits_valid_json() { let c = sma_cross().with_doc("line1\nline2\ttabbed\u{1}ctl"); let def = composite_def_json(&c); assert!( def.contains(r#","doc":"line1\nline2\ttabbed\u0001ctl""#), "control chars escape: {def}" ); assert!( serde_json::from_str::(&def).is_ok(), "the def with a multi-line doc parses as JSON: {def}" ); } // -- 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}"); } /// Property: a single interior producer node that backs MORE THAN ONE /// re-exported output field is serialized as one boundary binding per field — /// each `outputs` entry carries that field's OWN kind (`hi`→f64, `lo`→i64, not a /// shared kind), and each `#N` binding endpoint references the SAME producer node /// at a DISTINCT field ordinal (`o0` vs `o1`). Guards the flat per-`OutField` /// model emission (`composite_def_json`) that replaced the retired textual /// `(n1, n2) := ` tuple-binding renderer (#47). #[test] fn multi_outfield_producer_emits_one_binding_per_field() { let model = model_to_json(&multi_outfield_root()); // both fields surface, each with its OWN per-field kind (f64 vs i64): assert!(model.contains(r#""outputs":[["hi","f64"],["lo","i64"]]"#), "{model}"); // both boundary bindings reference the SAME producer node (0) at distinct // field ordinals — o0 → #0 and o1 → #1: assert!(model.contains(r##"["0.o0","#0"]"##), "{model}"); assert!(model.contains(r##"["0.o1","#1"]"##), "{model}"); } #[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); } // -- Task 6 (#61): gang render ----------------------------------------------- /// A ganged scope's model record carries the gang relation (name, kind, /// [node,pos,"param"] members); a gang-free scope carries NO gangs key /// (the model_golden byte-pin stays untouched). #[test] fn model_carries_gangs_only_for_ganged_scopes() { let ganged = ganged_fixture(); // Task-2-style helper, built via with_gangs let json = model_to_json(&ganged); assert!( json.contains(r#""gangs":[{"name":"length","kind":"I64","members":[[0,0,"length"],[1,0,"length"]]}]"#), "{json}" ); assert!(!model_to_json(&sample_root()).contains(r#""gangs""#)); } #[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); } }