refactor(aura-core): split Scalar into a tag-free Cell + ScalarKind

Motivation
----------
`Scalar` was a tagged enum (I64/F64/Bool/Ts), so every scalar value
physically carried its own kind tag. But the kind is already known from
the schema/port/column the value flows through (C7: the type is a
property of the column, not of the value — the hot path is already
columnar `Column<T>`, and `AnyColumn::get` *reconstructs* the tag from
the column on the way out). The per-value tag was therefore redundant
with the kind the surrounding context already holds.

That redundancy had three costs:

  * It baked an implicit `match` (a branch) into every function that read
    a Scalar payload — even where the caller statically knew the type.
    The tag could never be exploited away.
  * Size: a tagged enum is tag + payload = 16 bytes (f64/i64 alignment),
    twice the 8 bytes the value needs. A `Column<Scalar>` would be double
    the memory and half the cache utilisation.
  * It is the shared root of several downstream papercuts we keep hitting
    — the lossy f64 manifest field, the `unreachable!` panic on a
    non-numeric param, the serde-tag question — all symptoms of "the type
    is baked into the value".

Change
------
Introduce `Cell`: a type-erased 64-bit word (`struct Cell(u64)`) that is
not readable without external type context. It is constructed per base
type (`from_i64/from_f64/from_bool/from_ts`) and read only by naming the
type at the call site (`i64()/f64()/bool()/ts()`) — each a branch-free
bit-cast. The hot path resolves the kind once at the boundary (from the
schema) and then reads natively, with no per-value branch. `Cell` knows
nothing of `Scalar` or `ScalarKind`; the dependency is strictly one-way,
and it lives in its own `cell.rs` (more is planned on top of it).

`Scalar` becomes `struct { kind: ScalarKind, cell: Cell }` — the
self-describing form for the dynamic boundaries (builder binding,
serialization, rendering), built on top of `Cell`. Its `as_*` accessors
now `debug_assert` the kind and return the native value (free in
release); calling the wrong accessor is a caller bug, not a checked
`Option`. The variant constructors `Scalar::I64(..)` become associated
fns `Scalar::i64(..)`.

`PartialEq` is hand-written (not derived) to preserve the former enum's
value semantics: kinds must match, then native payloads compare, so f64
keeps IEEE-754 behaviour (`NaN != NaN`, `+0.0 == -0.0`) and a kind
mismatch is never equal even when the raw words coincide. A fixture
(`scalar_eq_is_value_not_bitwise`) pins exactly the cases where bit- and
value-equality diverge, so it can't silently regress. `Cell`'s own
`Eq`/`Hash` stay bitwise — correct for a raw word.

The change is behaviour-preserving: Scalar's observable behaviour is
identical to the pre-Cell enum (the value-equality fixture proves it);
only the internal representation changed. The ~440 call sites across the
workspace are a mechanical constructor rename plus ~12 destructuring
sites (match-arms / `let`-patterns) rewritten to `kind()` + `as_*`.

Verified: cargo build --workspace --all-targets, cargo clippy --workspace
--all-targets -- -D warnings, cargo test --workspace — all green.
This commit is contained in:
2026-06-16 12:12:52 +02:00
parent b188773fb8
commit cd3d1ca9ed
28 changed files with 573 additions and 429 deletions
+24 -24
View File
@@ -41,7 +41,7 @@ fn synthetic_prices() -> Vec<(Timestamp, Scalar)> {
(7, 0.9990),
]
.iter()
.map(|&(t, p)| (Timestamp(t), Scalar::F64(p)))
.map(|&(t, p)| (Timestamp(t), Scalar::f64(p)))
.collect()
}
@@ -58,7 +58,7 @@ fn showcase_prices() -> Vec<(Timestamp, Scalar)> {
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p)))
.collect()
}
@@ -163,9 +163,9 @@ 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)),
("exposure_scale".to_string(), Scalar::F64(0.5)),
("sma_fast".to_string(), Scalar::f64(2.0)),
("sma_slow".to_string(), Scalar::f64(4.0)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
@@ -226,9 +226,9 @@ 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)),
("exposure_scale".to_string(), Scalar::F64(0.5)),
("sma_fast".to_string(), Scalar::f64(2.0)),
("sma_slow".to_string(), Scalar::f64(4.0)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
@@ -295,7 +295,7 @@ fn signals(name: &str) -> Composite {
let blend = g.add(
LinComb::builder(3)
.named("blend")
.bind("weights[2]", Scalar::F64(0.5)),
.bind("weights[2]", Scalar::f64(0.5)),
);
let price = g.input_role("price");
g.feed(price, [trend.input("price"), momentum.input("price")]);
@@ -351,9 +351,9 @@ fn sample_blueprint() -> Composite {
/// 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 {
Scalar::I64(n) => *n as f64,
Scalar::F64(f) => *f,
match s.kind() {
ScalarKind::I64 => s.as_i64() as f64,
ScalarKind::F64 => s.as_f64(),
other => unreachable!("non-numeric sweep param: {other:?}"),
}
}
@@ -600,9 +600,9 @@ 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)),
("exposure_scale".to_string(), Scalar::F64(0.5)),
("sma_fast".to_string(), Scalar::f64(2.0)),
("sma_slow".to_string(), Scalar::f64(4.0)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
seed,
@@ -813,7 +813,7 @@ fn macd_blueprint() -> Composite {
/// windows so the 7-tick synthetic stream still produces a non-trivial trace
/// (conventional MACD is 12/26/9, meaningless on 7 points).
fn macd_point() -> Vec<Scalar> {
vec![Scalar::I64(2), Scalar::I64(4), Scalar::I64(3), Scalar::F64(0.5)]
vec![Scalar::i64(2), Scalar::i64(4), Scalar::i64(3), Scalar::f64(0.5)]
}
/// A longer synthetic stream than the SMA sample's 7 ticks: MACD's EMAs each warm
@@ -826,7 +826,7 @@ fn macd_prices() -> Vec<(Timestamp, Scalar)> {
]
.iter()
.enumerate()
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::F64(p)))
.map(|(i, &p)| (Timestamp(i as i64 + 1), Scalar::f64(p)))
.collect()
}
@@ -856,10 +856,10 @@ 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)),
("exposure_scale".to_string(), Scalar::F64(0.5)),
("ema_fast".to_string(), Scalar::f64(2.0)),
("ema_slow".to_string(), Scalar::f64(4.0)),
("ema_signal".to_string(), Scalar::f64(3.0)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
0,
@@ -957,9 +957,9 @@ 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)),
("exposure_scale".to_string(), Scalar::F64(0.5)),
("sma_fast".to_string(), Scalar::f64(2.0)),
("sma_slow".to_string(), Scalar::f64(4.0)),
("exposure_scale".to_string(), Scalar::f64(0.5)),
],
window,
seed,