From 86746e3d5d434acb13cafbb48f241a87222e2b1f Mon Sep 17 00:00:00 2001 From: Brummel Date: Tue, 16 Jun 2026 17:11:25 +0200 Subject: [PATCH] refactor(aura-core): Scalar as a native tagged enum, disjoint from Cell; typed RunManifest.params MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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. --- crates/aura-cli/src/main.rs | 45 +++--- crates/aura-cli/tests/cli_run.rs | 2 +- crates/aura-core/src/scalar.rs | 169 ++++++++++++--------- crates/aura-engine/src/report.rs | 39 ++--- crates/aura-engine/src/walkforward.rs | 12 +- crates/aura-ingest/tests/real_bars.rs | 6 +- crates/aura-ingest/tests/streaming_seam.rs | 6 +- crates/aura-registry/src/lib.rs | 4 +- docs/glossary.md | 2 +- 9 files changed, 154 insertions(+), 131 deletions(-) diff --git a/crates/aura-cli/src/main.rs b/crates/aura-cli/src/main.rs index 07f933a..a46cdff 100644 --- a/crates/aura-cli/src/main.rs +++ b/crates/aura-cli/src/main.rs @@ -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, to_ms: Option) -> 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}"); diff --git a/crates/aura-cli/tests/cli_run.rs b/crates/aura-cli/tests/cli_run.rs index 6d46efe..4ec3126 100644 --- a/crates/aura-cli/tests/cli_run.rs +++ b/crates/aura-cli/tests/cli_run.rs @@ -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); diff --git a/crates/aura-core/src/scalar.rs b/crates/aura-core/src/scalar.rs index 666ab9b..8cf497b 100644 --- a/crates/aura-core/src/scalar.rs +++ b/crates/aura-core/src/scalar.rs @@ -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}"); + } + } } diff --git a/crates/aura-engine/src/report.rs b/crates/aura-engine/src/report.rs index 256bdd8..6d75b42 100644 --- a/crates/aura-engine/src/report.rs +++ b/crates/aura-engine/src/report.rs @@ -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, diff --git a/crates/aura-engine/src/walkforward.rs b/crates/aura-engine/src/walkforward.rs index 4edf646..0311cbb 100644 --- a/crates/aura-engine/src/walkforward.rs +++ b/crates/aura-engine/src/walkforward.rs @@ -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, pub oos_equity: Vec<(Timestamp, f64)>, pub oos_report: RunReport, @@ -243,10 +247,10 @@ pub fn param_stability(result: &WalkForwardResult) -> Vec { .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, diff --git a/crates/aura-ingest/tests/real_bars.rs b/crates/aura-ingest/tests/real_bars.rs index 6674509..603e909 100644 --- a/crates/aura-ingest/tests/real_bars.rs +++ b/crates/aura-ingest/tests/real_bars.rs @@ -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, diff --git a/crates/aura-ingest/tests/streaming_seam.rs b/crates/aura-ingest/tests/streaming_seam.rs index a612db9..5955854 100644 --- a/crates/aura-ingest/tests/streaming_seam.rs +++ b/crates/aura-ingest/tests/streaming_seam.rs @@ -82,9 +82,9 @@ fn run_sample(window: (Timestamp, Timestamp), source: Box) -> 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, diff --git a/crates/aura-registry/src/lib.rs b/crates/aura-registry/src/lib.rs index dc38ed2..002f25a 100644 --- a/crates/aura-registry/src/lib.rs +++ b/crates/aura-registry/src/lib.rs @@ -178,14 +178,14 @@ impl From 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(), diff --git a/docs/glossary.md b/docs/glossary.md index bf28597..1015f2a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -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:** —