82635fa259
Motivation ---------- The C7 realization note (cd3d1ca/049f22a) deferred this step: with Scalar split into { kind, cell }, the tag-free Cell could become the hot-path carrier, but eval and the edges still flowed the fatter self-describing Scalar. This lands the swap. Change ------ - Node::eval now returns Option<&[Cell]>; every node out-buffer is [Cell; N]. The inter-node forward copies the row into a Vec<Cell> and pushes each field via a new branch-free, infallible AnyColumn::push_cell — the edge from_field->slot kind match is verified once at bootstrap (C7), so the runtime push needs no per-value kind check. It only removes a Result the forward path already .expect()ed. - Scalar stays on the self-describing dynamic boundaries: the param plane (build / bind / compile_with_params / sweep points / RunManifest), AnyColumn::get (the type-erased read for sinks / serde), and source ingestion (Source::next, the heterogeneous C3 merge). The fallible AnyColumn::push is retained for ingestion + the kind-guard tests. Trade ----- The per-value runtime kind check on node OUTPUT is removed: a node that builds a wrong-kind cell pushes raw bits with no panic. This is the same authoring-bug class C8 already leaves to a debug_assert (output width); node-output-kind correctness is the node's declared FieldSpec contract, caught by each node's own eval test. Cross-node kind agreement still rests on the bootstrap kind-check (bootstrap_rejects_*_kind_mismatch, green). Tests ----- Behaviour-preserving (C1): 285 green, 0 red. The only test-expectation edits are carrier re-spellings (Scalar::f64(x) -> Cell::from_f64(x)) of identical values; test column writes and out-of-graph channel rows stay Scalar (the spec's three-role rule). Adds an AnyColumn::push_cell round-trip test plus a cross-kind end-to-end forward test (a mixed f64/i64 record whose bit patterns diverge across kinds — pins the i64 forward path the f64-only tests cannot catch). Ledger C7/C8 notes updated. Gates: cargo build / test / clippy --all-targets -D warnings / doc --workspace all clean. closes #74
253 lines
8.5 KiB
Rust
253 lines
8.5 KiB
Rust
//! `AnyColumn` — the type-erased edge: the four concrete `Column<T>` behind one
|
|
//! enum. The kind check is paid at the edge (wiring time); the hot loop grabs
|
|
//! the concrete column once (`as_*_mut`) and pushes monomorphically (C7).
|
|
|
|
use crate::column::Column;
|
|
use crate::error::KindMismatch;
|
|
use crate::scalar::{Scalar, ScalarKind, Timestamp};
|
|
use crate::cell::Cell;
|
|
|
|
/// A type-erased edge over the four scalar columns (C7).
|
|
pub enum AnyColumn {
|
|
I64(Column<i64>),
|
|
F64(Column<f64>),
|
|
Bool(Column<bool>),
|
|
Ts(Column<Timestamp>),
|
|
}
|
|
|
|
impl AnyColumn {
|
|
/// Create a type-erased column of `kind`, sized to `lookback` (C8).
|
|
pub fn with_capacity(kind: ScalarKind, lookback: usize) -> Self {
|
|
match kind {
|
|
ScalarKind::I64 => AnyColumn::I64(Column::with_capacity(lookback)),
|
|
ScalarKind::F64 => AnyColumn::F64(Column::with_capacity(lookback)),
|
|
ScalarKind::Bool => AnyColumn::Bool(Column::with_capacity(lookback)),
|
|
ScalarKind::Timestamp => AnyColumn::Ts(Column::with_capacity(lookback)),
|
|
}
|
|
}
|
|
|
|
pub fn kind(&self) -> ScalarKind {
|
|
match self {
|
|
AnyColumn::I64(_) => ScalarKind::I64,
|
|
AnyColumn::F64(_) => ScalarKind::F64,
|
|
AnyColumn::Bool(_) => ScalarKind::Bool,
|
|
AnyColumn::Ts(_) => ScalarKind::Timestamp,
|
|
}
|
|
}
|
|
|
|
pub fn len(&self) -> usize {
|
|
match self {
|
|
AnyColumn::I64(c) => c.len(),
|
|
AnyColumn::F64(c) => c.len(),
|
|
AnyColumn::Bool(c) => c.len(),
|
|
AnyColumn::Ts(c) => c.len(),
|
|
}
|
|
}
|
|
|
|
pub fn is_empty(&self) -> bool {
|
|
self.len() == 0
|
|
}
|
|
|
|
pub fn run_count(&self) -> u64 {
|
|
match self {
|
|
AnyColumn::I64(c) => c.run_count(),
|
|
AnyColumn::F64(c) => c.run_count(),
|
|
AnyColumn::Bool(c) => c.run_count(),
|
|
AnyColumn::Ts(c) => c.run_count(),
|
|
}
|
|
}
|
|
|
|
/// Edge-time typed push. `Err(KindMismatch)` if `v`'s kind != this column's
|
|
/// kind; the column is left untouched (C7 guard). The hot path bypasses this
|
|
/// by taking the concrete `Column<T>` once via `as_*_mut`.
|
|
pub fn push(&mut self, v: Scalar) -> Result<(), KindMismatch> {
|
|
match (self, v.kind()) {
|
|
(AnyColumn::I64(c), ScalarKind::I64) => {
|
|
c.push(v.as_i64());
|
|
Ok(())
|
|
}
|
|
(AnyColumn::F64(c), ScalarKind::F64) => {
|
|
c.push(v.as_f64());
|
|
Ok(())
|
|
}
|
|
(AnyColumn::Bool(c), ScalarKind::Bool) => {
|
|
c.push(v.as_bool());
|
|
Ok(())
|
|
}
|
|
(AnyColumn::Ts(c), ScalarKind::Timestamp) => {
|
|
c.push(v.as_ts());
|
|
Ok(())
|
|
}
|
|
(this, got) => Err(KindMismatch { expected: this.kind(), got }),
|
|
}
|
|
}
|
|
|
|
/// Branch-free, infallible hot-path push of a bare [`Cell`] into this column's
|
|
/// concrete `Column<T>`, reinterpreting the cell by *this* column's kind. The
|
|
/// inter-node forward uses this: the edge's `from_field`→slot kind match is
|
|
/// verified once at bootstrap (C7), so no per-value kind check is needed here
|
|
/// — unlike [`push`](Self::push), which keeps the kind guard for the
|
|
/// ingestion boundary.
|
|
pub fn push_cell(&mut self, c: Cell) {
|
|
match self {
|
|
AnyColumn::I64(col) => col.push(c.i64()),
|
|
AnyColumn::F64(col) => col.push(c.f64()),
|
|
AnyColumn::Bool(col) => col.push(c.bool()),
|
|
AnyColumn::Ts(col) => col.push(c.ts()),
|
|
}
|
|
}
|
|
|
|
/// Read one type-erased value (0 = newest). `None` if cold / out of range.
|
|
pub fn get(&self, k: usize) -> Option<Scalar> {
|
|
match self {
|
|
AnyColumn::I64(c) => c.get(k).map(Scalar::i64),
|
|
AnyColumn::F64(c) => c.get(k).map(Scalar::f64),
|
|
AnyColumn::Bool(c) => c.get(k).map(Scalar::bool),
|
|
AnyColumn::Ts(c) => c.get(k).map(Scalar::ts),
|
|
}
|
|
}
|
|
|
|
/// Concrete-column accessor for the monomorphic hot path; `Some` only for
|
|
/// the matching kind.
|
|
pub fn as_i64_mut(&mut self) -> Option<&mut Column<i64>> {
|
|
match self {
|
|
AnyColumn::I64(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_f64_mut(&mut self) -> Option<&mut Column<f64>> {
|
|
match self {
|
|
AnyColumn::F64(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_bool_mut(&mut self) -> Option<&mut Column<bool>> {
|
|
match self {
|
|
AnyColumn::Bool(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_ts_mut(&mut self) -> Option<&mut Column<Timestamp>> {
|
|
match self {
|
|
AnyColumn::Ts(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Read-side concrete-column accessor (the symmetric partner of
|
|
/// `as_*_mut`); `Some` only for the matching kind. `Ctx` uses these to hand
|
|
/// a typed window from a type-erased edge.
|
|
pub fn as_i64(&self) -> Option<&Column<i64>> {
|
|
match self {
|
|
AnyColumn::I64(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_f64(&self) -> Option<&Column<f64>> {
|
|
match self {
|
|
AnyColumn::F64(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_bool(&self) -> Option<&Column<bool>> {
|
|
match self {
|
|
AnyColumn::Bool(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn as_ts(&self) -> Option<&Column<Timestamp>> {
|
|
match self {
|
|
AnyColumn::Ts(c) => Some(c),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn same_kind_push_round_trips() {
|
|
let mut e = AnyColumn::with_capacity(ScalarKind::F64, 4);
|
|
e.push(Scalar::f64(1.5)).unwrap();
|
|
e.push(Scalar::f64(2.5)).unwrap();
|
|
assert_eq!(e.kind(), ScalarKind::F64);
|
|
assert_eq!(e.len(), 2);
|
|
assert_eq!(e.run_count(), 2);
|
|
assert_eq!(e.get(0), Some(Scalar::f64(2.5))); // newest
|
|
assert_eq!(e.get(1), Some(Scalar::f64(1.5)));
|
|
assert_eq!(e.get(2), None);
|
|
}
|
|
|
|
#[test]
|
|
fn push_cell_round_trips_by_column_kind() {
|
|
// push_cell reinterprets the bare cell by the column's own kind; reading
|
|
// back through `get` reconstructs the self-describing Scalar.
|
|
let mut f = AnyColumn::with_capacity(ScalarKind::F64, 2);
|
|
f.push_cell(Cell::from_f64(1.5));
|
|
assert_eq!(f.get(0), Some(Scalar::f64(1.5)));
|
|
assert_eq!(f.run_count(), 1);
|
|
|
|
let mut i = AnyColumn::with_capacity(ScalarKind::I64, 2);
|
|
i.push_cell(Cell::from_i64(42));
|
|
assert_eq!(i.get(0), Some(Scalar::i64(42)));
|
|
|
|
let mut b = AnyColumn::with_capacity(ScalarKind::Bool, 2);
|
|
b.push_cell(Cell::from_bool(true));
|
|
assert_eq!(b.get(0), Some(Scalar::bool(true)));
|
|
|
|
let mut t = AnyColumn::with_capacity(ScalarKind::Timestamp, 2);
|
|
t.push_cell(Cell::from_ts(Timestamp(7)));
|
|
assert_eq!(t.get(0), Some(Scalar::ts(Timestamp(7))));
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_kind_push_is_rejected() {
|
|
let mut edge = AnyColumn::with_capacity(ScalarKind::I64, 8);
|
|
let err = edge.push(Scalar::f64(1.5)).unwrap_err(); // f64 into an i64 edge
|
|
assert_eq!(err, KindMismatch { expected: ScalarKind::I64, got: ScalarKind::F64 });
|
|
assert_eq!(edge.len(), 0); // nothing was stored
|
|
assert_eq!(edge.run_count(), 0); // and the counter did not move
|
|
}
|
|
|
|
#[test]
|
|
fn concrete_accessor_matches_only_its_kind() {
|
|
let mut e = AnyColumn::with_capacity(ScalarKind::F64, 2);
|
|
assert!(e.as_f64_mut().is_some());
|
|
assert!(e.as_i64_mut().is_none());
|
|
assert!(e.as_bool_mut().is_none());
|
|
assert!(e.as_ts_mut().is_none());
|
|
// hot-path push through the concrete column, monomorphic:
|
|
e.as_f64_mut().unwrap().push(3.0);
|
|
assert_eq!(e.get(0), Some(Scalar::f64(3.0)));
|
|
assert_eq!(e.run_count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn read_accessor_matches_only_its_kind() {
|
|
let f = AnyColumn::with_capacity(ScalarKind::F64, 2);
|
|
assert!(f.as_f64().is_some());
|
|
assert!(f.as_i64().is_none());
|
|
assert!(f.as_bool().is_none());
|
|
assert!(f.as_ts().is_none());
|
|
|
|
let i = AnyColumn::with_capacity(ScalarKind::I64, 2);
|
|
assert!(i.as_i64().is_some());
|
|
assert!(i.as_f64().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn timestamp_edge_round_trips() {
|
|
let mut e = AnyColumn::with_capacity(ScalarKind::Timestamp, 2);
|
|
e.push(Scalar::ts(Timestamp(1_700_000_000_000_000_000))).unwrap();
|
|
assert_eq!(e.get(0), Some(Scalar::ts(Timestamp(1_700_000_000_000_000_000))));
|
|
}
|
|
}
|