Files
Aura/docs/specs/0023-unify-blueprint-render.md
T
Brummel 21c1621bd0 docs,engine: drop coined "compilat", use FlatGraph / "flat graph"
"Compilat" (German "Kompilat") was a coined noun for the product of the
bootstrap compilation — neither English nor a natural fit. The runtime
artifact already has a code identifier for exactly this thing: the
`FlatGraph` struct (harness.rs). Replace the coinage with that identifier:

- prose mentions          -> "flat graph" (mirrors the type, reads plainly)
- definitional anchors    -> `FlatGraph` (C11, C23, the running-graph line)
- `render_compilat`       -> `render_flat_graph` (historical render symbol;
                             keeps the `render_blueprint` / `render_flat_graph`
                             source-vs-product pairing)
- `intra-compilat`        -> `intra-graph`
- `from_compilat` (test)  -> `from_flat`

"compilation" / "re-compilation" / "bootstrap-as-compilation" (the process,
ordinary English) are deliberately left untouched. Behaviour-preserving:
only comments, design ledger, specs/plans, one test-local variable and its
assert messages change. Full workspace test suite green; clippy clean.
2026-06-14 17:02:15 +02:00

17 KiB

Unify the Blueprint Main-Graph Render Through the Composite Machinery — Design Spec

Date: 2026-06-08 Status: Draft — awaiting user spec review Authors: orchestrator + Claude

Source: Gitea issue #48 (exhaustive, ratified in-session). This spec produces the design that issue names; it does not re-decide it.

Goal

aura graph --macd must make the strategy legible. Today the main graph draws bare nodes and bare edges, so what the strategy computes is invisible:

[macd] → [Exposure] → [SimBroker] → [Recorder]

Two facts the strategy turns on never reach the page: that the histogram field (not the macd line, not the signal) drives Exposure, and that Exposure's behaviour is parameterised by scale. Both are already present in the model (Edge.from_field, LeafFactory::params()) and both are already rendered for a composite's interior in the where: view. The main graph is poorer only because it runs a separate, less capable render path.

This cycle removes that asymmetry: the blueprint main graph renders through the same machinery as a composite interior, because a Blueprint is — for render purposes — the root composite. After this cycle the main graph carries leaf params, multi-input slot stubs, and a from_field edge label on every multi-output producer.

In scope: unifying the leaf-label and edge-rendering of the main graph with the composite-definition render (leaf_label, the edge handling, the slot-stub machinery, and a new from_field edge label that serves both views).

Out of scope (deferred, see Architecture §Deferred): a harness signature header line over the main graph. It is orthogonal to legibility and its exact shape is unresolved; it is surfaced at the review gate, not baked here.

Architecture

The asymmetry, precisely

Two render functions in crates/aura-cli/src/graph.rs consume nearly the same data but diverge in capability:

  • render_blueprint (the main graph) — crates/aura-cli/src/graph.rs:36. Pass 1 labels each top-level leaf with the bare factory.label() (graph.rs:44); pass 2 pushes each edge as a bare (from, to) pair, dropping from_field and slot (graph.rs:60).
  • render_definition (a composite interior) — crates/aura-cli/src/graph.rs:295. Labels each leaf with the enriched leaf_label(c, i, factory) (graph.rs:299), which folds in aliased param names and ordered input-slot stubs, and folds re-exported outputs onto producers via output_binding.

The enriched path operates only on a composite's nodes + edges. Those two fields are the same types on a Blueprint:

Composite { name, nodes: Vec<BlueprintNode>, edges: Vec<Edge>,
            input_roles: Vec<Role>, params: Vec<ParamAlias>, output: Vec<OutField> }
Blueprint {       nodes: Vec<BlueprintNode>, edges: Vec<Edge>,
            sources: Vec<SourceSpec> }

So the enriched machinery applies to a Blueprint unchanged on the interior, and differs only at the three borders. Each border is a small adapter, not a new code path:

  1. Entries. sources (typed, bound) play the role input_roles (named, open) play in a composite. Both already render as an entry-marker node — [source:F64] is the counterpart of [price]. The blueprint pass already draws source markers (graph.rs:49-54); this is unchanged.
  2. Per-node params. A composite re-names a leaf's params through its ParamAlias list. A blueprint has no alias list — a top-level leaf carries its own params, whose names the LeafFactory declares pre-build (LeafFactory::params() -> &[ParamSpec], crates/aura-core/src/node.rs:85; #43). So the param source differs (factory-declared name vs composite alias), the rendering principle does not (show the leaf's param names). The render stays value-empty (names, not values) — consistent with the param-generic blueprint (C11) and with the current [source:F64] / [Exposure] golden.
  3. Outputs / sinks. A composite re-exports ports (OutField, folded onto producers by output_binding). A blueprint re-exports nothing; its terminal nodes are sinks. Per the issue's sink sharpening, a sink is not a type — no trait Sink/enum exists; the only mark is an empty output schema (output: vec![], e.g. Recorder, crates/aura-std/src/recorder.rs). A sink is a leaf like any other that happens to have no outgoing edge, run through the same leaf_label. No special handling, and therefore no new border logic — this border is "absence of output_binding", which the blueprint already gets for free by carrying no OutFields.

The from_field edge label (serves both views)

Edge.from_field selects which output field a consumer reads. It is dropped at graph.rs:60 (blueprint) and never rendered in render_definition either; ascii-dag's per-edge label slot is passed None at graph.rs:87. The strategy fact "Exposure reads the histogram" lives exactly here: the macd→Exposure edge carries from_field: 2 (crates/aura-cli/src/main.rs:254), and macd's field 2 is histogram (OutField, crates/aura-cli/src/main.rs:226).

Rule (default, ratify at review): an edge renders a field label only when its producer has more than one output field — mirroring the slot-stub convention (fan_in_identifiers labels a slot only when a node has >1 wired input, graph.rs:184). For a single-output producer the label is omitted, so the existing golden output is unchanged.

The field name is resolved from the producer:

  • Producer is a composite → Composite::output()[from_field].name (crates/aura-engine/src/blueprint.rs:119). The headline case: macd is a composite, so from_field: 2 resolves to histogram.
  • Producer is a leaf → pre-build field names are not declared (#43, value-empty LeafFactory), so the label falls back to the field index (e.g. 2). No multi-output leaf exists in the current sample/macd, so the fallback is latent, not exercised — recorded so the planner handles it deliberately rather than panicking.

The label is attached through ascii-dag's existing add_edge(from, to, label) slot (graph.rs:87), shared by render_flat and therefore by both views.

Implementation risk (planner verifies): whether ascii-dag's RenderMode::Vertical renders a per-edge label legibly is not yet confirmed in this codebase. If the vertical layout cannot place an edge label cleanly, the fallback that preserves the same observable — "the selected field is visible at the macd→Exposure connection" — is to surface the field name at the consumer side, adjacent to the input that reads it (the slot-stub locus the #… machinery already owns). The acceptance is form-invariant: the field is visible; the exact locus is a planner/implementer call against ascii-dag's actual capability.

Deferred: the harness signature header

The issue's invariant note establishes that C12 (a harness is ontologically not a node) is the wrong axis to deny the main graph a signature — ontology and render form are independent. A header such as harness(price:f64) {fast, slow, signal, scale} -> {equity, exposure} is therefore permissible. It is nonetheless deferred: its exact shape is a genuine open choice (sources-and-sinks only vs the full param space; whether a root graph needs a title at all the way a where:-block does), and it is orthogonal to this cycle's headline — legibility is delivered by the leaf params and the edge label without it. Deferring keeps this spec fork-free. The choice is surfaced at the review gate; if wanted, it becomes its own cycle.

Concrete code shapes

User-facing: aura graph --macd (the acceptance evidence)

The program the user runs is unchanged; its output gains information. Current main-graph fragment (value-empty, composites opaque):

        [macd]
           │
           ↓
      [Exposure]

After this cycle (shapes shown; exact bytes are a golden re-capture, not spec-owned):

        [macd]
           │ histogram          ← from_field: 2 resolved via macd's OutField names
           ↓
    [Exposure(scale)]           ← factory-declared param name, value-empty

SimBroker, with two wired input slots (exposure, price), would gain slot stubs the same way the composite interior already shows them (e.g. [SimBroker(#…,#…)]) — deferred to issue #49: as shipped, SimBroker renders bare, because fan_in_identifiers/signature_of is still composite-coupled and threading the blueprint case through it was carved out (user-signed). Recorder (no params, one input, a sink) is unchanged — a bare terminal leaf. The headline is legibility: the histogram selection and Exposure's parameterisation are now on the page.

The where: section is unchanged in spirit. A composite interior already shows enriched leaves; the only addition reaching it is the same from_field edge label, which is a no-op for the all-single-output sample/macd interiors.

Implementation shape: one enriched path over the shared interior

The change is to make the enriched leaf/edge rendering reachable for the blueprint's top-level nodes + edges, and to teach the edge step the from_field label. Direction only — the planner owns the exact factoring (a shared helper over (nodes, edges, param-source), vs a thin blueprint-side call into the existing helpers); this spec does not prescribe the mechanism.

Before — blueprint leaf label is bare, edge drops field/slot:

// graph.rs:43-46 (render_blueprint, pass 1)
labels.push(match item {
    BlueprintNode::Leaf(factory) => factory.label(),          // bare
    BlueprintNode::Composite(c)  => c.name().to_string(),     // opaque (retained)
});

// graph.rs:59-61 (render_blueprint, pass 2)
for e in bp.edges() {
    edges.push((item_ids[e.from], item_ids[e.to]));           // from_field/slot dropped
}

After — top-level leaf is enriched from its factory params + wired slots; the edge carries a field label when the producer is multi-output (composites stay opaque, sinks need no special case):

// pass 1: a top-level leaf renders its factory-declared param names and its
// wired input-slot stubs — the same enrichment leaf_label already produces for
// a composite interior, sourced from factory.params() instead of a ParamAlias
// list. Composites remain opaque (#38). A sink is just a leaf with no out-edge.
labels.push(match item {
    BlueprintNode::Leaf(factory) => leaf_label_toplevel(bp, idx, factory), // enriched
    BlueprintNode::Composite(c)  => c.name().to_string(),                  // opaque
});

// pass 2: keep the (from, to) pair, add the field label when the producer has
// >1 output field — name via Composite::output() for a composite producer,
// index fallback for a (currently absent) multi-output leaf producer.
let label = field_label(bp, e);   // Some("histogram") | None for single-output
edges.push((item_ids[e.from], item_ids[e.to], label));

The edge-label rule, shared by both views through render_flat / add_edge (graph.rs:81-94):

// only-when-ambiguous, mirroring fan_in_identifiers' >1-slot guard:
//   producer single-output  -> None  (golden unchanged)
//   producer multi-output   -> Some(field_name_or_index)
fn field_label(producer_outputs: usize, from_field: usize, name: Option<&str>) -> Option<String> {
    if producer_outputs <= 1 { return None; }
    Some(name.map(str::to_string).unwrap_or_else(|| from_field.to_string()))
}

Components

  • crates/aura-cli/src/graph.rs — the only production file changed.
    • render_blueprint — pass 1 enriches top-level leaves; pass 2 adds the field label. Composite-opaque rendering retained.
    • render_flat / the add_edge call (graph.rs:81-94) — gains an optional per-edge label argument; used by render_blueprint, render_definition, and render_flat_graph alike (shared, so the label mechanism is single-owned).
    • leaf_label — generalised (or paralleled by a top-level variant) to source param names from LeafFactory::params() when there is no ParamAlias list.
    • a from_field → name resolver using Composite::output() with an index fallback.
  • crates/aura-cli/src/main.rs — golden tests re-captured (blueprint_view_golden, the macd render assertions); no production change.
  • No engine change. Blueprint/Composite/Edge/LeafFactory accessors all already exist; render reads structure + label() + params()/output() only, never eval (C9).

Data flow

Render is a pure read over graph-as-data (C9), unchanged in nature:

  1. render_blueprint(bp) walks bp.nodes(): a leaf → enriched label from factory.label() + factory.params() names + wired-slot stubs; a composite → opaque c.name().
  2. Walks bp.edges() + bp.sources(): each edge → (from, to, field_label), the label resolved from the producer's output arity and names; each source → marker node + edges to its targets.
  3. render_flat builds the ascii-dag graph with the optional labels and renders (Plain for golden/pipe, Ansi for TTY).
  4. The where: section renders each distinct composite via render_definition, now also passing field labels through the shared add_edge slot.

No eval, no compile, no ordering dependence — the same determinism the current render has (the golden tests are byte-exact, layout is RNG-free).

Error handling

  • Malformed from_field (index past the producer's output arity): the render is a best-effort debug view, not the validator (the compile path is — graph.rs:135 signature already falls back to ? rather than panicking). The field resolver falls back to the index string and never panics.
  • Multi-output leaf producer (no pre-build field names, #43): label falls back to the field index. Latent in the current corpus; handled, not assumed away.
  • ascii-dag vertical edge-label limitation: if the label cannot be placed legibly in RenderMode::Vertical, fall back to the consumer-side field locus (Architecture §from_field edge label). Observable is preserved.

Testing strategy

  • Golden re-capture, value-asserted (not just re-blessed). blueprint_view_golden (crates/aura-cli/src/main.rs:494) is re-captured; the diff must show the new information (a param name on [Exposure …]; the [SimBroker …] stubs are deferred to #49), proving enrichment rather than a blind re-bless.
  • The headline assertion (macd): a test on render_blueprint(&macd_blueprint()) asserts the macd→Exposure edge surfaces histogram and that [Exposure(scale)] shows its param — the legibility property the cycle exists to buy. Extends the existing macd_blueprint_renders_a_nested_composite_definition (crates/aura-cli/src/main.rs:585).
  • Single-output producers carry no field label — asserted on the sma_cross sample, where every producer is single-output, so the main-graph edges stay label-free (guards the only-when-ambiguous rule and keeps the sample golden honest).
  • where: unchanged in spirit: the composite-definition assertions ([EMA(fast)], [macd := Sub(#Ef,#Es)], the typed signature line) still pass — the shared edge-label change is a no-op for single-output interiors.
  • Determinism: byte-exact goldens remain the determinism guard (layout is RNG-free); no new determinism test needed.
  • Full cargo test --workspace + cargo clippy --workspace --all-targets -D warnings.

Note: the project profile declares no spec_validation parser, so the parse-every-block gate is a no-op this cycle (recorded for the Step-4 trace).

Acceptance criteria

Against aura's feature-acceptance criterion (CLAUDE.md):

  • The audience reaches for it. A researcher running aura graph on a strategy wants to see what the strategy does; legibility is the command's purpose. The worked aura graph --macd output above — histogram on the edge, scale on Exposure — is the empirical evidence.
  • Improves correctness / removes redundancy. Collapses two divergent render paths into one (less duplication) and stops the render from silently dropping from_field (more faithful).
  • Reintroduces no failure class. Render reads structure + label() only, never eval; determinism (C1), causality (C2), and the SoA hot path (C4) are untouched. Composites stay opaque (#38); sinks need no new concept (issue sink sharpening).

Concretely, the cycle is done when:

  • aura graph --macd's main graph shows the histogram field on the macd→Exposure connection and scale on the Exposure leaf. (Shipped as a consumer-side prefix [histogram → Exposure(scale)], not an edge label — ascii-dag drops edge labels on collision; see Architecture.)
  • Deferred to #49. SimBroker's two input slots render as stubs in the main graph (the same enrichment the composite interior already shows). As shipped, SimBroker renders bare — the stub machinery stays composite-coupled this cycle.
  • Single-output producers carry no field label; the sma_cross sample golden shows no spurious labels.
  • The where: section's existing assertions still pass (unchanged in spirit).
  • Composites remain opaque in the main graph (#38 retained).
  • cargo test --workspace and clippy are green; goldens re-captured with visible new information.