Work in progress. This course has not yet received the author’s final audit. Chapters, exercises, and commands may still change before the course is marked complete.
Chapter 5: Make Numeric Evaluation Generic
Chapter 4 separated an ordinary typed scalar function from the whole-batch boundary around it.
BatchExpression<N> validates physical inputs before calling one vectorized kernel. Writing
another ad hoc runtime shell—or dynamically dispatching every scalar operation—for each numeric
type pair would throw away that boundary.
The remaining problem has two parts. Given logical types such as SmallInt and Double, the
database must first decide whether an implicit conversion is lossless and what logical type the
result has. Only then can it choose one concrete Rust scalar type for the row operation. This
chapter keeps those decisions separate: an explicit promotion table owns the database policy, and
a small runtime match chooses one generic typed batch kernel before its row loop begins.
What is in the starter
Begin from your completed Chapter 4 workspace. The fixed-arity batch shell in src/operators.rs
is working code; preserve its validation order and its whole-batch kernel boundary. The Day 5
surface is still deliberately small:
src/promotion.rscontains comment shells for one promotion row, the promotion catalog, and its lookup function;src/operators.rsends with comments for the arithmetic and comparison selectors;src/array/primitive_array.rshas the Arrow-style value and validity buffers but not the all-valid constructor used by this chapter’s batch fixture; andsrc/lib.rsleaves the promotion module and the two operator enums unwired.
You own three connected additions: the logical promotion policy, generic arithmetic selection,
and generic numeric comparison. You will also add the small PrimitiveArray::from_values helper
needed to construct a non-null batch directly. Leave shared arity validation and ternary
evaluation for Chapter 6, runtime expression erasure for Chapter 8, and logical name binding for
Chapter 9.
Copy the cumulative supplied test before editing:
cargo x copy-test --chapter 5
cargo test -p type-exercise-starter chapter_5 --locked
The focused run should fail on the missing promotion items, operator selectors, and
PrimitiveArray::from_values. Do not edit the copied test.
Checkpoint 1: make widening a database policy
Open src/promotion.rs and define the public shape already named by the starter:
pub struct NumericPromotion {
pub left: DataType,
pub right: DataType,
pub output: DataType,
}
pub const NUMERIC_PROMOTIONS: &[NumericPromotion] = /* every supported ordered pair */;
pub fn promote_numeric(
left: impl Borrow<DataType>,
right: impl Borrow<DataType>,
) -> Option<DataType>;
This is an ordered-pair catalog, not a request to let Rust choose an as cast. Enter both operand
orders whenever both are supported. The complete policy for the five non-Decimal numeric types is:
| left ↓ / right → | SmallInt | Integer | BigInt | Real | Double |
|---|---|---|---|---|---|
SmallInt | SmallInt | Integer | BigInt | Real | Double |
Integer | Integer | Integer | BigInt | Double | Double |
BigInt | BigInt | BigInt | BigInt | reject | reject |
Real | Real | Double | reject | Real | Double |
Double | Double | Double | reject | Double | Double |
The unusual-looking rows state the rule. Every i16 value is exact in f32, so SmallInt with
Real may stay Real. Every i32 value is exact in f64 but not in f32, so Integer with
Real widens to Double. Neither f32 nor f64 represents every i64 value, so every
BigInt/floating-point pair is rejected even though Rust can spell the cast.
Decimal is also a numeric logical type, but it gets no row in this table. Precision, scale,
rounding, overflow, and division scale need a separate contract before an implicit Decimal
operation is meaningful. A physical representation alone does not supply those semantics.
Implement promote_numeric as a catalog lookup that returns the row’s logical output or None.
Do not infer a fallback from enum order or substitute a duplicate row: the supplied test audits all
25 ordered input pairs and the exact 21 supported catalog keys.
Enable promotion in src/lib.rs and export NumericPromotion, NUMERIC_PROMOTIONS, and
promote_numeric. The final focused test also imports the later operator selectors, so it cannot
be green at this checkpoint. Use the library boundary instead:
cargo check -p type-exercise-starter --lib --locked
Passing means the logical policy and its public lookup compile independently from physical evaluation.
Checkpoint 2: choose one arithmetic kernel before the rows
Start with the fixture helper in src/array/primitive_array.rs. It keeps the existing
representation and marks every supplied value valid:
impl<T> PrimitiveArray<T> {
pub fn from_values(values: Vec<T>) -> Self {
let validity = BitVec::repeat(true, values.len());
Self { values, validity }
}
}
This constructor is not a second array format and does not change null handling. It is simply the direct counterpart to building a batch whose rows are all non-null.
Now extend src/operators.rs with the public ArithmeticOperator variants Add, Subtract,
Multiply, and Divide. Describe the five concrete numeric types with standard operator bounds,
not another trait whose methods re-name arithmetic:
trait Numeric:
Scalar
+ Copy
+ PartialOrd
+ Add<Output = Self>
+ Sub<Output = Self>
+ Mul<Output = Self>
+ Div<Output = Self>
{ /* representation used by standard Add/Sub/Mul */ }
Implement the small representation bridge explicitly for i16, i32, i64, f32, and f64.
Express addition, subtraction, and multiplication through the standard Add, Sub, and Mul
traits. For signed
integers, apply those traits to std::num::Wrapping<T> and recover .0; this keeps the course’s
deterministic wrapping result in debug and release builds. Standard Add on a bare signed integer
does not itself choose one cross-profile overflow policy, so changing overflow into an error would
be a separate product-semantic decision rather than part of this generic refactor. Floating-point
implementations use the standard traits directly and retain ordinary IEEE results.
Division stays the one small custom fallible operation because stable std has no single checked
division trait covering both the course’s integers and floats. Integer division returns an
ordinary error with the cause division by zero for zero or signed integer division overflow
for MIN / -1. Treat both 0.0 and -0.0 floating-point divisors as division by zero; other
results such as infinity or NaN remain values.
The important Rust boundary is where all three generic types become concrete. The physical
builder matches the validated (operator, left, right, output) choice once and stores only the
selected monomorphized whole-batch function pointer in NumericBinaryExpression; it does not keep
an operator field for the row loop to inspect. Addition, subtraction, and multiplication select
their infallible typed kernels, while division alone selects the fallible typed kernel. Require
O: TryFrom<L, Error = Infallible> + TryFrom<R, Error = Infallible> for the lossless conversions
admitted by the promotion table. This uses Rust’s standard conversion vocabulary; do not add a
parallel conversion trait.
That function pointer owns the complete vectorized evaluation. It converts each erased column to
its typed view once, then the row loop receives L and R values directly, converts them to O
with TryFrom, and applies the selected standard operation. Do not accept ScalarRefImpl, create
a per-scalar erased operation object, re-run logical promotion, or match physical variants inside
every row. The caller must obtain the logical output from promote_numeric first; an unsupported
pair never reaches the physical builder.
Keep build_numeric_binary_expression and its returned shell crate-private. Export
ArithmeticOperator from the crate root, but do not turn the physical constructor into a public
user API: Chapter 9 will place logical name binding in front of it.
The copied test still imports numeric comparison, so use the library compile boundary again:
cargo check -p type-exercise-starter --lib --locked
Passing means all four arithmetic choices use a preselected monomorphized batch evaluator without widening the public runtime boundary or testing the operator inside each row.
Checkpoint 3: return Boolean through the same common type
Add the six public ComparisonOperator variants: Less, LessOrEqual, Greater,
GreaterOrEqual, Equal, and NotEqual. A crate-private NumericCompare<L, R, O> reuses the same
typed TryFrom conversions and tuple-selected batch kernel, but it builds bool. Keep
build_numeric_comparison_expression crate-private.
Both inputs may be converted to f64 for comparison while the batch kernel builds a BoolArray.
The runtime selector still chooses once before the rows.
Rust’s floating-point comparisons supply the required NaN behavior: <, <=, >, >=, and
= are false when either relevant comparison is unordered, while != is true. Do not turn NaN
into a batch error. Strict null handling remains different: if either input row is null, the
batch kernel appends null and performs no comparison.
Export ComparisonOperator beside ArithmeticOperator, then run the completed contract:
cargo test -p type-exercise-starter chapter_5 --locked
cargo test -p type-exercise-starter --lib --locked
The 10 focused cases and 43 cumulative learner tests prove the whole Day 5 boundary:
- the catalog contains exactly the approved ordered promotions and rejects every lossy pair;
- arithmetic works in both mixed operand orders and builds the promoted physical family;
- signed overflow wraps, while division by zero and signed division overflow stop the batch;
- a strict null prevents even a failing divide from being called;
- nonzero IEEE results and all six comparison operators retain their defined behavior; and
- comparison reuses the Chapter 4 arity, physical-type, length, null, and complete-output rules.
Read the two decisions separately
The promotion table and the generic kernel solve different problems. The table answers a logical
question before evaluation: “Is this implicit conversion allowed, and what is the result type?”
The physical match answers a Rust question once: “Which concrete Scalar implements this
operation?” The selected kernel then answers the batch question for every row. Collapsing those
three stages into as f64, a per-row type match, or another handwritten loop would make the code
shorter by hiding the policy you need to audit.
Before continuing, make sure you can explain these boundaries in your own words:
- Why may
SmallInt + RealproduceRealwhileInteger + RealproducesDouble? - Why is every
BigInt/floating-point pair absent even though Rust provides anasconversion? - Why does the physical builder select
(L, R, O)once instead of matching scalar variants in each row? - Why is
null / 0a null row rather than a division error?
You now have generic numeric operation selection without changing the batch contract that made the concrete loops correct. Chapter 6 will publish their validator across arities and add a real vectorized ternary path.