diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs index b277efa..456d04a 100644 --- a/crates/aura-cli/src/graph.rs +++ b/crates/aura-cli/src/graph.rs @@ -14,7 +14,7 @@ //! subgraph layout mis-centres wide sibling labels, the flat layout does not. use ascii_dag::graph::{Graph, RenderMode}; -use aura_core::Node; +use aura_core::{LeafFactory, Node, ScalarKind}; use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, SourceSpec}; /// Blueprint view: the authored structure (#38). A flat main graph wires the @@ -98,16 +98,98 @@ fn collect_distinct_composites(bp: &Blueprint) -> Vec<&Composite> { out } -/// Render one composite's interior as a flat graph: interior leaves as `[type]`, -/// nested composites as opaque `[name]`, plus an `[in:]` entry marker per -/// input role (wired to its interior targets), a `[param:]` marker per param -/// alias (wired to the leaf it relabels), and an `[out:]` marker per -/// re-exported output field (wired from its producer). Prefixed `":\n"`. +/// `ScalarKind` as a lowercase type string for a signature (`i64`/`f64`/`bool`/ +/// `timestamp`). The derived `Debug` gives PascalCase (`I64`), so this is explicit. +fn kind_str(kind: ScalarKind) -> &'static str { + match kind { + ScalarKind::I64 => "i64", + ScalarKind::F64 => "f64", + ScalarKind::Bool => "bool", + ScalarKind::Timestamp => "timestamp", + } +} + +/// The composite's typed signature for the definition title: +/// `name(p1:kind, …) -> (o1, …)`. Param kinds come from the aliased interior leaf's +/// declared params; output **names only** (kinds need a pre-build factory interface, +/// #43). An empty alias list renders `name()`. Total: a malformed alias falls back +/// to `?` rather than panicking (compile is the validator, #41). +fn signature(c: &Composite) -> String { + let params: Vec = c + .params() + .iter() + .map(|a| { + let kind = c + .nodes() + .get(a.node) + .and_then(|n| match n { + BlueprintNode::Leaf(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)), + BlueprintNode::Composite(_) => None, + }) + .unwrap_or("?"); + format!("{}:{}", a.name, kind) + }) + .collect(); + let outs: Vec = c.output().iter().map(|of| of.name.clone()).collect(); + format!("{}({}) -> ({})", c.name(), params.join(", "), outs.join(", ")) +} + +/// A leaf item's render label: `factory.label()` plus an optional `(...)` listing +/// its aliased param names, then its input-slot stubs (`#A`, `#B`, … one per wired +/// input slot, slot index → letter) when the leaf has more than one wired input +/// slot. Params and stubs are `; `-separated when both present; either alone has no +/// separator; neither yields the bare label. A node's wired slots are the distinct +/// `.slot` values targeting it across interior edges (`Edge.to == index`) and input +/// roles (`Role.targets` with `node == index`). +fn leaf_label(c: &Composite, index: usize, factory: &LeafFactory) -> String { + let params: Vec<&str> = c + .params() + .iter() + .filter(|a| a.node == index) + .map(|a| a.name.as_str()) + .collect(); + + let mut slots: Vec = Vec::new(); + for e in c.edges() { + if e.to == index && !slots.contains(&e.slot) { + slots.push(e.slot); + } + } + for role in c.input_roles() { + for t in &role.targets { + if t.node == index && !slots.contains(&t.slot) { + slots.push(t.slot); + } + } + } + slots.sort_unstable(); + let stubs: Vec = if slots.len() > 1 { + slots.iter().map(|s| format!("#{}", (b'A' + *s as u8) as char)).collect() + } else { + Vec::new() + }; + + let parts: Vec = match (params.is_empty(), stubs.is_empty()) { + (true, true) => return factory.label(), + (false, true) => vec![params.join(", ")], + (true, false) => vec![stubs.join(",")], + (false, false) => vec![params.join(", "), stubs.join(",")], + }; + format!("{}({})", factory.label(), parts.join("; ")) +} + +/// Render one composite's interior as a flat graph: interior leaves as +/// `[type(param…; #slot…)]` (aliased param names + ordered input-slot stubs folded +/// in via `leaf_label`), nested composites as opaque `[name]`, an `[in:]` +/// entry marker per input role (wired to its interior targets), and an `[]` +/// 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 { let mut labels: Vec = Vec::with_capacity(c.nodes().len()); - for inner in c.nodes() { + for (i, inner) in c.nodes().iter().enumerate() { labels.push(match inner { - BlueprintNode::Leaf(factory) => factory.label(), + BlueprintNode::Leaf(factory) => leaf_label(c, i, factory), BlueprintNode::Composite(inner_c) => inner_c.name().to_string(), }); } @@ -122,18 +204,13 @@ fn render_definition(c: &Composite) -> String { edges.push((in_id, t.node)); } } - for a in c.params() { - let p_id = labels.len(); - labels.push(format!("param:{}", a.name)); - edges.push((p_id, a.node)); - } for of in c.output() { let out_id = labels.len(); - labels.push(format!("out:{}", of.name)); + labels.push(of.name.clone()); edges.push((of.node, out_id)); } - format!("{}:\n{}", c.name(), render_flat(&labels, &edges)) + format!("{}:\n{}", signature(c), render_flat(&labels, &edges)) } /// Compiled view: the flat post-inline graph (no clusters; boundaries dissolved, diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index bba70fc..133a896 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -390,8 +390,8 @@ mod tests { fn blueprint_view_defines_each_composite_once() { let out = graph::render_blueprint(&sample_blueprint()); // 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]", "[in:price]", "[out:cross]"] { + assert_eq!(out.matches("sma_cross(").count(), 1, "definition not rendered once:\n{out}"); + for needle in ["[SMA]", "[Sub(#A,#B)]", "[in:price]", "[cross]"] { assert!(out.contains(needle), "missing {needle} in definition:\n{out}"); } } @@ -425,8 +425,8 @@ mod tests { // 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}"); - assert_eq!(out.matches("outer:").count(), 1, "outer defined once:\n{out}"); - assert_eq!(out.matches("inner:").count(), 1, "inner defined once:\n{out}"); + assert_eq!(out.matches("outer(").count(), 1, "outer defined once:\n{out}"); + assert_eq!(out.matches("inner(").count(), 1, "inner defined once:\n{out}"); } #[test] @@ -449,7 +449,7 @@ mod tests { ); let out = graph::render_blueprint(&bp); 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}"); + assert_eq!(out.matches("dup(").count(), 1, "body defined once:\n{out}"); } #[test] @@ -503,17 +503,17 @@ mod tests { where: -sma_cross: +sma_cross() -> (cross): [in:price] ┌───└───┐ ↓ ↓ [SMA] [SMA] └───┌───┘ ↓ - [Sub] + [Sub(#A,#B)] │ ↓ - [out:cross] + [cross] "#; @@ -578,12 +578,18 @@ sma_cross: // defines its EMA-of-EMA interior once under `where:`. let out = graph::render_blueprint(&macd_blueprint()); assert!(out.contains("[macd]"), "missing opaque macd node:\n{out}"); - assert_eq!(out.matches("macd:").count(), 1, "macd defined once:\n{out}"); - assert!(out.contains("[EMA]"), "macd interior must show EMA leaves:\n{out}"); + assert_eq!(out.matches("macd(").count(), 1, "macd defined once:\n{out}"); + assert!( + out.contains("macd(fast:i64, slow:i64, signal:i64) -> (macd, signal, histogram)"), + "typed signature line: {out}" + ); + assert!(out.contains("[EMA(fast)]"), "fast EMA folds its param: {out}"); + assert!(out.contains("[EMA(slow)]"), "slow EMA folds its param: {out}"); + assert!(out.contains("[EMA(signal)]"), "signal EMA folds its param: {out}"); + assert!(out.contains("[Sub(#A,#B)]"), "Sub shows its two ordered inputs: {out}"); assert!(out.contains("[in:price]"), "named MACD input role: {out}"); - assert!(out.contains("[param:fast]"), "aliased fast length: {out}"); - assert!(out.contains("[param:slow]"), "aliased slow length: {out}"); - assert!(out.contains("[param:signal]"), "aliased signal length: {out}"); + assert!(!out.contains("[param:"), "param marker nodes removed: {out}"); + assert!(!out.contains("[out:"), "output prefix dropped: {out}"); } /// E2E acceptance (#41 / spec 0019, the worked example): the real MACD strategy diff --git a/crates/aura-core/src/node.rs b/crates/aura-core/src/node.rs index bf3ddf8..84ce60f 100644 --- a/crates/aura-core/src/node.rs +++ b/crates/aura-core/src/node.rs @@ -91,14 +91,15 @@ impl LeafFactory { (self.build)(params) } /// The param-generic render label for the blueprint view (C22 "structure - /// before"): just the node type, e.g. `SMA`. A value-empty recipe has no - /// values to show; the tunable knobs are surfaced by `Blueprint::param_space`, - /// not in the graph. The label stays the bare type because the `ascii-dag` - /// renderer writes a label verbatim on one line (no wrapping) and overlaps two - /// wide sibling boxes inside a cluster subgraph — appending the knob names - /// (`SMA(length)`) would garble the blueprint view, and domain labels grow - /// unboundedly wide. The compiled view labels the built node valued (`SMA(2)`) - /// via `Node::label`, unaffected. + /// before"): just the node type, e.g. `SMA`. A value-empty recipe has no values + /// to show; the tunable knobs are surfaced by `Blueprint::param_space`. The + /// factory label stays the bare type because alias / handle names are a + /// *composite-level* concept — the renderer folds them into the leaf label at + /// the composite boundary (`render_definition`'s `leaf_label`), where the + /// `(node, slot)` → name mapping lives, not on the standalone factory. (Wide + /// labels are safe: both `aura graph` views are flat since cycle 0017 — no + /// cluster subgraph, so no sibling-overlap garble.) The compiled view labels the + /// built node valued (`SMA(2)`) via `Node::label`, unaffected. pub fn label(&self) -> String { self.name.to_string() }