diff --git a/crates/aura-cli/src/graph.rs b/crates/aura-cli/src/graph.rs index af91735..6b180bd 100644 --- a/crates/aura-cli/src/graph.rs +++ b/crates/aura-cli/src/graph.rs @@ -1,98 +1,38 @@ -//! 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`. +//! The `aura graph` ASCII-DAG adapter (#13, #38): turns the engine's +//! graph-as-data (C9) into an `ascii_dag::Graph` rendered to a `String`. Two +//! views. `render_blueprint` shows the authored structure — a flat main graph +//! wiring the harness with each composite as a single opaque node, plus a +//! `where:` section that defines each distinct composite type once (its interior +//! with `[in:k]`/`[out]` port markers). `render_compilat` shows 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. +//! ascii-dag borrows its node 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. Both views build flat graphs (no subgraphs): the +//! subgraph layout mis-centres wide sibling labels, the flat layout does not. use ascii_dag::graph::{Graph, RenderMode}; use aura_core::Node; -use aura_engine::{Blueprint, BlueprintNode, Edge, SourceSpec, Target}; +use aura_engine::{Blueprint, BlueprintNode, Composite, Edge, SourceSpec}; -/// 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). +/// 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 { + // 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 = 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). + let mut item_ids: Vec = Vec::with_capacity(bp.nodes().len()); for item in bp.nodes() { - match item { - BlueprintNode::Leaf(factory) => { - let id = labels.len(); - labels.push(factory.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(factory) => { - let id = labels.len(); - labels.push(factory.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(), - }); - } - } + let id = labels.len(); + labels.push(match item { + BlueprintNode::Leaf(factory) => factory.label(), + BlueprintNode::Composite(c) => c.name().to_string(), + }); + item_ids.push(id); } - - // 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(); @@ -100,37 +40,94 @@ pub fn render_blueprint(bp: &Blueprint) -> String { source_ids.push(id); } - // top-level edges, resolved through composite boundaries + // pass 2: edges — every endpoint is a single opaque node, so the slot that + // mattered for cluster fan-in is irrelevant here. + let mut edges: Vec<(usize, usize)> = Vec::new(); 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)); - } + edges.push((item_ids[e.from], item_ids[e.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)); - } + edges.push((sid, item_ids[t.node])); } } - // build the borrowed-label Graph (labels + sg_names are final & owned) + let main = render_flat(&labels, &edges); + + // 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::>().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 { 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 { + for &(from, to) in edges { g.add_edge(from, to, None); } g.render() } +/// Every distinct composite type in the blueprint, in first-seen order, keyed by +/// `name()` (the authoring type identity — same name implies same structure). +/// Recurses into a composite's interior on first sight so nested composites get +/// their own definition; a later same-name occurrence is skipped (deduped). +fn collect_distinct_composites(bp: &Blueprint) -> Vec<&Composite> { + fn walk<'a>(items: &'a [BlueprintNode], seen: &mut Vec<&'a str>, out: &mut Vec<&'a Composite>) { + for item in items { + if let BlueprintNode::Composite(c) = item + && !seen.contains(&c.name()) + { + seen.push(c.name()); + out.push(c); + walk(c.nodes(), seen, out); + } + } + } + let mut seen: Vec<&str> = Vec::new(); + let mut out: Vec<&Composite> = Vec::new(); + walk(bp.nodes(), &mut seen, &mut out); + out +} + +/// Render one composite's interior as a flat graph: interior leaves as `[type]`, +/// nested composites as opaque `[name]`, plus an `[in:k]` entry marker per input +/// role (wired to its interior targets) and an `[out]` marker (wired from the +/// output port). Prefixed `":\n"`. +fn render_definition(c: &Composite) -> String { + let mut labels: Vec = Vec::with_capacity(c.nodes().len()); + for inner in c.nodes() { + labels.push(match inner { + BlueprintNode::Leaf(factory) => factory.label(), + BlueprintNode::Composite(inner_c) => inner_c.name().to_string(), + }); + } + let mut edges: Vec<(usize, usize)> = Vec::new(); + for e in c.edges() { + edges.push((e.from, e.to)); + } + for (role, targets) in c.input_roles().iter().enumerate() { + let in_id = labels.len(); + labels.push(format!("in:{role}")); + for t in targets { + edges.push((in_id, t.node)); + } + } + let out_id = labels.len(); + labels.push("out".to_string()); + edges.push((c.output().node, out_id)); + + format!("{}:\n{}", c.name(), render_flat(&labels, &edges)) +} + /// 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 { diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 6dff6df..3de72cd 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -181,7 +181,8 @@ fn main() { Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()), Some("graph") => { // `--compiled` selects the flat post-inline view; default is the - // clustered blueprint view. Strictness beyond this stays minimal (#16). + // blueprint view (main graph + composite definitions). Strictness + // beyond this stays minimal (#16). let compiled = args.next().as_deref() == Some("--compiled"); let bp = sample_blueprint(); let out = if compiled { @@ -213,17 +214,82 @@ mod tests { } #[test] - fn blueprint_view_shows_cluster_and_param_generic_labels() { + fn blueprint_view_main_graph_shows_composite_as_opaque_node() { 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}"); - // the value-empty blueprint view labels leaves param-generically by bare - // type (no values, and no knob suffix — the ascii-dag layout cannot render - // wide cluster-sibling labels); both SMAs render identically as [SMA] - assert!(out.contains("[SMA]"), "missing [SMA]:\n{out}"); - for needle in ["Sub", "Exposure", "SimBroker", "Recorder"] { + // 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 + for needle in ["[Exposure]", "[SimBroker]", "[Recorder]"] { assert!(out.contains(needle), "missing {needle}:\n{out}"); } + // a definitions section is present + assert!(out.contains("where:"), "missing where: section:\n{out}"); + // the flat layout draws no subgraph cluster box + assert!(!out.contains('╔'), "blueprint view must not draw a cluster box:\n{out}"); + } + + #[test] + 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:0]", "[out]"] { + assert!(out.contains(needle), "missing {needle} in definition:\n{out}"); + } + } + + #[test] + fn nested_composite_renders_without_panic() { + // a composite whose interior contains another composite — render reads + // structure only (no compile/validate), so a minimal fixture suffices. + let inner = Composite::new( + "inner", + vec![Sma::factory().into()], + vec![], + vec![vec![Target { node: 0, slot: 0 }]], + OutPort { node: 0, field: 0 }, + ); + let outer = Composite::new( + "outer", + vec![BlueprintNode::Composite(inner), Sub::factory().into()], + vec![Edge { from: 0, to: 1, slot: 0, from_field: 0 }], + vec![vec![Target { node: 0, slot: 0 }]], + OutPort { node: 1, field: 0 }, + ); + let bp = Blueprint::new( + vec![BlueprintNode::Composite(outer)], + vec![SourceSpec { kind: ScalarKind::F64, targets: vec![Target { node: 0, slot: 0 }] }], + vec![], + ); + let out = graph::render_blueprint(&bp); // 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}"); + 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] + fn reused_composite_defined_once() { + // the same composite type used twice: two opaque nodes, one definition. + let bp = Blueprint::new( + vec![ + BlueprintNode::Composite(sma_cross("dup")), + BlueprintNode::Composite(sma_cross("dup")), + Exposure::factory().into(), + ], + vec![SourceSpec { + kind: ScalarKind::F64, + targets: vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }], + }], + vec![ + Edge { from: 0, to: 2, slot: 0, from_field: 0 }, + Edge { from: 1, to: 2, slot: 0, from_field: 0 }, + ], + ); + 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}"); } #[test] @@ -257,28 +323,37 @@ mod tests { 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] [SMA] ║ │ -║ └────┌──┘ ║ │ -║ ↓ ║┌──┘ -║ [Sub] ║│ -║ │ ║│ -╚════════╪══════╝│ - │ │ - ↓ │ - [Exposure] │ - ┌───┘───────┼┐ - ↓ └↓ -[Recorder] [SimBroker] - │ - ↓ - [Recorder] + // exact bytes `aura graph` emits — main graph (composites opaque) + the + // `where:` definitions section. Re-capture via `aura graph` if intended. + let expected = r#" [source:F64] + ┌───└─────┐ + ↓ │ + [sma_cross] │ + └┐ │ + ↓ │ + [Exposure] │ + ┌───┘────────│ + ↓ ↓ + [Recorder] [SimBroker] + ┌─────┘ + ↓ + [Recorder] + + + +where: + +sma_cross: + [in:0] + ┌───└───┐ + ↓ ↓ + [SMA] [SMA] + └───┌───┘ + ↓ + [Sub] + │ + ↓ + [out] "#;