diff --git a/docs/plans/0013-aura-graph-ascii-dag.md b/docs/plans/0013-aura-graph-ascii-dag.md new file mode 100644 index 0000000..5b57af8 --- /dev/null +++ b/docs/plans/0013-aura-graph-ascii-dag.md @@ -0,0 +1,798 @@ +# `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-58` — `label()` overrides. +- Test: `crates/aura-std/src/sma.rs:48` (tests module) — disambiguation test. +- Modify: `crates/aura-engine/src/blueprint.rs` — `Composite.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_compilat`). +- 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`: + +```rust + #[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: + +```rust +pub trait Node { + fn schema(&self) -> NodeSchema; + fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]>; +} +``` + +to: + +```rust +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::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: + +```markdown +**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 Node`s). + +--- + +## 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: + +```rust + #[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 ` block: + +`crates/aura-std/src/sma.rs` (impl at 22-46, field `length` at line 10): +```rust + fn label(&self) -> String { + format!("SMA({})", self.length) + } +``` +`crates/aura-std/src/sub.rs` (impl at 27-47): +```rust + fn label(&self) -> String { + "Sub".to_string() + } +``` +`crates/aura-std/src/exposure.rs` (impl at 23-39, field `scale` at line 11): +```rust + 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): +```rust + fn label(&self) -> String { + format!("SimBroker({})", self.pip_size) + } +``` +`crates/aura-std/src/add.rs` (impl at 36-56): +```rust + fn label(&self) -> String { + "Add".to_string() + } +``` +`crates/aura-std/src/lincomb.rs` (impl at 42-66): +```rust + fn label(&self) -> String { + "LinComb".to_string() + } +``` +`crates/aura-std/src/recorder.rs` (impl at 31-58): +```rust + 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: + +```rust +pub struct Composite { + nodes: Vec, + edges: Vec, + input_roles: Vec>, + output: OutPort, +} +``` + +to: + +```rust +pub struct Composite { + name: String, + nodes: Vec, + edges: Vec, + input_roles: Vec>, + output: OutPort, +} +``` + +- [ ] **Step 2: Widen `Composite::new` to take a name** + +Change `Composite::new` (lines 64-71) from: + +```rust + pub fn new( + nodes: Vec, + edges: Vec, + input_roles: Vec>, + output: OutPort, + ) -> Self { + Self { nodes, edges, input_roles, output } + } +``` + +to: + +```rust + /// 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 compilat (the boundary dissolves at inline, C23). + pub fn new( + name: impl Into, + nodes: Vec, + edges: Vec, + input_roles: Vec>, + 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`): + +```rust + /// 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] { + &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): + +```rust + /// 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): +```rust + 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): +```rust + 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: + +```toml +ascii-dag = "0.9.1" +``` + +- [ ] **Step 2: Create the adapter module `crates/aura-cli/src/graph.rs`** + +```rust +//! 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_compilat` 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, + output_interior: usize, + input_roles: Vec>, + }, +} + +/// 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 { + 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 = Vec::new(); + let mut sg_names: Vec = Vec::new(); + let mut memberships: Vec<(usize, Vec)> = Vec::new(); + let mut edges: Vec<(usize, usize)> = Vec::new(); + let mut item_display: Vec = 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 = 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 = 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` labels itself; node display id = node index. +pub fn render_compilat(nodes: &[Box], sources: &[SourceSpec], edges: &[Edge]) -> String { + let mut labels: Vec = 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: + +```rust +mod graph; +``` + +Then change the `aura_engine` import (lines 10-12) to add the blueprint types: + +```rust +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): + +```rust +/// 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: + +```rust + 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: + +```rust + 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_compilat(&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: + +```rust + /// 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_compilat(&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_compilat`/`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): + +```rust + #[test] + fn blueprint_view_golden() { + let out = graph::render_blueprint(&sample_blueprint()); + let expected = "<>"; + 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: + +```rust + #[test] + fn compiled_view_golden() { + let bp = sample_blueprint(); + let (nodes, sources, edges) = bp.compile().expect("valid sample"); + let out = graph::render_compilat(&nodes, &sources, &edges); + let expected = "<>"; + assert_eq!(out, expected, "compiled render drifted; re-capture if intended"); + } +``` + +> The `<>` 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.