4b64409036
Cycle B of milestone "The World — parameter-space & sweep". A blueprint is now value-empty: a leaf holds a param-generic recipe, not a built node, and a positional Scalar vector is bound slot-by-slot at bootstrap — so one blueprint bootstraps into many distinct instances under different vectors, with no cdylib rebuild (C12/C19). This is the binding primitive a sweep (#32) drives. Ratified design (brainstorm): value-empty reconstruct-through-new() over mutate-in-place and over a default-bearing variant. The value lives only in the injected vector (no baked default), keeping the blueprint a pure param-generic recipe (C19); every injected value flows through the node's own constructor (the single sizing/validation gate). - aura-core: LeafFactory { name, params, build } — the recipe (params -> sized node through `new`); Scalar::as_i64/as_f64 value accessors. Node trait unchanged. - aura-std: each of the 7 nodes exposes factory() (SMA length:I64, Exposure scale:F64, LinComb arity x weights[i]:F64; Sub/Add/SimBroker/Recorder paramless, capturing their non-param construction args — pip_size, the Recorder channel). - aura-engine: BlueprintNode::Leaf(LeafFactory), From<LeafFactory>; param_space() reads factory.params() pre-build; compile_with_params/bootstrap_with_params build each leaf from its kind-checked slice while lowering (build-then-wire), arity checked up front via param_space().len(); CompileError::{ParamKindMismatch, ParamArity}. compile/inline/edge-rewrite are structurally unchanged, so the compilat stays bit-identical for a given point (the bit-identity and both param_space mirror tests stay green). The vestigial pre-build Composite::schema / BlueprintNode::schema (no live caller — interface resolution is structural on the built flat nodes) are removed. - aura-cli: the blueprint view labels leaves by bare type via LeafFactory::label() (`[SMA]`) — the ascii-dag renderer cannot render wide cluster-sibling labels (see spec); the compiled view still labels valued (`SMA(2)`). The sample supplies its point as a vector; the mis-wiring swap moved to the compiled view. The #34 dual-traversal drift hazard is subsumed: compile_with_params consumes the vector in the same recipe walk param_space() reports, so the two share one traversal. Verified: cargo build/test --workspace green (127 tests, incl. bit-identity, both mirror tests, and 4 new injection tests — different-vector-different-run, kind mismatch, arity, determinism); clippy --workspace --all-targets -D warnings clean; `aura graph` blueprint view renders cleanly. Scope: one vector -> one instance. Deferred: sweep enumeration (#32), domain validation (#32/C20), single-run authoring convenience (#35). closes #31
353 lines
14 KiB
Rust
353 lines
14 KiB
Rust
//! `aura` — the programmatic / CLI face of the engine (the surface the LLM and
|
|
//! automation drive: author a node, run a sim/sweep, emit structured metrics).
|
|
//!
|
|
//! The walking skeleton's closing seam: `aura run` bootstraps a built-in sample
|
|
//! signal-quality harness (synthetic source → SMA-cross → Exposure → SimBroker →
|
|
//! recording sinks), runs it deterministically (C1), and prints the run's
|
|
//! metrics + manifest (#6) as canonical JSON to stdout (the headline C14 move).
|
|
|
|
mod graph;
|
|
|
|
use aura_core::{Firing, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{
|
|
f64_field, summarize, Blueprint, BlueprintNode, Composite, Edge, Harness, OutPort,
|
|
RunManifest, RunReport, SourceSpec, Target,
|
|
};
|
|
use aura_std::{Exposure, Recorder, SimBroker, Sma, Sub};
|
|
use std::sync::mpsc::{self, Receiver};
|
|
|
|
/// The built-in synthetic price stream: rises through t=4 then reverses, so the
|
|
/// demo trace carries one exposure sign flip and a real drawdown (C22 populated
|
|
/// trace). Deterministic and fixed (C1).
|
|
fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
|
|
[
|
|
(1_i64, 1.0000_f64),
|
|
(2, 1.0010),
|
|
(3, 1.0030),
|
|
(4, 1.0060),
|
|
(5, 1.0040),
|
|
(6, 1.0010),
|
|
(7, 0.9990),
|
|
]
|
|
.iter()
|
|
.map(|&(t, p)| (Timestamp(t), Scalar::F64(p)))
|
|
.collect()
|
|
}
|
|
|
|
/// Bootstrap the sample signal-quality harness with two recording sinks (equity
|
|
/// tapped on the SimBroker, exposure tapped on the Exposure node). Rust-authored
|
|
/// wiring (C17/C20) over the raw bootstrap API — no builder DSL this cycle. The
|
|
/// price taps both SMAs and the broker's price slot (slot 1); exposure feeds the
|
|
/// broker's slot 0 (slot order is load-bearing — both are f64).
|
|
// The harness-plus-two-drained-sink-receivers tuple has exactly one call site
|
|
// (`run_sample`); a named type would be speculative abstraction this cycle.
|
|
#[allow(clippy::type_complexity)]
|
|
fn sample_harness() -> (
|
|
Harness,
|
|
Receiver<(Timestamp, Vec<Scalar>)>,
|
|
Receiver<(Timestamp, Vec<Scalar>)>,
|
|
) {
|
|
let (tx_eq, rx_eq) = mpsc::channel();
|
|
let (tx_ex, rx_ex) = mpsc::channel();
|
|
let h = Harness::bootstrap(
|
|
vec![
|
|
Box::new(Sma::new(2)), // 0 fast SMA
|
|
Box::new(Sma::new(4)), // 1 slow SMA
|
|
Box::new(Sub::new()), // 2 spread
|
|
Box::new(Exposure::new(0.5)), // 3 exposure
|
|
Box::new(SimBroker::new(0.0001)), // 4 sim-optimal broker
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_eq)), // 5 equity sink
|
|
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_ex)), // 6 exposure sink
|
|
],
|
|
vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 },
|
|
Target { node: 1, slot: 0 },
|
|
Target { node: 4, slot: 1 }, // price into the broker's price slot
|
|
],
|
|
}],
|
|
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: 3, to: 4, slot: 0, from_field: 0 }, // exposure into broker slot 0
|
|
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // equity -> sink 5
|
|
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // exposure -> sink 6
|
|
],
|
|
)
|
|
.expect("valid sample signal-quality DAG");
|
|
(h, rx_eq, rx_ex)
|
|
}
|
|
|
|
/// Run the sample harness and fold it into a `RunReport` (drain both sinks →
|
|
/// `f64_field` → `summarize` → pair with a `RunManifest`). Pure and deterministic
|
|
/// (C1): the same build yields the same report.
|
|
fn run_sample() -> RunReport {
|
|
let (mut h, rx_eq, rx_ex) = sample_harness();
|
|
let prices = synthetic_prices();
|
|
let window = (
|
|
prices.first().expect("non-empty stream").0,
|
|
prices.last().expect("non-empty stream").0,
|
|
);
|
|
h.run(vec![prices]);
|
|
|
|
let eq_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_eq.try_iter().collect();
|
|
let ex_rows: Vec<(Timestamp, Vec<Scalar>)> = rx_ex.try_iter().collect();
|
|
let equity = f64_field(&eq_rows, 0);
|
|
let exposure = f64_field(&ex_rows, 0);
|
|
let metrics = summarize(&equity, &exposure);
|
|
|
|
RunReport {
|
|
manifest: RunManifest {
|
|
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
|
|
params: vec![
|
|
("sma_fast".to_string(), 2.0),
|
|
("sma_slow".to_string(), 4.0),
|
|
("exposure_scale".to_string(), 0.5),
|
|
],
|
|
window,
|
|
seed: 0,
|
|
broker: "sim-optimal(pip_size=0.0001)".to_string(),
|
|
},
|
|
metrics,
|
|
}
|
|
}
|
|
|
|
/// The SMA-cross signal as a named composite (price -> fast/slow SMA -> spread).
|
|
/// CLI-local sample builder; the engine ships no sample (the duplication with
|
|
/// `blueprint.rs`'s test helper is the dedup tracked in #14). Value-empty: the SMA
|
|
/// lengths are injected at compile, not baked here.
|
|
fn sma_cross(name: &str) -> Composite {
|
|
Composite::new(
|
|
name,
|
|
vec![Sma::factory().into(), Sma::factory().into(), Sub::factory().into()],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 },
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 },
|
|
],
|
|
vec![vec![Target { node: 0, slot: 0 }, Target { node: 1, slot: 0 }]],
|
|
OutPort { node: 2, field: 0 },
|
|
)
|
|
}
|
|
|
|
/// The sample signal-quality blueprint (value-empty): a recipe whose SMA lengths +
|
|
/// exposure scale are injected at compile via the point vector (see
|
|
/// `sample_point`). Recorders need a channel to construct; the receivers are
|
|
/// dropped because the render never runs the graph.
|
|
fn build_sample() -> Blueprint {
|
|
let (tx_eq, _rx_eq) = mpsc::channel();
|
|
let (tx_ex, _rx_ex) = mpsc::channel();
|
|
Blueprint::new(
|
|
vec![
|
|
BlueprintNode::Composite(sma_cross("sma_cross")),
|
|
Exposure::factory().into(),
|
|
SimBroker::factory(0.0001).into(),
|
|
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_eq).into(),
|
|
Recorder::factory(vec![ScalarKind::F64], Firing::Any, tx_ex).into(),
|
|
],
|
|
vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 }, // price -> sma_cross role 0
|
|
Target { node: 2, slot: 1 }, // price -> SimBroker price slot
|
|
],
|
|
}],
|
|
vec![
|
|
Edge { from: 0, to: 1, slot: 0, from_field: 0 }, // spread -> Exposure
|
|
Edge { from: 1, to: 2, slot: 0, from_field: 0 }, // exposure -> broker slot 0
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // equity -> sink
|
|
Edge { from: 1, to: 4, slot: 0, from_field: 0 }, // exposure -> sink
|
|
],
|
|
)
|
|
}
|
|
|
|
/// The built-in sample rendered by `aura graph`.
|
|
fn sample_blueprint() -> Blueprint {
|
|
build_sample()
|
|
}
|
|
|
|
/// The point vector injected into the sample blueprint, in `param_space()` slot
|
|
/// order: `[fast SMA length, slow SMA length, exposure scale]`.
|
|
fn sample_point() -> Vec<Scalar> {
|
|
vec![Scalar::I64(2), Scalar::I64(4), Scalar::F64(0.5)]
|
|
}
|
|
|
|
fn main() {
|
|
let mut args = std::env::args().skip(1);
|
|
match args.next().as_deref() {
|
|
// strict: a bare `run` proceeds; a trailing token falls through to the
|
|
// usage-error path rather than masquerading as a successful run (#16).
|
|
Some("run") if args.next().is_none() => println!("{}", run_sample().to_json()),
|
|
Some("graph") => {
|
|
// `--compiled` selects the flat post-inline view; default is the
|
|
// clustered blueprint view. Strictness beyond this stays minimal (#16).
|
|
let compiled = args.next().as_deref() == Some("--compiled");
|
|
let bp = sample_blueprint();
|
|
let out = if compiled {
|
|
let (nodes, sources, edges) =
|
|
bp.compile_with_params(&sample_point()).expect("valid sample blueprint");
|
|
graph::render_compilat(&nodes, &sources, &edges)
|
|
} else {
|
|
graph::render_blueprint(&bp)
|
|
};
|
|
println!("{out}");
|
|
}
|
|
Some("--help") | Some("-h") => println!("usage: aura run | aura graph [--compiled]"),
|
|
_ => {
|
|
eprintln!("aura: usage: aura run | aura graph [--compiled]");
|
|
std::process::exit(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
/// The sample's point vector with the fast/slow SMA lengths swapped — the
|
|
/// mis-wiring the compiled render must surface. The blueprint is param-generic
|
|
/// and identical for both orderings; only the injected vector differs.
|
|
fn swapped_point() -> Vec<Scalar> {
|
|
vec![Scalar::I64(4), Scalar::I64(2), Scalar::F64(0.5)]
|
|
}
|
|
|
|
#[test]
|
|
fn blueprint_view_shows_cluster_and_param_generic_labels() {
|
|
let out = graph::render_blueprint(&sample_blueprint());
|
|
// the composite renders as a named cluster box
|
|
assert!(out.contains("sma_cross"), "missing composite name:\n{out}");
|
|
// the value-empty blueprint view labels leaves param-generically by bare
|
|
// type (no values, and no knob suffix — the ascii-dag layout cannot render
|
|
// wide cluster-sibling labels); both SMAs render identically as [SMA]
|
|
assert!(out.contains("[SMA]"), "missing [SMA]:\n{out}");
|
|
for needle in ["Sub", "Exposure", "SimBroker", "Recorder"] {
|
|
assert!(out.contains(needle), "missing {needle}:\n{out}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compiled_view_dissolves_the_composite_boundary() {
|
|
let bp = sample_blueprint();
|
|
let (nodes, sources, edges) =
|
|
bp.compile_with_params(&sample_point()).expect("valid sample");
|
|
let out = graph::render_compilat(&nodes, &sources, &edges);
|
|
// node labels survive inlining...
|
|
assert!(out.contains("SMA(2)") && out.contains("SMA(4)"), "labels lost:\n{out}");
|
|
// ...but the composite cluster name does NOT (boundary dissolved, C23)
|
|
assert!(!out.contains("sma_cross"), "compiled view must not show the cluster:\n{out}");
|
|
}
|
|
|
|
#[test]
|
|
fn swapped_param_vector_changes_the_compiled_render() {
|
|
// the property the cycle exists to buy: a mis-wiring is no longer invisible.
|
|
// The blueprint is now param-generic (identical for both orderings), so the
|
|
// swap is observable only after the vector is injected — in the COMPILED
|
|
// view, where the flat nodes carry their valued labels (SMA(2)/SMA(4)).
|
|
let (cn, cs, ce) =
|
|
sample_blueprint().compile_with_params(&sample_point()).expect("valid sample");
|
|
let correct = graph::render_compilat(&cn, &cs, &ce);
|
|
let (sn, ss, se) =
|
|
sample_blueprint().compile_with_params(&swapped_point()).expect("valid sample");
|
|
let swapped = graph::render_compilat(&sn, &ss, &se);
|
|
assert_ne!(correct, swapped, "a fast/slow SMA swap must change the compiled render");
|
|
}
|
|
|
|
#[test]
|
|
fn blueprint_view_golden() {
|
|
let out = graph::render_blueprint(&sample_blueprint());
|
|
// ascii-dag's Sugiyama layout is deterministic (no RNG); these are the
|
|
// exact bytes `aura graph` emits (render() ends in three newlines).
|
|
let expected = r#" [source:F64]
|
|
┌───────└───────┐
|
|
│ │ │
|
|
╔═══╪═══════╪═══╗ │
|
|
║ sma_cross │ ║ │
|
|
║ ↓ ↓ ║ │
|
|
║ [SMA] [SMA] ║ │
|
|
║ └────┌──┘ ║ │
|
|
║ ↓ ║┌──┘
|
|
║ [Sub] ║│
|
|
║ │ ║│
|
|
╚════════╪══════╝│
|
|
│ │
|
|
↓ │
|
|
[Exposure] │
|
|
┌───┘───────┼┐
|
|
↓ └↓
|
|
[Recorder] [SimBroker]
|
|
│
|
|
↓
|
|
[Recorder]
|
|
|
|
|
|
"#;
|
|
assert_eq!(out, expected, "blueprint render drifted; re-capture if intended");
|
|
}
|
|
|
|
#[test]
|
|
fn compiled_view_golden() {
|
|
let bp = sample_blueprint();
|
|
let (nodes, sources, edges) =
|
|
bp.compile_with_params(&sample_point()).expect("valid sample");
|
|
let out = graph::render_compilat(&nodes, &sources, &edges);
|
|
let expected = r#" [source:F64]
|
|
┌────────└─┐──────┐
|
|
↓ ↓ │
|
|
[SMA(2)] [SMA(4)] │
|
|
└────┌─────┘ │
|
|
↓ ┌──────┘
|
|
[Sub] │
|
|
│ │
|
|
↓ └────┐
|
|
[Exposure(0.5)] │
|
|
┌──────┘─────────┐│
|
|
↓ ↓┘
|
|
[Recorder] [SimBroker(0.0001)]
|
|
┌─────┘
|
|
↓
|
|
[Recorder]
|
|
|
|
|
|
"#;
|
|
assert_eq!(out, expected, "compiled render drifted; re-capture if intended");
|
|
}
|
|
|
|
#[test]
|
|
fn run_sample_is_deterministic_and_non_trivial() {
|
|
let r1 = run_sample();
|
|
let r2 = run_sample();
|
|
// C1 determinism: two runs are bit-identical (metrics + rendered JSON).
|
|
assert_eq!(r1.metrics, r2.metrics);
|
|
assert_eq!(r1.to_json(), r2.to_json());
|
|
|
|
let m = &r1.metrics;
|
|
// exactly one exposure sign flip in the demo trace (rises then reverses).
|
|
assert_eq!(m.exposure_sign_flips, 1);
|
|
// a non-trivial, populated trace: a real drawdown.
|
|
assert!(m.max_drawdown > 0.0);
|
|
// hand-computed magnitudes for the chosen stream (float tolerance; the
|
|
// computation's dust is ~1e-15).
|
|
assert!(
|
|
(m.max_drawdown - 0.17).abs() < 1e-9,
|
|
"max_drawdown = {}",
|
|
m.max_drawdown
|
|
);
|
|
assert!(
|
|
(m.total_pips - (-0.13)).abs() < 1e-9,
|
|
"total_pips = {}",
|
|
m.total_pips
|
|
);
|
|
|
|
// manifest carries the sample's known configuration.
|
|
let (from, to) = r1.manifest.window;
|
|
assert_eq!((from.0, to.0), (1, 7));
|
|
// commit is the build's git identity (or the no-git "unknown" fallback);
|
|
// either way it is non-empty and fixed at compile time, so it is stable
|
|
// across runs of the same build (C1 determinism, already asserted above
|
|
// via `to_json()`).
|
|
assert!(!r1.manifest.commit.is_empty());
|
|
assert_eq!(r1.manifest.commit, r2.manifest.commit);
|
|
}
|
|
}
|