spec: 0036 sample-showcase-blueprint (boss-signed)
Enrich the `aura graph` / `aura sweep` sample blueprint into a "trend + momentum, weighted blend" strategy that showcases four authoring/viewer capabilities the single-level sample left dark: multiply-nested composites, a multi-param node inside a composite, a multi-output (MACD-like) node, and bind() (a param fixed as a structural constant, dropped from the sweep surface). Pure authoring over already-shipped primitives — no engine semantics or viewer change; reuses the existing sma_cross/macd builders. Auto-signed under /boss (spec auto-sign enabled): objective gates green (precondition clean, self-review clean, grounding-check PASS) and a unanimous five-lens spec-skeptic panel (criterion, grounding, scope-fork, ambiguity, plan-readiness) returned SOUND. Design provenance recorded on #62. refs #62
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
# 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**:
|
||||
|
||||
```text
|
||||
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
|
||||
compilat 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:
|
||||
|
||||
```rust
|
||||
/// 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:
|
||||
|
||||
```rust
|
||||
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"`):
|
||||
|
||||
```rust
|
||||
// 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:
|
||||
|
||||
```rust
|
||||
/// 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`:
|
||||
|
||||
```rust
|
||||
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.length` → `signals.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.
|
||||
Reference in New Issue
Block a user