//! The closed four-type scalar set streamed on aura's hot path (C7): `i64`, //! `f64`, `bool`, and `Timestamp` (epoch-ns UTC). The raw value is a type-erased //! [`Cell`] — one 64-bit word, no tag; a [`Scalar`] pairs a `Cell` with its //! [`ScalarKind`] for the dynamic boundaries. Both are `Copy` PODs — no heap, no //! reference counting. use crate::cell::Cell; /// Canonical engine time: epoch-nanoseconds UTC. Newtype over `i64` (C7). /// `Default` (epoch 0) lets `Column` pre-size its ring. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, serde::Serialize, serde::Deserialize)] pub struct Timestamp(pub i64); /// The kind tag of a scalar / column, used for the edge-time type check (C7). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] pub enum ScalarKind { I64, F64, Bool, Timestamp, } /// 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 matches the /// expected variant and **panics** on a mismatch (a caller bug, not a /// recoverable error), handing back the **native** value. /// /// `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::I64(v) } /// Construct an `f64` scalar. pub fn f64(v: f64) -> Self { Scalar::F64(v) } /// Construct a `bool` scalar. pub fn bool(v: bool) -> Self { Scalar::Bool(v) } /// Construct a [`Timestamp`] scalar. pub fn ts(v: Timestamp) -> Self { Scalar::Timestamp(v) } /// 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` 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 { 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()), } } /// 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 { 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 (the variant discriminant). pub fn kind(self) -> ScalarKind { 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 (panics). pub fn as_i64(self) -> i64 { match self { Scalar::I64(v) => v, other => panic!("as_i64 on {:?}", other.kind()), } } /// 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()), } } } impl From for Scalar { fn from(v: i64) -> Self { Scalar::i64(v) } } impl From for Scalar { fn from(v: f64) -> Self { Scalar::f64(v) } } impl From for Scalar { fn from(v: bool) -> Self { Scalar::bool(v) } } impl From for Scalar { fn from(v: Timestamp) -> Self { Scalar::ts(v) } } #[cfg(test)] mod tests { use super::*; #[test] fn kind_matches_variant() { assert_eq!(Scalar::i64(1).kind(), ScalarKind::I64); assert_eq!(Scalar::f64(1.0).kind(), ScalarKind::F64); assert_eq!(Scalar::bool(true).kind(), ScalarKind::Bool); assert_eq!(Scalar::ts(Timestamp(1)).kind(), ScalarKind::Timestamp); } #[test] fn from_widenings() { assert_eq!(Scalar::from(7i64), Scalar::i64(7)); assert_eq!(Scalar::from(2.5f64), Scalar::f64(2.5)); assert_eq!(Scalar::from(true), Scalar::bool(true)); assert_eq!(Scalar::from(Timestamp(9)), Scalar::ts(Timestamp(9))); } #[test] fn bare_literal_infers_kind_through_into_scalar() { // Authoring layers that lower raw literals via `impl Into` rely on // a bare (unsuffixed) integer literal inferring as `i64` and a bare float // literal as `f64` — because `i64`/`f64`/`bool` are the only `From` impls, // each literal class resolves to exactly one of them. Adding a second // integer `From` impl (e.g. `From`) would make `lower(2)` ambiguous // and break this, loudly, here rather than silently at the call site. fn lower(v: impl Into) -> Scalar { v.into() } assert_eq!(lower(2), Scalar::i64(2)); assert_eq!(lower(0.5), Scalar::f64(0.5)); } #[test] fn timestamp_orders_and_defaults() { assert!(Timestamp(1) < Timestamp(2)); assert_eq!(Timestamp::default(), Timestamp(0)); } #[test] fn scalar_value_accessors_return_native_payload() { assert_eq!(Scalar::i64(3).as_i64(), 3); assert_eq!(Scalar::f64(0.5).as_f64(), 0.5); assert!(Scalar::bool(true).as_bool()); assert_eq!(Scalar::ts(Timestamp(7)).as_ts(), Timestamp(7)); } #[test] fn from_cell_and_cell_are_inverse() { // cell() strips the kind; from_cell re-attaches it — the dynamic-boundary // round trip the param-plane frontend/base split relies on (strip to hand a // validated value to the cell base, re-attach to render it). for s in [Scalar::i64(7), Scalar::f64(2.5), Scalar::bool(true), Scalar::ts(Timestamp(9))] { assert_eq!(Scalar::from_cell(s.kind(), s.cell()), s); } } #[test] fn scalar_eq_is_value_not_bitwise() { // f64 follows IEEE-754 exactly as the former enum did: a NaN is never // equal to itself, and +0.0 equals -0.0 — value equality, NOT the // bitwise equality the underlying `Cell` carries (those two cases are // precisely where bit- and value-equality diverge). assert_ne!(Scalar::f64(f64::NAN), Scalar::f64(f64::NAN)); assert_eq!(Scalar::f64(0.0), Scalar::f64(-0.0)); assert_eq!(Scalar::f64(2.5), Scalar::f64(2.5)); // i64 / bool / timestamp compare by native value. assert_eq!(Scalar::i64(7), Scalar::i64(7)); assert_ne!(Scalar::i64(7), Scalar::i64(8)); assert_eq!(Scalar::bool(true), Scalar::bool(true)); assert_ne!(Scalar::bool(true), Scalar::bool(false)); assert_eq!(Scalar::ts(Timestamp(9)), Scalar::ts(Timestamp(9))); // A kind mismatch is never equal — even when the words coincide: // i64(0) and f64(0.0) share the bit pattern 0; bool(true) and i64(1) // share 1. Value equality still separates them by kind. assert_ne!(Scalar::i64(0), Scalar::f64(0.0)); assert_ne!(Scalar::i64(1), Scalar::bool(true)); } #[test] fn scalar_is_copy() { let s = Scalar::f64(1.0); let a = s; let b = s; // Copy: `s` still usable after move-by-value assert_eq!(a, b); assert_eq!(s, a); } #[test] fn timestamp_serde_round_trips() { let ts = Timestamp(1_700_000_000_000_000_000); let json = serde_json::to_string(&ts).expect("serialize Timestamp"); // a newtype struct serializes transparently as its inner i64 — this is // what lets RunManifest's `window` render as a [from, to] integer array assert_eq!(json, "1700000000000000000"); 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}"); } } #[test] fn scalar_kind_serde_is_variant_name() { // canonical kind encoding: a bare variant-name string, both directions. assert_eq!(serde_json::to_string(&ScalarKind::I64).unwrap(), "\"I64\""); assert_eq!(serde_json::to_string(&ScalarKind::F64).unwrap(), "\"F64\""); let back: ScalarKind = serde_json::from_str("\"I64\"").unwrap(); assert_eq!(back, ScalarKind::I64); } }