feat(aura-cli): aura graph renders a wired graph as an ASCII DAG (#13)

Make a wired graph introspectable so a mis-wiring becomes visible. Two
selectable views, both authored from one built-in sample blueprint:

  aura graph             clustered blueprint view — composites drawn as named
                         ascii-dag cluster boxes (pre-inline, C9)
  aura graph --compiled  flat compilat view — composite boundaries dissolved
                         (C23); the graph the run loop actually runs

The payoff is intrinsic, param-carrying node labels: two SMAs read SMA(2) /
SMA(4), so a swapped fast/slow input reads back differently. This is realized
by a `label()` default method on the core Node trait — a non-load-bearing
render symbol the run loop never reads (wiring is by index), the symbol C23
already reserves citing #13. Recorded as a C8 refinement in the ledger.
Rejected the alternatives in brainstorm: a separate Describe trait (forces a
combined trait-object dance for a label the ledger calls non-load-bearing) and
extrinsic parallel labels (decoupled from the node, so an author can label the
slow SMA "fast" and mask the very bug the render exists to surface).

Mechanism:
- aura-core: Node::label() default method (additive, object-safe, single-line).
- aura-std: per-node overrides — SMA(n)/Exposure(s)/SimBroker(p) carry params;
  Sub/Add/LinComb/Recorder are bare (their identity is not a mis-wiring axis).
- aura-engine: Composite gains an authored `name` (cluster title; dissolves at
  inline) + read-only graph-as-data accessors on Blueprint/Composite. No
  external dependency — the engine stays dependency-pure (C16); ascii-dag lives
  only in aura-cli. The label-free index-wired compilat (C23) is unchanged.
- aura-cli: ascii-dag v0.9.1 + a graph.rs adapter (Vertical mode; labels
  materialized into an owned Vec<String> that outlives the borrow-based Graph).
  `aura run` is untouched (the sample duplication is dedup idea #14).

Tests: a concern-defining test (swapped sample renders != correct), structure
pins (cluster present in blueprint view, absent in compiled view), per-node
label disambiguation, and two frozen byte-goldens of the deterministic render.
The cycle-0012 bit-identical and run-sample non-regression tests stay green.

Verified by the orchestrator (not the agent report): cargo build --workspace
clean; cargo test --workspace all green; cargo clippy --workspace --all-targets
-D warnings clean; both views run live and render as expected.

closes #13
This commit is contained in:
2026-06-05 20:56:52 +02:00
parent b01821653e
commit 0a855c3943
14 changed files with 463 additions and 7 deletions
+156
View File
@@ -0,0 +1,156 @@
//! 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<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_compilat(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()
}
+167 -3
View File
@@ -6,9 +6,12 @@
//! recording sinks), runs it deterministically (C1), and prints the run's
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
mod graph;
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
use aura_engine::{
f64_field, summarize, Edge, Harness, RunManifest, RunReport, SourceSpec, Target,
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort,
RunManifest, RunReport, SourceSpec, Target,
};
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
use std::sync::mpsc::{self, Receiver};
@@ -111,15 +114,79 @@ fn run_sample() -> RunReport {
}
}
/// 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)
}
fn main() {
let mut args = std::env::args().skip(1);
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"),
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");
eprintln!("aura: usage: aura run | aura graph [--compiled]");
std::process::exit(2);
}
}
@@ -129,6 +196,103 @@ fn main() {
mod tests {
use super::*;
/// 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");
}
#[test]
fn blueprint_view_golden() {
let out = graph::render_blueprint(&sample_blueprint());
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
// exact bytes `aura graph` emits (render() ends in three newlines).
let expected = r#" [source:F64]
┌─────────└┐────────┐
│ │ │
╔═════╪══════════╪════╗ │
║ sma_cross │ ║ │
║ ↓ ↓ ║ │
║ [SMA(2)] [SMA(4)] ║ │
║ └──┌───────┘ ║ │
║ ↓ ┌─╫───┘
║ [Sub] │ ║
║ │ │ ║
╚════════╪══════════╪═╝
│ │
↓ │
[Exposure(0.5)] │
┌───────┘────────┐ │
↓ ↓─┘
[Recorder] [SimBroker(0.0001)]
[Recorder]
"#;
assert_eq!(out, expected, "blueprint render drifted; re-capture if intended");
}
#[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 = r#" [source:F64]
┌────────└─┐──────┐
↓ ↓ │
[SMA(2)] [SMA(4)] │
└────┌─────┘ │
↓ ┌──────┘
[Sub] │
│ │
↓ └────┐
[Exposure(0.5)] │
┌──────┘─────────┐│
↓ ↓┘
[Recorder] [SimBroker(0.0001)]
┌─────┘
[Recorder]
"#;
assert_eq!(out, expected, "compiled render drifted; re-capture if intended");
}
#[test]
fn run_sample_is_deterministic_and_non_trivial() {
let r1 = run_sample();