diff --git a/dataframe-core.cabal b/dataframe-core.cabal
--- a/dataframe-core.cabal
+++ b/dataframe-core.cabal
@@ -1,6 +1,6 @@
 cabal-version:      2.4
 name:               dataframe-core
-version:            1.0.1.1
+version:            1.0.2.0
 
 synopsis:           Core data structures for the dataframe library.
 description:
@@ -17,7 +17,7 @@
 license-file:       LICENSE
 author:             Michael Chavinda
 maintainer:         mschavinda@gmail.com
-copyright:          (c) 2024-2025 Michael Chavinda
+copyright:          (c) 2024-2026 Michael Chavinda
 category:           Data
 tested-with:        GHC ==9.4.8 || ==9.6.7 || ==9.8.4 || ==9.10.3 || ==9.12.2
 
@@ -44,6 +44,7 @@
                         DataFrame.Internal.Interpreter
                         DataFrame.Internal.Nullable
                         DataFrame.Internal.Row
+                        DataFrame.Internal.Simplify
                         DataFrame.Internal.Types
                         DataFrame.Typed.Freeze
                         DataFrame.Typed.Generic
diff --git a/src/DataFrame/Internal/Expression.hs b/src/DataFrame/Internal/Expression.hs
--- a/src/DataFrame/Internal/Expression.hs
+++ b/src/DataFrame/Internal/Expression.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE DisambiguateRecordFields #-}
 {-# LANGUAGE ExplicitNamespaces #-}
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE FlexibleInstances #-}
@@ -10,6 +11,7 @@
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE UndecidableInstances #-}
+{-# LANGUAGE NoFieldSelectors #-}
 
 module DataFrame.Internal.Expression where
 
@@ -20,13 +22,35 @@
 import DataFrame.Internal.Column
 import Type.Reflection (Typeable, typeOf, typeRep)
 
-data UnaryOp a b = MkUnaryOp
+{- | Operators are an open typeclass: built-ins get their own 'Typeable' type so
+the simplifier can match them by 'cast', and users can add @instance@s. The
+generic 'UnUDF'/'BinUDF' carriers cover UDFs, dynamic-named, and arithmetic ops.
+Method names match the carrier record fields ('NoFieldSelectors' frees them), so
+existing construction and read sites are unchanged.
+-}
+class (Typeable op) => UnaryOp op where
+    unaryFn :: op a b -> a -> b
+    unaryName :: op a b -> T.Text
+    unarySymbol :: op a b -> Maybe T.Text
+    unarySymbol _ = Nothing
+
+class (Typeable op) => BinaryOp op where
+    binaryFn :: op a b c -> a -> b -> c
+    binaryName :: op a b c -> T.Text
+    binarySymbol :: op a b c -> Maybe T.Text
+    binarySymbol _ = Nothing
+    binaryCommutative :: op a b c -> Bool
+    binaryCommutative _ = False
+    binaryPrecedence :: op a b c -> Int
+    binaryPrecedence _ = 9
+
+data UnUDF a b = MkUnaryOp
     { unaryFn :: a -> b
     , unaryName :: T.Text
     , unarySymbol :: Maybe T.Text
     }
 
-data BinaryOp a b c = MkBinaryOp
+data BinUDF a b c = MkBinaryOp
     { binaryFn :: a -> b -> c
     , binaryName :: T.Text
     , binarySymbol :: Maybe T.Text
@@ -34,6 +58,18 @@
     , binaryPrecedence :: Int
     }
 
+instance UnaryOp UnUDF where
+    unaryFn (MkUnaryOp{unaryFn = f}) = f
+    unaryName (MkUnaryOp{unaryName = n}) = n
+    unarySymbol (MkUnaryOp{unarySymbol = s}) = s
+
+instance BinaryOp BinUDF where
+    binaryFn (MkBinaryOp{binaryFn = f}) = f
+    binaryName (MkBinaryOp{binaryName = n}) = n
+    binarySymbol (MkBinaryOp{binarySymbol = s}) = s
+    binaryCommutative (MkBinaryOp{binaryCommutative = c}) = c
+    binaryPrecedence (MkBinaryOp{binaryPrecedence = p}) = p
+
 data MeanAcc = MeanAcc {-# UNPACK #-} !Double {-# UNPACK #-} !Int
     deriving (Show, Eq, Ord, Read)
 
@@ -66,10 +102,10 @@
         Expr b
     Lit :: (Columnable a) => a -> Expr a
     Unary ::
-        (Columnable a, Columnable b) => UnaryOp b a -> Expr b -> Expr a
+        (UnaryOp op, Columnable a, Columnable b) => op b a -> Expr b -> Expr a
     Binary ::
-        (Columnable c, Columnable b, Columnable a) =>
-        BinaryOp c b a -> Expr c -> Expr b -> Expr a
+        (BinaryOp op, Columnable c, Columnable b, Columnable a) =>
+        op c b a -> Expr c -> Expr b -> Expr a
     If :: (Columnable a) => Expr Bool -> Expr a -> Expr a -> Expr a
     Agg :: (Columnable a, Columnable b) => AggStrategy a b -> Expr b -> Expr a
     Over :: (Columnable a) => [T.Text] -> Expr a -> Expr a
diff --git a/src/DataFrame/Internal/Grouping.hs b/src/DataFrame/Internal/Grouping.hs
--- a/src/DataFrame/Internal/Grouping.hs
+++ b/src/DataFrame/Internal/Grouping.hs
@@ -23,10 +23,11 @@
 
 import Control.Exception (throw)
 import Control.Monad
-import Control.Monad.ST (runST)
+import Control.Monad.ST (ST, runST)
 import Data.Type.Equality (TestEquality (..), type (:~:) (Refl))
 import DataFrame.Errors
 import DataFrame.Internal.Column (
+    Bitmap,
     Column (..),
     bitmapTestBit,
  )
@@ -78,49 +79,22 @@
         mh <- VUM.replicate n fnvOffset
         let selectedCols = map (columns df V.!) indicesToGroup
         forM_ selectedCols $ \case
-            UnboxedColumn _ (v :: VU.Vector a) ->
+            UnboxedColumn ubm (v :: VU.Vector a) ->
                 case testEquality (typeRep @a) (typeRep @Int) of
-                    Just Refl ->
-                        VU.imapM_
-                            ( \i x -> do
-                                !h <- VUM.unsafeRead mh i
-                                VUM.unsafeWrite mh i (mixInt h x)
-                            )
-                            v
+                    Just Refl -> hashUnboxed mh ubm mixInt v
                     Nothing ->
                         case testEquality (typeRep @a) (typeRep @Double) of
-                            Just Refl ->
-                                VU.imapM_
-                                    ( \i d -> do
-                                        !h <- VUM.unsafeRead mh i
-                                        VUM.unsafeWrite mh i (mixDouble h d)
-                                    )
-                                    v
+                            Just Refl -> hashUnboxed mh ubm mixDouble v
                             Nothing ->
                                 case sIntegral @a of
                                     STrue ->
-                                        VU.imapM_
-                                            ( \i d -> do
-                                                !h <- VUM.unsafeRead mh i
-                                                VUM.unsafeWrite mh i (mixInt h (fromIntegral @a @Int d))
-                                            )
-                                            v
+                                        hashUnboxed mh ubm (\h d -> mixInt h (fromIntegral @a @Int d)) v
                                     SFalse ->
                                         case sFloating @a of
                                             STrue ->
-                                                VU.imapM_
-                                                    ( \i d -> do
-                                                        !h <- VUM.unsafeRead mh i
-                                                        VUM.unsafeWrite mh i (mixDouble h (realToFrac d :: Double))
-                                                    )
-                                                    v
+                                                hashUnboxed mh ubm (\h d -> mixDouble h (realToFrac d :: Double)) v
                                             SFalse ->
-                                                VU.imapM_
-                                                    ( \i d -> do
-                                                        !h <- VUM.unsafeRead mh i
-                                                        VUM.unsafeWrite mh i (mixShow h d)
-                                                    )
-                                                    v
+                                                hashUnboxed mh ubm mixShow v
             BoxedColumn bm (v :: V.Vector a) ->
                 case testEquality (typeRep @a) (typeRep @T.Text) of
                     Just Refl ->
@@ -128,7 +102,7 @@
                             ( \i t -> do
                                 !h <- VUM.unsafeRead mh i
                                 let h' = case bm of
-                                        Just bm' | not (bitmapTestBit bm' i) -> mixInt h 0 -- null sentinel
+                                        Just bm' | not (bitmapTestBit bm' i) -> mixInt h nullSalt
                                         _ -> mixText h t
                                 VUM.unsafeWrite mh i h'
                             )
@@ -138,7 +112,7 @@
                             ( \i d -> do
                                 !h <- VUM.unsafeRead mh i
                                 let h' = case bm of
-                                        Just bm' | not (bitmapTestBit bm' i) -> mixInt h 0 -- null sentinel
+                                        Just bm' | not (bitmapTestBit bm' i) -> mixInt h nullSalt
                                         _ -> mixShow h d
                                 VUM.unsafeWrite mh i h'
                             )
@@ -159,6 +133,36 @@
                 , i <- reverse is
                 ]
         return (VU.fromList ordered)
+
+{- | Fold a value-mix over an unboxed column into the running hash vector,
+respecting the null bitmap: a null slot mixes a fixed 'nullSalt' sentinel.
+-}
+hashUnboxed ::
+    (VU.Unbox a) =>
+    VUM.MVector s Int ->
+    Maybe Bitmap ->
+    (Int -> a -> Int) ->
+    VU.Vector a ->
+    ST s ()
+hashUnboxed mh ubm mix v = case ubm of
+    Nothing ->
+        VU.imapM_
+            ( \i x -> do
+                !h <- VUM.unsafeRead mh i
+                VUM.unsafeWrite mh i (mix h x)
+            )
+            v
+    Just bm ->
+        VU.imapM_
+            ( \i x -> do
+                !h <- VUM.unsafeRead mh i
+                VUM.unsafeWrite
+                    mh
+                    i
+                    (if bitmapTestBit bm i then mix h x else mixInt h nullSalt)
+            )
+            v
+{-# INLINE hashUnboxed #-}
 
 -- Inline accessors to avoid depending on Operations.Core
 
diff --git a/src/DataFrame/Internal/Hash.hs b/src/DataFrame/Internal/Hash.hs
--- a/src/DataFrame/Internal/Hash.hs
+++ b/src/DataFrame/Internal/Hash.hs
@@ -1,14 +1,19 @@
+{-# LANGUAGE BangPatterns #-}
+{-# LANGUAGE CPP #-}
+{-# LANGUAGE MagicHash #-}
+
 {- |
 A poor-man's hash used by 'DataFrame.Internal.Grouping' to bucket rows
 without depending on the @hashable@ package.
 
-The hash is FNV-1a-shaped: an accumulator is repeatedly @xor@ed with the
-next chunk and multiplied by an FNV prime. It is intentionally small and
-not cryptographically strong — it only needs to spread group-key tuples
-well enough that 'Data.IntMap' bucketing produces sensible groups.
+Each value is folded into an 'Int' accumulator with an FxHash-style step
+(rotate, xor, multiply). It is intentionally small and not cryptographically
+strong — it only needs to spread group-key tuples well enough that
+'Data.IntMap' bucketing produces sensible groups.
 -}
 module DataFrame.Internal.Hash (
     fnvOffset,
+    nullSalt,
     mixInt,
     mixDouble,
     mixBool,
@@ -17,10 +22,17 @@
     mixShow,
 ) where
 
-import Data.Bits (xor)
+import Data.Bits (rotateL, unsafeShiftL, unsafeShiftR, xor)
 import Data.Char (ord)
 import qualified Data.Text as T
-import Data.Word (Word64)
+#if MIN_VERSION_text(2,1,0)
+import Data.Array.Byte (ByteArray (ByteArray))
+#else
+import Data.Text.Array (Array (ByteArray))
+#endif
+import Data.Text.Internal (Text (Text))
+import GHC.Exts (Int (I#), indexWord8Array#, indexWord8ArrayAsWord64#)
+import GHC.Word (Word64 (W64#), Word8 (W8#))
 
 {- | FNV-1a 64-bit offset basis (used as the initial accumulator).
 The literal is unsigned and exceeds 'Int' range, so we round-trip through
@@ -33,9 +45,26 @@
 fnvPrime :: Int
 fnvPrime = 0x00000100000001b3
 
--- | Mix an 'Int' into the accumulator.
+{- | Sentinel mixed in for a /null/ slot of a nullable column, so that a
+@Nothing@ does not hash to the same value as a present @Just x@ that happens to
+store the same underlying bits (notably @Just 0@). A fixed distinctive constant
+(the 64-bit golden-ratio mix constant) keeps null hashing deterministic; a real
+value equal to it collides only as rarely as any other hash collision.
+-}
+nullSalt :: Int
+nullSalt = fromIntegral (0x9E3779B97F4A7C15 :: Word64)
+
+{- | Mix an 'Int' into the accumulator.
+
+An FxHash-style step (rotate the accumulator, xor the value, multiply by a large
+odd constant). The rotate diffuses each value's bits across all positions before
+the next is folded in, so small/adjacent integers — common as group keys — do
+not produce the structured collisions that a plain @(acc `xor` x) * prime@ does
+once several columns are combined. Grouping trusts hash equality, so this
+robustness is what keeps distinct rows in distinct groups.
+-}
 mixInt :: Int -> Int -> Int
-mixInt acc x = (acc `xor` x) * fnvPrime
+mixInt acc x = (rotateL acc 13 `xor` x) * fnvPrime
 {-# INLINE mixInt #-}
 
 {- | Mix a 'Double' into the accumulator. Loses sub-millisecond precision
@@ -53,9 +82,31 @@
 mixChar acc = mixInt acc . ord
 {-# INLINE mixChar #-}
 
--- | Mix a 'T.Text' value into the accumulator, byte by byte.
+{- | Mix a 'T.Text' value into the accumulator over its raw UTF-8 bytes,
+eight at a time. Reading a whole 'Word64' per step (rather than decoding and
+mixing one codepoint at a time) cuts the multiply count ~8x on long keys while
+staying collision-equivalent: UTF-8 is injective, so equal 'T.Text's mix to the
+same value and distinct ones almost never collide. The trailing @len `mod` 8@
+bytes are folded in individually.
+-}
 mixText :: Int -> T.Text -> Int
-mixText = T.foldl' (\a c -> mixInt a (ord c))
+mixText !acc (Text (ByteArray ba) off len) = goBytes (goWords acc off) wordsEnd
+  where
+    !nWords = len `unsafeShiftR` 3
+    !wordsEnd = off + (nWords `unsafeShiftL` 3)
+    !end = off + len
+    goWords !h !i
+        | i >= wordsEnd = h
+        | otherwise =
+            let !(I# i#) = i
+                !w = fromIntegral (W64# (indexWord8ArrayAsWord64# ba i#)) :: Int
+             in goWords (mixInt h w) (i + 8)
+    goBytes !h !i
+        | i >= end = h
+        | otherwise =
+            let !(I# i#) = i
+                !b = fromIntegral (W8# (indexWord8Array# ba i#)) :: Int
+             in goBytes (mixInt h b) (i + 1)
 {-# INLINE mixText #-}
 
 {- | Fallback for arbitrary 'Show'-able values. Slower but covers types
diff --git a/src/DataFrame/Internal/Interpreter.hs b/src/DataFrame/Internal/Interpreter.hs
--- a/src/DataFrame/Internal/Interpreter.hs
+++ b/src/DataFrame/Internal/Interpreter.hs
@@ -262,7 +262,38 @@
     Column ->
     Either DataFrameException Column
     #-}
+-- Bool-returning binary comparators (hot path for Expr Bool used in
+-- DecisionTree splits)
+{-# SPECIALIZE zipWithColumns ::
+    (Double -> Double -> Bool) ->
+    Column ->
+    Column ->
+    Either DataFrameException Column
+    #-}
+{-# SPECIALIZE zipWithColumns ::
+    (Float -> Float -> Bool) ->
+    Column ->
+    Column ->
+    Either DataFrameException Column
+    #-}
+{-# SPECIALIZE zipWithColumns ::
+    (Int -> Int -> Bool) ->
+    Column ->
+    Column ->
+    Either DataFrameException Column
+    #-}
+{-# SPECIALIZE zipWithColumns ::
+    (Bool -> Bool -> Bool) ->
+    Column ->
+    Column ->
+    Either DataFrameException Column
+    #-}
 
+-- Bool-mapping unary ops (e.g. 'not')
+{-# SPECIALIZE mapColumn ::
+    (Bool -> Bool) -> Column -> Either DataFrameException Column
+    #-}
+
 -------------------------------------------------------------------------------
 -- Value: the unified result type
 -------------------------------------------------------------------------------
@@ -273,15 +304,15 @@
 -}
 data Value a where
     -- | A single value, not yet broadcast to any length.
-    Scalar :: (Columnable a) => a -> Value a
+    Scalar :: (Columnable a) => !a -> Value a
     {- | A flat column (one element per row in the flat case, or one
     element per group after aggregation).
     -}
-    Flat :: (Columnable a) => Column -> Value a
+    Flat :: (Columnable a) => !Column -> Value a
     {- | A grouped column: one 'Column' slice per group.  Only produced
     when interpreting inside a 'GroupCtx'.
     -}
-    Group :: (Columnable a) => V.Vector Column -> Value a
+    Group :: (Columnable a) => !(V.Vector Column) -> Value a
 
 instance (Show a) => Show (Value a) where
     show (Scalar v) = show v
@@ -325,6 +356,7 @@
 liftValue f (Scalar v) = Right (Scalar (f v))
 liftValue f (Flat col) = Flat <$> mapColumn f col
 liftValue f (Group gs) = Group <$> V.mapM (mapColumn f) gs
+{-# INLINEABLE liftValue #-}
 
 {- | Apply a binary function to two 'Value's.  When one side is a
 'Scalar' the operation degenerates to a 'liftValue' — this is how the
@@ -351,6 +383,7 @@
     Left $ AggregatedAndNonAggregatedException "non-aggregated" "aggregated"
 liftValue2 _ (Group _) (Group _) =
     Left $ InternalException "Group count mismatch in binary operation"
+{-# INLINEABLE liftValue2 #-}
 
 -- | Branch on a boolean 'Value', selecting from two same-typed 'Value's.
 branchValue ::
@@ -384,6 +417,7 @@
         AggregatedAndNonAggregatedException
             "if-then-else branches"
             "mismatched shapes"
+{-# INLINEABLE branchValue #-}
 
 {- | Low-level column branch: given a boolean column and two same-typed
 columns, produce the element-wise selection.
@@ -807,13 +841,13 @@
             Group <$> V.mapM (promoteColumnWith onResult) gs
 -- Unary ------------------------------------------------------------------
 
-eval ctx expr@(Unary (op :: UnaryOp b a) inner) = addContext expr $ do
+eval ctx expr@(Unary op (inner :: Expr b)) = addContext expr $ do
     v <- eval @b ctx inner
     liftValue (unaryFn op) v
 
 -- Binary -----------------------------------------------------------------
 
-eval ctx expr@(Binary (op :: BinaryOp c b a) left right) =
+eval ctx expr@(Binary op (left :: Expr c) (right :: Expr b)) =
     addContext expr $ do
         l <- eval @c ctx left
         r <- eval @b ctx right
diff --git a/src/DataFrame/Internal/Row.hs b/src/DataFrame/Internal/Row.hs
--- a/src/DataFrame/Internal/Row.hs
+++ b/src/DataFrame/Internal/Row.hs
@@ -17,7 +17,7 @@
 
 import Control.Exception (throw)
 import Data.Function (on)
-import Data.Maybe (fromMaybe)
+import Data.Maybe (catMaybes, fromMaybe, isNothing, mapMaybe)
 import Data.Type.Equality (TestEquality (..))
 import Data.Typeable (type (:~:) (..))
 import DataFrame.Errors (DataFrameException (..))
@@ -28,16 +28,23 @@
 
 data Any where
     Value :: (Columnable a) => a -> Any
+    -- Saves us the extra indirection we get from making Value (Maybe a)
+    -- and having to unpack it again to check for nulls.
+    -- Instead, we just have Null as a separate constructor.
+    Null :: Any
 
 instance Eq Any where
     (==) :: Any -> Any -> Bool
     (Value a) == (Value b) = fromMaybe False $ do
         Refl <- testEquality (typeOf a) (typeOf b)
         return $ a == b
+    Null == Null = True
+    _ == _ = False
 
 instance Show Any where
     show :: Any -> String
     show (Value a) = T.unpack (showValue a)
+    show Null = "null"
 
 showValue :: forall a. (Columnable a) => a -> T.Text
 showValue v = case testEquality (typeRep @a) (typeRep @T.Text) of
@@ -50,12 +57,21 @@
 toAny :: forall a. (Columnable a) => a -> Any
 toAny = Value
 
--- | Unwraps a value from an \Any\ type.
+-- | Unwraps a value from an \Any\ type. A 'Null' cell yields 'Nothing'.
 fromAny :: forall a. (Columnable a) => Any -> Maybe a
+fromAny Null = Nothing
 fromAny (Value (v :: b)) = do
     Refl <- testEquality (typeRep @a) (typeRep @b)
     pure v
 
+{- | Wrap a column cell into an 'Any', honouring the column's null bitmap: a slot
+marked invalid becomes 'Null', any other slot becomes a 'Value'. Only needs
+@Columnable a@ (the stored element type), not @Columnable (Maybe a)@.
+-}
+cellAny :: (Columnable a) => Maybe Bitmap -> Int -> a -> Any
+cellAny Nothing _ x = Value x
+cellAny (Just bm) i x = if bitmapTestBit bm i then Value x else Null
+
 type Row = V.Vector Any
 
 (!?) :: [a] -> Int -> Maybe a
@@ -63,18 +79,31 @@
 (!?) (x : _) 0 = Just x
 (!?) (_x : xs) n = (!?) xs (n - 1)
 
+{- | Reconstruct column @i@ from a list of rows. The element type is taken from
+the first non-'Null' cell; cells of a different type are skipped. If any cell is
+'Null' the result is a nullable column (built with 'fromMaybeVec', which needs
+only @Columnable a@), so a round-trip through 'toRowList'/'fromRows' preserves
+nulls.
+-}
 mkColumnFromRow :: Int -> [[Any]] -> Column
-mkColumnFromRow i rows = case rows of
-    [] -> fromList ([] :: [T.Text])
-    (row : _) -> case row !? i of
-        Nothing -> fromList ([] :: [T.Text])
-        Just (Value (v :: a)) -> fromList $ reverse $ L.foldl' addToList [v] (drop 1 rows)
-          where
-            addToList acc r = case r !? i of
-                Nothing -> acc
-                Just (Value (v' :: b)) -> case testEquality (typeRep @a) (typeRep @b) of
-                    Nothing -> acc
-                    Just Refl -> v' : acc
+mkColumnFromRow i rows =
+    let cells = mapMaybe (!? i) rows
+     in case L.find isValue cells of
+            Nothing -> fromList ([] :: [T.Text])
+            Just (Value (_ :: a)) ->
+                let collect Null = Just (Nothing :: Maybe a)
+                    collect (Value (v' :: b)) =
+                        case testEquality (typeRep @a) (typeRep @b) of
+                            Just Refl -> Just (Just v')
+                            Nothing -> Nothing
+                    maybes = mapMaybe collect cells
+                 in if any isNothing maybes
+                        then fromMaybeVec (V.fromList maybes)
+                        else fromList (catMaybes maybes)
+            Just Null -> fromList ([] :: [T.Text]) -- unreachable: find isValue
+  where
+    isValue (Value _) = True
+    isValue Null = False
 
 {- | Converts the entire dataframe to a list of rows.
 
@@ -146,8 +175,8 @@
                     [name]
                     "[INTERNAL] mkRowFromArgs"
                     (M.keys $ columnIndices df)
-        Just (BoxedColumn _ column) -> toAny (column V.! i)
-        Just (UnboxedColumn _ column) -> toAny (column VU.! i)
+        Just (BoxedColumn bm column) -> cellAny bm i (column V.! i)
+        Just (UnboxedColumn bm column) -> cellAny bm i (column VU.! i)
 
 -- This function will return the items in the order that is specified
 -- by the user. For example, if the dataframe consists of the columns
@@ -165,11 +194,11 @@
                 ++ "the other columns at index "
                 ++ show i
     get name = case getColumn name df of
-        Just (BoxedColumn _ c) -> case c V.!? i of
-            Just e -> toAny e
+        Just (BoxedColumn bm c) -> case c V.!? i of
+            Just e -> cellAny bm i e
             Nothing -> throwError name
-        Just (UnboxedColumn _ c) -> case c VU.!? i of
-            Just e -> toAny e
+        Just (UnboxedColumn bm c) -> case c VU.!? i of
+            Just e -> cellAny bm i e
             Nothing -> throwError name
         Nothing ->
             throw $ ColumnsNotFoundException [name] "mkRowRep" (M.keys $ columnIndices df)
diff --git a/src/DataFrame/Internal/Simplify.hs b/src/DataFrame/Internal/Simplify.hs
new file mode 100644
--- /dev/null
+++ b/src/DataFrame/Internal/Simplify.hs
@@ -0,0 +1,422 @@
+{-# LANGUAGE AllowAmbiguousTypes #-}
+{-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
+{-# LANGUAGE MultiWayIf #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE PatternSynonyms #-}
+{-# LANGUAGE ScopedTypeVariables #-}
+{-# LANGUAGE TypeApplications #-}
+
+module DataFrame.Internal.Simplify (
+    simplify,
+    simplifyPredicatePair,
+
+    -- * Path-condition entailment (for fitted-tree pruning)
+    PredFact,
+    factTrue,
+    factFalse,
+    entails,
+) where
+
+import Control.Monad (guard)
+import Data.Maybe (fromMaybe)
+import Data.Type.Equality (testEquality, (:~:) (Refl))
+import Type.Reflection (eqTypeRep, typeRep, (:~~:) (HRefl), pattern App)
+
+import DataFrame.Internal.Column (Columnable)
+import DataFrame.Internal.Expression (
+    BinaryOp,
+    Expr (..),
+    UnaryOp (unaryName),
+    eqExpr,
+    normalize,
+ )
+import DataFrame.Operators (
+    NullAnd,
+    NullEq,
+    NullGeq,
+    NullGt,
+    NullLeq,
+    NullLt,
+    NullNeq,
+    NullOr,
+    (.==.),
+ )
+
+simplify :: forall a. (Columnable a) => Expr a -> Expr a
+simplify e
+    | isBoolish @a = fixpoint (10 :: Int) e
+    | otherwise = e
+  where
+    fixpoint 0 x = x
+    fixpoint n x = let x' = simplifyB x in if eqExpr x x' then x else fixpoint (n - 1) x'
+
+isBoolish :: forall a. (Columnable a) => Bool
+isBoolish =
+    case ( testEquality (typeRep @a) (typeRep @Bool)
+         , testEquality (typeRep @a) (typeRep @(Maybe Bool))
+         ) of
+        (Just Refl, _) -> True
+        (_, Just Refl) -> True
+        _ -> False
+
+data Conn = ConnAnd | ConnOr
+
+connOf :: forall op c b r. (BinaryOp op) => op c b r -> Maybe Conn
+connOf _
+    | Just HRefl <- eqTypeRep (typeRep @op) (typeRep @NullAnd) = Just ConnAnd
+    | Just HRefl <- eqTypeRep (typeRep @op) (typeRep @NullOr) = Just ConnOr
+    | otherwise = Nothing
+
+simplifyB :: forall a. (Columnable a) => Expr a -> Expr a
+simplifyB expr = case expr of
+    Binary (op :: op c b a) l r
+        | Just conn <- connOf op
+        , Just Refl <- testEquality (typeRep @c) (typeRep @a)
+        , Just Refl <- testEquality (typeRep @b) (typeRep @a) ->
+            let l' = simplifyB l; r' = simplifyB r
+             in fromMaybe (Binary op l' r') (combine conn l' r')
+        | otherwise -> expr
+    Unary (op :: op b a) inner
+        | Just Refl <- testEquality (typeRep @a) (typeRep @Bool)
+        , Just Refl <- testEquality (typeRep @b) (typeRep @Bool)
+        , unaryName op == "not" ->
+            simplifyNot op (simplifyB inner)
+        | otherwise -> expr
+    If c t f ->
+        let c' = simplify c
+            t' = simplifyB t
+            f' = simplifyB f
+         in case asBoolLit c' of
+                Just True -> t'
+                Just False -> f'
+                Nothing
+                    | eqExpr t' f' -> t'
+                    | Just Refl <- testEquality (typeRep @a) (typeRep @Bool)
+                    , asBoolLit t' == Just True
+                    , asBoolLit f' == Just False ->
+                        c'
+                    | otherwise -> If c' t' f'
+    _ -> expr
+
+simplifyNot :: (UnaryOp op) => op Bool Bool -> Expr Bool -> Expr Bool
+simplifyNot op inner = case asBoolLit inner of
+    Just b -> Lit (not b)
+    Nothing -> case inner of
+        Unary (op2 :: op2 b2 Bool) inner2
+            | unaryName op2 == "not"
+            , Just Refl <- testEquality (typeRep @b2) (typeRep @Bool) ->
+                inner2
+        _ -> Unary op inner
+
+combine :: (Columnable a) => Conn -> Expr a -> Expr a -> Maybe (Expr a)
+combine ConnAnd = combineAnd
+combine ConnOr = combineOr
+
+asBoolLit :: forall a. (Columnable a) => Expr a -> Maybe Bool
+asBoolLit (Lit v) =
+    case testEquality (typeRep @a) (typeRep @Bool) of
+        Just Refl -> Just v
+        Nothing -> case testEquality (typeRep @a) (typeRep @(Maybe Bool)) of
+            Just Refl -> v
+            Nothing -> Nothing
+asBoolLit _ = Nothing
+
+{- | Polymorphic boolean literal: @Lit b@ for @Expr Bool@, @Lit (Just b)@ for
+@Expr (Maybe Bool)@.
+-}
+litBoolish :: forall a. (Columnable a) => Bool -> Maybe (Expr a)
+litBoolish v =
+    case testEquality (typeRep @a) (typeRep @Bool) of
+        Just Refl -> Just (Lit v)
+        Nothing -> case testEquality (typeRep @a) (typeRep @(Maybe Bool)) of
+            Just Refl -> Just (Lit (Just v))
+            Nothing -> Nothing
+
+combineAnd :: (Columnable a) => Expr a -> Expr a -> Maybe (Expr a)
+combineAnd l r
+    | eqExpr l r = Just l
+    | asBoolLit l == Just False = litBoolish False
+    | asBoolLit r == Just False = litBoolish False
+    | asBoolLit l == Just True = Just r
+    | asBoolLit r == Just True = Just l
+    | absorbs ConnOr l r = Just l
+    | absorbs ConnOr r l = Just r
+    | otherwise = simplifyPredicatePair True l r
+
+combineOr :: (Columnable a) => Expr a -> Expr a -> Maybe (Expr a)
+combineOr l r
+    | eqExpr l r = Just l
+    | asBoolLit l == Just True = litBoolish True
+    | asBoolLit r == Just True = litBoolish True
+    | asBoolLit l == Just False = Just r
+    | asBoolLit r == Just False = Just l
+    | absorbs ConnAnd l r = Just l
+    | absorbs ConnAnd r l = Just r
+    | otherwise = simplifyPredicatePair False l r
+
+absorbs :: (Columnable a) => Conn -> Expr a -> Expr a -> Bool
+absorbs conn x (Binary (op :: op c b a) ya yb)
+    | Just c' <- connOf op
+    , sameConn conn c'
+    , Just Refl <- testEquality (typeRep @c) (typeRep @a)
+    , Just Refl <- testEquality (typeRep @b) (typeRep @a) =
+        eqExpr x ya || eqExpr x yb
+absorbs _ _ _ = False
+
+sameConn :: Conn -> Conn -> Bool
+sameConn ConnAnd ConnAnd = True
+sameConn ConnOr ConnOr = True
+sameConn _ _ = False
+
+data Cmp = CLt | CLeq | CGt | CGeq | CEq | CNeq deriving (Eq)
+
+data NullK = Total | FalseOnNull | UnknownOnNull deriving (Eq)
+
+data Atom = Atom
+    { aCmp :: Cmp
+    , aThr :: !Double
+    , aKey :: String
+    , aNull :: NullK
+    , aIntegral :: Bool
+    }
+
+cmpOf :: forall op c b r. (BinaryOp op) => op c b r -> Maybe Cmp
+cmpOf _
+    | Just HRefl <- eqTypeRep (typeRep @op) (typeRep @NullLt) = Just CLt
+    | Just HRefl <- eqTypeRep (typeRep @op) (typeRep @NullLeq) = Just CLeq
+    | Just HRefl <- eqTypeRep (typeRep @op) (typeRep @NullGt) = Just CGt
+    | Just HRefl <- eqTypeRep (typeRep @op) (typeRep @NullGeq) = Just CGeq
+    | Just HRefl <- eqTypeRep (typeRep @op) (typeRep @NullEq) = Just CEq
+    | Just HRefl <- eqTypeRep (typeRep @op) (typeRep @NullNeq) = Just CNeq
+    | otherwise = Nothing
+
+isLower, isUpper :: Cmp -> Bool
+isLower c = c == CGt || c == CGeq
+isUpper c = c == CLt || c == CLeq
+
+-- | True if @x@ is a @Maybe _@ type.
+isMaybeTy :: forall x. (Columnable x) => Bool
+isMaybeTy = case typeRep @x of
+    App con _ -> case eqTypeRep con (typeRep @Maybe) of Just HRefl -> True; _ -> False
+    _ -> False
+
+litDouble :: forall b. (Columnable b) => Expr b -> Maybe Double
+litDouble (Lit v) =
+    case testEquality (typeRep @b) (typeRep @Double) of
+        Just Refl -> Just v
+        Nothing -> case testEquality (typeRep @b) (typeRep @Int) of
+            Just Refl -> Just (fromIntegral v)
+            Nothing -> case testEquality (typeRep @b) (typeRep @(Maybe Double)) of
+                Just Refl -> v
+                Nothing -> case testEquality (typeRep @b) (typeRep @(Maybe Int)) of
+                    Just Refl -> fromIntegral <$> v
+                    Nothing -> Nothing
+litDouble _ = Nothing
+
+{- | True for a column lifted from an integral type (never NaN): @toDouble (col …)@
+or a column whose type is itself integral.
+-}
+integralColE :: forall c. (Columnable c) => Expr c -> Bool
+integralColE (Unary op _) = unaryName op == "toDouble"
+integralColE _ =
+    or
+        [ matches @Int
+        , matches @(Maybe Int)
+        ]
+  where
+    matches :: forall t. (Columnable t) => Bool
+    matches = case testEquality (typeRep @c) (typeRep @t) of Just Refl -> True; _ -> False
+
+atomOf :: forall a. (Columnable a) => Expr a -> Maybe Atom
+atomOf (Unary fm (Binary (op :: op c b r) (colE :: Expr c) litE))
+    | unaryName fm == "fromMaybe"
+    , Just cmp <- cmpOf op
+    , Just t <- litDouble litE =
+        Just (Atom cmp t (show (normalize colE)) FalseOnNull (integralColE colE))
+atomOf (Binary (op :: op c b a) (colE :: Expr c) litE)
+    | Just cmp <- cmpOf op
+    , Just t <- litDouble litE =
+        let nk = if isMaybeTy @c then UnknownOnNull else Total
+         in Just (Atom cmp t (show (normalize colE)) nk (integralColE colE))
+atomOf _ = Nothing
+
+simplifyPredicatePair ::
+    forall a. (Columnable a) => Bool -> Expr a -> Expr a -> Maybe (Expr a)
+simplifyPredicatePair isAnd a b = do
+    atomA <- atomOf a
+    atomB <- atomOf b
+    guard (aKey atomA == aKey atomB)
+    let nk = aNull atomA
+        integral = aIntegral atomA
+    if isAnd
+        then andAtoms a atomA b atomB nk integral
+        else orAtoms a atomA b atomB nk integral
+
+-- | Contradiction folds to a literal False unless null-rows make it unknown.
+litFalseGated :: (Columnable a) => NullK -> Maybe (Expr a)
+litFalseGated UnknownOnNull = Nothing
+litFalseGated _ = litBoolish False
+
+{- | Tautology to literal True is sound only for total (never-null) atoms; the
+exhaustive-cover form additionally needs a non-NaN (integral) column.
+-}
+litTrueTotal :: (Columnable a) => NullK -> Maybe (Expr a)
+litTrueTotal Total = litBoolish True
+litTrueTotal _ = Nothing
+
+andAtoms ::
+    (Columnable a) =>
+    Expr a -> Atom -> Expr a -> Atom -> NullK -> Bool -> Maybe (Expr a)
+andAtoms a atomA b atomB nk _ =
+    let cA = aCmp atomA; tA = aThr atomA; cB = aCmp atomB; tB = aThr atomB
+     in if
+            | isLower cA, isLower cB, cA == cB -> Just (if tA >= tB then a else b)
+            | isUpper cA, isUpper cB, cA == cB -> Just (if tA <= tB then a else b)
+            | isLower cA, isUpper cB -> lu cA tA cB tB
+            | isUpper cA, isLower cB -> lu cB tB cA tA
+            | cA == CEq, cB == CEq -> if tA == tB then Just a else litFalseGated nk
+            | cA == CEq, cB == CNeq -> if tA == tB then litFalseGated nk else Just a
+            | cA == CNeq, cB == CEq -> if tA == tB then litFalseGated nk else Just b
+            | cA == CEq -> if satisfies tA cB tB then Just a else litFalseGated nk
+            | cB == CEq -> if satisfies tB cA tA then Just b else litFalseGated nk
+            | cA == CNeq, cB == CNeq -> Nothing
+            | cA == CNeq -> if outside tA cB tB then Just b else Nothing
+            | cB == CNeq -> if outside tB cA tA then Just a else Nothing
+            | otherwise -> Nothing
+  where
+    lu lc lo uc hi
+        | lo > hi = litFalseGated nk
+        | lo == hi, lc == CGeq, uc == CLeq = pointEq a lo
+        | lo == hi = litFalseGated nk
+        | otherwise = Nothing
+
+orAtoms ::
+    (Columnable a) =>
+    Expr a -> Atom -> Expr a -> Atom -> NullK -> Bool -> Maybe (Expr a)
+orAtoms a atomA b atomB nk integral =
+    let cA = aCmp atomA; tA = aThr atomA; cB = aCmp atomB; tB = aThr atomB
+     in if
+            | isLower cA, isLower cB, cA == cB -> Just (if tA <= tB then a else b)
+            | isUpper cA, isUpper cB, cA == cB -> Just (if tA >= tB then a else b)
+            | isUpper cA
+            , isLower cB
+            , nk == Total
+            , integral
+            , covers cB tB cA tA ->
+                litTrueTotal nk
+            | isLower cA
+            , isUpper cB
+            , nk == Total
+            , integral
+            , covers cA tA cB tB ->
+                litTrueTotal nk
+            | cA == CNeq, cB == CNeq -> if tA == tB then Just a else litTrueTotal nk
+            | cA == CEq, cB == CNeq -> if tA == tB then litTrueTotal nk else Just b
+            | cA == CNeq, cB == CEq -> if tA == tB then litTrueTotal nk else Just a
+            | cA == CEq, cB == CEq -> if tA == tB then Just a else Nothing
+            | otherwise -> Nothing
+
+{- | Build @col == t@ for the point-collapse rule; only strict @Expr Bool@ over a
+@Double@ column (otherwise bail).
+-}
+pointEq :: forall a. (Columnable a) => Expr a -> Double -> Maybe (Expr a)
+pointEq atom lo = case testEquality (typeRep @a) (typeRep @Bool) of
+    Just Refl -> (\colE -> colE .==. Lit lo) <$> recoverColD atom
+    Nothing -> Nothing
+
+recoverColD :: Expr x -> Maybe (Expr Double)
+recoverColD (Binary _ (colE :: Expr c) _) =
+    case testEquality (typeRep @c) (typeRep @Double) of
+        Just Refl -> Just colE
+        _ -> Nothing
+recoverColD (Unary _ inner) = recoverColD inner
+recoverColD _ = Nothing
+
+covers :: Cmp -> Double -> Cmp -> Double -> Bool
+covers lowerCmp lo upperCmp hi =
+    lo < hi || (lo == hi && (lowerCmp == CGeq || upperCmp == CLeq))
+
+satisfies :: Double -> Cmp -> Double -> Bool
+satisfies t CGt tb = t > tb
+satisfies t CGeq tb = t >= tb
+satisfies t CLt tb = t < tb
+satisfies t CLeq tb = t <= tb
+satisfies _ _ _ = False
+
+outside :: Double -> Cmp -> Double -> Bool
+outside t CGt tb = t <= tb
+outside t CGeq tb = t < tb
+outside t CLt tb = t >= tb
+outside t CLeq tb = t > tb
+outside _ _ _ = False
+
+-- ---------------------------------------------------------------------------
+-- Path-condition entailment for fitted-tree pruning.
+-- ---------------------------------------------------------------------------
+
+-- | A known same-column threshold fact accumulated along a tree path.
+data PredFact = PredFact !String !Cmp !Double
+
+-- | The fact a branch's true edge establishes (the condition holds).
+factTrue :: Expr Bool -> Maybe PredFact
+factTrue e = (\a -> PredFact (aKey a) (aCmp a) (aThr a)) <$> atomOf e
+
+{- | The fact a branch's false edge establishes (the negated condition). Only
+sound for non-NaN (integral) columns — a NaN row takes the false edge too,
+so @¬(x>t)@ is not a clean @x<=t@ bound for floats.
+-}
+factFalse :: Expr Bool -> Maybe PredFact
+factFalse e = do
+    a <- atomOf e
+    guard (aIntegral a && aNull a == Total)
+    nc <- negCmp (aCmp a)
+    pure (PredFact (aKey a) nc (aThr a))
+
+negCmp :: Cmp -> Maybe Cmp
+negCmp CLt = Just CGeq
+negCmp CLeq = Just CGt
+negCmp CGt = Just CLeq
+negCmp CGeq = Just CLt
+negCmp _ = Nothing
+
+{- | @entails facts cond@: 'Just' 'True' when the path facts force @cond@ true,
+'Just' 'False' when they force it false, 'Nothing' when undecided.
+-}
+entails :: [PredFact] -> Expr Bool -> Maybe Bool
+entails facts cond = do
+    a <- atomOf cond
+    let decisions =
+            [ d
+            | PredFact fk fc ft <- facts
+            , fk == aKey a
+            , Just d <- [factImplies (fc, ft) (aCmp a, aThr a)]
+            ]
+    case decisions of
+        (d : _) -> Just d
+        [] -> Nothing
+
+{- | Does the fact's solution set sit inside @cond@ ('Just' 'True'), disjoint
+from it ('Just' 'False'), or neither ('Nothing')? Boundary strictness is
+honoured: e.g. @x<=t@ does NOT entail @x<t@, and @x>=t ∧ x<=t@ is not empty.
+-}
+factImplies :: (Cmp, Double) -> (Cmp, Double) -> Maybe Bool
+factImplies (fc, ft) (cc, tc)
+    | isLower fc, isLower cc, subset = Just True
+    | isUpper fc, isUpper cc, subset = Just True
+    | isLower fc, isUpper cc, disjointAtEq = Just False
+    | isUpper fc, isLower cc, disjointBelow = Just False
+    | otherwise = Nothing
+  where
+    fIncl = fc == CGeq || fc == CLeq
+    cIncl = cc == CGeq || cc == CLeq
+    -- same-direction containment: strictly tighter, or equal threshold where the
+    -- fact's boundary inclusivity is no stronger than the condition's.
+    subset =
+        (if isLower fc then ft > tc else ft < tc)
+            || (ft == tc && (not fIncl || cIncl))
+    -- lower fact ∩ upper cond empty: fact starts above cond's top, or they meet
+    -- at a point that is not in both.
+    disjointAtEq = ft > tc || (ft == tc && not (fIncl && cIncl))
+    -- upper fact ∩ lower cond empty (mirror).
+    disjointBelow = ft < tc || (ft == tc && not (fIncl && cIncl))
diff --git a/src/DataFrame/Operators.hs b/src/DataFrame/Operators.hs
--- a/src/DataFrame/Operators.hs
+++ b/src/DataFrame/Operators.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE GADTs #-}
 {-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TypeFamilies #-}
 {-# LANGUAGE TypeOperators #-}
@@ -9,8 +10,8 @@
 import qualified Data.Text as T
 import DataFrame.Internal.Column (Columnable)
 import DataFrame.Internal.Expression (
+    BinUDF (MkBinaryOp),
     BinaryOp (
-        MkBinaryOp,
         binaryCommutative,
         binaryFn,
         binaryName,
@@ -20,7 +21,7 @@
     Expr (Binary, Col, If, Lit, Unary),
     NamedExpr,
     UExpr (UExpr),
-    UnaryOp (MkUnaryOp, unaryFn, unaryName, unarySymbol),
+    UnUDF (MkUnaryOp),
  )
 import DataFrame.Internal.Nullable (
     BaseType,
@@ -72,7 +73,7 @@
 liftDecorated ::
     (Columnable a, Columnable b) =>
     (a -> b) -> T.Text -> Maybe T.Text -> Expr a -> Expr b
-liftDecorated f opName rep = Unary (MkUnaryOp{unaryFn = f, unaryName = opName, unarySymbol = rep})
+liftDecorated f opName rep = Unary (MkUnaryOp f opName rep)
 
 lift2Decorated ::
     (Columnable c, Columnable b, Columnable a) =>
@@ -85,16 +86,111 @@
     Expr b ->
     Expr a
 lift2Decorated f opName rep comm prec =
-    Binary
-        ( MkBinaryOp
-            { binaryFn = f
-            , binaryName = opName
-            , binarySymbol = rep
-            , binaryCommutative = comm
-            , binaryPrecedence = prec
-            }
-        )
+    Binary (MkBinaryOp f opName rep comm prec)
 
+data NullEq a b c where
+    NullEq ::
+        ( NumericWidenOp (BaseType a) (BaseType b)
+        , NullLift2Op a b Bool (NullCmpResult a b)
+        , Eq (Promote (BaseType a) (BaseType b))
+        ) =>
+        NullEq a b (NullCmpResult a b)
+
+data NullNeq a b c where
+    NullNeq ::
+        ( NumericWidenOp (BaseType a) (BaseType b)
+        , NullLift2Op a b Bool (NullCmpResult a b)
+        , Eq (Promote (BaseType a) (BaseType b))
+        ) =>
+        NullNeq a b (NullCmpResult a b)
+
+data NullLt a b c where
+    NullLt ::
+        ( NumericWidenOp (BaseType a) (BaseType b)
+        , NullLift2Op a b Bool (NullCmpResult a b)
+        , Ord (Promote (BaseType a) (BaseType b))
+        ) =>
+        NullLt a b (NullCmpResult a b)
+
+data NullGt a b c where
+    NullGt ::
+        ( NumericWidenOp (BaseType a) (BaseType b)
+        , NullLift2Op a b Bool (NullCmpResult a b)
+        , Ord (Promote (BaseType a) (BaseType b))
+        ) =>
+        NullGt a b (NullCmpResult a b)
+
+data NullLeq a b c where
+    NullLeq ::
+        ( NumericWidenOp (BaseType a) (BaseType b)
+        , NullLift2Op a b Bool (NullCmpResult a b)
+        , Ord (Promote (BaseType a) (BaseType b))
+        ) =>
+        NullLeq a b (NullCmpResult a b)
+
+data NullGeq a b c where
+    NullGeq ::
+        ( NumericWidenOp (BaseType a) (BaseType b)
+        , NullLift2Op a b Bool (NullCmpResult a b)
+        , Ord (Promote (BaseType a) (BaseType b))
+        ) =>
+        NullGeq a b (NullCmpResult a b)
+
+data NullAnd a b c where
+    NullAnd ::
+        (NullableCmpOp a b (NullCmpResult a b), BaseType a ~ Bool) =>
+        NullAnd a b (NullCmpResult a b)
+
+data NullOr a b c where
+    NullOr ::
+        (NullableCmpOp a b (NullCmpResult a b), BaseType a ~ Bool) =>
+        NullOr a b (NullCmpResult a b)
+
+instance BinaryOp NullEq where
+    binaryFn NullEq = applyNull2 (widenCmpOp (==))
+    binaryName NullEq = "eq"
+    binarySymbol NullEq = Just ".=="
+    binaryCommutative NullEq = True
+    binaryPrecedence NullEq = 4
+instance BinaryOp NullNeq where
+    binaryFn NullNeq = applyNull2 (widenCmpOp (/=))
+    binaryName NullNeq = "neq"
+    binarySymbol NullNeq = Just "./="
+    binaryCommutative NullNeq = True
+    binaryPrecedence NullNeq = 4
+instance BinaryOp NullLt where
+    binaryFn NullLt = applyNull2 (widenCmpOp (<))
+    binaryName NullLt = "lt"
+    binarySymbol NullLt = Just ".<"
+    binaryPrecedence NullLt = 4
+instance BinaryOp NullGt where
+    binaryFn NullGt = applyNull2 (widenCmpOp (>))
+    binaryName NullGt = "gt"
+    binarySymbol NullGt = Just ".>"
+    binaryPrecedence NullGt = 4
+instance BinaryOp NullLeq where
+    binaryFn NullLeq = applyNull2 (widenCmpOp (<=))
+    binaryName NullLeq = "leq"
+    binarySymbol NullLeq = Just ".<="
+    binaryPrecedence NullLeq = 4
+instance BinaryOp NullGeq where
+    binaryFn NullGeq = applyNull2 (widenCmpOp (>=))
+    binaryName NullGeq = "geq"
+    binarySymbol NullGeq = Just ".>="
+    binaryPrecedence NullGeq = 4
+instance BinaryOp NullAnd where
+    binaryFn NullAnd = nullCmpOp (&&)
+    binaryName NullAnd = "nulland"
+    binarySymbol NullAnd = Just ".&&"
+    binaryCommutative NullAnd = True
+    binaryPrecedence NullAnd = 3
+instance BinaryOp NullOr where
+    binaryFn NullOr = nullCmpOp (||)
+    binaryName NullOr = "nullor"
+    binarySymbol NullOr = Just ".||"
+    binaryCommutative NullOr = True
+    binaryPrecedence NullOr = 2
+
 (.==.) ::
     (Columnable a, Eq a) =>
     Expr a ->
@@ -211,7 +307,7 @@
     Expr a ->
     Expr b ->
     Expr (NullCmpResult a b)
-(.==) = lift2Decorated (applyNull2 (widenCmpOp (==))) "eq" (Just ".==") True 4
+(.==) = Binary NullEq
 
 -- | Nullable-aware inequality. Widens numeric operands to their common type.
 (./=) ::
@@ -222,7 +318,7 @@
     Expr a ->
     Expr b ->
     Expr (NullCmpResult a b)
-(./=) = lift2Decorated (applyNull2 (widenCmpOp (/=))) "neq" (Just "./=") True 4
+(./=) = Binary NullNeq
 
 -- | Nullable-aware less-than. Widens numeric operands to their common type.
 (.<) ::
@@ -233,7 +329,7 @@
     Expr a ->
     Expr b ->
     Expr (NullCmpResult a b)
-(.<) = lift2Decorated (applyNull2 (widenCmpOp (<))) "lt" (Just ".<") False 4
+(.<) = Binary NullLt
 
 -- | Nullable-aware greater-than. Widens numeric operands to their common type.
 (.>) ::
@@ -244,7 +340,7 @@
     Expr a ->
     Expr b ->
     Expr (NullCmpResult a b)
-(.>) = lift2Decorated (applyNull2 (widenCmpOp (>))) "gt" (Just ".>") False 4
+(.>) = Binary NullGt
 
 {- | Nullable-aware less-than-or-equal. Widens numeric operands to their
 common type, so @Expr Double .<= Expr Int@ typechecks.
@@ -257,7 +353,7 @@
     Expr a ->
     Expr b ->
     Expr (NullCmpResult a b)
-(.<=) = lift2Decorated (applyNull2 (widenCmpOp (<=))) "leq" (Just ".<=") False 4
+(.<=) = Binary NullLeq
 
 -- | Nullable-aware greater-than-or-equal. Widens numeric operands to their common type.
 (.>=) ::
@@ -268,7 +364,7 @@
     Expr a ->
     Expr b ->
     Expr (NullCmpResult a b)
-(.>=) = lift2Decorated (applyNull2 (widenCmpOp (>=))) "geq" (Just ".>=") False 4
+(.>=) = Binary NullGeq
 
 (.&&.) :: Expr Bool -> Expr Bool -> Expr Bool
 (.&&.) = lift2Decorated (&&) "and" (Just ".&&.") True 3
@@ -282,7 +378,7 @@
     Expr a ->
     Expr b ->
     Expr (NullCmpResult a b)
-(.&&) = lift2Decorated (nullCmpOp (&&)) "nulland" (Just ".&&") True 3
+(.&&) = Binary NullAnd
 
 -- | Nullable-aware logical OR. Returns @Maybe Bool@ when either operand is nullable.
 (.||) ::
@@ -290,7 +386,7 @@
     Expr a ->
     Expr b ->
     Expr (NullCmpResult a b)
-(.||) = lift2Decorated (nullCmpOp (||)) "nullor" (Just ".||") True 2
+(.||) = Binary NullOr
 
 (.^^) ::
     ( Columnable (BaseType a)
