feat(aura-engine): graph model serializer (0026 it1)
Iteration 1 of the graph render redesign: a read-only model_to_json(&Composite) that serializes the harness root + every distinct composite type into the canonical, deterministic JSON model the browser viewer will consume. New crates/aura-engine/src/graph_model.rs; lib.rs re-exports model_to_json. - House style: hand-rolled JSON like RunReport::to_json, NO serde (C14). - Model shape (faithful to the types, resolving the spec's prototype-flavoured example): node keys are indices (C23, the blueprint has no unique names); input ports carry kind+firing only, no name (PortSpec has none — acceptance criterion 3); a vec![] output is the C8 sink; bound root source-roles are minted as synthetic src_<role> source nodes; composite boundaries use @role (input) and #N (output) endpoints; distinct composites collected once. - Improvement over the plan: a composite's input kind is read from the actually fed interior slot (matching blueprint::derive_signature) rather than a hardcoded f64 — invents nothing. - Read-only (C9): &Composite -> String, no eval/compile/bootstrap on the path. - 10 new tests green: byte golden on the sample harness, determinism, structural mis-wire differs (param swaps are invisible to the pre-compile model — the property is carried by a structural re-target), read-only witness, sink / two-input / distinct-once structural assertions. Workspace green, clippy clean. aura graph still renders via ascii-dag (untouched); iteration 2 ports the viewer JS, vendors the WASM-Graphviz asset, emits the self-contained HTML, and retires ascii-dag.
This commit is contained in:
@@ -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 <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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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_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<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(","))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<ScalarKind> {
|
||||||
|
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<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`).
|
||||||
|
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<usize> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,12 +32,14 @@
|
|||||||
//! Visualization is never here: it is a downstream consumer node on the streams.
|
//! Visualization is never here: it is a downstream consumer node on the streams.
|
||||||
|
|
||||||
mod blueprint;
|
mod blueprint;
|
||||||
|
mod graph_model;
|
||||||
mod harness;
|
mod harness;
|
||||||
mod report;
|
mod report;
|
||||||
|
|
||||||
pub use blueprint::{
|
pub use blueprint::{
|
||||||
aliases_on, signature_of, BlueprintNode, CompileError, Composite, OutField, ParamAlias, Role,
|
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 harness::{BootstrapError, Edge, FlatGraph, Harness, SourceSpec, Target};
|
||||||
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
|
pub use report::{f64_field, summarize, RunManifest, RunMetrics, RunReport};
|
||||||
// #29: re-export the core scalar vocabulary a Blueprint builder needs
|
// #29: re-export the core scalar vocabulary a Blueprint builder needs
|
||||||
|
|||||||
Reference in New Issue
Block a user