feat(aura-cli): TTY-gated ANSI edge colouring + blank line after composite signature

Two presentation refinements to the `aura graph` views, render-only (no
engine change, C8/C9/C23 untouched).

Edge colouring: thread a `Color { Plain, Ansi }` through render_flat /
render_definition / render_blueprint / render_compilat. `Ansi` uses
ascii-dag's colored scanline pass (per-edge palette colours) so crossing
edges stay traceable; `Plain` is the existing monochrome `g.render()`.
The CLI selects `Ansi` only when stdout is a TTY (`IsTerminal`), so a
redirect to a file or pipe stays byte-clean — `aura graph | …` carries no
escape codes. ascii-dag colours only edges; node-label text is unaffected.

Tests render `Color::Plain` explicitly, so both goldens stay byte-stable
(compiled_view_golden remains the C23 guard); a new test pins that `Ansi`
emits escapes and `Plain` does not, and that labels survive colouring.

Blank line: render_definition now separates the typed signature title from
the graph body with an empty line. blueprint_view_golden re-captured.

Considered but rejected this round: per-node role highlighting of the
signature identifiers — needs either an ascii-dag fork (node colouring is
hard-disabled upstream) or a per-graph string-surgery pass that does not
generalise beyond hand-known labels. Only edge colouring survives here.
This commit is contained in:
2026-06-08 12:08:13 +02:00
parent 32ac8a30ea
commit b73d7076c3
2 changed files with 67 additions and 25 deletions
+30 -9
View File
@@ -14,13 +14,24 @@
//! subgraph layout mis-centres wide sibling labels, the flat layout does not.
use ascii_dag::graph::{Graph, RenderMode};
use ascii_dag::render::colors::Palette;
use aura_core::{LeafFactory, Node, ScalarKind};
use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, SourceSpec};
/// Edge colouring for a rendered graph. `Plain` is monochrome — golden-stable and
/// pipe-safe (no escape codes when redirected to a file). `Ansi` emits per-edge
/// ANSI colours so crossing edges stay traceable on an interactive terminal. The
/// CLI selects `Ansi` only when stdout is a TTY; tests always render `Plain`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Color {
Plain,
Ansi,
}
/// Blueprint view: the authored structure (#38). A flat main graph wires the
/// harness with each composite shown as a single opaque node; a `where:` section
/// defines each distinct composite type once. Flat layout only (no subgraphs).
pub fn render_blueprint(bp: &Blueprint) -> String {
pub fn render_blueprint(bp: &Blueprint, color: Color) -> String {
// pass 1: one main-graph node per top-level item (leaf -> bare-type label;
// composite -> its name, opaque) and one node per source.
let mut labels: Vec<String> = Vec::new();
@@ -52,20 +63,20 @@ pub fn render_blueprint(bp: &Blueprint) -> String {
}
}
let main = render_flat(&labels, &edges);
let main = render_flat(&labels, &edges, color);
// definitions: each distinct composite type, once, recursively.
let defs = collect_distinct_composites(bp);
if defs.is_empty() {
return main;
}
let body = defs.iter().map(|c| render_definition(c)).collect::<Vec<_>>().join("\n");
let body = defs.iter().map(|c| render_definition(c, color)).collect::<Vec<_>>().join("\n");
format!("{main}\nwhere:\n\n{body}")
}
/// Build and render a flat (no-subgraph) ascii-dag graph from owned labels and
/// edge pairs — the same idiom `render_compilat` uses.
fn render_flat(labels: &[String], edges: &[(usize, usize)]) -> String {
fn render_flat(labels: &[String], edges: &[(usize, usize)], color: Color) -> String {
let mut g = Graph::with_mode(RenderMode::Vertical);
for (id, l) in labels.iter().enumerate() {
g.add_node(id, l);
@@ -73,7 +84,12 @@ fn render_flat(labels: &[String], edges: &[(usize, usize)]) -> String {
for &(from, to) in edges {
g.add_edge(from, to, None);
}
g.render()
match color {
Color::Plain => g.render(),
// colour lives in the layout IR's scanline pass; node labels stay default
// colour (ascii-dag only colours edges), so the box text is unaffected.
Color::Ansi => g.compute_layout().render_scanline_colored(Palette::Ansi),
}
}
/// Every distinct composite type in the blueprint, in first-seen order, keyed by
@@ -185,7 +201,7 @@ fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String {
/// node per re-exported output field (wired from its producer). The title line is
/// the composite's typed `signature` (`name(p:kind, …) -> (out, …)`); params live
/// in the signature, not as marker nodes.
fn render_definition(c: &Composite) -> String {
fn render_definition(c: &Composite, color: Color) -> String {
let mut labels: Vec<String> = Vec::with_capacity(c.nodes().len());
for (i, inner) in c.nodes().iter().enumerate() {
labels.push(match inner {
@@ -210,12 +226,17 @@ fn render_definition(c: &Composite) -> String {
edges.push((of.node, out_id));
}
format!("{}:\n{}", signature(c), render_flat(&labels, &edges))
format!("{}:\n\n{}", signature(c), render_flat(&labels, &edges, color))
}
/// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved,
/// C23). Each `Box<dyn Node>` labels itself; node display id = node index.
pub fn render_compilat(nodes: &[Box<dyn Node>], sources: &[SourceSpec], edges: &[Edge]) -> String {
pub fn render_compilat(
nodes: &[Box<dyn Node>],
sources: &[SourceSpec],
edges: &[Edge],
color: Color,
) -> String {
let mut labels: Vec<String> = nodes.iter().map(|n| n.label()).collect();
let source_base = labels.len();
for src in sources {
@@ -228,5 +249,5 @@ pub fn render_compilat(nodes: &[Box<dyn Node>], sources: &[SourceSpec], edges: &
edge_pairs.push((source_base + i, t.node));
}
}
render_flat(&labels, &edge_pairs)
render_flat(&labels, &edge_pairs, color)
}
+37 -16
View File
@@ -14,6 +14,7 @@ use aura_engine::{
ParamAlias, Role, RunManifest, RunReport, SourceSpec, Target,
};
use aura_std::{Ema, Exposure, Recorder, SimBroker, Sma, Sub};
use std::io::IsTerminal;
use std::sync::mpsc::{self, Receiver};
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
@@ -329,9 +330,9 @@ fn run_macd() -> RunReport {
/// Compile a blueprint under its point vector and render the flat post-inline
/// (C23) view — the shared body of the `--compiled` paths.
fn render_compiled(bp: Blueprint, point: &[Scalar]) -> String {
fn render_compiled(bp: Blueprint, point: &[Scalar], color: graph::Color) -> String {
let (nodes, sources, edges) = bp.compile_with_params(point).expect("valid blueprint");
graph::render_compilat(&nodes, &sources, &edges)
graph::render_compilat(&nodes, &sources, &edges, color)
}
const USAGE: &str = "usage: aura run [--macd] | aura graph [--compiled | --macd [--compiled]]";
@@ -341,16 +342,23 @@ fn main() {
// so an unexpected trailing token falls through to the usage-error path rather
// than masquerading as a successful run (#16 strict reading).
let args: Vec<String> = std::env::args().skip(1).collect();
// Edge colouring only when stdout is an interactive terminal; a redirect to a
// file or pipe stays plain (no escape codes). `run` output is JSON, never coloured.
let color = if std::io::stdout().is_terminal() {
graph::Color::Ansi
} else {
graph::Color::Plain
};
match args.iter().map(String::as_str).collect::<Vec<_>>().as_slice() {
["run"] => println!("{}", run_sample().to_json()),
["run", "--macd"] => println!("{}", run_macd().to_json()),
["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint())),
["graph"] => println!("{}", graph::render_blueprint(&sample_blueprint(), color)),
["graph", "--compiled"] => {
println!("{}", render_compiled(sample_blueprint(), &sample_point()));
println!("{}", render_compiled(sample_blueprint(), &sample_point(), color));
}
["graph", "--macd"] => println!("{}", graph::render_blueprint(&macd_blueprint())),
["graph", "--macd"] => println!("{}", graph::render_blueprint(&macd_blueprint(), color)),
["graph", "--macd", "--compiled"] => {
println!("{}", render_compiled(macd_blueprint(), &macd_point()));
println!("{}", render_compiled(macd_blueprint(), &macd_point(), color));
}
["--help"] | ["-h"] => println!("{USAGE}"),
_ => {
@@ -373,7 +381,7 @@ mod tests {
#[test]
fn blueprint_view_main_graph_shows_composite_as_opaque_node() {
let out = graph::render_blueprint(&sample_blueprint());
let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain);
// the composite is a single opaque main-graph node, not an expanded cluster
assert!(out.contains("[sma_cross]"), "missing opaque composite node:\n{out}");
// top-level leaves render as their bare-type nodes
@@ -388,7 +396,7 @@ mod tests {
#[test]
fn blueprint_view_defines_each_composite_once() {
let out = graph::render_blueprint(&sample_blueprint());
let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain);
// the sma_cross body is defined exactly once, with its interior + ports
assert_eq!(out.matches("sma_cross(").count(), 1, "definition not rendered once:\n{out}");
for needle in ["[SMA]", "[Sub(#A,#B)]", "[price]", "[cross]"] {
@@ -421,7 +429,7 @@ mod tests {
vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }],
vec![],
);
let out = graph::render_blueprint(&bp); // must not panic (no unimplemented!)
let out = graph::render_blueprint(&bp, graph::Color::Plain); // must not panic (no unimplemented!)
// outer shows the inner composite as an opaque node, and both get a definition
assert!(out.contains("[outer]"), "missing opaque outer node:\n{out}");
assert!(out.contains("[inner]"), "inner must be opaque inside outer's definition:\n{out}");
@@ -447,7 +455,7 @@ mod tests {
Edge { from: 1, to: 2, slot: 0, from_field: 0 },
],
);
let out = graph::render_blueprint(&bp);
let out = graph::render_blueprint(&bp, graph::Color::Plain);
assert_eq!(out.matches("[dup]").count(), 2, "two opaque uses expected:\n{out}");
assert_eq!(out.matches("dup(").count(), 1, "body defined once:\n{out}");
}
@@ -457,7 +465,7 @@ mod tests {
let bp = sample_blueprint();
let (nodes, sources, edges) =
bp.compile_with_params(&sample_point()).expect("valid sample");
let out = graph::render_compilat(&nodes, &sources, &edges);
let out = graph::render_compilat(&nodes, &sources, &edges, graph::Color::Plain);
// node labels survive inlining...
assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}");
// ...but the composite cluster name does NOT (boundary dissolved, C23)
@@ -472,16 +480,16 @@ mod tests {
// view, where the flat nodes carry their valued labels (SMA(2)/SMA(4)).
let (cn, cs, ce) =
sample_blueprint().compile_with_params(&sample_point()).expect("valid sample");
let correct = graph::render_compilat(&cn, &cs, &ce);
let correct = graph::render_compilat(&cn, &cs, &ce, graph::Color::Plain);
let (sn, ss, se) =
sample_blueprint().compile_with_params(&swapped_point()).expect("valid sample");
let swapped = graph::render_compilat(&sn, &ss, &se);
let swapped = graph::render_compilat(&sn, &ss, &se, graph::Color::Plain);
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the compiled render");
}
#[test]
fn blueprint_view_golden() {
let out = graph::render_blueprint(&sample_blueprint());
let out = graph::render_blueprint(&sample_blueprint(), graph::Color::Plain);
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
// exact bytes `aura graph` emits — main graph (composites opaque) + the
// `where:` definitions section. Re-capture via `aura graph` if intended.
@@ -504,6 +512,7 @@ mod tests {
where:
sma_cross() -> (cross):
[price]
┌───└───┐
↓ ↓
@@ -525,7 +534,7 @@ sma_cross() -> (cross):
let bp = sample_blueprint();
let (nodes, sources, edges) =
bp.compile_with_params(&sample_point()).expect("valid sample");
let out = graph::render_compilat(&nodes, &sources, &edges);
let out = graph::render_compilat(&nodes, &sources, &edges, graph::Color::Plain);
let expected = r#" [source:F64]
┌────────└─┐──────┐
↓ ↓ │
@@ -576,7 +585,7 @@ sma_cross() -> (cross):
fn macd_blueprint_renders_a_nested_composite_definition() {
// the MACD blueprint view shows the composite opaque in the main graph and
// defines its EMA-of-EMA interior once under `where:`.
let out = graph::render_blueprint(&macd_blueprint());
let out = graph::render_blueprint(&macd_blueprint(), graph::Color::Plain);
assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}");
assert_eq!(out.matches("macd(").count(), 1, "macd defined once:\n{out}");
assert!(
@@ -592,6 +601,18 @@ sma_cross() -> (cross):
assert!(!out.contains("[out:"), "output prefix dropped: {out}");
}
#[test]
fn ansi_colour_emits_escapes_only_when_requested() {
// `Color::Ansi` adds per-edge ANSI escapes for an interactive terminal;
// `Color::Plain` is byte-clean (the golden / redirected-to-file path). The
// colour is orthogonal to content — node-label text is identical either way.
let plain = graph::render_blueprint(&macd_blueprint(), graph::Color::Plain);
let coloured = graph::render_blueprint(&macd_blueprint(), graph::Color::Ansi);
assert!(!plain.contains('\x1b'), "plain render must carry no escape codes:\n{plain}");
assert!(coloured.contains('\x1b'), "ansi render must carry escape codes");
assert!(coloured.contains("[macd]"), "labels survive colouring: {coloured}");
}
/// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy
/// blueprint's swept param surface relabels the three otherwise-indistinguishable
/// EMA `length` slots to `macd.fast` / `macd.slow` / `macd.signal` — the named