From 33e87ff1999bdc5ea6fd47fd894389e00c9e23bc Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 5 Jun 2026 20:33:23 +0200 Subject: [PATCH] spec: 0013 aura-graph ascii-dag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render a wired graph as an ASCII DAG via an `aura graph` subcommand so a mis-wiring becomes visible (the concern of #13). Two selectable views: a blueprint view (composites as cluster boxes, pre-inline) and a `--compiled` view (the flat compilat, boundaries dissolved per C23). Settled in brainstorm: render labels come from a non-load-bearing `label()` default method on the core `Node` trait (a C8 refinement aligned with C23, which already reserves render names citing #13), overridden per node to carry params (SMA(2) vs SMA(4)) so a swap reads back differently. Composites gain an authored name; the engine exposes read-only graph-as-data accessors and stays dependency-free (C16) — ascii-dag lives only in aura-cli. `aura run` is left untouched this cycle. Gates: self-review clean; parse gate a no-op (no spec_validation in profile); grounding-check PASS. refs #13 --- docs/specs/0013-aura-graph-ascii-dag.md | 375 ++++++++++++++++++++++++ 1 file changed, 375 insertions(+) create mode 100644 docs/specs/0013-aura-graph-ascii-dag.md diff --git a/docs/specs/0013-aura-graph-ascii-dag.md b/docs/specs/0013-aura-graph-ascii-dag.md new file mode 100644 index 0000000..2e83291 --- /dev/null +++ b/docs/specs/0013-aura-graph-ascii-dag.md @@ -0,0 +1,375 @@ +# `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 **compilat** 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 compilat view holds + `Vec>` 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: + +```rust +// 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.) + +```text +$ 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).** + +```rust +// 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::label()` dispatches (a `type_name::()` +default would require `Self: Sized` and is therefore rejected — it would not be +callable on the `dyn Node` the compilat holds). + +**2. aura-std overrides — one shown, the rest analogous (aura-std/src/sma.rs).** + +```rust +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).** + +```rust +// before +pub struct Composite { nodes: Vec, edges: Vec, + input_roles: Vec>, output: OutPort } +impl Composite { + pub fn new(nodes: Vec, edges: Vec, + input_roles: Vec>, output: OutPort) -> Self { /* … */ } +} + +// after +pub struct Composite { name: String, nodes: Vec, edges: Vec, + input_roles: Vec>, output: OutPort } +impl Composite { + pub fn new(name: impl Into, nodes: Vec, edges: Vec, + input_roles: Vec>, 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).** + +```rust +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] { &self.input_roles } + pub fn output(&self) -> OutPort { self.output } +} +``` + +`BlueprintNode` is already a `pub enum` (`Leaf(Box) | 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` of +labels (indexed by display id) that outlives the `Graph`, then borrows into it. + +```rust +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_compilat(nodes: &[Box], sources: &[SourceSpec], + edges: &[Edge]) -> String { + let labels: Vec = /* 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 compilat. +pub fn render_blueprint(bp: &Blueprint) -> String { /* … */ } +``` + +**6. CLI dispatch (aura-cli/src/main.rs `main`).** + +```rust +// 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_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); } +} +``` + +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) + +```rust +// 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_compilat`; the + `graph` dispatch arm; the rendering tests. + +## Data flow + +`aura graph` → `sample_blueprint()` builds a `Blueprint` → `render_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 --compiled` → `sample_blueprint()` → `bp.compile()` yields the flat +`(nodes, sources, edges)` → `render_compilat(...)` assigns one display id per +source and per flat node, draws the flat edges, labels each `Box` 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 compilat (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.