R/linad.R

Defines functions linad_sweep linad_gram_loss linad_gram_solve linad_settings linad_unscale linad_scaling linad_design_matrix linad_select_leaves linad_size_curve linad_raw_prediction linad_route linad_check_tree linad_selected_nodes linad_frame linad_fit linad_steps linad_expand linad_propose linad_improves linad_split_search linad_min_child_cases linad_sample_features linad_node_loss linad_child linad_line_search linad_goes_left linad_stump linad_slope_gain linad_roles linad_scan_features linad_context linad_cut_positions linad_gradient linad_loss linad_solve linad_slopes linad_node_test linad_constant_from_sums linad_constant linad_baseline linad_forward linad_enet_gram linad_ridge_edf linad_ridge_diagonal linad_chol_solve linad_active_rows linad_gram

# linad.R
# ::rtemis::
# 2026- EDG rtemis.org

# The LINAD engine: a decision tree whose leaves carry linear models, grown by
# stagewise gradient descent so that a leaf's coefficients are the accumulated
# sum of every model on its root-to-leaf path.
#
# Reference: Gennatas, Friedman, Eaton, Simone, Ungar, Xing, Valdes,
# "The Linear Additive Tree". Equation and algorithm numbers below cite that
# manuscript.
#
# Everything here is plain numeric code over matrices, deliberately: the three
# primitives `linad_gram()`, `linad_solve()` and `linad_stump()` are the only
# places that touch the data in bulk, so a compiled kernel can replace them
# without the growth loop above them changing.

# %% LINAD_NUGGET ----
# Relative ridge added to every Gram diagonal before factorization. Leaves with
# a constant column are common, so the solve must survive rank deficiency; at
# 1e-9 of the mean diagonal the ridge moves a well-conditioned answer by far
# less than the convergence tolerance of an iterative solver.
LINAD_NUGGET <- 1e-9


# %% LINAD_WEIGHT_TOLERANCE ----
# A case whose soft weight has decayed below this fraction of a node's largest
# weight is dropped from that node's leaf-model fit. Under `gamma`, weight
# decays as `gamma^depth`, so this is what stops a deep node from carrying the
# whole dataset for the sake of contributions no double can represent. At
# `gamma = 0` it excludes exactly the node's non-members, so the hard partition
# is a special case of the same code path.
LINAD_WEIGHT_TOLERANCE <- 1e-8


# %% LINAD_BASELINE_MAX ----
# Largest margin `linad_baseline()` will return for a single-class outcome, where
# the minimizing constant is unbounded.
LINAD_BASELINE_MAX <- 10


# %% linad_gram ----
#' Weighted Gram matrix and cross-product
#'
#' `G = X'WX` and `Xty = X'Wy` over the rows in `idx`, the sufficient statistics
#' for every weighted least-squares fit LINAD performs.
#'
#' Subsetting rows, where zero-weighting would keep them, makes a deep node
#' cheap: a leaf holding 30 of 1000 cases costs 30 rows, not 1000.
#'
#' `sg` and `sh` are the weighted first and second derivative totals Equation 20
#' needs. They are sums over rows like every other quantity here, so the
#' exhaustive search can accumulate them incrementally and build the same
#' constant `linad_constant()` gives the commit.
#'
#' @param xm Numeric matrix: Intercept-augmented design matrix.
#' @param y Numeric vector: Target, length `nrow(xm)`.
#' @param w Numeric vector: Case weights, length `nrow(xm)`.
#' @param idx Optional Integer vector: Rows to include. NULL uses every row.
#' @param derivatives Optional List: `linad_gradient()` output. When given, the
#' weighted derivative totals are accumulated alongside.
#'
#' @return List with `G`, `Xty`, `sw` (sum of weights) and `syy` (weighted sum
#' of squared `y`), plus `sg` and `sh` when `derivatives` is given.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_gram <- function(xm, y, w, idx = NULL, derivatives = NULL) {
  if (!is.null(idx)) {
    xm <- xm[idx, , drop = FALSE]
    y <- y[idx]
    w <- w[idx]
    if (!is.null(derivatives)) {
      derivatives <- list(
        g = derivatives[["g"]][idx],
        h = derivatives[["h"]][idx]
      )
    }
  }
  xw <- xm * w
  out <- list(
    G = crossprod(xw, xm),
    Xty = drop(crossprod(xw, y)),
    sw = sum(w),
    syy = sum(w * y * y)
  )
  if (!is.null(derivatives)) {
    out[["sg"]] <- sum(w * derivatives[["g"]])
    out[["sh"]] <- sum(w * derivatives[["h"]])
  }
  out
} # /rtemis::linad_gram


# %% linad_active_rows ----
#' The rows a node fits on
#'
#' A case whose soft weight has decayed past `LINAD_WEIGHT_TOLERANCE` of the
#' node's largest contributes nothing a double can represent; dropping it keeps
#' a deep node's fit proportional to its own size.
#'
#' Computed once per node, from the **node's** weights, and handed to the split
#' search and to the commit alike, so the search scores a model fitted on the
#' same rows the commit uses.
#'
#' At `gamma = 0` a node's weight vector is already exactly zero off its own
#' cases, so this returns the node's members and the hard partition stays a
#' special case.
#'
#' @param w Numeric vector: The node's weights.
#'
#' @return Integer vector of row indices.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_active_rows <- function(w) {
  largest <- max(w)
  if (!is.finite(largest) || largest <= 0) {
    return(seq_along(w))
  }
  which(w > LINAD_WEIGHT_TOLERANCE * largest)
} # /rtemis::linad_active_rows


# %% linad_chol_solve ----
#' Solve a penalized normal-equation system by Cholesky
#'
#' The intercept, when there is one, is column 1 and is never penalized.
#'
#' @param G Numeric matrix: `X'WX`.
#' @param Xty Numeric vector: `X'Wy`.
#' @param penalty Numeric scalar: Ridge penalty already scaled to the weight
#' total, so it is comparable across nodes of different size.
#' @param intercept Logical: If TRUE, column 1 is an intercept and is left
#' unpenalized. FALSE for a centered, intercept-free design, where every column
#' is a slope and all of them are penalized.
#'
#' @return Numeric vector of coefficients, or NULL if the system is singular.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_chol_solve <- function(G, Xty, penalty, intercept = TRUE) {
  diag(G) <- diag(G) + linad_ridge_diagonal(G, penalty, intercept)
  chol_factor <- tryCatch(chol(G), error = function(e) NULL)
  if (is.null(chol_factor)) {
    return(NULL)
  }
  drop(backsolve(chol_factor, backsolve(chol_factor, Xty, transpose = TRUE)))
} # /rtemis::linad_chol_solve


# %% linad_ridge_diagonal ----
#' The diagonal a penalized solve adds to a Gram
#'
#' A nugget proportional to the trace keeps a rank-deficient node solvable (a
#' constant column inside a leaf is ordinary, not an error) without perceptibly
#' moving a well-conditioned solve. The intercept is never penalized: it carries
#' the level.
#'
#' Both the solve and the degrees-of-freedom count read the penalty from here,
#' so the two cannot describe different models.
#'
#' @param G Numeric matrix: `X'WX`.
#' @param penalty Numeric: Ridge penalty.
#' @param intercept Logical: Whether column 1 is the intercept.
#'
#' @return Numeric vector of length `ncol(G)`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_ridge_diagonal <- function(G, penalty, intercept = TRUE) {
  d <- ncol(G)
  # `penalty` is a scalar for a whole-design solve and one value per column when
  # forward selection penalizes an active set, so the length is set explicitly.
  ridge <- rep_len(penalty, d)
  if (intercept) {
    ridge[[1L]] <- 0
  }
  ridge + LINAD_NUGGET * max(mean(diag(G)), .Machine[["double.eps"]])
} # /rtemis::linad_ridge_diagonal


# %% linad_ridge_edf ----
#' Effective degrees of freedom of a penalized solve
#'
#' `tr((G + D)^-1 G)`, written as `d - sum_j D_jj [(G + D)^-1]_jj` so one
#' Cholesky answers it. Shrinkage means a ridge fit spends fewer parameters
#' than it has coefficients, and its nonzero count -- always the full width,
#' since ridge shrinks but never zeroes -- would charge it for parameters it is
#' not using. At 117 features and lambda 0.6 that difference decides whether a
#' node can ever keep a linear model.
#'
#' @param G Numeric matrix: `X'WX`.
#' @param penalty Numeric: Ridge penalty.
#' @param intercept Logical: Whether column 1 is the intercept.
#'
#' @return Numeric scalar in `[0, ncol(G)]`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_ridge_edf <- function(G, penalty, intercept = TRUE) {
  d <- ncol(G)
  ridge <- linad_ridge_diagonal(G, penalty, intercept)
  diag(G) <- diag(G) + ridge
  chol_factor <- tryCatch(chol(G), error = function(e) NULL)
  if (is.null(chol_factor)) {
    return(d)
  }
  min(max(d - sum(ridge * diag(chol2inv(chol_factor))), 0), d)
} # /rtemis::linad_ridge_edf


# %% linad_enet_gram ----
#' Elastic-net slopes from sufficient statistics
#'
#' Cyclic coordinate descent on the normal equations, minimizing
#'
#' ```
#' (1 / (2 sw)) sum_i w_i (r_i - x_i'b)^2
#'   + lambda (alpha |b|_1 + (1 - alpha) |b|^2 / 2)
#' ```
#'
#' which in Gram form needs only `S = X'WX` and `Sxr = X'Wr`. The design is
#' centered and intercept-free, so there is no level to profile out and no
#' column standardization: the growth loop already standardizes globally.
#'
#' Written natively because the exhaustive search fits a child model per
#' candidate, thousands of times per node, which a coordinate-descent package
#' call per fit cannot sustain. One implementation reached from both routes
#' makes the search and the commit agree: both score and build the same model,
#' `alpha` included.
#'
#' At `alpha = 0` the update reduces to `(S + lambda sw I) b = Sxr`, which is
#' exactly what `linad_chol_solve()` solves, so the elastic net and the ridge
#' agree at the boundary by construction.
#'
#' @param S Numeric matrix: `X'WX` on the centered, intercept-free design.
#' @param Sxr Numeric vector: `X'Wr` on the same design.
#' @param l2 Numeric: L2 penalty, already scaled to the weight total.
#' @param l1 Numeric: L1 penalty, on the same scale.
#' @param tolerance Numeric: Largest coefficient change that counts as
#' converged, relative to the columns' scale.
#' @param max_iterations Integer: Cap on full sweeps.
#'
#' @return Numeric vector of slopes, or NULL if the system is degenerate.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_enet_gram <- function(
  S,
  Sxr,
  l2,
  l1,
  tolerance = 1e-10,
  max_iterations = 1000L
) {
  d <- ncol(S)
  if (d == 0L) {
    return(numeric(0))
  }
  # The same nugget every other solve carries, so a rank-deficient node is
  # ordinary here too, and the L2 term is added on the diagonal exactly as the
  # ridge adds it.
  denominator <- diag(S) + linad_ridge_diagonal(S, l2, intercept = FALSE)
  if (any(!is.finite(denominator)) || any(denominator <= 0)) {
    return(NULL)
  }
  b <- numeric(d)
  # `Sxr - S b`, carried rather than recomputed: one coefficient changing
  # updates it in `O(d)` where reforming it costs `O(d^2)`.
  gradient <- Sxr
  own <- diag(S)
  for (iteration in seq_len(max_iterations)) {
    largest_change <- 0
    for (j in seq_len(d)) {
      # `Sxr_j - S[j, -j] b_{-j}`: the carried gradient with column `j`'s own
      # contribution added back, which is the quantity the soft threshold acts
      # on. The penalty enters only through the denominator.
      partial <- gradient[[j]] + own[[j]] * b[[j]]
      updated <- sign(partial) * max(abs(partial) - l1, 0) / denominator[[j]]
      change <- updated - b[[j]]
      if (change != 0) {
        gradient <- gradient - change * S[, j]
        b[[j]] <- updated
        largest_change <- max(largest_change, abs(change))
      }
    }
    if (largest_change <= tolerance * max(1, max(abs(b)))) {
      break
    }
  }
  if (!all(is.finite(b))) {
    return(NULL)
  }
  b
} # /rtemis::linad_enet_gram


# %% linad_forward ----
#' Forward stepwise selection from a Gram matrix
#'
#' Adds `nvmax` features one at a time, each time the one that reduces the
#' weighted residual sum of squares most. The reduction from adding feature `j`
#' to an active set `A` is `g_j^2 / s_j`, where `g` is the gradient
#' `Xty - G[, A] b_A` and `s_j` the Schur complement
#' `G[j, j] - G[j, A] G[A, A]^-1 G[A, j]` -- both readable straight off the
#' Gram, so no candidate is ever refit.
#'
#' With `intercept = TRUE` column 1 is always active and is not counted against
#' `nvmax`. With FALSE the search starts from nothing, which on a design centered
#' by its weighted means selects the same features and the same coefficients --
#' the gain `g_j^2 / s_j` is invariant to a column rescaling and to the level.
#'
#' `nvmax` is a term count, not an upper bound: the search takes exactly that
#' many steps unless it runs out of features that improve the fit. That is the
#' behavior the published experiments were run under.
#'
#' @param G Numeric matrix: `X'WX`.
#' @param Xty Numeric vector: `X'Wy`.
#' @param nvmax Integer: Number of features to select beside the intercept.
#' @param intercept Logical: If TRUE, column 1 is an always-active intercept.
#'
#' @return Numeric vector of coefficients, zero outside the selected set, or
#' NULL if even the starting system is singular.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_forward <- function(
  G,
  Xty,
  nvmax,
  intercept = TRUE,
  penalty = 0,
  syy = NULL,
  sample_weight = NULL,
  stop_rule = "none"
) {
  d <- ncol(G)
  coefficients <- rep(0, d)
  active <- if (intercept) 1L else integer(0)
  diagonal <- diag(G)
  nugget <- LINAD_NUGGET * max(mean(diagonal), .Machine[["double.eps"]])
  n_steps <- min(as.integer(nvmax), d - length(active))
  # A per-term cost is what makes `nvmax` a ceiling rather than a quota. Ridge
  # shrinks but never zeroes, so a penalized gain alone would still spend the
  # whole budget: every added term reduces the residual sum of squares by
  # something positive.
  cost <- switch(
    stop_rule,
    none = NULL,
    aic = 2,
    bic = if (is.null(sample_weight) || sample_weight <= 1) {
      NULL
    } else {
      log(sample_weight)
    },
    NULL
  )
  testing <- !is.null(cost) && !is.null(syy) && !is.null(sample_weight)
  penalties <- function(index) {
    # The intercept is never penalized: it carries the level, and shrinking it
    # would pull the fit toward zero rather than toward the mean.
    out <- rep(penalty, length(index))
    if (intercept) {
      out[index == 1L] <- 0
    }
    out
  }
  solve_active <- function(index) {
    if (length(index) == 0L) {
      return(numeric(0))
    }
    linad_chol_solve(
      G[index, index, drop = FALSE],
      Xty[index],
      penalties(index),
      intercept = intercept
    )
  }
  residual_sum_squares <- function(index, solved) {
    if (is.null(syy)) {
      return(NA_real_)
    }
    if (length(index) == 0L) {
      return(syy)
    }
    linad_gram_loss(
      G[index, index, drop = FALSE],
      Xty[index],
      syy,
      solved
    )
  }

  current <- NULL
  for (step in seq_len(n_steps + 1L)) {
    solved <- solve_active(active)
    if (is.null(solved)) {
      return(NULL)
    }
    coefficients[] <- 0
    coefficients[active] <- solved
    current <- residual_sum_squares(active, solved)
    if (step > n_steps) {
      break
    }
    gradient <- if (length(active) > 0L) {
      Xty - drop(G[, active, drop = FALSE] %*% solved)
    } else {
      Xty
    }
    candidates <- setdiff(seq_len(d), active)
    chol_active <- if (length(active) > 0L) {
      chol(
        G[active, active, drop = FALSE] +
          diag(nugget + penalties(active), length(active))
      )
    } else {
      NULL
    }
    # Selection under the objective the fit uses: the same penalty enters the
    # Schur complement, so a feature that only looks good unpenalized does not
    # win the search.
    # Every candidate's Schur complement from one triangular solve over their
    # columns together, rather than one solve each: the matrices are the size of
    # the active set, so per-call overhead otherwise dwarfs the arithmetic.
    schur <- if (is.null(chol_active)) {
      diagonal[candidates]
    } else {
      projected <- backsolve(
        chol_active,
        G[active, candidates, drop = FALSE],
        transpose = TRUE
      )
      diagonal[candidates] - colSums(projected^2)
    }
    schur <- schur + penalty
    gain <- gradient[candidates]^2 / schur
    gain[!is.finite(schur) | schur <= nugget] <- -Inf
    best <- which.max(gain)
    if (length(best) == 0L || !is.finite(gain[[best]]) || gain[[best]] <= 0) {
      break
    }
    proposed <- c(active, candidates[[best]])
    if (testing) {
      trial <- solve_active(proposed)
      if (is.null(trial)) {
        break
      }
      improved <- residual_sum_squares(proposed, trial)
      # The Gaussian information criterion, in the form that needs only the two
      # residual sums of squares: a term is kept when the deviance it buys
      # exceeds what it costs.
      if (
        !is.finite(improved) ||
          improved <= 0 ||
          !is.finite(current) ||
          current <= 0 ||
          sample_weight * log(current / improved) <= cost
      ) {
        break
      }
    }
    active <- proposed
  }
  coefficients
} # /rtemis::linad_forward


# %% linad_baseline ----
#' The constant that alone minimizes the loss
#'
#' Equation 3's `F_0 = argmin_c sum L(y_i, c)`: the weighted mean for squared
#' error, and half the log odds for the exponential-family logistic loss on
#' `{-1, +1}`, which is where `sum w y / (1 + exp(2yc))` vanishes.
#'
#' This is what `root_learning_rate` shrinks the root model *towards*, so
#' shrinkage moves predictions towards the outcome's center, wherever it lies.
#'
#' @param y Numeric vector: Outcome; `{-1, +1}` for classification.
#' @param w Numeric vector: Case weights.
#' @param type Character: "Regression" or "Classification".
#'
#' @return Numeric scalar.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_baseline <- function(y, w, type) {
  if (!identical(type, "Classification")) {
    return(stats::weighted.mean(y, w))
  }
  positive <- sum(w[y > 0])
  negative <- sum(w[y < 0])
  if (positive <= 0 || negative <= 0) {
    # One class only: the minimizing constant is unbounded, so stop at the
    # largest margin the line search is allowed to take anyway.
    return(sign(positive - negative) * LINAD_BASELINE_MAX)
  }
  0.5 * log(positive / negative)
} # /rtemis::linad_baseline


# %% linad_constant ----
#' The node's constant, by the manuscript's closed forms
#'
#' Equation 19 for squared error, with the `sum(p)` denominator the manuscript
#' drops -- a weighted *mean* of the residual, not a weighted sum. Equation 20
#' for classification, where the loss has no closed form and the constant is one
#' Newton step, the ratio of the weighted first to the weighted second
#' derivative.
#'
#' `constant_rule = "least_squares"` takes the weighted mean in both cases,
#' which is what an intercept-only least-squares fit returns. The two performed
#' alike in benchmarks (mean holdout AUC 0.8714 and 0.8712 over 288
#' classification fits), and both are available.
#'
#' This is the tree's own contribution at the node. Everything the linear model
#' adds is a slope, so the two parts of the fit stay separable all the way to
#' the plot.
#'
#' @param r Numeric vector: Negative gradient, the target the node is fitting.
#' @param derivatives List: `linad_gradient()` output at the parent's function
#' value, needed for Eq 20 and ignored for Eq 19.
#' @param w Numeric vector: Weights.
#' @param idx Integer vector: Rows this node fits on.
#' @param type Character: "Regression" or "Classification".
#' @param max_step Numeric: Bound on the absolute constant, as for the line
#' search: a node whose second derivative nearly vanishes would otherwise take
#' an unbounded step.
#' @param rule Character: "closed_form" for Eqs 19/20, or "least_squares" for
#' the weighted mean of the residual in both outcome types. They coincide for a
#' regression, so this only bites for a classification.
#'
#' @return Numeric scalar.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_constant <- function(
  r,
  derivatives,
  w,
  idx,
  type,
  max_step,
  rule = "closed_form"
) {
  linad_constant_from_sums(
    swr = sum(w[idx] * r[idx]),
    sw = sum(w[idx]),
    sg = if (is.null(derivatives)) {
      NULL
    } else {
      sum(w[idx] * derivatives[["g"]][idx])
    },
    sh = if (is.null(derivatives)) {
      NULL
    } else {
      sum(w[idx] * derivatives[["h"]][idx])
    },
    type = type,
    max_step = max_step,
    rule = rule
  )
} # /rtemis::linad_constant


# %% linad_constant_from_sums ----
#' The node's constant, from weighted totals alone
#'
#' Equations 19 and 20 need four weighted sums and nothing else, so the rule
#' lives here and both routes to it -- `linad_constant()` over explicit rows and
#' `linad_gram_solve()` over accumulated sufficient statistics -- reach the same
#' function. Two implementations agreeing is precisely what the exhaustive
#' search has been caught not doing.
#'
#' @param swr Numeric: `sum(w r)`.
#' @param sw Numeric: `sum(w)`.
#' @param sg Optional Numeric: `sum(w g)`, the weighted first derivative total.
#' @param sh Optional Numeric: `sum(w h)`, the weighted second derivative total.
#' @param type Character: "Regression" or "Classification".
#' @param max_step Numeric: Bound on the absolute constant.
#' @param rule Character: "closed_form" for Eqs 19/20, or "least_squares" for
#' the weighted mean of the residual in both outcome types.
#'
#' @return Numeric scalar.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_constant_from_sums <- function(
  swr,
  sw,
  sg,
  sh,
  type,
  max_step,
  rule = "closed_form"
) {
  if (!is.finite(sw) || sw <= 0) {
    return(0)
  }
  # Eq 19, and the whole of the `least_squares` rule: the weighted mean of the
  # residual is also what an intercept-only least-squares fit returns, so the
  # two rules coincide for a regression and differ only for a classification.
  if (
    !identical(type, "Classification") ||
      identical(rule, "least_squares") ||
      is.null(sg) ||
      is.null(sh)
  ) {
    return(swr / sw)
  }
  # Eq 20.
  if (!is.finite(sh) || sh <= 0) {
    return(sign(-sg) * min(max_step, LINAD_BASELINE_MAX))
  }
  step <- -sg / sh
  if (!is.finite(step)) {
    return(0)
  }
  sign(step) * min(abs(step), max_step)
} # /rtemis::linad_constant_from_sums


# %% linad_node_test ----
#' Whether a node's slopes earn their coefficients
#'
#' The constant is nested in the linear model, so on the node's own cases the
#' slopes always reduce the residual sum of squares and the comparison is
#' meaningless without a cost. This is the Gaussian information criterion in
#' the form that needs only the two sums of squares, charging `n_terms` at the
#' rule's per-term rate.
#'
#' Both split searches and the commit call this, so a candidate is scored by
#' the model the node will actually receive.
#'
#' @param rss_constant Numeric: Weighted residual sum of squares of the
#' constant-only fit.
#' @param rss_linear Numeric: The same after the slopes.
#' @param sw Numeric: Total weight carried by the fit.
#' @param n_terms Numeric: Parameters the slopes spend -- their count where a
#' fit zeroes what it does not use, and the effective degrees of freedom where
#' it shrinks instead.
#' @param rule Character: "none", "aic" or "bic".
#'
#' @return Logical: TRUE to keep the slopes.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_node_test <- function(rss_constant, rss_linear, sw, n_terms, rule) {
  if (identical(rule, "none") || n_terms <= 0) {
    return(TRUE)
  }
  per_term <- switch(rule, aic = 2, bic = log(sw), NA_real_)
  if (!is.finite(per_term) || per_term <= 0) {
    return(TRUE)
  }
  if (!is.finite(rss_constant) || rss_constant <= 0 || !is.finite(rss_linear)) {
    return(FALSE)
  }
  sw * log(rss_constant / max(rss_linear, .Machine[["double.xmin"]])) >
    per_term * n_terms
} # /rtemis::linad_node_test


# %% linad_slopes ----
#' Fit a node's slopes from centered sufficient statistics
#'
#' The single slope fitter. `linad_solve()` reaches it with statistics formed
#' from the node's rows and `linad_gram_solve()` with statistics the exhaustive
#' search accumulated, so a candidate is scored by the model the node will
#' receive.
#'
#' The design is centered on the node's weighted column means and carries no
#' intercept: the node's constant already holds the level, and on a centered
#' design the intercept-free slopes are the joint-fit slopes exactly.
#'
#' `allowed` is how effect scope reaches the model. A column outside it is
#' never offered to the fit and keeps a coefficient of zero, so the returned
#' vector is full width whatever the restriction and everything downstream --
#' accumulation, prediction, the coefficient tables -- is untouched.
#'
#' @param S Numeric matrix: `X'WX` on the centered, intercept-free design.
#' @param Sxr Numeric vector: `X'Wr` on the same design.
#' @param syy Numeric: `sum(w r^2)` for the target the slopes fit, that is,
#' after the constant is removed.
#' @param sw Numeric: Sum of weights.
#' @param node_model Character: "forward", "ridge" or "elasticnet".
#' @param lambda Numeric: Penalty.
#' @param alpha Numeric: Elastic-net mixing.
#' @param nvmax Integer: Forward-selection term count.
#' @param forward_stop Character: Forward selection's per-term cost rule.
#' @param node_test Character: Cost the slopes must earn over the constant.
#' @param allowed Optional Integer vector: Slope columns this node may fit.
#' NULL allows every column.
#'
#' @return Numeric vector of length `ncol(S)`, zero outside `allowed`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_slopes <- function(
  S,
  Sxr,
  syy,
  sw,
  node_model,
  lambda,
  alpha,
  nvmax,
  forward_stop = "none",
  node_test = "none",
  allowed = NULL
) {
  d <- ncol(S)
  slopes <- rep(0, d)
  columns <- if (is.null(allowed)) seq_len(d) else allowed
  if (d == 0L || length(columns) == 0L) {
    return(slopes)
  }
  sub_S <- S[columns, columns, drop = FALSE]
  sub_Sxr <- Sxr[columns]
  fitted <- if (identical(node_model, "forward")) {
    linad_forward(
      sub_S,
      sub_Sxr,
      nvmax,
      intercept = FALSE,
      penalty = lambda * sw,
      syy = syy,
      sample_weight = sw,
      stop_rule = forward_stop
    )
  } else if (identical(node_model, "elasticnet")) {
    linad_enet_gram(
      sub_S,
      sub_Sxr,
      lambda * sw * (1 - alpha),
      lambda * sw * alpha
    )
  } else {
    # "ridge". Scaling by the weight total matches the elastic net's objective,
    # which divides the residual sum of squares by it, so one `lambda` means the
    # same thing in a node of 30 cases and a node of 300.
    linad_chol_solve(sub_S, sub_Sxr, lambda * sw, intercept = FALSE)
  }
  if (is.null(fitted) || !all(is.finite(fitted))) {
    return(slopes)
  }
  if (!identical(node_test, "none") && any(fitted != 0)) {
    n_terms <- if (identical(node_model, "ridge")) {
      linad_ridge_edf(sub_S, lambda * sw, intercept = FALSE)
    } else {
      sum(fitted != 0)
    }
    keep <- linad_node_test(
      syy,
      linad_gram_loss(sub_S, sub_Sxr, syy, fitted),
      sw,
      n_terms,
      node_test
    )
    if (!keep) {
      return(slopes)
    }
  }
  slopes[columns] <- fitted
  slopes
} # /rtemis::linad_slopes


# %% linad_solve ----
#' Fit one leaf model, as a constant plus slopes
#'
#' The single entry point for every linear model LINAD fits, at the root and at
#' every node. `node_model` names both the fitting procedure and its
#' regularization, as one flat choice -- the schema's `applies_when` gates
#' cannot chain, and it matches the manuscript's own `lin.type` vocabulary.
#'
#' The update is split in two. The **constant** is the node's own value by
#' Eq 19/20; the **slopes** are then fitted without an intercept to what the
#' constant leaves behind, on a design centered by the node's *weighted* column
#' means. That centering is what makes the split exact: on a centered design the
#' intercept-free fit returns the same coefficients as fitting an intercept
#' jointly, for ridge at any `lambda` and for forward selection. Without it the
#' slopes absorb part of the level and the fit is worse.
#'
#' Separating them is the point. The constant is what the *tree* contributes at
#' this node and accumulates into `node_value`; the slopes are what the *linear
#' model* adds. `"constant"` simply fits no slopes, which is what reduces LINAD
#' to the Additive Tree -- the manuscript notes the software "allows to skip
#' fitting of the linear models".
#'
#' @param xm Numeric matrix: Intercept-augmented design matrix.
#' @param y Numeric vector: Target -- the pseudo-residual, except at the root.
#' @param w Numeric vector: Case weights.
#' @param idx Integer vector: Rows this node fits on.
#' @param node_model Character: "forward", "ridge", "elasticnet" or "constant".
#' @param lambda Numeric: Ridge/elastic-net penalty.
#' @param alpha Numeric: Elastic-net mixing.
#' @param nvmax Integer: Forward-selection term count.
#' @param derivatives List: `linad_gradient()` output, for Eq 20.
#' @param type Character: "Regression" or "Classification".
#' @param max_step Numeric: Bound on the constant.
#' @param constant_rule Character: Which rule computes the constant.
#' @param node_test Character: Cost a node's slopes must earn over the constant
#' alone, as `linad_node_test()` applies it.
#' @param allowed Optional Integer vector: Slope columns this node may fit,
#' indexing the design without its intercept. NULL allows every column.
#'
#' @return List with `coefficients` (length `ncol(xm)`, column 1 the effective
#' intercept in the node's own coordinates) and `constant` (the node's value),
#' or NULL when the node cannot be fit and the caller should treat the update as
#' zero.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_solve <- function(
  xm,
  y,
  w,
  idx,
  node_model,
  lambda = 0.05,
  forward_stop = "none",
  alpha = 1,
  nvmax = 3L,
  derivatives = NULL,
  type = "Regression",
  max_step = 1000,
  constant_rule = "closed_form",
  node_test = "none",
  allowed = NULL
) {
  if (length(idx) == 0L || sum(w[idx]) <= 0) {
    return(NULL)
  }
  constant <- linad_constant(
    y,
    derivatives,
    w,
    idx,
    type,
    max_step,
    constant_rule
  )
  d <- ncol(xm)
  if (identical(node_model, "constant")) {
    coefficients <- rep(0, d)
    coefficients[[1L]] <- constant
    return(list(coefficients = coefficients, constant = constant))
  }

  # Slopes, fitted to what the constant leaves, on a design centered by this
  # node's weighted means. Column 1 is the intercept and is dropped: the
  # constant already carries the level.
  slope_columns <- seq.int(2L, d)
  center <- drop(crossprod(w[idx], xm[idx, slope_columns, drop = FALSE])) /
    sum(w[idx])
  centered <- sweep(xm[, slope_columns, drop = FALSE], 2L, center, "-")
  gram <- linad_gram(cbind(centered), y - constant, w, idx)
  slopes <- linad_slopes(
    gram[["G"]],
    gram[["Xty"]],
    gram[["syy"]],
    gram[["sw"]],
    node_model,
    lambda,
    alpha,
    nvmax,
    forward_stop = forward_stop,
    node_test = node_test,
    allowed = allowed
  )
  # Back to the node's coordinates: the centered fit's level is `constant`, so
  # the effective intercept absorbs what centering removed.
  coefficients <- c(constant - sum(center * slopes), slopes)
  list(coefficients = coefficients, constant = constant)
} # /rtemis::linad_solve


# %% linad_loss ----
#' Per-case loss
#'
#' Regression is half squared error, so its gradient is the plain residual.
#' Classification is the exponential-family logistic loss on `y` in `{-1, +1}`,
#' the parameterization the Additive Tree line of work uses.
#'
#' @param y Numeric vector: Outcome, `{-1, +1}` for classification.
#' @param f Numeric vector: Current function value.
#' @param type Character: "Regression" or "Classification".
#'
#' @return Numeric vector of per-case losses.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_loss <- function(y, f, type) {
  if (identical(type, "Classification")) {
    # log(1 + exp(-2yF)), written so a large negative margin does not overflow.
    margin <- -2 * y * f
    ifelse(margin > 30, margin, log1p(exp(pmin(margin, 30))))
  } else {
    0.5 * (y - f)^2
  }
} # /rtemis::linad_loss


# %% linad_gradient ----
#' First and second derivatives of the loss with respect to the function value
#'
#' Taken with respect to the **accumulated** `f`, which is what Eqs 13 and 26
#' specify; Algorithm 1 lines 20 and 25 write the node's own local model
#' instead, and the two coincide only at the root.
#'
#' @param y Numeric vector: Outcome.
#' @param f Numeric vector: Current function value.
#' @param type Character: "Regression" or "Classification".
#'
#' @return List with `g` (gradient) and `h` (second derivative).
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_gradient <- function(y, f, type) {
  if (identical(type, "Classification")) {
    g <- -2 * y / (1 + exp(pmin(2 * y * f, 30)))
    list(g = g, h = abs(g) * (2 - abs(g)))
  } else {
    list(g = f - y, h = rep(1, length(y)))
  }
} # /rtemis::linad_gradient


# %% linad_cut_positions ----
#' Thin a feature's admissible split positions down to a fixed number
#'
#' Both searches need a way to consider `k` cut points rather than every
#' distinct value: the stump because scoring every value is wasted once the
#' feature is finely resolved, the exhaustive search because each candidate
#' costs a solve. Taking evenly spaced order statistics of the admissible
#' positions gives equal-frequency bins -- each candidate cut has roughly the
#' same number of cases on either side of it, which is what a histogram split
#' means here.
#'
#' `"frequency"` spaces the cuts evenly through the *cases*, so each candidate
#' has roughly the same number either side. `"width"` spaces them evenly through
#' the feature's *range*, which is the literal histogram and what a fixed grid
#' over the axis gives; on a skewed feature most of its bins can be near-empty.
#' The two differ only where a feature is not uniform, which is most of them.
#'
#' @param breaks Integer vector: Positions in sorted order after which the
#' feature's value changes.
#' @param n_cuts Integer: How many to keep.
#' @param sorted Optional Numeric vector: The feature's values in sorted order.
#' Required for `type = "width"`, which places cuts by value rather than by rank.
#' @param type Character: "frequency" or "width".
#'
#' @return Integer vector, sorted and unique, possibly shorter than `n_cuts`
#' when the feature has fewer distinct values than that.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_cut_positions <- function(
  breaks,
  n_cuts,
  sorted = NULL,
  type = "frequency"
) {
  if (length(breaks) <= n_cuts) {
    return(breaks)
  }
  if (identical(type, "width") && !is.null(sorted)) {
    # Thresholds, not the values below them, and spanning the feature's whole
    # observed range: taking the range of `sorted[breaks]` instead stops short
    # of the last break's right-hand value, so a sparse upper tail falls outside
    # every target.
    values <- (sorted[breaks] + sorted[breaks + 1L]) / 2
    targets <- seq(
      sorted[[1L]],
      sorted[[length(sorted)]],
      length.out = n_cuts + 2L
    )[-c(1L, n_cuts + 2L)]
    # The admissible position whose value sits closest to each evenly spaced
    # target. A feature with a gap contributes one position for every target
    # that falls in it, which `unique()` then collapses -- so an equal-width
    # search on a skewed feature really does try fewer cuts.
    chosen <- vapply(
      targets,
      function(target) breaks[[which.min(abs(values - target))]],
      integer(1L)
    )
    return(sort(unique(chosen)))
  }
  probabilities <- seq(0, 1, length.out = n_cuts + 2L)[-c(1L, n_cuts + 2L)]
  sort(unique(stats::quantile(
    breaks,
    probs = probabilities,
    names = FALSE,
    type = 1L
  )))
} # /rtemis::linad_cut_positions


# %% linad_context ----
#' Precompute everything about the features that does not change between nodes
#'
#' Sort orders and the positions where a feature's sorted values change are
#' properties of the training data, not of a node, so they are computed once and
#' reused at every split search. This is the single largest reason the split
#' search is not delegated to `rpart`, which re-sorts on every call.
#'
#' Splits are searched on the **original** features, so a factor splits on a set
#' of levels and the rule reads naturally, while the leaf models use the encoded
#' design matrix. The two indexings are kept separate: a factor's encoded
#' columns shift every later position, so an encoded index does not name the
#' original feature at the same position.
#'
#' @param x data.frame: Features, before encoding.
#' @param n_bins Optional Integer: Discretize each numeric feature into this
#' many bins and consider only bin boundaries as candidate splits. NULL
#' considers every distinct value.
#' @param bin_type Character: "frequency" or "width".
#'
#' @return List describing the numeric and factor features.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_context <- function(x, n_bins = NULL, bin_type = "frequency") {
  n <- nrow(x)
  is_factor <- vapply(x, is.factor, logical(1L))
  numeric_names <- names(x)[!is_factor]
  factor_names <- names(x)[is_factor]
  numeric_matrix <- if (length(numeric_names) > 0L) {
    as.matrix(as.data.frame(lapply(x[numeric_names], as.numeric)))
  } else {
    matrix(numeric(0), nrow = n, ncol = 0L)
  }
  numeric_order <- vector("list", length(numeric_names))
  numeric_breaks <- vector("list", length(numeric_names))
  for (j in seq_along(numeric_names)) {
    o <- order(numeric_matrix[, j])
    sorted <- numeric_matrix[o, j]
    numeric_order[[j]] <- o
    # Positions after which the value changes: the only places a split can go.
    admissible <- if (n > 1L) {
      which(sorted[-n] < sorted[-1L])
    } else {
      integer(0)
    }
    numeric_breaks[[j]] <- if (is.null(n_bins)) {
      admissible
    } else {
      linad_cut_positions(admissible, n_bins - 1L, sorted, bin_type)
    }
  }
  list(
    n = n,
    numeric_matrix = numeric_matrix,
    numeric_names = numeric_names,
    numeric_order = numeric_order,
    numeric_breaks = numeric_breaks,
    factor_codes = lapply(x[factor_names], as.integer),
    factor_levels = lapply(x[factor_names], levels),
    factor_names = factor_names
  )
} # /rtemis::linad_context


# %% linad_scan_features ----
#' Resolve which features a split search scans
#'
#' A restriction arrives as a list of two integer vectors, one indexing
#' `context[["numeric_names"]]` and one `context[["factor_names"]]`. The context, which
#' holds each feature's sort order and break positions, is built once and
#' shared by every node.
#'
#' @param context List: `linad_context()` output.
#' @param features Optional List: `numeric` and `factor` integer vectors.
#'
#' @return List of two integer vectors, named `numeric` and `factor`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_scan_features <- function(context, features = NULL) {
  if (is.null(features)) {
    list(
      numeric = seq_along(context[["numeric_names"]]),
      factor = seq_along(context[["factor_names"]])
    )
  } else {
    features
  }
} # /rtemis::linad_scan_features


# %% linad_roles ----
#' Resolve feature roles into the indices the engine works in
#'
#' Feature behavior has two independent axes: **split eligibility**, whether a
#' feature may define a partition, and **effect scope**, whether its modeled
#' effect is absent, shared by every subgroup, or allowed to change by node.
#' The public contract names features; everything below this line works in
#' column indices, and this is the one place the two meet.
#'
#' `NULL` means *no constraint imposed* for each of the three selectors, which
#' reads as "all" for splits and linear terms and "nothing pinned" for global
#' effects. Each returns NULL in turn, so an unrestricted run takes exactly the
#' code path it took before feature roles existed.
#'
#' A categorical's dummy columns share one `assign` code, so naming the factor
#' moves the whole encoded group together -- roles are assigned to source
#' features, never to unrelated indicator columns.
#'
#' Names are matched against the features present: `validate_hyperparameters()`
#' has already rejected names absent from the training data, and a LINADForest
#' tree holding a `mtry_tree` subset sees only some of them.
#'
#' @param context List: `linad_context()` output.
#' @param design_assign Integer vector: `attr(model.matrix(), "assign")`, one
#' entry per design column with 0 at the intercept.
#' @param xnames Character vector: Source feature names, in column order.
#' @param split_features Optional Character vector: Features that may split.
#' @param linear_features Optional Character vector: Features with a slope.
#' @param global_features Optional Character vector: Linear features whose
#' slope is shared by every leaf.
#'
#' @return List with `split` (a `linad_scan_features()` restriction or NULL),
#' `linear` and `adaptive` (slope-column indices or NULL).
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_roles <- function(
  context,
  design_assign,
  xnames,
  split_features = NULL,
  linear_features = NULL,
  global_features = NULL
) {
  split_columns <- if (is.null(split_features)) {
    NULL
  } else {
    list(
      numeric = which(context[["numeric_names"]] %in% split_features),
      factor = which(context[["factor_names"]] %in% split_features)
    )
  }
  # Slope columns are the design without its intercept, indexed from 1, which
  # is the coordinate system `linad_slopes()` works in.
  slope_feature <- xnames[design_assign[-1L]]
  linear_columns <- if (is.null(linear_features)) {
    NULL
  } else {
    which(slope_feature %in% linear_features)
  }
  adaptive_columns <- if (is.null(global_features)) {
    linear_columns
  } else {
    pool <- if (is.null(linear_columns)) {
      seq_along(slope_feature)
    } else {
      linear_columns
    }
    setdiff(pool, which(slope_feature %in% global_features))
  }
  list(
    split = split_columns,
    linear = linear_columns,
    adaptive = adaptive_columns
  )
} # /rtemis::linad_roles


# %% linad_slope_gain ----
#' What a side's slope in the split variable explains
#'
#' The sum of squares a weighted least-squares slope removes beyond the level,
#' `Sxy_c^2 / Sxx_c` on centered sufficient statistics. A side whose split
#' variable is constant has no slope to fit and explains nothing extra.
#'
#' @param sw Numeric: Sum of weights.
#' @param sx Numeric: Weighted sum of the split variable.
#' @param sxx Numeric: Weighted sum of its square.
#' @param sxy Numeric: Weighted sum of its product with the residual.
#' @param sy Numeric: Weighted sum of the residual.
#'
#' @return Numeric: Explained sum of squares, one per input element.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_slope_gain <- function(sw, sx, sxx, sxy, sy) {
  sxx_c <- sxx - sx * sx / sw
  sxy_c <- sxy - sx * sy / sw
  ifelse(
    sxx_c > .Machine[["double.eps"]] * pmax(1, abs(sxx)),
    sxy_c^2 / sxx_c,
    0
  )
} # /rtemis::linad_slope_gain


# %% linad_stump ----
#' Best weighted squared-error split over every feature
#'
#' The decision stump of Algorithm 1 line 21, fit to the negative gradient with
#' the node's weights. Minimizing the weighted residual sum of squares over a
#' binary partition is the same as maximizing
#' `SwrL^2 / SwL + SwrR^2 / SwR`, which one pass of cumulative sums evaluates at
#' every candidate cut at once.
#'
#' A factor's levels are ordered by their weighted mean residual and then
#' scanned the same way, which is exact for squared error over the
#' `2^(k-1) - 1` partitions.
#'
#' `min_cases_child` counts the node's own members, not all rows: under soft
#' weighting every case carries some weight everywhere, so a row count would not
#' mean what a user asking for a minimum leaf size means.
#'
#' @param context List: `linad_context()` output.
#' @param r Numeric vector: Negative gradient, length n.
#' @param w Numeric vector: Node weights, length n.
#' @param member Logical vector: Node membership, length n.
#' @param min_cases_child Integer: Minimum members either side of the split.
#' @param features Optional List: Which features to scan, as
#' `linad_scan_features()` describes. NULL scans every feature.
#' @param criterion Character: What each side's fit explains -- its level
#' ("mean") or its level and its slope in the split variable ("linear").
#'
#' @return List describing the split, or NULL when no admissible split exists.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_stump <- function(
  context,
  r,
  w,
  member,
  min_cases_child,
  features = NULL,
  criterion = "mean"
) {
  n <- context[["n"]]
  wr <- w * r
  membership <- as.numeric(member)
  best <- list(gain = -Inf)
  scan <- linad_scan_features(context, features)
  slopes <- identical(criterion, "linear")

  # Numeric features ----
  for (j in scan[["numeric"]]) {
    breaks <- context[["numeric_breaks"]][[j]]
    if (length(breaks) == 0L) {
      next
    }
    o <- context[["numeric_order"]][[j]]
    cum_w <- cumsum(w[o])
    cum_wr <- cumsum(wr[o])
    cum_member <- cumsum(membership[o])
    total_w <- cum_w[[n]]
    total_wr <- cum_wr[[n]]
    total_member <- cum_member[[n]]
    left_w <- cum_w[breaks]
    right_w <- total_w - left_w
    admissible <- cum_member[breaks] >= min_cases_child &
      (total_member - cum_member[breaks]) >= min_cases_child &
      left_w > 0 &
      right_w > 0
    if (!any(admissible)) {
      next
    }
    left_wr <- cum_wr[breaks]
    gain <- rep(-Inf, length(breaks))
    gain[admissible] <- left_wr[admissible]^2 /
      left_w[admissible] +
      (total_wr - left_wr[admissible])^2 / right_w[admissible]
    if (slopes) {
      xo <- context[["numeric_matrix"]][o, j]
      cum_wx <- cumsum(w[o] * xo)
      cum_wxx <- cumsum(w[o] * xo * xo)
      cum_wxr <- cumsum(wr[o] * xo)
      gain[admissible] <- gain[admissible] +
        linad_slope_gain(
          left_w[admissible],
          cum_wx[breaks][admissible],
          cum_wxx[breaks][admissible],
          cum_wxr[breaks][admissible],
          left_wr[admissible]
        ) +
        linad_slope_gain(
          right_w[admissible],
          cum_wx[[n]] - cum_wx[breaks][admissible],
          cum_wxx[[n]] - cum_wxx[breaks][admissible],
          cum_wxr[[n]] - cum_wxr[breaks][admissible],
          total_wr - left_wr[admissible]
        )
    }
    k <- which.max(gain)
    if (gain[[k]] > best[["gain"]]) {
      position <- breaks[[k]]
      sorted <- context[["numeric_matrix"]][o, j]
      best <- list(
        gain = gain[[k]],
        feature = context[["numeric_names"]][[j]],
        kind = "numeric",
        value = (sorted[[position]] + sorted[[position + 1L]]) / 2,
        levels = NULL,
        column = j
      )
    }
  }

  # Factor features ----
  for (j in scan[["factor"]]) {
    codes <- context[["factor_codes"]][[j]]
    levels_j <- context[["factor_levels"]][[j]]
    n_levels <- length(levels_j)
    if (n_levels < 2L) {
      next
    }
    level_w <- as.vector(rowsum(w, codes, reorder = TRUE))
    level_wr <- as.vector(rowsum(wr, codes, reorder = TRUE))
    level_member <- as.vector(rowsum(membership, codes, reorder = TRUE))
    present <- sort(unique(codes))
    keep <- level_w > 0
    if (sum(keep) < 2L) {
      next
    }
    # Ordering levels by weighted mean residual makes the best contiguous split
    # of that ordering the best of all level partitions, for squared error.
    ranking <- order(level_wr[keep] / level_w[keep])
    ordered_levels <- present[keep][ranking]
    cum_w <- cumsum(level_w[keep][ranking])
    cum_wr <- cumsum(level_wr[keep][ranking])
    cum_member <- cumsum(level_member[keep][ranking])
    m <- length(ordered_levels)
    total_w <- cum_w[[m]]
    total_wr <- cum_wr[[m]]
    total_member <- cum_member[[m]]
    cuts <- seq_len(m - 1L)
    left_w <- cum_w[cuts]
    right_w <- total_w - left_w
    admissible <- cum_member[cuts] >= min_cases_child &
      (total_member - cum_member[cuts]) >= min_cases_child &
      left_w > 0 &
      right_w > 0
    if (!any(admissible)) {
      next
    }
    left_wr <- cum_wr[cuts]
    gain <- rep(-Inf, length(cuts))
    gain[admissible] <- left_wr[admissible]^2 /
      left_w[admissible] +
      (total_wr - left_wr[admissible])^2 / right_w[admissible]
    k <- which.max(gain)
    if (gain[[k]] > best[["gain"]]) {
      best <- list(
        gain = gain[[k]],
        feature = context[["factor_names"]][[j]],
        kind = "factor",
        value = NA_real_,
        levels = levels_j[ordered_levels[seq_len(k)]],
        column = j
      )
    }
  }

  if (!is.finite(best[["gain"]])) {
    return(NULL)
  }
  best
} # /rtemis::linad_stump


# %% linad_goes_left ----
#' Which cases take the left branch at one split
#'
#' The single definition of the routing rule, used while growing, while
#' predicting, and while rebuilding a tree from its frame -- so training and
#' inference cannot drift apart.
#'
#' @param split List: A `linad_stump()` result, or a frame row carrying
#' `feature`, `kind`, `value` and `levels`.
#' @param x data.frame: Cases.
#'
#' @return Logical vector, one per case.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_goes_left <- function(split, x) {
  values <- x[[split[["feature"]]]]
  if (identical(split[["kind"]], "factor")) {
    as.character(values) %in% split[["levels"]]
  } else {
    as.numeric(values) < split[["value"]]
  }
} # /rtemis::linad_goes_left


# %% linad_line_search ----
#' Newton step size for one functional update
#'
#' Equation 27. One Newton step in `rho` for the update `f + rho * v`, over the
#' rows the update actually touches -- cases outside the node are unaffected, so
#' they cannot inform the step.
#'
#' For squared error the step is the exact minimizer, since the objective is
#' quadratic in `rho`.
#'
#' @param y Numeric vector: Outcome.
#' @param f Numeric vector: Current function value.
#' @param v Numeric vector: Update direction.
#' @param w Numeric vector: Case weights.
#' @param idx Integer vector: Rows the update applies to.
#' @param type Character: "Regression" or "Classification".
#' @param max_step Numeric: Bound on the absolute step.
#'
#' @return Numeric scalar.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_line_search <- function(y, f, v, w, idx, type, max_step) {
  derivatives <- linad_gradient(y[idx], f[idx], type)
  numerator <- sum(w[idx] * derivatives[["g"]] * v[idx])
  denominator <- sum(w[idx] * derivatives[["h"]] * v[idx]^2)
  if (!is.finite(denominator) || denominator <= 0) {
    return(0)
  }
  step <- -numerator / denominator
  if (!is.finite(step)) {
    return(0)
  }
  sign(step) * min(abs(step), max_step)
} # /rtemis::linad_line_search


# %% linad_child ----
#' Build one child of a node being expanded
#'
#' Algorithm 1 lines 23-28 and 32-34. The child's coefficients are the parent's
#' plus the shrunk, line-searched update, which is what makes a leaf's
#' coefficients the accumulated sum along its path.
#'
#' There is no separate stump constant. The manuscript's stump supplies the
#' partition only (Algorithm 1 line 21) and the leaf model, which carries its own
#' intercept, is the whole update; the legacy implementation added both and then
#' shrank them separately, which is where its fitted values and its stored
#' coefficients came apart for classification.
#'
#' @param state List: Fit state.
#' @param node List: Parent node.
#' @param r Numeric vector: Negative gradient at the parent's function value.
#' @param derivatives List: `linad_gradient()` at the parent's function value.
#' @param idx Integer vector: The child's member rows.
#' @param weights Numeric vector: The child's weights, after Eq 29.
#' @param active Integer vector: The rows the node fits on, as
#' `linad_active_rows()` resolves them from the parent's weights. The split
#' search scored this candidate over the same rows.
#'
#' @return List with the child's `index`, `weights`, `coef`, `depth`, the
#' un-shrunk update direction `v` and its coefficients, and the `constant` that
#' accumulates into `node_value`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_child <- function(state, node, r, derivatives, idx, weights, active) {
  fit <- NULL
  # A node below the linear model's floor is left to its parent. Splits already
  # respect that floor, so this fires only for a node the caller placed there.
  model <- if (
    identical(state[["node_model"]], "constant") ||
      length(idx) >= state[["min_cases_node_model"]]
  ) {
    state[["node_model"]]
  } else {
    NULL
  }
  # A residual with no variation is still fitted: the update is that constant
  # with zero slopes, which is what the solve returns. Degeneracy is handled
  # below, where a NULL or non-finite fit falls back to a zero update.
  if (!is.null(model) && length(idx) > 0L) {
    # Every case with weight left, not just the node's own. This is what gives
    # `gamma` its effect: at 0 the active set is
    # exactly the node's cases and the fit is the hard-partition one, and as it
    # rises each leaf model is pulled toward what the rest of the data supports.
    fit <- linad_solve(
      state[["xm"]],
      r,
      weights,
      active,
      model,
      state[["lambda"]],
      state[["alpha"]],
      state[["nvmax"]],
      forward_stop = state[["forward_stop"]],
      derivatives = derivatives,
      type = state[["type"]],
      max_step = state[["line_search_max"]],
      constant_rule = state[["constant_rule"]],
      node_test = state[["node_test"]],
      allowed = state[["adaptive_columns"]]
    )
  }
  if (is.null(fit) || !all(is.finite(fit[["coefficients"]]))) {
    fit <- list(coefficients = rep(0, ncol(state[["xm"]])), constant = 0)
  }
  list(
    index = idx,
    weights = weights,
    depth = node[["depth"]] + 1L,
    update = fit[["coefficients"]],
    constant = fit[["constant"]],
    v = drop(state[["xm"]] %*% fit[["coefficients"]])
  )
} # /rtemis::linad_child


# %% linad_node_loss ----
#' Loss over a set of cases
#'
#' Case weights, not the soft membership weights: the criterion should respond
#' to inverse-frequency weighting, and it must be additive across the two halves
#' of a split for the loss reduction of Algorithm 1 line 9 to mean anything.
#'
#' @param state List: Fit state.
#' @param coefficients Numeric vector: The node's accumulated coefficients.
#' @param idx Integer vector: Rows to score.
#'
#' @return Numeric scalar.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_node_loss <- function(state, coefficients, idx) {
  if (length(idx) == 0L) {
    return(0)
  }
  f <- drop(state[["xm"]][idx, , drop = FALSE] %*% coefficients)
  sum(
    state[["case_weights"]][idx] *
      linad_loss(state[["y"]][idx], f, state[["type"]])
  )
} # /rtemis::linad_node_loss


# %% linad_sample_features ----
#' Draw the feature sample one split search scans
#'
#' `mtry` features without replacement from those the tree may split on,
#' returned in the two-vector form `linad_scan_features()` reads. The draw is
#' made **within** `allowed`, so `split_features`
#' and `mtry_split` compose: the first says which partitions are admissible, the
#' second how many of them one node considers.
#'
#' NULL, or an `mtry` that reaches the whole pool, returns `allowed` unchanged --
#' NULL where nothing was restricted, so an unrestricted run takes its original
#' path.
#'
#' @param context List: `linad_context()` output.
#' @param mtry Optional Integer: Features to sample.
#' @param allowed Optional List: The split-eligible features, as
#' `linad_scan_features()` describes them. NULL allows every feature.
#'
#' @return List of two integer vectors, or NULL for no restriction.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_sample_features <- function(context, mtry = NULL, allowed = NULL) {
  pool <- linad_scan_features(context, allowed)
  n_numeric <- length(pool[["numeric"]])
  total <- n_numeric + length(pool[["factor"]])
  if (is.null(mtry) || mtry >= total) {
    return(allowed)
  }
  drawn <- sample.int(total, mtry)
  list(
    numeric = pool[["numeric"]][drawn[drawn <= n_numeric]],
    factor = pool[["factor"]][drawn[drawn > n_numeric] - n_numeric]
  )
} # /rtemis::linad_sample_features


# %% linad_min_child_cases ----
#' Fewest cases a split may leave on a side
#'
#' The higher of `min_cases_leaf` and `min_cases_node_model`, so that a split
#' never creates a node too small to carry its own model: such a node takes a
#' zero update and predicts exactly what its parent did, spending a leaf to
#' change nothing. Both split searches use this, so the exhaustive search scores
#' a candidate only by child models the commit will fit.
#'
#' A constant node carries no model and is exempt, which is what keeps the CART
#' and Additive Tree reductions exact.
#'
#' @param state List: Fit state.
#'
#' @return Integer.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_min_child_cases <- function(state) {
  if (identical(state[["node_model"]], "constant")) {
    return(state[["min_cases_leaf"]])
  }
  max(state[["min_cases_leaf"]], state[["min_cases_node_model"]])
} # /rtemis::linad_min_child_cases


# %% linad_split_search ----
#' Dispatch to the configured split search
#'
#' @param state List: Fit state.
#' @param r Numeric vector: Negative gradient.
#' @param w Numeric vector: Node weights.
#' @param member Logical vector: Node membership.
#' @param features Optional List: Which features to scan, as
#' `linad_scan_features()` describes. NULL scans every feature.
#' @param derivatives List: `linad_gradient()` at the parent's function value.
#' @param active Integer vector: The node's active rows.
#'
#' @return A split, or NULL when no admissible split exists.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_split_search <- function(
  state,
  r,
  w,
  member,
  features,
  derivatives = NULL,
  active = NULL
) {
  if (identical(state[["split_search"]], "exhaustive")) {
    linad_sweep(state, r, w, member, features, derivatives, active)
  } else {
    # The stump scores a candidate by the mean shift it produces rather than by
    # a fitted child model, so it has no model to keep in step with the commit
    # and reads every row's weight as it stands.
    linad_stump(
      state[["context"]],
      r,
      w,
      member,
      linad_min_child_cases(state),
      features,
      criterion = state[["split_criterion"]]
    )
  }
} # /rtemis::linad_split_search


# %% linad_improves ----
#' Whether a proposal reduces the loss
#'
#' The growth loop's own admission test, named once so the `mtry_split` retry
#' and the loop cannot drift apart on what counts as progress.
#'
#' @param proposal Optional List: `linad_propose()` output.
#'
#' @return Logical.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_improves <- function(proposal) {
  !is.null(proposal) &&
    is.finite(proposal[["score"]]) &&
    proposal[["score"]] > 0
} # /rtemis::linad_improves


# %% linad_propose ----
#' Propose a split for one node, over the features it is given
#'
#' `ExpandNode` of Algorithm 1. Splits the node, fits a leaf model on each side,
#' line-searches the step, and scores the result -- but commits nothing. The
#' growth loop expands every frontier node speculatively and then commits only
#' the best proposal, which is the one-step lookahead of Figure 2 and the reason
#' a LINAD tree's split order is a global argmax over the frontier.
#'
#' @param state List: Fit state.
#' @param node List: The node to expand.
#' @param features Optional List: Which features the split search may scan, as
#' `linad_scan_features()` describes. NULL scans every feature.
#'
#' @return List describing the proposal, or NULL if the node cannot be split.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_propose <- function(state, node, features = NULL) {
  f <- drop(state[["xm"]] %*% node[["coef"]])
  derivatives <- linad_gradient(state[["y"]], f, state[["type"]])
  r <- -derivatives[["g"]]
  if (length(node[["index"]]) < state[["min_cases_split"]]) {
    return(NULL)
  }
  member <- logical(state[["n"]])
  member[node[["index"]]] <- TRUE
  # One active-row rule per node, resolved here and handed to the search and to
  # both commits. Resolving it separately on each side is how the search comes
  # to score a model that is never built.
  active <- linad_active_rows(node[["weights"]])
  split <- linad_split_search(
    state,
    r,
    node[["weights"]],
    member,
    features,
    derivatives,
    active
  )
  if (is.null(split)) {
    return(NULL)
  }
  goes_left <- linad_goes_left(split, state[["x"]])
  index_left <- node[["index"]][goes_left[node[["index"]]]]
  index_right <- node[["index"]][!goes_left[node[["index"]]]]
  if (length(index_left) == 0L || length(index_right) == 0L) {
    return(NULL)
  }

  # Equation 29: a case keeps its weight in the branch it belongs to and has it
  # scaled by gamma in the other, so influence decays as gamma^depth rather than
  # vanishing at the first split.
  weights_left <- node[["weights"]]
  weights_left[!goes_left] <- weights_left[!goes_left] * state[["gamma"]]
  weights_right <- node[["weights"]]
  weights_right[goes_left] <- weights_right[goes_left] * state[["gamma"]]

  left <- linad_child(
    state,
    node,
    r,
    derivatives,
    index_left,
    weights_left,
    active
  )
  right <- linad_child(
    state,
    node,
    r,
    derivatives,
    index_right,
    weights_right,
    active
  )

  steps <- linad_steps(state, node, f, goes_left, left, right)
  coef_left <- node[["coef"]] +
    state[["learning_rate"]] * steps[[1L]] * left[["update"]]
  coef_right <- node[["coef"]] +
    state[["learning_rate"]] * steps[[2L]] * right[["update"]]
  # The tree's own value, accumulated exactly as the coefficients are, so it is
  # the model evaluated with the slopes zeroed.
  value_left <- node[["node_value"]] +
    state[["learning_rate"]] * steps[[1L]] * left[["constant"]]
  value_right <- node[["node_value"]] +
    state[["learning_rate"]] * steps[[2L]] * right[["constant"]]
  loss_left <- linad_node_loss(state, coef_left, index_left)
  loss_right <- linad_node_loss(state, coef_right, index_right)

  # Algorithm 1 line 9, without its stray leading minus.
  score <- node[["loss"]] - (loss_left + loss_right)

  list(
    split = split,
    score = score,
    left = list(
      index = index_left,
      weights = weights_left,
      coef = coef_left,
      node_value = value_left,
      depth = left[["depth"]],
      loss = loss_left
    ),
    right = list(
      index = index_right,
      weights = weights_right,
      coef = coef_right,
      node_value = value_right,
      depth = right[["depth"]],
      loss = loss_right
    )
  )
} # /rtemis::linad_propose


# %% linad_expand ----
#' Propose a split for one node, over a sample of the features
#'
#' `ExpandNode` of Algorithm 1, with `mtry_split` drawn once per node from the
#' features `split_features` admits.
#'
#' A node is expanded once and its proposal cached, so a feature sample that
#' finds nothing worth splitting on would close that node for good, an artifact
#' of the caching. One retry over every admissible feature keeps `mtry_split` a
#' choice of *which* split is made and never of *whether* the node can split at
#' all: a node closes only where no admissible feature improves the loss.
#'
#' The retry costs a second expansion, child model fits included, and only at
#' nodes whose sample found nothing -- terminal nodes, mostly, where the full
#' search is about to find nothing either.
#'
#' @param state List: Fit state.
#' @param node List: The node to expand.
#'
#' @return List describing the proposal, or NULL if the node cannot be split.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_expand <- function(state, node) {
  allowed <- state[["split_columns"]]
  features <- linad_sample_features(
    state[["context"]],
    state[["mtry_split"]],
    allowed
  )
  proposal <- linad_propose(state, node, features)
  # The retry restores every *admissible* feature, not every feature: a split
  # the user disallowed must not come back through the mechanism that stops a
  # poor sample from closing a node.
  if (!identical(features, allowed) && !linad_improves(proposal)) {
    proposal <- linad_propose(state, node, allowed)
  }
  proposal
} # /rtemis::linad_expand


# %% linad_steps ----
#' Resolve the line-search step for a pair of children
#'
#' `line_search` decides the scope. Equation 27 and Algorithm 1 line 30 estimate
#' a single step over the whole expansion; the reference implementation
#' estimates one per child. Both are available.
#'
#' **The step is estimated over every case, weighted by the soft membership
#' weights**, not over the node's own cases. That is Eq 15's expectation, and it
#' is the channel through which `gamma` reaches a regression fit: a node whose
#' cases suggest a large step is pulled back toward one the rest of the data can
#' also live with, in proportion to how much weight `gamma` leaves them. Scored
#' over the node's own cases instead, `gamma` would only ever change which split
#' is chosen, and would be very nearly inert.
#'
#' @param state List: Fit state.
#' @param node List: The node being expanded.
#' @param f Numeric vector: Parent's function value.
#' @param goes_left Logical vector: Which cases the split sends left.
#' @param left List: `linad_child()` output for the left child.
#' @param right List: `linad_child()` output for the right child.
#'
#' @return Numeric vector of length 2: the left and right steps.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_steps <- function(state, node, f, goes_left, left, right) {
  if (identical(state[["line_search"]], "none")) {
    return(c(1, 1))
  }
  all_rows <- seq_len(state[["n"]])
  if (identical(state[["line_search"]], "child")) {
    return(c(
      linad_line_search(
        state[["y"]],
        f,
        left[["v"]],
        left[["weights"]],
        all_rows,
        state[["type"]],
        state[["line_search_max"]]
      ),
      linad_line_search(
        state[["y"]],
        f,
        right[["v"]],
        right[["weights"]],
        all_rows,
        state[["type"]],
        state[["line_search_max"]]
      )
    ))
  }
  # "expansion": one step over both children's combined update direction, each
  # case taking the direction of the side the split sends it to.
  combined <- numeric(state[["n"]])
  combined[goes_left] <- left[["v"]][goes_left]
  combined[!goes_left] <- right[["v"]][!goes_left]
  step <- linad_line_search(
    state[["y"]],
    f,
    combined,
    node[["weights"]],
    all_rows,
    state[["type"]],
    state[["line_search_max"]]
  )
  c(step, step)
} # /rtemis::linad_steps


# %% linad_fit ----
#' Grow a linear additive tree
#'
#' Algorithm 1. The engine proper: takes numeric inputs and hyperparameter
#' values, returns a plain description of the tree. It knows nothing about S7,
#' preprocessing or outcome types beyond `type`, which is what makes it
#' testable on its own.
#'
#' A node's function value is never stored. It is always recomputed as
#' `xm %*% coef`, so the fitted values and the coefficients a prediction will
#' use are the same object by construction.
#'
#' @param x data.frame: Features, unencoded, used for splits.
#' @param xm Numeric matrix: Intercept-augmented design matrix, used for models.
#' @param y Numeric vector: Outcome; `{-1, +1}` for classification.
#' @param case_weights Numeric vector: Case weights, rescaled to average 1.
#' @param type Character: "Regression" or "Classification".
#' @param settings List: Hyperparameter values, as named in `setup_LINAD`.
#' @param verbosity Integer: Verbosity level.
#'
#' @return List with `frame`, `coefficients`, `steps`, `n_leaves` and
#' `settings`, the last being every hyperparameter as the run resolved it.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_fit <- function(
  x,
  xm,
  y,
  case_weights,
  type,
  settings,
  validation = NULL,
  verbosity = 1L
) {
  n <- nrow(xm)
  # Growth runs in standardized space so one `lambda` means the same thing at
  # every node; the finished coefficients are mapped back before they are
  # stored, so nothing downstream sees the standardized scale.
  scaling <- linad_scaling(xm)
  xm_scaled <- sweep(
    sweep(xm, 2L, scaling[["center"]], "-"),
    2L,
    scaling[["scale"]],
    "/"
  )
  context <- linad_context(
    x,
    settings[["split_binning"]],
    settings[["split_bin_type"]]
  )
  # Feature roles, resolved from names to column indices once. The design's
  # `assign` attribute is read from `xm` rather than from the scaled copy,
  # which `sweep()` returns without it.
  roles <- linad_roles(
    context,
    as.integer(attr(xm, "assign")),
    names(x),
    settings[["split_features"]],
    settings[["linear_features"]],
    settings[["global_features"]]
  )
  # A node with no adaptive column may change its constant and nothing else,
  # which is what `node_model = "constant"` already means. Saying so here rather
  # than special-casing it below is what keeps `min_cases_node_model`, the split
  # floors and `node_test` behaving as they do for any constant-node tree. The
  # root is untouched: it still fits the global effects.
  if (!is.null(roles[["adaptive"]]) && length(roles[["adaptive"]]) == 0L) {
    settings[["node_model"]] <- "constant"
  }
  state <- c(
    settings,
    list(
      x = x,
      xm = xm_scaled,
      y = y,
      n = n,
      case_weights = case_weights,
      type = type,
      context = context,
      split_columns = roles[["split"]],
      linear_columns = roles[["linear"]],
      adaptive_columns = roles[["adaptive"]]
    )
  )

  # Root ----
  # Algorithm 1 line 3: a regularized linear model of y itself, on every case,
  # with uniform weights. Its intercept is the model's initialization, so there
  # is no separate init term to keep in step with the coefficients.
  # The root fits `y` itself by least squares, in both outcome types -- it is an
  # initialization, not a boosting step, so there is no gradient yet to fit. Its
  # constant is the baseline that minimizes the loss, which is the weighted mean
  # for a regression and half the log odds for a classification, and
  # `root_learning_rate` shrinks only the slopes around it.
  #
  # Shrinking the slopes leaves the level unchanged, so a shrunk root still
  # predicts around the mean. At a rate of 0 the root's slopes are discarded,
  # so the fit is skipped.
  root_fit <- if (settings[["root_learning_rate"]] == 0) {
    NULL
  } else {
    linad_solve(
      xm_scaled,
      y,
      case_weights,
      seq_len(n),
      settings[["root_model"]],
      settings[["root_lambda"]],
      settings[["root_alpha"]],
      settings[["root_nvmax"]],
      forward_stop = settings[["root_forward_stop"]],
      derivatives = NULL,
      type = "Regression",
      max_step = settings[["line_search_max"]],
      # As at the nodes, the slopes test applies to the shrinking models;
      # forward selection prices each term through `root_forward_stop`.
      node_test = if (settings[["root_model"]] %in% c("ridge", "elasticnet")) {
        settings[["root_node_test"]]
      } else {
        "none"
      },
      allowed = roles[["linear"]]
    )
  }
  # Taken from the outcome: the fit runs as a regression on -1/+1 labels, whose
  # mean is not the prevalence an intercept-only classifier must predict.
  root_constant <- linad_baseline(y, case_weights, type)
  root_slopes <- if (is.null(root_fit)) {
    rep(0, ncol(xm_scaled) - 1L)
  } else {
    root_fit[["coefficients"]][-1L]
  }
  if (!all(is.finite(root_slopes))) {
    root_slopes <- rep(0, ncol(xm_scaled) - 1L)
  }
  root_slopes <- settings[["root_learning_rate"]] * root_slopes
  root_center <- if (ncol(xm_scaled) > 1L) {
    drop(crossprod(case_weights, xm_scaled[, -1L, drop = FALSE])) /
      sum(case_weights)
  } else {
    numeric(0)
  }
  root_coef <- c(root_constant - sum(root_center * root_slopes), root_slopes)
  baseline <- root_constant

  nodes <- list(list(
    id = 1L,
    parent = NA_integer_,
    index = seq_len(n),
    weights = case_weights,
    coef = root_coef,
    node_value = baseline,
    depth = 0L,
    loss = 0,
    split = NULL,
    left = NA_integer_,
    right = NA_integer_,
    proposal = NULL
  ))
  nodes[[1L]][["loss"]] <- linad_node_loss(state, root_coef, seq_len(n))
  leaves <- 1L
  steps <- list(1L)

  # Early stopping ----
  # Validation cases are routed as the tree grows rather than re-routed from
  # scratch at every size: a split moves only the cases sitting in the node it
  # splits, and `linad_goes_left()` is the same primitive the commit routes
  # with, so there is no second copy of the routing rule.
  patience <- settings[["patience"]]
  watching <- !is.null(validation) && !is.null(patience)
  if (watching) {
    validation_xm <- sweep(
      sweep(validation[["xm"]], 2L, scaling[["center"]], "-"),
      2L,
      scaling[["scale"]],
      "/"
    )
    validation_leaf <- rep(1L, nrow(validation_xm))
    validation_f <- drop(validation_xm %*% root_coef)
    validation_loss <- mean(
      linad_loss(validation[["y"]], validation_f, type)
    )
    best_loss <- validation_loss
    since_best <- 0L
  }

  if (settings[["max_leaves"]] > 1L) {
    nodes[[1L]][["proposal"]] <- linad_expand(state, nodes[[1L]])
    candidates <- if (is.null(nodes[[1L]][["proposal"]])) integer(0) else 1L

    # Tree-growing ----
    while (
      length(leaves) < settings[["max_leaves"]] && length(candidates) > 0L
    ) {
      scores <- vapply(
        candidates,
        function(id) nodes[[id]][["proposal"]][["score"]],
        numeric(1L)
      )
      best <- which.max(scores)
      if (!is.finite(scores[[best]]) || scores[[best]] <= 0) {
        # No frontier node can still reduce the loss. `linad_improves()` is the
        # same test, applied per proposal.
        break
      }
      chosen <- candidates[[best]]
      proposal <- nodes[[chosen]][["proposal"]]
      child_ids <- length(nodes) + seq_len(2L)
      for (side in seq_len(2L)) {
        child <- proposal[[c("left", "right")[[side]]]]
        nodes[[child_ids[[side]]]] <- list(
          id = child_ids[[side]],
          parent = chosen,
          index = child[["index"]],
          weights = child[["weights"]],
          coef = child[["coef"]],
          node_value = child[["node_value"]],
          depth = child[["depth"]],
          loss = child[["loss"]],
          split = NULL,
          left = NA_integer_,
          right = NA_integer_,
          proposal = NULL
        )
      }
      nodes[[chosen]][["split"]] <- proposal[["split"]]
      nodes[[chosen]][["left"]] <- child_ids[[1L]]
      nodes[[chosen]][["right"]] <- child_ids[[2L]]
      nodes[[chosen]][["proposal"]] <- NULL
      # An internal node's weights are only ever read while it sits on the
      # frontier, so dropping them here bounds memory by the frontier size
      # rather than by the node count.
      nodes[[chosen]][["weights"]] <- NULL
      leaves <- c(setdiff(leaves, chosen), child_ids)
      candidates <- setdiff(candidates, chosen)
      for (id in child_ids) {
        nodes[[id]][["proposal"]] <- linad_expand(state, nodes[[id]])
        if (!is.null(nodes[[id]][["proposal"]])) {
          candidates <- c(candidates, id)
        }
      }
      steps[[length(leaves)]] <- leaves
      if (watching) {
        moved <- which(validation_leaf == chosen)
        if (length(moved) > 0L) {
          goes_left <- linad_goes_left(
            proposal[["split"]],
            validation[["x"]][moved, , drop = FALSE]
          )
          validation_leaf[moved] <- ifelse(
            goes_left,
            child_ids[[1L]],
            child_ids[[2L]]
          )
          for (side in seq_len(2L)) {
            rows <- moved[validation_leaf[moved] == child_ids[[side]]]
            if (length(rows) > 0L) {
              validation_f[rows] <- drop(
                validation_xm[rows, , drop = FALSE] %*%
                  nodes[[child_ids[[side]]]][["coef"]]
              )
            }
          }
        }
        validation_loss <- mean(
          linad_loss(validation[["y"]], validation_f, type)
        )
        if (validation_loss < best_loss) {
          best_loss <- validation_loss
          since_best <- 0L
        } else {
          since_best <- since_best + 1L
        }
        if (since_best >= patience) {
          if (verbosity > 0L) {
            info(
              "Stopped at ",
              length(leaves),
              ngettext(length(leaves), " leaf", " leaves"),
              ": validation loss has not improved for ",
              patience,
              ngettext(patience, " expansion", " expansions"),
              "."
            )
          }
          break
        }
      }
      if (verbosity > 1L) {
        dbg(
          "LINAD: ",
          length(leaves),
          " leaves, split node ",
          chosen,
          " on ",
          proposal[["split"]][["feature"]]
        )
      }
    }
  }

  fitted <- linad_frame(nodes, leaves, steps, colnames(xm))
  fitted[["coefficients"]] <- linad_unscale(fitted[["coefficients"]], scaling)
  # Every value the run actually used, resolved. Several defaults are resolved
  # only here -- `n_cuts` is the clearest -- so without this a serialized fit
  # does not record what produced it and reproducing it means knowing the
  # package version's resolution rules.
  fitted[["settings"]] <- settings
  fitted
} # /rtemis::linad_fit


# %% linad_frame ----
#' Flatten a grown tree into a frame and a coefficient matrix
#'
#' One row per node and one coefficient row per node, in place of a nested
#' structure: routing is then a vectorized pass per internal node, and the whole
#' object serializes with no external references.
#'
#' @param nodes List: Grown nodes.
#' @param leaves Integer vector: Terminal node ids.
#' @param steps List: Terminal sets by tree size.
#' @param design_names Character vector: Design-matrix column names.
#'
#' @return List with `frame`, `coefficients`, `steps` and `n_leaves`.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_frame <- function(nodes, leaves, steps, design_names) {
  n_nodes <- length(nodes)
  ids <- seq_len(n_nodes)
  split_of <- function(field, empty) {
    vapply(
      nodes,
      function(node) {
        if (is.null(node[["split"]])) empty else node[["split"]][[field]]
      },
      empty
    )
  }
  frame <- data.table::data.table(
    node = ids,
    parent = vapply(nodes, function(node) node[["parent"]], integer(1L)),
    left = vapply(nodes, function(node) node[["left"]], integer(1L)),
    right = vapply(nodes, function(node) node[["right"]], integer(1L)),
    is_leaf = ids %in% leaves,
    depth = vapply(nodes, function(node) node[["depth"]], integer(1L)),
    n = vapply(nodes, function(node) length(node[["index"]]), integer(1L)),
    # What the tree alone predicts here, with the slopes zeroed. The root's is
    # Eq 3's constant, and a child's is its parent's plus the shrunk,
    # line-searched constant of Eq 19/20.
    node_value = vapply(
      nodes,
      function(node) node[["node_value"]],
      numeric(1L)
    ),
    loss = vapply(nodes, function(node) node[["loss"]], numeric(1L)),
    split_feature = split_of("feature", NA_character_),
    split_kind = split_of("kind", NA_character_),
    split_value = split_of("value", NA_real_)
  )
  frame[["split_levels"]] <- lapply(nodes, function(node) {
    if (is.null(node[["split"]])) NULL else node[["split"]][["levels"]]
  })
  coefficients <- do.call(rbind, lapply(nodes, function(node) node[["coef"]]))
  colnames(coefficients) <- design_names
  rownames(coefficients) <- NULL
  list(
    frame = frame,
    coefficients = coefficients,
    steps = steps,
    n_leaves = length(leaves)
  )
} # /rtemis::linad_frame


# %% linad_selected_nodes ----
#' The rows a selected tree can reach
#'
#' A fitted model keeps the fully grown frame, and validation selects a size
#' from it. Everything below that size's terminal nodes is still in the frame
#' and is unreachable: prediction stops at the terminals, so anything that
#' describes the model -- its structure, its importances, its printed summary --
#' must stop there too.
#'
#' @param frame data.table: The tree frame.
#' @param terminal Integer vector: Node ids terminal at the selected size.
#'
#' @return Integer vector of frame row indices, sorted.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_selected_nodes <- function(frame, terminal) {
  keep <- match(terminal, frame[["node"]])
  repeat {
    parents <- frame[["parent"]][keep]
    parents <- match(parents[!is.na(parents)], frame[["node"]])
    new <- setdiff(parents, keep)
    if (length(new) == 0L) {
      break
    }
    keep <- c(keep, new)
  }
  sort(keep)
} # /rtemis::linad_selected_nodes


# %% linad_check_tree ----
#' Structural invariants of a grown tree
#'
#' Everything a fitted `LinearAdditiveTree` must satisfy whatever the
#' hyperparameters, checked against the frame it already stores: that each split
#' partitions its node, that no leaf merely repeats its parent, that the numbers
#' are finite, that the leaf flags match the selected size, and that the
#' sequence of tree sizes is unbroken.
#'
#' Returns the violations, so a caller can assert on them in a test or report
#' them across a benchmark. Cheap enough to run on every fit.
#'
#' These are properties an accuracy measurement cannot see: a tree that wastes
#' leaves is a worse number, not a visible fault.
#'
#' @param model `LinearAdditiveTree` object.
#'
#' @param min_cases_child Optional Integer: The floor a split had to leave on
#' each side. Supplied by a caller that knows the hyperparameters, since the
#' fitted tree does not carry them.
#'
#' @return Character vector of violations, empty when the tree is sound.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_check_tree <- function(model, min_cases_child = NULL) {
  problems <- character()
  frame <- model@frame
  node <- frame[["node"]]
  terminal <- model@steps[[model@n_leaves]]
  leaf_rows <- match(terminal, node)
  internal <- which(!is.na(frame[["left"]]))

  # No node smaller than the floor its split was required to leave. A rule that
  # moves that floor without saying so spends the leaf budget on nodes too small
  # to carry a model, which no accuracy measurement reliably shows.
  if (!is.null(min_cases_child)) {
    undersized <- node[frame[["n"]] < min_cases_child]
    if (length(undersized) > 0L) {
      problems <- c(
        problems,
        paste0(
          "node(s) ",
          paste(undersized, collapse = ", "),
          " hold fewer than the ",
          min_cases_child,
          " cases a split had to leave"
        )
      )
    }
  }

  # A split partitions its node: nothing is lost or double counted.
  for (row in internal) {
    children <- match(
      c(frame[["left"]][[row]], frame[["right"]][[row]]),
      node
    )
    if (anyNA(children)) {
      problems <- c(
        problems,
        paste0("node ", node[[row]], " names a child that is not in the frame")
      )
      next
    }
    if (frame[["n"]][[row]] != sum(frame[["n"]][children])) {
      problems <- c(
        problems,
        paste0(
          "node ",
          node[[row]],
          " holds ",
          frame[["n"]][[row]],
          " cases but its children hold ",
          sum(frame[["n"]][children])
        )
      )
    }
  }

  # A leaf that predicts exactly what its parent did spent one of `max_leaves`
  # to change nothing.
  for (i in seq_along(terminal)) {
    parent <- frame[["parent"]][[leaf_rows[[i]]]]
    if (is.na(parent)) {
      next
    }
    parent_row <- match(parent, node)
    same_coefficients <- isTRUE(all.equal(
      model@coefficients[leaf_rows[[i]], ],
      model@coefficients[parent_row, ]
    ))
    same_value <- isTRUE(all.equal(
      frame[["node_value"]][[leaf_rows[[i]]]],
      frame[["node_value"]][[parent_row]]
    ))
    if (same_coefficients && same_value) {
      problems <- c(
        problems,
        paste0(
          "leaf ",
          terminal[[i]],
          " (n = ",
          frame[["n"]][[leaf_rows[[i]]]],
          ") is identical to its parent ",
          parent
        )
      )
    }
  }

  # A model whose numbers are not numbers predicts nothing.
  if (!all(is.finite(model@coefficients))) {
    problems <- c(problems, "coefficients hold non-finite values")
  }
  if (any(!is.finite(frame[["loss"]]))) {
    problems <- c(problems, "frame holds a non-finite loss")
  }

  # The leaf flags describe the tree at its selected size, which is what every
  # later reader assumes.
  flagged <- node[frame[["is_leaf"]]]
  if (!setequal(flagged, terminal)) {
    problems <- c(
      problems,
      "is_leaf disagrees with the terminal set at the selected size"
    )
  }

  # Growing to k leaves passes through every smaller size, which is what makes
  # selecting a size on held-out data meaningful.
  sizes <- lengths(model@steps)
  if (!identical(sizes, seq_along(model@steps))) {
    problems <- c(problems, "the sequence of tree sizes is not 1, 2, ... k")
  }
  # Feature roles, as the fitted model records them: a coefficient outside the
  # linear set was never fitted, and a global one is the same in every node by
  # construction.
  roles <- linad_roles(
    list(numeric_names = character(), factor_names = character()),
    model@design_assign,
    model@xnames,
    linear_features = model@settings[["linear_features"]],
    global_features = model@settings[["global_features"]]
  )
  slopes <- model@coefficients[, -1L, drop = FALSE]
  if (!is.null(roles[["linear"]]) && NCOL(slopes) > 0L) {
    outside <- setdiff(seq_len(NCOL(slopes)), roles[["linear"]])
    nonzero <- outside[apply(slopes[, outside, drop = FALSE] != 0, 2L, any)]
    if (length(nonzero) > 0L) {
      problems <- c(
        problems,
        paste0(
          "design column(s) ",
          paste(colnames(slopes)[nonzero], collapse = ", "),
          " carry a coefficient but are not linear features"
        )
      )
    }
  }
  if (!is.null(roles[["adaptive"]]) && NCOL(slopes) > 0L) {
    pinned <- setdiff(
      if (is.null(roles[["linear"]])) {
        seq_len(NCOL(slopes))
      } else {
        roles[["linear"]]
      },
      roles[["adaptive"]]
    )
    reachable <- linad_selected_nodes(frame, terminal)
    varying <- pinned[apply(
      slopes[reachable, pinned, drop = FALSE],
      2L,
      function(column) diff(range(column)) != 0
    )]
    if (length(varying) > 0L) {
      problems <- c(
        problems,
        paste0(
          "global column(s) ",
          paste(colnames(slopes)[varying], collapse = ", "),
          " differ between nodes"
        )
      )
    }
  }

  problems
} # /rtemis::linad_check_tree


# %% linad_route ----
#' Route cases to their terminal node
#'
#' Algorithm 2. Descends the frame one internal node at a time, splitting the
#' set of cases that reached it, rather than walking the tree once per case.
#' Instance weights and `gamma` play no part: soft weighting is a training
#' device, and prediction follows hard splits.
#'
#' @param frame data.table: The tree frame.
#' @param x data.frame: Cases, unencoded.
#' @param terminal Integer vector: Node ids to stop at, which is how a tree
#' grown to `max_leaves` is evaluated at a smaller size.
#'
#' @return Integer vector of terminal node ids, one per case.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_route <- function(frame, x, terminal) {
  current <- rep(1L, nrow(x))
  settled <- frame[["node"]] %in% terminal | is.na(frame[["left"]])
  repeat {
    pending <- which(!settled[current])
    if (length(pending) == 0L) {
      break
    }
    for (id in unique(current[pending])) {
      rows <- pending[current[pending] == id]
      goes_left <- linad_goes_left(
        list(
          feature = frame[["split_feature"]][[id]],
          kind = frame[["split_kind"]][[id]],
          value = frame[["split_value"]][[id]],
          levels = frame[["split_levels"]][[id]]
        ),
        x[rows, , drop = FALSE]
      )
      current[rows] <- ifelse(
        goes_left,
        frame[["left"]][[id]],
        frame[["right"]][[id]]
      )
    }
  }
  current
} # /rtemis::linad_route


# %% linad_raw_prediction ----
#' Function value for new cases
#'
#' The leaf's coefficients already carry the sum along its path, so prediction
#' is one dot product per case and the whole set is one vectorized pass.
#'
#' @param model `LinearAdditiveTree` object.
#' @param x data.frame: Cases, unencoded, for routing.
#' @param xm Numeric matrix: The same cases, encoded, for the leaf models.
#' @param n_leaves Optional Integer: Evaluate the tree at this size. NULL uses
#' the size selected at training.
#'
#' @return Numeric vector.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_raw_prediction <- function(model, x, xm, n_leaves = NULL) {
  # Coefficients are applied by position, so a design rebuilt on a different
  # basis has to be caught here or it is multiplied silently. Empty for a model
  # fitted before the names were recorded.
  if (
    length(model@design_names) > 0L &&
      !identical(colnames(xm), model@design_names)
  ) {
    rtemis.core::abort(
      "Design matrix does not match the one this model was fitted on. Fitted: ",
      paste(model@design_names, collapse = ", "),
      ". Rebuilt: ",
      paste(colnames(xm), collapse = ", "),
      ".",
      class = c("rtemis_value_error", "rtemis_data_error")
    )
  }
  if (is.null(n_leaves)) {
    n_leaves <- model@n_leaves
  }
  leaf <- linad_route(model@frame, x, model@steps[[n_leaves]])
  rowSums(xm * model@coefficients[leaf, , drop = FALSE])
} # /rtemis::linad_raw_prediction


# %% linad_size_curve ----
#' Loss at every size the tree passed through
#'
#' Truncating the tree is exact -- a node's coefficients depend only on its
#' ancestors -- so scoring size `k` needs no refit, only a prediction that stops
#' at that size's terminal set.
#'
#' @param model `LinearAdditiveTree` object.
#' @param x data.frame: Features, unencoded.
#' @param xm Numeric matrix: Features, encoded.
#' @param y Numeric vector: Outcome, on the training scale.
#' @param type Character: "Regression" or "Classification".
#'
#' @return Numeric vector, one mean loss per size.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_size_curve <- function(model, x, xm, y, type) {
  vapply(
    seq_along(model@steps),
    function(k) {
      mean(linad_loss(
        y,
        linad_raw_prediction(model, x, xm, n_leaves = k),
        type
      ))
    },
    numeric(1L)
  )
} # /rtemis::linad_size_curve


# %% linad_select_leaves ----
#' Choose the tree size that minimizes validation loss
#'
#' The manuscript's leaf-count selection: score the tree at every size it passed
#' through and take the argmin, "equivalent to choosing the number of trees in
#' gradient boosting based on validation error". Smoothing the curve first trades
#' a little optimism for a lot of stability when the validation set is small.
#'
#' @param model `LinearAdditiveTree` object.
#' @param x data.frame: Validation features, unencoded.
#' @param xm Numeric matrix: Validation features, encoded.
#' @param y Numeric vector: Validation outcome, on the training scale.
#' @param type Character: "Regression" or "Classification".
#' @param smooth Logical: Smooth the curve before taking the argmin.
#'
#' @return List with the selected `n_leaves` and the `curve` it was read from.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_select_leaves <- function(model, x, xm, y, type, smooth = FALSE) {
  sizes <- seq_along(model@steps)
  curve <- linad_size_curve(model, x, xm, y, type)
  scored <- curve
  if (smooth && length(sizes) >= 4L) {
    smoothed <- tryCatch(
      stats::loess(curve ~ sizes)[["fitted"]],
      error = function(e) NULL
    )
    if (!is.null(smoothed)) {
      scored <- smoothed
    }
  }
  list(n_leaves = sizes[[which.min(scored)]], curve = curve)
} # /rtemis::linad_select_leaves


# %% linad_design_matrix ----
#' Intercept-augmented design matrix for the leaf models
#'
#' Reference coding, not one-hot: an intercept plus a full set of level
#' indicators is rank deficient, which leaves the leaf coefficients
#' unidentifiable -- unacceptable for a model whose coefficients are the thing a
#' reader is meant to look at.
#'
#' LINAD is therefore one of the algorithms that keeps its features unencoded
#' through the pipeline and encodes them here. It needs both forms at once: splits are searched and routed
#' on the original features, so a factor splits on a set of levels, while the
#' models need numbers. A one-hot `Preprocessor` would deliver only the encoded
#' frame to `predict_super()` and the level sets would have nothing to route on.
#'
#' Passing `xlev` forces the training levels, so the columns line up even when
#' new data happens not to contain one.
#'
#' @param x data.frame: Features.
#' @param xlev Optional named list: Factor levels recorded at training.
#'
#' @return Numeric matrix with `(Intercept)` as its first column.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_design_matrix <- function(x, xlev = NULL) {
  x <- as.data.frame(x)
  if (!is.null(xlev)) {
    for (feature in names(xlev)) {
      values <- factor(as.character(x[[feature]]), levels = xlev[[feature]])
      if (anyNA(values)) {
        rtemis.core::abort(
          "Feature '",
          feature,
          "' contains levels not seen during training: ",
          paste(
            setdiff(unique(as.character(x[[feature]])), xlev[[feature]]),
            collapse = ", "
          ),
          ".",
          class = c("rtemis_value_error", "rtemis_data_error")
        )
      }
      x[[feature]] <- values
    }
  }
  # Reference coding, named explicitly: `getOption("contrasts")` is
  # process-global and mutable, so a model fitted under one
  # setting and predicted under another would be multiplied against a design
  # built from a different basis, positionally and silently.
  factors <- names(x)[vapply(
    x,
    function(column) is.factor(column) && nlevels(column) > 1L,
    logical(1L)
  )]
  contrasts <- if (length(factors) > 0L) {
    stats::setNames(rep(list("contr.treatment"), length(factors)), factors)
  } else {
    NULL
  }
  stats::model.matrix(~., data = x, contrasts.arg = contrasts)
} # /rtemis::linad_design_matrix


# %% linad_scaling ----
#' Centering and scaling for the design matrix
#'
#' One `lambda` is shared by every node, so it only means the same thing at each
#' of them if the columns are on a common scale. Growth runs in standardized
#' space and `linad_unscale()` maps the finished coefficients back, so what the
#' model stores -- and what a reader sees -- is on the features' own scale.
#'
#' Forward selection needs none of this: its gain `g_j^2 / s_j` scales as
#' `c^2 / c^2` in a column rescaled by `c`, so the selected set is unchanged.
#' Ridge and the elastic net do need it.
#'
#' @param xm Numeric matrix: Design matrix, intercept first.
#'
#' @return List with `center` and `scale`, both length `ncol(xm)` and both
#' neutral at the intercept.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_scaling <- function(xm) {
  d <- ncol(xm)
  center <- rep(0, d)
  scale <- rep(1, d)
  if (d > 1L) {
    columns <- seq.int(2L, d)
    center[columns] <- colMeans(xm[, columns, drop = FALSE])
    spread <- apply(xm[, columns, drop = FALSE], 2L, stats::sd)
    spread[!is.finite(spread) | spread <= 0] <- 1
    scale[columns] <- spread
  }
  list(center = center, scale = scale)
} # /rtemis::linad_scaling


# %% linad_unscale ----
#' Map coefficients from standardized space back to the features' own scale
#'
#' For `z_j = (x_j - m_j) / s_j`, `sum_j b_j z_j + b_0` equals
#' `sum_j (b_j / s_j) x_j + (b_0 - sum_j b_j m_j / s_j)`. Applied row-wise, so
#' every node's coefficients come back together.
#'
#' @param coefficients Numeric matrix: One row per node, standardized space.
#' @param scaling List: `linad_scaling()` output.
#'
#' @return Numeric matrix of the same shape.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_unscale <- function(coefficients, scaling) {
  center <- scaling[["center"]]
  scale <- scaling[["scale"]]
  rescaled <- sweep(coefficients, 2L, scale, "/")
  shift <- drop(rescaled %*% center)
  rescaled[, 1L] <- coefficients[, 1L] - shift
  rescaled
} # /rtemis::linad_unscale


# %% linad_settings ----
#' Resolve hyperparameters into the values the engine runs on
#'
#' Optional hyperparameters are NULL until here. Two kinds of NULL are resolved:
#' a parameter left unset takes its default, and a `root_*` parameter left
#' unset inherits the node-level value -- so `setup_LINAD(node_model =
#' "constant")` gives a tree with constant nodes *including its root*, rather
#' than an Additive Tree with one stray linear model at the top.
#'
#' `train_()` records the resolved values on the hyperparameters it returns
#' through `record_backend_values()`, which leaves a gated property NULL when
#' its gate is shut: `nvmax` is not recorded for a ridge fit.
#'
#' @param hyperparameters `LINADHyperparameters` object.
#'
#' @return Named list of engine settings.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_settings <- function(hyperparameters) {
  value_or <- function(name, fallback) {
    value <- hyperparameters[[name]]
    if (is.null(value)) fallback else value
  }
  node_model <- hyperparameters[["node_model"]]
  nvmax <- value_or("nvmax", 3L)
  lambda <- value_or("lambda", 0.05)
  alpha <- value_or("alpha", 1)
  forward_stop <- value_or("forward_stop", "bic")
  node_test <- value_or("node_test", "none")
  list(
    max_leaves = hyperparameters[["max_leaves"]],
    force_max_leaves = hyperparameters[["force_max_leaves"]],
    smooth_validation_curve = value_or("smooth_validation_curve", FALSE),
    min_cases_split = hyperparameters[["min_cases_split"]],
    min_cases_leaf = hyperparameters[["min_cases_leaf"]],
    min_cases_node_model = value_or("min_cases_node_model", 10L),
    node_model = node_model,
    nvmax = nvmax,
    lambda = lambda,
    alpha = alpha,
    learning_rate = hyperparameters[["learning_rate"]],
    root_model = value_or("root_model", node_model),
    root_nvmax = value_or("root_nvmax", nvmax),
    root_lambda = value_or("root_lambda", lambda),
    root_alpha = value_or("root_alpha", alpha),
    root_forward_stop = value_or("root_forward_stop", forward_stop),
    root_node_test = value_or("root_node_test", node_test),
    root_learning_rate = hyperparameters[["root_learning_rate"]],
    forward_stop = forward_stop,
    patience = hyperparameters[["patience"]],
    node_test = node_test,
    split_search = hyperparameters[["split_search"]],
    split_criterion = value_or("split_criterion", "mean"),
    # A single tree scans every feature at every split; `LINADForest` overrides
    # this in `linadforest_settings()`, which is the only caller that samples.
    mtry_split = NULL,
    split_binning = hyperparameters[["split_binning"]],
    split_bin_type = hyperparameters[["split_bin_type"]],
    n_cuts = value_or("n_cuts", 20L),
    split_features = hyperparameters[["split_features"]],
    linear_features = hyperparameters[["linear_features"]],
    global_features = hyperparameters[["global_features"]],
    gamma = hyperparameters[["gamma"]],
    line_search = hyperparameters[["line_search"]],
    line_search_max = hyperparameters[["line_search_max"]],
    constant_rule = hyperparameters[["constant_rule"]]
  )
} # /rtemis::linad_settings


# %% linad_gram_solve ----
#' Fit a leaf model from sufficient statistics alone
#'
#' The Gram route into `linad_solve()`'s own two steps, split out because the
#' exhaustive split search fits thousands of candidate models without ever
#' touching the rows again.
#'
#' The statistics arrive on the raw intercept-augmented design; the constant and
#' the centered slope system are read off it algebraically. Writing
#' `sw = sum(w)` and `m = G[1, -1] / sw` for the weighted column means,
#'
#' ```
#' S   = G[-1, -1] - sw m m'
#' Sxr = Xty[-1] - m Xty[1]
#' ```
#'
#' are the centered cross-products, and `X_c'W(r - c) = X_c'Wr` for any constant
#' `c`, since the centered columns are weight-orthogonal to the intercept. So
#' the same two pieces `linad_solve()` fits are available here, and both routes
#' call `linad_constant_from_sums()` and `linad_slopes()`.
#'
#' @param G Numeric matrix: `X'WX`.
#' @param Xty Numeric vector: `X'Wy`.
#' @param syy Numeric: `sum(w y^2)`.
#' @param node_model Character: Leaf model name.
#' @param lambda Numeric: Penalty.
#' @param alpha Numeric: Elastic-net mixing.
#' @param nvmax Integer: Forward-selection term count.
#' @param forward_stop Character: Forward selection's per-term cost rule.
#' @param node_test Character: Cost a side's slopes must earn over the constant
#' alone, as `linad_node_test()` applies it.
#' @param sg Optional Numeric: `sum(w g)` over the same rows, for Equation 20.
#' @param sh Optional Numeric: `sum(w h)` over the same rows.
#' @param type Character: "Regression" or "Classification".
#' @param max_step Numeric: Bound on the constant.
#' @param constant_rule Character: Which rule computes the constant.
#' @param allowed Optional Integer vector: Slope columns the node may fit.
#'
#' @return Numeric vector of coefficients, or NULL.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_gram_solve <- function(
  G,
  Xty,
  syy,
  node_model,
  lambda,
  alpha = 1,
  nvmax = 3L,
  forward_stop = "none",
  node_test = "none",
  sg = NULL,
  sh = NULL,
  type = "Regression",
  max_step = 1000,
  constant_rule = "closed_form",
  allowed = NULL
) {
  sw <- G[[1L, 1L]]
  if (sw <= 0) {
    return(NULL)
  }
  constant <- linad_constant_from_sums(
    Xty[[1L]],
    sw,
    sg,
    sh,
    type,
    max_step,
    constant_rule
  )
  d <- ncol(G)
  if (identical(node_model, "constant") || d == 1L) {
    coefficients <- rep(0, d)
    coefficients[[1L]] <- constant
    return(coefficients)
  }
  center <- G[1L, -1L] / sw
  S <- G[-1L, -1L, drop = FALSE] - sw * outer(center, center)
  Sxr <- Xty[-1L] - center * Xty[[1L]]
  # The target the slopes fit is `r - constant`, so its weighted sum of squares
  # is the node's own expanded about that constant.
  syy_centered <- syy - 2 * constant * Xty[[1L]] + constant * constant * sw
  slopes <- linad_slopes(
    S,
    Sxr,
    syy_centered,
    sw,
    node_model,
    lambda,
    alpha,
    nvmax,
    forward_stop = forward_stop,
    node_test = node_test,
    allowed = allowed
  )
  c(constant - sum(center * slopes), slopes)
} # /rtemis::linad_gram_solve


# %% linad_gram_loss ----
#' Weighted residual sum of squares implied by a Gram and a coefficient vector
#'
#' `sum_i w_i (y_i - x_i'b)^2 = syy - 2 b'Xty + b'Gb`, so the loss of a
#' candidate split costs no data access at all.
#'
#' @param G Numeric matrix: `X'WX`.
#' @param Xty Numeric vector: `X'Wy`.
#' @param syy Numeric: `sum(w * y^2)`.
#' @param b Numeric vector: Coefficients.
#'
#' @return Numeric scalar.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_gram_loss <- function(G, Xty, syy, b) {
  syy - 2 * sum(b * Xty) + sum(b * drop(G %*% b))
} # /rtemis::linad_gram_loss


# %% linad_sweep ----
#' Split search scored by the loss after fitting both child models
#'
#' Section 5.1's second strategy: score every candidate split by what the two
#' child models achieve, where the default strategy takes the best
#' squared-error split of the gradient and fits models afterwards. The manuscript describes
#' it, notes it "significantly increases the computational demands", and leaves
#' it out of the experiments.
#'
#' It is affordable here because the sweep is incremental. Ordering the rows by
#' a feature once and accumulating `X'WX`, `X'Wy` and `sum(w y^2)` in blocks
#' between consecutive cut points yields every candidate's left-hand sufficient
#' statistics in a single pass; the right-hand ones are the node totals minus
#' them. Each candidate then costs one small solve instead of two fits over the
#' data, which takes the cost per feature from `O(k(n d^2 + d^3))` to
#' `O(n d^2 + k d^3)`.
#'
#' Soft weighting survives the same trick. Writing `G_L` and `G_R` for the two
#' hard-side Grams, the gamma-weighted ones are `G_L + gamma * G_R` and
#' `gamma * G_L + G_R`, both of which the sweep already has.
#'
#' @param state List: Fit state.
#' @param r Numeric vector: Negative gradient.
#' @param w Numeric vector: Node weights.
#' @param member Logical vector: Node membership.
#' @param features Optional List: Which features to scan, as
#' `linad_scan_features()` describes. NULL scans every feature.
#' @param derivatives Optional List: `linad_gradient()` at the parent's function
#' value. Needed only where the constant is a Newton step.
#' @param active Optional Integer vector: The node's active rows, as
#' `linad_active_rows()` resolves them. NULL uses every row.
#'
#' @return A split, shaped as `linad_stump()` returns one, with `gain` the
#' negated loss so the caller compares the two the same way.
#'
#' @author EDG
#' @keywords internal
#' @noRd
linad_sweep <- function(
  state,
  r,
  w,
  member,
  features = NULL,
  derivatives = NULL,
  active = NULL
) {
  context <- state[["context"]]
  xm <- state[["xm"]]
  gamma <- state[["gamma"]]
  best <- list(gain = -Inf)
  scan <- linad_scan_features(context, features)
  min_cases_child <- linad_min_child_cases(state)
  # The node's active rows, resolved once by the caller and shared with the
  # commit. A row the commit will not fit on must not contribute to the
  # statistics a candidate is scored by.
  in_scope <- logical(context[["n"]])
  in_scope[if (is.null(active)) seq_len(context[["n"]]) else active] <- TRUE

  # The node's own sufficient statistics, which every candidate's right-hand
  # side is read off by subtraction. They do not depend on the feature, so they
  # are formed once per node rather than once per feature.
  weights <- w
  total <- linad_gram(xm, r, weights, which(in_scope), derivatives)

  # One candidate, scored by the loss after fitting both child models. Every
  # search path below reaches a split through this, so a candidate is never
  # scored by a model the commit would not fit.
  score_split <- function(left, left_members, total_members) {
    if (
      left_members < min_cases_child ||
        (total_members - left_members) < min_cases_child
    ) {
      return(Inf)
    }
    # A case carries `gamma` of its weight into the branch it does not belong
    # to, so each side's statistics are a mixture of the two hard sides -- both
    # of which the caller already holds. At `gamma = 0` the mixture collapses to
    # the hard sides themselves. Every quantity here is a sum over rows, the
    # derivative totals included, so all of them mix the same way.
    mix <- function(name) {
      whole <- total[[name]]
      if (is.null(whole)) {
        return(NULL)
      }
      part <- left[[name]]
      rest <- whole - part
      list(part + gamma * rest, gamma * part + rest)
    }
    G <- mix("G")
    Xty <- mix("Xty")
    syy <- mix("syy")
    sg <- mix("sg")
    sh <- mix("sh")
    loss <- 0
    for (side in seq_len(2L)) {
      if (G[[side]][[1L, 1L]] <= 0) {
        return(Inf)
      }
      b <- linad_gram_solve(
        G[[side]],
        Xty[[side]],
        syy[[side]],
        state[["node_model"]],
        state[["lambda"]],
        state[["alpha"]],
        state[["nvmax"]],
        forward_stop = state[["forward_stop"]],
        node_test = state[["node_test"]],
        sg = if (is.null(sg)) NULL else sg[[side]],
        sh = if (is.null(sh)) NULL else sh[[side]],
        type = state[["type"]],
        max_step = state[["line_search_max"]],
        constant_rule = state[["constant_rule"]],
        allowed = state[["adaptive_columns"]]
      )
      if (is.null(b) || !all(is.finite(b))) {
        return(Inf)
      }
      loss <- loss + linad_gram_loss(G[[side]], Xty[[side]], syy[[side]], b)
    }
    loss
  }

  score_cuts <- function(order_index, cut_positions, member_counts) {
    # Sufficient statistics accumulated left-to-right in blocks, one block per
    # candidate cut, so each row is visited once per feature rather than once
    # per cut.
    d <- ncol(xm)
    running <- list(
      G = matrix(0, d, d),
      Xty = numeric(d),
      syy = 0,
      sg = if (is.null(derivatives)) NULL else 0,
      sh = if (is.null(derivatives)) NULL else 0
    )
    from <- 1L
    losses <- rep(Inf, length(cut_positions))
    total_members <- member_counts[[length(member_counts)]]
    for (k in seq_along(cut_positions)) {
      rows <- order_index[seq.int(from, cut_positions[[k]])]
      from <- cut_positions[[k]] + 1L
      rows <- rows[in_scope[rows]]
      if (length(rows) > 0L) {
        block <- linad_gram(xm, r, weights, rows, derivatives)
        for (name in names(running)) {
          if (!is.null(running[[name]])) {
            running[[name]] <- running[[name]] + block[[name]]
          }
        }
      }
      losses[[k]] <- score_split(running, member_counts[[k]], total_members)
    }
    losses
  }

  # Numeric features ----
  for (j in scan[["numeric"]]) {
    order_index <- context[["numeric_order"]][[j]]
    sorted <- context[["numeric_matrix"]][order_index, j]
    breaks <- context[["numeric_breaks"]][[j]]
    if (length(breaks) == 0L) {
      next
    }
    # Cut points among the admissible ones, so the sweep costs `n_cuts`
    # solves per feature rather than one per distinct value. If `split_binning`
    # already coarsened the feature, this thins what is left, by the same rule.
    wanted <- linad_cut_positions(
      breaks,
      state[["n_cuts"]],
      sorted,
      state[["split_bin_type"]]
    )
    wanted <- wanted[wanted >= 1L & wanted < context[["n"]]]
    if (length(wanted) == 0L) {
      next
    }
    member_counts <- cumsum(as.numeric(member)[order_index])[
      c(wanted, context[["n"]])
    ]
    losses <- score_cuts(order_index, wanted, member_counts)
    k <- which.min(losses)
    if (
      length(k) == 1L && is.finite(losses[[k]]) && -losses[[k]] > best[["gain"]]
    ) {
      position <- wanted[[k]]
      best <- list(
        gain = -losses[[k]],
        feature = context[["numeric_names"]][[j]],
        kind = "numeric",
        value = (sorted[[position]] + sorted[[position + 1L]]) / 2,
        levels = NULL,
        column = j
      )
    }
  }

  # Factor features ----
  for (j in scan[["factor"]]) {
    codes <- context[["factor_codes"]][[j]]
    levels_j <- context[["factor_levels"]][[j]]
    scoped <- weights * in_scope
    level_w <- as.vector(rowsum(scoped, codes, reorder = TRUE))
    level_wr <- as.vector(rowsum(scoped * r, codes, reorder = TRUE))
    present <- sort(unique(codes))
    keep <- level_w > 0
    if (sum(keep) < 2L) {
      next
    }
    present_levels <- present[keep]
    n_partitions <- 2^(length(present_levels) - 1L) - 1L
    # Ordering levels by mean residual makes the best contiguous split of that
    # ordering the best of all partitions -- for squared error of the *mean*.
    # This search scores by child linear models, for which the theorem does not
    # hold, so it enumerates every partition where that costs no more than a
    # numeric feature's candidates and falls back to the ordering above that.
    if (n_partitions <= state[["n_cuts"]]) {
      by_level <- lapply(
        present_levels,
        function(level) {
          linad_gram(
            xm,
            r,
            weights,
            which(codes == level & in_scope),
            derivatives
          )
        }
      )
      members_by_level <- vapply(
        present_levels,
        function(level) sum(member[codes == level]),
        numeric(1L)
      )
      total_members <- sum(members_by_level)
      # A mask below 2^(k-1) never holds the last level, so each partition and
      # its complement are enumerated once between them.
      for (mask in seq_len(n_partitions)) {
        left <- bitwAnd(mask, bitwShiftL(1L, seq_along(present_levels) - 1L)) >
          0L
        chosen <- by_level[left]
        loss <- score_split(
          list(
            G = Reduce(`+`, lapply(chosen, `[[`, "G")),
            Xty = Reduce(`+`, lapply(chosen, `[[`, "Xty")),
            syy = sum(vapply(chosen, `[[`, numeric(1L), "syy")),
            sg = if (is.null(derivatives)) {
              NULL
            } else {
              sum(vapply(chosen, `[[`, numeric(1L), "sg"))
            },
            sh = if (is.null(derivatives)) {
              NULL
            } else {
              sum(vapply(chosen, `[[`, numeric(1L), "sh"))
            }
          ),
          sum(members_by_level[left]),
          total_members
        )
        if (is.finite(loss) && -loss > best[["gain"]]) {
          best <- list(
            gain = -loss,
            feature = context[["factor_names"]][[j]],
            kind = "factor",
            value = NA_real_,
            levels = levels_j[present_levels[left]],
            column = j
          )
        }
      }
      next
    }
    ranking <- order(level_wr[keep] / level_w[keep])
    ordered_levels <- present_levels[ranking]
    order_index <- order(match(codes, ordered_levels))
    sizes <- cumsum(as.vector(table(codes)[as.character(ordered_levels)]))
    cuts <- sizes[-length(sizes)]
    if (length(cuts) == 0L) {
      next
    }
    member_counts <- cumsum(as.numeric(member)[order_index])[
      c(cuts, context[["n"]])
    ]
    losses <- score_cuts(order_index, cuts, member_counts)
    k <- which.min(losses)
    if (
      length(k) == 1L && is.finite(losses[[k]]) && -losses[[k]] > best[["gain"]]
    ) {
      best <- list(
        gain = -losses[[k]],
        feature = context[["factor_names"]][[j]],
        kind = "factor",
        value = NA_real_,
        levels = levels_j[ordered_levels[seq_len(k)]],
        column = j
      )
    }
  }

  if (!is.finite(best[["gain"]])) {
    return(NULL)
  }
  best
} # /rtemis::linad_sweep

Try the rtemis package in your browser

Any scripts or data that you put into this service are public.

rtemis documentation built on Oct. 20, 2026, 1:06 a.m.