a982b96ecc
First fieldtest of the run-metrics + manifest report surface. A standalone
downstream-consumer crate (fieldtests/cycle-0009-run-metrics/) path-depends on
the engine crates and exercises the post-0009 surface from the public interface
only (rustdoc + ledger + glossary + project layout, never crates/*/src).
Primary axis empirically met: the north-star "a run emits metrics + manifest"
move is reachable from rustdoc alone — drain two recording sinks -> f64_field ->
summarize -> RunManifest -> to_json, metrics matching the hand model on the first
run, deterministic across reruns.
Findings: 0 bugs, 1 friction, 1 spec_gap, 4 working.
- working x4: north-star reachable from rustdoc; SimBroker firing/slot docs (a
resolved 0007 gap) now carry the example; summarize metric definitions exact
on six degenerate inputs (incl. negative-curve drawdown + flat-as-sign-0);
f64_field panics precise and well-located.
- spec_gap: to_json's JSON key names + {manifest,metrics} nesting are not on
the public surface — a consumer parsing the JSON (C18 registry, the deferred
aura run printer) cannot author against it from rustdoc alone.
- friction: to_json renders whole-valued f64 without a decimal point (3.0 ->
"3"), so one f64 field appears as integer or decimal token within one schema.
Both doc-level findings are the same doc pass and matter mainly for the deferred
aura run (#8) and the C18 registry that will parse this JSON. Spec feeds the next
plan as reference.
refs #6
159 lines
7.1 KiB
Rust
159 lines
7.1 KiB
Rust
//! Fieldtest c0009 #1 — the north-star run-to-report move, end to end.
|
|
//!
|
|
//! Axis (carrier): bootstrap a harness (SMA fast/slow -> Sub -> Exposure ->
|
|
//! SimBroker, price tapped into the broker) with TWO recording sinks (equity on
|
|
//! the broker, exposure on the Exposure node), run it, drain both sinks,
|
|
//! f64_field + summarize + build a RunManifest + to_json. Question under test:
|
|
//! does the public rustdoc make this reachable WITHOUT reading crates/*/src?
|
|
//!
|
|
//! price ----+--> SMA(2) --\
|
|
//! | Sub(fast - slow) --> Exposure(4) --+--> SimBroker --> equitySink
|
|
//! +--> SMA(4) --/ | ^
|
|
//! | v |
|
|
//! | exposureSink |
|
|
//! +------------------------------------------------ price --+ (slot 1)
|
|
//!
|
|
//! Public-surface facts used (rustdoc only):
|
|
//! - aura_std::Exposure::new(scale) = clamp(signal/scale, -1, +1), None until warm.
|
|
//! - aura_std::SimBroker::new(pip_size): slot 0 = exposure, slot 1 = price;
|
|
//! both Firing::Any, leading 0.0 rows during warm-up, one row per price cycle
|
|
//! (rustdoc struct.SimBroker "Input slots"/"Firing and warm-up" — these were
|
|
//! a 0007 spec_gap, now ON the surface; recorded as a `working` for that).
|
|
//! - aura_engine::f64_field(rows, field) extracts one f64 column (rustdoc).
|
|
//! - aura_engine::summarize(equity, exposure) -> RunMetrics (rustdoc).
|
|
//! - RunManifest { commit, params, window, seed, broker }, RunReport, to_json.
|
|
|
|
use std::sync::mpsc::{self, Sender};
|
|
|
|
use aura_core::{Ctx, Firing, InputSpec, Node, NodeSchema, Scalar, ScalarKind, Timestamp};
|
|
use aura_engine::{Edge, Harness, RunManifest, RunReport, SourceSpec, Target, f64_field, summarize};
|
|
use aura_std::{Exposure, SimBroker, Sma, Sub};
|
|
|
|
/// A recording sink: one f64 input, persists (ts, row) out of graph (C8/C22).
|
|
/// Records the WHOLE row (Vec<Scalar>) the way a real registry sink would, so
|
|
/// f64_field can later project a column — this is the shape summarize expects.
|
|
struct RowRecorder {
|
|
tx: Sender<(Timestamp, Vec<Scalar>)>,
|
|
}
|
|
impl Node for RowRecorder {
|
|
fn schema(&self) -> NodeSchema {
|
|
NodeSchema {
|
|
inputs: vec![InputSpec { kind: ScalarKind::F64, lookback: 1, firing: Firing::Any }],
|
|
output: vec![],
|
|
}
|
|
}
|
|
fn eval(&mut self, ctx: Ctx<'_>) -> Option<&[Scalar]> {
|
|
let w = ctx.f64_in(0);
|
|
if w.is_empty() {
|
|
return None;
|
|
}
|
|
let _ = self.tx.send((ctx.now(), vec![Scalar::F64(w[0])]));
|
|
None
|
|
}
|
|
}
|
|
|
|
fn f64_stream(pairs: &[(i64, f64)]) -> Vec<(Timestamp, Scalar)> {
|
|
pairs.iter().map(|&(t, v)| (Timestamp(t), Scalar::F64(v))).collect()
|
|
}
|
|
|
|
fn main() {
|
|
let (eq_tx, eq_rx) = mpsc::channel();
|
|
let (exp_tx, exp_rx) = mpsc::channel();
|
|
|
|
// nodes: 0=SMA(2) fast, 1=SMA(4) slow, 2=Sub, 3=Exposure(4),
|
|
// 4=SimBroker(1.0), 5=equity sink (taps broker), 6=exposure sink (taps Exposure).
|
|
let mut h = Harness::bootstrap(
|
|
vec![
|
|
Box::new(Sma::new(2)),
|
|
Box::new(Sma::new(4)),
|
|
Box::new(Sub::new()),
|
|
Box::new(Exposure::new(4.0)),
|
|
Box::new(SimBroker::new(1.0)),
|
|
Box::new(RowRecorder { tx: eq_tx }),
|
|
Box::new(RowRecorder { tx: exp_tx }),
|
|
],
|
|
vec![SourceSpec {
|
|
kind: ScalarKind::F64,
|
|
targets: vec![
|
|
Target { node: 0, slot: 0 }, // -> SMA fast
|
|
Target { node: 1, slot: 0 }, // -> SMA slow
|
|
Target { node: 4, slot: 1 }, // -> SimBroker price (slot 1)
|
|
],
|
|
}],
|
|
vec![
|
|
Edge { from: 0, to: 2, slot: 0, from_field: 0 }, // SMA2 -> Sub.in0 (fast)
|
|
Edge { from: 1, to: 2, slot: 1, from_field: 0 }, // SMA4 -> Sub.in1 (slow)
|
|
Edge { from: 2, to: 3, slot: 0, from_field: 0 }, // Sub -> Exposure
|
|
Edge { from: 3, to: 4, slot: 0, from_field: 0 }, // Exposure -> SimBroker.in0
|
|
Edge { from: 3, to: 6, slot: 0, from_field: 0 }, // Exposure -> exposure sink
|
|
Edge { from: 4, to: 5, slot: 0, from_field: 0 }, // SimBroker -> equity sink
|
|
],
|
|
)
|
|
.expect("valid two-sink signal-quality DAG");
|
|
|
|
let prices: &[(i64, f64)] = &[
|
|
(1, 100.0),
|
|
(2, 102.0),
|
|
(3, 104.0),
|
|
(4, 106.0),
|
|
(5, 108.0),
|
|
(6, 110.0),
|
|
(7, 112.0),
|
|
];
|
|
h.run(vec![f64_stream(prices)]);
|
|
|
|
// Drain both sinks (the World's post-run step the rustdoc describes).
|
|
let equity_rows: Vec<(Timestamp, Vec<Scalar>)> = eq_rx.try_iter().collect();
|
|
let exposure_rows: Vec<(Timestamp, Vec<Scalar>)> = exp_rx.try_iter().collect();
|
|
println!("equity rows = {equity_rows:?}");
|
|
println!("exposure rows = {exposure_rows:?}");
|
|
|
|
// Project field 0 of each (the documented bridge to summarize).
|
|
let equity = f64_field(&equity_rows, 0);
|
|
let exposure = f64_field(&exposure_rows, 0);
|
|
|
|
let metrics = summarize(&equity, &exposure);
|
|
println!("metrics = {metrics:?}");
|
|
|
|
// Hand model (public-surface, from c0007_1 + SimBroker rustdoc):
|
|
// equity curve = [0,0,0,0,1,2,3] (leading zeros until exposure warms at t=5),
|
|
// so total_pips = 3.0, monotonic non-decreasing => max_drawdown = 0.0.
|
|
// exposure samples are recorded only from the Exposure node's first warm
|
|
// cycle (t=4 on): all +0.5 (long), no sign change => exposure_sign_flips = 0.
|
|
assert_eq!(metrics.total_pips, 3.0, "final cumulative pips");
|
|
assert_eq!(metrics.max_drawdown, 0.0, "monotonic-up curve has no drawdown");
|
|
assert_eq!(metrics.exposure_sign_flips, 0, "exposure stays long throughout");
|
|
|
|
// Pair with a caller-built manifest and render the structured C14 face.
|
|
let report = RunReport {
|
|
manifest: RunManifest {
|
|
commit: "fieldtest-c0009-synthetic".to_string(),
|
|
params: vec![
|
|
("sma_fast".to_string(), 2.0),
|
|
("sma_slow".to_string(), 4.0),
|
|
("exposure_scale".to_string(), 4.0),
|
|
],
|
|
window: (Timestamp(1), Timestamp(7)),
|
|
seed: 0,
|
|
broker: "sim-optimal(pip_size=1)".to_string(),
|
|
},
|
|
metrics,
|
|
};
|
|
let json = report.to_json();
|
|
println!("to_json() =\n{json}");
|
|
|
|
// The rustdoc says: machine-readable JSON, field order fixed, params a JSON
|
|
// object in insertion order, f64 via {} shortest form. It does NOT state the
|
|
// exact field NAMES or nesting on the public surface — recorded as a
|
|
// spec_gap. I assert only what the rustdoc promises that I can check without
|
|
// reading src: it must parse as a non-empty object string and carry the
|
|
// numbers I put in. (No serde in this crate either, by design — so I do a
|
|
// substring check, which is itself a small friction.)
|
|
assert!(json.starts_with('{') && json.ends_with('}'), "looks like a JSON object");
|
|
assert!(json.contains("3"), "total_pips value present somewhere");
|
|
assert!(json.contains("sma_fast"), "a param key present");
|
|
assert!(json.contains("sim-optimal(pip_size=1)"), "broker label present");
|
|
|
|
println!("c0009_1 OK: two sinks -> f64_field -> summarize -> RunManifest -> to_json");
|
|
}
|