Files
Aura/docs/plans/0013-aura-graph-ascii-dag.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

30 KiB

aura graph ASCII-DAG render — Implementation Plan

Parent spec: docs/specs/0013-aura-graph-ascii-dag.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Render a wired graph as an ASCII DAG via an aura graph [--compiled] subcommand so a mis-wiring becomes visible, with composites drawn as named cluster boxes (blueprint view) or dissolved (compiled view).

Architecture: A non-load-bearing label() default method on the core Node trait (aura-core) lets each node describe itself in one line with its params; Composite gains an authored name; the engine exposes read-only graph-as-data accessors and stays dependency-free; the ascii-dag adapter and the aura graph subcommand live in aura-cli.

Tech Stack: aura-core (Node trait), aura-std (node label() overrides), aura-engine (blueprint.rs accessors + Composite.name), aura-cli (ascii-dag v0.9.1, new graph.rs), docs/design/INDEX.md (C8 refinement note).


Files this plan creates or modifies

  • Modify: crates/aura-core/src/node.rs:65-68 — add label() default method to trait Node.
  • Test: crates/aura-core/src/node.rs:70-82 — default-label unit test.
  • Modify: docs/design/INDEX.md (after line 238, before ### C9 at 240) — C8 refinement note.
  • Modify: crates/aura-std/src/sma.rs:22-46, sub.rs:27-47, exposure.rs:23-39, sim_broker.rs:59-85, add.rs:36-56, lincomb.rs:42-66, recorder.rs:31-58label() overrides.
  • Test: crates/aura-std/src/sma.rs:48 (tests module) — disambiguation test.
  • Modify: crates/aura-engine/src/blueprint.rsComposite.name field (54-59), widened Composite::new (64-71), accessors on Composite (61-90) and Blueprint (115-161), and the 7 Composite::new call sites (356, 433, 481, 513, 527, 541, 631).
  • Create: crates/aura-cli/src/graph.rs — the ascii-dag adapter (render_blueprint, render_flat_graph).
  • Modify: crates/aura-cli/Cargo.toml:12-15 — add ascii-dag = "0.9.1".
  • Modify: crates/aura-cli/src/main.rs — imports (9-14), mod graph;, sample builders, graph dispatch arm + usage strings (114-126). sample_harness (42-78) and run_sample (83-112) are NOT touched.
  • Test: crates/aura-cli/src/main.rs:128-168 (tests module) — render tests.

Task 1: Node::label() default method + C8 ledger refinement

Files:

  • Modify: crates/aura-core/src/node.rs:65-68

  • Test: crates/aura-core/src/node.rs:70-82

  • Modify: docs/design/INDEX.md (after line 238)

  • Step 1: Write the failing test

In crates/aura-core/src/node.rs, inside the existing #[cfg(test)] mod tests block (currently lines 70-82), add this test after input_spec_carries_firing:

    #[test]
    fn default_label_is_placeholder() {
        // A node that does not override label() falls back to the placeholder.
        struct Bare;
        impl Node for Bare {
            fn schema(&self) -> NodeSchema {
                NodeSchema { inputs: vec![], output: vec![] }
            }
            fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> {
                None
            }
        }
        assert_eq!(Bare.label(), "node");
    }
  • Step 2: Run test to verify it fails

Run: cargo test -p aura-core default_label_is_placeholder Expected: FAIL — compile error no method named 'label' found for struct 'Bare' (the default method does not exist yet).

  • Step 3: Add the label() default method

In crates/aura-core/src/node.rs, change the trait Node body (lines 65-68) from:

pub trait Node {
    fn schema(&self) -> NodeSchema;
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>;
}

to:

pub trait Node {
    fn schema(&self) -> NodeSchema;
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>;
    /// A one-line, **non-load-bearing** render label (C23): a debug symbol for
    /// tracing / graph rendering (#13), never read by the run loop and never
    /// part of wiring (which is by index). Overrides SHOULD carry the node's
    /// identifying params so identical node types disambiguate (`SMA(2)` vs
    /// `SMA(4)`). MUST be single-line (no `\n`): ascii-dag breaks box drawing on
    /// a multiline label. The default is a placeholder; every shipped node
    /// overrides it. Returns an owned `String` and takes `&self`, so `Node`
    /// stays object-safe and `Box<dyn Node>::label()` dispatches.
    fn label(&self) -> String {
        "node".to_string()
    }
}
  • Step 4: Run test to verify it passes

Run: cargo test -p aura-core default_label_is_placeholder Expected: PASS.

  • Step 5: Append the C8 refinement note to the ledger

In docs/design/INDEX.md, immediately after the cycle-0006 realization paragraph that ends at line 238 (and before ### C9 at line 240), insert:

**Refinement (Construction-layer milestone — render labels, 2026-06-05).** A node
additionally exposes `label() -> String`, a **single-line, non-load-bearing**
render symbol: a default trait method the run loop never calls (wiring is by
index, C23). Overrides carry the node's identifying params (`SMA(2)` vs `SMA(4)`)
so a graph render (C9 graph-as-data, #13) disambiguates identical node types and
surfaces a mis-wiring. Like `FieldSpec.name`, it is an informative debug symbol,
not part of the C8 dataflow contract — adding it changes no run behaviour.
  • Step 6: Verify the workspace still builds (default method breaks no caller)

Run: cargo build --workspace Expected: builds clean (the defaulted method adds no obligation to existing impl Nodes).


Task 2: aura-std label() overrides + disambiguation test

Files:

  • Modify: crates/aura-std/src/{sma,sub,exposure,sim_broker,add,lincomb,recorder}.rs

  • Test: crates/aura-std/src/sma.rs:48 (tests module)

  • Step 1: Write the failing disambiguation test

In crates/aura-std/src/sma.rs, inside the existing #[cfg(test)] mod tests (line 48), add:

    #[test]
    fn labels_carry_identifying_params() {
        use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
        use aura_core::{Firing, ScalarKind};

        // the load-bearing payoff: two SMAs disambiguate by window
        assert_eq!(Sma::new(2).label(), "SMA(2)");
        assert_eq!(Sma::new(4).label(), "SMA(4)");
        // param-carrying single nodes
        assert_eq!(Exposure::new(0.5).label(), "Exposure(0.5)");
        assert_eq!(SimBroker::new(0.0001).label(), "SimBroker(0.0001)");
        // bare-kind nodes (identity is not a mis-wiring axis here, per spec)
        assert_eq!(Sub::new().label(), "Sub");
        assert_eq!(Add::new().label(), "Add");
        assert_eq!(LinComb::new(&[1.0, -1.0]).label(), "LinComb");
        let (tx, _rx) = std::sync::mpsc::channel();
        assert_eq!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).label(), "Recorder");
    }
  • Step 2: Run test to verify it fails

Run: cargo test -p aura-std labels_carry_identifying_params Expected: FAIL — assertions fail because every node currently returns the default "node" (e.g. assert_eq!(Sma::new(2).label(), "SMA(2)") sees "node").

If LinComb::new does not take &[f64], read crates/aura-std/src/lincomb.rs for its real constructor signature and adjust the call in this test to construct a valid LinComb; the asserted label "LinComb" is unchanged.

  • Step 3: Add the label() override to each node

In each file, add the label() method as the last method inside the impl Node for <T> block:

crates/aura-std/src/sma.rs (impl at 22-46, field length at line 10):

    fn label(&self) -> String {
        format!("SMA({})", self.length)
    }

crates/aura-std/src/sub.rs (impl at 27-47):

    fn label(&self) -> String {
        "Sub".to_string()
    }

crates/aura-std/src/exposure.rs (impl at 23-39, field scale at line 11):

    fn label(&self) -> String {
        format!("Exposure({})", self.scale)
    }

crates/aura-std/src/sim_broker.rs (impl at 59-85, field pip_size at line 37):

    fn label(&self) -> String {
        format!("SimBroker({})", self.pip_size)
    }

crates/aura-std/src/add.rs (impl at 36-56):

    fn label(&self) -> String {
        "Add".to_string()
    }

crates/aura-std/src/lincomb.rs (impl at 42-66):

    fn label(&self) -> String {
        "LinComb".to_string()
    }

crates/aura-std/src/recorder.rs (impl at 31-58):

    fn label(&self) -> String {
        "Recorder".to_string()
    }
  • Step 4: Run test to verify it passes

Run: cargo test -p aura-std labels_carry_identifying_params Expected: PASS.

  • Step 5: Verify the crate builds clean

Run: cargo build -p aura-std Expected: builds clean.


Task 3: Composite.name + introspection accessors + thread the 7 call sites

Files:

  • Modify: crates/aura-engine/src/blueprint.rs (struct 54-59, new 64-71, impl Composite 61-90, impl Blueprint 115-161, call sites 356/433/481/513/527/541/631)

  • Step 1: Add the name field to Composite

In crates/aura-engine/src/blueprint.rs, change the Composite struct (lines 54-59) from:

pub struct Composite {
    nodes: Vec<BlueprintNode>,
    edges: Vec<Edge>,
    input_roles: Vec<Vec<Target>>,
    output: OutPort,
}

to:

pub struct Composite {
    name: String,
    nodes: Vec<BlueprintNode>,
    edges: Vec<Edge>,
    input_roles: Vec<Vec<Target>>,
    output: OutPort,
}
  • Step 2: Widen Composite::new to take a name

Change Composite::new (lines 64-71) from:

    pub fn new(
        nodes: Vec<BlueprintNode>,
        edges: Vec<Edge>,
        input_roles: Vec<Vec<Target>>,
        output: OutPort,
    ) -> Self {
        Self { nodes, edges, input_roles, output }
    }

to:

    /// Build a composite from its authored name, interior items, interior edges
    /// (local indices), input roles, and output port. The `name` is a
    /// non-load-bearing render symbol (the cluster title for #13); it does not
    /// reach the flat graph (the boundary dissolves at inline, C23).
    pub fn new(
        name: impl Into<String>,
        nodes: Vec<BlueprintNode>,
        edges: Vec<Edge>,
        input_roles: Vec<Vec<Target>>,
        output: OutPort,
    ) -> Self {
        Self { name: name.into(), nodes, edges, input_roles, output }
    }
  • Step 3: Add read-only accessors to Composite

In the impl Composite block (61-90), add these methods (e.g. after new, before schema):

    /// The authored render name (cluster title, #13). Non-load-bearing.
    pub fn name(&self) -> &str {
        &self.name
    }
    /// The interior blueprint items (read-only graph-as-data, C9).
    pub fn nodes(&self) -> &[BlueprintNode] {
        &self.nodes
    }
    /// The interior edges (local indices).
    pub fn edges(&self) -> &[Edge] {
        &self.edges
    }
    /// The input roles: role `r` fans into `input_roles()[r]` interior targets.
    pub fn input_roles(&self) -> &[Vec<Target>] {
        &self.input_roles
    }
    /// The single exposed output port.
    pub fn output(&self) -> OutPort {
        self.output
    }
  • Step 4: Add read-only accessors to Blueprint

In the impl Blueprint block (115-161), add (e.g. after new at 118):

    /// The top-level blueprint items (read-only graph-as-data, C9).
    pub fn nodes(&self) -> &[BlueprintNode] {
        &self.nodes
    }
    /// The declared sources.
    pub fn sources(&self) -> &[SourceSpec] {
        &self.sources
    }
    /// The top-level edges (blueprint-level indices).
    pub fn edges(&self) -> &[Edge] {
        &self.edges
    }
  • Step 5: Thread a name into all 7 Composite::new call sites

All 7 are inside the #[cfg(test)] mod tests block. Add a leading string argument to each:

  • Line 356 (composite_schema_derives_role_and_output_kinds): Composite::new(Composite::new(\n "c",
  • Line 433 (fan_composite() helper): first arg "fan".
  • Line 481 (outer in nested_composite_inlines): first arg "outer".
  • Line 513 (bad_interior_index_rejected): first arg "c".
  • Line 527 (role_kind_mismatch_rejected): first arg "c".
  • Line 541 (output_port_out_of_range_rejected): first arg "c".
  • Line 631 (sma_cross(fast, slow) helper): first arg "sma_cross".

Concretely, e.g. for fan_composite (433):

    fn fan_composite() -> Composite {
        Composite::new(
            "fan",
            vec![pass1(), pass1(), join2()],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                Edge { from: 1, to: 2, slot: 1, from_field: 0 },
            ],
            vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
            OutPort { node: 2, field: 0 },
        )
    }

and for sma_cross (631):

    fn sma_cross(fast: usize, slow: usize) -> Composite {
        Composite::new(
            "sma_cross",
            vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
            vec![
                Edge { from: 0, to: 2, slot: 0, from_field: 0 },
                Edge { from: 1, to: 2, slot: 1, from_field: 0 },
            ],
            vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
            OutPort { node: 2, field: 0 },
        )
    }

Apply the same one-line leading-argument insertion at 356, 481, 513, 527, 541.

  • Step 6: Run the engine tests (all Composite::new sites threaded, accessors compile)

Run: cargo test -p aura-engine Expected: PASS — all existing blueprint tests (including composite_sma_cross_runs_bit_identical_to_hand_wired, single_composite_inlines_with_offset_fan_and_output, nested_composite_inlines) stay green; the crate compiles with the widened signature.

  • Step 7: Confirm the engine took no external dependency

Run: grep -nE '^\s*(ascii|[a-z].*=.*version|[a-z].*=.*")' crates/aura-engine/Cargo.toml Expected: only the aura-core path dependency (and aura-std under [dev-dependencies]); no ascii-dag, no crates.io version dep. (C16 preserved.)


Task 4: aura-cli — ascii-dag dep, graph.rs adapter, sample builder, dispatch

Files:

  • Modify: crates/aura-cli/Cargo.toml:12-15

  • Create: crates/aura-cli/src/graph.rs

  • Modify: crates/aura-cli/src/main.rs (imports 9-14, mod graph;, sample builders, dispatch 114-126)

  • Step 1: Add the ascii-dag dependency

In crates/aura-cli/Cargo.toml, under [dependencies] (currently the three path deps at 12-15), add:

ascii-dag = "0.9.1"
  • Step 2: Create the adapter module crates/aura-cli/src/graph.rs
//! The `aura graph` ASCII-DAG adapter (#13): turns the engine's graph-as-data
//! (C9) into an `ascii_dag::Graph` rendered to a `String`. Two views:
//! `render_blueprint` draws composites as named cluster boxes (pre-inline);
//! `render_flat_graph` draws the flat post-inline graph (boundaries dissolved,
//! C23). Rendering reads structure + node `label()`s only — never `eval`.
//!
//! ascii-dag borrows its node/subgraph labels as `&'a str`, so each function
//! first materializes the owned label `String`s (which outlive the `Graph`),
//! then borrows into them. `RenderMode::Vertical` is mandatory: Horizontal
//! collapses a fan-out onto one path.

use ascii_dag::graph::{Graph, RenderMode};
use aura_core::Node;
use aura_engine::{Blueprint, BlueprintNode, Edge, SourceSpec, Target};

/// How one top-level blueprint item maps into display nodes (blueprint view).
enum ItemDisplay {
    /// A leaf is one display node at this id.
    Leaf(usize),
    /// A composite is a cluster of interior leaf display ids; its output port and
    /// input roles resolve edges crossing its boundary.
    Composite {
        interior_ids: Vec<usize>,
        output_interior: usize,
        input_roles: Vec<Vec<Target>>,
    },
}

/// The display id an edge *from* this item originates at (its producer).
fn producer_id(d: &ItemDisplay) -> usize {
    match d {
        ItemDisplay::Leaf(id) => *id,
        ItemDisplay::Composite { interior_ids, output_interior, .. } => interior_ids[*output_interior],
    }
}

/// The display id(s) an edge *into* this item at `slot` reaches (its consumers).
/// A composite input role fans into several interior targets.
fn consumer_ids(d: &ItemDisplay, slot: usize) -> Vec<usize> {
    match d {
        ItemDisplay::Leaf(id) => vec![*id],
        ItemDisplay::Composite { interior_ids, input_roles, .. } => {
            input_roles[slot].iter().map(|t| interior_ids[t.node]).collect()
        }
    }
}

/// Blueprint view: composites become labelled cluster boxes (pre-inline, C9).
pub fn render_blueprint(bp: &Blueprint) -> String {
    let mut labels: Vec<String> = Vec::new();
    let mut sg_names: Vec<String> = Vec::new();
    let mut memberships: Vec<(usize, Vec<usize>)> = Vec::new();
    let mut edges: Vec<(usize, usize)> = Vec::new();
    let mut item_display: Vec<ItemDisplay> = Vec::with_capacity(bp.nodes().len());

    // pass 1: assign display ids + labels; open a subgraph per composite; record
    // interior edges (the interior ids are in hand here).
    for item in bp.nodes() {
        match item {
            BlueprintNode::Leaf(node) => {
                let id = labels.len();
                labels.push(node.label());
                item_display.push(ItemDisplay::Leaf(id));
            }
            BlueprintNode::Composite(c) => {
                let mut interior_ids = Vec::with_capacity(c.nodes().len());
                for inner in c.nodes() {
                    match inner {
                        BlueprintNode::Leaf(node) => {
                            let id = labels.len();
                            labels.push(node.label());
                            interior_ids.push(id);
                        }
                        BlueprintNode::Composite(_) => unimplemented!(
                            "cycle 0013 renders leaf-interior composites (the built-in \
                             sample); nested-composite cluster rendering is a follow-up"
                        ),
                    }
                }
                for e in c.edges() {
                    edges.push((interior_ids[e.from], interior_ids[e.to]));
                }
                let sg = sg_names.len();
                sg_names.push(c.name().to_string());
                memberships.push((sg, interior_ids.clone()));
                item_display.push(ItemDisplay::Composite {
                    interior_ids,
                    output_interior: c.output().node,
                    input_roles: c.input_roles().to_vec(),
                });
            }
        }
    }

    // sources as producer display nodes (unnamed in the data model -> label by kind)
    let mut source_ids: Vec<usize> = Vec::with_capacity(bp.sources().len());
    for src in bp.sources() {
        let id = labels.len();
        labels.push(format!("source:{:?}", src.kind));
        source_ids.push(id);
    }

    // top-level edges, resolved through composite boundaries
    for e in bp.edges() {
        let from = producer_id(&item_display[e.from]);
        for to in consumer_ids(&item_display[e.to], e.slot) {
            edges.push((from, to));
        }
    }
    // source -> target edges
    for (src, &sid) in bp.sources().iter().zip(&source_ids) {
        for t in &src.targets {
            for to in consumer_ids(&item_display[t.node], t.slot) {
                edges.push((sid, to));
            }
        }
    }

    // build the borrowed-label Graph (labels + sg_names are final & owned)
    let mut g = Graph::with_mode(RenderMode::Vertical);
    for (id, l) in labels.iter().enumerate() {
        g.add_node(id, l);
    }
    let sg_ids: Vec<usize> = sg_names.iter().map(|n| g.add_subgraph(n)).collect();
    for (sg, members) in &memberships {
        g.put_nodes(members).inside(sg_ids[*sg]).expect("valid subgraph placement");
    }
    for (from, to) in edges {
        g.add_edge(from, to, None);
    }
    g.render()
}

/// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved,
/// C23). Each `Box<dyn Node>` labels itself; node display id = node index.
pub fn render_flat_graph(nodes: &[Box<dyn Node>], sources: &[SourceSpec], edges: &[Edge]) -> String {
    let mut labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
    let source_base = labels.len();
    for src in sources {
        labels.push(format!("source:{:?}", src.kind));
    }

    let mut g = Graph::with_mode(RenderMode::Vertical);
    for (id, l) in labels.iter().enumerate() {
        g.add_node(id, l);
    }
    for e in edges {
        g.add_edge(e.from, e.to, None);
    }
    for (i, src) in sources.iter().enumerate() {
        for t in &src.targets {
            g.add_edge(source_base + i, t.node, None);
        }
    }
    g.render()
}
  • Step 3: Declare the module and widen the imports in main.rs

At the top of crates/aura-cli/src/main.rs, after the doc comment and before the use block, add:

mod graph;

Then change the aura_engine import (lines 10-12) to add the blueprint types:

use aura_engine::{
    f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort,
    RunManifest, RunReport, SourceSpec, Target,
};
  • Step 4: Add the sample-blueprint builders to main.rs

Add these free functions (e.g. just below run_sample at line 112, leaving sample_harness/run_sample untouched):

/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
/// CLI-local sample builder; the engine ships no sample (the duplication with
/// `blueprint.rs`'s test helper is the dedup tracked in #14).
fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite {
    Composite::new(
        name,
        vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()],
        vec![
            Edge { from: 0, to: 2, slot: 0, from_field: 0 },
            Edge { from: 1, to: 2, slot: 1, from_field: 0 },
        ],
        vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
        OutPort { node: 2, field: 0 },
    )
}

/// The sample signal-quality blueprint, parameterized by the SMA windows so a
/// test can author a deliberately swapped variant. Recorders need a channel to
/// construct; the receivers are dropped because the render never runs the graph.
fn build_sample(fast: usize, slow: usize) -> Blueprint {
    let (tx_eq, _rx_eq) = mpsc::channel();
    let (tx_ex, _rx_ex) = mpsc::channel();
    Blueprint::new(
        vec![
            BlueprintNode::Composite(sma_cross("sma_cross", fast, slow)),
            Exposure::new(0.5).into(),
            SimBroker::new(0.0001).into(),
            Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(),
            Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(),
        ],
        vec![SourceSpec {
            kind: ScalarKind::F64,
            targets: vec![
                Target { node: 0, slot: 0 }, // price -> sma_cross role 0
                Target { node: 2, slot: 1 }, // price -> SimBroker price slot
            ],
        }],
        vec![
            Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread   -> Exposure
            Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
            Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity   -> sink
            Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
        ],
    )
}

/// The built-in sample rendered by `aura graph`.
fn sample_blueprint() -> Blueprint {
    build_sample(2, 4)
}
  • Step 5: Add the graph dispatch arm + usage strings

Change the main() match block (lines 114-126) from:

    match args.next().as_deref() {
        // strict: a bare `run` proceeds; a trailing token falls through to the
        // usage-error path rather than masquerading as a successful run (#16).
        Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
        Some("--help") | Some("-h") => println!("usage: aura run"),
        _ => {
            eprintln!("aura: usage: aura run");
            std::process::exit(2);
        }
    }

to:

    match args.next().as_deref() {
        // strict: a bare `run` proceeds; a trailing token falls through to the
        // usage-error path rather than masquerading as a successful run (#16).
        Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
        Some("graph") => {
            // `--compiled` selects the flat post-inline view; default is the
            // clustered blueprint view. Strictness beyond this stays minimal (#16).
            let compiled = args.next().as_deref() == Some("--compiled");
            let bp = sample_blueprint();
            let out = if compiled {
                let (nodes, sources, edges) = bp.compile().expect("valid sample blueprint");
                graph::render_flat_graph(&nodes, &sources, &edges)
            } else {
                graph::render_blueprint(&bp)
            };
            println!("{out}");
        }
        Some("--help") | Some("-h") => println!("usage: aura run | aura graph [--compiled]"),
        _ => {
            eprintln!("aura: usage: aura run | aura graph [--compiled]");
            std::process::exit(2);
        }
    }
  • Step 6: Build aura-cli and smoke-test both views

Run: cargo build -p aura-cli Expected: builds clean (downloads/compiles ascii-dag v0.9.1 on first build).

Run: cargo run -q -p aura-cli -- graph Expected: prints an ASCII DAG to stdout containing sma_cross, SMA(2), SMA(4), Sub, Exposure(0.5), SimBroker(0.0001), Recorder.

Run: cargo run -q -p aura-cli -- graph --compiled Expected: prints an ASCII DAG containing SMA(2)/SMA(4) but NOT sma_cross (boundary dissolved).


Task 5: render tests (concern-defining + structure pins + frozen golden)

Files:

  • Test: crates/aura-cli/src/main.rs:128-168 (tests module)

  • Step 1: Add the swapped-variant builder (test-only) and the failing tests

In crates/aura-cli/src/main.rs, inside the existing #[cfg(test)] mod tests (128-168), add the test-only swapped builder and the render tests:

    /// The sample authored with fast/slow SMA windows swapped — the mis-wiring
    /// the render must surface. Test-only: nothing outside tests builds it.
    fn sample_blueprint_swapped() -> Blueprint {
        build_sample(4, 2)
    }

    #[test]
    fn blueprint_view_shows_cluster_and_param_labels() {
        let out = graph::render_blueprint(&sample_blueprint());
        // the composite renders as a named cluster box
        assert!(out.contains("sma_cross"), "missing composite name:\n{out}");
        // param-carrying labels disambiguate the two SMAs
        assert!(out.contains("SMA(2)"), "missing SMA(2):\n{out}");
        assert!(out.contains("SMA(4)"), "missing SMA(4):\n{out}");
        for needle in ["Sub", "Exposure(0.5)", "SimBroker(0.0001)", "Recorder"] {
            assert!(out.contains(needle), "missing {needle}:\n{out}");
        }
    }

    #[test]
    fn compiled_view_dissolves_the_composite_boundary() {
        let bp = sample_blueprint();
        let (nodes, sources, edges) = bp.compile().expect("valid sample");
        let out = graph::render_flat_graph(&nodes, &sources, &edges);
        // node labels survive inlining...
        assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}");
        // ...but the composite cluster name does NOT (boundary dissolved, C23)
        assert!(!out.contains("sma_cross"), "compiled view must not show the cluster:\n{out}");
    }

    #[test]
    fn swapped_sma_inputs_render_differently() {
        // the property the cycle exists to buy: a mis-wiring is no longer invisible.
        let correct = graph::render_blueprint(&sample_blueprint());
        let swapped = graph::render_blueprint(&sample_blueprint_swapped());
        assert_ne!(correct, swapped, "a fast/slow SMA swap must change the render");
    }
  • Step 2: Run the tests to verify they pass

Run: cargo test -p aura-cli blueprint_view_shows_cluster_and_param_labels compiled_view_dissolves_the_composite_boundary swapped_sma_inputs_render_differently Expected: PASS (all three). (render_blueprint/render_flat_graph/sample_blueprint already exist from Task 4; this task only adds tests + the test-only swapped builder.)

  • Step 3: Freeze the full-byte golden snapshots

The exact rendered bytes are ascii-dag's deterministic Sugiyama layout — capture them from the now-green render rather than hand-authoring them.

Run: cargo run -q -p aura-cli -- graph Copy the exact stdout. Add this test to the same mod tests, pasting the captured bytes as the EXPECTED literal (use a raw string r#"..."# if the output contains "; preserve a trailing newline if render() emits one):

    #[test]
    fn blueprint_view_golden() {
        let out = graph::render_blueprint(&sample_blueprint());
        let expected = "<<paste the exact captured `aura graph` stdout here>>";
        assert_eq!(out, expected, "blueprint render drifted; re-capture if intended");
    }

Then run: cargo run -q -p aura-cli -- graph --compiled, and add the twin:

    #[test]
    fn compiled_view_golden() {
        let bp = sample_blueprint();
        let (nodes, sources, edges) = bp.compile().expect("valid sample");
        let out = graph::render_flat_graph(&nodes, &sources, &edges);
        let expected = "<<paste the exact captured `aura graph --compiled` stdout here>>";
        assert_eq!(out, expected, "compiled render drifted; re-capture if intended");
    }

The <<paste ...>> markers are filled with the captured deterministic output in this step — they are a capture instruction, not shipped code. Do NOT commit the test with the marker text still in place; the test must hold the real bytes and go green.

  • Step 4: Run the golden tests to verify they pass

Run: cargo test -p aura-cli blueprint_view_golden compiled_view_golden Expected: PASS (the literals match the captured render; determinism makes this stable).

  • Step 5: Full workspace gate

Run: cargo test --workspace Expected: PASS — all tests across aura-core/std/engine/cli green, including the pre-existing non-regression tests composite_sma_cross_runs_bit_identical_to_hand_wired and run_sample_is_deterministic_and_non_trivial.

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: no warnings (the test-only sample_blueprint_swapped is exercised by swapped_sma_inputs_render_differently, so no dead-code warning).

Run: cargo build --workspace Expected: builds clean.