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
+51 -4
View File
@@ -52,6 +52,7 @@ impl BlueprintNode {
/// interior edges (local indices), input roles (role `r` fans into the interior
/// targets `input_roles[r]`), and the one exposed output port.
pub struct Composite {
name: String,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
@@ -59,15 +60,39 @@ pub struct Composite {
}
impl Composite {
/// Build a composite from its interior items, interior edges (local indices),
/// input roles, and output port.
/// 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<String>,
nodes: Vec<BlueprintNode>,
edges: Vec<Edge>,
input_roles: Vec<Vec<Target>>,
output: OutPort,
) -> Self {
Self { nodes, edges, input_roles, output }
Self { name: name.into(), nodes, edges, input_roles, output }
}
/// 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<Target>] {
&self.input_roles
}
/// The single exposed output port.
pub fn output(&self) -> OutPort {
self.output
}
/// The derived interface the enclosing graph wires against: input role `r`'s
@@ -119,6 +144,19 @@ impl Blueprint {
Self { nodes, sources, edges }
}
/// 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
}
/// Lower to the flat compilat: inline every composite (recursive), offset
/// interior indices, rewrite edges, and fan input roles out. The run loop and
/// `bootstrap`'s data model are unchanged; the lowered compilat is wired by raw
@@ -201,7 +239,9 @@ fn inline_composite(
flat_nodes: &mut Vec<Box<dyn Node>>,
flat_edges: &mut Vec<Edge>,
) -> Result<ItemLowering, CompileError> {
let Composite { nodes, edges, input_roles, output } = c;
// `name` is the non-load-bearing render symbol (#13); it dissolves at inline
// (C23 — the boundary does not reach the compilat), so it is not destructured.
let Composite { name: _, nodes, edges, input_roles, output } = c;
let item_count = nodes.len();
// the output port must name an in-range interior item (field range checked
@@ -354,6 +394,7 @@ mod tests {
// one interior node (Join2: 2 f64 inputs, 1 f64 output); two roles, each
// feeding one interior slot; output port = the Join2 output field 0.
let c = Composite::new(
"c",
vec![BlueprintNode::Leaf(Box::new(Join2 { out: [Scalar::F64(0.0)] }))],
vec![],
vec![
@@ -431,6 +472,7 @@ mod tests {
/// the SMA-cross shape.
fn fan_composite() -> Composite {
Composite::new(
"fan",
vec![pass1(), pass1(), join2()],
vec![
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
@@ -479,6 +521,7 @@ mod tests {
// Pass1 slots; the inner Join2 lands at flat index 2.
let inner = fan_composite();
let outer = Composite::new(
"outer",
vec![BlueprintNode::Composite(inner)],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
@@ -511,6 +554,7 @@ mod tests {
fn bad_interior_index_rejected() {
// interior edge references interior node 9, which does not exist
let c = Composite::new(
"c",
vec![pass1()],
vec![Edge { from: 0, to: 9, slot: 0, from_field: 0 }],
vec![vec![Target { node: 0, slot: 0 }]],
@@ -525,6 +569,7 @@ mod tests {
fn role_kind_mismatch_rejected() {
// role 0 fans into a Pass1 f64 slot AND a SinkI64 i64 slot -> mismatch
let c = Composite::new(
"c",
vec![pass1(), BlueprintNode::Leaf(Box::new(SinkI64))],
vec![],
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
@@ -539,6 +584,7 @@ mod tests {
fn output_port_out_of_range_rejected() {
// output names field 5 of a node whose output has one field
let c = Composite::new(
"c",
vec![pass1()],
vec![],
vec![vec![Target { node: 0, slot: 0 }]],
@@ -629,6 +675,7 @@ mod tests {
/// output (the fast-minus-slow spread). Interior wired with raw local indices.
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 },