feat(aura-cli): render the root composite like any other composite
Close #49 and remove the last two render special-cases the blueprint root still carried, both pre-0024 vestiges. Since cycle 0024 the root is an ordinary Composite with input_roles, so the render no longer needs to treat it apart: (A) Fan-in slot stubs at the root. A composite-interior multi-input leaf already renders its wired slots as #… stubs; a top-level leaf did not, because render_blueprint threaded stub_ctx: None. It now passes the root composite (it IS a &Composite, carrying roles + edges), and stub_ctx drops its Option — both render_graph callers pass &Composite. The existing slot_source/ fan_in_identifiers/signature_of serve a top-level leaf verbatim; no second stub path (#49 acceptance). SimBroker now renders [SimBroker(#E,#price)] (#E = the Exposure producer's sibling-unique signature prefix, #price = the price source role name). (B) Role-named root entries. render_blueprint built entry markers from format!("source:{kind}") while render_definition uses role.name — the marker/stub asymmetry. The root now builds entries from role.name (filtered to bound roles, source.is_some()), identical to the interior, so the source marker reads [price] and matches the #price slot stub it feeds. render_compilat is deliberately untouched: post-inline the role name has dissolved (C23), so the compiled view keeps [source:F64] from the flat SourceSpec.kind — [price] pre-inline / [source:F64] post-inline is C23 made visible, and compiled_view_golden is the byte-unchanged negative control. Design call (orchestrator, with the user): the marker/stub asymmetry was fixed at its source (the entry naming) rather than by special-casing the stub to match the kind-named marker — the latter would have been a root-only stub behaviour, violating the "no second stub path" acceptance. The user chose full symmetry ([price], dropping the kind annotation) over a name+kind marker, prioritising structural cleanliness; the only remaining root-vs-interior distinction is the bound-role filter (C3), which is semantic, not cosmetic. Scope: crates/aura-cli/src/graph.rs only; read-only render (C9), behaviour-preserving for the run path (C1), no engine change. blueprint_view_golden re-captured from the live `aura graph` output (the where: section stays byte-identical — only the main graph re-flows); the SimBroker needle, a root-SimBroker-stub assertion on the macd view, and a [src] role-name-passthrough assertion are added. Verification (orchestrator-run, not agent-reported): cargo build --workspace green; cargo test --workspace 150 passed / 0 failed (same count, no run-path test moved); cargo clippy --workspace --all-targets -D warnings clean. compiled_view_golden and render_compilat byte-untouched. closes #49
This commit is contained in:
@@ -35,10 +35,11 @@ enum ParamNames<'a> {
|
||||
}
|
||||
|
||||
/// A CLI-local, *borrowed* render notion unifying a composite **input role** and a
|
||||
/// blueprint **source** for the shared graph core (#48): both are an external entry
|
||||
/// drawn as a marker node wired into its interior `targets`. The composite builds
|
||||
/// these from `Role` (name = role name), the blueprint from `SourceSpec` (name =
|
||||
/// `source:{kind}`) — no engine change, the list is assembled CLI-side and borrows
|
||||
/// blueprint **bound source role** for the shared graph core (#48): both are an
|
||||
/// external entry drawn as a marker node wired into its interior `targets`. Both
|
||||
/// views build these from `Role` (name = role name); the blueprint root filters to
|
||||
/// bound roles (`source.is_some()`), the only remaining root-vs-interior
|
||||
/// distinction (C3) — no engine change, the list is assembled CLI-side and borrows
|
||||
/// the engine's `Target` slices.
|
||||
struct Entry<'a> {
|
||||
name: String,
|
||||
@@ -60,22 +61,18 @@ pub enum Color {
|
||||
/// defines each distinct composite type once. Flat layout only (no subgraphs).
|
||||
pub fn render_blueprint(bp: &Composite, color: Color) -> String {
|
||||
// the main graph is the shared graph core (`render_graph`) over the root
|
||||
// composite's top-level (nodes, edges): leaves enriched exactly as `where:`
|
||||
// interior leaves are (param names folded in, a `<field> →` prefix for any input
|
||||
// fed by a multi-output producer — the headline: macd's `histogram` driving
|
||||
// Exposure), composites opaque. The deltas vs a composite definition: param names
|
||||
// come from the builder (no alias overlay at the root); the entries are the
|
||||
// root's source-bound roles, not interior roles; there is no output record
|
||||
// (terminals are sinks) and no title.
|
||||
// composite's top-level (nodes, edges), rendered exactly as a `where:` interior:
|
||||
// leaves enriched (param names, a `<field> →` prefix for a multi-output producer
|
||||
// — macd's `histogram` driving Exposure — and, now threaded at the root too,
|
||||
// fan-in slot stubs), composites opaque, entries named by their role. The only
|
||||
// deltas vs a composite definition: param names come from the builder (no alias
|
||||
// overlay at the root); entries are filtered to bound source roles (C3); there
|
||||
// is no output record (terminals are sinks) and no title.
|
||||
let entries: Vec<Entry> = bp
|
||||
.input_roles()
|
||||
.iter()
|
||||
.filter_map(|role| {
|
||||
role.source.map(|kind| Entry {
|
||||
name: format!("source:{kind:?}"),
|
||||
targets: &role.targets,
|
||||
})
|
||||
})
|
||||
.filter(|role| role.source.is_some())
|
||||
.map(|role| Entry { name: role.name.clone(), targets: &role.targets })
|
||||
.collect();
|
||||
let main = render_graph(
|
||||
bp.nodes(),
|
||||
@@ -83,7 +80,7 @@ pub fn render_blueprint(bp: &Composite, color: Color) -> String {
|
||||
&entries,
|
||||
&[], // no output bindings: a blueprint's terminals are sinks
|
||||
ParamNames::Factory,
|
||||
None, // no fan-in stub context at the root (deterministic no-op, #48)
|
||||
bp, // the root IS the fan-in stub context (it carries roles + edges)
|
||||
None, // no title
|
||||
color,
|
||||
);
|
||||
@@ -105,8 +102,9 @@ pub fn render_blueprint(bp: &Composite, color: Color) -> String {
|
||||
/// `OutField` re-export gets the `name := ` binding prefix folded on (empty list →
|
||||
/// no binding, the blueprint case); every [`Entry`] is drawn as a marker wired to its
|
||||
/// interior targets. A `title` (the composite's typed signature) is prefixed when
|
||||
/// `Some`. The two render-borders that differ — param-name source and fan-in stub
|
||||
/// availability — are passed as `param_names` / `stub_ctx`.
|
||||
/// `Some`. The render-border that differs — the param-name source — is passed as
|
||||
/// `param_names`; `stub_ctx` is the borrowed composite (root or interior) both
|
||||
/// views thread for fan-in slot resolution.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_graph(
|
||||
nodes: &[BlueprintNode],
|
||||
@@ -114,7 +112,7 @@ fn render_graph(
|
||||
entries: &[Entry],
|
||||
output: &[OutField],
|
||||
param_names: ParamNames,
|
||||
stub_ctx: Option<&Composite>,
|
||||
stub_ctx: &Composite,
|
||||
title: Option<&str>,
|
||||
color: Color,
|
||||
) -> String {
|
||||
@@ -240,10 +238,10 @@ fn signature(c: &Composite) -> String {
|
||||
/// composite path keeps using the alias overlay filtered to this node (so `where:`
|
||||
/// stays byte-identical — only aliased slots, never factory defaults); the
|
||||
/// blueprint-root path uses the factory's declared names.
|
||||
/// 3. The leaf's input-slot stubs (`#Sf`, …) when it is a multi-input fan-in **and** a
|
||||
/// composite context is available (`stub_ctx = Some`). At the blueprint root no
|
||||
/// such context is threaded, so a top-level fan-in renders without stubs (a
|
||||
/// deterministic no-op, #48) rather than duplicating the signature machinery.
|
||||
/// 3. The leaf's input-slot stubs (`#Sf`, …) when it is a multi-input fan-in. The
|
||||
/// `stub_ctx` is the borrowed composite (root or interior) the wired slots
|
||||
/// resolve against; both views thread it, so a top-level fan-in stubs exactly as
|
||||
/// an interior one does — one shared path, no root carve-out (#49).
|
||||
///
|
||||
/// Params and stubs are `; `-separated inside the `(...)` when both present; the
|
||||
/// field prefix always sits outside the parens. The bare label results when none of
|
||||
@@ -255,7 +253,7 @@ fn leaf_label(
|
||||
index: usize,
|
||||
factory: &PrimitiveBuilder,
|
||||
param_names: &ParamNames,
|
||||
stub_ctx: Option<&Composite>,
|
||||
stub_ctx: &Composite,
|
||||
) -> String {
|
||||
let params: Vec<&str> = match param_names {
|
||||
ParamNames::Aliases(aliases) => {
|
||||
@@ -280,9 +278,10 @@ fn leaf_label(
|
||||
}
|
||||
}
|
||||
slots.sort_unstable();
|
||||
let stubs: Vec<String> = match stub_ctx {
|
||||
Some(c) if slots.len() > 1 => fan_in_identifiers(c, index, &slots),
|
||||
_ => Vec::new(),
|
||||
let stubs: Vec<String> = if slots.len() > 1 {
|
||||
fan_in_identifiers(stub_ctx, index, &slots)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let inner: String = match (params.is_empty(), stubs.is_empty()) {
|
||||
@@ -457,7 +456,7 @@ fn render_definition(c: &Composite, color: Color) -> String {
|
||||
&entries,
|
||||
c.output(),
|
||||
ParamNames::Aliases(c.params()),
|
||||
Some(c),
|
||||
c,
|
||||
Some(&title),
|
||||
color,
|
||||
)
|
||||
|
||||
+24
-17
@@ -411,10 +411,11 @@ mod tests {
|
||||
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 enriched (param names folded in, #48), exactly as
|
||||
// the `where:` interior leaves are — `Exposure` folds its `scale` param;
|
||||
// paramless leaves (SimBroker, Recorder) stay bare.
|
||||
for needle in ["[Exposure(scale)]", "[SimBroker]", "[Recorder]"] {
|
||||
// top-level leaves render enriched exactly as the `where:` interior leaves:
|
||||
// `Exposure` folds its `scale` param; a paramless SINGLE-input leaf
|
||||
// (`Recorder`) stays bare, but a paramless MULTI-input fan-in (`SimBroker`)
|
||||
// now shows its slot stubs (#49).
|
||||
for needle in ["[Exposure(scale)]", "[SimBroker(#E,#price)]", "[Recorder]"] {
|
||||
assert!(out.contains(needle), "missing {needle}:\n{out}");
|
||||
}
|
||||
// a definitions section is present
|
||||
@@ -494,6 +495,9 @@ mod tests {
|
||||
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}");
|
||||
// the bound root role named "src" carries its name into the entry marker
|
||||
// (general non-"price" role-name passthrough, not just the sample's price).
|
||||
assert!(out.contains("[src]"), "root role name renders as the entry marker: {out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -528,19 +532,19 @@ mod tests {
|
||||
// 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.
|
||||
let expected = r#" [source:F64]
|
||||
┌───└─────┐
|
||||
↓ │
|
||||
[sma_cross] │
|
||||
│ │
|
||||
↓ └──┐
|
||||
[Exposure(scale)] │
|
||||
┌──┘─────────┐ │
|
||||
↓ ↓──┘
|
||||
[Recorder] [SimBroker]
|
||||
┌─────┘
|
||||
↓
|
||||
[Recorder]
|
||||
let expected = r#" [price]
|
||||
┌──└──────┐
|
||||
↓ │
|
||||
[sma_cross] │
|
||||
│ │
|
||||
↓ └──┐
|
||||
[Exposure(scale)] │
|
||||
┌────────┘─────────┐ │
|
||||
↓ ↓──┘
|
||||
[Recorder] [SimBroker(#E,#price)]
|
||||
┌──────┘
|
||||
↓
|
||||
[Recorder]
|
||||
|
||||
|
||||
|
||||
@@ -628,6 +632,9 @@ sma_cross(fast:i64, slow:i64) -> (cross):
|
||||
assert!(out.contains("[macd := Sub(#Ef,#Es)]"), "macd line Sub bound as output `macd`: {out}");
|
||||
assert!(out.contains("[signal := EMA(signal)]"), "signal EMA bound as output `signal` (name/param pun is intended): {out}");
|
||||
assert!(out.contains("[histogram := Sub(#S,#Es)]"), "histogram Sub bound as output `histogram`: {out}");
|
||||
// the root SimBroker is a top-level multi-input fan-in: its two slots now
|
||||
// render as #… stubs, mirroring the where: interior (#49).
|
||||
assert!(out.contains("[SimBroker(#E,#price)]"), "root SimBroker shows its two slot stubs: {out}");
|
||||
// output re-exports are folded onto their producers — no standalone stubs.
|
||||
// `[macd]` is NOT a valid negative here: it still appears as the opaque
|
||||
// composite node in the MAIN graph. Discriminate on signal/histogram.
|
||||
|
||||
Reference in New Issue
Block a user