953d04a774
Composite gains doc: Option<String> (the prose twin of name, a C23 debug symbol): with_doc/doc() on Composite, a doc knob on GraphBuilder, a Tier-1 additive-optional CompositeData field (no format-version bump; absent-field documents keep their exact bytes), identity-stripped like the name. The graph model emits an optional trailing doc fragment in both scopes; json_str is hardened for free text (\n/\t/\r named, control chars as \u00XX — the doc is the first multi-line value through it). e2e: a hand-authored doc'd blueprint renders with the doc embedded; the doc moves the content id but never the identity id. refs #125
706 lines
29 KiB
Rust
706 lines
29 KiB
Rust
//! 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 <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 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: `["<kind>","<firing>","<name>"]`. 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<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)));
|
|
// 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<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)
|
|
}
|
|
|
|
/// `"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<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.
|
|
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<usize> {
|
|
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::<serde_json::Value>(&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) := <producer>` 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);
|
|
}
|
|
}
|