Files
Aura/crates/aura-ingest/examples/shared/breakout_real.rs
T
Brummel 4928e289f7 feat(project): the project-as-crate load boundary (cycle 0102)
A research project is now a loadable external cdylib crate. Inside a
directory whose ancestry holds an Aura.toml, aura discovers the project
root cargo-style, locates the compiled dylib via cargo metadata (debug
default, --release opt-in), loads it load-and-hold, and refuses
mismatches before trusting anything: the AURA_PROJECT descriptor
(aura-core::project, #[repr(C)]) carries a C-ABI stamp prefix (rustc +
aura-core version, baked per consuming build by the new aura-core
build.rs) validated before any Rust-ABI field is read. The vocabulary
charter gates the merged resolution: project type ids are ::-namespaced
(std stays bare), duplicates refuse, and the enumerable type-id list
must agree with the resolver, so introspection can never silently omit
a project type.

All blueprint verbs resolve through the merged project + std vocabulary
via a per-invocation Env threaded through the dispatch chains;
registry, trace-store, and data paths anchor at the project runs root
(Aura.toml [paths], paths-only by design — instrument geometry stays
the recorded sidecar, C15). RunManifest gains the Tier-1 project
provenance field (namespace + dylib sha256 + best-effort commit),
stamped beside topology_hash on the blueprint-run paths; pre-0102
registry lines load unchanged. Default node names strip the namespace,
so :: never reaches the param-path address space.

Proven by the demo-project fixture (built by the e2e via cargo,
path-dep on this workspace): run twice bit-identical, provenance
recorded, introspection lists demo::* beside std, registry anchors at
the discovered root from a subdirectory; the badcharter fixture proves
the charter refusal through the real libloading path; a never-built
project refuses with a cargo-build hint. Outside a project every path
collapses to the previous literals — goldens and manifest pins
byte-identical.

Verification: cargo build --workspace clean; cargo test --workspace 862
passed / 0 failed (incl. 7 project_load e2e); clippy -D warnings clean
(one precedent-matching allow(too_many_arguments) on run_oos_blueprint,
whose arity the Env threading raised to 8); doc build unchanged.
Docs/ledger aligned: Aura.toml field lists are paths-only in
project-layout.md, glossary, C16/C17; new C13 realization note records
the per-invocation-reload reading and the load-and-hold one-shot scope
boundary.

New deps, per-case review (aura-cli leaf binary only, never the frozen
artifact): libloading, toml.

refs #180
2026-07-02 18:13:37 +02:00

431 lines
21 KiB
Rust

//! Shared wiring for the real-data GER40 15m session-breakout demonstration.
//!
//! This is the *single* definition of the breakout harness used by BOTH the
//! runnable example (`examples/ger40_breakout_real.rs`) and the gated
//! determinism test (`tests/ger40_breakout_real.rs`), so the 11-node FlatGraph
//! is wired exactly once and never drifts between the two. It is included into
//! each consumer via `#[path = "..."] mod`, not compiled as its own target.
//!
//! The DAG is the VERBATIM breakout graph proven synthetically in
//! `aura-engine/tests/ger40_breakout.rs` — only the sources change: real
//! `M1FieldSource`s over a data-server window replace the synthetic
//! `VecSource`s. Source declaration order (open, high, low, close) is the C4
//! merge tie-break order Resample's `Barrier(0)` group depends on, so it is
//! load-bearing and preserved.
//!
//! ```text
//! open,high,low,close (4 real M1 sources, FIXED order)
//! -> Resample(15) ⇒ {open15,high15,low15,close15}
//! close15 -> Gt.a
//! high15 -> Delay(1) -> Gt.b (prevHigh15)
//! Gt -> breakout:bool (close15 > prevHigh15, STRICT)
//! close15 -> Session(9,0,Berlin,15) ⇒ bars_since_open:i64
//! bars_since_open -> EqConst(3) -> isBar3
//! bars_since_open -> EqConst(5) -> isBar5Close
//! And(breakout, isBar3) -> entry
//! Latch(set=entry, reset=isBar5Close) -> held:f64
//! held -> SimBroker(exposure=held, price=close15) ⇒ equity [pip_size=1.0]
//! equity -> Recorder(A); held -> Recorder(B)
//! bars_since_open -> Recorder(C); breakout -> Recorder(D)
//! ```
//!
//! The synthetic test taps two channels (equity, held). The real demonstration
//! taps two more — `bars_since_open` (i64) and `breakout` (bool) — so the
//! entry/exit trace can name *why* each session did or did not fire, without
//! reaching into engine internals (it reads only what sinks record, C8/C18).
#![allow(dead_code)] // each consumer (example / test) uses a subset of this API
use std::sync::mpsc;
use aura_core::{NodeSchema, Scalar, ScalarKind, Timestamp};
use aura_engine::{
join_on_ts, summarize, Composite, Edge, FlatGraph, GraphBuilder, Harness, RunManifest,
RunReport, SourceSpec, Target,
};
use aura_std::{And, Delay, EqConst, Gt, Latch, Recorder, Resample, Session, SimBroker};
use chrono::TimeZone;
use chrono_tz::Europe::Berlin;
// ---------------------------------------------------------------------------
// Node indices in the FlatGraph (one fixed layout, the same nodes 0..=8 as
// `aura-engine/tests/ger40_breakout.rs`, plus two extra trace taps 11/12).
// ---------------------------------------------------------------------------
pub const RESAMPLE: usize = 0;
pub const DELAY: usize = 1;
pub const GT: usize = 2;
pub const SESSION: usize = 3;
pub const EQ3: usize = 4;
pub const EQ5: usize = 5;
pub const AND: usize = 6;
pub const LATCH: usize = 7;
pub const BROKER: usize = 8;
pub const REC_EQUITY: usize = 9; // tap A — SimBroker equity (f64, pips)
pub const REC_HELD: usize = 10; // tap B — Latch exposure (f64, 0.0/1.0)
pub const REC_BARS: usize = 11; // tap C — Session bars_since_open (i64)
pub const REC_BREAKOUT: usize = 12; // tap D — Gt breakout flag (bool)
/// The instrument. GER40 is an index, so pips are index points (`pip_size = 1.0`).
pub const SYMBOL: &str = "GER40";
/// The example's pip divisor, sourced from the recorded geometry sidecar — the same
/// provider truth the CLI `--real` path uses, now provider-sourced. Panics on a
/// symbol with no sidecar (the examples only run known instruments with local data).
pub fn pip_size_of(symbol: &str) -> f64 {
aura_ingest::instrument_geometry(&aura_ingest::default_data_server(), symbol)
.unwrap_or_else(|| panic!("no recorded geometry sidecar for {symbol}"))
.pip_size
}
/// Session config: Frankfurt cash open 09:00 Europe/Berlin, 15m bars. The same
/// `Session::new(9, 0, Europe::Berlin, 15)` the synthetic test pins.
pub const SESSION_HOUR: u32 = 9;
pub const SESSION_MINUTE: u32 = 0;
pub const BAR_MINUTES: i64 = 15;
/// The four recorder channels a built harness drains after a run.
pub struct Taps {
pub equity: mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
pub held: mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
pub bars: mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
pub breakout: mpsc::Receiver<(Timestamp, Vec<Scalar>)>,
}
/// Hand-wire the breakout DAG into a frozen [`Harness`] with four recording
/// taps. A fresh build per call (fresh nodes + channels) keeps two runs
/// disjoint for the determinism assertion (C1). The wiring of nodes 0..=10 is
/// VERBATIM from `aura-engine/tests/ger40_breakout.rs`; nodes 11/12 add the two
/// trace taps.
pub fn build_harness() -> (Harness, Taps) {
use aura_core::Firing;
let pip_size = pip_size_of(SYMBOL);
let (tx_equity, rx_equity) = mpsc::channel();
let (tx_held, rx_held) = mpsc::channel();
let (tx_bars, rx_bars) = mpsc::channel();
let (tx_breakout, rx_breakout) = mpsc::channel();
// Each node's declared signature (kind/firing per port), parallel to `nodes`.
let signatures: Vec<NodeSchema> = vec![
Resample::builder().schema().clone(), // 0
Delay::builder().schema().clone(), // 1
Gt::builder().schema().clone(), // 2
Session::builder(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)
.schema()
.clone(), // 3
EqConst::builder().schema().clone(), // 4
EqConst::builder().schema().clone(), // 5
And::builder().schema().clone(), // 6
Latch::builder().schema().clone(), // 7
SimBroker::builder(pip_size).schema().clone(), // 8
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_equity.clone())
.schema()
.clone(), // 9
Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_held.clone())
.schema()
.clone(), // 10
Recorder::builder(vec![ScalarKind::I64], Firing::Any, tx_bars.clone())
.schema()
.clone(), // 11
Recorder::builder(vec![ScalarKind::Bool], Firing::Any, tx_breakout.clone())
.schema()
.clone(), // 12
];
let nodes: Vec<Box<dyn aura_core::Node>> = vec![
Box::new(Resample::new(15)), // 0
Box::new(Delay::new(1)), // 1
Box::new(Gt::new()), // 2
Box::new(Session::new(SESSION_HOUR, SESSION_MINUTE, Berlin, BAR_MINUTES)), // 3
Box::new(EqConst::new(3)), // 4
Box::new(EqConst::new(5)), // 5
Box::new(And::new()), // 6
Box::new(Latch::new()), // 7
Box::new(SimBroker::new(pip_size)), // 8
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_equity)), // 9
Box::new(Recorder::new(&[ScalarKind::F64], Firing::Any, tx_held)), // 10
Box::new(Recorder::new(&[ScalarKind::I64], Firing::Any, tx_bars)), // 11
Box::new(Recorder::new(&[ScalarKind::Bool], Firing::Any, tx_breakout)), // 12
];
// Resample output fields: 0=open15, 1=high15, 2=low15, 3=close15.
const F_HIGH15: usize = 1;
const F_CLOSE15: usize = 3;
let edges = vec![
// close15 -> Gt.a (slot 0)
Edge { from: RESAMPLE, to: GT, slot: 0, from_field: F_CLOSE15 },
// high15 -> Delay.series (slot 0) -> Gt.b (slot 1)
Edge { from: RESAMPLE, to: DELAY, slot: 0, from_field: F_HIGH15 },
Edge { from: DELAY, to: GT, slot: 1, from_field: 0 },
// close15 -> Session.trigger (value ignored)
Edge { from: RESAMPLE, to: SESSION, slot: 0, from_field: F_CLOSE15 },
// bars_since_open -> EqConst(3) / EqConst(5)
Edge { from: SESSION, to: EQ3, slot: 0, from_field: 0 },
Edge { from: SESSION, to: EQ5, slot: 0, from_field: 0 },
// And(breakout, isBar3) -> entry
Edge { from: GT, to: AND, slot: 0, from_field: 0 },
Edge { from: EQ3, to: AND, slot: 1, from_field: 0 },
// Latch(set=entry, reset=isBar5Close)
Edge { from: AND, to: LATCH, slot: 0, from_field: 0 },
Edge { from: EQ5, to: LATCH, slot: 1, from_field: 0 },
// held -> SimBroker.exposure (slot 0); close15 -> SimBroker.price (slot 1)
Edge { from: LATCH, to: BROKER, slot: 0, from_field: 0 },
Edge { from: RESAMPLE, to: BROKER, slot: 1, from_field: F_CLOSE15 },
// equity -> tap A; held -> tap B
Edge { from: BROKER, to: REC_EQUITY, slot: 0, from_field: 0 },
Edge { from: LATCH, to: REC_HELD, slot: 0, from_field: 0 },
// trace taps: bars_since_open -> tap C; breakout -> tap D
Edge { from: SESSION, to: REC_BARS, slot: 0, from_field: 0 },
Edge { from: GT, to: REC_BREAKOUT, slot: 0, from_field: 0 },
];
// Four f64 sources, one per OHLC field, each feeding one Resample slot. The
// declaration order open(0), high(1), low(2), close(3) is the merge tie-break
// order for the Barrier(0) group — identical to the synthetic capstone.
let sources = (0..4)
.map(|slot| SourceSpec {
kind: ScalarKind::F64,
targets: vec![Target { node: RESAMPLE, slot }],
})
.collect();
let h = Harness::bootstrap(FlatGraph { nodes, signatures, sources, edges })
.expect("valid breakout DAG");
(h, Taps { equity: rx_equity, held: rx_held, bars: rx_bars, breakout: rx_breakout })
}
/// Author the GER40 session-breakout as a World-consumable [`Composite`]
/// blueprint — the canonical shippable form of the strategy, of which
/// [`build_harness`]'s hand-wired `FlatGraph` is the compiled substrate (C11/C23).
///
/// This is the SAME 13-node breakout `build_harness` wires by raw index, authored
/// here fluently by name via [`GraphBuilder`] (canonical template
/// `aura-engine/src/test_fixtures.rs`). Being a `Composite` it exposes
/// `param_space()` / the by-name binder / `bootstrap_with_cells`, so `sweep` /
/// `walk_forward` / compare consume it with NO re-authoring (the #94 friction).
///
/// The structural decisions are baked here, not left to the caller:
/// - `bar_period_minutes` binds BOTH the period-bearing nodes at construction —
/// `Resample`'s `period_minutes` param AND `Session`'s baked period — so the two
/// are LOCKED EQUAL and a sweep cannot desync them to 0.0 pips (#96 / C34: a
/// 15m and a 30m breakout are *different strategies*, not one tuned).
/// - `delay.lag` is bound out to its structural constant `1` (the "previous bar's
/// high" register is not a tuning knob — the exact C34 deform-vs-tune case).
/// - The two `EqConst` nodes are `.named("entry_bar")` / `.named("exit_bar")` with
/// their `target` param LEFT UNBOUND — so `param_space()` is EXACTLY
/// `{ entry_bar.target, exit_bar.target }`, the two genuine tuning knobs.
///
/// The four OHLC fields are root **source roles** (open, high, low, close) in the
/// fixed C4 merge order, fanning into Resample's four `Barrier(0)` slots. The
/// SimBroker equity is exposed as an output field (`equity`, for the later World
/// demos) AND tapped into a recorder, alongside the held/bars/breakout taps — so
/// the standalone example/test drains the same trace `build_harness` does today.
///
/// Returns the blueprint plus the four recording [`Taps`]. A fresh build per call
/// (fresh nodes + channels) keeps two bootstraps disjoint for the C1 determinism
/// assertion, exactly as `build_harness` does.
pub fn ger40_breakout_blueprint(
pip_size: f64,
bar_period_minutes: i64,
open_hour: u32,
open_minute: u32,
tz: chrono_tz::Tz,
) -> (Composite, Taps) {
use aura_core::Firing;
let (tx_equity, rx_equity) = mpsc::channel();
let (tx_held, rx_held) = mpsc::channel();
let (tx_bars, rx_bars) = mpsc::channel();
let (tx_breakout, rx_breakout) = mpsc::channel();
let mut g = GraphBuilder::new("ger40_breakout");
// Strategy nodes. The two period-bearing nodes are bound EQUAL by construction:
// Resample's `period_minutes` param and Session's baked period both take
// `bar_period_minutes`, so no sweep can desync them. `delay.lag` is bound out.
let resample = g.add(
Resample::builder().bind("period_minutes", Scalar::i64(bar_period_minutes)),
);
let delay = g.add(Delay::builder().bind("lag", Scalar::i64(1)));
let gt = g.add(Gt::builder());
let session = g.add(Session::builder(open_hour, open_minute, tz, bar_period_minutes));
// The ONLY two params: the EqConst targets, named so the space reads
// `entry_bar.target` / `exit_bar.target`. Their `target` is left UNBOUND.
let entry_bar = g.add(EqConst::builder().named("entry_bar"));
let exit_bar = g.add(EqConst::builder().named("exit_bar"));
let and = g.add(And::builder());
let latch = g.add(Latch::builder());
let broker = g.add(SimBroker::builder(pip_size));
// Recording sinks: equity (A), held (B), bars_since_open (C), breakout (D).
let rec_equity = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_equity));
let rec_held = g.add(Recorder::builder(vec![ScalarKind::F64], Firing::Any, tx_held));
let rec_bars = g.add(Recorder::builder(vec![ScalarKind::I64], Firing::Any, tx_bars));
let rec_breakout =
g.add(Recorder::builder(vec![ScalarKind::Bool], Firing::Any, tx_breakout));
// Four OHLC source roles (open, high, low, close) in the fixed C4 merge order,
// each fanning into the matching Resample Barrier(0) slot (port names match
// the field names: "open"/"high"/"low"/"close").
let open = g.source_role("open", ScalarKind::F64);
let high = g.source_role("high", ScalarKind::F64);
let low = g.source_role("low", ScalarKind::F64);
let close = g.source_role("close", ScalarKind::F64);
g.feed(open, [resample.input("open")]);
g.feed(high, [resample.input("high")]);
g.feed(low, [resample.input("low")]);
g.feed(close, [resample.input("close")]);
// close15 -> Gt.a; high15 -> Delay -> Gt.b (prevHigh15, STRICT comparator).
g.connect(resample.output("close"), gt.input("a"));
g.connect(resample.output("high"), delay.input("series"));
g.connect(delay.output("value"), gt.input("b"));
// close15 -> Session.trigger (value ignored; fires once per completed bar).
g.connect(resample.output("close"), session.input("trigger"));
// bars_since_open -> EqConst(entry) / EqConst(exit).
g.connect(session.output("bars_since_open"), entry_bar.input("value"));
g.connect(session.output("bars_since_open"), exit_bar.input("value"));
// And(breakout, isEntryBar) -> entry.
g.connect(gt.output("value"), and.input("a"));
g.connect(entry_bar.output("value"), and.input("b"));
// Latch(set=entry, reset=isExitBar) -> held.
g.connect(and.output("value"), latch.input("set"));
g.connect(exit_bar.output("value"), latch.input("reset"));
// held -> SimBroker.exposure; close15 -> SimBroker.price.
g.connect(latch.output("value"), broker.input("exposure"));
g.connect(resample.output("close"), broker.input("price"));
// equity -> tap A; held -> tap B; bars -> tap C; breakout -> tap D.
g.connect(broker.output("equity"), rec_equity.input("col[0]"));
g.connect(latch.output("value"), rec_held.input("col[0]"));
g.connect(session.output("bars_since_open"), rec_bars.input("col[0]"));
g.connect(gt.output("value"), rec_breakout.input("col[0]"));
// Expose the SimBroker equity at the boundary (for the later World demos);
// the recorder taps still drain the standalone trace.
g.expose(broker.output("equity"), "equity");
let bp = g.build().expect("breakout blueprint resolves by name");
(bp, Taps { equity: rx_equity, held: rx_held, bars: rx_bars, breakout: rx_breakout })
}
/// Build the inclusive window for the whole calendar month `(year, month)` in
/// UTC: `[first instant of the 1st, last ms of the last day]`, in aura's native
/// epoch-ns [`Timestamp`] currency. UTC keeps the boundary trivial to reason
/// about; the Session node still indexes bars in Berlin wall-clock inside the
/// graph. The SAME UTC instants as before, typed as `Timestamp` (each bound
/// wrapped through the one `unix_ms_to_epoch_ns` seam), so the consumer layer is
/// Timestamp-native and no caller round-trips ms→Timestamp→ms.
pub fn utc_month_window(year: i32, month: u32) -> (Timestamp, Timestamp) {
let from = chrono::Utc
.with_ymd_and_hms(year, month, 1, 0, 0, 0)
.unwrap()
.timestamp_millis();
// first instant of the next month, minus one ms => last ms of this month.
let (ny, nm) = if month == 12 { (year + 1, 1) } else { (year, month + 1) };
let next = chrono::Utc
.with_ymd_and_hms(ny, nm, 1, 0, 0, 0)
.unwrap()
.timestamp_millis();
(
aura_ingest::unix_ms_to_epoch_ns(from),
aura_ingest::unix_ms_to_epoch_ns(next - 1),
)
}
/// One bar's recorded trace row, fused across the four taps by **timestamp**.
///
/// The four sinks do NOT all fire on the same cardinality: `equity` and `held`
/// fire once per completed Resample bar (downstream of Latch/SimBroker), but the
/// `breakout` (Gt) tap is one bar shorter — `Gt` depends on `Delay(1)` of
/// high15, which is cold on the very first bar and emits nothing — and the
/// `bars_since_open` (Session) tap filters bars that precede a session open. So
/// they cannot be fused by positional index; they are joined on the recorded
/// timestamp. `equity`/`held` are the spine (one row per bar); the other two are
/// looked up by ts, defaulting where they did not fire this bar.
pub struct BarTrace {
pub ts: Timestamp,
pub equity: f64,
pub held: f64,
/// Session bar index, or `-1` where Session did not emit this bar (outside a
/// tracked session).
pub bars_since_open: i64,
/// The strict-breakout flag, or `false` where Gt did not emit (the cold
/// first bar, before Delay(1) is warm).
pub breakout: bool,
}
/// Drain the four taps into a per-bar trace, joining on the recorded timestamp
/// (the sinks fire on different cardinalities — see [`BarTrace`]). Reads only
/// recorded sink output (C8/C18) — never engine internals.
pub fn drain_trace(taps: &Taps) -> Vec<BarTrace> {
let equity: Vec<(Timestamp, Vec<Scalar>)> = taps.equity.try_iter().collect();
let held: Vec<(Timestamp, Vec<Scalar>)> = taps.held.try_iter().collect();
let bars: Vec<(Timestamp, Vec<Scalar>)> = taps.bars.try_iter().collect();
let breakout: Vec<(Timestamp, Vec<Scalar>)> = taps.breakout.try_iter().collect();
join_on_ts(&equity, &[&held, &bars, &breakout])
.into_iter()
.map(|j| BarTrace {
ts: j.ts,
equity: as_f64(&j.spine[0]),
held: j.sides[0].as_ref().map_or(0.0, |r| as_f64(&r[0])),
bars_since_open: j.sides[1].as_ref().map_or(-1, |r| as_i64(&r[0])),
breakout: j.sides[2].as_ref().is_some_and(|r| as_bool(&r[0])),
})
.collect()
}
/// Fold an already-drained per-bar trace into a [`RunReport`] over the real
/// window, exactly as `tests/real_bars.rs` does for the SMA sample. The trace is
/// the single drain of the recording channels (`drain_trace`); this reduction is
/// pure over it, so the example and the test share one report definition. The
/// window is the requested `[from, to]` epoch-ns window.
pub fn report_from_trace(trace: &[BarTrace], from: Timestamp, to: Timestamp) -> RunReport {
let equity: Vec<(Timestamp, f64)> = trace.iter().map(|b| (b.ts, b.equity)).collect();
let exposure: Vec<(Timestamp, f64)> = trace.iter().map(|b| (b.ts, b.held)).collect();
let metrics = summarize(&equity, &exposure);
RunReport {
manifest: RunManifest {
commit: "ger40-breakout-real".to_string(),
params: vec![
("session_hour".to_string(), Scalar::i64(SESSION_HOUR as i64)),
("session_minute".to_string(), Scalar::i64(SESSION_MINUTE as i64)),
("bar_minutes".to_string(), Scalar::i64(BAR_MINUTES)),
("entry_bar".to_string(), Scalar::i64(3)),
("exit_bar".to_string(), Scalar::i64(5)),
],
window: (from, to),
seed: 0,
broker: "sim-optimal(pip_size=1)".to_string(),
selection: None,
instrument: None,
topology_hash: None,
project: None,
},
metrics,
}
}
fn as_f64(s: &Scalar) -> f64 {
match s {
Scalar::F64(v) => *v,
other => panic!("expected f64, got {other:?}"),
}
}
fn as_i64(s: &Scalar) -> i64 {
match s {
Scalar::I64(v) => *v,
other => panic!("expected i64, got {other:?}"),
}
}
fn as_bool(s: &Scalar) -> bool {
match s {
Scalar::Bool(v) => *v,
other => panic!("expected bool, got {other:?}"),
}
}