21c1621bd0
"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.
376 lines
18 KiB
Markdown
376 lines
18 KiB
Markdown
# `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:
|
|
|
|
```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<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).**
|
|
|
|
```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<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).**
|
|
|
|
```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<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.
|
|
|
|
```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_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`).**
|
|
|
|
```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_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)
|
|
|
|
```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_flat_graph`; 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_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.
|