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
+4
View File
@@ -53,6 +53,10 @@ impl Node for Add {
self.out[0] = Scalar::F64(a[0] + b[0]);
Some(&self.out)
}
fn label(&self) -> String {
"Add".to_string()
}
}
#[cfg(test)]
+4
View File
@@ -36,6 +36,10 @@ impl Node for Exposure {
self.out[0] = Scalar::F64((w[0] / self.scale).clamp(-1.0, 1.0));
Some(&self.out)
}
fn label(&self) -> String {
format!("Exposure({})", self.scale)
}
}
#[cfg(test)]
+4
View File
@@ -63,6 +63,10 @@ impl Node for LinComb {
self.out[0] = Scalar::F64(acc);
Some(&self.out)
}
fn label(&self) -> String {
"LinComb".to_string()
}
}
#[cfg(test)]
+4
View File
@@ -55,6 +55,10 @@ impl Node for Recorder {
let _ = self.tx.send((ctx.now(), row));
None
}
fn label(&self) -> String {
"Recorder".to_string()
}
}
#[cfg(test)]
+4
View File
@@ -82,6 +82,10 @@ impl Node for SimBroker {
self.out[0] = Scalar::F64(self.cum);
Some(&self.out)
}
fn label(&self) -> String {
format!("SimBroker({})", self.pip_size)
}
}
#[cfg(test)]
+23
View File
@@ -43,6 +43,10 @@ impl Node for Sma {
self.out[0] = Scalar::F64(sum / self.length as f64);
Some(&self.out)
}
fn label(&self) -> String {
format!("SMA({})", self.length)
}
}
#[cfg(test)]
@@ -86,4 +90,23 @@ mod tests {
inputs[0].push(Scalar::F64(9.0)).unwrap();
assert_eq!(sma.eval(Ctx::new(&inputs, Timestamp(0))), Some([Scalar::F64(9.0)].as_slice()));
}
#[test]
fn labels_carry_identifying_params() {
use crate::{Add, Exposure, LinComb, Recorder, SimBroker, Sub};
use aura_core::{Firing, ScalarKind};
// the load-bearing payoff: two SMAs disambiguate by window
assert_eq!(Sma::new(2).label(), "SMA(2)");
assert_eq!(Sma::new(4).label(), "SMA(4)");
// param-carrying single nodes
assert_eq!(Exposure::new(0.5).label(), "Exposure(0.5)");
assert_eq!(SimBroker::new(0.0001).label(), "SimBroker(0.0001)");
// bare-kind nodes (identity is not a mis-wiring axis here, per spec)
assert_eq!(Sub::new().label(), "Sub");
assert_eq!(Add::new().label(), "Add");
assert_eq!(LinComb::new(vec![1.0, -1.0]).label(), "LinComb");
let (tx, _rx) = std::sync::mpsc::channel();
assert_eq!(Recorder::new(&[ScalarKind::F64], Firing::Any, tx).label(), "Recorder");
}
}
+4
View File
@@ -44,6 +44,10 @@ impl Node for Sub {
self.out[0] = Scalar::F64(a[0] - b[0]);
Some(&self.out)
}
fn label(&self) -> String {
"Sub".to_string()
}
}
#[cfg(test)]