From 0a855c3943c4b31d660edf098eb7feb666285f02 Mon Sep 17 00:00:00 2001 From: Brummel Date: Fri, 5 Jun 2026 20:56:52 +0200 Subject: [PATCH] feat(aura-cli): `aura graph` renders a wired graph as an ASCII DAG (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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 --- Cargo.lock | 7 ++ crates/aura-cli/Cargo.toml | 1 + crates/aura-cli/src/graph.rs | 156 +++++++++++++++++++++++++ crates/aura-cli/src/main.rs | 170 +++++++++++++++++++++++++++- crates/aura-core/src/node.rs | 26 +++++ crates/aura-engine/src/blueprint.rs | 55 ++++++++- crates/aura-std/src/add.rs | 4 + crates/aura-std/src/exposure.rs | 4 + crates/aura-std/src/lincomb.rs | 4 + crates/aura-std/src/recorder.rs | 4 + crates/aura-std/src/sim_broker.rs | 4 + crates/aura-std/src/sma.rs | 23 ++++ crates/aura-std/src/sub.rs | 4 + docs/design/INDEX.md | 8 ++ 14 files changed, 463 insertions(+), 7 deletions(-) create mode 100644 crates/aura-cli/src/graph.rs diff --git a/Cargo.lock b/Cargo.lock index 76ac85f..9ed6adf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -46,10 +46,17 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "ascii-dag" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd898d2dcffeeb51015c511a2849a8637c894b833afc946936d48b1e5c8aa6ec" + [[package]] name = "aura-cli" version = "0.1.0" dependencies = [ + "ascii-dag", "aura-core", "aura-engine", "aura-std", diff --git a/crates/aura-cli/Cargo.toml b/crates/aura-cli/Cargo.toml index a5e4aae..e1b69e1 100644 --- a/crates/aura-cli/Cargo.toml +++ b/crates/aura-cli/Cargo.toml @@ -13,3 +13,4 @@ path = "src/main.rs" aura-core = { path = "../aura-core" } aura-engine = { path = "../aura-engine" } aura-std = { path = "../aura-std" } +ascii-dag = "0.9.1" diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs new file mode 100644 index 0000000..105bdf5 --- /dev/null +++ b/crates/aura-cli/src/graph.rs @@ -0,0 +1,156 @@ +//! The `aura graph` ASCII-DAG adapter (#13): turns the engine's graph-as-data +//! (C9) into an `ascii_dag::Graph` rendered to a `String`. Two views: +//! `render_blueprint` draws composites as named cluster boxes (pre-inline); +//! `render_compilat` draws the flat post-inline graph (boundaries dissolved, +//! C23). Rendering reads structure + node `label()`s only — never `eval`. +//! +//! ascii-dag borrows its node/subgraph labels as `&'a str`, so each function +//! first materializes the owned label `String`s (which outlive the `Graph`), +//! then borrows into them. `RenderMode::Vertical` is mandatory: Horizontal +//! collapses a fan-out onto one path. + +use ascii_dag::graph::{Graph, RenderMode}; +use aura_core::Node; +use aura_engine::{Blueprint, BlueprintNode, Edge, SourceSpec, Target}; + +/// How one top-level blueprint item maps into display nodes (blueprint view). +enum ItemDisplay { + /// A leaf is one display node at this id. + Leaf(usize), + /// A composite is a cluster of interior leaf display ids; its output port and + /// input roles resolve edges crossing its boundary. + Composite { + interior_ids: Vec, + output_interior: usize, + input_roles: Vec>, + }, +} + +/// The display id an edge *from* this item originates at (its producer). +fn producer_id(d: &ItemDisplay) -> usize { + match d { + ItemDisplay::Leaf(id) => *id, + ItemDisplay::Composite { interior_ids, output_interior, .. } => interior_ids[*output_interior], + } +} + +/// The display id(s) an edge *into* this item at `slot` reaches (its consumers). +/// A composite input role fans into several interior targets. +fn consumer_ids(d: &ItemDisplay, slot: usize) -> Vec { + match d { + ItemDisplay::Leaf(id) => vec![*id], + ItemDisplay::Composite { interior_ids, input_roles, .. } => { + input_roles[slot].iter().map(|t| interior_ids[t.node]).collect() + } + } +} + +/// Blueprint view: composites become labelled cluster boxes (pre-inline, C9). +pub fn render_blueprint(bp: &Blueprint) -> String { + let mut labels: Vec = Vec::new(); + let mut sg_names: Vec = Vec::new(); + let mut memberships: Vec<(usize, Vec)> = Vec::new(); + let mut edges: Vec<(usize, usize)> = Vec::new(); + let mut item_display: Vec = Vec::with_capacity(bp.nodes().len()); + + // pass 1: assign display ids + labels; open a subgraph per composite; record + // interior edges (the interior ids are in hand here). + for item in bp.nodes() { + match item { + BlueprintNode::Leaf(node) => { + let id = labels.len(); + labels.push(node.label()); + item_display.push(ItemDisplay::Leaf(id)); + } + BlueprintNode::Composite(c) => { + let mut interior_ids = Vec::with_capacity(c.nodes().len()); + for inner in c.nodes() { + match inner { + BlueprintNode::Leaf(node) => { + let id = labels.len(); + labels.push(node.label()); + interior_ids.push(id); + } + BlueprintNode::Composite(_) => unimplemented!( + "cycle 0013 renders leaf-interior composites (the built-in \ + sample); nested-composite cluster rendering is a follow-up" + ), + } + } + for e in c.edges() { + edges.push((interior_ids[e.from], interior_ids[e.to])); + } + let sg = sg_names.len(); + sg_names.push(c.name().to_string()); + memberships.push((sg, interior_ids.clone())); + item_display.push(ItemDisplay::Composite { + interior_ids, + output_interior: c.output().node, + input_roles: c.input_roles().to_vec(), + }); + } + } + } + + // sources as producer display nodes (unnamed in the data model -> label by kind) + let mut source_ids: Vec = Vec::with_capacity(bp.sources().len()); + for src in bp.sources() { + let id = labels.len(); + labels.push(format!("source:{:?}", src.kind)); + source_ids.push(id); + } + + // top-level edges, resolved through composite boundaries + for e in bp.edges() { + let from = producer_id(&item_display[e.from]); + for to in consumer_ids(&item_display[e.to], e.slot) { + edges.push((from, to)); + } + } + // source -> target edges + for (src, &sid) in bp.sources().iter().zip(&source_ids) { + for t in &src.targets { + for to in consumer_ids(&item_display[t.node], t.slot) { + edges.push((sid, to)); + } + } + } + + // build the borrowed-label Graph (labels + sg_names are final & owned) + let mut g = Graph::with_mode(RenderMode::Vertical); + for (id, l) in labels.iter().enumerate() { + g.add_node(id, l); + } + let sg_ids: Vec = sg_names.iter().map(|n| g.add_subgraph(n)).collect(); + for (sg, members) in &memberships { + g.put_nodes(members).inside(sg_ids[*sg]).expect("valid subgraph placement"); + } + for (from, to) in edges { + g.add_edge(from, to, None); + } + g.render() +} + +/// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved, +/// C23). Each `Box` labels itself; node display id = node index. +pub fn render_compilat(nodes: &[Box], sources: &[SourceSpec], edges: &[Edge]) -> String { + let mut labels: Vec = nodes.iter().map(|n| n.label()).collect(); + let source_base = labels.len(); + for src in sources { + labels.push(format!("source:{:?}", src.kind)); + } + + let mut g = Graph::with_mode(RenderMode::Vertical); + for (id, l) in labels.iter().enumerate() { + g.add_node(id, l); + } + for e in edges { + g.add_edge(e.from, e.to, None); + } + for (i, src) in sources.iter().enumerate() { + for t in &src.targets { + g.add_edge(source_base + i, t.node, None); + } + } + g.render() +} diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 9b85f6e..d190bb5 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -6,9 +6,12 @@ //! recording sinks), runs it deterministically (C1), and prints the run's //! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move). +mod graph; + use aura_core::{Firing, Scalar, ScalarKind, Timestamp}; use aura_engine::{ - f64_field, summarize, Edge, Harness, RunManifest, RunReport, SourceSpec, Target, + f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort, + RunManifest, RunReport, SourceSpec, Target, }; use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub}; use std::sync::mpsc::{self, Receiver}; @@ -111,15 +114,79 @@ fn run_sample() -> RunReport { } } +/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread). +/// CLI-local sample builder; the engine ships no sample (the duplication with +/// `blueprint.rs`'s test helper is the dedup tracked in #14). +fn sma_cross(name: &str, fast: usize, slow: usize) -> Composite { + Composite::new( + name, + vec![Sma::new(fast).into(), Sma::new(slow).into(), Sub::new().into()], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 1, from_field: 0 }, + ], + vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]], + OutPort { node: 2, field: 0 }, + ) +} + +/// The sample signal-quality blueprint, parameterized by the SMA windows so a +/// test can author a deliberately swapped variant. Recorders need a channel to +/// construct; the receivers are dropped because the render never runs the graph. +fn build_sample(fast: usize, slow: usize) -> Blueprint { + let (tx_eq, _rx_eq) = mpsc::channel(); + let (tx_ex, _rx_ex) = mpsc::channel(); + Blueprint::new( + vec![ + BlueprintNode::Composite(sma_cross("sma_cross", fast, slow)), + Exposure::new(0.5).into(), + SimBroker::new(0.0001).into(), + Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq).into(), + Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex).into(), + ], + 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 built-in sample rendered by `aura graph`. +fn sample_blueprint() -> Blueprint { + build_sample(2, 4) +} + fn main() { let mut args = std::env::args().skip(1); match args.next().as_deref() { // strict: a bare `run` proceeds; a trailing token falls through to the // usage-error path rather than masquerading as a successful run (#16). Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()), - Some("--help") | Some("-h") => println!("usage: aura run"), + Some("graph") => { + // `--compiled` selects the flat post-inline view; default is the + // clustered blueprint view. Strictness beyond this stays minimal (#16). + 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"); + eprintln!("aura: usage: aura run | aura graph [--compiled]"); std::process::exit(2); } } @@ -129,6 +196,103 @@ fn main() { mod tests { use super::*; + /// The sample authored with fast/slow SMA windows swapped — the mis-wiring + /// the render must surface. Test-only: nothing outside tests builds it. + fn sample_blueprint_swapped() -> Blueprint { + build_sample(4, 2) + } + + #[test] + fn blueprint_view_shows_cluster_and_param_labels() { + let out = graph::render_blueprint(&sample_blueprint()); + // the composite renders as a named cluster box + assert!(out.contains("sma_cross"), "missing composite name:\n{out}"); + // param-carrying labels disambiguate the two SMAs + assert!(out.contains("SMA(2)"), "missing SMA(2):\n{out}"); + assert!(out.contains("SMA(4)"), "missing SMA(4):\n{out}"); + for needle in ["Sub", "Exposure(0.5)", "SimBroker(0.0001)", "Recorder"] { + assert!(out.contains(needle), "missing {needle}:\n{out}"); + } + } + + #[test] + fn compiled_view_dissolves_the_composite_boundary() { + let bp = sample_blueprint(); + let (nodes, sources, edges) = bp.compile().expect("valid sample"); + let out = graph::render_compilat(&nodes, &sources, &edges); + // 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) + assert!(!out.contains("sma_cross"), "compiled view must not show the cluster:\n{out}"); + } + + #[test] + fn swapped_sma_inputs_render_differently() { + // the property the cycle exists to buy: a mis-wiring is no longer invisible. + let correct = graph::render_blueprint(&sample_blueprint()); + let swapped = graph::render_blueprint(&sample_blueprint_swapped()); + assert_ne!(correct, swapped, "a fast/slow SMA swap must change the render"); + } + + #[test] + fn blueprint_view_golden() { + let out = graph::render_blueprint(&sample_blueprint()); + // ascii-dag's Sugiyama layout is deterministic (no RNG); these are the + // exact bytes `aura graph` emits (render() ends in three newlines). + let expected = r#" [source:F64] + ┌─────────└┐────────┐ + │ │ │ + ╔═════╪══════════╪════╗ │ + ║ sma_cross │ ║ │ + ║ ↓ ↓ ║ │ + ║ [SMA(2)] [SMA(4)] ║ │ + ║ └──┌───────┘ ║ │ + ║ ↓ ┌─╫───┘ + ║ [Sub] │ ║ + ║ │ │ ║ + ╚════════╪══════════╪═╝ + │ │ + ↓ │ + [Exposure(0.5)] │ + ┌───────┘────────┐ │ + ↓ ↓─┘ +[Recorder] [SimBroker(0.0001)] + │ + ↓ + [Recorder] + + +"#; + assert_eq!(out, expected, "blueprint render drifted; re-capture if intended"); + } + + #[test] + fn compiled_view_golden() { + let bp = sample_blueprint(); + let (nodes, sources, edges) = bp.compile().expect("valid sample"); + let out = graph::render_compilat(&nodes, &sources, &edges); + let expected = r#" [source:F64] + ┌────────└─┐──────┐ + ↓ ↓ │ + [SMA(2)] [SMA(4)] │ + └────┌─────┘ │ + ↓ ┌──────┘ + [Sub] │ + │ │ + ↓ └────┐ + [Exposure(0.5)] │ + ┌──────┘─────────┐│ + ↓ ↓┘ + [Recorder] [SimBroker(0.0001)] + ┌─────┘ + ↓ + [Recorder] + + +"#; + assert_eq!(out, expected, "compiled render drifted; re-capture if intended"); + } + #[test] fn run_sample_is_deterministic_and_non_trivial() { let r1 = run_sample(); diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index 7ef756d..acbf023 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -65,6 +65,17 @@ pub struct NodeSchema { 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). 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. Returns an owned `String` and takes `&self`, so `Node` + /// stays object-safe and `Box::label()` dispatches. + fn label(&self) -> String { + "node".to_string() + } } #[cfg(test)] @@ -79,4 +90,19 @@ mod tests { assert_eq!(b.firing, Firing::Barrier(0)); assert_ne!(a.firing, b.firing); } + + #[test] + fn default_label_is_placeholder() { + // A node that does not override label() falls back to the placeholder. + struct Bare; + impl Node for Bare { + fn schema(&self) -> NodeSchema { + NodeSchema { inputs: vec![], output: vec![] } + } + fn eval(&mut self, _ctx: Ctx<'_>) -> Option<&[Scalar]> { + None + } + } + assert_eq!(Bare.label(), "node"); + } } diff --git a/crates/aura-engine/src/blueprint.rs b/crates/aura-engine/src/blueprint.rs index 5b7175c..3f238dd 100644 --- a/crates/aura-engine/src/blueprint.rs +++ b/crates/aura-engine/src/blueprint.rs @@ -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, edges: Vec, input_roles: Vec>, @@ -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, nodes: Vec, edges: Vec, input_roles: Vec>, 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] { + &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>, flat_edges: &mut Vec, ) -> Result { - 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 }, diff --git a/crates/aura-std/src/add.rs b/crates/aura-std/src/add.rs index cc1fac2..209529f 100644 --- a/crates/aura-std/src/add.rs +++ b/crates/aura-std/src/add.rs @@ -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)] diff --git a/crates/aura-std/src/exposure.rs b/crates/aura-std/src/exposure.rs index 41d0c4c..90a703e 100644 --- a/crates/aura-std/src/exposure.rs +++ b/crates/aura-std/src/exposure.rs @@ -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)] diff --git a/crates/aura-std/src/lincomb.rs b/crates/aura-std/src/lincomb.rs index ad2baad..4bcef65 100644 --- a/crates/aura-std/src/lincomb.rs +++ b/crates/aura-std/src/lincomb.rs @@ -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)] diff --git a/crates/aura-std/src/recorder.rs b/crates/aura-std/src/recorder.rs index 7610a64..db03632 100644 --- a/crates/aura-std/src/recorder.rs +++ b/crates/aura-std/src/recorder.rs @@ -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)] diff --git a/crates/aura-std/src/sim_broker.rs b/crates/aura-std/src/sim_broker.rs index 422f8af..7090b36 100644 --- a/crates/aura-std/src/sim_broker.rs +++ b/crates/aura-std/src/sim_broker.rs @@ -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)] diff --git a/crates/aura-std/src/sma.rs b/crates/aura-std/src/sma.rs index caf3527..9377b87 100644 --- a/crates/aura-std/src/sma.rs +++ b/crates/aura-std/src/sma.rs @@ -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"); + } } diff --git a/crates/aura-std/src/sub.rs b/crates/aura-std/src/sub.rs index 5d161e0..c130859 100644 --- a/crates/aura-std/src/sub.rs +++ b/crates/aura-std/src/sub.rs @@ -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)] diff --git a/docs/design/INDEX.md b/docs/design/INDEX.md index 151201a..488e108 100644 --- a/docs/design/INDEX.md +++ b/docs/design/INDEX.md @@ -237,6 +237,14 @@ out-of-graph side effect. In-graph routing stays engine-owned data (the edge tab of the graph is the node's own side effect — and that boundary is the determinism / graph-as-data boundary (C1/C7). +**Refinement (Construction-layer milestone — render labels, 2026-06-05).** A node +additionally exposes `label() -> String`, a **single-line, non-load-bearing** +render symbol: a default trait method the run loop never calls (wiring is by +index, C23). Overrides carry the node's identifying params (`SMA(2)` vs `SMA(4)`) +so a graph render (C9 graph-as-data, #13) disambiguates identical node types and +surfaces a mis-wiring. Like `FieldSpec.name`, it is an informative debug symbol, +not part of the C8 dataflow contract — adding it changes no run behaviour. + ### C9 — Fractal, acyclic composition **Guarantee.** A composite is itself a `Node` that wires a sub-graph and exposes one output; signal, combined signal, and (with execution) strategy are all the