diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -57,7 +57,7 @@
 import qualified DataFrame.Typed as T
 import Data.Maybe (fromJust)
 
-salesT     = T.unsafeFreeze @'[T.Column "x" Double, T.Column "y" Double] sales
+salesT     = T.unsafeFreeze @'[ '("x", Double), '("y", Double) ] sales
 typedModel = fit defaultLinearConfig (T.col @"y") salesT
 scored     = T.derive @"prediction" (predict typedModel) salesT
 
diff --git a/dataframe-learn.cabal b/dataframe-learn.cabal
--- a/dataframe-learn.cabal
+++ b/dataframe-learn.cabal
@@ -1,6 +1,6 @@
 cabal-version:      3.4
 name:               dataframe-learn
-version:            2.2.0.0
+version:            2.3.0.0
 synopsis:           Interpretable, expression-returning machine learning for the dataframe ecosystem.
 description:
     A small scikit-learn-style ML library where every model returns both an
@@ -61,8 +61,8 @@
                         containers >= 0.6.7 && < 0.10,
                         parallel >= 3.3 && < 4,
                         random >= 1.2 && < 2,
-                        dataframe-core >= 2.2 && < 2.3,
-                        dataframe-operations >= 2.2 && < 2.3,
+                        dataframe-core >= 2.3 && < 2.4,
+                        dataframe-operations >= 2.3 && < 2.4,
                         text >= 2.1 && < 3,
                         vector >= 0.13 && < 0.15,
                         vector-algorithms >= 0.9 && < 0.11
@@ -105,10 +105,10 @@
                         containers >= 0.6.7 && < 0.10,
                         parallel >= 3.3 && < 4,
                         random >= 1.2 && < 2,
-                        dataframe-core >= 2.2 && < 2.3,
-                        dataframe-core >= 2.2 && < 2.3,
-                        dataframe-operations >= 2.2 && < 2.3,
-                        dataframe-operations >= 2.2 && < 2.3,
+                        dataframe-core >= 2.3 && < 2.4,
+                        dataframe-core >= 2.3 && < 2.4,
+                        dataframe-operations >= 2.3 && < 2.4,
+                        dataframe-operations >= 2.3 && < 2.4,
                         dataframe-expr-serializer >= 1.2.0.1 && < 1.3,
                         dataframe-learn:internal,
                         text >= 2.1 && < 3,
@@ -140,13 +140,13 @@
                         aeson >= 0.11.0.0 && < 3,
                         bytestring >= 0.11 && < 0.14,
                         containers >= 0.6.7 && < 0.10,
-                        dataframe-core >= 2.2 && < 2.3,
-                        dataframe-core >= 2.2 && < 2.3,
+                        dataframe-core >= 2.3 && < 2.4,
+                        dataframe-core >= 2.3 && < 2.4,
                         dataframe-csv >= 2.3 && < 2.4,
                         dataframe-learn,
                         dataframe-learn:internal,
-                        dataframe-operations >= 2.2 && < 2.3,
-                        dataframe-operations >= 2.2 && < 2.3,
+                        dataframe-operations >= 2.3 && < 2.4,
+                        dataframe-operations >= 2.3 && < 2.4,
                         HUnit >= 1.6 && < 1.8,
                         QuickCheck >= 2 && < 3,
                         random >= 1 && < 2,
diff --git a/src-internal/DataFrame/DecisionTree/Cart.hs b/src-internal/DataFrame/DecisionTree/Cart.hs
--- a/src-internal/DataFrame/DecisionTree/Cart.hs
+++ b/src-internal/DataFrame/DecisionTree/Cart.hs
@@ -18,15 +18,22 @@
 ) where
 
 import DataFrame.DecisionTree.Types (Tree (..), TreeConfig (..))
+import DataFrame.Errors (DataFrameException (..), TypeErrorContext (..))
 import qualified DataFrame.Functions as F
 import DataFrame.Internal.Column
-import DataFrame.Internal.DataFrame (DataFrame, columnNames, unsafeGetColumn)
+import DataFrame.Internal.DataFrame (
+    DataFrame,
+    columnNames,
+    getColumn,
+    unsafeGetColumn,
+ )
 import DataFrame.Internal.Expression (Expr (..))
 import DataFrame.Internal.Interpreter (interpret)
 import DataFrame.Internal.Types
 import DataFrame.Operations.Core (nRows)
 import DataFrame.Operators
 
+import Control.Exception (throw)
 import Data.Either (fromRight)
 import Data.Function (on)
 import Data.List (foldl')
@@ -37,7 +44,7 @@
 import qualified Data.Vector as V
 import qualified Data.Vector.Algorithms.Merge as VA
 import qualified Data.Vector.Unboxed as VU
-import Type.Reflection (typeRep)
+import Type.Reflection (TypeRep, typeRep)
 
 {- | A one-hot feature column: per-row Double values plus the sklearn LEFT
 predicate (@x <= threshold@) over the ORIGINAL DataFrame.
@@ -89,12 +96,25 @@
             (maxTreeDepth cfg)
             (max 1 (minLeafSize cfg))
 
+{- | Read the target column at the type the tree is being fitted at. Names the
+column and both types on failure: a bare @fromIntegral@ defaults to 'Integer'
+and lands here, and the old message said only that something went wrong.
+-}
 cartLabels :: forall a. (Columnable a) => DataFrame -> T.Text -> V.Vector a
 cartLabels df target = case interpret @a df (Col target) of
-    Right (TColumn column) -> fromRight err (toVector @a column)
-    _ -> err
+    Right (TColumn column) -> fromRight (throw err) (toVector @a column)
+    Left e -> throw e
   where
-    err = error "buildCartTree: cannot interpret target column"
+    err =
+        TypeMismatchException
+            ( MkTypeErrorContext
+                (Right (typeRep @a))
+                ( Left (maybe "missing" columnTypeString (getColumn target df)) ::
+                    Either String (TypeRep a)
+                )
+                (Just (T.unpack target))
+                (Just "buildCartTree")
+            )
 
 cartClasses :: (Ord a) => V.Vector a -> V.Vector a
 cartClasses = V.fromList . Set.toList . Set.fromList . V.toList
diff --git a/src-internal/DataFrame/DecisionTree/Fit.hs b/src-internal/DataFrame/DecisionTree/Fit.hs
--- a/src-internal/DataFrame/DecisionTree/Fit.hs
+++ b/src-internal/DataFrame/DecisionTree/Fit.hs
@@ -1,5 +1,6 @@
 {-# LANGUAGE AllowAmbiguousTypes #-}
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
 
@@ -38,6 +39,7 @@
 import DataFrame.DecisionTree.Prune (pruneDead, pruneExpr)
 import DataFrame.DecisionTree.Tao (taoOptimize, taoOptimizeCV)
 import DataFrame.DecisionTree.Types (Tree (..), TreeConfig (..))
+import DataFrame.Errors (DataFrameException (..))
 import qualified DataFrame.Functions as F
 import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)
 import DataFrame.Internal.DataFrame (DataFrame)
@@ -69,7 +71,8 @@
     condVecs = candidatePool @a cfg target df
     initialTree = buildCartTree @a cfg target df
     indices = V.enumFromN 0 (nRows df)
-fitDecisionTree _ expr _ = error ("Cannot create tree for compound expression: " ++ show expr)
+fitDecisionTree _ expr _ =
+    throw (NonColumnReferenceException ("fitDecisionTree: " <> T.pack (show expr)))
 
 -- | The deduplicated numeric + discrete candidate pool for a target column.
 candidatePool ::
@@ -116,7 +119,7 @@
 
 majorityValue :: forall a. (Columnable a, Ord a) => T.Text -> DataFrame -> a
 majorityValue target df
-    | M.null counts = error "Empty DataFrame in leaf"
+    | M.null counts = throw (EmptyDataSetException "majorityValue (tree leaf)")
     | otherwise = fst (maximumBy (compare `on` snd) (M.toList counts))
   where
     counts = getCounts @a target df
@@ -197,7 +200,8 @@
     pruned =
         pruneDead
             (taoOptimize @a cfg target conds df indices (buildCartTree @a cfg target df))
-fitProbTree _ expr _ = error ("Cannot create prob tree for compound expression: " ++ show expr)
+fitProbTree _ expr _ =
+    throw (NonColumnReferenceException ("fitProbTree: " <> T.pack (show expr)))
 
 -- | Convert a 'ProbTree' into one @Expr Double@ per class.
 probExprs ::
diff --git a/src-internal/DataFrame/DecisionTree/Linear.hs b/src-internal/DataFrame/DecisionTree/Linear.hs
--- a/src-internal/DataFrame/DecisionTree/Linear.hs
+++ b/src-internal/DataFrame/DecisionTree/Linear.hs
@@ -15,7 +15,7 @@
     materializeFeatureForCare,
 ) where
 
-import DataFrame.DecisionTree.Numeric (NumExpr (..), numericCols)
+import DataFrame.DecisionTree.Numeric (NumExpr (..), numExprCols, numericCols)
 import DataFrame.DecisionTree.Types (
     CarePoint (..),
     Direction (..),
@@ -33,27 +33,34 @@
 import qualified Data.Vector.Unboxed as VU
 
 {- | Best oblique candidate, or 'Nothing' when the linear path is disabled or
-there are too few care points to fit on.
+there are too few care points to fit on. The target column is named so it can
+be kept out of the feature set.
 -}
 bestLinearCandidate ::
-    TreeConfig -> DataFrame -> [CarePoint] -> Maybe (Expr Bool)
-bestLinearCandidate cfg df carePoints
+    TreeConfig -> T.Text -> DataFrame -> [CarePoint] -> Maybe (Expr Bool)
+bestLinearCandidate cfg target df carePoints
     | not (useLinearSolver cfg) = Nothing
     | length carePoints < minCarePointsForLinear cfg = Nothing
-    | otherwise = fitLinearCandidate cfg df carePoints
+    | otherwise = fitLinearCandidate cfg target df carePoints
 
 {- | Fit an L1 logistic regression to the care points and convert the resulting
 hyperplane to a condition, or 'Nothing' when no numeric features exist or the
 fitted model is all-zero or degenerate.
 -}
 fitLinearCandidate ::
-    TreeConfig -> DataFrame -> [CarePoint] -> Maybe (Expr Bool)
-fitLinearCandidate cfg df carePoints = case materializedFeatures df carePoints of
-    [] -> Nothing
-    mats -> linearFromFeatures cfg carePoints mats
+    TreeConfig -> T.Text -> DataFrame -> [CarePoint] -> Maybe (Expr Bool)
+fitLinearCandidate cfg target df carePoints =
+    case materializedFeatures target df carePoints of
+        [] -> Nothing
+        mats -> linearFromFeatures cfg carePoints mats
 
-materializedFeatures :: DataFrame -> [CarePoint] -> [(T.Text, VU.Vector Double)]
-materializedFeatures df carePoints = mapMaybe (materializeFeatureForCare df carePoints) (numericCols df)
+materializedFeatures ::
+    T.Text -> DataFrame -> [CarePoint] -> [(T.Text, VU.Vector Double)]
+materializedFeatures target df carePoints =
+    mapMaybe (materializeFeatureForCare df carePoints) (featureCols target df)
+
+featureCols :: T.Text -> DataFrame -> [NumExpr]
+featureCols target df = filter (notElem target . numExprCols) (numericCols df)
 
 linearFromFeatures ::
     TreeConfig -> [CarePoint] -> [(T.Text, VU.Vector Double)] -> Maybe (Expr Bool)
diff --git a/src-internal/DataFrame/DecisionTree/Predict.hs b/src-internal/DataFrame/DecisionTree/Predict.hs
--- a/src-internal/DataFrame/DecisionTree/Predict.hs
+++ b/src-internal/DataFrame/DecisionTree/Predict.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
 
@@ -32,6 +33,7 @@
     Tree (..),
     TreeConfig (..),
  )
+import DataFrame.Errors (DataFrameException (..))
 import DataFrame.Internal.Column (Columnable, TypedColumn (..), toVector)
 import DataFrame.Internal.DataFrame (DataFrame)
 import DataFrame.Internal.Expression (Expr (..))
@@ -183,7 +185,7 @@
 
 majorityOf :: M.Map a Int -> a
 majorityOf counts
-    | M.null counts = error "Empty indices in majorityValueFromIndices"
+    | M.null counts = throw (EmptyDataSetException "majorityValueFromIndices")
     | otherwise = fst (maximumBy (compare `on` snd) (M.toList counts))
 
 computeTreeLoss ::
diff --git a/src-internal/DataFrame/DecisionTree/Tao.hs b/src-internal/DataFrame/DecisionTree/Tao.hs
--- a/src-internal/DataFrame/DecisionTree/Tao.hs
+++ b/src-internal/DataFrame/DecisionTree/Tao.hs
@@ -213,7 +213,7 @@
             leftTree
             rightTree
     penaltyCV = evalWithPenaltyVec cfg carePoints
-    linearCandidate = bestLinearCandidate cfg (teDf env) carePoints
+    linearCandidate = bestLinearCandidate cfg (teTarget env) (teDf env) carePoints
     valid = filterValidCandidates cfg indices (teConds env)
     pool =
         candidatePool
diff --git a/src-internal/DataFrame/Featurize/Internal.hs b/src-internal/DataFrame/Featurize/Internal.hs
--- a/src-internal/DataFrame/Featurize/Internal.hs
+++ b/src-internal/DataFrame/Featurize/Internal.hs
@@ -1,4 +1,5 @@
 {-# LANGUAGE FlexibleContexts #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
 
@@ -166,7 +167,8 @@
 -- | The column name behind a @Col@ feature expression.
 columnExprName :: Expr Double -> T.Text
 columnExprName (Col n) = n
-columnExprName e = error ("expected a column expression, got " ++ show e)
+columnExprName e =
+    throw (NonColumnReferenceException ("columnExprName: " <> T.pack (show e)))
 
 -- | Interpret a @Col@ (or numeric) expression to a @Double@ vector.
 materializeColumn :: DataFrame -> Expr Double -> VU.Vector Double
@@ -197,7 +199,7 @@
 argExtreme ::
     (Columnable a) =>
     (Expr Double -> Expr Double -> Expr Bool) -> [(a, Expr Double)] -> Expr a
-argExtreme _ [] = error "argExtreme: no classes"
+argExtreme _ [] = throw (EmptyDataSetException "argExtreme")
 argExtreme _ [(c, _)] = Lit c
 argExtreme cmp ((c, sc) : rest) =
     If
diff --git a/src/DataFrame/Boosting/AdaBoost.hs b/src/DataFrame/Boosting/AdaBoost.hs
--- a/src/DataFrame/Boosting/AdaBoost.hs
+++ b/src/DataFrame/Boosting/AdaBoost.hs
@@ -2,6 +2,7 @@
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE FlexibleInstances #-}
 {-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE TypeFamilies #-}
@@ -19,10 +20,13 @@
     AdaBoostModel (..),
 ) where
 
+import Control.Exception (throw)
 import Data.List (sort)
 import Data.Maybe (fromMaybe, maybeToList)
+import qualified Data.Text as T
 import qualified Data.Vector as V
 import qualified Data.Vector.Unboxed as VU
+import DataFrame.Errors (DataFrameException (..))
 
 import DataFrame.DecisionTree.Cart (
     CartFeature (..),
@@ -101,7 +105,7 @@
     clamp e = max 1e-10 (min (1 - 1e-10) e)
     normalize v = let s = VU.sum v in if s == 0 then v else VU.map (/ s) v
 fitAdaBoost _ expr _ =
-    error ("fitAdaBoost: target must be a column, got " ++ show expr)
+    throw (NonColumnReferenceException ("fitAdaBoost: " <> T.pack (show expr)))
 
 predictCodes ::
     forall a.
@@ -113,7 +117,7 @@
     preds :: [a]
     preds = case interpret df (treeToExpr stump) of
         Right (TColumn c) -> either (const []) V.toList (toVector @a @V.Vector c)
-        Left e -> error (show e)
+        Left e -> throw e
     toCode v = fromMaybe 0 (V.findIndex (== v) classesV)
 
 -- | A depth-bounded weighted classification tree (weighted Gini splits).
diff --git a/src/DataFrame/Boosting/GBM.hs b/src/DataFrame/Boosting/GBM.hs
--- a/src/DataFrame/Boosting/GBM.hs
+++ b/src/DataFrame/Boosting/GBM.hs
@@ -23,11 +23,13 @@
     gbDecisionExpr,
 ) where
 
+import Control.Exception (throw)
 import Data.Either (fromRight)
 import qualified Data.Map.Strict as M
 import qualified Data.Text as T
 import qualified Data.Vector as V
 import qualified Data.Vector.Unboxed as VU
+import DataFrame.Errors (DataFrameException (..))
 
 import DataFrame.DecisionTree.Cart (cartFeatures)
 import DataFrame.DecisionTree.Fit (treeToExpr)
@@ -118,16 +120,32 @@
     boost !m fScores ts ss usageAcc
         | m >= gbNEstimators cfg = (ts, ss, usageAcc)
         | otherwise =
-            let grad = negGradient (gbLoss cfg) y fScores
-                tree = fitRegTreeOn rtCfg feats grad Nothing
+            let (target', weights) = newtonStep (gbLoss cfg) y fScores
+                tree = fitRegTreeOn rtCfg feats target' weights
                 pred = predictTree df tree
                 fScores' = VU.zipWith (\f p -> f + lr * p) fScores pred
                 score = lossValue (gbLoss cfg) y fScores'
                 usage' = foldr (\c -> M.insertWith (+) c 1) usageAcc (treeColumns tree)
              in boost (m + 1) fScores' (tree : ts) (score : ss) usage'
 fitGBM _ expr _ =
-    error ("fitGBM: target must be a column, got " ++ show expr)
+    throw (NonColumnReferenceException ("fitGBM: " <> T.pack (show expr)))
 
+newtonStep ::
+    GBLoss ->
+    VU.Vector Double ->
+    VU.Vector Double ->
+    (VU.Vector Double, Maybe (VU.Vector Double))
+newtonStep SquaredError y f = (negGradient SquaredError y f, Nothing)
+newtonStep LogisticDeviance y f = (z, Just h)
+  where
+    p = VU.map sigmoid f
+    h = VU.map (\pi' -> max hFloor (pi' * (1 - pi'))) p
+    z = VU.zipWith3 (\yi pi' hi -> (yi - pi') / hi) y p h
+
+-- | Floor on the Hessian, so a saturated row cannot produce an unbounded step.
+hFloor :: Double
+hFloor = 1e-6
+
 negGradient ::
     GBLoss -> VU.Vector Double -> VU.Vector Double -> VU.Vector Double
 negGradient SquaredError y f = VU.zipWith (-) y f
@@ -159,7 +177,7 @@
 predictTree :: DataFrame -> Tree Double -> VU.Vector Double
 predictTree df t = case interpret @Double df (treeToExpr t) of
     Right (TColumn c) -> fromRight VU.empty (toVector @Double @VU.Vector c)
-    Left e -> error (show e)
+    Left e -> throw e
 
 treeColumns :: Tree Double -> [T.Text]
 treeColumns = getColumns . treeToExpr
diff --git a/src/DataFrame/DecisionTree/Model.hs b/src/DataFrame/DecisionTree/Model.hs
--- a/src/DataFrame/DecisionTree/Model.hs
+++ b/src/DataFrame/DecisionTree/Model.hs
@@ -1,6 +1,7 @@
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE FlexibleInstances #-}
 {-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE TypeFamilies #-}
 {-# LANGUAGE UndecidableInstances #-}
 
@@ -17,8 +18,10 @@
     DecisionTreeRegressor (..),
 ) where
 
+import Control.Exception (throw)
 import qualified Data.Map.Strict as M
 import qualified Data.Text as T
+import DataFrame.Errors (DataFrameException (..))
 
 import qualified Data.Vector as V
 
@@ -83,8 +86,10 @@
                     (targetDoubles target df)
                     Nothing
             _ ->
-                error
-                    ("fit @DecisionTreeRegressor: target must be a column, got " ++ show target)
+                throw
+                    ( NonColumnReferenceException
+                        ("fit @DecisionTreeRegressor: " <> T.pack (show target))
+                    )
         e = treeToExpr t
 
 instance Predict DecisionTreeRegressor where
diff --git a/src/DataFrame/Metrics.hs b/src/DataFrame/Metrics.hs
--- a/src/DataFrame/Metrics.hs
+++ b/src/DataFrame/Metrics.hs
@@ -38,6 +38,7 @@
     f1Of,
 ) where
 
+import Control.Exception (throw)
 import Data.Either (fromRight)
 import Data.List (nub, sort, sortBy)
 import Data.Ord (comparing)
@@ -70,7 +71,7 @@
 columnOf :: DataFrame -> Expr Double -> VU.Vector Double
 columnOf df e = case interpret @Double df e of
     Right (TColumn c) -> fromRight VU.empty (toVector @Double @VU.Vector c)
-    Left err -> error (show err)
+    Left err -> throw err
 
 n2 :: VU.Vector Double -> Double
 n2 = fromIntegral . VU.length
diff --git a/src/DataFrame/SVM/RFF.hs b/src/DataFrame/SVM/RFF.hs
--- a/src/DataFrame/SVM/RFF.hs
+++ b/src/DataFrame/SVM/RFF.hs
@@ -18,10 +18,12 @@
     RFFSVMModel (..),
 ) where
 
+import Control.Exception (throw)
 import Data.List (sort)
 import qualified Data.Text as T
 import qualified Data.Vector as V
 import qualified Data.Vector.Unboxed as VU
+import DataFrame.Errors (DataFrameException (..))
 
 import DataFrame.Featurize.Internal (featureNames, numericMatrix, targetValues)
 import qualified DataFrame.Functions as F
@@ -100,7 +102,11 @@
 fitRFFSVM cfg target df =
     case classes of
         [neg, pos] -> build neg pos
-        _ -> error "fitRFFSVM: binary classification only (got /= 2 classes)"
+        _ ->
+            throw
+                ( InternalException
+                    "fitRFFSVM: binary classification only, but the target has /= 2 classes"
+                )
   where
     names = featureNames target df
     (nameVec, mat) = numericMatrix names df
diff --git a/src/DataFrame/Segmented.hs b/src/DataFrame/Segmented.hs
--- a/src/DataFrame/Segmented.hs
+++ b/src/DataFrame/Segmented.hs
@@ -2,6 +2,7 @@
 {-# LANGUAGE FlexibleContexts #-}
 {-# LANGUAGE FlexibleInstances #-}
 {-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeApplications #-}
 {-# LANGUAGE TypeFamilies #-}
@@ -23,6 +24,7 @@
     SegmentFit (..),
 ) where
 
+import Control.Exception (throw)
 import Data.List (foldl', (\\))
 import qualified Data.Map.Strict as M
 import Data.Maybe (isJust)
@@ -30,6 +32,7 @@
 import qualified Data.Text as T
 import qualified Data.Vector as V
 import qualified Data.Vector.Unboxed as VU
+import DataFrame.Errors (DataFrameException (..))
 
 import DataFrame.Featurize.Internal (featureNames, numericMatrix, targetDoubles)
 import DataFrame.Internal.Column (
@@ -302,7 +305,7 @@
   where
     names = case dfs of
         (d0 : _) -> featureNames target d0
-        [] -> error "shrinkLinear: no segments"
+        [] -> throw (EmptyDataSetException "shrinkLinear")
     d = length names
     mats = [snd (numericMatrix names dframe) | dframe <- dfs]
     ys = [targetDoubles target dframe | dframe <- dfs]
diff --git a/src/DataFrame/Synthesis.hs b/src/DataFrame/Synthesis.hs
--- a/src/DataFrame/Synthesis.hs
+++ b/src/DataFrame/Synthesis.hs
@@ -1,18 +1,16 @@
 {-# LANGUAGE BangPatterns #-}
 {-# LANGUAGE FlexibleInstances #-}
 {-# LANGUAGE MultiParamTypeClasses #-}
+{-# LANGUAGE OverloadedStrings #-}
 {-# LANGUAGE ScopedTypeVariables #-}
 {-# LANGUAGE TypeFamilies #-}
 
 {- | Feature synthesis by bottom-up enumerative search with observational
 equivalence — the canonical enumerative method from Solar-Lezama's
-/Introduction to Program Synthesis/, hardened for a numeric, examples-only
-setting.
+/Introduction to Program Synthesis/.
 
 Given a frame and a numeric target column, it searches for a small, interpretable
-arithmetic expression over the other columns whose values track the target. The
-specification is purely the example rows; there is no SMT solver and no logical
-spec. Deterministic and pure.
+arithmetic expression over the other columns whose values track the target.
 
 The engine:
 
@@ -48,6 +46,7 @@
     synthesizeFeatures,
 ) where
 
+import Control.Exception (throw)
 import Data.Bits (xor)
 import Data.Either (fromRight)
 import Data.List (sortBy)
@@ -59,6 +58,7 @@
 import Data.Word (Word64)
 import GHC.Float (castDoubleToWord64)
 
+import DataFrame.Errors (DataFrameException (..))
 import DataFrame.Featurize.Internal (featureNames)
 import qualified DataFrame.Functions as F
 import DataFrame.Internal.DataFrame (DataFrame)
@@ -91,6 +91,8 @@
     , synLoss :: !LossFunction
     , synTopK :: !Int
     -- ^ How many ranked features to return in the bank.
+    , synMaxAllocBytes :: !Int
+    -- ^ Refuse a search whose largest layer would allocate beyond this.
     }
     deriving (Eq, Show)
 
@@ -101,6 +103,7 @@
         , synBankCap = 500
         , synLoss = PearsonCorrelation
         , synTopK = 16
+        , synMaxAllocBytes = 8 * 1024 * 1024 * 1024
         }
 
 {- | A synthesized feature. 'sfExpr' is the best-scoring expression and 'sfFeatures'
@@ -134,6 +137,7 @@
     SynthesisConfig -> Expr Double -> DataFrame -> SynthesizedFeature
 synthesizeFeatures cfg target df
     | null leaves || VU.null tgt = SynthesizedFeature (Lit 0) (negate (1 / 0)) []
+    | Just err <- oversizedSearch cfg (length leaves) n = throw err
     | otherwise = SynthesizedFeature best bestScore ranked
   where
     feats = featureNames target df
@@ -276,6 +280,49 @@
 absorb cfg tgt seen0 cands = (capLayer cfg tgt fresh, seen')
   where
     (fresh, seen') = dedupProgs seen0 cands
+
+oversizedSearch :: SynthesisConfig -> Int -> Int -> Maybe DataFrameException
+oversizedSearch cfg nLeaves n
+    | synMaxSize cfg <= 3 = Nothing
+    | estimate <= budget = Nothing
+    | otherwise =
+        Just
+            ( InternalException
+                ( "synthesizeFeatures: a search to synMaxSize="
+                    <> T.pack (show (synMaxSize cfg))
+                    <> " over "
+                    <> T.pack (show nLeaves)
+                    <> " leaves and "
+                    <> T.pack (show n)
+                    <> " rows would allocate about "
+                    <> T.pack (show (estimate `div` (1024 * 1024 * 1024)))
+                    <> " GiB, past the "
+                    <> T.pack (show (budget `div` (1024 * 1024 * 1024)))
+                    <> " GiB synMaxAllocBytes budget. Lower synMaxSize to "
+                    <> T.pack (show largestFittingSize)
+                    <> ", narrow the column set, or raise synMaxAllocBytes."
+                )
+            )
+  where
+    budget = synMaxAllocBytes cfg
+    bytesPerProg = 8 * max 1 n
+    -- 4 commutative ops over unordered pairs plus sub and div over ordered
+    -- pairs, all quadratic in the bank; unaries and powers are lower order.
+    binaryOpCount = 4 :: Int
+    -- Layer k pairs the capped bank with itself over the binary operators.
+    candidatesAt k
+        | k <= 2 = nLeaves
+        | otherwise =
+            binaryOpCount * min (synBankCap cfg) (candidatesAt (k - 1)) ^ (2 :: Int)
+    estimate = sum [candidatesAt k * bytesPerProg | k <- [2 .. synMaxSize cfg]]
+    largestFittingSize =
+        last
+            ( 3
+                : [ k
+                  | k <- [3 .. synMaxSize cfg]
+                  , sum [candidatesAt j * bytesPerProg | j <- [2 .. k]] <= budget
+                  ]
+            )
 
 -- | When a layer has more distinct programs than the cap, keep the best-scoring.
 capLayer :: SynthesisConfig -> Output -> [Prog] -> [Prog]
diff --git a/tests-internal/DecisionTree.hs b/tests-internal/DecisionTree.hs
--- a/tests-internal/DecisionTree.hs
+++ b/tests-internal/DecisionTree.hs
@@ -441,7 +441,6 @@
         0.0
         finalLoss
 
--- Shared setup for C2 (a) and (b): axis-aligned pool only, oblique label.
 obliqueAxisAlignedFixture ::
     (D.DataFrame, V.Vector Int, [Expr Bool], Tree T.Text)
 obliqueAxisAlignedFixture =
@@ -463,8 +462,6 @@
                 Tree T.Text
      in (df, indices, axisConds, initTree)
 
--- C2 (a): with the linear solver OFF, axis-aligned pool cannot recover the
--- oblique decision boundary. Preserves the original guarantee of the test.
 taoAxisAlignedInsufficientForObliqueDiscreteOnly :: Test
 taoAxisAlignedInsufficientForObliqueDiscreteOnly = TestCase $ do
     let (df, indices, axisConds, initTree) = obliqueAxisAlignedFixture
@@ -481,8 +478,6 @@
         "axis-aligned stump cannot recover oblique label without linear solver (loss > 0.1)"
         (finalLoss > 0.1)
 
--- C2 (b): with the linear solver ON, the L1-LR fit discovers the oblique
--- (x + y) hyperplane even though only axis-aligned conditions are in the pool.
 taoLinearRecoversObliqueFromAxisAlignedPool :: Test
 taoLinearRecoversObliqueFromAxisAlignedPool = TestCase $ do
     let (df, indices, axisConds, initTree) = obliqueAxisAlignedFixture
@@ -501,10 +496,6 @@
         0.0
         finalLoss
 
-------------------------------------------------------------------------
--- Nullable numeric feature tests
-------------------------------------------------------------------------
-
 -- Cleanly separable nullable column (no actual nulls): Just 1..6 -> "pos",
 -- Just 7..12 -> "neg". Exercises the nullable numeric path.
 nullableSepDF :: D.DataFrame
@@ -633,10 +624,6 @@
         "combined exprs include NMaybeDouble (nullable arithmetic)"
         (any (\case NMaybeDouble _ -> True; _ -> False) exprs)
 
-------------------------------------------------------------------------
--- Probability tree tests
-------------------------------------------------------------------------
-
 -- probsFromIndices: counts correct on a 3-row slice
 probsFromIndicesBasic :: Test
 probsFromIndicesBasic = TestCase $ do
@@ -780,13 +767,6 @@
                         )
                         indices
 
-------------------------------------------------------------------------
--- C4-C9 / D-series: linear solver integration tests
-------------------------------------------------------------------------
-
--- C4: Nested oblique recovery without oblique hints; label set by two oblique
--- boundaries but only axis-aligned thresholds in the pool. The linear solver
--- should learn both splits and reach zero loss.
 taoRecoversNestedObliqueWithoutHint :: Test
 taoRecoversNestedObliqueWithoutHint = TestCase $ do
     let labelExpr =
@@ -828,9 +808,6 @@
         0.0
         finalLoss
 
--- C5: Monotone loss across iterations with the linear solver enabled.
--- Resolves Issue 1 from the prior plan (currentCond included in the
--- competition pool).
 taoMonotoneWithLinear :: Test
 taoMonotoneWithLinear = TestCase $ do
     let indices = V.enumFromN 0 20
@@ -868,8 +845,6 @@
         0.0
         finalLoss
 
--- C8: Linear solver respects the L1 penalty and produces sparse hyperplanes
--- on data where only some features are informative.
 taoLinearProducesSparsity :: Test
 taoLinearProducesSparsity = TestCase $ do
     let n = 50 :: Int
@@ -911,7 +886,32 @@
         )
         ("a" `elem` rootCols || "b" `elem` rootCols)
 
--- C9: Determinism — same training data produces an equal (eqExpr) tree.
+taoLinearDoesNotUseTarget :: Test
+taoLinearDoesNotUseTarget = TestCase $ do
+    let n = 40 :: Int
+        as = [fromIntegral (i `div` 4) :: Double | i <- [0 .. n - 1]]
+        bs = [fromIntegral (i `mod` 3) :: Double | i <- [0 .. n - 1]]
+        targets = [if even i then 1.0 else 0.0 :: Double | i <- [0 .. n - 1]]
+        df =
+            D.fromNamedColumns
+                [ ("target", DI.fromList targets)
+                , ("a", DI.fromList as)
+                , ("b", DI.fromList bs)
+                ]
+        cfg =
+            defaultTreeConfig
+                { maxTreeDepth = 1
+                , taoIterations = 10
+                , minLeafSize = 1
+                , useLinearSolver = True
+                , minCarePointsForLinear = 2
+                }
+        result = fitDecisionTree @Double cfg (Col "target") df
+        rootCols = getColumns result
+    assertBool
+        ("target must not appear in the fitted Expr (got " ++ show result ++ ")")
+        ("target" `notElem` rootCols)
+
 taoLinearDeterministic :: Test
 taoLinearDeterministic = TestCase $ do
     let cfg =
@@ -945,13 +945,6 @@
         "skipping linear solver yields same expression as linear-off baseline"
         (eqExpr result resultOff)
 
-------------------------------------------------------------------------
--- Categorical-condition generator tests (Phase 1-2 of the plan)
-------------------------------------------------------------------------
-
--- A binary-target DataFrame with a 5-level Text column whose levels have
--- monotonically-increasing positive rates. Breiman's algorithm should
--- enumerate the 4 contiguous-prefix splits in that exact rate order.
 breimanBinaryDF :: D.DataFrame
 breimanBinaryDF =
     let n = 100 :: Int
@@ -1085,11 +1078,6 @@
         feats' = filter (\c -> feat `elem` getColumns c) conds
     assertEqual "Breiman prefixes on nullable column ignore nulls" 2 (length feats')
 
-------------------------------------------------------------------------
--- PR 2 extended: threshold-consolidation rewrite in combineAndVec /
--- combineOrVec. Positive cases, negative cases, semantic-preservation check.
-------------------------------------------------------------------------
-
 -- A small synthetic DataFrame to materialize CondVecs against.
 threshFixtureDF :: D.DataFrame
 threshFixtureDF =
@@ -1329,6 +1317,7 @@
     , TestLabel "C5 taoMonotoneWithLinear" taoMonotoneWithLinear
     , TestLabel "C6 taoLinearVsDiscreteCompetition" taoLinearVsDiscreteCompetition
     , TestLabel "C8 taoLinearProducesSparsity" taoLinearProducesSparsity
+    , TestLabel "C8b taoLinearDoesNotUseTarget" taoLinearDoesNotUseTarget
     , TestLabel "C9 taoLinearDeterministic" taoLinearDeterministic
     , TestLabel "D1 taoLinearTinyCareSet" taoLinearTinyCareSet
     , TestLabel "E1 categoricalBreimanBinary" testCategoricalBreimanBinary
