Files
Aura/docs/plans/0036-sample-showcase-blueprint.md
T
Brummel 30c1ad5e2e plan: 0036 sample-showcase-blueprint
Five tasks for the sample-showcase cycle: (1) the enriched blueprint source —
LinComb import, the `signals` composite (trend = sma_cross, momentum = macd, blend
= LinComb(3) with weights[2] bound), the node-0 swap in sample_blueprint_with_sinks,
the re-pathed sweep_family axes + a showcase_prices warm-up stream; (2) re-path the
two in-crate tests (bootstrap-runs-drains, sweep odometer); (3) re-capture the
sample-model.json render fixture; (4) a new headless depth-2 nesting guard
(viewer_nested_depth.mjs + .rs); (5) the workspace build/test/clippy gate.

refs #62
2026-06-13 20:12:10 +02:00

20 KiB

Sample Showcase Blueprint — Implementation Plan

Parent spec: docs/specs/0036-sample-showcase-blueprint.md

For agentic workers: REQUIRED SUB-SKILL: use the implement skill to run this plan. Steps use - [ ] checkboxes for tracking.

Goal: Enrich the aura graph / aura sweep sample blueprint into a "trend + momentum, weighted blend" strategy that demonstrates multiply-nested composites, a multi-param node inside a composite, a multi-output (MACD-like) node, and bind().

Architecture: A new signals composite (root → signals → {trend = sma_cross, momentum = macd}, plus a LinComb(3) blend) replaces the bare sma_cross at node 0 of sample_blueprint_with_sinks. Pure authoring over already-shipped primitives — no engine/viewer change. The sweep re-paths its axes to the new param-space; a longer warm-up stream lets the enriched signal run; the render fixture is re-captured; a new headless guard protects depth-2 nesting.

Tech Stack: crates/aura-cli/src/main.rs (the sample + sweep + their in-crate tests), crates/aura-cli/tests/fixtures/sample-model.json (render golden), a new crates/aura-cli/tests/ headless guard pair. Reuses aura_std::LinComb + the existing sma_cross/macd builders. No change to aura-engine or graph-viewer.js.


Files this plan creates or modifies:

  • Modify: crates/aura-cli/src/main.rs:17 — add LinComb to the aura_std import.
  • Modify: crates/aura-cli/src/main.rs — add fn signals(name: &str) -> Composite and fn showcase_prices() -> Vec<(Timestamp, Scalar)>; swap node 0 of sample_blueprint_with_sinks (:173); re-path the sweep_family axes (:228-230) and swap its closure stream (:236).
  • Modify: crates/aura-cli/src/main.rs:503-540 — re-path the two in-crate tests (sample_blueprint_with_sinks_bootstraps_runs_and_drains, sweep_report_renders_four_points_in_odometer_order).
  • Modify: crates/aura-cli/tests/fixtures/sample-model.json — re-captured enriched model.
  • Create: crates/aura-cli/tests/viewer_nested_depth.mjs — headless depth-2 DOT-id guard.
  • Create: crates/aura-cli/tests/viewer_nested_depth.rs.rs wrapper that runs the .mjs under node.

Untouched (must stay green): run_sample/sample_harness, run_macd/macd()/ macd_point, crates/aura-engine/src/graph_model.rs model_golden (own minimal sample_root()), crates/aura-cli/assets/graph-viewer.js, crates/aura-cli/src/render.rs.

Parse-the-bytes gate (planner self-review item 9): the project declares no spec-validation parsers in its CLAUDE.md project facts → documented no-op. The inlined Rust is source-language (the implement compile gate catches it, not the parse gate); the .mjs is JS with no declared parser; the fixture JSON is machine-generated (Task 3), not hand-inlined. Gate fires empty.


Task 1: Enriched blueprint source

Files:

  • Modify: crates/aura-cli/src/main.rs:17 (import), plus new fns + the sample_blueprint_with_sinks node-0 swap (:173) + sweep_family axes (:228-230) and stream (:236).

  • Step 1: Add LinComb to the aura_std import

Replace crates/aura-cli/src/main.rs:17:

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

with:

use aura_std::{Ema, Exposure, LinComb, Recorder, SimBroker, Sma, Sub};
  • Step 2: Add the signals composite builder

Insert this fn immediately after fn sma_cross(...) (which ends at :155), before fn sample_blueprint_with_sinks:

/// 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 (so it drops out of the sweepable surface).
fn signals(name: &str) -> Composite {
    Composite::new(
        name,
        vec![
            BlueprintNode::Composite(sma_cross("trend")), // 0 trend leg (one f64 "cross")
            BlueprintNode::Composite(macd("momentum")),   // 1 momentum leg (3 outputs)
            // 2 blend: Σ wᵢ·termᵢ over [trend.cross, momentum.histogram, momentum.signal].
            // weights[2] is bound (a fixed signal-line weight) → removed from param_space;
            // weights[0]/[1] stay tunable. `.named("blend")` makes the path signals.blend.*
            // (and renders the `blend:` prefix).
            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() }],
    )
}
  • Step 3: Add the showcase_prices warm-up stream

Insert this fn next to signals (anywhere in the non-test region; suggested: right after synthetic_prices, which ends at :36):

/// A warm-up-adequate synthetic stream for the enriched sample/sweep: ~18 ticks
/// rising, falling, then rising again so the trend SMA spread and the MACD
/// EMA-of-EMA histogram both warm up and flip sign. It shares the proven warm-up
/// profile of `macd_prices` (the enriched sample embeds the same `macd` composite,
/// so it needs the same warm-up length); the flat `run_sample` keeps the shorter
/// `synthetic_prices`. Deterministic and fixed (C1).
fn showcase_prices() -> Vec<(Timestamp, Scalar)> {
    [
        1.0000_f64, 1.0008, 1.0021, 1.0039, 1.0062, 1.0090, 1.0083, 1.0061, 1.0034,
        1.0012, 0.9998, 1.0006, 1.0024, 1.0047, 1.0069, 1.0086, 1.0097, 1.0092,
    ]
    .iter()
    .enumerate()
    .map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
    .collect()
}
  • Step 4: Swap node 0 of sample_blueprint_with_sinks

Replace crates/aura-cli/src/main.rs:173:

            BlueprintNode::Composite(sma_cross("sma_cross")),

with:

            BlueprintNode::Composite(signals("signals")),

(The other four root nodes, the root edges :179-184, and the price role :185-192 are unchanged: signals re-exports a single f64 "signal", the same one-output shape sma_cross re-exported as "cross", so signals.out0 → Exposure keeps working.)

  • Step 5: Re-path the sweep_family axes

Replace the axis chain at crates/aura-cli/src/main.rs:228-230:

    bp.axis("sma_cross.fast.length", [2, 3])
        .axis("sma_cross.slow.length", [4, 5])
        .axis("exposure.scale", [0.5])

with the eight free-param axes (two real on the trend lengths, the rest singletons → still 4 points; signals.blend.weights[2] is bound, so it is not an axis):

    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])
  • Step 6: Swap the sweep closure's stream to showcase_prices

In the same sweep_family closure, replace the single call at crates/aura-cli/src/main.rs:236:

            let prices = synthetic_prices();

with:

            let prices = showcase_prices();

(The rest of the closure — window, run, drain, fold — is unchanged.)

  • Step 7: Build + clippy gate (source only)

Run: cargo build -p aura-cli Expected: compiles, 0 errors (the in-crate tests still reference the old param strings — they compile fine as data; they FAIL at test-time, which Task 2 fixes).

Run: cargo clippy -p aura-cli --all-targets -- -D warnings Expected: clean — signals is used by the node-0 swap and showcase_prices by the sweep closure, so no dead_code warning.


Task 2: Re-path the in-crate tests

Files:

  • Modify: crates/aura-cli/src/main.rs:503-517 (sample_blueprint_with_sinks_bootstraps_runs_and_drains)

  • Modify: crates/aura-cli/src/main.rs:519-540 (sweep_report_renders_four_points_in_odometer_order)

  • Step 1: Re-path + re-stream the bootstrap-runs-drains test

Replace the body of sample_blueprint_with_sinks_bootstraps_runs_and_drains (:507-514) — set all eight free params via .with() (the bound weights[2] is NOT set) and run the warm-up stream:

        let (bp, rx_eq, rx_ex) = sample_blueprint_with_sinks();
        let mut h = bp
            .with("signals.trend.fast.length", 2)
            .with("signals.trend.slow.length", 4)
            .with("signals.momentum.fast.length", 2)
            .with("signals.momentum.slow.length", 4)
            .with("signals.momentum.signal.length", 3)
            .with("signals.blend.weights[0]", 1.0)
            .with("signals.blend.weights[1]", 1.0)
            .with("exposure.scale", 0.5)
            .bootstrap()
            .expect("sample blueprint compiles under a valid point");
        h.run(vec![showcase_prices()]);

(The two assert!(... drained empty) lines at :515-516 are unchanged — the 18-tick stream warms every leg, so both sinks drain non-empty.)

  • Step 2: Re-path the sweep-report odometer assertions

Replace the four param-row assertions at crates/aura-cli/src/main.rs:530-533 with the re-pathed rows (param-space render order: trend.fast, trend.slow, momentum fast/slow/signal, blend weights[0]/[1], exposure.scale — the two trend lengths vary, trend.slow fastest, the rest constant). Each row is one contiguous string:

        assert!(lines[0].contains(r#""params":[["signals.trend.fast.length",2.0],["signals.trend.slow.length",4.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line0: {}", lines[0]);
        assert!(lines[1].contains(r#""params":[["signals.trend.fast.length",2.0],["signals.trend.slow.length",5.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line1: {}", lines[1]);
        assert!(lines[2].contains(r#""params":[["signals.trend.fast.length",3.0],["signals.trend.slow.length",4.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line2: {}", lines[2]);
        assert!(lines[3].contains(r#""params":[["signals.trend.fast.length",3.0],["signals.trend.slow.length",5.0],["signals.momentum.fast.length",2.0],["signals.momentum.slow.length",4.0],["signals.momentum.signal.length",3.0],["signals.blend.weights[0]",1.0],["signals.blend.weights[1]",1.0],["exposure.scale",0.5]]"#), "line3: {}", lines[3]);

(Lines :520-529 — the 4-line count, the {"manifest":{"commit":" prefix check — and the metric-key block :534-539 are unchanged: structurally still 4 points with the same metric keys.)

  • Step 3: Run the in-crate tests

Run: cargo test -p aura-cli Expected: PASS — including sample_blueprint_with_sinks_bootstraps_runs_and_drains, sweep_report_renders_four_points_in_odometer_order, sweep_report_is_deterministic (re-runs the enriched sweep; deterministic), and the untouched run_sample_* / run_macd_* / macd_param_space_* tests. (The integration guards still read the not-yet-re-captured fixture — they stay green on the old fixture until Task 3.)

Note: if a sweep_report row mismatches at runtime, the param names are fixed (param-space order above) — re-capture only the values/order by reading the actual failing-assert output ({} prints the real line) and correcting that row; do not change the param names.


Task 3: Re-capture the sample-model.json fixture

Files:

  • Modify: crates/aura-cli/tests/fixtures/sample-model.json

  • Step 1: Add a temporary capture test

Insert into crates/aura-cli/src/main.rs mod tests (e.g. after sample_blueprint_with_sinks_bootstraps_runs_and_drains):

    #[test]
    fn capture_sample_model_tmp() {
        println!("AURA_MODEL_CAPTURE:{}", aura_engine::model_to_json(&sample_blueprint()));
    }
  • Step 2: Capture the enriched model into the fixture

Run (one positional filter; prefix-grep isolates the model line, strip it, write the file):

cargo test -p aura-cli capture_sample_model_tmp -- --nocapture 2>/dev/null \
  | grep '^AURA_MODEL_CAPTURE:' | sed 's/^AURA_MODEL_CAPTURE://' \
  > crates/aura-cli/tests/fixtures/sample-model.json

Expected: crates/aura-cli/tests/fixtures/sample-model.json is one line of valid JSON whose root node "0" is now {"comp":"signals"} and whose "composites" object carries signals, trend, momentum (and no longer a top-level sma_cross). Verify it parses:

Run: node -e "JSON.parse(require('fs').readFileSync('crates/aura-cli/tests/fixtures/sample-model.json','utf8')); console.log('OK valid JSON')" Expected: OK valid JSON

  • Step 3: Remove the temporary capture test

Delete the capture_sample_model_tmp test added in Step 1.

  • Step 4: Confirm the fixture consumers stay green

Run: cargo test -p aura-cli --test viewer_dot Expected: PASS (the re-captured fixture still yields valid Graphviz ids at depth 1; viewer_dot.rs is the .rs wrapper that runs viewer_dot_ids.mjs).

Run: cargo test -p aura-cli render_html_is_self_contained Expected: PASS (the live render still contains "type":"Exposure" and starts at {"root":).


Task 4: Depth-2 render guard

Files:

  • Create: crates/aura-cli/tests/viewer_nested_depth.mjs

  • Create: crates/aura-cli/tests/viewer_nested_depth.rs

  • Step 1: Write the headless depth-2 guard

Create crates/aura-cli/tests/viewer_nested_depth.mjs:

// Headless depth-2 nesting guard for the `aura graph` viewer (graph-viewer.js).
//
// Property protected: the viewer renders MULTIPLY-nested composites — a composite
// inside a composite inside the root — with valid Graphviz node identifiers at
// every depth. genDot builds ids as `n` + the node-key path joined by `__`
// (graph-viewer.js: `cid = "n" + cpath.join("__")`), so a depth-2 interior node
// gets a three-segment id like `n0__0__0`. A digit-led non-numeral id (`0__0__0`)
// would be misparsed by Graphviz and collapse the interior; this guard pins that
// the `n`-prefix rule holds at depth 2, beyond the depth-1 viewer_dot_ids.mjs guard.
//
// It loads the real viewer module and the re-captured sample fixture (which is now
// the enriched root → signals → {trend, momentum} graph), then EXPANDS RECURSIVELY:
// discover the top-level expandable (signals), expand, re-discover the newly
// revealed expandables (trend, momentum), expand those — until none remain — and
// asserts (a) every DOT id is valid and (b) at least one three-segment (depth-2) id
// was actually produced, so the guard cannot pass vacuously on a flat model.

import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";

const require = createRequire(import.meta.url);
const here = dirname(fileURLToPath(import.meta.url));

const model = JSON.parse(readFileSync(join(here, "fixtures", "sample-model.json"), "utf8"));

// Stub the global BEFORE requiring the viewer (mirrors the browser bootstrap).
global.window = { AURA_MODEL: model };
const { normalizeModel, genDot } = require(join(here, "..", "assets", "graph-viewer.js"));
const ROOT = normalizeModel(model).root;

// Expand every expandable composite, re-discovering deeper ones each pass, until
// the expandable set stops growing (reaches every nesting level).
let expanded = new Set();
let dot = "";
for (let pass = 0; pass < 16; pass++) {
  const g = genDot(ROOT, "root", expanded, false);
  dot = g.dot;
  const fresh = g.expandable.filter((id) => !expanded.has(id));
  if (!fresh.length) break;
  fresh.forEach((id) => expanded.add(id));
}

// Collect every node identifier referenced in the final DOT (node-def lines + edge
// endpoints), mirroring viewer_dot_ids.mjs.
const ids = new Set();
for (const m of dot.matchAll(/^\s*([^\s\[]+)\s*\[label=/gm)) ids.add(m[1]);
for (const m of dot.matchAll(/(\S+?):[a-z]\w*:[a-z]\s*->\s*(\S+?):[a-z]\w*:[a-z]/g)) {
  ids.add(m[1]);
  ids.add(m[2]);
}

const QUOTED = /^".*"$/s;
const NUMERAL = /^-?(\.[0-9]+|[0-9]+(\.[0-9]*)?)$/;
const BARE = /^[A-Za-z_][A-Za-z0-9_]*$/;
const isValid = (id) => QUOTED.test(id) || NUMERAL.test(id) || BARE.test(id);

const bad = [...ids].filter((id) => !isValid(id));
if (bad.length) {
  console.error(
    "INVALID DOT node identifier(s) at nesting depth — Graphviz will misparse a\n" +
    "digit-prefixed, non-numeral id and collapse the nested composite:\n  " +
    bad.map((id) => JSON.stringify(id)).join("\n  ")
  );
  process.exit(1);
}

// Anti-vacuous: at least one three-segment (depth-2) id must exist, proving the
// guard actually expanded two composite levels (root → signals → trend/momentum).
const depth2 = [...ids].filter((id) => /^n\d+__\d+__\d+/.test(id));
if (!depth2.length) {
  console.error(
    "no depth-2 (three-segment `nA__B__C`) id found — the guard did not reach two\n" +
    "nesting levels; the fixture is not multiply-nested or the expand loop stalled."
  );
  process.exit(1);
}

console.log(`OK — ${ids.size} DOT ids valid; ${depth2.length} reached nesting depth 2.`);
process.exit(0);
  • Step 2: Write the .rs wrapper

Create crates/aura-cli/tests/viewer_nested_depth.rs (mirrors viewer_name_prefix.rs):

//! Integration guard: the `aura graph` viewer renders multiply-nested composites
//! (a composite inside a composite inside the root) with valid Graphviz node ids
//! at every depth.
//!
//! Like `viewer_dot_ids.rs`/`viewer_dot.rs`, the property lives in JavaScript
//! (`genDot` in assets/graph-viewer.js), so this shells out to `node` running the
//! headless guard `tests/viewer_nested_depth.mjs`, which loads the real viewer
//! module and the re-captured sample fixture and expands it recursively to depth 2.
//!
//! `node` is REQUIRED: if it is not on PATH this test FAILS (a skipped guard is
//! not a guard).

use std::path::PathBuf;
use std::process::Command;

#[test]
fn viewer_renders_nested_composites_to_depth_2_with_valid_ids() {
    let script = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("viewer_nested_depth.mjs");
    assert!(
        script.exists(),
        "guard script missing at {}",
        script.display()
    );

    let out = match Command::new("node").arg(&script).output() {
        Ok(out) => out,
        Err(e) => panic!(
            "node is required for the viewer depth-2 nesting guard but could not be run \
             ({e}); install Node.js or ensure `node` is on PATH"
        ),
    };

    let stdout = String::from_utf8_lossy(&out.stdout);
    let stderr = String::from_utf8_lossy(&out.stderr);
    assert!(
        out.status.success(),
        "viewer depth-2 nesting guard failed (exit {:?}).\n--- node stdout ---\n{stdout}\n--- node stderr ---\n{stderr}",
        out.status.code()
    );
}
  • Step 3: Run the new guard

Run: cargo test -p aura-cli --test viewer_nested_depth Expected: PASS — OK — N DOT ids valid; M reached nesting depth 2. (M ≥ 1). This confirms the depth-2 nesting renders with valid ids against the re-captured fixture.


Task 5: Workspace gate

Files: none (verification only).

  • Step 1: Full build

Run: cargo build --workspace Expected: compiles, 0 errors.

  • Step 2: Full test suite

Run: cargo test --workspace Expected: PASS — all crates, including the re-pathed in-crate tests, the re-captured fixture consumers, the new viewer_nested_depth guard, and the untouched engine model_golden / nested_composite_inlines / param_space_* tests.

  • Step 3: Clippy

Run: cargo clippy --workspace --all-targets -- -D warnings Expected: clean, no warnings.