feat(aura): node signature lives in the blueprint; collapse Blueprint into Composite

Consolidate the node data structure so every node's signature (NodeSchema:
inputs/output/params) is declared once and exists in the blueprint pre-build,
and dissolve the special "root graph" type. Behaviour-preserving (C1).

Signature vs sizing
- NodeSchema is now the static signature only: InputSpec -> PortSpec{kind,firing},
  with lookback removed. The signature is fully static per blueprint (input
  kinds/firing, output fields, params); LinComb's variable arity is a builder arg,
  not an injected param.
- The one param-dependent quantity, an input's buffer lookback (e.g. Sma's window =
  its injected length), moves out of the signature to Node::lookbacks() -> Vec<usize>,
  read only by bootstrap for sizing. Node::schema() is removed.
- LeafFactory -> PrimitiveBuilder, which carries the full NodeSchema. The built node
  no longer re-declares it: closes the params-declared-twice drift (#36, the 8
  per-node factory_params_match_built_node_schema lockstep tests are deleted — their
  subject is now structurally impossible) and a value-empty recipe exposes its full
  I/O interface pre-build (#43).

Root is just a bound composite
- struct Blueprint is deleted; its compile/bootstrap/param_space methods move onto
  Composite. Role gains source: Option<ScalarKind> (None = open interior port,
  Some = bound ingestion feed). A composite is runnable iff every root role is bound;
  the "main graph" is no longer a category, only the fully-source-bound composite.
  New error CompileError::UnboundRootRole for an open root role.
- BlueprintNode::signature() answers uniformly for both arms: Primitive returns the
  builder's declared schema, Composite derives it from the interior (role kinds in,
  OutField kinds out, aggregated params), pre-build, no build.

compile -> FlatGraph -> bootstrap
- compile validates structure pre-build via signature() (validate_wiring: range +
  kind, returning the same variants as before, so an edge kind fault is now caught
  before any build closure fires) and emits FlatGraph{nodes,signatures,sources,edges}.
- bootstrap consumes the FlatGraph: kinds/firing/output from the carried signatures,
  buffer depth from node.lookbacks(). SourceSpec survives as the flat descriptor.

Renames: BlueprintNode::Leaf -> Primitive, LeafFactory -> PrimitiveBuilder.

Render (aura-cli/src/graph.rs) is migrated compile-only: it takes &Composite, maps
bound roles to the same source-entry shape, so both render goldens reproduce
byte-identical output (no re-capture needed). Render-fidelity tuning is the next cycle.

Verification (orchestrator-run, not agent-reported): cargo build --workspace green;
cargo test --workspace 150 passed / 0 failed; cargo clippy --workspace --all-targets
-D warnings clean. All pinned determinism/run-output tests pass with values unchanged;
no behavioural assertion was altered to go green. 5 new tests assert the signature is
pre-build and uniform, that compile rejects a kind mismatch without building (via a
panicking builder), UnboundRootRole, and lookbacks()/signature arity agreement.

Deferred to cycle-close audit (per plan): docs/design/INDEX.md and some aura-std
module docs still name the old Node::schema()/LeafFactory/BlueprintNode::Leaf/
Blueprint::param_space contracts; prose reconciliation is the architect's at audit.

closes #43 #36
This commit is contained in:
2026-06-09 12:49:22 +02:00
parent c7fc2f6a37
commit 1b3909316e
17 changed files with 1398 additions and 775 deletions
+26 -20
View File
@@ -15,10 +15,10 @@
use ascii_dag::graph::{Graph, RenderMode};
use ascii_dag::render::colors::Palette;
use aura_core::{LeafFactory, Node, ScalarKind};
use aura_core::{Node, PrimitiveBuilder, ScalarKind};
use aura_engine::{
aliases_on, signature_of, Blueprint, BlueprintNode, Composite, Edge, OutField, ParamAlias,
SourceSpec, Target,
aliases_on, signature_of, BlueprintNode, Composite, Edge, OutField, ParamAlias, SourceSpec,
Target,
};
/// Where a leaf's param **names** come from in the shared leaf-label path — the one
@@ -58,18 +58,24 @@ pub enum Color {
/// 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, color: Color) -> String {
// the main graph is the shared graph core (`render_graph`) over the blueprint'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 factory (no alias overlay at the root); the entries are sources, not roles;
// there is no output record (terminals are sinks) and no title.
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.
let entries: Vec<Entry> = bp
.sources()
.input_roles()
.iter()
.map(|src| Entry { name: format!("source:{:?}", src.kind), targets: &src.targets })
.filter_map(|role| {
role.source.map(|kind| Entry {
name: format!("source:{kind:?}"),
targets: &role.targets,
})
})
.collect();
let main = render_graph(
bp.nodes(),
@@ -94,7 +100,7 @@ pub fn render_blueprint(bp: &Blueprint, color: Color) -> String {
/// The shared graph core both views render through (#48): a flat ascii-dag of the
/// computation DAG plus external-entry markers. `nodes`/`edges` are a borrowed
/// (blueprint-root or composite-interior) graph slice — never an owned
/// `Composite` (`LeafFactory` is not `Clone`). Each leaf gets the unified
/// `Composite` (`PrimitiveBuilder` is not `Clone`). Each leaf gets the unified
/// [`leaf_label`]; each composite is opaque (`[name]`); a producing node carrying an
/// `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
@@ -115,7 +121,7 @@ fn render_graph(
let mut labels: Vec<String> = Vec::with_capacity(nodes.len());
for (i, item) in nodes.iter().enumerate() {
let base = match item {
BlueprintNode::Leaf(factory) => {
BlueprintNode::Primitive(factory) => {
leaf_label(nodes, edges, entries, i, factory, &param_names, stub_ctx)
}
BlueprintNode::Composite(c) => c.name().to_string(),
@@ -166,7 +172,7 @@ fn render_flat(labels: &[String], edges: &[(usize, usize)], color: Color) -> Str
/// `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 collect_distinct_composites(bp: &Composite) -> 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
@@ -209,7 +215,7 @@ fn signature(c: &Composite) -> String {
.nodes()
.get(a.node)
.and_then(|n| match n {
BlueprintNode::Leaf(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)),
BlueprintNode::Primitive(f) => f.params().get(a.slot).map(|p| kind_str(p.kind)),
BlueprintNode::Composite(_) => None,
})
.unwrap_or("?");
@@ -247,7 +253,7 @@ fn leaf_label(
edges: &[Edge],
entries: &[Entry],
index: usize,
factory: &LeafFactory,
factory: &PrimitiveBuilder,
param_names: &ParamNames,
stub_ctx: Option<&Composite>,
) -> String {
@@ -334,7 +340,7 @@ fn multi_output_field_name(nodes: &[BlueprintNode], from: usize, from_field: usi
None
}
}
BlueprintNode::Leaf(_) => (from_field > 0).then(|| from_field.to_string()),
BlueprintNode::Primitive(_) => (from_field > 0).then(|| from_field.to_string()),
}
}
@@ -396,7 +402,7 @@ fn slot_source(c: &Composite, index: usize, slot: usize) -> (String, usize, bool
/// rendered identifier never goes below.
fn signature_base_len(c: &Composite, node: usize) -> usize {
match &c.nodes()[node] {
BlueprintNode::Leaf(_) => 1 + aliases_on(c.params(), node).count(),
BlueprintNode::Primitive(_) => 1 + aliases_on(c.params(), node).count(),
BlueprintNode::Composite(_) => 1,
}
}