Files
Aura/docs/specs/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

18 KiB

aura graph — render a wired graph as an ASCII DAG — Design Spec

Date: 2026-06-05 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Goal

Make a wired graph introspectable as an ASCII DAG printed by an aura graph subcommand, so an author can eyeball the graph they actually authored and a mis-wiring becomes visible. Today a type-valid but wrong graph — fast/slow SMA inputs swapped, exposure fed into the wrong broker slot — compiles, bootstraps, and runs, and nothing surfaces the mistake (in aura-cli's sample_harness the fast/slow distinction survives only as a // 0 fast SMA source comment). This cycle realizes the CLI-face half of "structure before a run" (C14/C22) and the graph-as-data promise of C9 ("the built graph is introspectable runtime data"), using the non-load-bearing render names C23 already reserves for exactly this purpose ("kept for tracing / rendering … #13").

Two views are rendered, selectable:

  • aura graph — the blueprint view: the authored graph before inlining, with each composite drawn as a labelled cluster box (C9 fractal structure as written).
  • aura graph --compiled — the flat graph view: the flat, post-inline (nodes, sources, edges) that actually runs; composite boundaries are dissolved (C23). This is the same graph the run loop and the deferred C23 optimiser see.

Out of scope (named, deferred): rendering an arbitrary project blueprint (the sample is built-in this cycle, exactly as aura run's is); refactoring aura run onto the new sample_blueprint() (the sample-definition duplication belongs to dedup idea #14); the egui visual playground render (C22, a separate face); any snapshot-test crate (insta).

Architecture

The headless-core / two-faces split (C14) decides the crate layout, and it is the reason the work is split across crates rather than landing in one:

  • The engine stays UI-agnostic and dependency-free (C16). aura-engine gains no dependency and no rendering code. It only widens its existing graph-as-data surface with read-only accessors so an external face can walk the structure. The audit-confirmed zero-external-dependency engine invariant is preserved.
  • The render lives in aura-cli — already the aura binary, already depending on aura-core/-engine/-std. It takes the one new external dependency, ascii-dag v0.9.1 (MIT/Apache-2.0, no_std, Sugiyama layered layout, renders to a String). No new crate is introduced: a dedicated render crate would be speculative this cycle, and the only other future render consumer — the playground (C22) — is egui-native, not an ASCII consumer.
  • Labels come from the nodes themselves. A label() default method on the core Node trait (aura-core) lets every node describe itself in one line, with its parameters, so two Sma nodes read SMA(2) and SMA(4) and a swap is visible. The method is additive, non-load-bearing, and never read by the run loop — a C8 refinement aligned with C23, not a behavioural change. It must live on Node (not a side table) because the flat graph view holds Vec<Box<dyn Node>> and labels each node by asking the trait object directly.

Concrete code shapes

User-facing: the author writes a blueprint and asks to see it

The author builds the sample as a composite blueprint (the sma_cross sub-graph is a named, reusable fragment) and renders it:

// crates/aura-cli/src/main.rs — the rendered sample, authored once.
fn sample_blueprint() -> Blueprint {
    // Recorders need a channel to construct; we never run this graph, so the
    // receivers are dropped. Rendering reads structure + labels, never `eval`.
    let (tx_eq, _rx_eq) = mpsc::channel();
    let (tx_ex, _rx_ex) = mpsc::channel();
    Blueprint::new(
        vec![
            BlueprintNode::Composite(sma_cross("sma_cross", 2, 4)), // 0
            Exposure::new(0.5).into(),                              // 1
            SimBroker::new(0.0001).into(),                          // 2
            Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(), // 3 equity sink
            Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(), // 4 exposure sink
        ],
        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 author then runs the subcommand. The blueprint view groups the sma_cross interior into a cluster; the compiled view shows the inlined flat graph. (Box geometry is ascii-dag's; the sketch is illustrative — the test pins the exact bytes.)

$ aura graph
            ┌─ sma_cross ─────────────┐
price ─────▶│ SMA(2) ┐                │
   │        │        ├─▶ Sub ────────▶│─▶ Exposure(0.5) ┬─▶ SimBroker(0.0001) ─▶ Recorder
   │        │ SMA(4) ┘                │                 └─▶ Recorder
   │        └─────────────────────────┘
   └────────────────────────────────────────────────────▶ SimBroker(0.0001)

$ aura graph --compiled
price ─▶ SMA(2) ┐
price ─▶ SMA(4) ┴─▶ Sub ─▶ Exposure(0.5) ─▶ SimBroker(0.0001) ─▶ Recorder
price ─────────────────────────────────────▶ SimBroker(0.0001)
                         Exposure(0.5) ─────▶ Recorder

The SMA(2) / SMA(4) labels are the load-bearing payoff: a swapped wiring reads back differently, which is what surfaces the mistake.

Secondary: before → after of each load-bearing change

1. Node::label() — additive default method (aura-core/src/node.rs).

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

// after
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, C23). 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.
    fn label(&self) -> String {
        "node".to_string()
    }
}

The default takes &self and returns an owned String, so Node stays object-safe and Box<dyn Node>::label() dispatches (a type_name::<Self>() default would require Self: Sized and is therefore rejected — it would not be callable on the dyn Node the flat graph holds).

2. aura-std overrides — one shown, the rest analogous (aura-std/src/sma.rs).

impl Node for Sma {
    fn schema(&self) -> NodeSchema { /* unchanged */ }
    fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> { /* unchanged */ }
    fn label(&self) -> String { format!("SMA({})", self.length) }
}
// Sub -> "Sub", Exposure -> format!("Exposure({})", self.scale),
// SimBroker -> format!("SimBroker({})", self.pip_size), Add -> "Add",
// LinComb -> "LinComb", Recorder -> "Recorder".

3. Composite gains an authored name (aura-engine/src/blueprint.rs).

// before
pub struct Composite { nodes: Vec<BlueprintNode>, edges: Vec<Edge>,
                       input_roles: Vec<Vec<Target>>, output: OutPort }
impl Composite {
    pub fn new(nodes: Vec<BlueprintNode>, edges: Vec<Edge>,
               input_roles: Vec<Vec<Target>>, output: OutPort) -> Self { /* … */ }
}

// after
pub struct Composite { name: String, nodes: Vec<BlueprintNode>, edges: Vec<Edge>,
                       input_roles: Vec<Vec<Target>>, output: OutPort }
impl Composite {
    pub fn new(name: impl Into<String>, nodes: Vec<BlueprintNode>, edges: Vec<Edge>,
               input_roles: Vec<Vec<Target>>, output: OutPort) -> Self { /* … */ }
}

The name is the cluster-box title in the blueprint view and is absent from the compiled view (the boundary dissolves, C23). It is a non-load-bearing debug symbol like FieldSpec.name. Every Composite::new call site updates — the engine-internal blueprint tests (fan_composite, sma_cross, the nested- composite fixtures) gain a name argument.

4. Read-only introspection accessors (aura-engine/src/blueprint.rs).

impl Blueprint {
    pub fn nodes(&self) -> &[BlueprintNode] { &self.nodes }
    pub fn sources(&self) -> &[SourceSpec] { &self.sources }
    pub fn edges(&self) -> &[Edge] { &self.edges }
}
impl Composite {
    pub fn name(&self) -> &str { &self.name }
    pub fn nodes(&self) -> &[BlueprintNode] { &self.nodes }
    pub fn edges(&self) -> &[Edge] { &self.edges }
    pub fn input_roles(&self) -> &[Vec<Target>] { &self.input_roles }
    pub fn output(&self) -> OutPort { self.output }
}

BlueprintNode is already a pub enum (Leaf(Box<dyn Node>) | Composite(...)), so the CLI matches it to reach each leaf's .label() and each nested composite.

5. The adapter (aura-cli/src/graph.rs). ascii-dag's add_node(id, label: &'a str) borrows its labels, so the adapter first materializes one owned Vec<String> of labels (indexed by display id) that outlives the Graph, then borrows into it.

use ascii_dag::graph::{Graph, RenderMode};

// Compiled view: trivial — compile(), then one display node per flat node, one
// edge per flat edge, each label from the boxed node itself.
pub fn render_flat_graph(nodes: &[Box<dyn Node>], sources: &[SourceSpec],
                       edges: &[Edge]) -> String {
    let labels: Vec<String> = /* source labels ++ nodes.iter().map(|n| n.label()) */;
    let mut g = Graph::with_mode(RenderMode::Vertical);
    for (id, l) in labels.iter().enumerate() { g.add_node(id, l); }
    // source->target edges and node->node edges, slot annotated where it
    // disambiguates a multi-input node (e.g. Some("slot 1")).
    g.render()
}

// Blueprint view: walk top-level items; a Composite opens an ascii-dag subgraph
// (add_subgraph(name) + put_nodes(interior_ids).inside(sg)) and its interior
// leaves render inside it; blueprint-level edges/source-targets resolve through
// composite input-roles (fan) and output ports to interior display ids — the
// same boundary resolution compile() performs, but emitting cluster-grouped
// display nodes instead of a flat graph.
pub fn render_blueprint(bp: &Blueprint) -> String { /* … */ }

6. CLI dispatch (aura-cli/src/main.rs main).

// before:  Some("run") if args.next().is_none() => …  (only run + usage error)
// after: add a graph arm; --compiled selects the flat view.
match args.next().as_deref() {
    Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
    Some("graph") => {
        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); }
}

Argument-parse strictness beyond this (rejecting an unknown trailing token on graph) stays deliberately minimal — the aura run strictness question is issue #16 and is not reopened here.

The concern-defining test (acceptance evidence)

// A deliberately mis-wired sample — fast/slow SMA swapped — renders VISIBLY
// DIFFERENT from the correct one. This is the property the cycle exists to buy:
// a mis-wiring is no longer invisible.
#[test]
fn swapped_sma_inputs_render_differently() {
    let correct = graph::render_blueprint(&sample_blueprint());
    let swapped = graph::render_blueprint(&sample_blueprint_swapped()); // SMA(4),SMA(2)
    assert_ne!(correct, swapped);
}

Components

  • aura-core (node.rs): the Node::label() default method. No other change.
  • aura-core / design ledger (docs/design/INDEX.md): a C8 Refinement note recording label() as an additive, non-load-bearing, render-only debug symbol (cross-referencing C23/#13), in the style of the existing cycle-0005 / 0006 realization notes under C8.
  • aura-std (sma.rs, sub.rs, exposure.rs, sim_broker.rs, add.rs, lincomb.rs, recorder.rs): a label() override per node, params included where they identify the node; one unit test for the disambiguation property.
  • aura-engine (blueprint.rs): Composite.name + the widened Composite::new; read-only accessors on Blueprint and Composite; updated internal test call sites. No dependency added.
  • aura-cli (Cargo.toml, src/main.rs, new src/graph.rs): the ascii-dag dependency; sample_blueprint() (+ a swapped variant for the test); the graph module with render_blueprint / render_flat_graph; the graph dispatch arm; the rendering tests.

Data flow

aura graphsample_blueprint() builds a Blueprintrender_blueprint(&bp) walks bp.nodes()/sources()/edges(), opens an ascii-dag subgraph per composite (title composite.name()), places interior leaves inside it, resolves blueprint edges/source-targets through composite roles/output to interior display ids, labels each leaf via node.label()Graph::render()println! to stdout.

aura graph --compiledsample_blueprint()bp.compile() yields the flat (nodes, sources, edges)render_flat_graph(...) assigns one display id per source and per flat node, draws the flat edges, labels each Box<dyn Node> via .label()Graph::render() → stdout. Composite boundaries are gone (C23): the cluster boxes of the blueprint view are absent, by design.

Neither path runs the harness: rendering reads structure + labels only, never eval, so it is side-effect-free and needs no input stream.

Error handling

  • Blueprint view is infallible: it reads borrowed structure that a constructed Blueprint already holds. (A malformed blueprint that would fail compile() can still be drawn — drawing the broken structure is exactly the point of an introspection tool.)
  • Compiled view calls compile(), which returns Result<_, CompileError>. For the built-in sample (always valid) this is an expect; the Result is surfaced rather than swallowed so that, when aura graph later renders arbitrary project blueprints, a compile fault prints an error and exits non-zero instead of rendering a lie.
  • ascii-dag constraints are met by construction: RenderMode::Vertical is hard-coded (Horizontal collapses fan-out, per the issue's verified testing); the single-line label() contract prevents the multiline-label box break. add_subgraph / put_nodes().inside() return Result; a placement error on the built-in sample is an expect (a programming error, not user input).

Testing strategy

  • Inline string snapshots (no insta; the bit-identical-demonstrator style already used in blueprint.rs): assert the exact rendered String of both aura graph and aura graph --compiled for sample_blueprint(). These pin layout and labels, so a wiring or label regression fails the build. The expected strings are captured from the first green render and frozen.
  • Disambiguation unit test (aura-std): Sma::new(2).label() == "SMA(2)", Sma::new(4).label() == "SMA(4)", and the other overrides' one-line forms.
  • Concern-defining test (aura-cli): swapped_sma_inputs_render_differently — the swapped-input sample renders != the correct one. This is the empirical evidence that the render surfaces a mis-wiring.
  • Non-regression: the existing composite_sma_cross_runs_bit_identical_to_hand_wired and run_sample_is_deterministic_and_non_trivial tests stay green — label() is an additive default method and the run loop is untouched (C1).

Acceptance criteria

The project declares no bespoke feature-acceptance criterion, so the default applies — solves a real user-facing problem and contradicts no stated design commitment — and is met:

  • Real problem solved. An author can render the graph they authored and see a mis-wiring; the worked sample_blueprint() + the two rendered views + the swapped-render test are the empirical evidence. This is the CLI-face of "structure before a run" (C14/C22) and realizes C9's introspectable graph-as-data.
  • No design commitment contradicted. aura-engine gains no external dependency (C16 preserved — ascii-dag is aura-cli-only); the run loop and C1 determinism are untouched (label() is never read during a run); the C8 node contract is refined, not broken (the addition is the non-load-bearing render symbol C23 already reserves and cites #13 for); composites still inline to a name-free index-wired flat graph (C23 — the compiled view demonstrates it).

Issue #13's own acceptance checklist is satisfied: ascii-dag added as the rendering crate's dependency; a graph → ascii_dag::Graph adapter in Vertical mode with single-line labels; aura graph prints an ASCII DAG to stdout; a snapshot test pins the rendered output. Beyond the checklist, both the authored (clustered) and compiled (flat) views are selectable, and the swapped-render test proves the mis-wiring is actually surfaced.