bbe2266b9e
Iteration 1 of the graph render redesign: a read-only model_to_json(&Composite) in a new crates/aura-engine/src/graph_model.rs, hand-rolled deterministic JSON (RunReport::to_json house style, no serde). Six tasks: helpers, primitive record, scope (index keys + synthetic source nodes from bound roles), composite defs (@role/#N endpoints, distinct-once walk), top-level + byte golden + determinism, structural assertions + structural-miswire-differs + read-only. Decisions: home in aura-engine; node keys are indices (C23); input ports carry kind+firing, no name (per acceptance criterion 3); bound root source-roles minted as synthetic source nodes.
646 lines
24 KiB
Markdown
646 lines
24 KiB
Markdown
# Iteration 1 — graph model serializer — Implementation Plan
|
|
|
|
> **Parent spec:** `docs/specs/0026-graph-render-redesign.md`
|
|
>
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: use the `implement` skill to run this
|
|
> plan. Steps use `- [ ]` checkboxes for tracking.
|
|
|
|
**Goal:** Add a read-only Rust serializer `model_to_json(&Composite) -> String` that
|
|
turns the harness root composite + every distinct composite type into the canonical,
|
|
deterministic JSON graph model the viewer (iteration 2) will consume.
|
|
|
|
**Architecture:** A new `crates/aura-engine/src/graph_model.rs`, hand-rolled
|
|
deterministic JSON in the `RunReport::to_json` house style (no serde). It walks the
|
|
blueprint via the existing read-only accessors and emits a model with two top-level
|
|
keys: `root` (the harness scope) and `composites` (each distinct composite type once).
|
|
Read-only (C9): the function takes `&Composite`, returns `String`, and never calls
|
|
`eval`/`compile`/`bootstrap`.
|
|
|
|
**Tech Stack:** aura-engine (`blueprint.rs` accessors, `harness::Edge`,
|
|
`report.rs` JSON idiom), aura-core (`NodeSchema`/`PortSpec`/`FieldSpec`/`ParamSpec`/
|
|
`Firing`/`ScalarKind`).
|
|
|
|
---
|
|
|
|
## Model shape (the contract this iteration produces)
|
|
|
|
Resolved against the **types** (recon) and spec acceptance criterion 3, where the
|
|
spec's abridged example was prototype-flavoured:
|
|
|
|
```
|
|
{
|
|
"root": <scope>,
|
|
"composites": { "<composite-name>": <composite-def>, ... } // first-seen order
|
|
}
|
|
```
|
|
|
|
- **scope** = `{ "nodes": { "<key>": <node>, ... }, "edges": [ <edge>, ... ] }`
|
|
- **composite-def** = `{ "inputs": [ <port>, ... ], "outputs": [ [name,kind], ... ],
|
|
"nodes": {...}, "edges": [...] }`
|
|
- **node** (one of):
|
|
- primitive: `{ "prim": { "type": <label>, "role": <role>, "params": [[name,kind],...],
|
|
"ins": [<port>,...], "outs": [[name,kind],...] } }`
|
|
- composite: `{ "comp": "<name>" }`
|
|
- **port** (an input) = `[kind, firing]` — `firing` is `"any"` or `"barrier <N>"`.
|
|
Input ports carry **no name** (`PortSpec` has none); kind + firing only (criterion 3,
|
|
"omit nothing").
|
|
- **role** = `"source"` | `"sink"` | `"node"`:
|
|
- `sink` = primitive whose `schema().output` is empty (C8 pure consumer).
|
|
- `source` = a **synthetic** node minted from a bound root input-role
|
|
(`Role.source.is_some()`); see below.
|
|
- `node` = any other primitive.
|
|
- **kind** = `"i64"` | `"f64"` | `"bool"` | `"timestamp"` (the `kind_str` lowercase
|
|
mapping; C4 palette keys off these exact strings).
|
|
- **key** = the node's **index** in `nodes()` as a string (`"0"`, `"1"`, …). The
|
|
blueprint has no unique node names (C23: identity is positional); the type label is
|
|
display-only and lives in `"type"`. Synthetic source nodes are keyed `"src_<role>"`.
|
|
- **edge** = `[ <from-endpoint>, <to-endpoint> ]`:
|
|
- `"<key>.o<field>"` — node `key` output field (from `Edge.from`/`from_field`).
|
|
- `"<key>.i<slot>"` — node `key` input slot (from `Edge.to`/`slot`, or a role/source
|
|
target's `slot`).
|
|
- `"@<rolename>"` — an interior composite **input role** (composite scopes only).
|
|
- `"#<N>"` — a composite **output binding**, N = index into `output()` (composite
|
|
scopes only).
|
|
- **Synthetic source nodes (root scope only).** Each bound root input-role
|
|
(`Role.source.is_some()`) becomes a node `"src_<role.name>"` =
|
|
`{ "prim": { "type": <role.name>, "role": "source", "params": [],
|
|
"ins": [], "outs": [[<role.name>, <kind>]] } }`, plus one edge
|
|
`["src_<role>.o0", "<target.node>.i<target.slot>"]` per target. Interior composite
|
|
input-roles stay as `"@<role>"` endpoints (rendered as boundary inputs when drilled).
|
|
|
|
Deterministic ordering: nodes in index order, synthetic sources appended in
|
|
`input_roles()` order; `composites` in first-seen order; every JSON object's keys in
|
|
the fixed literal order shown above (the `RunReport::to_json` discipline).
|
|
|
|
**Files this plan creates or modifies:**
|
|
|
|
- Create: `crates/aura-engine/src/graph_model.rs` — the serializer + its `#[cfg(test)]`
|
|
module (golden / determinism / mis-wire-differs / structural / read-only).
|
|
- Modify: `crates/aura-engine/src/lib.rs:40-47` — add `mod graph_model;` and
|
|
`pub use graph_model::model_to_json;`.
|
|
- Test: (in `graph_model.rs`) a local fixture builder + the six test cases.
|
|
|
|
---
|
|
|
|
## Task 1: Module skeleton, lib wiring, JSON + kind helpers
|
|
|
|
**Files:**
|
|
- Create: `crates/aura-engine/src/graph_model.rs`
|
|
- Modify: `crates/aura-engine/src/lib.rs:40-47`
|
|
|
|
- [ ] **Step 1: Create the module with the JSON-escape + kind helpers**
|
|
|
|
Create `crates/aura-engine/src/graph_model.rs`:
|
|
|
|
```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`. 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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Wire the module into the crate root**
|
|
|
|
In `crates/aura-engine/src/lib.rs`, next to the existing `mod blueprint; mod harness;
|
|
mod report;` declarations (around lines 40-42), add:
|
|
|
|
```rust
|
|
mod graph_model;
|
|
```
|
|
|
|
and in the `pub use` surface (next to the `pub use blueprint::{…}` block around lines
|
|
45-47) add:
|
|
|
|
```rust
|
|
pub use graph_model::model_to_json;
|
|
```
|
|
|
|
- [ ] **Step 3: Add a placeholder `model_to_json` so the crate compiles**
|
|
|
|
Append to `graph_model.rs`:
|
|
|
|
```rust
|
|
/// Serialize the harness root composite + every distinct composite type into the
|
|
/// canonical graph model (`{ "root": …, "composites": … }`). Read-only (C9).
|
|
pub fn model_to_json(root: &Composite) -> String {
|
|
let _ = root;
|
|
String::new() // filled in Task 5
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Build to verify the skeleton compiles**
|
|
|
|
Run: `cargo build -p aura-engine`
|
|
Expected: PASS (0 errors; `dead_code` warnings on the unused helpers are acceptable
|
|
until Task 2-5 consume them).
|
|
|
|
---
|
|
|
|
## Task 2: Serialize one primitive node (type, role, params, ins, outs)
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-engine/src/graph_model.rs`
|
|
|
|
- [ ] **Step 1: Write the failing test for a primitive record**
|
|
|
|
Append to a `#[cfg(test)] mod tests` block in `graph_model.rs`:
|
|
|
|
```rust
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use aura_core::{NodeSchema, ParamSpec, PortSpec, FieldSpec, PrimitiveBuilder, Node, Scalar};
|
|
|
|
/// 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 }
|
|
}
|
|
|
|
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),
|
|
)
|
|
}
|
|
|
|
#[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"]]}}"#
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::primitive_record_carries_type_role_ins_outs`
|
|
Expected: FAIL — `prim_record` does not exist (compile error).
|
|
|
|
- [ ] **Step 3: Implement `prim_record` + the role derivation**
|
|
|
|
Append to `graph_model.rs` (before the tests module):
|
|
|
|
```rust
|
|
/// 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_json`.)
|
|
fn prim_record(b: &crate::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),
|
|
)
|
|
}
|
|
```
|
|
|
|
(`PrimitiveBuilder` is re-exported from the crate root; reference it as
|
|
`crate::PrimitiveBuilder`. Confirm the import path against `lib.rs` — if it is only
|
|
`aura_core::PrimitiveBuilder`, use that.)
|
|
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::primitive_record_carries_type_role_ins_outs`
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
## Task 3: Serialize a scope (nodes map + edges) with synthetic source nodes
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-engine/src/graph_model.rs`
|
|
|
|
- [ ] **Step 1: Add a local harness fixture + the failing scope test**
|
|
|
|
Add to the `tests` module a fixture that builds a small **root** harness exercising a
|
|
bound source role, a composite reference, a plain node, and a sink. Use the blueprint
|
|
builder API as `build_sample()` (`aura-cli/src/main.rs:161`) and the engine's own
|
|
blueprint test fixtures (`blueprint.rs`) do — the implementer mirrors that builder
|
|
surface. The fixture must produce a `Composite` whose:
|
|
- `input_roles()` has one bound role `price` (`source: Some(ScalarKind::F64)`)
|
|
targeting node 0 slot 0,
|
|
- `nodes()` = `[ Composite("sma_cross"), Primitive(Exposure), Primitive(Recorder-sink) ]`,
|
|
- `edges()` wire them.
|
|
|
|
```rust
|
|
fn sample_root() -> Composite { /* build via the blueprint builder, mirroring
|
|
build_sample() in aura-cli/src/main.rs:161-190 and the fixtures in blueprint.rs */ }
|
|
|
|
#[test]
|
|
fn scope_emits_index_keyed_nodes_synthetic_sources_and_edges() {
|
|
let root = sample_root();
|
|
let got = scope_json(&root, /* is_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}");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the test to verify it fails**
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::scope_emits_index_keyed_nodes_synthetic_sources_and_edges`
|
|
Expected: FAIL — `scope_json` does not exist.
|
|
|
|
- [ ] **Step 3: Implement `scope_json`**
|
|
|
|
Append to `graph_model.rs`:
|
|
|
|
```rust
|
|
/// 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 (Task 4). `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(","))
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the test to verify it passes**
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::scope_emits_index_keyed_nodes_synthetic_sources_and_edges`
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
## Task 4: Composite collection + composite-def with @role / #N endpoints
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-engine/src/graph_model.rs`
|
|
|
|
- [ ] **Step 1: Write the failing test for a composite definition**
|
|
|
|
Add to the `tests` module:
|
|
|
|
```rust
|
|
#[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.contains(r#""inputs":[["f64""#), "{got}");
|
|
// an interior input role becomes an @role endpoint:
|
|
assert!(got.contains(r#""@price""#) || got.contains("@"), "{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}");
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Run the tests to verify they fail**
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::composite_def_carries_inputs_outputs_and_role_output_endpoints`
|
|
Expected: FAIL — `composite_def_json` does not exist.
|
|
|
|
- [ ] **Step 3: Implement composite-def serialization + the distinct-composite walk**
|
|
|
|
Append to `graph_model.rs`:
|
|
|
|
```rust
|
|
/// 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 role.source or the fed port.
|
|
let inputs = join(c.input_roles().iter().map(|r| {
|
|
let kind = r.source.unwrap_or(ScalarKind::F64); // interior roles: kind via the fed slot; fallback 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 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`, graph.rs:173).
|
|
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 {
|
|
if !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
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run the tests to verify they pass**
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::composite_def_carries_inputs_outputs_and_role_output_endpoints graph_model::tests::distinct_composites_collected_once`
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
## Task 5: Top-level `model_to_json` + byte golden + determinism
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-engine/src/graph_model.rs`
|
|
|
|
- [ ] **Step 1: Implement the top-level assembly**
|
|
|
|
Replace the placeholder `model_to_json` body with:
|
|
|
|
```rust
|
|
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,
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Write the determinism test (fully specified)**
|
|
|
|
Add to `tests`:
|
|
|
|
```rust
|
|
#[test]
|
|
fn model_is_deterministic() {
|
|
let a = model_to_json(&sample_root());
|
|
let b = model_to_json(&sample_root());
|
|
assert_eq!(a, b);
|
|
}
|
|
```
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::model_is_deterministic`
|
|
Expected: PASS.
|
|
|
|
- [ ] **Step 3: Capture the byte golden**
|
|
|
|
Run the serializer once and capture its exact output as the golden constant. Add a
|
|
temporary print test:
|
|
|
|
```rust
|
|
#[test]
|
|
fn print_model() { println!("GOLDEN<<<{}>>>", model_to_json(&sample_root())); }
|
|
```
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::print_model -- --nocapture`
|
|
Expected: prints the model JSON between `GOLDEN<<<` and `>>>`. Copy that exact string.
|
|
|
|
- [ ] **Step 4: Write the byte-golden test with the captured string**
|
|
|
|
Replace `print_model` with the golden assertion, pasting the captured bytes verbatim:
|
|
|
|
```rust
|
|
#[test]
|
|
fn model_golden() {
|
|
// Re-capture via the print_model harness if an intended model-shape change lands.
|
|
let expected = r##"<PASTE THE EXACT CAPTURED JSON STRING HERE>"##;
|
|
assert_eq!(model_to_json(&sample_root()), expected);
|
|
}
|
|
```
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::model_golden`
|
|
Expected: PASS (the pasted string equals the freshly serialized model).
|
|
|
|
---
|
|
|
|
## Task 6: Structural assertions + mis-wire-differs + read-only
|
|
|
|
**Files:**
|
|
- Modify: `crates/aura-engine/src/graph_model.rs`
|
|
|
|
- [ ] **Step 1: Write the structural + mis-wire + read-only tests**
|
|
|
|
Add to `tests`. The mis-wire fixture is a **structural** change (a different edge
|
|
target), not a param swap — the model is pre-compile and param-generic, so a param
|
|
swap is invisible to it (recon concern 3).
|
|
|
|
```rust
|
|
#[test]
|
|
fn sink_has_empty_outs() {
|
|
// the Recorder node in the root is a sink with no outputs.
|
|
assert!(model_to_json(&sample_root()).contains(r#""role":"sink","params":[],"ins":[["f64","any"]],"outs":[]"#),
|
|
"{}", model_to_json(&sample_root()));
|
|
}
|
|
|
|
#[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(); // local fixture: a composite with 2 f64 input roles
|
|
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()); // local fixture: same nodes, one edge re-targeted
|
|
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. This test documents the C9
|
|
// contract; it passes by construction.
|
|
fn assert_sig(f: fn(&Composite) -> String) { let _ = f; }
|
|
assert_sig(model_to_json);
|
|
}
|
|
```
|
|
|
|
> Implementer note: `multi_input_composite_has_two_inputs` should assert against the
|
|
> `"inputs":[...]` segment specifically (slice it out), not the whole def, so the
|
|
> count is unambiguous. `two_input_composite()` and `miswired_root()` are small local
|
|
> fixtures built with the same blueprint builder as `sample_root()`.
|
|
|
|
- [ ] **Step 2: Run the new tests to verify they fail (where not yet satisfied)**
|
|
|
|
Run: `cargo test -p aura-engine graph_model::tests::structural_miswire_changes_the_model graph_model::tests::multi_input_composite_has_two_inputs`
|
|
Expected: FAIL initially if the fixtures are stubbed — implement the fixtures, then PASS.
|
|
|
|
- [ ] **Step 3: Implement the fixtures and make all tests pass**
|
|
|
|
Build `two_input_composite()` and `miswired_root()` with the blueprint builder
|
|
(mirroring `sample_root`). `miswired_root` = `sample_root` with one `Edge`'s `to`/`slot`
|
|
changed to a different valid target.
|
|
|
|
Run: `cargo test -p aura-engine graph_model`
|
|
Expected: PASS — all `graph_model` tests green.
|
|
|
|
- [ ] **Step 4: Full workspace gate**
|
|
|
|
Run: `cargo test --workspace`
|
|
Expected: PASS — no regression in any crate (the old ascii-dag goldens are untouched;
|
|
this iteration only adds a module). Then:
|
|
|
|
Run: `cargo clippy --workspace --all-targets -- -D warnings`
|
|
Expected: PASS — clean (remove any `dead_code` left from Task 1 by ensuring every
|
|
helper is now used).
|