refactor(aura-core): Scalar as a native tagged enum, disjoint from Cell; typed RunManifest.params

Scalar was `struct { kind: ScalarKind, cell: Cell }` — "a Cell wearing a kind hat." The recorded reason for that shape was migration ease, which is not a design rationale (CLAUDE.md: rationale != effort), so the struct had no substantive defense. Redefine it as the native tagged union it conceptually is:

    enum Scalar { I64(i64), F64(f64), Bool(bool), Timestamp(Timestamp) }

This is substantively better on four axes: kind/bits skew becomes unrepresentable (illegal states gone); accessors panic loudly on the wrong variant instead of a release-mode silent bit reinterpret; PartialEq derives the documented IEEE-754 / cross-kind value semantics (the hand-roll, needed only because the struct inherited Cell's bitwise compare, is gone); and serde is a plain derive emitting the externally-tagged wire form ({"I64":10}/{"F64":2.5}) — the private ScalarRepr shadow enum that motivated this was never needed. It is also C7-honest: erased-on-the-hot-path (Cell) and self-describing-at-the-edge (Scalar) are two disjoint types bridged by explicit conversion.

The whole public API is preserved (Scalar's fields were private), so call sites do not churn: the enum change is contained to scalar.rs, where cell() now encodes and from_cell() decodes per kind. Cell and ScalarKind are untouched.

With Scalar serializable, lift RunManifest.params from Vec<(String, f64)> to Vec<(String, Scalar)>: the param's kind (an i64 length vs an f64 scale) now survives into the C18 record (runs.jsonl) and the CLI JSON instead of collapsing to f64. scalar_as_param_f64 is deleted; sim_optimal_manifest passes typed params through. This is a deliberate wire-shape change — params now render as tagged scalars; the JSON-asserting tests are updated to the new shape on purpose.

Hand-authored manifest params across the CLI's single-run/mc/macd sites use honest kinds (lengths -> i64, scales -> f64) so they match the sweep path (which already derives correct kinds via zip_params); their in-binary JSON assertions are re-tagged accordingly.

Walk-forward fork resolves with no code change: WindowRun.chosen_params stays Vec<Scalar> (the serializable record carrier), reduced to f64 only at the param_stability statistic boundary (scalar_as_f64 retained). Glossary 'cell' entry updated to describe Scalar as the disjoint tagged union, not 'a cell plus its kind tag'.

Gates: build --all-targets, test --workspace (incl. new scalar_serde_round_trips), clippy -D warnings, doc --no-deps — all clean.
This commit is contained in:
2026-06-16 17:11:25 +02:00
parent 43716be10e
commit 86746e3d5d
9 changed files with 154 additions and 131 deletions
+17 -28
View File
@@ -132,9 +132,8 @@ fn sim_optimal_manifest(
window: (Timestamp, Timestamp),
seed: u64,
) -> RunManifest {
// The lossy f64 collapse lives here — the manifest field (the deferred typed
// param-space precursor) owns its own lossiness; callers pass typed Scalars.
let params = params.into_iter().map(|(n, s)| (n, scalar_as_param_f64(&s))).collect();
// Typed params pass straight through: the manifest carries self-describing
// Scalars, so a length stays `i64` and a scale `f64` in the record.
RunManifest {
commit: option_env!("AURA_COMMIT").unwrap_or("unknown").to_string(),
params,
@@ -163,8 +162,8 @@ fn run_sample() -> RunReport {
RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::f64(2.0)),
("sma_slow".to_string(), Scalar::f64(4.0)),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
@@ -226,8 +225,8 @@ fn run_sample_real(symbol: &str, from_ms: Option<i64>, to_ms: Option<i64>) -> Ru
RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::f64(2.0)),
("sma_slow".to_string(), Scalar::f64(4.0)),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
@@ -348,16 +347,6 @@ fn sample_blueprint() -> Composite {
build_sample()
}
/// Coerce a sweep point's `Scalar` value to the manifest's `f64` param type.
/// A tuning grid carries only numeric params (`I64` lengths, `F64` scales).
fn scalar_as_param_f64(s: &Scalar) -> f64 {
match s.kind() {
ScalarKind::I64 => s.as_i64() as f64,
ScalarKind::F64 => s.as_f64(),
other => unreachable!("non-numeric sweep param: {other:?}"),
}
}
/// Run the built-in sample over a small built-in grid (fast ∈ {2,3},
/// slow ∈ {4,5}, scale ∈ {0.5} — 4 points) and render one JSON line per point in
/// enumeration (odometer) order. Pure + deterministic (C1): the same build yields
@@ -611,8 +600,8 @@ fn mc_family() -> McFamily {
RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::f64(2.0)),
("sma_slow".to_string(), Scalar::f64(4.0)),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
@@ -867,9 +856,9 @@ fn run_macd() -> RunReport {
RunReport {
manifest: sim_optimal_manifest(
vec![
("ema_fast".to_string(), Scalar::f64(2.0)),
("ema_slow".to_string(), Scalar::f64(4.0)),
("ema_signal".to_string(), Scalar::f64(3.0)),
("ema_fast".to_string(), Scalar::i64(2)),
("ema_slow".to_string(), Scalar::i64(4)),
("ema_signal".to_string(), Scalar::i64(3)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
@@ -968,8 +957,8 @@ mod tests {
let report = RunReport {
manifest: sim_optimal_manifest(
vec![
("sma_fast".to_string(), Scalar::f64(2.0)),
("sma_slow".to_string(), Scalar::f64(4.0)),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
@@ -1035,10 +1024,10 @@ mod tests {
for line in &lines {
assert!(line.starts_with(r#"{"manifest":{"commit":""#), "not a RunReport: {line}");
}
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]);
assert!(lines[0].contains(r#""params":[["signals.trend.fast.length",{"I64":2}],["signals.trend.slow.length",{"I64":4}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["exposure.scale",{"F64":0.5}]]"#), "line0: {}", lines[0]);
assert!(lines[1].contains(r#""params":[["signals.trend.fast.length",{"I64":2}],["signals.trend.slow.length",{"I64":5}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["exposure.scale",{"F64":0.5}]]"#), "line1: {}", lines[1]);
assert!(lines[2].contains(r#""params":[["signals.trend.fast.length",{"I64":3}],["signals.trend.slow.length",{"I64":4}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["exposure.scale",{"F64":0.5}]]"#), "line2: {}", lines[2]);
assert!(lines[3].contains(r#""params":[["signals.trend.fast.length",{"I64":3}],["signals.trend.slow.length",{"I64":5}],["signals.momentum.fast.length",{"I64":2}],["signals.momentum.slow.length",{"I64":4}],["signals.momentum.signal.length",{"I64":3}],["signals.blend.weights[0]",{"F64":1.0}],["signals.blend.weights[1]",{"F64":1.0}],["exposure.scale",{"F64":0.5}]]"#), "line3: {}", lines[3]);
for line in &lines {
assert!(line.contains(r#""total_pips":"#), "missing total_pips: {line}");
assert!(line.contains(r#""max_drawdown":"#), "missing max_drawdown: {line}");
+1 -1
View File
@@ -213,7 +213,7 @@ fn sweep_prints_four_family_json_lines_and_exits_zero() {
// `broker` (the commit value is the volatile git HEAD, pinned by params below).
assert!(first.starts_with("{\"family_id\":\"sweep-0\",\"report\":{\"manifest\":{"), "got: {stdout}");
assert!(
first.contains("\"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]]"),
first.contains("\"params\":[[\"signals.trend.fast.length\",{\"I64\":2}],[\"signals.trend.slow.length\",{\"I64\":4}],[\"signals.momentum.fast.length\",{\"I64\":2}],[\"signals.momentum.slow.length\",{\"I64\":4}],[\"signals.momentum.signal.length\",{\"I64\":3}],[\"signals.blend.weights[0]\",{\"F64\":1.0}],[\"signals.blend.weights[1]\",{\"F64\":1.0}],[\"exposure.scale\",{\"F64\":0.5}]]"),
"got: {stdout}"
);
let _ = std::fs::remove_dir_all(&cwd);
+99 -70
View File
@@ -20,109 +20,118 @@ pub enum ScalarKind {
Timestamp,
}
/// One scalar value: a type-erased [`Cell`] plus its [`ScalarKind`] tag — the
/// self-describing form for the dynamic boundaries (builder binding,
/// serialization, rendering). The hot path uses the bare [`Cell`]; a `Scalar`
/// is the fatter form that remembers its own type.
/// One scalar value: a self-describing tagged union of the four base types — the
/// carrier for the dynamic boundaries (builder binding, serialization,
/// rendering), where a value travels detached from any co-present schema and so
/// must remember its own type. Its type-erased projection is [`Cell`], the
/// tag-free word the SoA hot path reads (the kind living at the column / edge,
/// C7). `Scalar` and `Cell` are **disjoint**, bridged by
/// [`from_cell`](Self::from_cell) (decode) and [`cell`](Self::cell) (encode).
///
/// Reading is the caller's responsibility. Each `as_*` accessor `debug_assert`s
/// that the stored kind matches, then hands back the **native** value — in
/// release the assert is gone, so an accessor *is* the bare cell read
/// (branch-free). Calling the wrong `as_*` for the stored kind is a caller bug
/// (debug-asserted), not a recoverable error.
/// Reading is the caller's responsibility: each `as_*` accessor matches the
/// expected variant and **panics** on a mismatch (a caller bug, not a
/// recoverable error), handing back the **native** value.
///
/// `PartialEq` is **value** equality (hand-written, not derived — the derived
/// one would inherit the `Cell`'s bitwise compare). Two scalars are equal iff
/// their kinds match and their native payloads compare equal, so `f64` keeps
/// IEEE-754 semantics (`NaN != NaN`, `+0.0 == -0.0`) exactly as the former enum
/// did, and a kind mismatch is never equal even when the words coincide
/// (`i64(0) != f64(0.0)`).
#[derive(Clone, Copy, Debug)]
pub struct Scalar {
kind: ScalarKind,
cell: Cell,
/// `PartialEq` is the derived value equality: same variant, then native payload
/// compare — so `f64` keeps IEEE-754 semantics (`NaN != NaN`, `+0.0 == -0.0`),
/// and a kind mismatch is never equal even when the words would coincide
/// (`i64(0) != f64(0.0)`, distinct variants). serde's externally-tagged enum
/// default is the self-describing wire form (`{"I64": 10}`, `{"F64": 2.5}`).
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub enum Scalar {
I64(i64),
F64(f64),
Bool(bool),
Timestamp(Timestamp),
}
impl Scalar {
/// Construct an `i64` scalar.
pub fn i64(v: i64) -> Self {
Scalar { kind: ScalarKind::I64, cell: Cell::from_i64(v) }
Scalar::I64(v)
}
/// Construct an `f64` scalar.
pub fn f64(v: f64) -> Self {
Scalar { kind: ScalarKind::F64, cell: Cell::from_f64(v) }
Scalar::F64(v)
}
/// Construct a `bool` scalar.
pub fn bool(v: bool) -> Self {
Scalar { kind: ScalarKind::Bool, cell: Cell::from_bool(v) }
Scalar::Bool(v)
}
/// Construct a [`Timestamp`] scalar.
pub fn ts(v: Timestamp) -> Self {
Scalar { kind: ScalarKind::Timestamp, cell: Cell::from_ts(v) }
Scalar::Timestamp(v)
}
/// Re-attach a `kind` to a tag-free [`Cell`] at a dynamic boundary — the
/// inverse of the hot path's tag-stripping. Used where the kind comes from a
/// Decode a tag-free [`Cell`] into a `Scalar` by naming its `kind` — the
/// inverse of [`cell`](Self::cell). Used where the kind comes from a
/// co-present schema rather than from the value itself: a validated param
/// point carries its values as bare cells (C7 — the kind lives once, in the
/// co-indexed `ParamSpec`), and `zip_params` recombines the two into the
/// self-describing `Scalar` only at the render boundary. The `Cell`'s bits are
/// taken verbatim; correctness rests on `kind` actually describing them (the
/// bootstrap's edge-time check, not a per-value tag).
/// self-describing `Scalar` at the render boundary. The cell's bits are read
/// per `kind`; correctness rests on `kind` actually describing them (the
/// bootstrap's edge-time check, not a per-value tag) — but the result is an
/// honestly-typed value, so no skewed kind/bits pairing survives downstream.
pub fn from_cell(kind: ScalarKind, cell: Cell) -> Self {
Scalar { kind, cell }
match kind {
ScalarKind::I64 => Scalar::I64(cell.i64()),
ScalarKind::F64 => Scalar::F64(cell.f64()),
ScalarKind::Bool => Scalar::Bool(cell.bool()),
ScalarKind::Timestamp => Scalar::Timestamp(cell.ts()),
}
}
/// The bare [`Cell`], kind dropped — the strip side of the dynamic boundary
/// (the inverse of [`from_cell`](Self::from_cell)). Used by the param-plane
/// frontend to hand a validated value to the cell-carrying construction base,
/// once the kind has been checked against the slot it no longer needs to ride
/// along.
/// Encode to the tag-free [`Cell`], kind dropped — the strip side of the
/// dynamic boundary (the inverse of [`from_cell`](Self::from_cell)). Used by
/// the param-plane frontend to hand a validated value to the cell-carrying
/// construction base, once the kind has been checked against the slot it no
/// longer needs to ride along.
pub fn cell(self) -> Cell {
self.cell
match self {
Scalar::I64(v) => Cell::from_i64(v),
Scalar::F64(v) => Cell::from_f64(v),
Scalar::Bool(v) => Cell::from_bool(v),
Scalar::Timestamp(v) => Cell::from_ts(v),
}
}
/// The kind tag of this scalar (a field read, no branch).
/// The kind tag of this scalar (the variant discriminant).
pub fn kind(self) -> ScalarKind {
self.kind
match self {
Scalar::I64(_) => ScalarKind::I64,
Scalar::F64(_) => ScalarKind::F64,
Scalar::Bool(_) => ScalarKind::Bool,
Scalar::Timestamp(_) => ScalarKind::Timestamp,
}
}
/// Read as `i64`. The caller asserts the kind by choosing this accessor; a
/// mismatch is a caller bug (debug-asserted, free in release).
/// mismatch is a caller bug (panics).
pub fn as_i64(self) -> i64 {
debug_assert_eq!(self.kind, ScalarKind::I64);
self.cell.i64()
}
/// Read as `f64`. Caller asserts the kind (debug-asserted, free in release).
pub fn as_f64(self) -> f64 {
debug_assert_eq!(self.kind, ScalarKind::F64);
self.cell.f64()
}
/// Read as `bool`. Caller asserts the kind (debug-asserted, free in release).
pub fn as_bool(self) -> bool {
debug_assert_eq!(self.kind, ScalarKind::Bool);
self.cell.bool()
}
/// Read as [`Timestamp`]. Caller asserts the kind (debug-asserted, free in release).
pub fn as_ts(self) -> Timestamp {
debug_assert_eq!(self.kind, ScalarKind::Timestamp);
self.cell.ts()
}
}
/// Value equality, not the `Cell`'s bitwise one: kinds must match, then the
/// native payloads compare — so `f64` keeps IEEE-754 semantics (`NaN != NaN`,
/// `+0.0 == -0.0`), matching the pre-`Cell` enum's behaviour.
impl PartialEq for Scalar {
fn eq(&self, other: &Self) -> bool {
if self.kind != other.kind {
return false;
match self {
Scalar::I64(v) => v,
other => panic!("as_i64 on {:?}", other.kind()),
}
match self.kind {
ScalarKind::I64 => self.as_i64() == other.as_i64(),
ScalarKind::F64 => self.as_f64() == other.as_f64(),
ScalarKind::Bool => self.as_bool() == other.as_bool(),
ScalarKind::Timestamp => self.as_ts() == other.as_ts(),
}
/// Read as `f64`. Caller asserts the kind (panics on mismatch).
pub fn as_f64(self) -> f64 {
match self {
Scalar::F64(v) => v,
other => panic!("as_f64 on {:?}", other.kind()),
}
}
/// Read as `bool`. Caller asserts the kind (panics on mismatch).
pub fn as_bool(self) -> bool {
match self {
Scalar::Bool(v) => v,
other => panic!("as_bool on {:?}", other.kind()),
}
}
/// Read as [`Timestamp`]. Caller asserts the kind (panics on mismatch).
pub fn as_ts(self) -> Timestamp {
match self {
Scalar::Timestamp(v) => v,
other => panic!("as_ts on {:?}", other.kind()),
}
}
}
@@ -248,4 +257,24 @@ mod tests {
let back: Timestamp = serde_json::from_str(&json).expect("deserialize Timestamp");
assert_eq!(back, ts);
}
#[test]
fn scalar_serde_round_trips() {
// serde's externally-tagged enum default: the kind is the JSON key, the
// native value the body — the self-describing wire form the run record
// (RunManifest.params) relies on. i64 and f64 keep their distinct shapes,
// so a length and a scale never collapse to the same token.
assert_eq!(serde_json::to_string(&Scalar::i64(42)).unwrap(), r#"{"I64":42}"#);
assert_eq!(serde_json::to_string(&Scalar::f64(2.5)).unwrap(), r#"{"F64":2.5}"#);
for s in [
Scalar::i64(42),
Scalar::f64(2.5),
Scalar::bool(true),
Scalar::ts(Timestamp(1_700_000_000_000_000_000)),
] {
let json = serde_json::to_string(&s).expect("serialize Scalar");
let back: Scalar = serde_json::from_str(&json).expect("deserialize Scalar");
assert_eq!(back, s, "round-trip failed: {json}");
}
}
}
+20 -19
View File
@@ -36,9 +36,10 @@ pub struct RunManifest {
/// Node/engine identity: the git commit of the frozen artifact (C18 —
/// commit = identity; the frozen bot *is* a commit).
pub commit: String,
/// The bound tuning params as ordered `name -> value` pairs — the precursor
/// to the eventual typed param-space (deferred; see spec Non-goals).
pub params: Vec<(String, f64)>,
/// The bound tuning params as ordered `name -> value` pairs. Each value is a
/// self-describing [`Scalar`], so the param's kind (an `i64` length vs an
/// `f64` scale) survives into the record instead of collapsing to `f64`.
pub params: Vec<(String, Scalar)>,
/// The data-window: inclusive `(from, to)` epoch-ns bounds (C12).
pub window: (Timestamp, Timestamp),
/// The RNG seed (C12 seed-as-input). `0` for a seed-free synthetic run.
@@ -60,9 +61,9 @@ impl RunReport {
/// Render the canonical, machine-readable JSON (C14) via serde — the same
/// encoder the run registry uses on disk, so a record's stdout shape and its
/// `runs.jsonl` shape are byte-identical. `params` is an array of
/// `[name, value]` pairs; finite `f64` fields carry a fractional part
/// (`2.0`). Consumers must parse values as numbers, never key off the token
/// shape.
/// `[name, value]` pairs where `value` is a self-describing tagged scalar
/// (serde's externally-tagged enum: `{"I64": 10}` for a length, `{"F64": 2.5}`
/// for a scale). Consumers parse the tagged object, never a bare number.
pub fn to_json(&self) -> String {
serde_json::to_string(self).expect("a finite RunReport always serializes")
}
@@ -236,9 +237,9 @@ mod tests {
manifest: RunManifest {
commit: "test-commit".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window: (Timestamp(1), Timestamp(5)),
seed: 0,
@@ -338,9 +339,9 @@ mod tests {
manifest: RunManifest {
commit: "abc123".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 1.0),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(1.0)),
],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
@@ -354,7 +355,7 @@ mod tests {
};
assert_eq!(
report.to_json(),
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",2.0],["sma_slow",4.0],["exposure_scale",1.0]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}}"#,
r#"{"manifest":{"commit":"abc123","params":[["sma_fast",{"I64":2}],["sma_slow",{"I64":4}],["exposure_scale",{"F64":1.0}]],"window":[1,6],"seed":0,"broker":"sim-optimal(pip_size=1.0)"},"metrics":{"total_pips":12.0,"max_drawdown":1.0,"exposure_sign_flips":1}}"#,
);
}
@@ -365,9 +366,9 @@ mod tests {
manifest: RunManifest {
commit: "abc123".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 1.0),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(1.0)),
],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
@@ -385,9 +386,9 @@ mod tests {
manifest: RunManifest {
commit: "abc123".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 1.0),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(1.0)),
],
window: (Timestamp(1), Timestamp(6)),
seed: 0,
+8 -4
View File
@@ -122,6 +122,10 @@ impl Iterator for WindowRoller {
/// [`SweepPoint`](crate::SweepPoint)/[`McDraw`](crate::McDraw).
#[derive(Clone, Debug, PartialEq)]
pub struct WindowRun {
/// The chosen params, kept as self-describing [`Scalar`]s — the record carrier
/// (serializable). `param_stability` reduces them to `f64` only at the
/// statistic boundary (a distribution over windows is intrinsically
/// real-valued); the typed value is preserved up to that one reduction.
pub chosen_params: Vec<Scalar>,
pub oos_equity: Vec<(Timestamp, f64)>,
pub oos_report: RunReport,
@@ -243,10 +247,10 @@ pub fn param_stability(result: &WalkForwardResult) -> Vec<MetricStats> {
.collect()
}
/// Coerce a numeric `Scalar` to `f64` for stability statistics (mirrors the CLI's
/// `scalar_as_param_f64`). Params are `i64`/`f64` typed values; a non-numeric
/// scalar in a param slot is a wiring bug, surfaced like the engine's other
/// "checked at wiring" violations.
/// Coerce a numeric `Scalar` to `f64` for stability statistics — the one place a
/// distribution over windows demands a real-valued reduction. Params are
/// `i64`/`f64` typed values; a non-numeric scalar in a param slot is a wiring
/// bug, surfaced like the engine's other "checked at wiring" violations.
fn scalar_as_f64(s: Scalar) -> f64 {
match s.kind() {
aura_core::ScalarKind::I64 => s.as_i64() as f64,
+3 -3
View File
@@ -82,9 +82,9 @@ fn run_sample_over(prices: Vec<(Timestamp, Scalar)>) -> RunReport {
manifest: RunManifest {
commit: "real-bars-test".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
seed: 0,
+3 -3
View File
@@ -82,9 +82,9 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box<dyn Source>) -> RunRep
manifest: RunManifest {
commit: "streaming-seam-test".to_string(),
params: vec![
("sma_fast".to_string(), 2.0),
("sma_slow".to_string(), 4.0),
("exposure_scale".to_string(), 0.5),
("sma_fast".to_string(), Scalar::i64(2)),
("sma_slow".to_string(), Scalar::i64(4)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
seed: 0,
+2 -2
View File
@@ -178,14 +178,14 @@ impl From<std::io::Error> for RegistryError {
#[cfg(test)]
mod tests {
use super::*;
use aura_core::{Cell, Timestamp};
use aura_core::{Cell, Scalar, Timestamp};
use aura_engine::{RunManifest, RunMetrics, RunReport, SweepFamily, SweepPoint};
fn report_with(total_pips: f64, max_drawdown: f64, flips: u64) -> RunReport {
RunReport {
manifest: RunManifest {
commit: "c".to_string(),
params: vec![("p".to_string(), 1.0)],
params: vec![("p".to_string(), Scalar::f64(1.0))],
window: (Timestamp(1), Timestamp(2)),
seed: 0,
broker: "b".to_string(),
+1 -1
View File
@@ -45,7 +45,7 @@ The dynamically-loadable Rust library form of a project and its nodes, hot-reloa
### cell
**Avoid:** —
The type-erased 64-bit word holding one scalar-base-type value with its kind stripped off (`crates/aura-core/src/cell.rs`): the type lives at the schema/column/port, so a cell is read only by naming it via a branch-free accessor (`i64()`/`f64()`/`bool()`/`ts()`). A `Scalar` is a cell plus its kind tag — the self-describing carrier for the dynamic boundaries — whereas a bare cell is what the SoA hot path reads without a per-value branch.
The type-erased 64-bit word holding one scalar-base-type value with its kind stripped off (`crates/aura-core/src/cell.rs`): the type lives at the schema/column/port, so a cell is read only by naming it via a branch-free accessor (`i64()`/`f64()`/`bool()`/`ts()`). A bare cell is what the SoA hot path reads without a per-value branch, whereas a `Scalar` — the self-describing tagged union of the four base types, used at the dynamic boundaries — is its disjoint counterpart, bridged by `Scalar::cell` (encode) / `Scalar::from_cell` (decode).
### composite
**Avoid:** —