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:
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user