Files
Aura/docs/specs/0036-sample-showcase-blueprint.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

15 KiB

Sample Showcase Blueprint — Design Spec

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

Goal

Replace the single-level sample_blueprint_with_sinks — the blueprint aura graph renders and aura sweep runs — with a richer "trend + momentum, weighted blend" strategy that exercises four authoring/viewer capabilities the current sample leaves dark:

  1. Multiply-nested composites — a composite inside a composite inside the root (three levels), where today the sample is single-level.
  2. A node with multiple parameters, inside a composite — today the only multi-param surface (LinComb) appears in no rendered sample.
  3. A node with multiple outputs (MACD-like) — today every rendered node has exactly one output column.
  4. bind() — a declared param fixed as a structural constant, which the current sample never demonstrates.

Settled decision (chat, option A): one shared sample. The viewer shows exactly the graph aura sweep runs — no render-only divergence. This is pure authoring over already-shipped primitives: no engine semantics change, no graph-viewer.js change. The serializer, compiler, and viewer already recurse over arbitrary nesting; multi-output already serialises and renders (via a composite re-exporting >1 field); bind() already removes a param from the surface. The cycle wires these into the sample, nothing more.

Architecture

The enriched sample is the same five-node signal-quality root harness as today (signals → Exposure → SimBroker → two Recorder sinks), with node [0] changed from a bare sma_cross composite to a new signals composite that blends a trend signal and a momentum signal:

root "sample"                                  (price source → … → sinks)
├─ [0] signals  (composite)                    ── nesting level 1
│       price ─┬─► trend
│              └─► momentum
│       ├─ [0] trend    = sma_cross("trend")   ── nesting level 2  ▸ MULTIPLY NESTED
│       │        fast: SMA[length] · slow: SMA[length] · Sub → "cross"
│       ├─ [1] momentum = macd("momentum")     ── ▸ MULTIPLE OUTPUTS (macd, signal, histogram)
│       │        fast:EMA · slow:EMA · Sub · signal:EMA · Sub → 3 re-exported fields
│       └─ [2] blend    = LinComb(3).named("blend")   ── ▸ MULTIPLE PARAMS, inside a composite
│                term[0]←trend.cross · term[1]←momentum.histogram · term[2]←momentum.signal
│                weights[0], weights[1] free · weights[2] = bind(const)   ── ▸ bind()
│       re-export blend.value → "signal"
├─ [1] Exposure
├─ [2] SimBroker
├─ [3] Recorder (equity sink)
└─ [4] Recorder (exposure sink)

Both interior composites are built by the existing builders sma_cross(name) and macd(name) (no duplication); signals is the only new composite. The blend combines three f64 legs — the trend spread, the MACD histogram, and the MACD signal line — so it both demonstrates multi-param and visibly consumes two of the momentum composite's three outputs.

bind() earns its place structurally, not just cosmetically. The built-in sweep requires a bijection between sweep axes and free params (the sweep_family().expect("the built-in named grid matches the sample param-space") is exactly that check). Every unbound param therefore forces a sweep axis. Binding blend.weights[2] removes it from the param-space, so it needs no axis — a bound param is a structural constant that drops out of the tuning surface (C23: bind resolves the param name to a fixed position; the flat graph is unchanged).

Blast radius.

  • Changed: sample_blueprint_with_sinks, the new signals builder, the LinComb import, sweep_family (re-pathed axes), a new longer warm-up price stream for the sweep, and the re-captured sample-model.json fixture.
  • Unchanged: run_sample / sample_harness (the flat hand-wired 7-tick SMA-cross harness behind aura run); run_macd / macd() / macd_point (the separate MACD proof-of-concept behind aura run --macd); the inline model_golden test in graph_model.rs (it uses its own minimal sample_root(), not the CLI sample); and graph-viewer.js.

Concrete code shapes

The authoring surface (what a researcher writes — the criterion's evidence)

The new signals composite. Note LinComb::builder(3) fixes the arity (3 input terms, 3 weight params); .named("blend") makes the blend's param path signals.blend.weights[i] (and renders the blend: prefix); .bind("weights[2]", …) fixes the third weight as a constant, removing it from the param-space while leaving its input term wired:

/// The blended signal: a trend leg (SMA-cross) and a momentum leg (MACD),
/// combined by a weighted sum. A multiply-nested composite (root → signals →
/// {trend, momentum}); the blend is a multi-param node living inside it, with
/// one weight bound as a structural constant.
fn signals(name: &str) -> Composite {
    Composite::new(
        name,
        vec![
            BlueprintNode::Composite(sma_cross("trend")), // 0 trend leg
            BlueprintNode::Composite(macd("momentum")),   // 1 momentum leg (3 outputs)
            // 2 blend: Σ wᵢ·termᵢ over [trend.cross, momentum.histogram, momentum.signal].
            // weights[2] is bound (the fixed signal-line weight); weights[0]/[1] stay tunable.
            LinComb::builder(3)
                .named("blend")
                .bind("weights[2]", Scalar::F64(0.5))
                .into(),
        ],
        vec![
            Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // trend.cross        → blend.term[0]
            Edge { from: 1, to: 2, slot: 1, from_field: 2 }, // momentum.histogram → blend.term[1]
            Edge { from: 1, to: 2, slot: 2, from_field: 1 }, // momentum.signal    → blend.term[2]
        ],
        vec![Role {
            name: "price".into(),
            targets: vec![
                Target { node: 0, slot: 0 }, // price → trend role 0
                Target { node: 1, slot: 0 }, // price → momentum role 0
            ],
            source: None,
        }],
        vec![OutField { node: 2, field: 0, name: "signal".into() }],
    )
}

The re-pathed sweep axes — one axis per free param (the bijection the sweep validates), two real axes on the trend lengths and the rest singletons, so the grid stays at 4 points; signals.blend.weights[2] is bound and therefore absent:

bp.axis("signals.trend.fast.length", [2, 3])
    .axis("signals.trend.slow.length", [4, 5])
    .axis("signals.momentum.fast.length", [2])
    .axis("signals.momentum.slow.length", [4])
    .axis("signals.momentum.signal.length", [3])
    .axis("signals.blend.weights[0]", [1.0])
    .axis("signals.blend.weights[1]", [1.0])
    .axis("exposure.scale", [0.5])
    .sweep(|point| { /* unchanged fold; uses the longer warm-up stream below */ })

Before → after (the load-bearing edits)

sample_blueprint_with_sinks — node [0] becomes the signals composite; the root edges and the price role are structurally unchanged (signals re-exports a single f64 "signal", the same shape sma_cross re-exported as "cross"):

// before — node 0 is the bare sma_cross composite
vec![
    BlueprintNode::Composite(sma_cross("sma_cross")),
    Exposure::builder().into(),
    SimBroker::builder(0.0001).into(),
    Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
    Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],
// after — node 0 is the blended signals composite
vec![
    BlueprintNode::Composite(signals("signals")),
    Exposure::builder().into(),
    SimBroker::builder(0.0001).into(),
    Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
    Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
],

The longer warm-up stream — the MACD EMAs (an EMA of an EMA) and the all-Any LinComb join withhold until every leg is warm, so the sweep needs more than the 7-tick synthetic_prices(). A sample-local rise-fall-rise stream (~18 ticks, the shape macd_prices already uses) gives the blended signal room to move:

/// A warm-up-adequate synthetic stream for the showcase sweep: rises, falls, then
/// rises again so the trend spread and the MACD histogram both flip sign after the
/// EMAs warm. Sample-local and deterministic (C1); the flat `run_sample` keeps the
/// shorter `synthetic_prices`.
fn showcase_prices() -> Vec<(Timestamp, Scalar)> { /* ~18 deterministic f64 ticks */ }

The import gains LinComb:

use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};

Components

Component File Change
signals(name) -> Composite crates/aura-cli/src/main.rs new — the blended trend+momentum composite (the only new composite)
sample_blueprint_with_sinks crates/aura-cli/src/main.rs modified — node [0] becomes signals("signals")
showcase_prices() -> Vec<…> crates/aura-cli/src/main.rs new — ~18-tick warm-up stream for the sweep closure
sweep_family crates/aura-cli/src/main.rs modified — axes re-pathed to the 8 free params; closure uses showcase_prices
LinComb import crates/aura-cli/src/main.rs modified — add to the aura_std use
sample-model.json crates/aura-cli/tests/fixtures/sample-model.json re-captured — the enriched model
depth-2 render guard crates/aura-cli/tests/ new — see Testing strategy
sma_cross(name), macd(name) crates/aura-cli/src/main.rs reused as-is — no edit

Data flow

A single f64 price source fans into signals (role price) and the SimBroker price slot. Inside signals, price fans to both the trend (SMA-cross) and momentum (MACD) composites. trend emits one f64 cross; momentum re-exports three f64 fields (macd, signal, histogram). The blend LinComb(3) reads trend.cross (term 0), momentum.histogram (from_field: 2, term 1), and momentum.signal (from_field: 1, term 2), emitting one f64 value, re-exported by signals as signal. The root feeds signal to Exposure → SimBroker, and taps equity (off the broker) and exposure (off Exposure) into the two recording sinks. Every port is f64; one record per eval everywhere (C8 — multi-output is the composite re-exporting columns, not a primitive emitting >1 record).

Param-space (the flat, path-qualified, injective surface — C12/C19): the eight free params signals.trend.fast.length, signals.trend.slow.length, signals.momentum.fast.length, signals.momentum.slow.length, signals.momentum.signal.length, signals.blend.weights[0], signals.blend.weights[1], exposure.scale. signals.blend.weights[2] is bound and absent from this surface.

Error handling

  • Sweep bijection. sweep_family() ends in .expect("the built-in named grid matches the sample param-space"): the axes must cover exactly the free params, no more, no fewer. The eight axes above are exactly the eight free params. Adding the signals nesting re-paths every existing axis (sma_cross.fast.lengthsignals.trend.fast.length), so leaving an old path in place would panic — the planner must re-path all of them.
  • Bind kind-match. bind("weights[2]", Scalar::F64(_)) must match the param's declared kind (F64); a mismatch panics at authoring time (by design). The weight is f64, so the constant is Scalar::F64.
  • Warm-up, not an error. On too-short a stream the LinComb join simply emits None until every leg is warm — deterministic, not a failure, but it yields an empty/degenerate equity trace. showcase_prices exists to avoid that; the run stays deterministic (C1) either way.
  • No new failure surface. No new node, no new edge kind, no new param kind — the compile-time checks (acyclicity, fan-in, kind-match, param-space injectivity) that pass for the current sample pass for the enriched one.

Testing strategy

  • Re-capture sample-model.json from the enriched model_to_json(&sample_blueprint()). The fixture is consumed by viewer_dot_ids.mjs (DOT-id validity) and by the render_html_is_self_contained_and_embeds_the_model test (which asserts "type":"Exposure" and a {"root": prefix — both still hold, since the root still contains Exposure and starts at root).
  • New depth-2 render guard (a headless node guard in the family of viewer_dot_ids.mjs / viewer_name_prefix.mjs, plus its .rs wrapper): drive the exported genDot and expand the sample recursively to depth 2 — discover the top-level expandable (signals), expand it, re-discover the newly-revealed expandables (trend, momentum), expand those, and assert every DOT node identifier stays a valid Graphviz identifier at every depth. This protects the multiply-nested-composite claim beyond the existing depth-1 guard (the cid = "n" + cpath.join("__") rule must hold for n0__1__2-shaped ids, not just n0__0).
  • Sweep test / golden re-captured. The enriched signal moves the per-point metrics; any test pinning sweep_report() output (or the sweep point count / param names) is re-captured. The point count stays 4.
  • Untouched green tests stay green. run_macd_compiles_from_nested_composite_and_is_deterministic, macd_param_space_surfaces_the_three_named_legs, and the model_golden inline test do not reference the CLI sample and must not change.
  • Manual. aura graph > /tmp/.../index.html then expand signals, trend, momentum in the browser to confirm: three nesting levels drill/expand; the momentum box shows three output ports; blend: LinComb[weights[0], weights[1]] renders with exactly two visible params (the bound weights[2] absent).

Acceptance criteria

  1. aura graph renders the enriched sample: signals is a composite containing the trend and momentum composites (three nesting levels), reachable by expand/drill.
  2. momentum renders with three output ports (macd, signal, histogram); blend consumes two of them.
  3. blend renders as blend: LinComb[weights[0], weights[1]] — a multi-param node inside a composite, with the bound weights[2] not shown.
  4. aura sweep runs the same blueprint deterministically over a 4-point grid; its param names are the eight free, re-pathed params; signals.blend.weights[2] never appears as a sweep param.
  5. sma_cross and macd are reused unmodified; run_sample, run_macd, the inline model_golden test, and graph-viewer.js are unchanged.
  6. cargo build --workspace, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings are green; the new depth-2 render guard passes.