Files
Aura/docs/specs/0019-name-composite-boundary.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

18 KiB
Raw Blame History

Name the Composite Boundary — Design Spec

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

Goal

Make all three composite-boundary edge-kinds — input roles, params, and outputs — a uniform named projection of interior handles. Cycle 0018 (#40) did this for outputs (Vec<OutField { node, field, name }>). This cycle does the same for the other two:

  • Input role naming. input_roles: Vec<Vec<Target>> (bare, positional, rendered [in:0], [in:1]) becomes Vec<Role { name, targets }>, rendered [in:price].
  • Param aliasing. Today param_space() aggregates interior leaf params under their factory names, so the three EMA length knobs of a MACD composite all surface as macd.length — distinguishable only by slot. A composite gains a params: Vec<ParamAlias { name, node, slot }> projection that relabels a named interior param slot's surface name (fast, slow, signal).

This is pure legibility, not a capability (the load-bearing decision — see Architecture). Inputs and params already function (wireable by role index, sweepable by slot); this names slots that already work. The payoff is the full boundary signature visible in one render — conceptually, for MACD, named inputs (price), named params (fast, slow, signal), and named outputs (macd, signal, histogram). The authoritative output format is the marker DAG of render_definition (see "Render — the full named signature" below), not a one-line signature string.

The implementation commit closes #41.

Architecture

The one load-bearing decision: naming overlay, not curation

When a composite aliases a param slot, does it change the sweep surface (param_space()) — selectively exposing/hiding interior slots — or is it a pure naming overlay?

Resolved → pure naming overlay. Every interior param slot remains in param_space() and stays sweepable exactly as today; an alias only relabels one slot's surface name from the factory name to the alias. Nothing is hidden, the surface arity is unchanged, the alias is cosmetic. This is forced by two sources:

  • The issue's own framing: "Pure legibility, NOT a capability: params and inputs already work; this renames slots that already function." A selective/curation reading (the composite curates which knobs are public) is a capability the issue explicitly disclaims.
  • C23 (the design ledger): a boundary name is a non-load-bearing debug symbol; identity is positional and survives lowering, the name does not. An alias that changed what is sweepable would make the name load-bearing — a C23 violation.

Consequence, made explicit so the implementation cannot drift: a param's identity in param_space() stays its slot (position), never its name. Aliases relabel in place; they never reorder, add, or remove a slot. The existing invariant test param_space_mirrors_compiled_flat_node_param_order (slot order mirrors the flat flat graph) therefore stays green unchanged — it is the C23 anchor for this cycle.

Invariant-neutrality

  • C23 honoured. Names are non-load-bearing debug symbols dropped at lowering; identity is positional everywhere (param slot, role index, output field index). The flat graph is byte-identical — compiled_view_golden is the regression guard, exactly as #40 honoured it. The role/param/output names never reach a Box<dyn Node>.
  • C8 / C7 / C4 untouched. This is authoring-surface legibility only: no change to the runtime record shape, the four base scalars, or one-record-per-cycle.
  • param_space() surface unchanged in size, order, kind, and sweepability — only the name string of an aliased slot changes.

Orthogonal to #36

Issue #36 ("param declared twice: factory().params() and schema().params must stay in lockstep") touches the leaf param-declaration surface. #41 is purely additive at the composite level (an alias overlay consulted during aggregation). They are orthogonal: this cycle neither resolves nor depends on #36, and the spec deliberately does not entangle them.

Concrete code shapes

Worked author example (the acceptance evidence) — MACD

The MACD composite (crates/aura-cli/src/main.rs) is the worked example: three interior EMAs each carry a length param, so today its boundary reads by position only. Below is the author site before → after.

Before (today — one bare role, params anonymous, outputs already named in #40):

fn macd(name: &str) -> Composite {
    Composite::new(
        name,
        vec![
            Ema::factory().into(), // 0 fast EMA
            Ema::factory().into(), // 1 slow EMA
            Sub::factory().into(), // 2 MACD line = fast  slow
            Ema::factory().into(), // 3 signal EMA of the MACD line
            Sub::factory().into(), // 4 histogram = MACD line  signal
        ],
        vec![
            Edge { from: 0, to: 2, slot: 0, from_field: 0 },
            Edge { from: 1, to: 2, slot: 1, from_field: 0 },
            Edge { from: 2, to: 3, slot: 0, from_field: 0 },
            Edge { from: 2, to: 4, slot: 0, from_field: 0 },
            Edge { from: 3, to: 4, slot: 1, from_field: 0 },
        ],
        vec![vec![
            Target { node: 0, slot: 0 }, // price → fast EMA
            Target { node: 1, slot: 0 }, // price → slow EMA
        ]],
        vec![
            OutField { node: 2, field: 0, name: "macd".into() },
            OutField { node: 3, field: 0, name: "signal".into() },
            OutField { node: 4, field: 0, name: "histogram".into() },
        ],
    )
}

macd_blueprint().param_space() today surfaces (the three EMA length knobs, indistinguishable by name):

["macd.length", "macd.length", "macd.length"]

After (this cycle — named role, aliased params; outputs unchanged):

fn macd(name: &str) -> Composite {
    Composite::new(
        name,
        vec![
            Ema::factory().into(), // 0 fast EMA
            Ema::factory().into(), // 1 slow EMA
            Sub::factory().into(), // 2 MACD line = fast  slow
            Ema::factory().into(), // 3 signal EMA of the MACD line
            Sub::factory().into(), // 4 histogram = MACD line  signal
        ],
        vec![
            Edge { from: 0, to: 2, slot: 0, from_field: 0 },
            Edge { from: 1, to: 2, slot: 1, from_field: 0 },
            Edge { from: 2, to: 3, slot: 0, from_field: 0 },
            Edge { from: 2, to: 4, slot: 0, from_field: 0 },
            Edge { from: 3, to: 4, slot: 1, from_field: 0 },
        ],
        vec![Role {
            name: "price".into(),
            targets: vec![
                Target { node: 0, slot: 0 }, // price → fast EMA
                Target { node: 1, slot: 0 }, // price → slow EMA
            ],
        }],
        vec![
            ParamAlias { name: "fast".into(), node: 0, slot: 0 },   // fast EMA length
            ParamAlias { name: "slow".into(), node: 1, slot: 0 },   // slow EMA length
            ParamAlias { name: "signal".into(), node: 3, slot: 0 }, // signal EMA length
        ],
        vec![
            OutField { node: 2, field: 0, name: "macd".into() },
            OutField { node: 3, field: 0, name: "signal".into() },
            OutField { node: 4, field: 0, name: "histogram".into() },
        ],
    )
}

macd_blueprint().param_space() now surfaces (same slots, same order, same kinds — only the names change):

["macd.fast", "macd.slow", "macd.signal"]

The injected point vector (sample_point / the MACD point) is unchanged — slot order is identical; only the labels improved. This is the empirical proof of the naming-overlay decision: the sweep surface is byte-for-byte the same shape, the author just reads it by name now.

Render — the full named signature

aura graph --macd renders the composite definition. Input roles render [in:<name>] (was [in:<index>]); outputs already render [out:<name>] (#40); aliased params render [param:<name>] markers wired into the leaf they configure (direction: marker → node, like [in:]). Marker labels stay short (one word) to avoid the ascii-dag wide-sibling-label garble. The MACD definition gains:

[in:price]   (was [in:0])
[param:fast]   → EMA(0)
[param:slow]   → EMA(1)
[param:signal] → EMA(3)
[out:macd] [out:signal] [out:histogram]   (unchanged, #40)

The compiled view (aura graph --macd --compiled) stays name-free — the boundary dissolves at inline, markers gone (C23). compiled_view_golden is byte-identical.

Secondary — engine struct / signature shapes (before → after)

Role (new) and ParamAlias (new) in crates/aura-engine/src/blueprint.rs, each #[derive(Clone, Debug, PartialEq, Eq)] (mirroring Target / OutField):

/// One named input role: role `r` (by position) fans the source value into
/// `targets`. The name is a non-load-bearing render symbol (C23); identity is
/// the role index, which survives lowering.
pub struct Role {
    pub name: String,
    pub targets: Vec<Target>,
}

/// A composite-level alias relabelling one interior leaf param slot's surface
/// name in `param_space()`. `node` is the interior item index, `slot` the param
/// slot within that leaf. Pure legibility: the alias relabels in place and never
/// reorders, adds, or removes a slot (C23 — identity stays the slot).
pub struct ParamAlias {
    pub name: String,
    pub node: usize,
    pub slot: usize,
}

Composite gains a params field; input_roles changes element type:

// before
pub struct Composite {
    name: String,
    nodes: Vec<BlueprintNode>,
    edges: Vec<Edge>,
    input_roles: Vec<Vec<Target>>,
    output: Vec<OutField>,
}

// after
pub struct Composite {
    name: String,
    nodes: Vec<BlueprintNode>,
    edges: Vec<Edge>,
    input_roles: Vec<Role>,
    params: Vec<ParamAlias>,
    output: Vec<OutField>,
}

Composite::new takes input_roles: Vec<Role> and a new params: Vec<ParamAlias> argument (positioned between input_roles and output, matching the inputs → params → outputs signature order). New accessors:

pub fn input_roles(&self) -> &[Role] { &self.input_roles }
pub fn params(&self) -> &[ParamAlias] { &self.params }   // new
// output() unchanged

collect_params (the param_space() walk) threads the directly-containing composite's alias slice and consults it when naming a leaf param:

// before: fn collect_params(items: &[BlueprintNode], prefix: &str, out: &mut Vec<ParamSpec>)
// after: aliases is the alias list of the composite directly containing `items`
//        (empty &[] at the top level).
fn collect_params(
    items: &[BlueprintNode],
    prefix: &str,
    aliases: &[ParamAlias],
    out: &mut Vec<ParamSpec>,
) {
    for (i, item) in items.iter().enumerate() {
        match item {
            BlueprintNode::Leaf(factory) => {
                for (s, p) in factory.params().iter().enumerate() {
                    // an alias for this exact (node, slot) relabels in place;
                    // otherwise the factory param name, as today.
                    let local = aliases
                        .iter()
                        .find(|a| a.node == i && a.slot == s)
                        .map(|a| a.name.as_str())
                        .unwrap_or(p.name.as_str());
                    let name = if prefix.is_empty() {
                        local.to_string()
                    } else {
                        format!("{prefix}.{local}")
                    };
                    out.push(ParamSpec { name, kind: p.kind });
                }
            }
            BlueprintNode::Composite(c) => {
                let child = if prefix.is_empty() {
                    c.name().to_string()
                } else {
                    format!("{prefix}.{}", c.name())
                };
                collect_params(c.nodes(), &child, c.params(), out);
            }
        }
    }
}

Blueprint::param_space seeds the top level with no aliases: collect_params(&self.nodes, "", &[], &mut out).

inline_composite (the compile-path role walk, ~blueprint.rs:342) iterates Roles instead of bare target vecs — for (r, role) in input_roles.iter().enumerate() becomes a walk over role.targets; the role's name is inert at compile (dropped, C23). The destructure let Composite { name: _, nodes, edges, input_roles, output } = c; gains params: _ (params do not lower — they are a read-side projection only).

render_definition (crates/aura-cli/src/graph.rs): the input-role loop reads role.name for the marker label and role.targets for the edges; a new loop emits [param:<name>] markers for c.params(), each wired marker → alias.node.

Components

Component File Change
Role struct crates/aura-engine/src/blueprint.rs new; re-exported from lib.rs
ParamAlias struct crates/aura-engine/src/blueprint.rs new; re-exported from lib.rs
Composite crates/aura-engine/src/blueprint.rs input_roles: Vec<Role>; new params: Vec<ParamAlias>; new arg; params() accessor
collect_params crates/aura-engine/src/blueprint.rs thread aliases: &[ParamAlias]; relabel matched slot
inline_composite crates/aura-engine/src/blueprint.rs walk role.targets; destructure params: _
render_definition crates/aura-cli/src/graph.rs [in:<name>]; [param:<name>] markers
MACD + sample author sites crates/aura-cli/src/main.rs Role { … }, ParamAlias { … }
Construction-layer fixtures fieldtests/milestone-construction-layer/mc_*.rs Role { … } sweep (single unnamed role → Role { name, targets })

Data flow

  1. Author writes a Composite with Vec<Role>, Vec<ParamAlias>, Vec<OutField>.
  2. param_space() walks the tree; for each leaf param slot it picks the alias name (if the directly-containing composite aliases that (node, slot)) or the factory name, path-qualified by composite name()s as today. Slot order is unchanged — aliases relabel, never reorder.
  3. compile_with_params lowers exactly as today: roles fan in by targets, params bind slot-by-slot to the injected vector, names dropped (C23). An out-of-range alias (node, slot) is rejected here (see Error handling).
  4. Render surfaces names as markers in the blueprint view; the compiled view is name-free.

Error handling

  • Alias references a missing/non-leaf interior node or a slot beyond the leaf's param count. (A composite interior item has no params, so any slot on it is out of range.) Rejected at compile_with_params (the fallible, tree-walking path that already range-checks edges and outputs) as CompileError::BadInteriorIndex — reusing the existing variant, no new variant (the same widening posture #40 took for outputs). param_space() stays infallible: an alias that matches no emitted (node, slot) is simply inert there (no slot is relabelled), and the structural defect surfaces at compile. This keeps param_space() deterministic and slot-stable regardless of alias validity.
  • Role with a bad target — unchanged: already BadInteriorIndex / RoleKindMismatch { role } at compile.
  • Duplicate aliases for the same (node, slot). First match wins (find); benign and not worth a dedicated error — the surface is still a valid relabel.

Testing strategy

Engine unit tests (crates/aura-engine/src/blueprint.rs):

  1. Alias relabels in place. A composite with two same-type leaves + two aliases → param_space() names are the aliases (e.g. ["c.shortLen", "c.longLen"]), not duplicate factory names.
  2. Unaliased params unchanged (regression). A composite with params: vec![]param_space() identical to today's path-qualified factory names.
  3. Slot order invariant (C23 anchor). param_space_mirrors_compiled_flat_node_param_order stays green unchanged — aliases relabel, never reorder; the slot still mirrors the flat graph. (No new test; the existing one is the guard.)
  4. Out-of-range alias → BadInteriorIndex at compile_with_params (alias node/slot past the interior leaf's params).
  5. Partial aliasing. A composite aliasing one of two slots → the aliased slot takes the alias name, the other keeps its factory name; order intact.

CLI render test (crates/aura-cli):

  1. Named role + param markers render. render_definition of the MACD (or a fixture) composite contains [in:price], [param:fast], [param:slow], [param:signal] and the existing [out:*]; asserts no [in:0].
  2. compiled_view_golden byte-identical (C23 regression — names dropped at lowering). If it drifts, that is a bug, not a golden to re-capture.

Author-site / build:

  1. cargo build/test/clippy --workspace (-D warnings) all green; the MACD run (aura run --macd) stays deterministic (same metrics as 0018 — params only got names, the injected vector is unchanged).

Acceptance criteria

  • A trader authoring a multi-line indicator reads macd.fast / macd.slow / macd.signal and [in:price] instead of three indistinguishable macd.length and [in:0] — the worked MACD before → after is the evidence the project's intended audience naturally reaches for this.
  • param_space() surface is unchanged in size, order, kind, and sweepability; only names change (the naming-overlay decision, empirically shown by the unchanged point vector).
  • C23 preserved: compiled_view_golden byte-identical; no role/param/output name reaches a Box<dyn Node>.
  • cargo build/test/clippy --workspace (-D warnings) green; aura run --macd deterministic and unchanged in metrics.
  • Closes #41.