R/tabmachine.R

Defines functions .r4vn_machine_final_table .r4vn_machine_pretty_metric .r4vn_machine_balance_table .r4vn_machine_selection_table .r4vn_machine_preprocessing_table .r4vn_machine_overview .r4vn_machine_bind .r4vn_machine_learning_curve .r4vn_machine_pdp .r4vn_machine_shap .r4vn_machine_importance .r4vn_machine_decision .r4vn_machine_decision_nb .r4vn_machine_calibration .r4vn_machine_simplify .r4vn_machine_compare_balance .r4vn_machine_compare_selection .r4vn_machine_baseline_method .r4vn_machine_coefficients .r4vn_machine_overfitting .r4vn_machine_model_differences .r4vn_machine_prediction_map .r4vn_machine_choose_best .r4vn_machine_comparison .r4vn_machine_threshold .r4vn_machine_threshold_point .r4vn_machine_evaluate .r4vn_machine_auc_ci .r4vn_machine_multiclass_class_performance .r4vn_machine_boot_multi .r4vn_machine_boot_reg .r4vn_machine_boot_binary .r4vn_machine_multi_metric_value .r4vn_machine_multi_metrics .r4vn_machine_reg_metric_value .r4vn_machine_reg_metrics .r4vn_machine_binary_metric_value .r4vn_machine_binary_metrics .r4vn_machine_pr_auc .r4vn_machine_auc .r4vn_machine_wilson .r4vn_machine_div .r4vn_machine_tune .r4vn_machine_metric_from_cv .r4vn_machine_oof_unique_binary .r4vn_machine_cv_one .r4vn_machine_predict_pipeline .r4vn_machine_fit_pipeline .r4vn_machine_predict_engine .r4vn_machine_knn_predict `%||%` .r4vn_machine_fit_engine .r4vn_machine_resolve_params .r4vn_machine_tune_grid .r4vn_machine_default_params .r4vn_machine_balance_apply .r4vn_machine_smote .r4vn_machine_knn_dist .r4vn_machine_updown .r4vn_machine_class_weights .r4vn_machine_feature_select .r4vn_machine_univariate_score .r4vn_machine_auto_nfeatures .r4vn_machine_scale_method .r4vn_machine_needs_scale .r4vn_machine_scale_apply .r4vn_machine_scale_fit .r4vn_machine_recipe_apply .r4vn_machine_recipe_fit .r4vn_machine_pca_apply .r4vn_machine_pca_fit .r4vn_machine_feature_expand_apply .r4vn_machine_feature_expand_fit .r4vn_machine_feature_mode .r4vn_machine_corr_threshold .r4vn_machine_corr_remove .r4vn_machine_yj_lambda .r4vn_machine_yj .r4vn_machine_nzv .r4vn_machine_mode .r4vn_machine_engine_table .r4vn_machine_methods .r4vn_machine_method_ok .r4vn_machine_method_package .r4vn_machine_resolve_balance .r4vn_machine_resolve_select .r4vn_machine_metric_direction .r4vn_machine_metric .r4vn_machine_make_folds .r4vn_machine_split .r4vn_machine_strata_indices .r4vn_machine_data_warnings .r4vn_machine_apply_outcome .r4vn_machine_outcome .r4vn_machine_positive_guess .r4vn_machine_get_label .r4vn_machine_labels .r4vn_machine_label .r4vn_machine_predictor_names .r4vn_machine_names .r4vn_machine_one_name .r4vn_machine_resolve_data .r4vn_machine_validate_scalar tabmachine

Documented in tabmachine

# ============================================================================
# R4VN tabmachine() - comprehensive machine-learning workflow
# Package-ready implementation
# ============================================================================

#' Comprehensive machine-learning analysis with publication-ready reporting
#'
#' @description
#' `tabmachine()` is the comprehensive machine-learning command in R4VN. It is
#' designed for health and biomedical research where the user needs a complete,
#' reproducible workflow rather than only a fitted prediction model.
#'
#' The function can detect the prediction task, split development data,
#' preprocess predictors, handle missing values, encode categorical predictors,
#' detect problematic predictors, standardize predictors when required,
#' transform skewed numeric variables when explicitly requested, address class
#' imbalance, select predictors, tune candidate algorithms, perform cross-
#' validation, compare models, select a final model, determine a classification
#' threshold using training data only, evaluate the untouched test set, calculate
#' confidence intervals for performance measures whenever a defensible interval
#' is implemented, assess calibration and clinical utility, calculate variable
#' importance, and generate prediction-ready model objects.
#'
#' The central R4VN principle is that automation must remain transparent.
#' `"auto"` may choose an analysis action, but every action is stored in the
#' returned object and shown in the report. Preprocessing, feature selection,
#' class balancing, tuning, and threshold optimization are learned from training
#' data only. The held-out test data are not used to make those decisions.
#'
#' @param outcome Outcome variable. Supply an unquoted variable name or a
#'   one-element character name. Binary, multiclass, and continuous outcomes are
#'   supported.
#' @param x Candidate predictors. Use `vars(age, sex, bmi)`, a character vector,
#'   a single bare variable, or `.` to use every variable except `outcome` and
#'   `exclude`. If omitted, all other variables are used.
#' @param data Optional data frame. If omitted, the active R4VN data selected by
#'   `usedf()` or `opendata(..., active = TRUE)` is used.
#' @param exclude Optional predictors to exclude, supplied as `vars(...)`, a bare
#'   variable, or a character vector. Typical examples are identifiers, names,
#'   dates that would leak future information, or variables unavailable at the
#'   intended prediction time.
#' @param task Prediction task: `"auto"` (default), `"binary"`, `"multiclass"`,
#'   or `"regression"`. In auto mode, an outcome with two observed levels is
#'   binary; a factor/character outcome or a low-cardinality integer outcome is
#'   multiclass; otherwise a numeric outcome is regression.
#' @param event Positive/event level for binary classification. If omitted,
#'   R4VN recognizes common positive encodings such as 1, TRUE, Yes, Positive,
#'   Case, or Co; otherwise the second observed level is used. The chosen event
#'   is always reported.
#'
#' @param preprocess Preprocessing policy. `"auto"` performs safe structural
#'   checks, imputation, factor-level alignment, dummy encoding, zero/near-zero
#'   variance removal, and optional correlation filtering. `"none"` disables
#'   automatic structural filtering but still creates a usable model matrix.
#' @param missing Missing-value handling for predictors: `"auto"`, `"median"`,
#'   `"mean"`, `"mode"`, or `"complete"`. `"auto"` uses median for numeric and
#'   mode for categorical predictors. Imputation values are estimated on each
#'   training fold and then applied to its validation fold. Outcome missingness
#'   is never imputed.
#' @param missing_max Maximum allowed proportion missing in a candidate predictor
#'   before automatic structural filtering removes it. Default 0.50.
#' @param encode Encoding of categorical predictors. Currently `"auto"` and
#'   `"dummy"` use treatment/dummy coding through a training-derived
#'   `model.matrix` blueprint. New/unseen levels in validation data are mapped
#'   to the training reference fallback and reported.
#' @param standardize Standardization policy: `"auto"`, `"none"`, `"z"`,
#'   `"minmax"`, or `"robust"`. In auto mode, z-standardization is applied only
#'   inside algorithms that materially benefit from scaling (penalized models,
#'   SVM, KNN, and multinomial neural optimization). Tree-based models receive
#'   the unscaled encoded matrix.
#' @param transform Numeric transformation: `"none"` (default), `"auto"`,
#'   `"log"`, or `"yeojohnson"`. `"auto"` is intentionally conservative and
#'   currently performs no transformation unless a future R4VN rule explicitly
#'   justifies one. `"log"` uses a training-derived shift when necessary.
#'   `"yeojohnson"` estimates lambda on the training data by profile likelihood.
#' @param outlier Outlier policy: `"none"` (default), `"detect"`, `"winsor"`, or
#'   `"robust"`. Detection uses training-data IQR rules and never deletes rows.
#'   `"winsor"` caps numeric values at the 1st and 99th training percentiles;
#'   `"robust"` caps at median +/- 5 MAD. Cut points are then reused unchanged
#'   for validation/test data.
#' @param corr Correlation filtering for numeric candidate predictors. `"auto"`
#'   uses 0.95, a numeric value in (0,1) supplies a custom absolute-correlation
#'   threshold, and `FALSE`/`"none"` disables correlation filtering. Filtering
#'   is learned separately inside each training fold.
#'
#' @param feature Feature engineering: `"none"` (default), `"auto"`,
#'   `"polynomial"`, `"interaction"`, or `"all"`. `"polynomial"` adds powers
#'   of numeric predictors up to `degree`; `"interaction"` adds pairwise
#'   products among numeric predictors; `"all"` adds both. `"auto"` is
#'   intentionally conservative and currently behaves as `"none"`. All
#'   engineered features are created from a fold-specific training blueprint.
#' @param degree Highest polynomial degree for numeric feature engineering.
#'   Default 2; values 2 or 3 are supported.
#' @param reduce Dimensionality reduction: `"none"` (default), `"auto"`, or
#'   `"pca"`. PCA is learned only on the training fold after encoding/feature
#'   engineering. `"auto"` uses PCA only in strongly high-dimensional settings
#'   (more encoded features than training observations and at least 50 encoded
#'   features); otherwise it remains off to preserve clinical interpretability.
#' @param variance Target cumulative variance retained by PCA. Default 0.95.
#'
#' @param select Feature-selection strategy: `"auto"`, `"none"`, `"filter"`,
#'   `"lasso"`, `"stepwise"`, `"importance"`, `"rfe"`, `"boruta"`, or
#'   `"compare"`. `"auto"` deliberately resolves to the native R4VN `"filter"`
#'   strategy so the default workflow never depends on an optional selection
#'   package. LASSO uses `glmnet` when explicitly requested. Boruta uses the optional `Boruta`
#'   package. `"compare"` compares available strategies by cross-validation on
#'   the development training set and does not inspect the held-out test set.
#' @param nfeatures Number of predictors to retain for importance/RFE selection,
#'   or `"auto"`. With `"auto"`, R4VN chooses a compact candidate size based on
#'   the training sample size and number of available encoded predictors.
#' @param simplify Logical; after choosing the best algorithm, search for a
#'   smaller predictor set whose development cross-validated performance is
#'   within `simplify_tol` of the larger selected model. This step is training-
#'   only. Default TRUE.
#' @param simplify_tol Maximum acceptable loss in the primary metric when
#'   preferring a smaller model. For metrics where larger is better this is an
#'   absolute decrease; for RMSE/MAE it is an absolute increase. Default 0.01.
#'
#' @param balance Class-imbalance handling for binary classification:
#'   `"auto"`, `"none"`, `"weight"`, `"up"`, `"down"`, `"smote"`,
#'   `"adasyn"`, `"rose"`, or `"compare"`. Balancing is applied only to the
#'   training portion of each resample. Test/validation observations are never
#'   resampled. `"auto"` uses class weighting when the minority class is below
#'   20 percent and otherwise leaves the sample unchanged. `"compare"` compares
#'   available approaches on development cross-validation.
#' @param balance_target Target minority proportion after sampling. Default 0.50.
#' @param neighbors Number of nearest neighbors for native SMOTE/ADASYN. Default 5.
#'
#' @param split Development/test split. A single number such as 0.80 means 80%
#'   training and 20% untouched test data. `FALSE` uses all development data for
#'   model development (appropriate when a separate `validation` data set is
#'   supplied). A length-three vector such as `c(.70,.15,.15)` creates training,
#'   internal validation, and test partitions; the internal validation portion
#'   is combined with training for final refitting after model decisions are
#'   completed, while the test portion remains untouched.
#' @param folds Number of cross-validation folds in the development training
#'   sample. Default 10. Classification folds are stratified when possible.
#' @param repeats Number of repeated cross-validation repetitions. Default 1.
#' @param nested Logical; if TRUE, hyperparameter tuning is repeated within each
#'   outer cross-validation fold. This is computationally expensive but gives a
#'   less optimistic development estimate. Regardless of this option, the final
#'   held-out test evaluation remains untouched by tuning.
#' @param seed Optional random seed used for splitting, resampling, tuning, and bootstrap. The default `NULL` does not set a seed.
#'
#' @param method Algorithms to fit. `"auto"` deliberately uses a compact
#'   low-dependency R4VN core set so that a routine analysis does not require
#'   installation of an ML ecosystem: logistic regression plus a decision tree
#'   for binary outcomes, linear regression plus a decision tree for continuous
#'   outcomes, and multinomial regression plus a decision tree and R4VN-native
#'   KNN for multiclass outcomes. The native KNN fallback keeps multiclass auto
#'   usable even if recommended modelling packages are unavailable. `"all"` tries every
#'   supported engine that is already installed. Optional engines are never
#'   installed automatically. Methods may also be supplied explicitly:
#'   `"logistic"`, `"linear"`, `"multinom"`, `"lasso"`, `"ridge"`, `"elastic"`,
#'   `"tree"`, `"rf"`, `"xgb"`, `"svm"`, `"knn"`, `"naive"`.
#' @param tune Hyperparameter tuning. `TRUE`/`"grid"` uses compact clinically
#'   practical grids; `"random"` samples candidate combinations; FALSE uses
#'   defaults. Penalized `glmnet` engines select lambda internally by CV.
#' @param tune_n Maximum random-search combinations when `tune = "random"`.
#' @param metric Primary model-selection metric. `"auto"` uses ROC-AUC for most
#'   binary problems, PR-AUC when the event is uncommon, macro F1 for multiclass,
#'   and RMSE for regression. Other supported names include `auc`, `pr_auc`,
#'   `accuracy`, `balanced_accuracy`, `sensitivity`, `specificity`, `f1`,
#'   `brier`, `logloss`, `macro_f1`, `weighted_f1`, `macro_auc`,
#'   `macro_pr_auc`, `rmse`, `mae`, `r2`, and `mape`.
#'
#' @param threshold Binary classification threshold. A numeric value between 0 and 1 fixes the
#'   cutoff. `"auto"`/`"youden"` maximizes sensitivity + specificity - 1 using
#'   out-of-fold predictions from the development training set. `"f1"` maximizes
#'   F1. `"sens"` chooses the most specific threshold achieving at least
#'   `target_sens`; `"spec"` chooses the most sensitive threshold achieving at
#'   least `target_spec`. The test set is never used to choose the threshold.
#' @param target_sens Target sensitivity used when `threshold = "sens"`.
#' @param target_spec Target specificity used when `threshold = "spec"`.
#'
#' @param ci Logical; calculate 95% confidence intervals (or the level supplied
#'   by `ci_level`) for performance measures whenever an implemented interval is
#'   statistically meaningful. Default TRUE.
#' @param ci_level Confidence level, default 0.95.
#' @param boot Number of bootstrap replicates for performance measures whose
#'   interval has no preferred closed-form method. Default 1000. For final
#'   publication analyses, 2000 or more may be preferred when runtime permits.
#' @param calibration Logical; for binary classification, calculate calibration
#'   intercept, calibration slope, Brier score, and calibration-curve data.
#' @param decision Logical; for binary classification, calculate decision-curve
#'   net benefit for the final model over `decision_thresholds`.
#' @param decision_thresholds Probability thresholds for decision-curve analysis.
#'   Default `seq(.01, .99, .01)`.
#' @param learning Logical; calculate training-size learning-curve summaries for
#'   the final model. Default FALSE because it can be computationally expensive.
#'
#' @param importance Logical; calculate permutation importance for the final
#'   model. Default TRUE. Importance is grouped back to original predictor names
#'   when dummy variables were created.
#' @param importance_repeats Number of repeated permutations used to stabilize
#'   permutation importance. Default 20.
#' @param explain Logical; retain explanation data and display the principal
#'   importance/calibration information in the report. Default TRUE.
#' @param shap Logical; calculate SHAP-like contribution output when a supported
#'   engine is available. Native XGBoost `predcontrib` is used for XGBoost;
#'   other engines are left as `NULL` unless a future R4VN explainer is added.
#' @param pdp Logical or character vector. TRUE calculates partial-dependence
#'   data for up to the five most important original numeric predictors;
#'   a character vector requests specific predictors.
#'
#' @param validation Optional external validation data frame. It must contain the
#'   same outcome and required predictors. It is never used for preprocessing,
#'   selection, tuning, balancing, threshold selection, or final model fitting.
#' @param predict Optional new data frame for predictions after the final model
#'   is fitted. Predictions are returned in `$predictions`.
#' @param id Optional identifier variable to copy into prediction output.
#'
#' @param show Logical; open the publication-style HTML report in the Viewer.
#'   Default TRUE.
#' @param plot Logical or character vector controlling figures embedded in the
#'   HTML Viewer. TRUE embeds every principal figure available for the analysis.
#'   Character values may include `"roc"`, `"pr"`, `"calibration"`,
#'   `"threshold"`, `"confusion"`, `"importance"`, `"decision"`, `"learning"`,
#'   `"observed"`, `"residual"`, `"pdp"`, and `"shap"`; `"all"` requests every
#'   available figure.
#'   Any figure sent to the Plot pane through `plot_display` is also retained in
#'   the Viewer whenever plotting is enabled.
#' @param plot_display Figure type(s) also drawn in the interactive R/RStudio
#'   Plot pane and Plot history. `"auto"` (default) draws the most useful
#'   diagnostic figures for the task. Use `"all"` for every available figure,
#'   `"none"` to keep figures only in the Viewer, or a character vector of figure
#'   names. This option never changes model fitting or performance estimates.
#' @param plot_args Named list of base-graphics options used for Viewer and Plot
#'   pane figures. Options may be common (for example
#'   `list(font_family="Arial")`) or nested, for example
#'   `list(all=list(font_family="Arial"), roc=list(lwd=3))`.
#' @param strict Logical. If FALSE (default), a non-essential figure that cannot
#'   be drawn is skipped with a warning while the analysis result is retained.
#'   If TRUE, such a plotting error stops the call.
#' @param console Logical; also print a compact console summary. Default FALSE.
#' @param digit Number of digits displayed for estimates. Default 3.
#' @param title Optional report title.
#' @param ai FALSE, TRUE, or a named R4VN AI endpoint. When R4VN `aiask()` is
#'   available, interpretation is requested only after the quantitative analysis
#'   object has been created. `tabmachine()` never calls AI when `ai = FALSE`;
#'   when enabling AI, the privacy behavior of the configured `aiask()` endpoint
#'   should be reviewed because fitted model objects may contain development
#'   information needed for prediction.
#' @param ... Reserved for future model-engine options.
#'
#' @details
#' ## 1. What `tabmachine()` regards as a complete ML workflow
#'
#' A typical call performs the following sequence:
#'
#' 1. resolve active/explicit data and variable labels;
#' 2. validate the outcome and candidate predictors;
#' 3. create an untouched test partition;
#' 4. inside training resamples, learn imputation/transformation/encoding rules;
#' 5. remove structural problems such as constant predictors;
#' 6. optionally create polynomial/interaction features and/or training-fold PCA;
#' 7. apply feature selection inside the training portion of each resample;
#' 8. apply class balancing only inside the training portion of each resample;
#' 9. tune and compare candidate algorithms;
#' 10. choose the final algorithm using development data only;
#' 11. choose a binary classification threshold from training out-of-fold
#'     predictions only;
#' 12. refit the selected pipeline on the full development training sample;
#' 13. evaluate the untouched test set and calculate confidence intervals;
#' 14. optionally validate on a completely external data set;
#' 15. assess calibration, decision-curve utility, and variable importance;
#' 16. store a prediction blueprint for future `predict()` calls.
#'
#' ## 2. Confidence intervals
#'
#' `ci = TRUE` is the default because R4VN is intended for scientific reporting.
#' The implementation does not attach a made-up CI to a quantity merely because
#' a point estimate exists. Methods currently used are:
#'
#' * sensitivity, specificity, PPV, NPV, accuracy, and prevalence: Wilson
#'   binomial intervals;
#' * ROC-AUC: DeLong interval through `pROC` when available; otherwise a
#'   stratified nonparametric bootstrap interval;
#' * an optimized binary classification threshold: stratified bootstrap interval; a user-fixed threshold has no sampling CI because it is specified rather than estimated;
#' * balanced accuracy, F1, MCC, kappa, PR-AUC, Brier score, log loss and other
#'   derived binary metrics: paired-observation nonparametric bootstrap;
#' * calibration intercept and slope: model-based Wald intervals, with bootstrap
#'   fallback when the calibration model is unstable;
#' * RMSE, MAE, R-squared and MAPE: nonparametric bootstrap over test subjects;
#' * multiclass accuracy/balanced accuracy/macro-F1/weighted-F1, macro one-vs-rest
#'   AUC/PR-AUC, and log loss: nonparametric bootstrap over test subjects;
#' * class-specific multiclass one-vs-rest sensitivity, specificity, PPV, NPV,
#'   accuracy and prevalence: Wilson intervals where the denominator is fixed;
#'   class-specific AUC uses DeLong through `pROC` when available, while PR-AUC,
#'   F1 and other derived class measures use nonparametric bootstrap;
#' * decision-curve net benefit: pointwise nonparametric bootstrap when `ci=TRUE`.
#'
#' These intervals quantify uncertainty in performance on the evaluation sample
#' conditional on the fitted development procedure. They do not replace full
#' external validation or transportability assessment.
#'
#' ## 3. Leakage prevention
#'
#' The most important implementation rule is that no data-dependent
#' preprocessing action is estimated on the test set. Imputation values,
#' transformations, factor levels, scaling parameters, feature selection,
#' balancing, tuning, and threshold optimization are fitted using training data.
#' During CV, those operations are refitted inside each training fold before
#' predictions are made for the corresponding validation fold.
#'
#' ## 4. Class imbalance
#'
#' `balance = "weight"` is the preferred automatic strategy because it does not
#' fabricate observations. `up`, `down`, native numeric-space `smote`, native
#' `adasyn`, and optional `ROSE` are available for explicit experiments. Synthetic
#' sampling occurs after fold-specific numeric encoding and is therefore applied
#' only to the analysis portion of a resample. The original test prevalence is
#' preserved for evaluation, PPV/NPV, calibration, Brier score and decision curves.
#'
#' ## 5. Feature selection versus feature importance
#'
#' `select` controls which predictors are allowed into the fitted model.
#' `importance` explains which predictors contribute most to a fitted final
#' model. They are intentionally separate concepts. A variable may survive
#' selection yet have weak final permutation importance, and correlated
#' variables may share or exchange importance.
#'
#' ## 6. Model comparison
#'
#' Development CV estimates are useful for choosing an algorithm. The untouched
#' test estimate is the primary internal-validation result. When several models
#' are evaluated on the same test subjects, `$performance` includes a CI for each
#' available metric. `$model_difference` additionally stores paired bootstrap
#' differences in the primary metric between each model and the selected model.
#'
#' ## 7. External validation
#'
#' Supply `validation = external_data` to evaluate the finalized development
#' pipeline without refitting it. External performance and its CIs are stored
#' separately. If external validation is the principal evaluation, use
#' `split = FALSE` to use all development observations for model development.
#'
#' ## 8. Optional packages and the low-dependency default
#'
#' R4VN deliberately does not require every ML engine for every user. The
#' default `method = "auto"` is intentionally based on base/recommended R
#' engines and does not require `caret`, `tidymodels`, `recipes`, `yardstick`,
#' or a collection of boosting/forest packages. Optional modelling engines are
#' checked only when the user explicitly requests them or uses `method = "all"`.
#' `glmnet` supplies penalized models; `ranger` random forests; `xgboost`
#' gradient boosting; `e1071` SVM and naive Bayes; `Boruta` Boruta feature
#' selection; `ROSE` ROSE sampling; and `pROC` DeLong ROC intervals. When
#' `pROC` is absent, R4VN uses its native bootstrap AUC interval. The returned
#' `$engines` table records what was requested, installed, and actually used.
#'
#' @return
#' Invisibly returns an object of class `c("r4vn_machine", "r4vn_tab")` with
#' major components:
#' \describe{
#'   \item{overview}{Data/task overview.}
#'   \item{engines}{Supported algorithms, package requirements, availability, and engines actually used.}
#'   \item{preprocessing}{Auditable preprocessing decisions.}
#'   \item{balance}{Chosen imbalance strategy and comparison when requested.}
#'   \item{selection}{Chosen feature-selection strategy and selected predictors.}
#'   \item{tuning}{Selected hyperparameters for each candidate algorithm.}
#'   \item{cv_performance}{Development cross-validation performance.}
#'   \item{performance}{Held-out test performance in long format with CI columns.}
#'   \item{comparison}{Publication-ready model comparison table.}
#'   \item{model_difference}{Paired difference in the primary metric versus the
#'     selected model, with bootstrap CI where available.}
#'   \item{overfitting}{Development-versus-evaluation comparison for the primary metric.}
#'   \item{coefficients}{For an unpenalized logistic or linear final model, model coefficients with 95% CI; logistic coefficients are exponentiated to OR.}
#'   \item{best}{Name of the selected algorithm.}
#'   \item{final}{Final fitted pipeline/model object.}
#'   \item{threshold}{Training-derived classification threshold information.}
#'   \item{confusion}{Final binary or multiclass confusion matrix counts.}
#'   \item{class_performance}{For multiclass outcomes, one-vs-rest class-specific discrimination and classification measures with 95% CI.}
#'   \item{calibration}{Calibration statistics and curve data.}
#'   \item{decision}{Decision-curve data.}
#'   \item{importance}{Grouped permutation importance.}
#'   \item{explanation}{Convenience list collecting importance, SHAP and PDP outputs when `explain = TRUE`.}
#'   \item{shap}{Native XGBoost contribution matrix when requested/supported.}
#'   \item{pdp}{Partial-dependence data when requested.}
#'   \item{external_performance}{External-validation performance when supplied.}
#'   \item{external_confusion}{External binary or multiclass confusion matrix when applicable.}
#'   \item{external_class_performance}{External multiclass one-vs-rest performance with CI.}
#'   \item{external_calibration}{External binary calibration statistics/curve when requested.}
#'   \item{external_decision}{External binary decision-curve data when requested.}
#'   \item{predictions}{Predictions for `predict=` data when supplied.}
#'   \item{plots}{Data required to replay publication plots.}
#'   \item{plot_titles}{Stable publication titles for available figure types.}
#'   \item{tables}{Named publication-ready tables used by R4VN/Studio/export.}
#'   \item{html}{Finished HTML report.}
#'   \item{settings}{Complete analysis settings.}
#'   \item{notes}{Methodological notes and any optional-engine skips.}
#' }
#'
#' @section Examples - binary classification:
#' ```
#' set.seed(123)
#' n <- 500
#' d <- data.frame(
#'   patient_id = seq_len(n),
#'   age = rnorm(n, 45, 12),
#'   sex = factor(sample(c("Female", "Male"), n, TRUE)),
#'   bmi = rnorm(n, 23, 3.5),
#'   smoke = factor(sample(c("No", "Yes"), n, TRUE, c(.75, .25)))
#' )
#' lp <- -6 + .055*d$age + .10*d$bmi + .65*(d$smoke == "Yes")
#' d$hypertension <- factor(rbinom(n, 1, plogis(lp)), 0:1, c("No", "Yes"))
#'
#' m <- tabmachine(
#'   hypertension,
#'   x = vars(age, sex, bmi, smoke),
#'   data = d,
#'   event = "Yes",
#'   method = "logistic",
#'   tune = FALSE,
#'   boot = 200,
#'   show = FALSE,
#'   plot = FALSE
#' )
#' m$comparison
#' m$performance
#' ```
#'
#' @section Examples - automatic ML comparison:
#' ```
#' \donttest{
#' # The automatic comparison uses the low-dependency R4VN core set.
#' # Optional engines join only when explicitly requested or method = "all".
#' m <- tabmachine(
#'   hypertension,
#'   x = vars(age, sex, bmi, smoke),
#'   data = d,
#'   event = "Yes",
#'   method = "auto",
#'   select = "auto",
#'   balance = "auto",
#'   ci = TRUE,
#'   boot = 2000
#' )
#' plot(m, "roc")
#' plot(m, "calibration")
#' plot(m, "importance")
#' }
#' ```
#'
#' @section Examples - class imbalance and feature selection:
#' ```
#' \donttest{
#' m2 <- tabmachine(
#'   hypertension,
#'   x = .,
#'   data = d,
#'   exclude = vars(patient_id),
#'   event = "Yes",
#'   balance = "compare",
#'   select = "compare",
#'   metric = "pr_auc",
#'   nested = TRUE
#' )
#' m2$balance
#' m2$selection
#' }
#' ```
#'
#' @section Examples - continuous outcome:
#' ```
#' set.seed(321)
#' r <- data.frame(
#'   age = rnorm(400, 50, 14),
#'   bmi = rnorm(400, 24, 4),
#'   sex = factor(sample(c("Female", "Male"), 400, TRUE))
#' )
#' r$sbp <- 75 + .65*r$age + 1.15*r$bmi + 4*(r$sex == "Male") + rnorm(400, 0, 9)
#'
#' mr <- tabmachine(
#'   sbp,
#'   x = vars(age, bmi, sex),
#'   data = r,
#'   method = "linear",
#'   tune = FALSE,
#'   boot = 200,
#'   show = FALSE,
#'   plot = FALSE
#' )
#' mr$performance
#' ```
#'
#' @section Examples - active data and new predictions:
#' ```
#' \donttest{
#' usedf(d)
#' fit <- tabmachine(
#'   hypertension,
#'   x = vars(age, sex, bmi, smoke),
#'   event = "Yes",
#'   method = "logistic"
#' )
#'
#' newpatients <- data.frame(
#'   age = c(35, 68),
#'   sex = factor(c("Female", "Male"), levels = levels(d$sex)),
#'   bmi = c(21, 31),
#'   smoke = factor(c("No", "Yes"), levels = levels(d$smoke))
#' )
#' predict(fit, newpatients)
#' }
#' ```
#'
#' @section Examples - external validation:
#' ```
#' \donttest{
#' dev <- d[1:350, ]
#' ext <- d[351:500, ]
#' me <- tabmachine(
#'   hypertension,
#'   x = vars(age, sex, bmi, smoke),
#'   data = dev,
#'   validation = ext,
#'   split = FALSE,
#'   event = "Yes",
#'   method = "auto"
#' )
#' me$external_performance
#' }
#' ```
#'
#' @section Examples - Viewer and Plot pane together:
#' ```
#' \donttest{
#' # Every requested figure remains in the Viewer. The selected figures are
#' # also added to Plot history so Previous/Next can be used in RStudio.
#' mv <- tabmachine(
#'   hypertension, vars(age, sex, bmi, smoke), data = d, event = "Yes",
#'   method = "logistic", tune = FALSE, boot = 200,
#'   plot = TRUE,
#'   plot_display = c("roc", "calibration", "confusion", "importance")
#' )
#' names(mv$plots)
#' mv$settings$viewer_plots
#' mv$settings$display_plots
#'
#' # Keep all figures in Viewer but draw none in the Plot pane.
#' mv2 <- tabmachine(
#'   hypertension, vars(age, sex, bmi, smoke), data = d, event = "Yes",
#'   method = "logistic", tune = FALSE, boot = 200,
#'   plot = TRUE, plot_display = "none"
#' )
#' }
#' ```
#'
#' @section Examples - classification thresholds:
#' ```
#' \donttest{
#' myouden <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, threshold="youden",
#'   boot=200, show=FALSE, plot=FALSE)
#' mf1 <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, threshold="f1",
#'   boot=200, show=FALSE, plot=FALSE)
#' msens <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, threshold="sens",
#'   target_sens=.90, boot=200, show=FALSE, plot=FALSE)
#' mspec <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, threshold="spec",
#'   target_spec=.90, boot=200, show=FALSE, plot=FALSE)
#' mfixed <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, threshold=.20,
#'   boot=200, show=FALSE, plot=FALSE)
#' myouden$threshold
#' plot(myouden, "threshold")
#' }
#' ```
#'
#' @section Examples - missing data, correlation, outliers, and transformations:
#' ```
#' \donttest{
#' mp <- tabmachine(
#'   hypertension, vars(age, sex, bmi, smoke), data=d, event="Yes",
#'   method="logistic", tune=FALSE,
#'   missing="auto", corr=.90, outlier="winsor", transform="none",
#'   boot=200, show=FALSE, plot=FALSE
#' )
#' mp$preprocessing
#' }
#' ```
#'
#' @section Examples - imbalance without extra packages:
#' ```
#' \donttest{
#' # weight, up, down, SMOTE, and ADASYN are implemented inside R4VN.
#' mw <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, balance="weight",
#'   metric="pr_auc", boot=200, show=FALSE, plot=FALSE)
#' msmote <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, balance="smote",
#'   metric="pr_auc", boot=200, show=FALSE, plot=FALSE)
#' mw$balance
#' msmote$balance
#' }
#' ```
#'
#' @section Examples - feature engineering and PCA:
#' ```
#' \donttest{
#' mfeat <- tabmachine(hypertension, vars(age, bmi), data=d, event="Yes",
#'   method="logistic", tune=FALSE, feature="all", degree=2,
#'   boot=200, show=FALSE, plot=FALSE)
#' mfeat$selection
#'
#' mpdp <- tabmachine(hypertension, vars(age, bmi, smoke), data=d, event="Yes",
#'   method="logistic", tune=FALSE, pdp=c("age","bmi"), boot=200,
#'   plot=TRUE, plot_display="pdp")
#' mpdp$pdp
#' plot(mpdp, "pdp")
#'
#' set.seed(11)
#' hd <- as.data.frame(matrix(rnorm(180*60),180,60))
#' names(hd) <- paste0("x",1:60)
#' hd$y <- factor(rbinom(180,1,plogis(hd$x1-.7*hd$x2+.5*hd$x3)),0:1,c("No","Yes"))
#' mpca <- tabmachine(y, x=., data=hd, event="Yes", method="logistic",
#'   tune=FALSE, reduce="pca", variance=.90, select="filter",
#'   simplify=FALSE, boot=100, show=FALSE, plot=FALSE)
#' mpca$selection
#' }
#' ```
#'
#' @section Examples - feature selection and parsimony:
#' ```
#' \donttest{
#' mfilter <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, select="filter",
#'   simplify=TRUE, boot=200, show=FALSE, plot=FALSE)
#' mfilter$selection
#' mfilter$simplify
#'
#' # Penalized selection is optional and used only when glmnet is installed.
#' if (requireNamespace("glmnet", quietly=TRUE)) {
#'   mlasso <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'     event="Yes", method="lasso", select="lasso", boot=200,
#'     show=FALSE, plot=FALSE)
#'   mlasso$selection
#' }
#' }
#' ```
#'
#' @section Examples - repeated and nested validation:
#' ```
#' \donttest{
#' mrep <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method=c("logistic","tree"), tune=TRUE,
#'   folds=5, repeats=3, boot=200, show=FALSE, plot=FALSE)
#' mrep$cv_performance
#'
#' mnested <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method=c("logistic","tree"), tune=TRUE,
#'   folds=5, nested=TRUE, boot=200, show=FALSE, plot=FALSE)
#' mnested$cv_performance
#' }
#' ```
#'
#' @section Examples - three-way split:
#' ```
#' \donttest{
#' msplit <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", split=c(.70,.15,.15), method="auto", boot=200,
#'   show=FALSE, plot=FALSE)
#' msplit$overview
#' }
#' ```
#'
#' @section Examples - calibration, clinical utility, and learning curve:
#' ```
#' \donttest{
#' mclin <- tabmachine(hypertension, vars(age, sex, bmi, smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE,
#'   calibration=TRUE, decision=TRUE, learning=TRUE, boot=200,
#'   plot=TRUE, plot_display="all")
#' mclin$calibration$statistics
#' head(mclin$decision)
#' mclin$learning
#' }
#' ```
#'
#' @section Examples - regression diagnostics:
#' ```
#' \donttest{
#' mr2 <- tabmachine(sbp, vars(age,bmi,sex), data=r, method="auto",
#'   boot=200, plot=TRUE, plot_display=c("observed","residual","importance"))
#' mr2$comparison
#' plot(mr2,"observed")
#' plot(mr2,"residual")
#' }
#' ```
#'
#' @section Examples - multiclass classification:
#' ```
#' \donttest{
#' ir <- iris
#' mm <- tabmachine(Species, vars(Sepal.Length,Sepal.Width,Petal.Length,Petal.Width),
#'   data=ir, method="auto", folds=5, boot=200,
#'   plot=TRUE, plot_display=c("confusion","importance"))
#' mm$performance
#' mm$class_performance
#' mm$confusion
#' plot(mm,"confusion")
#' }
#' ```
#'
#' @section Examples - predictions for new patients:
#' ```
#' \donttest{
#' newpatients <- data.frame(
#'   patient_id=c("P001","P002"), age=c(35,68),
#'   sex=factor(c("Female","Male"),levels=levels(d$sex)),
#'   bmi=c(21,31), smoke=factor(c("No","Yes"),levels=levels(d$smoke))
#' )
#' mpred <- tabmachine(hypertension, vars(age,sex,bmi,smoke), data=d,
#'   event="Yes", method="logistic", tune=FALSE, boot=200,
#'   predict=newpatients, id=patient_id, show=FALSE, plot=FALSE)
#' mpred$predictions
#' predict(mpred,newpatients,type="prob")
#' predict(mpred,newpatients,type="class")
#' }
#' ```
#'
#' @section Examples - optional advanced engines:
#' ```
#' \donttest{
#' # None of these packages is needed for the ordinary R4VN auto workflow.
#' if (requireNamespace("ranger",quietly=TRUE)) {
#'   mrf <- tabmachine(hypertension, vars(age,sex,bmi,smoke), data=d,
#'     event="Yes", method="rf", boot=200, show=FALSE, plot=FALSE)
#' }
#' if (requireNamespace("xgboost",quietly=TRUE)) {
#'   mxgb <- tabmachine(hypertension, vars(age,sex,bmi,smoke), data=d,
#'     event="Yes", method="xgb", shap=TRUE, boot=200, show=FALSE, plot=FALSE)
#'   mxgb$shap
#'   plot(mxgb, "shap")
#' }
#' if (requireNamespace("e1071",quietly=TRUE)) {
#'   msvm <- tabmachine(hypertension, vars(age,sex,bmi,smoke), data=d,
#'     event="Yes", method="svm", boot=200, show=FALSE, plot=FALSE)
#' }
#' if (requireNamespace("Boruta",quietly=TRUE)) {
#'   mb <- tabmachine(hypertension, vars(age,sex,bmi,smoke), data=d,
#'     event="Yes", method="logistic", select="boruta", tune=FALSE,
#'     boot=200, show=FALSE, plot=FALSE)
#' }
#' if (requireNamespace("ROSE",quietly=TRUE)) {
#'   mrose <- tabmachine(hypertension, vars(age,sex,bmi,smoke), data=d,
#'     event="Yes", method="logistic", balance="rose", tune=FALSE,
#'     boot=200, show=FALSE, plot=FALSE)
#' }
#' }
#' ```
#'
#' @examples
#' set.seed(99)
#' n <- 110
#' dd <- data.frame(
#'   x1 = rnorm(n),
#'   x2 = rnorm(n),
#'   group = factor(sample(c("A", "B"), n, TRUE))
#' )
#' pp <- plogis(-0.4 + 0.9 * dd$x1 - 0.5 * dd$x2)
#' dd$y <- factor(rbinom(n, 1, pp), 0:1, c("No", "Yes"))
#'
#' z <- tabmachine(
#'   y,
#'   x = vars(x1, x2, group),
#'   data = dd,
#'   event = "Yes",
#'   method = "logistic",
#'   tune = FALSE,
#'   folds = 2,
#'   ci = FALSE,
#'   boot = 50,
#'   calibration = FALSE,
#'   decision = FALSE,
#'   importance = FALSE,
#'   explain = FALSE,
#'   show = FALSE,
#'   plot = FALSE
#' )
#' z$comparison
#' @export
tabmachine <- function(
  outcome,
  x = NULL,
  data = NULL,
  exclude = NULL,
  task = c("auto", "binary", "multiclass", "regression"),
  event = NULL,
  preprocess = c("auto", "none"),
  missing = c("auto", "median", "mean", "mode", "complete"),
  missing_max = 0.50,
  encode = c("auto", "dummy"),
  standardize = c("auto", "none", "z", "minmax", "robust"),
  transform = c("none", "auto", "log", "yeojohnson"),
  outlier = c("none", "detect", "winsor", "robust"),
  corr = "auto",
  feature = c("none", "auto", "polynomial", "interaction", "all"),
  degree = 2,
  reduce = c("none", "auto", "pca"),
  variance = 0.95,
  select = c("auto", "none", "filter", "lasso", "stepwise",
             "importance", "rfe", "boruta", "compare"),
  nfeatures = "auto",
  simplify = TRUE,
  simplify_tol = 0.01,
  balance = c("auto", "none", "weight", "up", "down", "smote",
              "adasyn", "rose", "compare"),
  balance_target = 0.50,
  neighbors = 5,
  split = 0.80,
  folds = 10,
  repeats = 1,
  nested = FALSE,
  seed = NULL,
  method = "auto",
  tune = TRUE,
  tune_n = 10,
  metric = "auto",
  threshold = "auto",
  target_sens = 0.90,
  target_spec = 0.90,
  ci = TRUE,
  ci_level = 0.95,
  boot = 1000,
  calibration = TRUE,
  decision = TRUE,
  decision_thresholds = seq(.01, .99, .01),
  learning = FALSE,
  importance = TRUE,
  importance_repeats = 20,
  explain = TRUE,
  shap = FALSE,
  pdp = FALSE,
  validation = NULL,
  predict = NULL,
  id = NULL,
  show = TRUE,
  plot = TRUE,
  plot_display = "auto",
  plot_args = list(),
  strict = FALSE,
  console = FALSE,
  digit = 3,
  title = NULL,
  ai = FALSE,
  ...
) {
  seed <- .r4vn_seed_normalize(seed)
  task <- match.arg(task)
  preprocess <- match.arg(preprocess)
  missing <- match.arg(missing)
  encode <- match.arg(encode)
  standardize <- match.arg(standardize)
  transform <- match.arg(transform)
  outlier <- match.arg(outlier)
  feature <- match.arg(feature)
  reduce <- match.arg(reduce)
  select <- match.arg(select)
  balance <- match.arg(balance)

  .r4vn_machine_validate_plot_options(plot, plot_display, plot_args, strict)

  .r4vn_machine_validate_scalar(missing_max, "missing_max", 0, 1, include_low = TRUE, include_high = FALSE)
  .r4vn_machine_validate_scalar(variance, "variance", 0, 1, include_low = FALSE, include_high = TRUE)
  if (!is.numeric(degree) || length(degree) != 1L || is.na(degree) || !degree %in% c(2, 3)) stop("`degree` must be 2 or 3.", call. = FALSE)
  .r4vn_machine_validate_scalar(balance_target, "balance_target", 0, 1, include_low = FALSE, include_high = FALSE)
  .r4vn_machine_validate_scalar(simplify_tol, "simplify_tol", 0, Inf, include_low = TRUE, include_high = TRUE)
  .r4vn_machine_validate_scalar(ci_level, "ci_level", 0, 1, include_low = FALSE, include_high = FALSE)
  .r4vn_machine_validate_scalar(target_sens, "target_sens", 0, 1, include_low = FALSE, include_high = TRUE)
  .r4vn_machine_validate_scalar(target_spec, "target_spec", 0, 1, include_low = FALSE, include_high = TRUE)
  if (!is.numeric(folds) || length(folds) != 1L || folds < 2) stop("`folds` must be at least 2.", call. = FALSE)
  if (!is.numeric(repeats) || length(repeats) != 1L || repeats < 1) stop("`repeats` must be at least 1.", call. = FALSE)
  if (!is.numeric(boot) || length(boot) != 1L || boot < 50) stop("`boot` must be at least 50.", call. = FALSE)
  if (!is.numeric(neighbors) || length(neighbors) != 1L || neighbors < 1) stop("`neighbors` must be at least 1.", call. = FALSE)
  if (!is.numeric(importance_repeats) || length(importance_repeats) != 1L || importance_repeats < 1) stop("`importance_repeats` must be at least 1.", call. = FALSE)
  if (!is.numeric(tune_n) || length(tune_n) != 1L || is.na(tune_n) || tune_n < 1) stop("`tune_n` must be at least 1.", call. = FALSE)
  if (!(is.character(nfeatures) && length(nfeatures) == 1L && identical(tolower(nfeatures), "auto"))) {
    if (!is.numeric(nfeatures) || length(nfeatures) != 1L || is.na(nfeatures) || nfeatures < 1) stop("`nfeatures` must be `\"auto\"` or a positive number.", call. = FALSE)
  }

  dat <- .r4vn_machine_resolve_data(data)
  outcome_expr <- substitute(outcome)
  outcome_name <- .r4vn_machine_one_name(outcome_expr, names(dat), "outcome")
  if (!outcome_name %in% names(dat)) stop("Outcome not found in `data`: ", outcome_name, call. = FALSE)

  x_expr <- if (missing(x)) NULL else substitute(x)
  ex_expr <- if (missing(exclude)) NULL else substitute(exclude)
  # Evaluate selector objects only when the expression is not itself a data
  # column. Bare column names have priority over same-named caller objects.
  x_value <- if (
    is.null(x_expr) ||
    (is.symbol(x_expr) && as.character(x_expr) %in% c(".", names(dat)))
  ) {
    NULL
  } else {
    tryCatch(eval(x_expr, parent.frame()), error = function(e) NULL)
  }

  ex_value <- if (
    is.null(ex_expr) ||
    (is.symbol(ex_expr) && as.character(ex_expr) %in% names(dat))
  ) {
    NULL
  } else {
    tryCatch(eval(ex_expr, parent.frame()), error = function(e) NULL)
  }
  predictor_names <- .r4vn_machine_predictor_names(x_expr, x_value, dat, outcome_name)
  exclude_names <- .r4vn_machine_names(ex_expr, ex_value, names(dat), allow_dot = FALSE)
  predictor_names <- setdiff(unique(predictor_names), c(outcome_name, exclude_names))
  if (!length(predictor_names)) stop("No candidate predictors remain after exclusions.", call. = FALSE)
  bad_names <- setdiff(predictor_names, names(dat))
  if (length(bad_names)) stop("Predictors not found in `data`: ", paste(bad_names, collapse = ", "), call. = FALSE)

  labels <- .r4vn_machine_labels(dat, c(outcome_name, predictor_names))
  outcome_info <- .r4vn_machine_outcome(dat[[outcome_name]], task = task, event = event, name = outcome_name)
  task <- outcome_info$task
  dat[[outcome_name]] <- outcome_info$y
  dat <- dat[!is.na(dat[[outcome_name]]), , drop = FALSE]
  if (nrow(dat) < 20L) stop("At least 20 observations with non-missing outcome are required.", call. = FALSE)

  notes <- outcome_info$notes
  warnings0 <- .r4vn_machine_data_warnings(dat, predictor_names, outcome_name)
  notes <- c(notes, warnings0)

  split_obj <- .r4vn_machine_split(dat, outcome_name, task, split = split, seed = seed)
  train_raw <- split_obj$train
  test_raw <- split_obj$test
  internal_raw <- split_obj$internal
  if(!nrow(test_raw)) {
    notes_split <- "No held-out internal test set was created; development performance is apparent/resubstitution performance. Prefer external validation or a held-out test for final assessment."
  } else notes_split <- character()

  # Resolve task-sensitive defaults only from the development training sample.
  notes <- c(notes, notes_split)
  event_rate <- if (task == "binary") mean(train_raw[[outcome_name]] == 1L) else NA_real_
  primary_metric <- .r4vn_machine_metric(metric, task, event_rate)
  select_resolved <- .r4vn_machine_resolve_select(select, train_raw, predictor_names, task)
  balance_resolved <- .r4vn_machine_resolve_balance(balance, task, event_rate)
  methods <- .r4vn_machine_methods(method, task)
  engine_table <- .r4vn_machine_engine_table(method, task, methods)
  if (!length(methods)) {
    unavailable <- engine_table[engine_table$Requested == "Yes" & engine_table$Available == "No", , drop = FALSE]
    detail <- if (nrow(unavailable)) paste0(
      " Missing optional package(s): ",
      paste(unique(unavailable$Package), collapse = ", "),
      ". Use `method = 'auto'` for the low-dependency R4VN core workflow."
    ) else ""
    stop("No requested machine-learning engine is available.", detail, call. = FALSE)
  }
  skipped_engines <- engine_table$Method[engine_table$Requested == "Yes" & engine_table$Available == "No"]
  if (length(skipped_engines)) {
    notes <- c(notes, paste0(
      "Requested optional engine(s) unavailable and therefore skipped: ",
      paste(skipped_engines, collapse = ", "), "."
    ))
  }

  # Strategy comparison is performed only on development training data.
  selection_compare <- data.frame()
  if (identical(select, "compare")) {
    sc <- .r4vn_machine_compare_selection(
      train_raw, outcome_name, predictor_names, task, outcome_info,
      methods = methods, preprocess = preprocess, missing = missing,
      missing_max = missing_max, encode = encode, standardize = standardize,
      transform = transform, outlier = outlier, corr = corr,
      feature = feature, degree = degree, reduce = reduce, variance = variance,
      nfeatures = nfeatures, balance = balance_resolved,
      balance_target = balance_target, neighbors = neighbors,
      folds = min(5L, as.integer(folds)), metric = primary_metric,
      seed = seed + 101L
    )
    select_resolved <- sc$best
    selection_compare <- sc$table
    notes <- c(notes, sc$notes)
  }

  balance_compare <- data.frame()
  if (identical(balance, "compare") && task == "binary") {
    bc <- .r4vn_machine_compare_balance(
      train_raw, outcome_name, predictor_names, task, outcome_info,
      methods = methods, preprocess = preprocess, missing = missing,
      missing_max = missing_max, encode = encode, standardize = standardize,
      transform = transform, outlier = outlier, corr = corr,
      feature = feature, degree = degree, reduce = reduce, variance = variance,
      select = select_resolved, nfeatures = nfeatures,
      balance_target = balance_target, neighbors = neighbors,
      folds = min(5L, as.integer(folds)), metric = primary_metric,
      seed = seed + 202L
    )
    balance_resolved <- bc$best
    balance_compare <- bc$table
    notes <- c(notes, bc$notes)
  }

  # Fit/tune each algorithm using development data only.
  model_results <- list()
  tuning_rows <- list()
  cv_rows <- list()
  engine_notes <- character()

  for (i in seq_along(methods)) {
    meth <- methods[i]
    grid <- .r4vn_machine_tune_grid(meth, task, tune = tune, tune_n = tune_n, seed = seed + i)

    tuned <- .r4vn_machine_tune(
      method = meth,
      grid = grid,
      raw = train_raw,
      outcome_name = outcome_name,
      predictor_names = predictor_names,
      task = task,
      outcome_info = outcome_info,
      preprocess = preprocess,
      missing = missing,
      missing_max = missing_max,
      encode = encode,
      standardize = standardize,
      transform = transform,
      outlier = outlier,
      corr = corr,
      feature = feature,
      degree = degree,
      reduce = reduce,
      variance = variance,
      select = select_resolved,
      nfeatures = nfeatures,
      balance = balance_resolved,
      balance_target = balance_target,
      neighbors = neighbors,
      folds = as.integer(folds),
      repeats = as.integer(repeats),
      nested = nested,
      metric = primary_metric,
      seed = seed + 1000L * i
    )

    if (!isTRUE(tuned$ok)) {
      engine_notes <- c(engine_notes, paste0(meth, ": ", tuned$error))
      next
    }
    model_results[[meth]] <- tuned
    if (nrow(tuned$tuning)) tuning_rows[[meth]] <- tuned$tuning
    cv_rows[[meth]] <- tuned$cv_summary
  }

  if (!length(model_results)) {
    stop("All requested models failed. Details: ", paste(engine_notes, collapse = " | "), call. = FALSE)
  }
  notes <- c(notes, engine_notes)
  tuning_table <- .r4vn_machine_bind(tuning_rows)
  cv_performance <- .r4vn_machine_bind(cv_rows)

  best_method <- .r4vn_machine_choose_best(cv_performance, primary_metric, task)
  if (!best_method %in% names(model_results)) best_method <- names(model_results)[1L]

  # OOF predictions for training-derived threshold use only the selected model.
  best_cv <- model_results[[best_method]]$cv
  threshold_info <- NULL
  threshold_value <- NA_real_
  if (task == "binary") {
    threshold_cv <- .r4vn_machine_oof_unique_binary(best_cv)
    threshold_info <- .r4vn_machine_threshold(
      y = threshold_cv$truth,
      p = threshold_cv$prob,
      threshold = threshold,
      target_sens = target_sens,
      target_spec = target_spec,
      ci = ci,
      level = ci_level,
      boot = as.integer(boot),
      seed = seed + 505L
    )
    threshold_value <- threshold_info$threshold
  }

  # Optional parsimony search is training-only and can replace the selected
  # encoded feature set used by the final fit. It never inspects test outcomes.
  simplify_info <- NULL
  if (isTRUE(simplify)) {
    simplify_info <- .r4vn_machine_simplify(
      train_raw, outcome_name, predictor_names, task, outcome_info,
      method = best_method,
      params = model_results[[best_method]]$params,
      preprocess = preprocess, missing = missing, missing_max = missing_max,
      encode = encode, standardize = standardize, transform = transform,
      outlier = outlier, corr = corr, feature = feature, degree = degree, reduce = reduce, variance = variance,
      select = select_resolved,
      nfeatures = nfeatures, balance = balance_resolved,
      balance_target = balance_target, neighbors = neighbors,
      metric = primary_metric, folds = min(5L, as.integer(folds)),
      tolerance = simplify_tol, seed = seed + 303L
    )
  }

  # Recombine the optional internal-validation partition only after all model
  # decisions. The held-out test remains untouched.
  final_train_raw <- if (nrow(internal_raw)) rbind(train_raw, internal_raw) else train_raw
  final_selected_override <- if (!is.null(simplify_info) && length(simplify_info$selected_encoded)) simplify_info$selected_encoded else NULL

  # Fit each candidate on full development training for fair held-out comparison.
  fitted_models <- list()
  perf_rows <- list()
  test_predictions <- list()
  for (meth in names(model_results)) {
    ff <- .r4vn_machine_fit_pipeline(
      raw = final_train_raw,
      outcome_name = outcome_name,
      predictor_names = predictor_names,
      task = task,
      outcome_info = outcome_info,
      method = meth,
      params = model_results[[meth]]$params,
      preprocess = preprocess,
      missing = missing,
      missing_max = missing_max,
      encode = encode,
      standardize = standardize,
      transform = transform,
      outlier = outlier,
      corr = corr,
      feature = feature,
      degree = degree,
      reduce = reduce,
      variance = variance,
      select = select_resolved,
      nfeatures = nfeatures,
      balance = balance_resolved,
      balance_target = balance_target,
      neighbors = neighbors,
      selected_override = if (identical(meth, best_method)) final_selected_override else NULL,
      seed = seed + match(meth, names(model_results)) * 7000L
    )
    fitted_models[[meth]] <- ff

    eval_raw <- if (nrow(test_raw)) test_raw else train_raw
    pr <- .r4vn_machine_predict_pipeline(ff, eval_raw, outcome_name = outcome_name)
    test_predictions[[meth]] <- pr
    ev <- .r4vn_machine_evaluate(
      truth = eval_raw[[outcome_name]],
      pred = pr,
      task = task,
      method = meth,
      threshold = if (task == "binary") threshold_value else NULL,
      ci = ci,
      ci_level = ci_level,
      boot = as.integer(boot),
      seed = seed + 9000L + match(meth, names(model_results)),
      dataset = if (nrow(test_raw)) "Test" else "Development (apparent)"
    )
    perf_rows[[meth]] <- ev$performance
  }

  performance <- .r4vn_machine_bind(perf_rows)
  comparison <- .r4vn_machine_comparison(performance, primary_metric, digit = digit)
  if(nrow(comparison)) comparison$Selected <- ifelse(comparison$Model==best_method,"Yes","")
  final_fit <- fitted_models[[best_method]]
  final_pred <- test_predictions[[best_method]]
  eval_raw <- if (nrow(test_raw)) test_raw else train_raw

  model_difference <- .r4vn_machine_model_differences(
    truth = eval_raw[[outcome_name]],
    predictions = test_predictions,
    best = best_method,
    task = task,
    metric = primary_metric,
    threshold = threshold_value,
    ci = ci,
    level = ci_level,
    boot = as.integer(boot),
    seed = seed + 12000L
  )

  overfitting <- .r4vn_machine_overfitting(cv_performance, performance, primary_metric)
  coefficients <- .r4vn_machine_coefficients(final_fit, labels = labels, ci = ci, ci_level = ci_level)

  # Final clinical evaluation uses exactly the rows returned by the finalized
  # prediction pipeline. This matters for policies such as missing = "complete",
  # where prediction rows can be a strict subset of the evaluation data.
  final_rows <- final_pred$rows %||% seq_len(nrow(eval_raw))
  final_truth <- eval_raw[[outcome_name]][final_rows]
  confusion <- data.frame()
  calibration_out <- NULL
  decision_out <- data.frame()
  if (task == "binary") {
    bm <- .r4vn_machine_binary_metrics(final_truth, final_pred$prob, threshold_value)
    confusion <- data.frame(
      Actual = c(outcome_info$event, outcome_info$negative),
      `Predicted event` = c(unname(bm["tp"]), unname(bm["fp"])),
      `Predicted non-event` = c(unname(bm["fn"]), unname(bm["tn"])),
      check.names = FALSE, stringsAsFactors = FALSE
    )
    if (isTRUE(calibration)) {
      calibration_out <- .r4vn_machine_calibration(
        final_truth, final_pred$prob,
        ci = ci, level = ci_level, boot = as.integer(boot), seed = seed + 13000L
      )
    }
    if (isTRUE(decision)) {
      decision_out <- .r4vn_machine_decision(
        final_truth, final_pred$prob,
        thresholds = decision_thresholds,
        ci = ci, level = ci_level, boot = as.integer(boot), seed = seed + 14000L
      )
    }
  }

  class_performance <- data.frame()
  if(task=="multiclass") {
    class_performance <- .r4vn_machine_multiclass_class_performance(
      final_truth, final_pred, ci=ci, level=ci_level, boot=as.integer(boot), seed=seed+14500L
    )
    confusion <- .r4vn_machine_multiclass_confusion(
      final_truth, final_pred$class, outcome_info$levels
    )
  }

  importance_out <- data.frame()
  if (isTRUE(importance)) {
    importance_out <- .r4vn_machine_importance(
      final_fit, eval_raw, outcome_name,
      metric = primary_metric,
      threshold = threshold_value,
      repeats = as.integer(importance_repeats),
      seed = seed + 15000L,
      labels = labels,
      ci = ci, level = ci_level
    )
  }

  shap_out <- NULL
  if (isTRUE(shap)) shap_out <- .r4vn_machine_shap(final_fit, eval_raw, outcome_name)
  pdp_out <- NULL
  if (!identical(pdp, FALSE)) {
    pdp_out <- .r4vn_machine_pdp(final_fit, eval_raw, outcome_name, pdp, importance_out, labels)
  }

  learning_out <- data.frame()
  if (isTRUE(learning)) {
    learning_out <- .r4vn_machine_learning_curve(
      final_train_raw, outcome_name, predictor_names, task, outcome_info,
      method = best_method, params = model_results[[best_method]]$params,
      preprocess = preprocess, missing = missing, missing_max = missing_max,
      encode = encode, standardize = standardize, transform = transform,
      outlier = outlier, corr = corr, feature = feature, degree = degree, reduce = reduce, variance = variance,
      select = select_resolved,
      nfeatures = nfeatures, balance = balance_resolved,
      balance_target = balance_target, neighbors = neighbors,
      metric = primary_metric, threshold = threshold_value,
      ci = ci, ci_level = ci_level, seed = seed + 16000L
    )
  }

  external_performance <- data.frame()
  external_predictions <- NULL
  external_confusion <- data.frame()
  external_class_performance <- data.frame()
  external_calibration <- NULL
  external_decision <- data.frame()
  if (!is.null(validation)) {
    if (!is.data.frame(validation)) stop("`validation` must be a data frame.", call. = FALSE)
    needed <- unique(c(outcome_name, final_fit$recipe$keep))
    missv <- setdiff(needed, names(validation))
    if (length(missv)) stop("External validation data are missing: ", paste(missv, collapse = ", "), call. = FALSE)
    val <- validation
    val[[outcome_name]] <- .r4vn_machine_apply_outcome(val[[outcome_name]], outcome_info)
    val <- val[!is.na(val[[outcome_name]]), , drop = FALSE]
    external_predictions <- .r4vn_machine_predict_pipeline(final_fit, val, outcome_name = outcome_name)
    external_rows <- external_predictions$rows %||% seq_len(nrow(val))
    external_truth <- val[[outcome_name]][external_rows]
    external_performance <- .r4vn_machine_evaluate(
      val[[outcome_name]], external_predictions, task, best_method,
      threshold = if (task == "binary") threshold_value else NULL,
      ci = ci, ci_level = ci_level, boot = as.integer(boot),
      seed = seed + 17000L, dataset = "External validation"
    )$performance
    if (task == "binary") {
      ebm <- .r4vn_machine_binary_metrics(external_truth, external_predictions$prob, threshold_value)
      external_confusion <- data.frame(
        Actual = c(outcome_info$event, outcome_info$negative),
        `Predicted event` = c(unname(ebm["tp"]), unname(ebm["fp"])),
        `Predicted non-event` = c(unname(ebm["fn"]), unname(ebm["tn"])),
        check.names = FALSE, stringsAsFactors = FALSE
      )
      if (isTRUE(calibration)) external_calibration <- .r4vn_machine_calibration(
        external_truth, external_predictions$prob,
        ci = ci, level = ci_level, boot = as.integer(boot), seed = seed + 17100L
      )
      if (isTRUE(decision)) external_decision <- .r4vn_machine_decision(
        external_truth, external_predictions$prob,
        thresholds = decision_thresholds, ci = ci, level = ci_level,
        boot = as.integer(boot), seed = seed + 17200L
      )
    } else if (task == "multiclass") {
      external_class_performance <- .r4vn_machine_multiclass_class_performance(
        external_truth, external_predictions, ci = ci, level = ci_level,
        boot = as.integer(boot), seed = seed + 17300L
      )
      external_confusion <- .r4vn_machine_multiclass_confusion(
        external_truth, external_predictions$class, outcome_info$levels
      )
    }
  }

  prediction_output <- NULL
  if (!is.null(predict)) {
    if (!is.data.frame(predict)) stop("`predict` must be a data frame.", call. = FALSE)
    id_expr <- if (missing(id)) NULL else substitute(id)
    id_name <- if (is.null(id_expr)) NULL else .r4vn_machine_one_name(id_expr, names(predict), "id")
    prediction_output <- .r4vn_machine_prediction_frame(
      final_fit, predict, threshold = threshold_value,
      id_name = id_name, outcome_info = outcome_info
    )
  }

  preprocessing_summary <- .r4vn_machine_preprocessing_table(final_fit)
  selection_table <- .r4vn_machine_selection_table(final_fit, selection_compare, labels)
  balance_table <- .r4vn_machine_balance_table(balance_resolved, final_train_raw[[outcome_name]], balance_compare, task)
  overview <- .r4vn_machine_overview(
    dat, outcome_name, predictor_names, task, outcome_info,
    train_raw, internal_raw, test_raw, validation, primary_metric
  )

  settings <- list(
    outcome = outcome_name,
    outcome_label = labels[[outcome_name]],
    predictors = predictor_names,
    excluded = exclude_names,
    task = task,
    event = outcome_info$event,
    preprocess = preprocess,
    missing = missing,
    missing_max = missing_max,
    encode = encode,
    standardize = standardize,
    transform = transform,
    outlier = outlier,
    corr = corr,
    feature = feature,
    degree = degree,
    reduce = reduce,
    variance = variance,
    select_requested = select,
    select = select_resolved,
    nfeatures = nfeatures,
    simplify = simplify,
    simplify_tol = simplify_tol,
    balance_requested = balance,
    balance = balance_resolved,
    balance_target = balance_target,
    neighbors = neighbors,
    split = split,
    folds = folds,
    repeats = repeats,
    nested = nested,
    seed = seed,
    methods = names(model_results),
    tune = tune,
    metric = primary_metric,
    threshold = threshold,
    threshold_value = threshold_value,
    ci = ci,
    ci_level = ci_level,
    boot = boot,
    calibration = calibration,
    decision = decision,
    learning = learning,
    importance = importance,
    explain = explain,
    shap = shap,
    pdp = pdp,
    plot = plot,
    plot_display = plot_display,
    plot_args = plot_args,
    strict = strict,
    digit = digit,
    title = title
  )

  out <- list(
    overview = overview,
    engines = engine_table,
    preprocessing = preprocessing_summary,
    balance = balance_table,
    selection = selection_table,
    simplify = simplify_info,
    tuning = tuning_table,
    cv_performance = cv_performance,
    performance = performance,
    comparison = comparison,
    model_difference = model_difference,
    overfitting = overfitting,
    coefficients = coefficients,
    best = best_method,
    final = final_fit,
    models = fitted_models,
    threshold = threshold_info,
    confusion = confusion,
    class_performance = class_performance,
    calibration = calibration_out,
    decision = decision_out,
    importance = importance_out,
    explanation = if (isTRUE(explain)) list(importance = importance_out, shap = shap_out, pdp = pdp_out) else NULL,
    shap = shap_out,
    pdp = pdp_out,
    learning = learning_out,
    external_performance = external_performance,
    external_predictions = external_predictions,
    external_confusion = external_confusion,
    external_class_performance = external_class_performance,
    external_calibration = external_calibration,
    external_decision = external_decision,
    predictions = prediction_output,
    plots = list(),
    plot_titles = .r4vn_machine_plot_title_map(),
    tables = list(),
    table = comparison,
    html = NULL,
    settings = settings,
    notes = unique(notes[nzchar(notes)]),
    call = match.call()
  )
  class(out) <- c("r4vn_machine", "r4vn_tab")

  out$plots <- .r4vn_machine_plot_data(out, eval_raw[[outcome_name]], test_predictions)
  out$tables <- .r4vn_machine_tables(out)
  plot_enabled <- !identical(plot, FALSE) && !is.null(plot) &&
    !(is.character(plot) && "none" %in% tolower(plot))
  viewer_types <- .r4vn_machine_plot_types(out, plot)
  display_types <- if (plot_enabled) .r4vn_machine_display_types(out, plot_display) else character()
  if (plot_enabled) {
    viewer_types <- unique(c(viewer_types, display_types))
  }
  out$settings$viewer_plots <- viewer_types
  out$settings$display_plots <- display_types
  out$html <- .r4vn_machine_html(out, plot = viewer_types, plot_args = plot_args, title = title, digit = digit)

  if (!identical(ai, FALSE) && exists("aiask", mode = "function", inherits = TRUE)) {
    ai_fun <- get("aiask", mode = "function", inherits = TRUE)
    out$ai <- tryCatch({
      fml <- names(formals(ai_fun))
      if (isTRUE(ai) || !"api" %in% fml) ai_fun(out) else ai_fun(out, api = ai)
    }, error = function(e) paste0("AI interpretation unavailable: ", conditionMessage(e)))
  }

  if (interactive() && plot_enabled && length(display_types)) {
    .r4vn_machine_draw_display(out, display_types, plot_args = plot_args, strict = strict)
  }
  if (isTRUE(show)) .r4vn_machine_show_html(out$html)
  if (isTRUE(console)) print(out)
  invisible(out)
}

# ============================================================================
# Internal utilities: validation, NSE, labels, outcome, splitting
# ============================================================================

.r4vn_machine_validate_scalar <- function(x, name, low, high,
                                          include_low = FALSE,
                                          include_high = FALSE) {
  if (!is.numeric(x) || length(x) != 1L || is.na(x)) {
    stop("`", name, "` must be a single numeric value.", call. = FALSE)
  }
  low_ok <- if (include_low) x >= low else x > low
  high_ok <- if (include_high) x <= high else x < high
  if (!low_ok || !high_ok) {
    stop("`", name, "` is outside its allowed range.", call. = FALSE)
  }
  invisible(TRUE)
}

.r4vn_machine_resolve_data <- function(data) {
  if (!is.null(data)) {
    if (!is.data.frame(data)) stop("`data` must be a data frame.", call. = FALSE)
    return(data)
  }
  if (exists(".r4vn_get_active", mode = "function", inherits = TRUE)) {
    z <- get(".r4vn_get_active", mode = "function", inherits = TRUE)()
    if (is.data.frame(z)) return(z)
  }
  stop("Supply `data` or select active data with `usedf()`/`opendata(..., active = TRUE)`.", call. = FALSE)
}

.r4vn_machine_one_name <- function(expr, data_names, what = "variable") {
  if (is.character(expr) && length(expr) == 1L) return(expr)
  if (is.symbol(expr)) return(as.character(expr))
  if (is.call(expr) && identical(expr[[1L]], as.name("get"))) {
    val <- tryCatch(eval(expr, parent.frame()), error = function(e) NULL)
    if (is.character(val) && length(val) == 1L) return(val)
  }
  val <- tryCatch(eval(expr, parent.frame()), error = function(e) NULL)
  if (is.character(val) && length(val) == 1L && val %in% data_names) return(val)
  stop("Could not resolve `", what, "` to one variable name.", call. = FALSE)
}

.r4vn_machine_names <- function(expr, value, data_names, allow_dot = TRUE) {
  if (is.null(expr)) return(character())

  # A bare symbol that matches a data column always means that column.
  # This prevents caller objects with the same name from hijacking NSE.
  if (is.symbol(expr)) {
    nm <- as.character(expr)
    if (allow_dot && identical(nm, ".")) return(data_names)
    if (nm %in% data_names) return(nm)

    # A symbol that is not a data column may name a character selector
    # object in the caller environment.
    if (is.character(value)) {
      return(unique(as.character(value)))
    }

    return(nm)
  }

  # Literal character selector.
  if (is.character(expr)) {
    return(unique(as.character(expr)))
  }

  # Evaluated character selectors, e.g. x = c("age", "bmi").
  if (is.character(value)) {
    return(unique(as.character(value)))
  }

  walk <- function(e) {
    if (is.null(e)) return(character())

    if (is.symbol(e)) {
      nm <- as.character(e)
      if (allow_dot && identical(nm, ".")) return(data_names)
      return(nm)
    }

    if (is.character(e)) {
      return(as.character(e))
    }

    if (is.call(e)) {
      head <- as.character(e[[1L]])

      if (head %in% c("vars", "c", "list")) {
        return(unique(unlist(
          lapply(as.list(e)[-1L], walk),
          use.names = FALSE
        )))
      }
    }

    character()
  }

  unique(walk(expr))
}

.r4vn_machine_predictor_names <- function(expr, value, dat, outcome_name) {
  if (is.null(expr)) return(setdiff(names(dat), outcome_name))
  z <- .r4vn_machine_names(expr, value, names(dat), allow_dot = TRUE)
  if (!length(z)) return(setdiff(names(dat), outcome_name))
  setdiff(z, outcome_name)
}

.r4vn_machine_label <- function(x, fallback) {
  lab <- attr(x, "label", exact = TRUE)
  if (is.null(lab) || !length(lab) || is.na(lab[1L]) || !nzchar(as.character(lab[1L]))) fallback else as.character(lab[1L])
}

.r4vn_machine_labels <- function(dat, vars) {
  out <- stats::setNames(vars, vars)
  for (v in vars) out[[v]] <- .r4vn_machine_label(dat[[v]], v)
  out
}

.r4vn_machine_get_label <- function(labels, v) {
  if (!is.null(labels) && v %in% names(labels)) {
    z <- labels[[v]]
    if (!is.null(z) && length(z) && !is.na(z[1L]) && nzchar(as.character(z[1L]))) return(as.character(z[1L]))
  }
  v
}

.r4vn_machine_positive_guess <- function(values) {
  vals <- unique(as.character(values[!is.na(values)]))
  if (!length(vals)) return(NULL)
  norm <- tolower(trimws(vals))
  positive <- c("1", "true", "yes", "y", "positive", "pos", "case", "event", "co", "c\u00f3")
  hit <- match(positive, norm, nomatch = 0L)
  hit <- hit[hit > 0L]
  if (length(hit)) return(vals[hit[1L]])
  NULL
}

.r4vn_machine_outcome <- function(y, task = "auto", event = NULL, name = "outcome") {
  raw <- y
  obs <- y[!is.na(y)]
  uniq <- unique(obs)
  if (!length(uniq)) stop("Outcome `", name, "` has no observed values.", call. = FALSE)

  if (task == "auto") {
    if (length(uniq) == 2L) {
      task <- "binary"
    } else if (is.factor(y) || is.character(y) || is.logical(y)) {
      task <- "multiclass"
    } else if ((is.integer(y) || all(abs(obs - round(obs)) < .Machine$double.eps^0.5)) && length(uniq) <= 10L) {
      task <- "multiclass"
    } else if (is.numeric(y)) {
      task <- "regression"
    } else {
      stop("Could not infer outcome type. Set `task=` explicitly.", call. = FALSE)
    }
  }

  notes <- character()
  if (task == "binary") {
    vals <- unique(as.character(obs))
    if (length(vals) != 2L) stop("Binary outcome must have exactly two observed levels.", call. = FALSE)
    ev <- if (!is.null(event)) as.character(event)[1L] else .r4vn_machine_positive_guess(obs)
    if (is.null(ev) || !ev %in% vals) ev <- vals[2L]
    neg <- setdiff(vals, ev)[1L]
    yy <- rep(NA_integer_, length(y))
    yy[!is.na(y) & as.character(y) == neg] <- 0L
    yy[!is.na(y) & as.character(y) == ev] <- 1L
    notes <- c(notes, paste0("Binary event level: ", ev, "; non-event level: ", neg, "."))
    return(list(task = task, y = yy, event = ev, negative = neg,
                levels = c(neg, ev), original_class = class(raw), notes = notes))
  }

  if (task == "multiclass") {
    yy <- factor(y)
    if (nlevels(yy) < 3L) stop("Multiclass outcome must have at least three observed levels.", call. = FALSE)
    return(list(task = task, y = yy, event = NULL, negative = NULL,
                levels = levels(yy), original_class = class(raw), notes = notes))
  }

  if (!is.numeric(y)) stop("Regression outcome must be numeric.", call. = FALSE)
  yy <- as.numeric(y)
  if (length(unique(yy[!is.na(yy)])) < 5L) {
    notes <- c(notes, "Regression outcome has fewer than five distinct values; verify that regression is intended.")
  }
  list(task = "regression", y = yy, event = NULL, negative = NULL,
       levels = NULL, original_class = class(raw), notes = notes)
}

.r4vn_machine_apply_outcome <- function(y, info) {
  if (info$task == "binary") {
    z <- rep(NA_integer_, length(y))
    z[!is.na(y) & as.character(y) == info$negative] <- 0L
    z[!is.na(y) & as.character(y) == info$event] <- 1L
    return(z)
  }
  if (info$task == "multiclass") {
    return(factor(as.character(y), levels = info$levels))
  }
  suppressWarnings(as.numeric(y))
}

.r4vn_machine_data_warnings <- function(dat, predictors, outcome) {
  out <- character()
  for (v in predictors) {
    z <- dat[[v]]
    miss <- mean(is.na(z))
    if (miss > .5) out <- c(out, paste0(v, ": ", round(100 * miss, 1), "% missing."))
    nu <- length(unique(z[!is.na(z)]))
    if (nu <= 1L) out <- c(out, paste0(v, ": no variation."))
    if (grepl("(^id$|_id$|^id_|identifier|record.?id|patient.?id)", tolower(v)) &&
        sum(!is.na(z)) > 0 && length(unique(z[!is.na(z)])) / sum(!is.na(z)) > .95) {
      out <- c(out, paste0(v, ": looks like an identifier; consider `exclude = vars(", v, ")`."))
    }
  }
  unique(out)
}

.r4vn_machine_strata_indices <- function(y, prop, seed) {
  .r4vn_set_seed_if(seed)
  n <- length(y)
  train <- integer()
  lev <- unique(y[!is.na(y)])
  for (lv in lev) {
    idx <- which(y == lv)
    ni <- max(1L, min(length(idx) - as.integer(length(idx) > 1L), round(length(idx) * prop)))
    if (length(idx) == 1L) ni <- 1L
    train <- c(train, sample(idx, ni))
  }
  sort(unique(train))
}

.r4vn_machine_split <- function(dat, outcome, task, split = .8, seed = NA_integer_) {
  if (identical(split, FALSE)) {
    return(list(train = dat, internal = dat[0, , drop = FALSE], test = dat[0, , drop = FALSE]))
  }
  if (!is.numeric(split) || !length(split)) stop("`split` must be FALSE or numeric.", call. = FALSE)
  if (length(split) == 1L) {
    if (split <= 0 || split >= 1) stop("Single `split` must be strictly between 0 and 1.", call. = FALSE)
    if (task %in% c("binary", "multiclass")) {
      tr <- .r4vn_machine_strata_indices(dat[[outcome]], split, seed)
    } else {
      .r4vn_set_seed_if(seed); tr <- sample(seq_len(nrow(dat)), max(1L, floor(nrow(dat) * split)))
    }
    te <- setdiff(seq_len(nrow(dat)), tr)
    return(list(train = dat[tr, , drop = FALSE], internal = dat[0, , drop = FALSE], test = dat[te, , drop = FALSE]))
  }
  if (length(split) != 3L || any(split < 0) || abs(sum(split) - 1) > 1e-8) {
    stop("Length-three `split` must contain nonnegative proportions summing to 1.", call. = FALSE)
  }
  # First select test, then internal validation from the remaining observations.
  train_internal_prop <- split[1L] + split[2L]
  if (task %in% c("binary", "multiclass")) {
    ti <- .r4vn_machine_strata_indices(dat[[outcome]], train_internal_prop, seed)
  } else {
    .r4vn_set_seed_if(seed); ti <- sample(seq_len(nrow(dat)), floor(nrow(dat) * train_internal_prop))
  }
  test <- setdiff(seq_len(nrow(dat)), ti)
  rem <- dat[ti, , drop = FALSE]
  ptrain <- if (train_internal_prop == 0) 0 else split[1L] / train_internal_prop
  if (ptrain <= 0 || ptrain >= 1) {
    tr_local <- if (ptrain >= 1) seq_len(nrow(rem)) else integer()
  } else if (task %in% c("binary", "multiclass")) {
    tr_local <- .r4vn_machine_strata_indices(rem[[outcome]], ptrain, seed + 1L)
  } else {
    .r4vn_set_seed_if(seed + 1L); tr_local <- sample(seq_len(nrow(rem)), floor(nrow(rem) * ptrain))
  }
  in_local <- setdiff(seq_len(nrow(rem)), tr_local)
  list(train = rem[tr_local, , drop = FALSE], internal = rem[in_local, , drop = FALSE], test = dat[test, , drop = FALSE])
}

.r4vn_machine_make_folds <- function(y, k = 10L, repeats = 1L, task, seed = NA_integer_) {
  n <- length(y)
  k <- min(as.integer(k), n)
  if (task %in% c("binary", "multiclass")) {
    tb <- table(y, useNA = "no")
    if (!length(tb) || min(tb) < 2L) stop("Each outcome class needs at least two training observations for cross-validation.", call. = FALSE)
    k <- min(k, as.integer(min(tb)))
  }
  if (k < 2L) stop("At least two cross-validation folds are required.", call. = FALSE)
  out <- list(); ii <- 0L
  for (r in seq_len(as.integer(repeats))) {
    .r4vn_set_seed_if(seed + r)
    fold_id <- integer(n)
    if (task %in% c("binary", "multiclass")) {
      lev <- unique(y)
      for (lv in lev) {
        idx <- sample(which(y == lv))
        fold_id[idx] <- rep(seq_len(k), length.out = length(idx))
      }
    } else {
      idx <- sample(seq_len(n))
      fold_id[idx] <- rep(seq_len(k), length.out = n)
    }
    for (f in seq_len(k)) {
      val <- which(fold_id == f)
      if (!length(val)) next
      ii <- ii + 1L
      out[[ii]] <- list(train = setdiff(seq_len(n), val), val = val, rep_id = r, fold = f)
    }
  }
  out
}

.r4vn_machine_metric <- function(metric, task, event_rate = NA_real_) {
  m <- tolower(as.character(metric)[1L])
  alias <- c(sens="sensitivity",spec="specificity",roc_auc="auc",prauc="pr_auc",f1_score="f1")
  if(m %in% names(alias)) m <- unname(alias[[m]])
  if (m == "auto") {
    m <- if (task == "binary") {
      if (is.finite(event_rate) && min(event_rate, 1 - event_rate) < .10) "pr_auc" else "auc"
    } else if (task == "multiclass") "macro_f1" else "rmse"
  }
  allowed <- if(task=="binary") c("auc","pr_auc","accuracy","balanced_accuracy","sensitivity","specificity","ppv","npv","f1","mcc","kappa","brier","logloss") else if(task=="multiclass") c("accuracy","balanced_accuracy","macro_f1","weighted_f1","macro_auc","macro_pr_auc","logloss") else c("rmse","mae","r2","mape")
  if(!m %in% allowed) stop("Unsupported `metric` for ",task,": ",m,". Allowed: ",paste(allowed,collapse=", "),".",call.=FALSE)
  m
}

.r4vn_machine_metric_direction <- function(metric) {
  if (tolower(metric) %in% c("rmse", "mae", "mape", "brier", "logloss")) "min" else "max"
}

.r4vn_machine_resolve_select <- function(select, train, predictors, task) {
  if (select != "auto") return(select)
  # Keep the default deterministic and dependency-light. More aggressive
  # selectors remain available through explicit `select=` or `select="compare"`.
  "filter"
}

.r4vn_machine_resolve_balance <- function(balance, task, event_rate) {
  if (task != "binary") return("none")
  if (balance != "auto") return(balance)
  if (is.finite(event_rate) && min(event_rate, 1 - event_rate) < .20) "weight" else "none"
}

.r4vn_machine_method_package <- function(method) {
  switch(method,
         lasso = "glmnet", ridge = "glmnet", elastic = "glmnet",
         tree = "rpart", rf = "ranger", xgb = "xgboost",
         svm = "e1071", naive = "e1071", multinom = "nnet",
         NULL)
}

.r4vn_machine_method_ok <- function(method, task) {
  allowed <- switch(task,
                    binary = c("logistic", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn", "naive"),
                    regression = c("linear", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn"),
                    multiclass = c("multinom", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn", "naive"))
  method %in% allowed
}

.r4vn_machine_methods <- function(method, task) {
  req <- unique(tolower(as.character(method)))
  if (identical(req, "auto")) {
    # R4VN auto is intentionally low-dependency. Optional ML engines join only
    # when the user asks for them explicitly or requests method = "all".
    req <- switch(task,
                  binary = c("logistic", "tree"),
                  regression = c("linear", "tree"),
                  multiclass = c("multinom", "tree", "knn"))
  } else if (identical(req, "all")) {
    req <- switch(task,
                  binary = c("logistic", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn", "naive"),
                  regression = c("linear", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn"),
                  multiclass = c("multinom", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn", "naive"))
  } else {
    bad <- req[!vapply(req, .r4vn_machine_method_ok, logical(1), task = task)]
    if (length(bad)) {
      allowed <- switch(task,
        binary = c("logistic", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn", "naive"),
        regression = c("linear", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn"),
        multiclass = c("multinom", "lasso", "ridge", "elastic", "tree", "rf", "xgb", "svm", "knn", "naive")
      )
      stop(
        "Unsupported `method` for ", task, ": ", paste(bad, collapse = ", "),
        ". Available methods are: ", paste(allowed, collapse = ", "), ".",
        call. = FALSE
      )
    }
  }
  req <- req[vapply(req, .r4vn_machine_method_ok, logical(1), task = task)]
  keep <- logical(length(req))
  for (i in seq_along(req)) {
    pkg <- .r4vn_machine_method_package(req[i])
    keep[i] <- is.null(pkg) || requireNamespace(pkg, quietly = TRUE)
  }
  out <- req[keep]
  # Keep a decision-tree fallback when the compact auto set collapses because
  # optional engines are unavailable. Explicit method requests are not altered.
  if (identical(tolower(as.character(method))[1L], "auto") && length(out) <= 1L &&
      requireNamespace("rpart", quietly=TRUE) && .r4vn_machine_method_ok("tree",task)) out <- unique(c(out,"tree"))
  out
}

.r4vn_machine_engine_table <- function(method, task, used) {
  supported <- switch(task,
    binary = c("logistic", "tree", "knn", "lasso", "ridge", "elastic", "rf", "xgb", "svm", "naive"),
    regression = c("linear", "tree", "knn", "lasso", "ridge", "elastic", "rf", "xgb", "svm"),
    multiclass = c("multinom", "tree", "knn", "lasso", "ridge", "elastic", "rf", "xgb", "svm", "naive")
  )
  req0 <- unique(tolower(as.character(method)))
  requested <- if (identical(req0, "auto")) {
    switch(task, binary = c("logistic", "tree"), regression = c("linear", "tree"), multiclass = c("multinom", "tree", "knn"))
  } else if (identical(req0, "all")) supported else intersect(req0, supported)
  pkg <- vapply(supported, function(m) {
    p <- .r4vn_machine_method_package(m)
    if (is.null(p)) "Base R / R4VN" else p
  }, character(1))
  available <- vapply(supported, function(m) {
    p <- .r4vn_machine_method_package(m)
    is.null(p) || requireNamespace(p, quietly = TRUE)
  }, logical(1))
  core <- supported %in% switch(task,
    binary = c("logistic", "tree"),
    regression = c("linear", "tree"),
    multiclass = c("multinom", "tree", "knn")
  )
  data.frame(
    Method = supported,
    Role = ifelse(core, "R4VN core", "Optional"),
    Package = pkg,
    Requested = ifelse(supported %in% requested, "Yes", ""),
    Available = ifelse(available, "Yes", "No"),
    Used = ifelse(supported %in% used, "Yes", ""),
    stringsAsFactors = FALSE, check.names = FALSE
  )
}

# ============================================================================
# Preprocessing blueprint
# ============================================================================

.r4vn_machine_mode <- function(x) {
  z <- x[!is.na(x)]
  if (!length(z)) return(NA)
  tab <- table(z, useNA = "no")
  names(tab)[which.max(tab)]
}

.r4vn_machine_nzv <- function(x) {
  z <- x[!is.na(x)]
  if (length(z) < 2L) return(TRUE)
  tab <- sort(table(z), decreasing = TRUE)
  unique_pct <- 100 * length(tab) / length(z)
  freq_ratio <- if (length(tab) >= 2L) as.numeric(tab[1L] / tab[2L]) else Inf
  unique_pct <= 10 && freq_ratio >= 19
}

.r4vn_machine_yj <- function(x, lambda) {
  out <- rep(NA_real_, length(x))
  pos <- !is.na(x) & x >= 0
  neg <- !is.na(x) & x < 0
  if (abs(lambda) < 1e-8) out[pos] <- log1p(x[pos])
  else out[pos] <- ((x[pos] + 1)^lambda - 1) / lambda
  if (abs(lambda - 2) < 1e-8) out[neg] <- -log1p(-x[neg])
  else out[neg] <- -((1 - x[neg])^(2 - lambda) - 1) / (2 - lambda)
  out
}

.r4vn_machine_yj_lambda <- function(x) {
  z <- x[is.finite(x)]
  if (length(z) < 10L || stats::sd(z) == 0) return(1)
  grid <- seq(-2, 2, by = .1)
  ll <- vapply(grid, function(lam) {
    t <- .r4vn_machine_yj(z, lam)
    vv <- stats::var(t)
    if (!is.finite(vv) || vv <= 0) return(-Inf)
    jac <- (lam - 1) * sum(sign(z) * log1p(abs(z)))
    -length(z) / 2 * log(vv) + jac
  }, numeric(1))
  grid[which.max(ll)]
}

.r4vn_machine_corr_remove <- function(dat, vars, threshold) {
  num <- vars[vapply(dat[vars], is.numeric, logical(1))]
  if (length(num) < 2L || is.null(threshold) || !is.finite(threshold)) return(character())
  cc <- suppressWarnings(stats::cor(dat[num], use = "pairwise.complete.obs"))
  if (!is.matrix(cc) || nrow(cc) < 2L) return(character())
  diag(cc) <- 0
  remove <- character()
  while (TRUE) {
    aa <- abs(cc)
    aa[!is.finite(aa)] <- 0
    mx <- max(aa)
    if (mx < threshold) break
    pos <- which(aa == mx, arr.ind = TRUE)[1L, ]
    v1 <- rownames(cc)[pos[1L]]; v2 <- colnames(cc)[pos[2L]]
    mean1 <- mean(abs(cc[v1, ]), na.rm = TRUE)
    mean2 <- mean(abs(cc[v2, ]), na.rm = TRUE)
    kill <- if (mean1 >= mean2) v1 else v2
    remove <- c(remove, kill)
    cc[kill, ] <- 0; cc[, kill] <- 0
  }
  unique(remove)
}

.r4vn_machine_corr_threshold <- function(corr) {
  if (identical(corr, FALSE) || identical(corr, "none") || is.null(corr)) return(NULL)
  if (is.character(corr) && identical(tolower(corr[1L]), "auto")) return(.95)
  if (is.numeric(corr) && length(corr) == 1L && corr > 0 && corr < 1) return(as.numeric(corr))
  stop("`corr` must be 'auto', 'none'/FALSE, or a number strictly between 0 and 1.", call. = FALSE)
}

.r4vn_machine_feature_mode <- function(feature) {
  f <- tolower(as.character(feature)[1L])
  if(f == "auto") "none" else f
}

.r4vn_machine_feature_expand_fit <- function(mm, raw_map, types, feature="none", degree=2L) {
  mode <- .r4vn_machine_feature_mode(feature)
  spec <- list(mode=mode, degree=as.integer(degree), powers=list(), interactions=list())
  out <- mm; map <- raw_map
  if(mode == "none") return(list(x=out,map=map,spec=spec))
  numeric_cols <- names(raw_map)[vapply(names(raw_map), function(nm) {
    rv <- raw_map[[nm]]
    !is.na(rv) && rv %in% names(types) && identical(types[[rv]], "numeric")
  }, logical(1))]
  numeric_cols <- intersect(numeric_cols,colnames(mm))
  if(mode %in% c("polynomial","all") && length(numeric_cols)) {
    for(nm in numeric_cols) for(pow in 2L:as.integer(degree)) {
      nn <- paste0(nm,"__pow",pow); out <- cbind(out, stats::setNames(data.frame(mm[,nm]^pow),nn));
      spec$powers[[nn]] <- list(source=nm,power=pow); map[[nn]] <- raw_map[[nm]]
    }
  }
  if(mode %in% c("interaction","all") && length(numeric_cols)>=2L) {
    cmb <- utils::combn(numeric_cols,2L,simplify=FALSE)
    if(length(cmb)>200L) cmb <- cmb[seq_len(200L)]
    for(cc in cmb) {
      nn <- paste0(cc[1L],"__X__",cc[2L]); out <- cbind(out, stats::setNames(data.frame(mm[,cc[1L]]*mm[,cc[2L]]),nn))
      spec$interactions[[nn]] <- cc
      rv <- unique(na.omit(c(raw_map[[cc[1L]]],raw_map[[cc[2L]]]))); map[[nn]] <- paste(rv,collapse=" x ")
    }
  }
  out <- as.matrix(out); storage.mode(out) <- "double"
  list(x=out,map=map[colnames(out)],spec=spec)
}

.r4vn_machine_feature_expand_apply <- function(mm, spec) {
  out <- mm
  if(is.null(spec) || identical(spec$mode,"none")) return(out)
  if(length(spec$powers)) for(nn in names(spec$powers)) {
    z <- spec$powers[[nn]]; out <- cbind(out, stats::setNames(data.frame(out[,z$source]^z$power),nn))
  }
  if(length(spec$interactions)) for(nn in names(spec$interactions)) {
    cc <- spec$interactions[[nn]]; out <- cbind(out, stats::setNames(data.frame(out[,cc[1L]]*out[,cc[2L]]),nn))
  }
  out <- as.matrix(out); storage.mode(out) <- "double"; out
}

.r4vn_machine_pca_fit <- function(x, reduce="none", variance=.95) {
  mode <- tolower(as.character(reduce)[1L])
  if(mode == "auto") mode <- if(ncol(x)>=50L && ncol(x)>nrow(x)) "pca" else "none"
  if(mode == "none") return(list(method="none",x=x,center=NULL,scale=NULL,rotation=NULL,variance=NULL,k=ncol(x)))
  cen <- colMeans(x); sc <- apply(x,2L,stats::sd); sc[!is.finite(sc)|sc==0] <- 1
  xs <- sweep(sweep(x,2L,cen,"-"),2L,sc,"/")
  pc <- stats::prcomp(xs,center=FALSE,scale.=FALSE)
  vv <- pc$sdev^2; prop <- vv/sum(vv); cum <- cumsum(prop)
  k <- which(cum>=variance)[1L]; if(is.na(k)) k <- length(cum); k <- max(1L,k)
  rot <- pc$rotation[,seq_len(k),drop=FALSE]
  xx <- xs %*% rot; colnames(xx) <- paste0("PC",seq_len(k))
  list(method="pca",x=xx,center=cen,scale=sc,rotation=rot,variance=prop[seq_len(k)],k=k,cumulative=cum[k])
}

.r4vn_machine_pca_apply <- function(x, pca) {
  if(is.null(pca) || identical(pca$method,"none")) return(x)
  xs <- sweep(sweep(x,2L,pca$center,"-"),2L,pca$scale,"/")
  z <- xs %*% pca$rotation; colnames(z) <- paste0("PC",seq_len(ncol(z))); z
}

.r4vn_machine_recipe_fit <- function(raw, predictors, preprocess, missing, missing_max,
                                     encode, transform, outlier, corr, feature="none", degree=2L,
                                     reduce="none", variance=.95) {
  d <- raw[, predictors, drop = FALSE]
  actions <- list()
  removed <- character()

  miss_prop <- vapply(d, function(z) mean(is.na(z)), numeric(1))
  if (preprocess == "auto") {
    rm_miss <- names(miss_prop)[miss_prop > missing_max]
    if (length(rm_miss)) {
      removed <- c(removed, rm_miss)
      actions[[length(actions) + 1L]] <- data.frame(step = "High missingness", variable = rm_miss,
                                                    action = paste0("Removed (> ", round(100 * missing_max), "% missing)"))
    }
  }
  keep <- setdiff(names(d), removed)
  if (!length(keep)) stop("All predictors were removed by missingness filtering.", call. = FALSE)

  if (preprocess == "auto") {
    constant <- keep[vapply(d[keep], function(z) length(unique(z[!is.na(z)])) <= 1L, logical(1))]
    nzv <- setdiff(keep[vapply(d[keep], .r4vn_machine_nzv, logical(1))], constant)
    if (length(constant)) {
      removed <- c(removed, constant)
      actions[[length(actions) + 1L]] <- data.frame(step = "Zero variance", variable = constant, action = "Removed")
    }
    if (length(nzv)) {
      removed <- c(removed, nzv)
      actions[[length(actions) + 1L]] <- data.frame(step = "Near-zero variance", variable = nzv, action = "Removed")
    }
  }
  keep <- setdiff(names(d), removed)

  cth <- .r4vn_machine_corr_threshold(corr)
  if (preprocess == "auto" && length(keep) && !is.null(cth)) {
    cr <- .r4vn_machine_corr_remove(d, keep, cth)
    if (length(cr)) {
      removed <- c(removed, cr)
      actions[[length(actions) + 1L]] <- data.frame(step = "High correlation", variable = cr,
                                                    action = paste0("Removed (|r| >= ", cth, ")"))
    }
  }
  keep <- setdiff(names(d), removed)
  if (!length(keep)) stop("No predictors remain after preprocessing filters.", call. = FALSE)

  # Factor blueprints and imputation learned on training only.
  types <- vapply(d[keep], function(z) if (is.numeric(z)) "numeric" else "categorical", character(1))
  levels_map <- list(); impute <- list(); transform_map <- list(); outlier_map <- list()
  proc <- d[keep]
  complete_rows <- rep(TRUE, nrow(proc))

  for (v in keep) {
    z <- proc[[v]]
    if (types[[v]] == "categorical") {
      zz <- as.character(z)
      lev <- unique(zz[!is.na(zz)])
      if (!length(lev)) lev <- "Missing"
      levels_map[[v]] <- lev
      if (missing == "complete") {
        complete_rows <- complete_rows & !is.na(zz)
      } else {
        mv <- .r4vn_machine_mode(zz)
        if (is.na(mv)) mv <- lev[1L]
        impute[[v]] <- mv
        zz[is.na(zz)] <- mv
      }
      proc[[v]] <- factor(zz, levels = levels_map[[v]])
    } else {
      zz <- suppressWarnings(as.numeric(z))
      if (missing == "complete") {
        complete_rows <- complete_rows & is.finite(zz)
      } else {
        meth <- if (missing == "auto") "median" else missing
        if (meth == "mode") {
          mv <- suppressWarnings(as.numeric(.r4vn_machine_mode(zz)))
        } else if (meth == "mean") {
          mv <- mean(zz, na.rm = TRUE)
        } else {
          mv <- stats::median(zz, na.rm = TRUE)
        }
        if (!is.finite(mv)) mv <- 0
        impute[[v]] <- mv
        zz[!is.finite(zz)] <- mv
      }

      if (outlier %in% c("detect", "winsor", "robust")) {
        q <- stats::quantile(zz, c(.25, .75), na.rm = TRUE, names = FALSE)
        iqr <- q[2L] - q[1L]
        detect_low <- q[1L] - 1.5 * iqr; detect_high <- q[2L] + 1.5 * iqr
        n_out <- sum(zz < detect_low | zz > detect_high, na.rm = TRUE)
        if (outlier == "winsor") {
          cuts <- stats::quantile(zz, c(.01, .99), na.rm = TRUE, names = FALSE)
          outlier_map[[v]] <- list(method = "winsor", low = cuts[1L], high = cuts[2L], detected = n_out)
          zz <- pmin(pmax(zz, cuts[1L]), cuts[2L])
        } else if (outlier == "robust") {
          md <- stats::median(zz, na.rm = TRUE); ma <- stats::mad(zz, center = md, constant = 1, na.rm = TRUE)
          if (!is.finite(ma) || ma == 0) ma <- stats::sd(zz, na.rm = TRUE)
          cuts <- c(md - 5 * ma, md + 5 * ma)
          outlier_map[[v]] <- list(method = "robust", low = cuts[1L], high = cuts[2L], detected = n_out)
          zz <- pmin(pmax(zz, cuts[1L]), cuts[2L])
        } else {
          outlier_map[[v]] <- list(method = "detect", low = detect_low, high = detect_high, detected = n_out)
        }
      }

      tr <- transform
      if (tr == "auto") tr <- "none"
      if (tr == "log") {
        minz <- min(zz, na.rm = TRUE)
        shift <- if (minz <= 0) 1 - minz else 0
        transform_map[[v]] <- list(method = "log", shift = shift)
        zz <- log(zz + shift)
      } else if (tr == "yeojohnson") {
        lam <- .r4vn_machine_yj_lambda(zz)
        transform_map[[v]] <- list(method = "yeojohnson", lambda = lam)
        zz <- .r4vn_machine_yj(zz, lam)
      }
      proc[[v]] <- zz
    }
  }

  if (missing == "complete") proc <- proc[complete_rows, , drop = FALSE]
  if (!nrow(proc)) stop("No complete training rows remain after `missing = 'complete'`.", call. = FALSE)

  # model.matrix blueprint learned from training data only.
  # Build model.frame first so its expanded terms object is available and
  # consistent with the model matrix. Do not rely on attr(model.matrix(), "terms").
  frm <- stats::as.formula("~ .")
  mf <- stats::model.frame(
    frm,
    data = proc,
    na.action = stats::na.pass,
    drop.unused.levels = FALSE
  )
  trm <- attr(mf, "terms")
  if (is.null(trm)) {
    stop("Could not create the training design terms.", call. = FALSE)
  }

  mm_full <- stats::model.matrix(
    trm,
    data = mf,
    contrasts.arg = NULL
  )

  assign <- attr(mm_full, "assign")
  term_labels <- attr(trm, "term.labels")

  if (is.null(assign) || length(assign) != ncol(mm_full)) {
    stop("The training model matrix does not contain a valid term assignment.", call. = FALSE)
  }
  if (!length(term_labels) && any(assign > 0L)) {
    stop("Could not recover predictor term labels from the training design.", call. = FALSE)
  }

  # Map each encoded column back to its original predictor.
  raw_map_full <- stats::setNames(
    rep(NA_character_, ncol(mm_full)),
    colnames(mm_full)
  )

  for (j in seq_along(assign)) {
    term_id <- assign[[j]]
    if (is.na(term_id) || term_id == 0L) next
    if (term_id < 1L || term_id > length(term_labels)) {
      stop(
        "Invalid model-matrix term assignment while building the preprocessing blueprint.",
        call. = FALSE
      )
    }
    raw_map_full[[j]] <- gsub(
      "`", "", term_labels[[term_id]], fixed = TRUE
    )
  }

  keep_mm <- colnames(mm_full) != "(Intercept)"
  mm <- mm_full[, keep_mm, drop = FALSE]
  if (!ncol(mm)) {
    stop("No encoded predictor columns remain.", call. = FALSE)
  }

  mm_cols <- colnames(mm)
  raw_map <- raw_map_full[mm_cols]

  # Defensive fallback: with the base ~ . blueprint every non-intercept encoded
  # column should map to one original predictor.
  if (anyNA(raw_map)) {
    bad <- names(raw_map)[is.na(raw_map)]
    stop(
      "Could not map encoded predictor column(s) back to original variables: ",
      paste(bad, collapse = ", "),
      call. = FALSE
    )
  }

  base_cols <- mm_cols

  fx <- .r4vn_machine_feature_expand_fit(mm, raw_map, types, feature=feature, degree=degree)
  mm_feature <- fx$x; raw_map_feature <- fx$map
  fsd <- apply(mm_feature,2L,stats::sd,na.rm=TRUE); good_fx <- is.finite(fsd) & fsd > 0
  if(any(!good_fx)) mm_feature <- mm_feature[,good_fx,drop=FALSE]
  if(!ncol(mm_feature)) stop("No non-constant encoded/engineered features remain.",call.=FALSE)
  feature_cols <- colnames(mm_feature); raw_map_feature <- raw_map_feature[feature_cols]
  pca <- .r4vn_machine_pca_fit(mm_feature, reduce=reduce, variance=variance)
  mm_final <- pca$x; mm_cols <- colnames(mm_final)
  raw_map <- if(identical(pca$method,"pca")) stats::setNames(rep(NA_character_,length(mm_cols)),mm_cols) else raw_map_feature[mm_cols]

  if (length(actions)) actions_df <- do.call(rbind, actions) else actions_df <- data.frame(step=character(), variable=character(), action=character())
  for (v in keep) {
    if (!is.null(impute[[v]])) {
      actions_df <- rbind(actions_df, data.frame(step = "Imputation", variable = v,
                                                 action = paste0(if (types[[v]] == "numeric") {
                                                   if (missing == "auto") "Median" else tools::toTitleCase(missing)
                                                 } else "Mode", ": ", format(impute[[v]], digits = 6))))
    }
    if (!is.null(transform_map[[v]])) actions_df <- rbind(actions_df, data.frame(step="Transformation", variable=v, action=transform_map[[v]]$method))
    if (!is.null(outlier_map[[v]])) actions_df <- rbind(actions_df, data.frame(step="Outlier", variable=v,
      action=paste0(outlier_map[[v]]$method, "; detected=", outlier_map[[v]]$detected)))
  }
  actions_df <- rbind(actions_df, data.frame(step="Encoding", variable="Categorical predictors", action="Training-derived dummy/model matrix"))
  if(!identical(fx$spec$mode,"none")) actions_df <- rbind(actions_df,data.frame(step="Feature engineering",variable="Numeric predictors",action=paste0(fx$spec$mode,"; degree=",degree,"; encoded features=",ncol(mm_feature))))
  if(identical(pca$method,"pca")) actions_df <- rbind(actions_df,data.frame(step="Dimensionality reduction",variable="Encoded predictors",action=paste0("PCA retained ",pca$k," components; cumulative variance=",formatC(pca$cumulative,format="f",digits=3))))

  list(
    predictors = predictors,
    keep = keep,
    removed = unique(removed),
    types = types,
    levels = levels_map,
    impute = impute,
    transform = transform_map,
    outlier = outlier_map,
    base_cols = base_cols,
    feature_spec = fx$spec,
    feature_cols = feature_cols,
    pca = pca,
    mm_cols = mm_cols,
    raw_map = raw_map,
    missing = missing,
    complete_rows = if (missing == "complete") which(complete_rows) else seq_len(nrow(raw)),
    actions = actions_df
  )
}

.r4vn_machine_recipe_apply <- function(recipe, raw) {
  d <- raw[, recipe$keep, drop = FALSE]
  keep_rows <- rep(TRUE, nrow(d))
  unseen <- character()
  for (v in recipe$keep) {
    if (recipe$types[[v]] == "categorical") {
      z <- as.character(d[[v]])
      if (recipe$missing == "complete") {
        keep_rows <- keep_rows & !is.na(z)
      } else {
        z[is.na(z)] <- as.character(recipe$impute[[v]])
      }
      bad <- !is.na(z) & !z %in% recipe$levels[[v]]
      if (any(bad)) {
        unseen <- c(unseen, paste0(v, ": ", paste(unique(z[bad]), collapse = ", ")))
        z[bad] <- recipe$levels[[v]][1L]
      }
      d[[v]] <- factor(z, levels = recipe$levels[[v]])
    } else {
      z <- suppressWarnings(as.numeric(d[[v]]))
      if (recipe$missing == "complete") keep_rows <- keep_rows & is.finite(z)
      else z[!is.finite(z)] <- as.numeric(recipe$impute[[v]])
      oo <- recipe$outlier[[v]]
      if (!is.null(oo) && oo$method %in% c("winsor", "robust")) z <- pmin(pmax(z, oo$low), oo$high)
      tt <- recipe$transform[[v]]
      if (!is.null(tt) && tt$method == "log") z <- log(z + tt$shift)
      if (!is.null(tt) && tt$method == "yeojohnson") z <- .r4vn_machine_yj(z, tt$lambda)
      d[[v]] <- z
    }
  }
  if (recipe$missing == "complete") d2 <- d[keep_rows, , drop = FALSE] else d2 <- d
  mm <- stats::model.matrix(~ ., data = d2)
  if ("(Intercept)" %in% colnames(mm)) mm <- mm[, colnames(mm) != "(Intercept)", drop = FALSE]
  # Align base encoded columns with the training blueprint.
  miss_cols <- setdiff(recipe$base_cols, colnames(mm))
  if (length(miss_cols)) {
    add <- matrix(0, nrow(mm), length(miss_cols), dimnames = list(NULL, miss_cols))
    mm <- cbind(mm, add)
  }
  extra <- setdiff(colnames(mm), recipe$base_cols)
  if (length(extra)) mm <- mm[, setdiff(colnames(mm), extra), drop = FALSE]
  mm <- mm[, recipe$base_cols, drop = FALSE]
  mm <- .r4vn_machine_feature_expand_apply(mm, recipe$feature_spec)
  miss_fx <- setdiff(recipe$feature_cols,colnames(mm))
  if(length(miss_fx)){add<-matrix(0,nrow(mm),length(miss_fx),dimnames=list(NULL,miss_fx));mm<-cbind(mm,add)}
  mm <- mm[,recipe$feature_cols,drop=FALSE]
  mm <- .r4vn_machine_pca_apply(mm, recipe$pca)
  mm <- mm[,recipe$mm_cols,drop=FALSE]
  list(x = mm, rows = which(keep_rows), unseen = unique(unseen))
}

.r4vn_machine_scale_fit <- function(x, method) {
  if (method == "none") return(list(method = "none", center = NULL, scale = NULL))
  if (method == "z") {
    cen <- colMeans(x, na.rm = TRUE); sc <- apply(x, 2L, stats::sd, na.rm = TRUE)
  } else if (method == "minmax") {
    lo <- apply(x, 2L, min, na.rm = TRUE); hi <- apply(x, 2L, max, na.rm = TRUE)
    cen <- lo; sc <- hi - lo
  } else {
    cen <- apply(x, 2L, stats::median, na.rm = TRUE); sc <- apply(x, 2L, stats::mad, na.rm = TRUE)
  }
  sc[!is.finite(sc) | sc == 0] <- 1
  list(method = method, center = cen, scale = sc)
}

.r4vn_machine_scale_apply <- function(x, scaler) {
  if (is.null(scaler) || scaler$method == "none") return(x)
  sweep(sweep(x, 2L, scaler$center, "-"), 2L, scaler$scale, "/")
}

.r4vn_machine_needs_scale <- function(method) method %in% c("lasso", "ridge", "elastic", "svm", "knn", "multinom")

.r4vn_machine_scale_method <- function(standardize, model_method) {
  if (standardize == "none") return("none")
  if (standardize == "auto") return(if (.r4vn_machine_needs_scale(model_method)) "z" else "none")
  standardize
}

# ============================================================================
# Feature selection and class balancing
# ============================================================================

.r4vn_machine_auto_nfeatures <- function(p, n) {
  max(3L, min(p, max(5L, floor(sqrt(max(1, p * n / max(p, 1)))))))
}

.r4vn_machine_univariate_score <- function(x, y, task) {
  if (task == "regression") {
    return(abs(suppressWarnings(stats::cor(x, y, use = "complete.obs"))))
  }
  if (task == "binary") {
    return(abs(mean(x[y == 1L], na.rm = TRUE) - mean(x[y == 0L], na.rm = TRUE)) /
             max(stats::sd(x, na.rm = TRUE), 1e-8))
  }
  # multiclass eta-squared-like between/total variation
  grand <- mean(x, na.rm = TRUE)
  lev <- levels(y)
  ssb <- sum(vapply(lev, function(g) {
    z <- x[y == g]; sum(!is.na(z)) * (mean(z, na.rm = TRUE) - grand)^2
  }, numeric(1)), na.rm = TRUE)
  sst <- sum((x - grand)^2, na.rm = TRUE)
  if (!is.finite(sst) || sst == 0) 0 else ssb / sst
}

.r4vn_machine_feature_select <- function(x, y, task, method, nfeatures = "auto", seed = NA_integer_) {
  p <- ncol(x)
  if (!p) stop("No encoded predictors available for feature selection.", call. = FALSE)
  if (method %in% c("none", "filter")) return(list(selected = colnames(x), detail = data.frame()))
  nf <- if (is.character(nfeatures) && nfeatures[1L] == "auto") .r4vn_machine_auto_nfeatures(p, nrow(x)) else as.integer(nfeatures[1L])
  nf <- max(1L, min(p, nf))

  if (method == "lasso") {
    if (!requireNamespace("glmnet", quietly = TRUE)) {
      return(list(selected = colnames(x), detail = data.frame(), note = "glmnet unavailable; feature selection fell back to filter."))
    }
    fam <- if (task == "binary") "binomial" else if (task == "multiclass") "multinomial" else "gaussian"
    yy <- if (task == "multiclass") y else as.numeric(y)
    .r4vn_set_seed_if(seed)
    cv <- tryCatch(glmnet::cv.glmnet(x, yy, family = fam, alpha = 1,
                                     nfolds = max(3L, min(5L, nrow(x))), standardize = FALSE),
                   error = function(e) NULL)
    if (is.null(cv)) return(list(selected = colnames(x), detail = data.frame(), note = "LASSO selection failed; filter selection used."))
    cf <- tryCatch(stats::coef(cv, s = "lambda.1se"), error = function(e) NULL)
    sel <- character()
    detail <- data.frame()
    if (task == "multiclass" && is.list(cf)) {
      rn <- unique(unlist(lapply(cf, rownames)))
      rn <- setdiff(rn, "(Intercept)")
      keep <- vapply(rn, function(v) any(vapply(cf, function(m) v %in% rownames(m) && as.numeric(m[v, 1L]) != 0, logical(1))), logical(1))
      sel <- rn[keep]
    } else if (!is.null(cf)) {
      vv <- as.matrix(cf)
      sel <- rownames(vv)[rownames(vv) != "(Intercept)" & as.numeric(vv[, 1L]) != 0]
      detail <- data.frame(feature = setdiff(rownames(vv), "(Intercept)"),
                           coefficient = as.numeric(vv[rownames(vv) != "(Intercept)", 1L]), stringsAsFactors = FALSE)
    }
    sel <- intersect(sel, colnames(x))
    if (!length(sel)) sel <- colnames(x)[seq_len(min(nf, p))]
    return(list(selected = sel, detail = detail))
  }

  if (method == "stepwise") {
    df <- data.frame(.y = y, x, check.names = FALSE)
    fit <- tryCatch({
      if (task == "binary") stats::glm(.y ~ ., data = df, family = stats::binomial())
      else if (task == "regression") stats::lm(.y ~ ., data = df)
      else NULL
    }, error = function(e) NULL)
    if (is.null(fit)) return(list(selected = colnames(x), detail = data.frame(), note = "Stepwise selection is not available for this task/model matrix; filter used."))
    st <- tryCatch(stats::step(fit, trace = 0), error = function(e) NULL)
    if (is.null(st)) return(list(selected = colnames(x), detail = data.frame(), note = "Stepwise selection failed; filter used."))
    sel <- attr(stats::terms(st), "term.labels")
    sel <- intersect(sel, colnames(x))
    if (!length(sel)) sel <- colnames(x)
    return(list(selected = sel, detail = data.frame(feature = sel, stringsAsFactors = FALSE)))
  }

  if (method == "boruta") {
    if (!requireNamespace("Boruta", quietly = TRUE)) {
      return(list(selected = colnames(x), detail = data.frame(), note = "Boruta unavailable; filter selection used."))
    }
    df <- data.frame(x, check.names = FALSE)
    yy <- if (task == "regression") as.numeric(y) else factor(y)
    .r4vn_set_seed_if(seed)
    b <- tryCatch(Boruta::Boruta(x = df, y = yy, doTrace = 0), error = function(e) NULL)
    if (is.null(b)) return(list(selected = colnames(x), detail = data.frame(), note = "Boruta selection failed; filter used."))
    decision <- b$finalDecision
    sel <- names(decision)[decision %in% c("Confirmed", "Tentative")]
    if (!length(sel)) sel <- names(decision)[decision == "Confirmed"]
    if (!length(sel)) sel <- colnames(x)
    detail <- data.frame(feature = names(decision), decision = as.character(decision), stringsAsFactors = FALSE)
    return(list(selected = intersect(sel, colnames(x)), detail = detail))
  }

  scores <- vapply(seq_len(ncol(x)), function(j) .r4vn_machine_univariate_score(x[, j], y, task), numeric(1))
  scores[!is.finite(scores)] <- -Inf
  ord <- order(scores, decreasing = TRUE)

  if (method == "importance") {
    sel <- colnames(x)[ord[seq_len(nf)]]
    return(list(selected = sel,
                detail = data.frame(feature = colnames(x), score = scores, selected = colnames(x) %in% sel, stringsAsFactors = FALSE)))
  }

  if (method == "rfe") {
    # Lightweight fold-local RFE: rank within the training fold, then choose the
    # smallest top-k set within 0.5% of the best simple holdout score.
    sizes <- unique(sort(pmax(1L, pmin(p, c(3L, 5L, 10L, 20L, nf, p)))))
    .r4vn_set_seed_if(seed)
    idx <- sample(seq_len(nrow(x)))
    cut <- max(2L, floor(.8 * nrow(x)))
    tr <- idx[seq_len(cut)]; va <- setdiff(idx, tr)
    if (!length(va)) va <- tr
    vals <- rep(NA_real_, length(sizes))
    for (i in seq_along(sizes)) {
      cols <- colnames(x)[ord[seq_len(sizes[i])]]
      if (task == "binary") {
        df <- data.frame(.y = y[tr], x[tr, cols, drop = FALSE], check.names = FALSE)
        ft <- tryCatch(stats::glm(.y ~ ., data = df, family = stats::binomial()), error = function(e) NULL)
        pp <- if (is.null(ft)) rep(mean(y[tr]), length(va)) else suppressWarnings(as.numeric(stats::predict(ft, newdata = data.frame(x[va, cols, drop=FALSE], check.names=FALSE), type="response")))
        vals[i] <- .r4vn_machine_auc(y[va], pp)
      } else if (task == "regression") {
        df <- data.frame(.y = y[tr], x[tr, cols, drop = FALSE], check.names = FALSE)
        ft <- tryCatch(stats::lm(.y ~ ., data = df), error = function(e) NULL)
        pp <- if (is.null(ft)) rep(mean(y[tr]), length(va)) else suppressWarnings(as.numeric(stats::predict(ft, newdata=data.frame(x[va, cols, drop=FALSE], check.names=FALSE))))
        vals[i] <- sqrt(mean((y[va] - pp)^2, na.rm = TRUE))
      } else {
        vals[i] <- mean(scores[ord[seq_len(sizes[i])]], na.rm = TRUE)
      }
    }
    if (task == "regression") bestv <- min(vals, na.rm = TRUE) else bestv <- max(vals, na.rm = TRUE)
    eligible <- if (task == "regression") which(vals <= bestv * 1.005) else which(vals >= bestv - .005)
    ii <- eligible[which.min(sizes[eligible])]
    sel <- colnames(x)[ord[seq_len(sizes[ii])]]
    return(list(selected = sel,
                detail = data.frame(nfeatures = sizes, score = vals, selected_size = sizes == sizes[ii])))
  }

  list(selected = colnames(x), detail = data.frame())
}

.r4vn_machine_class_weights <- function(y) {
  tab <- table(y)
  wmap <- sum(tab) / (length(tab) * tab)
  as.numeric(wmap[as.character(y)])
}

.r4vn_machine_updown <- function(x, y, method, target, seed) {
  .r4vn_set_seed_if(seed)
  tab <- table(y)
  if (length(tab) != 2L) return(list(x=x, y=y, weights=NULL))
  lev <- names(tab); minlev <- lev[which.min(tab)]; majlev <- lev[which.max(tab)]
  imin <- which(as.character(y) == minlev); imaj <- which(as.character(y) == majlev)
  if (method == "up") {
    n_target_min <- ceiling(length(imaj) * target / (1 - target))
    add <- sample(imin, max(0L, n_target_min - length(imin)), replace = TRUE)
    idx <- c(seq_along(y), add)
  } else {
    n_target_maj <- floor(length(imin) * (1 - target) / target)
    keepmaj <- sample(imaj, min(length(imaj), max(1L, n_target_maj)))
    idx <- c(imin, keepmaj)
  }
  idx <- sample(idx)
  list(x=x[idx,,drop=FALSE], y=y[idx], weights=NULL)
}

.r4vn_machine_knn_dist <- function(x, row) {
  sqrt(rowSums((x - matrix(row, nrow(x), ncol(x), byrow = TRUE))^2))
}

.r4vn_machine_smote <- function(x, y, target, k, seed, adasyn = FALSE) {
  .r4vn_set_seed_if(seed)
  tab <- table(y)
  if (length(tab) != 2L) return(list(x=x, y=y, weights=NULL))
  lev <- names(tab); minlev <- lev[which.min(tab)]; majlev <- lev[which.max(tab)]
  imin <- which(as.character(y) == minlev); imaj <- which(as.character(y) == majlev)
  n_target <- ceiling(length(imaj) * target / (1 - target))
  need <- max(0L, n_target - length(imin))
  if (!need || length(imin) < 2L) return(list(x=x, y=y, weights=NULL))
  kmin <- min(k, length(imin)-1L)
  minx <- x[imin,,drop=FALSE]

  source_prob <- rep(1/length(imin), length(imin))
  if (isTRUE(adasyn)) {
    kall <- min(k, nrow(x)-1L)
    difficulty <- numeric(length(imin))
    for (i in seq_along(imin)) {
      ds <- .r4vn_machine_knn_dist(x, x[imin[i],])
      nn <- order(ds)[seq_len(min(kall + 1L, length(ds)))]
      nn <- setdiff(nn, imin[i])[seq_len(min(kall, length(setdiff(nn, imin[i]))))]
      difficulty[i] <- if (length(nn)) mean(as.character(y[nn]) == majlev) else 0
    }
    if (sum(difficulty) > 0) source_prob <- difficulty / sum(difficulty)
  }

  synth <- matrix(NA_real_, need, ncol(x), dimnames = list(NULL, colnames(x)))
  for (b in seq_len(need)) {
    i <- sample(seq_along(imin), 1L, prob = source_prob)
    ds <- .r4vn_machine_knn_dist(minx, minx[i,])
    nn <- order(ds)[order(ds) != i]
    nn <- nn[seq_len(min(kmin, length(nn)))]
    j <- sample(nn, 1L)
    gap <- stats::runif(1L)
    synth[b,] <- minx[i,] + gap * (minx[j,] - minx[i,])
  }
  yy <- if (is.factor(y)) factor(c(as.character(y), rep(minlev, need)), levels=levels(y)) else c(y, rep(type.convert(minlev, as.is=TRUE), need))
  list(x=rbind(x, synth), y=yy, weights=NULL)
}

.r4vn_machine_balance_apply <- function(x, y, method, target=.5, k=5, seed=NA_integer_) {
  if (method == "none") return(list(x=x, y=y, weights=NULL, method="none"))
  if (method == "weight") return(list(x=x, y=y, weights=.r4vn_machine_class_weights(y), method="weight"))
  if (method %in% c("up","down")) {
    z <- .r4vn_machine_updown(x,y,method,target,seed); z$method <- method; return(z)
  }
  if (method == "smote") {
    z <- .r4vn_machine_smote(x,y,target,k,seed,FALSE); z$method <- method; return(z)
  }
  if (method == "adasyn") {
    z <- .r4vn_machine_smote(x,y,target,k,seed,TRUE); z$method <- method; return(z)
  }
  if (method == "rose") {
    if (requireNamespace("ROSE", quietly = TRUE)) {
      df <- data.frame(.y = factor(y), x, check.names=FALSE)
      .r4vn_set_seed_if(seed)
      rose_args <- list(formula = .y ~ ., data = df, N = nrow(df), p = target)
      if (is.finite(seed)) rose_args$seed <- as.integer(seed)
      rr <- tryCatch(do.call(ROSE::ROSE, rose_args)$data, error=function(e) NULL)
      if (!is.null(rr)) {
        yy <- rr$.y; xx <- as.matrix(rr[, setdiff(names(rr), ".y"), drop=FALSE]); storage.mode(xx) <- "double"
        return(list(x=xx,y=yy,weights=NULL,method="rose"))
      }
    }
    z <- .r4vn_machine_smote(x,y,target,k,seed,FALSE); z$method <- "smote (ROSE unavailable)"; return(z)
  }
  list(x=x,y=y,weights=NULL,method="none")
}

# ============================================================================
# Model engines, tuning, cross-validation, and complete pipelines
# ============================================================================

.r4vn_machine_default_params <- function(method, task, p = 10L) {
  switch(method,
    logistic = list(),
    linear = list(),
    multinom = list(decay = 0, maxit = 500),
    lasso = list(alpha = 1, lambda_rule = "lambda.1se"),
    ridge = list(alpha = 0, lambda_rule = "lambda.1se"),
    elastic = list(alpha = .5, lambda_rule = "lambda.1se"),
    tree = list(cp = .01, minsplit = 20L, maxdepth = 30L),
    rf = list(num.trees = 500L, mtry = max(1L, floor(sqrt(p))), min.node.size = if (task == "regression") 5L else 1L),
    xgb = list(nrounds = 250L, max_depth = 4L, eta = .05, subsample = .8, colsample_bytree = .8, min_child_weight = 1),
    svm = list(kernel = "radial", cost = 1, gamma = 1 / max(1,p)),
    knn = list(k = 7L),
    naive = list(laplace = 0),
    list()
  )
}

.r4vn_machine_tune_grid <- function(method, task, tune = TRUE, tune_n = 10, seed = NA_integer_) {
  if (identical(tune, FALSE)) return(list(.r4vn_machine_default_params(method, task)))
  mode <- if (isTRUE(tune)) "grid" else tolower(as.character(tune)[1L])
  grid <- switch(method,
    tree = list(
      list(cp=.001, minsplit=10L, maxdepth=10L),
      list(cp=.005, minsplit=20L, maxdepth=20L),
      list(cp=.01, minsplit=20L, maxdepth=30L)
    ),
    rf = list(
      list(num.trees=500L, mtry=NULL, mtry_factor=.5, min.node.size=1L),
      list(num.trees=500L, mtry=NULL, mtry_factor=1, min.node.size=if (task=="regression") 5L else 1L),
      list(num.trees=700L, mtry=NULL, mtry_factor=2, min.node.size=if (task=="regression") 10L else 5L)
    ),
    xgb = list(
      list(nrounds=200L,max_depth=3L,eta=.05,subsample=.8,colsample_bytree=.8,min_child_weight=1),
      list(nrounds=300L,max_depth=4L,eta=.05,subsample=.8,colsample_bytree=.8,min_child_weight=1),
      list(nrounds=250L,max_depth=6L,eta=.03,subsample=.9,colsample_bytree=.8,min_child_weight=2)
    ),
    svm = list(
      list(kernel="radial",cost=.5,gamma=NULL,gamma_factor=.5),
      list(kernel="radial",cost=1,gamma=NULL,gamma_factor=1),
      list(kernel="radial",cost=4,gamma=NULL,gamma_factor=2)
    ),
    knn = lapply(c(3L,5L,7L,11L,15L), function(k) list(k=k)),
    multinom = lapply(c(0,.001,.01,.1), function(d) list(decay=d,maxit=500)),
    elastic = lapply(c(.25,.5,.75), function(a) list(alpha=a,lambda_rule="lambda.1se")),
    list(.r4vn_machine_default_params(method, task))
  )
  if (mode == "random" && length(grid) > tune_n) {
    .r4vn_set_seed_if(seed); grid <- grid[sample(seq_along(grid), tune_n)]
  }
  grid
}

.r4vn_machine_resolve_params <- function(params, p, task) {
  z <- params
  if (!is.null(z$mtry_factor)) {
    z$mtry <- max(1L, min(p, round(sqrt(p) * z$mtry_factor))); z$mtry_factor <- NULL
  }
  if (!is.null(z$gamma_factor)) {
    z$gamma <- z$gamma_factor / max(1,p); z$gamma_factor <- NULL
  }
  z
}

.r4vn_machine_fit_engine <- function(x, y, task, method, params, weights = NULL, seed = NA_integer_, standardize = "auto") {
  p <- ncol(x)
  params <- .r4vn_machine_resolve_params(params, p, task)
  sm <- .r4vn_machine_scale_method(standardize, method)
  scaler <- .r4vn_machine_scale_fit(x, sm)
  xx <- .r4vn_machine_scale_apply(x, scaler)
  .r4vn_set_seed_if(seed)

  if (method == "logistic") {
    df <- data.frame(.y=as.numeric(y), xx, check.names=FALSE)
    fit <- stats::glm(.y ~ ., data=df, family=stats::binomial(), weights=weights)
  } else if (method == "linear") {
    df <- data.frame(.y=as.numeric(y), xx, check.names=FALSE)
    fit <- stats::lm(.y ~ ., data=df, weights=weights)
  } else if (method == "multinom") {
    if (!requireNamespace("nnet", quietly=TRUE)) stop("Package `nnet` is required for multinomial regression.")
    df <- data.frame(.y=factor(y), xx, check.names=FALSE)
    fit <- nnet::multinom(.y ~ ., data=df, weights=weights, trace=FALSE,
                         decay=params$decay %||% 0, maxit=params$maxit %||% 500)
  } else if (method %in% c("lasso","ridge","elastic")) {
    if (!requireNamespace("glmnet", quietly=TRUE)) stop("Package `glmnet` is required for penalized models.")
    fam <- if (task=="binary") "binomial" else if (task=="multiclass") "multinomial" else "gaussian"
    yy <- if (task=="multiclass") factor(y) else as.numeric(y)
    alpha <- params$alpha %||% switch(method,lasso=1,ridge=0,elastic=.5)
    fit <- glmnet::cv.glmnet(xx, yy, family=fam, alpha=alpha, weights=weights,
                             nfolds=max(3L,min(10L,nrow(xx))), standardize=FALSE)
  } else if (method == "tree") {
    if (!requireNamespace("rpart", quietly=TRUE)) stop("Package `rpart` is required for decision trees.")
    yy <- if (task=="regression") as.numeric(y) else factor(y)
    df <- data.frame(.y=yy, xx, check.names=FALSE)
    control <- rpart::rpart.control(cp=params$cp %||% .01,
                                   minsplit=params$minsplit %||% 20L,
                                   maxdepth=params$maxdepth %||% 30L,
                                   xval=0)
    fit <- rpart::rpart(.y ~ ., data=df, weights=weights,
                        method=if(task=="regression") "anova" else "class", control=control)
  } else if (method == "rf") {
    if (!requireNamespace("ranger", quietly=TRUE)) stop("Package `ranger` is required for random forest.")
    yy <- if (task=="regression") as.numeric(y) else factor(y)
    df <- data.frame(.y=yy, xx, check.names=FALSE)
    fit <- ranger::ranger(.y ~ ., data=df,
                          probability=task != "regression",
                          num.trees=params$num.trees %||% 500L,
                          mtry=min(p, params$mtry %||% max(1L,floor(sqrt(p)))),
                          min.node.size=params$min.node.size %||% if(task=="regression") 5L else 1L,
                          case.weights=weights,
                          importance="permutation",
                          seed=if (is.finite(seed)) seed else NULL)
  } else if (method == "xgb") {
    if (!requireNamespace("xgboost", quietly=TRUE)) stop("Package `xgboost` is required for XGBoost.")
    label <- if (task=="multiclass") as.integer(factor(y))-1L else as.numeric(y)
    dm <- if (is.null(weights)) xgboost::xgb.DMatrix(data=xx, label=label) else
      xgboost::xgb.DMatrix(data=xx, label=label, weight=weights)
    obj <- if(task=="binary") "binary:logistic" else if(task=="multiclass") "multi:softprob" else "reg:squarederror"
    xp <- list(objective=obj, max_depth=params$max_depth %||% 4L, eta=params$eta %||% .05,
               subsample=params$subsample %||% .8, colsample_bytree=params$colsample_bytree %||% .8,
               min_child_weight=params$min_child_weight %||% 1, verbosity=0)
    if(task=="multiclass") xp$num_class <- length(levels(factor(y)))
    fit <- xgboost::xgb.train(params=xp,data=dm,nrounds=params$nrounds %||% 250L,verbose=0)
  } else if (method == "svm") {
    if (!requireNamespace("e1071", quietly=TRUE)) stop("Package `e1071` is required for SVM.")
    yy <- if(task=="regression") as.numeric(y) else factor(y)
    df <- data.frame(.y=yy,xx,check.names=FALSE)
    class.weights <- NULL
    if (task != "regression" && !is.null(weights)) {
      tmp <- tapply(weights, yy, mean); class.weights <- as.numeric(tmp); names(class.weights) <- names(tmp)
    }
    fit <- e1071::svm(.y ~ .,data=df,type=if(task=="regression") "eps-regression" else "C-classification",
                      kernel=params$kernel %||% "radial",cost=params$cost %||% 1,
                      gamma=params$gamma %||% 1/max(1,p),probability=task!="regression",
                      class.weights=class.weights,scale=FALSE)
  } else if (method == "knn") {
    fit <- list(x=xx,y=y,k=as.integer(params$k %||% 7L),task=task)
    class(fit) <- "r4vn_machine_knn"
  } else if (method == "naive") {
    if (!requireNamespace("e1071", quietly=TRUE)) stop("Package `e1071` is required for naive Bayes.")
    df <- data.frame(.y=factor(y),xx,check.names=FALSE)
    fit <- e1071::naiveBayes(.y ~ .,data=df,laplace=params$laplace %||% 0)
  } else stop("Unsupported method: ",method,call.=FALSE)

  list(model=fit, method=method, task=task, scaler=scaler, params=params,
       x_cols=colnames(x), y_levels=if(task=="multiclass") levels(factor(y)) else if(task=="binary") c("0","1") else NULL)
}

`%||%` <- function(x,y) if(is.null(x)) y else x

.r4vn_machine_knn_predict <- function(fit, newx) {
  tr <- fit$x; y <- fit$y; k <- min(fit$k,nrow(tr))
  if(fit$task=="regression") {
    return(vapply(seq_len(nrow(newx)),function(i){d<-rowSums((tr-matrix(newx[i,],nrow(tr),ncol(tr),byrow=TRUE))^2);mean(as.numeric(y)[order(d)[seq_len(k)]])},numeric(1)))
  }
  lev <- levels(factor(y)); probs <- matrix(0,nrow(newx),length(lev),dimnames=list(NULL,lev))
  for(i in seq_len(nrow(newx))){
    d<-rowSums((tr-matrix(newx[i,],nrow(tr),ncol(tr),byrow=TRUE))^2); nn<-order(d)[seq_len(k)]; tb<-table(factor(y[nn],levels=lev)); probs[i,]<-as.numeric(tb)/k
  }
  probs
}

.r4vn_machine_predict_engine <- function(fit, x) {
  xx <- .r4vn_machine_scale_apply(x, fit$scaler)
  method <- fit$method; task <- fit$task; model <- fit$model
  if(method=="logistic") return(list(prob=as.numeric(stats::predict(model,newdata=data.frame(xx,check.names=FALSE),type="response"))))
  if(method=="linear") return(list(value=as.numeric(stats::predict(model,newdata=data.frame(xx,check.names=FALSE)))))
  if(method=="multinom") {
    p<-stats::predict(model,newdata=data.frame(xx,check.names=FALSE),type="probs"); if(is.vector(p)) p<-matrix(p,nrow=1)
    if(is.null(colnames(p)) && ncol(p)==length(fit$y_levels)) colnames(p)<-fit$y_levels
    return(list(prob=p,class=colnames(p)[max.col(p)]))
  }
  if(method %in% c("lasso","ridge","elastic")) {
    s <- fit$params$lambda_rule %||% "lambda.1se"
    pp <- stats::predict(model,newx=xx,s=s,type="response")
    if(task=="regression") return(list(value=as.numeric(pp)))
    if(task=="binary") return(list(prob=as.numeric(pp)))
    ar <- as.array(pp); if(length(dim(ar))==3L) ar<-ar[,,1L,drop=FALSE][,,1L]; if(is.vector(ar)) ar<-matrix(ar,nrow=nrow(xx))
    return(list(prob=ar,class=colnames(ar)[max.col(ar)]))
  }
  if(method=="tree") {
    if(task=="regression") return(list(value=as.numeric(stats::predict(model,newdata=data.frame(xx,check.names=FALSE)))))
    p<-stats::predict(model,newdata=data.frame(xx,check.names=FALSE),type="prob"); if(task=="binary") return(list(prob=as.numeric(p[,ncol(p)]))); return(list(prob=p,class=colnames(p)[max.col(p)]))
  }
  if(method=="rf") {
    p<-predict(model,data=data.frame(xx,check.names=FALSE))$predictions
    if(task=="regression") return(list(value=as.numeric(p)))
    if(task=="binary") return(list(prob=as.numeric(p[,ncol(p)])))
    return(list(prob=p,class=colnames(p)[max.col(p)]))
  }
  if(method=="xgb") {
    p<-predict(model,xgboost::xgb.DMatrix(xx))
    if(task=="regression") return(list(value=as.numeric(p)))
    if(task=="binary") return(list(prob=as.numeric(p)))
    nc<-length(fit$y_levels); if(is.null(dim(p))) p<-matrix(p,ncol=nc,byrow=TRUE); colnames(p)<-fit$y_levels
    return(list(prob=p,class=colnames(p)[max.col(p)]))
  }
  if(method=="svm") {
    if(task=="regression") return(list(value=as.numeric(stats::predict(model,newdata=data.frame(xx,check.names=FALSE)))))
    pr<-stats::predict(model,newdata=data.frame(xx,check.names=FALSE),probability=TRUE); p<-attr(pr,"probabilities")
    if(is.null(p)) {lev<-levels(pr);p<-matrix(0,length(pr),length(lev),dimnames=list(NULL,lev));p[cbind(seq_along(pr),match(pr,lev))]<-1}
    if(task=="binary") return(list(prob=as.numeric(p[,ncol(p)])))
    return(list(prob=p,class=colnames(p)[max.col(p)]))
  }
  if(method=="knn") {
    p<-.r4vn_machine_knn_predict(model,xx); if(task=="regression") return(list(value=p)); if(task=="binary") return(list(prob=as.numeric(p[,ncol(p)]))); return(list(prob=p,class=colnames(p)[max.col(p)]))
  }
  if(method=="naive") {
    p<-stats::predict(model,newdata=data.frame(xx,check.names=FALSE),type="raw"); if(task=="binary") return(list(prob=as.numeric(p[,ncol(p)]))); return(list(prob=p,class=colnames(p)[max.col(p)]))
  }
  stop("Prediction is not implemented for method: ",method,call.=FALSE)
}

.r4vn_machine_fit_pipeline <- function(raw,outcome_name,predictor_names,task,outcome_info,
                                       method,params,preprocess,missing,missing_max,encode,
                                       standardize,transform,outlier,corr,feature,degree,reduce,variance,select,nfeatures,
                                       balance,balance_target,neighbors,selected_override=NULL,seed=NA_integer_) {
  recipe <- .r4vn_machine_recipe_fit(raw,predictor_names,preprocess,missing,missing_max,encode,transform,outlier,corr,feature,degree,reduce,variance)
  ap <- .r4vn_machine_recipe_apply(recipe,raw)
  yy <- raw[[outcome_name]][ap$rows]
  fs <- .r4vn_machine_feature_select(ap$x,yy,task,select,nfeatures,seed+1L)
  selected <- fs$selected
  if(!is.null(selected_override)) {
    ov <- intersect(selected_override,colnames(ap$x)); if(length(ov)) selected<-ov
  }
  xx <- ap$x[,selected,drop=FALSE]
  balance_engine <- balance
  if (task == "binary" && identical(balance, "weight") && method %in% c("knn", "naive")) {
    balance_engine <- "up"
  }
  bal <- if(task=="binary") .r4vn_machine_balance_apply(xx,yy,balance_engine,balance_target,neighbors,seed+2L) else list(x=xx,y=yy,weights=NULL,method="none")
  if (!identical(balance_engine, balance)) bal$method <- paste0(bal$method, " (fallback for ", method, ")")
  eng <- .r4vn_machine_fit_engine(bal$x,bal$y,task,method,params,bal$weights,seed+3L,standardize)
  list(recipe=recipe,selected=selected,selection_detail=fs$detail,selection_note=fs$note %||% NULL,
       balance=bal$method,engine=eng,method=method,task=task,outcome=outcome_name,
       predictors=predictor_names,outcome_info=outcome_info,standardize=standardize)
}

.r4vn_machine_predict_pipeline <- function(fit,raw,outcome_name=NULL) {
  if(!is.data.frame(raw)) stop("Prediction data must be a data frame.",call.=FALSE)
  miss <- setdiff(fit$recipe$keep,names(raw))
  if(length(miss)) stop("Prediction data are missing required predictors: ",paste(miss,collapse=", "),call.=FALSE)
  ap <- .r4vn_machine_recipe_apply(fit$recipe,raw)
  x <- ap$x[,fit$selected,drop=FALSE]
  pr <- .r4vn_machine_predict_engine(fit$engine,x)
  pr$rows <- ap$rows; pr$unseen <- ap$unseen
  pr
}

.r4vn_machine_cv_one <- function(raw,outcome_name,predictor_names,task,outcome_info,method,params,
                                 preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,
                                 feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,folds,repeats,seed) {
  sp <- .r4vn_machine_make_folds(raw[[outcome_name]], folds, repeats, task, seed)
  rows <- list()
  errors <- character()
  ii <- 0L

  remember_error <- function(stage, s, e) {
    msg <- paste0(
      stage, " failed in repeat ", s$rep_id, ", fold ", s$fold,
      ": ", conditionMessage(e)
    )
    errors <<- c(errors, msg)
    invisible(NULL)
  }

  for (s in sp) {
    tr <- raw[s$train, , drop = FALSE]
    va <- raw[s$val, , drop = FALSE]

    fit <- tryCatch(
      .r4vn_machine_fit_pipeline(
        tr, outcome_name, predictor_names, task, outcome_info, method, params,
        preprocess, missing, missing_max, encode, standardize, transform, outlier, corr,
        feature, degree, reduce, variance, select, nfeatures, balance, balance_target, neighbors,
        seed = seed + s$rep_id * 100L + s$fold
      ),
      error = function(e) {
        remember_error("Model fitting", s, e)
        NULL
      }
    )

    if (is.null(fit)) next

    pr <- tryCatch(
      .r4vn_machine_predict_pipeline(fit, va, outcome_name),
      error = function(e) {
        remember_error("Prediction", s, e)
        NULL
      }
    )

    if (is.null(pr)) next
    if (!length(pr$rows)) {
      errors <- c(
        errors,
        paste0("Prediction returned zero usable rows in repeat ", s$rep_id, ", fold ", s$fold, ".")
      )
      next
    }

    truth <- va[[outcome_name]][pr$rows]
    ii <- ii + 1L

    if (task == "binary") {
      if (is.null(pr$prob) || length(pr$prob) != length(truth)) {
        errors <- c(errors, paste0("Binary prediction shape mismatch in repeat ", s$rep_id, ", fold ", s$fold, "."))
        ii <- ii - 1L
        next
      }
      rows[[ii]] <- data.frame(
        truth = as.numeric(truth),
        prob = as.numeric(pr$prob),
        row_id = s$val[pr$rows],
        rep_id = s$rep_id,
        fold = s$fold
      )
    } else if (task == "regression") {
      if (is.null(pr$value) || length(pr$value) != length(truth)) {
        errors <- c(errors, paste0("Regression prediction shape mismatch in repeat ", s$rep_id, ", fold ", s$fold, "."))
        ii <- ii - 1L
        next
      }
      rows[[ii]] <- data.frame(
        truth = as.numeric(truth),
        value = as.numeric(pr$value),
        row_id = s$val[pr$rows],
        rep_id = s$rep_id,
        fold = s$fold
      )
    } else {
      if (is.null(pr$prob) || !is.matrix(pr$prob) || nrow(pr$prob) != length(truth)) {
        errors <- c(errors, paste0("Multiclass prediction shape mismatch in repeat ", s$rep_id, ", fold ", s$fold, "."))
        ii <- ii - 1L
        next
      }
      p <- as.data.frame(pr$prob, check.names = FALSE)
      names(p) <- paste0("prob__", names(p))
      rows[[ii]] <- cbind(
        data.frame(
          truth = as.character(truth),
          row_id = s$val[pr$rows],
          rep_id = s$rep_id,
          fold = s$fold
        ),
        p
      )
    }
  }

  if (!length(rows)) {
    detail <- if (length(errors)) {
      paste0(" First fold error: ", errors[[1L]])
    } else {
      ""
    }
    stop(
      paste0("Cross-validation produced no valid predictions.", detail),
      call. = FALSE
    )
  }

  out <- do.call(rbind, rows)
  row.names(out) <- NULL
  attr(out, "cv_errors") <- unique(errors)
  out
}

.r4vn_machine_oof_unique_binary <- function(cv) {
  if (!all(c("truth", "prob") %in% names(cv))) stop("Binary OOF predictions are incomplete.", call. = FALSE)
  if (!"row_id" %in% names(cv) || anyNA(cv$row_id)) return(cv[, c("truth", "prob"), drop = FALSE])
  ids <- unique(cv$row_id)
  data.frame(
    truth = vapply(ids, function(i) cv$truth[match(i, cv$row_id)], numeric(1)),
    prob = vapply(ids, function(i) mean(cv$prob[cv$row_id == i], na.rm = TRUE), numeric(1)),
    row_id = ids,
    stringsAsFactors = FALSE
  )
}

.r4vn_machine_metric_from_cv <- function(cv,task,metric) {
  if(task=="binary") return(.r4vn_machine_binary_metric_value(cv$truth,cv$prob,metric,.5))
  if(task=="regression") return(.r4vn_machine_reg_metric_value(cv$truth,cv$value,metric))
  pcols<-grep("^prob__",names(cv),value=TRUE); pp<-as.matrix(cv[pcols]); colnames(pp)<-sub("^prob__","",pcols)
  .r4vn_machine_multi_metric_value(factor(cv$truth,levels=colnames(pp)),pp,metric)
}

.r4vn_machine_tune <- function(method,grid,raw,outcome_name,predictor_names,task,outcome_info,
                               preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,
                               feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,folds,repeats,nested,metric,seed) {
  vals<-rep(NA_real_,length(grid)); cvs<-vector("list",length(grid)); errs<-character()
  for(i in seq_along(grid)){
    cv<-tryCatch(.r4vn_machine_cv_one(raw,outcome_name,predictor_names,task,outcome_info,method,grid[[i]],preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,folds,repeats,seed+i*50L),error=function(e)e)
    if(inherits(cv,"error")){errs<-c(errs,conditionMessage(cv));next}
    cvs[[i]]<-cv; vals[i]<-.r4vn_machine_metric_from_cv(cv,task,metric)
  }
  good<-which(is.finite(vals)); if(!length(good)) return(list(ok=FALSE,error=paste(unique(errs),collapse=" | ")))
  dir<-.r4vn_machine_metric_direction(metric); besti<-if(dir=="max") good[which.max(vals[good])] else good[which.min(vals[good])]
  cv<-cvs[[besti]]
  if (isTRUE(nested) && length(grid) > 1L) {
    nested_cv <- .r4vn_machine_nested_cv(raw,outcome_name,predictor_names,task,outcome_info,method,grid,
      preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,folds,metric,seed+777L)
    if (!is.null(nested_cv)) cv <- nested_cv
  }
  # summary for the primary metric plus common secondary metrics on OOF predictions
  metrics<-if(task=="binary") c("auc","pr_auc","accuracy","balanced_accuracy","sensitivity","specificity","f1","brier","logloss") else if(task=="regression") c("rmse","mae","r2","mape") else c("accuracy","balanced_accuracy","macro_f1","weighted_f1","macro_auc","macro_pr_auc","logloss")
  summ<-data.frame(method=method,metric=metrics,estimate=vapply(metrics,function(m).r4vn_machine_metric_from_cv(cv,task,m),numeric(1)),stringsAsFactors=FALSE)
  tuning<-data.frame(method=method,candidate=seq_along(grid),score=vals,selected=seq_along(grid)==besti,stringsAsFactors=FALSE)
  tuning$params<-vapply(grid,function(z)paste(paste(names(z),unlist(z),sep="="),collapse="; "),character(1))
  list(ok=TRUE,params=grid[[besti]],cv=cv,cv_summary=summ,tuning=tuning,nested=nested)
}

# ============================================================================
# Performance metrics and confidence intervals
# ============================================================================

.r4vn_machine_div <- function(a,b) ifelse(b==0,NA_real_,a/b)

.r4vn_machine_wilson <- function(x,n,level=.95) {
  if(!is.finite(x)||!is.finite(n)||n<=0) return(c(NA_real_,NA_real_))
  z<-stats::qnorm(1-(1-level)/2); ph<-x/n; den<-1+z^2/n
  ctr<-(ph+z^2/(2*n))/den; half<-z*sqrt(ph*(1-ph)/n+z^2/(4*n^2))/den
  pmax(0,pmin(1,c(ctr-half,ctr+half)))
}

.r4vn_machine_auc <- function(y,p) {
  ok<-is.finite(p)&!is.na(y); y<-as.numeric(y[ok]);p<-p[ok]
  n1<-sum(y==1);n0<-sum(y==0);if(n1==0||n0==0)return(NA_real_)
  r<-rank(p,ties.method="average"); (sum(r[y==1])-n1*(n1+1)/2)/(n1*n0)
}

.r4vn_machine_pr_auc <- function(y,p) {
  ok<-is.finite(p)&!is.na(y);y<-as.numeric(y[ok]);p<-p[ok];npos<-sum(y==1);if(npos==0)return(NA_real_)
  o<-order(p,decreasing=TRUE); yy<-y[o];tp<-cumsum(yy==1);fp<-cumsum(yy==0)
  rec<-tp/npos;prec<-tp/pmax(tp+fp,1)
  rec<-c(0,rec);prec<-c(1,prec)
  sum(diff(rec)*(head(prec,-1)+tail(prec,-1))/2,na.rm=TRUE)
}

.r4vn_machine_binary_metrics <- function(y,p,threshold=.5) {
  ok<-!is.na(y)&is.finite(p);y<-as.numeric(y[ok]);p<-p[ok];cl<-as.integer(p>=threshold)
  tp<-sum(cl==1&y==1);fp<-sum(cl==1&y==0);fn<-sum(cl==0&y==1);tn<-sum(cl==0&y==0);n<-length(y)
  sens<-.r4vn_machine_div(tp,tp+fn);spec<-.r4vn_machine_div(tn,tn+fp);ppv<-.r4vn_machine_div(tp,tp+fp);npv<-.r4vn_machine_div(tn,tn+fn)
  acc<-.r4vn_machine_div(tp+tn,n);f1<-.r4vn_machine_div(2*tp,2*tp+fp+fn);bal<-mean(c(sens,spec),na.rm=TRUE)
  mccden<-sqrt((tp+fp)*(tp+fn)*(tn+fp)*(tn+fn));mcc<-.r4vn_machine_div(tp*tn-fp*fn,mccden)
  po<-acc; pe<-.r4vn_machine_div((tp+fp)*(tp+fn)+(fn+tn)*(fp+tn),n^2);kappa<-.r4vn_machine_div(po-pe,1-pe)
  prev<-mean(y==1); brier<-mean((p-y)^2); eps<-1e-15;pp<-pmin(pmax(p,eps),1-eps);logloss<- -mean(y*log(pp)+(1-y)*log(1-pp))
  c(n=n,tp=tp,fp=fp,fn=fn,tn=tn,prevalence=prev,auc=.r4vn_machine_auc(y,p),pr_auc=.r4vn_machine_pr_auc(y,p),
    accuracy=acc,balanced_accuracy=bal,sensitivity=sens,specificity=spec,ppv=ppv,npv=npv,f1=f1,mcc=mcc,kappa=kappa,brier=brier,logloss=logloss)
}

.r4vn_machine_binary_metric_value <- function(y,p,metric,threshold=.5) {
  z<-.r4vn_machine_binary_metrics(y,p,threshold); nm<-tolower(metric)
  if(nm=="sens")nm<-"sensitivity";if(nm=="spec")nm<-"specificity";if(nm=="roc_auc")nm<-"auc"
  unname(z[nm] %||% NA_real_)
}

.r4vn_machine_reg_metrics <- function(y,p) {
  ok<-is.finite(y)&is.finite(p);y<-as.numeric(y[ok]);p<-as.numeric(p[ok]);n<-length(y);if(!n)return(c(n=0,rmse=NA,mae=NA,r2=NA,mape=NA))
  rmse<-sqrt(mean((y-p)^2));mae<-mean(abs(y-p));sst<-sum((y-mean(y))^2);r2<-if(sst>0)1-sum((y-p)^2)/sst else NA_real_
  nz<-abs(y)>.Machine$double.eps^.5;mape<-if(any(nz))mean(abs((y[nz]-p[nz])/y[nz])) else NA_real_
  c(n=n,rmse=rmse,mae=mae,r2=r2,mape=mape)
}
.r4vn_machine_reg_metric_value <- function(y,p,metric) unname(.r4vn_machine_reg_metrics(y,p)[tolower(metric)] %||% NA_real_)

.r4vn_machine_multi_metrics <- function(y,p) {
  if(!is.matrix(p))p<-as.matrix(p);lev<-colnames(p);if(is.null(lev))lev<-levels(factor(y));y<-factor(y,levels=lev)
  pred<-factor(lev[max.col(p,ties.method="first")],levels=lev);cm<-table(y,pred);n<-sum(cm);acc<-sum(diag(cm))/n
  sens<-diag(cm)/pmax(rowSums(cm),1);prec<-diag(cm)/pmax(colSums(cm),1);f1<-2*prec*sens/pmax(prec+sens,.Machine$double.eps)
  macro_f1<-mean(f1,na.rm=TRUE);weighted_f1<-sum(f1*rowSums(cm))/n;bal<-mean(sens,na.rm=TRUE)
  aucs<-praucs<-rep(NA_real_,length(lev))
  for(j in seq_along(lev)){
    yy<-as.integer(y==lev[j]);aucs[j]<-.r4vn_machine_auc(yy,p[,j]);praucs[j]<-.r4vn_machine_pr_auc(yy,p[,j])
  }
  macro_auc<-mean(aucs,na.rm=TRUE);macro_pr_auc<-mean(praucs,na.rm=TRUE)
  eps<-1e-15;p<-pmin(pmax(p,eps),1-eps);idx<-cbind(seq_len(nrow(p)),match(y,lev));ll<- -mean(log(p[idx]))
  c(n=n,accuracy=acc,balanced_accuracy=bal,macro_f1=macro_f1,weighted_f1=weighted_f1,macro_auc=macro_auc,macro_pr_auc=macro_pr_auc,logloss=ll)
}
.r4vn_machine_multi_metric_value <- function(y,p,metric) unname(.r4vn_machine_multi_metrics(y,p)[tolower(metric)] %||% NA_real_)

.r4vn_machine_boot_binary <- function(y,p,threshold,metrics,boot,level,seed,stratified=TRUE) {
  .r4vn_set_seed_if(seed);n<-length(y);mat<-matrix(NA_real_,boot,length(metrics),dimnames=list(NULL,metrics));idx0<-which(y==0);idx1<-which(y==1)
  for(b in seq_len(boot)){
    if(stratified&&length(idx0)&&length(idx1)) idx<-c(sample(idx0,length(idx0),TRUE),sample(idx1,length(idx1),TRUE)) else idx<-sample(seq_len(n),n,TRUE)
    zz<-.r4vn_machine_binary_metrics(y[idx],p[idx],threshold);mat[b,]<-zz[metrics]
  }
  alpha<-(1-level)/2;t(apply(mat,2,stats::quantile,probs=c(alpha,1-alpha),na.rm=TRUE,names=FALSE))
}

.r4vn_machine_boot_reg <- function(y,p,metrics,boot,level,seed) {
  .r4vn_set_seed_if(seed);n<-length(y);mat<-matrix(NA_real_,boot,length(metrics),dimnames=list(NULL,metrics))
  for(b in seq_len(boot)){idx<-sample(seq_len(n),n,TRUE);mat[b,]<-.r4vn_machine_reg_metrics(y[idx],p[idx])[metrics]}
  alpha<-(1-level)/2;t(apply(mat,2,stats::quantile,probs=c(alpha,1-alpha),na.rm=TRUE,names=FALSE))
}

.r4vn_machine_boot_multi <- function(y,p,metrics,boot,level,seed) {
  .r4vn_set_seed_if(seed);n<-length(y);mat<-matrix(NA_real_,boot,length(metrics),dimnames=list(NULL,metrics))
  for(b in seq_len(boot)){idx<-sample(seq_len(n),n,TRUE);mat[b,]<-.r4vn_machine_multi_metrics(y[idx],p[idx,,drop=FALSE])[metrics]}
  alpha<-(1-level)/2;t(apply(mat,2,stats::quantile,probs=c(alpha,1-alpha),na.rm=TRUE,names=FALSE))
}

.r4vn_machine_multiclass_class_performance <- function(truth,pred,ci=TRUE,level=.95,boot=1000L,seed=NA_integer_) {
  p <- as.matrix(pred$prob); lev <- colnames(p)
  if(is.null(lev) || !length(lev)) return(data.frame())
  rows <- pred$rows %||% seq_along(truth)
  # `truth` may already have been aligned to the prediction rows by the caller.
  if (length(truth) == nrow(p)) rows <- seq_len(nrow(p))
  y <- factor(truth[rows],levels=lev); pcl <- factor(lev[max.col(p,ties.method="first")],levels=lev)
  out <- list(); ii <- 0L; .r4vn_set_seed_if(seed)
  for(j in seq_along(lev)) {
    cl <- lev[j]; yb <- as.integer(y==cl); cb <- as.integer(pcl==cl); ps <- p[,j]
    tp<-sum(cb==1&yb==1);fp<-sum(cb==1&yb==0);fn<-sum(cb==0&yb==1);tn<-sum(cb==0&yb==0);n<-length(yb)
    est <- c(
      auc=.r4vn_machine_auc(yb,ps), pr_auc=.r4vn_machine_pr_auc(yb,ps),
      sensitivity=.r4vn_machine_div(tp,tp+fn), specificity=.r4vn_machine_div(tn,tn+fp),
      ppv=.r4vn_machine_div(tp,tp+fp), npv=.r4vn_machine_div(tn,tn+fn),
      accuracy=.r4vn_machine_div(tp+tn,n),
      f1=.r4vn_machine_div(2*tp,2*tp+fp+fn), prevalence=mean(yb==1)
    )
    tab <- data.frame(class=cl,metric=names(est),estimate=as.numeric(est),lower=NA_real_,upper=NA_real_,ci_method=NA_character_,stringsAsFactors=FALSE)
    if(isTRUE(ci)) {
      den <- list(sensitivity=c(tp,tp+fn),specificity=c(tn,tn+fp),ppv=c(tp,tp+fp),npv=c(tn,tn+fn),accuracy=c(tp+tn,n),prevalence=c(tp+fn,n))
      for(nm in names(den)){cc<-.r4vn_machine_wilson(den[[nm]][1L],den[[nm]][2L],level);k<-which(tab$metric==nm);tab[k,c("lower","upper")]<-cc;tab$ci_method[k]<-"Wilson"}
      ac <- .r4vn_machine_auc_ci(yb,ps,level,boot,seed+j*101L);k<-which(tab$metric=="auc");tab[k,c("lower","upper")]<-ac$ci;tab$ci_method[k]<-ac$method
      # PR-AUC and F1 are derived measures; bootstrap complete subject rows.
      bpr<-bf1<-rep(NA_real_,boot)
      idx0<-which(yb==0);idx1<-which(yb==1)
      for(b in seq_len(boot)){
        ix<-if(length(idx0)&&length(idx1))c(sample(idx0,length(idx0),TRUE),sample(idx1,length(idx1),TRUE))else sample(seq_len(n),n,TRUE)
        bpr[b]<-.r4vn_machine_pr_auc(yb[ix],ps[ix])
        tpb<-sum(cb[ix]==1&yb[ix]==1);fpb<-sum(cb[ix]==1&yb[ix]==0);fnb<-sum(cb[ix]==0&yb[ix]==1)
        bf1[b]<-.r4vn_machine_div(2*tpb,2*tpb+fpb+fnb)
      }
      aq<-c((1-level)/2,1-(1-level)/2)
      qfun<-function(v){v<-v[is.finite(v)];if(length(v)<30L)c(NA_real_,NA_real_)else stats::quantile(v,aq,na.rm=TRUE,names=FALSE)}
      qq<-qfun(bpr);k<-which(tab$metric=="pr_auc");tab[k,c("lower","upper")]<-qq;tab$ci_method[k]<-"Stratified bootstrap"
      qq<-qfun(bf1);k<-which(tab$metric=="f1");tab[k,c("lower","upper")]<-qq;tab$ci_method[k]<-"Stratified bootstrap"
    }
    ii<-ii+1L;out[[ii]]<-tab
  }
  .r4vn_machine_bind(out)
}

.r4vn_machine_auc_ci <- function(y,p,level,boot,seed) {
  ok<-!is.na(y)&is.finite(p);y<-as.numeric(y[ok]);p<-p[ok]
  if(length(unique(y))<2L)return(list(ci=c(NA_real_,NA_real_),method=NA_character_))
  if(requireNamespace("pROC",quietly=TRUE)){
    rr<-tryCatch(pROC::roc(y,p,levels=c(0,1),direction="<",quiet=TRUE),error=function(e)NULL)
    if(!is.null(rr)){
      cc<-tryCatch(pROC::ci.auc(rr,conf.level=level,method="delong"),error=function(e)NULL)
      if(!is.null(cc))return(list(ci=c(as.numeric(cc[1L]),as.numeric(cc[3L])),method="DeLong"))
    }
  }
  bb<-.r4vn_machine_boot_binary(y,p,.5,"auc",boot,level,seed,TRUE)
  list(ci=as.numeric(bb[1,]),method="Stratified bootstrap")
}

.r4vn_machine_evaluate <- function(truth,pred,task,method,threshold=NULL,ci=TRUE,ci_level=.95,boot=1000,seed=NA_integer_,dataset="Test") {
  if(task=="binary"){
    y<-as.numeric(truth[pred$rows %||% seq_along(truth)]);p<-pred$prob;th<-threshold %||% .5;z<-.r4vn_machine_binary_metrics(y,p,th)
    metrics<-c("auc","pr_auc","accuracy","balanced_accuracy","sensitivity","specificity","ppv","npv","f1","mcc","kappa","brier","logloss","prevalence")
    out<-data.frame(dataset=dataset,method=method,metric=metrics,estimate=as.numeric(z[metrics]),lower=NA_real_,upper=NA_real_,ci_method=NA_character_,stringsAsFactors=FALSE)
    if(isTRUE(ci)){
      # Wilson intervals where the metric is a direct binomial proportion.
      denoms<-list(accuracy=c(z["tp"]+z["tn"],z["n"]),sensitivity=c(z["tp"],z["tp"]+z["fn"]),specificity=c(z["tn"],z["tn"]+z["fp"]),
                   ppv=c(z["tp"],z["tp"]+z["fp"]),npv=c(z["tn"],z["tn"]+z["fn"]),prevalence=c(z["tp"]+z["fn"],z["n"]))
      for(nm in names(denoms)){cc<-.r4vn_machine_wilson(denoms[[nm]][1],denoms[[nm]][2],ci_level);ii<-which(out$metric==nm);out[ii,c("lower","upper")]<-cc;out$ci_method[ii]<-"Wilson"}
      ac<-.r4vn_machine_auc_ci(y,p,ci_level,boot,seed+1L);ii<-which(out$metric=="auc");out[ii,c("lower","upper")]<-ac$ci;out$ci_method[ii]<-ac$method
      derived<-setdiff(metrics,c(names(denoms),"auc"));bb<-.r4vn_machine_boot_binary(y,p,th,derived,boot,ci_level,seed+2L,TRUE)
      for(nm in derived){ii<-which(out$metric==nm);out[ii,c("lower","upper")]<-bb[nm,];out$ci_method[ii]<-"Bootstrap"}
    }
    return(list(performance=out,metrics=z))
  }
  if(task=="regression"){
    y<-as.numeric(truth[pred$rows %||% seq_along(truth)]);p<-pred$value;metrics<-c("rmse","mae","r2","mape");z<-.r4vn_machine_reg_metrics(y,p)
    out<-data.frame(dataset=dataset,method=method,metric=metrics,estimate=as.numeric(z[metrics]),lower=NA_real_,upper=NA_real_,ci_method=NA_character_)
    if(isTRUE(ci)){bb<-.r4vn_machine_boot_reg(y,p,metrics,boot,ci_level,seed);out[,c("lower","upper")]<-bb[metrics,,drop=FALSE];out$ci_method<-"Bootstrap"}
    return(list(performance=out,metrics=z))
  }
  rows<-pred$rows %||% seq_along(truth);y<-factor(truth[rows],levels=colnames(pred$prob));p<-pred$prob;metrics<-c("accuracy","balanced_accuracy","macro_f1","weighted_f1","macro_auc","macro_pr_auc","logloss");z<-.r4vn_machine_multi_metrics(y,p)
  out<-data.frame(dataset=dataset,method=method,metric=metrics,estimate=as.numeric(z[metrics]),lower=NA_real_,upper=NA_real_,ci_method=NA_character_)
  if(isTRUE(ci)){
    pred_class <- factor(colnames(p)[max.col(p,ties.method="first")],levels=levels(y))
    acc_ci <- .r4vn_machine_wilson(sum(pred_class==y,na.rm=TRUE),sum(!is.na(y)),ci_level)
    ia <- which(out$metric=="accuracy"); out[ia,c("lower","upper")] <- acc_ci; out$ci_method[ia] <- "Wilson"
    derived <- setdiff(metrics,"accuracy")
    bb <- .r4vn_machine_boot_multi(y,p,derived,boot,ci_level,seed)
    for(nm in derived){ii<-which(out$metric==nm);out[ii,c("lower","upper")]<-bb[nm,];out$ci_method[ii]<-"Bootstrap"}
  }
  list(performance=out,metrics=z)
}

.r4vn_machine_threshold_point <- function(y,p,method="youden",target_sens=.9,target_spec=.9) {
  cuts <- sort(unique(c(0, p[is.finite(p)], 1)))
  if (length(cuts) > 2000L) cuts <- unique(stats::quantile(cuts, probs=seq(0,1,length.out=2000), na.rm=TRUE))
  tab <- do.call(rbind, lapply(cuts, function(th) {
    m <- .r4vn_machine_binary_metrics(y,p,th)
    data.frame(threshold=th,sensitivity=m["sensitivity"],specificity=m["specificity"],
               f1=m["f1"],youden=m["sensitivity"]+m["specificity"]-1)
  }))
  if (method == "youden") i <- which.max(tab$youden)
  else if (method == "f1") i <- which.max(tab$f1)
  else if (method == "sens") {
    ok <- which(tab$sensitivity >= target_sens)
    i <- if(length(ok)) ok[which.max(tab$specificity[ok])] else which.max(tab$sensitivity)
  } else if (method == "spec") {
    ok <- which(tab$specificity >= target_spec)
    i <- if(length(ok)) ok[which.max(tab$sensitivity[ok])] else which.max(tab$specificity)
  } else stop("Unknown `threshold` method: ",method,call.=FALSE)
  list(threshold=tab$threshold[i], curve=tab)
}

.r4vn_machine_threshold <- function(y,p,threshold="auto",target_sens=.9,target_spec=.9,
                                    ci=TRUE,level=.95,boot=1000L,seed=NA_integer_) {
  ok <- !is.na(y) & is.finite(p)
  y <- as.numeric(y[ok]); p <- as.numeric(p[ok])
  if (is.numeric(threshold)) {
    th <- max(0,min(1,as.numeric(threshold[1])))
    # Retain the full sensitivity/specificity curve for plotting even when the
    # cutoff itself was fixed a priori by the user.
    curve <- .r4vn_machine_threshold_point(y,p,"youden",target_sens,target_spec)$curve
    return(list(method="fixed",threshold=th,lower=NA_real_,upper=NA_real_,ci_method="Not applicable (fixed by user)",
                training_metrics=.r4vn_machine_binary_metrics(y,p,th),curve=curve))
  }
  method <- tolower(as.character(threshold)[1]); if(method=="auto") method <- "youden"
  pt <- .r4vn_machine_threshold_point(y,p,method,target_sens,target_spec)
  th <- pt$threshold; lo <- hi <- NA_real_; ci_method <- NA_character_
  if (isTRUE(ci) && length(unique(y)) == 2L && boot >= 50L) {
    .r4vn_set_seed_if(seed); idx0 <- which(y==0); idx1 <- which(y==1); vals <- rep(NA_real_,boot)
    for (b in seq_len(boot)) {
      ix <- c(sample(idx0,length(idx0),replace=TRUE), sample(idx1,length(idx1),replace=TRUE))
      vals[b] <- tryCatch(.r4vn_machine_threshold_point(y[ix],p[ix],method,target_sens,target_spec)$threshold,
                          error=function(e) NA_real_)
    }
    vals <- vals[is.finite(vals)]
    if (length(vals) >= max(30L, ceiling(.5*boot))) {
      a <- (1-level)/2; qq <- stats::quantile(vals,c(a,1-a),na.rm=TRUE,names=FALSE)
      lo <- qq[1L]; hi <- qq[2L]; ci_method <- "Stratified bootstrap"
    }
  }
  list(method=method,threshold=th,lower=lo,upper=hi,ci_method=ci_method,
       training_metrics=.r4vn_machine_binary_metrics(y,p,th),curve=pt$curve)
}

.r4vn_machine_comparison <- function(performance,primary,digit=3) {
  if(!nrow(performance))return(data.frame())
  methods<-unique(performance$method);metrics<-unique(performance$metric);out<-data.frame(Model=methods,stringsAsFactors=FALSE)
  fmt<-function(est,lo,hi){if(is.finite(lo)&&is.finite(hi))paste0(formatC(est,format="f",digits=digit)," (",formatC(lo,format="f",digits=digit),"-",formatC(hi,format="f",digits=digit),")") else formatC(est,format="f",digits=digit)}
  for(m in metrics){nm<-.r4vn_machine_pretty_metric(m);out[[nm]]<-vapply(methods,function(md){r<-performance[performance$method==md&performance$metric==m,,drop=FALSE];if(!nrow(r))return(NA_character_);fmt(r$estimate[1],r$lower[1],r$upper[1])},character(1))}
  out
}

.r4vn_machine_choose_best <- function(cv,metric,task) {
  z<-cv[cv$metric==metric&is.finite(cv$estimate),,drop=FALSE];if(!nrow(z))return(unique(cv$method)[1L]);if(.r4vn_machine_metric_direction(metric)=="max")z$method[which.max(z$estimate)] else z$method[which.min(z$estimate)]
}

.r4vn_machine_prediction_map <- function(pr, task) {
  rows <- pr$rows
  if (is.null(rows)) {
    n <- if(task=="binary") length(pr$prob) else if(task=="regression") length(pr$value) else nrow(pr$prob)
    rows <- seq_len(n)
  }
  list(rows=rows,
       prob=if(task=="binary") pr$prob else if(task=="multiclass") pr$prob else NULL,
       value=if(task=="regression") pr$value else NULL)
}

.r4vn_machine_model_differences <- function(truth,predictions,best,task,metric,threshold,ci,level,boot,seed) {
  if(length(predictions)<2L)return(data.frame())
  bmap <- .r4vn_machine_prediction_map(predictions[[best]],task)
  methods <- setdiff(names(predictions),best); out <- list(); .r4vn_set_seed_if(seed)
  for(md in methods) {
    cmap <- .r4vn_machine_prediction_map(predictions[[md]],task)
    rows <- intersect(bmap$rows,cmap$rows); if(!length(rows))next
    ib <- match(rows,bmap$rows); ic <- match(rows,cmap$rows); yy <- truth[rows]; n <- length(rows)
    if(task=="binary") {
      p1 <- bmap$prob[ib]; p2 <- cmap$prob[ic]
      v1 <- .r4vn_machine_binary_metric_value(yy,p1,metric,threshold)
      v2 <- .r4vn_machine_binary_metric_value(yy,p2,metric,threshold)
    } else if(task=="regression") {
      p1 <- bmap$value[ib]; p2 <- cmap$value[ic]
      v1 <- .r4vn_machine_reg_metric_value(yy,p1,metric); v2 <- .r4vn_machine_reg_metric_value(yy,p2,metric)
    } else {
      p1 <- bmap$prob[ib,,drop=FALSE]; p2 <- cmap$prob[ic,,drop=FALSE]
      lev <- intersect(colnames(p1),colnames(p2)); p1 <- p1[,lev,drop=FALSE]; p2 <- p2[,lev,drop=FALSE]
      yy <- factor(yy,levels=lev)
      v1 <- .r4vn_machine_multi_metric_value(yy,p1,metric); v2 <- .r4vn_machine_multi_metric_value(yy,p2,metric)
    }
    d0 <- v1-v2; lo <- hi <- NA_real_
    if(isTRUE(ci)) {
      bb <- rep(NA_real_,boot)
      for(b in seq_len(boot)) {
        ix <- sample(seq_len(n),n,TRUE)
        if(task=="binary") bb[b] <- .r4vn_machine_binary_metric_value(yy[ix],p1[ix],metric,threshold)-.r4vn_machine_binary_metric_value(yy[ix],p2[ix],metric,threshold)
        else if(task=="regression") bb[b] <- .r4vn_machine_reg_metric_value(yy[ix],p1[ix],metric)-.r4vn_machine_reg_metric_value(yy[ix],p2[ix],metric)
        else bb[b] <- .r4vn_machine_multi_metric_value(factor(yy[ix],levels=levels(yy)),p1[ix,,drop=FALSE],metric)-.r4vn_machine_multi_metric_value(factor(yy[ix],levels=levels(yy)),p2[ix,,drop=FALSE],metric)
      }
      good <- bb[is.finite(bb)]
      if(length(good)>=30L) {qq<-stats::quantile(good,c((1-level)/2,1-(1-level)/2),na.rm=TRUE,names=FALSE);lo<-qq[1];hi<-qq[2]}
    }
    out[[md]]<-data.frame(best=best,comparison=md,metric=metric,difference=d0,lower=lo,upper=hi,
                          ci_method=if(isTRUE(ci))"Paired bootstrap" else NA_character_,stringsAsFactors=FALSE)
  }
  .r4vn_machine_bind(out)
}

.r4vn_machine_overfitting <- function(cv, performance, metric) {
  cvz <- cv[cv$metric == metric, c("method","estimate"), drop=FALSE]
  tez <- performance[performance$metric == metric, c("method","estimate"), drop=FALSE]
  if(!nrow(cvz) || !nrow(tez)) return(data.frame())
  names(cvz)[2L] <- "development"
  names(tez)[2L] <- "evaluation"
  z <- merge(cvz,tez,by="method",all=FALSE)
  if(!nrow(z)) return(z)
  dir <- .r4vn_machine_metric_direction(metric)
  z$optimism <- if(dir=="max") z$development-z$evaluation else z$evaluation-z$development
  z$flag <- ifelse(is.finite(z$optimism) & z$optimism > .05,"Possible overfitting","")
  z$metric <- metric
  z[,c("method","metric","development","evaluation","optimism","flag"),drop=FALSE]
}

.r4vn_machine_coefficients <- function(fit, labels=NULL, ci=TRUE, ci_level=.95) {
  method <- fit$method
  if(!method %in% c("logistic","linear")) return(data.frame())
  model <- fit$engine$model
  sm <- tryCatch(summary(model)$coefficients,error=function(e)NULL)
  if(is.null(sm) || !nrow(sm)) return(data.frame())
  est <- sm[,1L]; se <- sm[,2L]
  pcol <- grep("Pr\\(",colnames(sm)); pv <- if(length(pcol)) sm[,pcol[1L]] else rep(NA_real_,length(est))
  z <- stats::qnorm(1-(1-ci_level)/2); lo <- if(isTRUE(ci)) est-z*se else rep(NA_real_,length(est)); hi <- if(isTRUE(ci)) est+z*se else rep(NA_real_,length(est))
  term <- names(est); clean <- gsub("`","",term,fixed=TRUE)
  raw <- rep(NA_character_,length(clean))
  raw[clean=="(Intercept)"] <- "(Intercept)"
  mp <- fit$recipe$raw_map
  for(i in which(is.na(raw))) {
    if(clean[i] %in% names(mp)) raw[i] <- as.character(mp[[clean[i]]])
    if(is.na(raw[i]) || !nzchar(raw[i])) raw[i] <- clean[i]
  }
  lab <- vapply(raw,function(v)if(v=="(Intercept)")"Intercept" else .r4vn_machine_get_label(labels,v),character(1))
  if(method=="logistic") {
    data.frame(Predictor=lab,Term=term,OR=exp(est),Lower=exp(lo),Upper=exp(hi),p=pv,
               `CI method`=if(isTRUE(ci))"Wald" else NA_character_,check.names=FALSE,stringsAsFactors=FALSE)
  } else {
    data.frame(Predictor=lab,Term=term,Beta=est,Lower=lo,Upper=hi,p=pv,
               `CI method`=if(isTRUE(ci))"Wald" else NA_character_,check.names=FALSE,stringsAsFactors=FALSE)
  }
}

# ============================================================================
# Strategy comparison, parsimony, calibration, decision curves, explanations
# ============================================================================

.r4vn_machine_baseline_method <- function(task, methods) {
  pref <- if(task=="binary") c("logistic","tree","rf") else if(task=="regression") c("linear","tree","rf") else c("multinom","tree","rf")
  z<-intersect(pref,methods);if(length(z))z[1L] else methods[1L]
}

.r4vn_machine_compare_selection <- function(raw,outcome_name,predictor_names,task,outcome_info,methods,
                                            preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,
                                            feature,degree,reduce,variance,nfeatures,balance,balance_target,neighbors,folds,metric,seed) {
  cand<-c("filter","importance","rfe");if(requireNamespace("glmnet",quietly=TRUE))cand<-c(cand,"lasso");if(requireNamespace("Boruta",quietly=TRUE))cand<-c(cand,"boruta")
  meth<-.r4vn_machine_baseline_method(task,methods);par<-.r4vn_machine_default_params(meth,task,length(predictor_names));rows<-list();notes<-character()
  for(i in seq_along(cand)){
    cv<-tryCatch(.r4vn_machine_cv_one(raw,outcome_name,predictor_names,task,outcome_info,meth,par,preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,feature,degree,reduce,variance,cand[i],nfeatures,balance,balance_target,neighbors,folds,1L,seed+i*17L),error=function(e)e)
    if(inherits(cv,"error")){notes<-c(notes,paste0("Selection ",cand[i]," skipped: ",conditionMessage(cv)));next}
    rows[[cand[i]]]<-data.frame(strategy=cand[i],metric=metric,estimate=.r4vn_machine_metric_from_cv(cv,task,metric),stringsAsFactors=FALSE)
  }
  tab<-.r4vn_machine_bind(rows);if(!nrow(tab))return(list(best="filter",table=tab,notes=notes))
  best<-if(.r4vn_machine_metric_direction(metric)=="max")tab$strategy[which.max(tab$estimate)] else tab$strategy[which.min(tab$estimate)]
  list(best=best,table=tab,notes=c(notes,paste0("Feature-selection comparison selected `",best,"` using development CV only.")))
}

.r4vn_machine_compare_balance <- function(raw,outcome_name,predictor_names,task,outcome_info,methods,
                                          preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,
                                          feature,degree,reduce,variance,select,nfeatures,balance_target,neighbors,folds,metric,seed) {
  cand<-c("none","weight","up","down","smote","adasyn");if(requireNamespace("ROSE",quietly=TRUE))cand<-c(cand,"rose")
  meth<-.r4vn_machine_baseline_method(task,methods);par<-.r4vn_machine_default_params(meth,task,length(predictor_names));rows<-list();notes<-character()
  for(i in seq_along(cand)){
    cv<-tryCatch(.r4vn_machine_cv_one(raw,outcome_name,predictor_names,task,outcome_info,meth,par,preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,feature,degree,reduce,variance,select,nfeatures,cand[i],balance_target,neighbors,folds,1L,seed+i*19L),error=function(e)e)
    if(inherits(cv,"error")){notes<-c(notes,paste0("Balancing ",cand[i]," skipped: ",conditionMessage(cv)));next}
    rows[[cand[i]]]<-data.frame(strategy=cand[i],metric=metric,estimate=.r4vn_machine_metric_from_cv(cv,task,metric),stringsAsFactors=FALSE)
  }
  tab<-.r4vn_machine_bind(rows);if(!nrow(tab))return(list(best="none",table=tab,notes=notes))
  best<-if(.r4vn_machine_metric_direction(metric)=="max")tab$strategy[which.max(tab$estimate)] else tab$strategy[which.min(tab$estimate)]
  list(best=best,table=tab,notes=c(notes,paste0("Balancing comparison selected `",best,"` using development CV only.")))
}

.r4vn_machine_simplify <- function(raw,outcome_name,predictor_names,task,outcome_info,method,params,
                                   preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,
                                   feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,metric,folds,tolerance,seed) {
  # Fit preprocessing and selection once on development training data only to
  # obtain a candidate ranking. Final test data remain untouched.
  rec<-tryCatch(.r4vn_machine_recipe_fit(raw,predictor_names,preprocess,missing,missing_max,encode,transform,outlier,corr,feature,degree,reduce,variance),error=function(e)NULL)
  if(is.null(rec))return(NULL);ap<-.r4vn_machine_recipe_apply(rec,raw);y<-raw[[outcome_name]][ap$rows]
  fs<-.r4vn_machine_feature_select(ap$x,y,task,select,nfeatures,seed);sel<-fs$selected
  if(length(sel)<=5L)return(list(selected_encoded=sel,table=data.frame(nfeatures=length(sel),score=NA_real_,chosen=TRUE),note="Already compact."))
  scores<-vapply(sel,function(v).r4vn_machine_univariate_score(ap$x[,v],y,task),numeric(1));scores[!is.finite(scores)]<- -Inf;ranked<-sel[order(scores,decreasing=TRUE)]
  sizes<-unique(sort(pmax(3L,pmin(length(ranked),c(3L,5L,8L,10L,15L,length(ranked))))))
  # Use a fixed training-only holdout for a conservative parsimony screen.
  sp<-.r4vn_machine_split(raw,outcome_name,task,.8,seed+1L);tr<-sp$train;va<-sp$test;vals<-rep(NA_real_,length(sizes))
  for(i in seq_along(sizes)){
    ft<-tryCatch(.r4vn_machine_fit_pipeline(tr,outcome_name,predictor_names,task,outcome_info,method,params,preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,selected_override=ranked[seq_len(sizes[i])],seed=seed+i),error=function(e)NULL)
    if(is.null(ft))next;pr<-tryCatch(.r4vn_machine_predict_pipeline(ft,va,outcome_name),error=function(e)NULL);if(is.null(pr))next;yy<-va[[outcome_name]][pr$rows]
    vals[i]<-if(task=="binary").r4vn_machine_binary_metric_value(yy,pr$prob,metric,.5) else if(task=="regression").r4vn_machine_reg_metric_value(yy,pr$value,metric) else .r4vn_machine_multi_metric_value(factor(yy,levels=colnames(pr$prob)),pr$prob,metric)
  }
  good<-which(is.finite(vals));if(!length(good))return(list(selected_encoded=sel,table=data.frame(),note="Parsimony screen failed; original selected set retained."))
  dir<-.r4vn_machine_metric_direction(metric);bestv<-if(dir=="max")max(vals[good]) else min(vals[good]);eligible<-if(dir=="max")which(vals>=bestv-tolerance) else which(vals<=bestv+tolerance)
  if(!length(eligible))eligible<-good;ii<-eligible[which.min(sizes[eligible])];chosen<-ranked[seq_len(sizes[ii])]
  list(selected_encoded=chosen,table=data.frame(nfeatures=sizes,score=vals,chosen=seq_along(sizes)==ii),
       note=paste0("Training-only parsimony screen retained ",length(chosen)," encoded predictors."))
}

.r4vn_machine_calibration <- function(y,p,ci=TRUE,level=.95,boot=1000,seed=NA_integer_,bins=10L) {
  ok<-!is.na(y)&is.finite(p);y<-as.numeric(y[ok]);p<-pmin(pmax(p[ok],1e-6),1-1e-6);lp<-stats::qlogis(p)
  fit_s<-tryCatch(stats::glm(y~lp,family=stats::binomial()),error=function(e)NULL)
  fit_i<-tryCatch(stats::glm(y~offset(lp),family=stats::binomial()),error=function(e)NULL)
  slope<-intercept<-NA_real_;slo<-shi<-ilo<-ihi<-NA_real_;smeth<-imeth<-NA_character_
  zcrit<-stats::qnorm(1-(1-level)/2)
  if(!is.null(fit_s)){
    cf<-tryCatch(summary(fit_s)$coefficients,error=function(e)NULL);slope<-unname(stats::coef(fit_s)["lp"])
    if(isTRUE(ci)&&!is.null(cf)&&"lp"%in%rownames(cf)&&is.finite(cf["lp","Std. Error"])){
      slo<-slope-zcrit*cf["lp","Std. Error"];shi<-slope+zcrit*cf["lp","Std. Error"];smeth<-"Wald"
    }
  }
  if(!is.null(fit_i)){
    cf<-tryCatch(summary(fit_i)$coefficients,error=function(e)NULL);intercept<-unname(stats::coef(fit_i)[1L])
    if(isTRUE(ci)&&!is.null(cf)&&nrow(cf)&&is.finite(cf[1L,"Std. Error"])){
      ilo<-intercept-zcrit*cf[1L,"Std. Error"];ihi<-intercept+zcrit*cf[1L,"Std. Error"];imeth<-"Wald"
    }
  }
  brier<-mean((p-y)^2);blo<-bhi<-NA_real_;bmeth<-NA_character_
  if(isTRUE(ci)){
    .r4vn_set_seed_if(seed);n<-length(y);bs<-bi<-bb<-rep(NA_real_,boot)
    for(b in seq_len(boot)){
      ix<-sample(seq_len(n),n,TRUE);yb<-y[ix];pb<-p[ix];lpb<-stats::qlogis(pb)
      bb[b]<-mean((pb-yb)^2)
      fs<-tryCatch(stats::glm(yb~lpb,family=stats::binomial()),error=function(e)NULL)
      fi<-tryCatch(stats::glm(yb~offset(lpb),family=stats::binomial()),error=function(e)NULL)
      if(!is.null(fs))bs[b]<-unname(stats::coef(fs)["lpb"])
      if(!is.null(fi))bi[b]<-unname(stats::coef(fi)[1L])
    }
    aq<-c((1-level)/2,1-(1-level)/2)
    qfun<-function(v){v<-v[is.finite(v)];if(length(v)<30L)c(NA_real_,NA_real_)else stats::quantile(v,aq,na.rm=TRUE,names=FALSE)}
    qb<-qfun(bb);blo<-qb[1];bhi<-qb[2];bmeth<-"Bootstrap"
    if(!is.finite(slo)||!is.finite(shi)){qs<-qfun(bs);slo<-qs[1];shi<-qs[2];if(all(is.finite(qs)))smeth<-"Bootstrap"}
    if(!is.finite(ilo)||!is.finite(ihi)){qi<-qfun(bi);ilo<-qi[1];ihi<-qi[2];if(all(is.finite(qi)))imeth<-"Bootstrap"}
  }
  qs<-unique(stats::quantile(p,probs=seq(0,1,length.out=bins+1L),na.rm=TRUE));if(length(qs)<3L)grp<-factor(rep(1,length(p))) else grp<-cut(p,breaks=qs,include.lowest=TRUE,labels=FALSE)
  curve<-aggregate(cbind(predicted=p,observed=y)~grp,FUN=mean);curve$n<-as.numeric(table(grp)[as.character(curve$grp)])
  events <- tapply(y, grp, sum); curve$events <- as.numeric(events[as.character(curve$grp)])
  curve$observed_lower <- curve$observed_upper <- NA_real_
  curve$ci_method <- NA_character_
  if (isTRUE(ci)) {
    for (i in seq_len(nrow(curve))) {
      cc <- .r4vn_machine_wilson(curve$events[i], curve$n[i], level)
      curve$observed_lower[i] <- cc[1L]; curve$observed_upper[i] <- cc[2L]; curve$ci_method[i] <- "Wilson"
    }
  }
  statsdf<-data.frame(metric=c("Calibration intercept","Calibration slope","Brier score"),estimate=c(intercept,slope,brier),
                      lower=c(ilo,slo,blo),upper=c(ihi,shi,bhi),ci_method=c(imeth,smeth,bmeth),stringsAsFactors=FALSE)
  list(statistics=statsdf,curve=curve)
}

.r4vn_machine_decision_nb <- function(y,p,t) {
  n<-length(y);pred<-p>=t;tp<-sum(pred&y==1);fp<-sum(pred&y==0);tp/n-(fp/n)*(t/(1-t))
}

.r4vn_machine_decision <- function(y,p,thresholds=seq(.01,.99,.01),ci=TRUE,level=.95,boot=1000,seed=NA_integer_) {
  ok<-!is.na(y)&is.finite(p);y<-as.numeric(y[ok]);p<-p[ok];th<-sort(unique(thresholds[thresholds>0&thresholds<1]));prev<-mean(y==1)
  out<-data.frame(threshold=th,net_benefit=vapply(th,function(t).r4vn_machine_decision_nb(y,p,t),numeric(1)),
                  treat_all=prev-(1-prev)*th/(1-th),treat_none=0,lower=NA_real_,upper=NA_real_,
                  ci_method=if(isTRUE(ci))"Pointwise bootstrap" else NA_character_)
  if(isTRUE(ci)&&length(y)>5L){.r4vn_set_seed_if(seed);mat<-matrix(NA_real_,boot,length(th));for(b in seq_len(boot)){ix<-sample(seq_along(y),length(y),TRUE);mat[b,]<-vapply(th,function(t).r4vn_machine_decision_nb(y[ix],p[ix],t),numeric(1))};alpha<-(1-level)/2;qq<-apply(mat,2,stats::quantile,probs=c(alpha,1-alpha),na.rm=TRUE);out$lower<-qq[1,];out$upper<-qq[2,]}
  out
}

.r4vn_machine_importance <- function(fit,raw,outcome_name,metric,threshold,repeats=20,seed=NA_integer_,labels=NULL,ci=TRUE,level=.95) {
  basepr<-.r4vn_machine_predict_pipeline(fit,raw,outcome_name);yy<-raw[[outcome_name]][basepr$rows]
  base<-if(fit$task=="binary").r4vn_machine_binary_metric_value(yy,basepr$prob,metric,threshold) else if(fit$task=="regression").r4vn_machine_reg_metric_value(yy,basepr$value,metric) else .r4vn_machine_multi_metric_value(factor(yy,levels=colnames(basepr$prob)),basepr$prob,metric)
  vars<-fit$recipe$keep;res<-list();.r4vn_set_seed_if(seed);dir<-.r4vn_machine_metric_direction(metric)
  for(v in vars){vals<-numeric(repeats);for(r in seq_len(repeats)){d<-raw;d[[v]]<-sample(d[[v]]);pr<-tryCatch(.r4vn_machine_predict_pipeline(fit,d,outcome_name),error=function(e)NULL);if(is.null(pr)){vals[r]<-NA;next};yt<-d[[outcome_name]][pr$rows];pm<-if(fit$task=="binary").r4vn_machine_binary_metric_value(yt,pr$prob,metric,threshold) else if(fit$task=="regression").r4vn_machine_reg_metric_value(yt,pr$value,metric) else .r4vn_machine_multi_metric_value(factor(yt,levels=colnames(pr$prob)),pr$prob,metric);vals[r]<-if(dir=="max")base-pm else pm-base}
    a<-(1-level)/2;lo<-if(isTRUE(ci))stats::quantile(vals,a,na.rm=TRUE,names=FALSE)else NA_real_;hi<-if(isTRUE(ci))stats::quantile(vals,1-a,na.rm=TRUE,names=FALSE)else NA_real_
    res[[v]]<-data.frame(variable=v,label=if(!is.null(labels)&&v%in%names(labels))labels[[v]] else v,importance=mean(vals,na.rm=TRUE),lower=lo,upper=hi,metric=metric,ci_method=if(isTRUE(ci))"Empirical permutation interval" else NA_character_,stringsAsFactors=FALSE)
  }
  out<-.r4vn_machine_bind(res);if(nrow(out))out<-out[order(out$importance,decreasing=TRUE),,drop=FALSE];row.names(out)<-NULL;out
}

.r4vn_machine_shap <- function(fit,raw,outcome_name) {
  if(fit$method!="xgb"||!requireNamespace("xgboost",quietly=TRUE))return(NULL)
  ap<-.r4vn_machine_recipe_apply(fit$recipe,raw);x<-ap$x[,fit$selected,drop=FALSE];xx<-.r4vn_machine_scale_apply(x,fit$engine$scaler)
  tryCatch(predict(fit$engine$model,xgboost::xgb.DMatrix(xx),predcontrib=TRUE),error=function(e)NULL)
}

.r4vn_machine_pdp <- function(fit,raw,outcome_name,pdp,importance,labels=NULL) {
  vars<-if(isTRUE(pdp)){if(nrow(importance))head(importance$variable[vapply(raw[importance$variable],is.numeric,logical(1))],5L) else head(fit$recipe$keep[vapply(raw[fit$recipe$keep],is.numeric,logical(1))],5L)} else as.character(pdp)
  vars<-intersect(vars,fit$recipe$keep);out<-list()
  for(v in vars){if(!is.numeric(raw[[v]]))next;grid<-unique(as.numeric(stats::quantile(raw[[v]],probs=seq(.05,.95,length.out=20),na.rm=TRUE)));rows<-list();for(i in seq_along(grid)){d<-raw;d[[v]]<-grid[i];pr<-.r4vn_machine_predict_pipeline(fit,d,outcome_name);if(fit$task=="binary")rows[[i]]<-data.frame(variable=v,label=.r4vn_machine_get_label(labels,v),value=grid[i],class=NA,prediction=mean(pr$prob,na.rm=TRUE)) else if(fit$task=="regression")rows[[i]]<-data.frame(variable=v,label=.r4vn_machine_get_label(labels,v),value=grid[i],class=NA,prediction=mean(pr$value,na.rm=TRUE)) else {mm<-colMeans(pr$prob,na.rm=TRUE);rows[[i]]<-data.frame(variable=v,label=.r4vn_machine_get_label(labels,v),value=grid[i],class=names(mm),prediction=as.numeric(mm))}};out[[v]]<-.r4vn_machine_bind(rows)}
  .r4vn_machine_bind(out)
}

.r4vn_machine_learning_curve <- function(raw,outcome_name,predictor_names,task,outcome_info,method,params,
                                         preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,
                                         feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,metric,threshold,
                                         ci=TRUE,ci_level=.95,seed=NA_integer_) {
  fracs<-c(.3,.5,.7,.9,1);rows<-list();ii<-0L;nr<-if(isTRUE(ci))5L else 3L
  for(f in fracs){
    vals<-rep(NA_real_,nr)
    for(r in seq_len(nr)){
      .r4vn_set_seed_if(seed+r+round(f*100));nsub<-max(20L,floor(nrow(raw)*f));idx<-sample(seq_len(nrow(raw)),min(nsub,nrow(raw)));sub<-raw[idx,,drop=FALSE]
      sp<-.r4vn_machine_split(sub,outcome_name,task,.8,seed+r)
      ft<-tryCatch(.r4vn_machine_fit_pipeline(sp$train,outcome_name,predictor_names,task,outcome_info,method,params,preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,seed=seed+r),error=function(e)NULL)
      if(is.null(ft)||!nrow(sp$test))next
      pr<-.r4vn_machine_predict_pipeline(ft,sp$test,outcome_name);yy<-sp$test[[outcome_name]][pr$rows]
      vals[r]<-if(task=="binary").r4vn_machine_binary_metric_value(yy,pr$prob,metric,threshold) else if(task=="regression").r4vn_machine_reg_metric_value(yy,pr$value,metric) else .r4vn_machine_multi_metric_value(factor(yy,levels=colnames(pr$prob)),pr$prob,metric)
    }
    ii<-ii+1L;good<-vals[is.finite(vals)];est<-if(length(good))mean(good)else NA_real_;lo<-hi<-NA_real_;cm<-NA_character_
    if(isTRUE(ci)&&length(good)>=2L){se<-stats::sd(good)/sqrt(length(good));crit<-stats::qt(1-(1-ci_level)/2,df=length(good)-1L);lo<-est-crit*se;hi<-est+crit*se;cm<-"t interval across repeated holdouts"}
    rows[[ii]]<-data.frame(fraction=f,n=round(nrow(raw)*f),metric=metric,estimate=est,lower=lo,upper=hi,ci_method=cm,resamples=length(good),stringsAsFactors=FALSE)
  }
  .r4vn_machine_bind(rows)
}

# ============================================================================
# Reporting tables, prediction output, plots, HTML, and S3 methods
# ============================================================================

.r4vn_machine_bind <- function(x) {
  x<-x[vapply(x,function(z)!is.null(z)&&is.data.frame(z)&&nrow(z)>0,logical(1))]
  if(!length(x))return(data.frame())
  alln<-unique(unlist(lapply(x,names),use.names=FALSE))
  x<-lapply(x,function(z){miss<-setdiff(alln,names(z));for(m in miss)z[[m]]<-NA;z[,alln,drop=FALSE]})
  do.call(rbind,x)
}

.r4vn_machine_overview <- function(dat,outcome,predictors,task,outcome_info,train,internal,test,validation,metric) {
  n<-nrow(dat);missx<-sum(is.na(dat[predictors]));cells<-n*length(predictors)
  vals<-list(
    "Outcome"=outcome,
    "Task"=task,
    "Observations"=n,
    "Candidate predictors"=length(predictors),
    "Predictor missing cells"=paste0(missx," (",formatC(if(cells)100*missx/cells else 0,format="f",digits=1),"%)"),
    "Development training"=nrow(train),
    "Internal validation"=nrow(internal),
    "Untouched test"=nrow(test),
    "External validation"=if(is.null(validation))0 else nrow(validation),
    "Primary metric"=metric
  )
  if(task=="binary")vals<-append(vals,list("Event"=outcome_info$event,"Development event rate"=paste0(formatC(100*mean(train[[outcome]]==1),format="f",digits=1),"%")),after=2L)
  data.frame(Item=names(vals),Value=unlist(vals,use.names=FALSE),stringsAsFactors=FALSE)
}

.r4vn_machine_preprocessing_table <- function(fit) {
  a<-fit$recipe$actions
  if(!nrow(a))a<-data.frame(step="Preprocessing",variable="All predictors",action="No data-dependent filtering requested",stringsAsFactors=FALSE)
  a<-rbind(a,data.frame(step="Feature selection",variable="Encoded predictors",action=paste0("Method: ",if(length(fit$selection_detail))"selected" else "filter/none","; retained ",length(fit$selected)),stringsAsFactors=FALSE))
  sc<-fit$engine$scaler$method
  rbind(a,data.frame(step="Standardization",variable="Model matrix",action=if(sc=="none")"Not required for selected algorithm" else paste0(sc," scaling learned on training data"),stringsAsFactors=FALSE))
}

.r4vn_machine_selection_table <- function(fit,comparison,labels) {
  if (!is.null(fit$recipe$pca) && identical(fit$recipe$pca$method, "pca")) {
    selected <- data.frame(
      type = "Selected component", variable = fit$selected, label = fit$selected,
      value = "Retained after PCA", stringsAsFactors = FALSE
    )
    original <- data.frame(
      type = "Original predictor entering PCA", variable = fit$recipe$keep,
      label = vapply(fit$recipe$keep, function(v) .r4vn_machine_get_label(labels, v), character(1)),
      value = "Encoded before PCA", stringsAsFactors = FALSE
    )
  } else {
    map <- fit$recipe$raw_map[fit$selected]
    raw <- unique(na.omit(as.character(map)))
    if (!length(raw)) raw <- fit$recipe$keep
    selected <- data.frame(
      type = "Selected predictor", variable = raw,
      label = vapply(raw, function(v) .r4vn_machine_get_label(labels, v), character(1)),
      value = "Retained", stringsAsFactors = FALSE
    )
    original <- data.frame()
  }
  removed <- fit$recipe$removed
  rem <- if(length(removed)) data.frame(
    type="Preprocessing removal", variable=removed,
    label=vapply(removed,function(v).r4vn_machine_get_label(labels,v),character(1)),
    value="Removed", stringsAsFactors=FALSE
  ) else data.frame()
  cmp <- if(nrow(comparison)) data.frame(
    type="Strategy comparison", variable=comparison$strategy,label=comparison$strategy,
    value=paste0(comparison$metric,"=",formatC(comparison$estimate,format="f",digits=3)),
    stringsAsFactors=FALSE
  ) else data.frame()
  .r4vn_machine_bind(list(selected, original, rem, cmp))
}

.r4vn_machine_balance_table <- function(strategy,y,comparison,task) {
  if(task!="binary")return(data.frame(Item=c("Strategy","Reason"),Value=c("none","Class balancing is only applied to binary classification.")))
  tab<-table(y); n0<-if("0"%in%names(tab))as.numeric(tab[["0"]])else 0; n1<-if("1"%in%names(tab))as.numeric(tab[["1"]])else 0
  base<-data.frame(Item=c("Chosen strategy","Non-event n","Event n","Event proportion"),Value=c(strategy,n0,n1,formatC(mean(y==1),format="f",digits=3)),stringsAsFactors=FALSE)
  if(nrow(comparison)){
    cmp<-data.frame(Item=paste0("Compared: ",comparison$strategy),Value=paste0(comparison$metric," = ",formatC(comparison$estimate,format="f",digits=3)),stringsAsFactors=FALSE);base<-rbind(base,cmp)
  }
  base
}

.r4vn_machine_pretty_metric <- function(x) {
  map<-c(auc="ROC-AUC",pr_auc="PR-AUC",accuracy="Accuracy",balanced_accuracy="Balanced accuracy",sensitivity="Sensitivity",specificity="Specificity",ppv="PPV",npv="NPV",f1="F1",mcc="MCC",kappa="Kappa",brier="Brier score",logloss="Log loss",prevalence="Prevalence",rmse="RMSE",mae="MAE",r2="R-squared",mape="MAPE",macro_f1="Macro F1",weighted_f1="Weighted F1",macro_auc="Macro one-vs-rest AUC",macro_pr_auc="Macro one-vs-rest PR-AUC")
  z <- unname(map[x]); if(length(z)==1L && !is.na(z)) z else x
}

.r4vn_machine_final_table <- function(performance,best,digit=3) {
  z<-performance[performance$method==best,,drop=FALSE]
  if(!nrow(z))return(data.frame())
  fmt<-function(e,l,u)if(is.finite(l)&&is.finite(u))paste0(formatC(e,format="f",digits=digit)," (",formatC(l,format="f",digits=digit),"-",formatC(u,format="f",digits=digit),")") else formatC(e,format="f",digits=digit)
  data.frame(Measure=vapply(z$metric,.r4vn_machine_pretty_metric,character(1)),`Estimate (CI)`=mapply(fmt,z$estimate,z$lower,z$upper),`CI method`=z$ci_method,check.names=FALSE,stringsAsFactors=FALSE)
}

.r4vn_machine_tables <- function(out) {
  lst<-list(
    "Data and modelling strategy"=out$overview,
    "Algorithms and package requirements"=out$engines,
    "Preprocessing"=out$preprocessing,
    "Class balance"=out$balance,
    "Feature selection"=out$selection,
    "Development cross-validation"=out$cv_performance,
    "Model comparison"=out$comparison,
    "Final model performance"=.r4vn_machine_final_table(out$performance,out$best,out$settings$digit)
  )
  if(nrow(out$tuning))lst[["Hyperparameter tuning"]]<-out$tuning
  if(!is.null(out$simplify) && is.list(out$simplify) && is.data.frame(out$simplify$table) && nrow(out$simplify$table)) {
    st <- out$simplify$table
    if (!is.null(out$simplify$note) && length(out$simplify$note)) st$Note <- c(out$simplify$note, rep("", max(0L, nrow(st)-1L)))
    lst[["Parsimony screen"]] <- st
  }
  if(nrow(out$model_difference))lst[["Paired model differences"]]<-out$model_difference
  if(nrow(out$overfitting))lst[["Development versus evaluation"]]<-out$overfitting
  if(nrow(out$coefficients))lst[["Interpretable model coefficients"]]<-out$coefficients
  if(!is.null(out$threshold)&&length(out$threshold))lst[["Classification threshold"]]<-data.frame(Method=out$threshold$method,Threshold=out$threshold$threshold,Lower=out$threshold$lower,Upper=out$threshold$upper,`CI method`=out$threshold$ci_method,check.names=FALSE)
  if(nrow(out$confusion))lst[["Confusion matrix"]]<-out$confusion
  if(nrow(out$class_performance))lst[["Class-specific performance"]]<-out$class_performance
  if(!is.null(out$calibration))lst[["Calibration"]]<-out$calibration$statistics
  if(nrow(out$importance))lst[["Predictor importance"]]<-out$importance
  if(nrow(out$learning))lst[["Learning curve"]]<-out$learning
  if(!is.null(out$plots$shap) && is.data.frame(out$plots$shap) && nrow(out$plots$shap)) lst[["SHAP summary"]] <- out$plots$shap
  if(nrow(out$external_performance))lst[["External validation"]]<-.r4vn_machine_final_table(out$external_performance,out$best,out$settings$digit)
  if(nrow(out$external_confusion))lst[["External confusion matrix"]]<-out$external_confusion
  if(nrow(out$external_class_performance))lst[["External class-specific performance"]]<-out$external_class_performance
  if(!is.null(out$external_calibration))lst[["External calibration"]]<-out$external_calibration$statistics
  if(!is.null(out$predictions)&&is.data.frame(out$predictions))lst[["Predictions"]]<-out$predictions
  lst[vapply(lst,function(z)is.data.frame(z)&&nrow(z)>0,logical(1))]
}

.r4vn_machine_prediction_frame <- function(fit,newdata,threshold=.5,id_name=NULL,outcome_info=NULL) {
  pr<-.r4vn_machine_predict_pipeline(fit,newdata)
  idv<-if(!is.null(id_name)&&id_name%in%names(newdata))newdata[[id_name]][pr$rows] else pr$rows
  if(fit$task=="binary"){
    cl<-ifelse(pr$prob>=threshold,outcome_info$event,outcome_info$negative)
    return(data.frame(ID=idv,Probability=pr$prob,Predicted=cl,stringsAsFactors=FALSE))
  }
  if(fit$task=="regression")return(data.frame(ID=idv,Prediction=pr$value,stringsAsFactors=FALSE))
  pp<-as.data.frame(pr$prob,check.names=FALSE);names(pp)<-paste0("Probability: ",names(pp));data.frame(ID=idv,Predicted=pr$class,pp,check.names=FALSE,stringsAsFactors=FALSE)
}

.r4vn_machine_multiclass_confusion <- function(truth, predicted, levels) {
  truth <- factor(truth, levels = levels)
  predicted <- factor(predicted, levels = levels)
  tab <- table(truth, predicted, useNA = "no")
  out <- as.data.frame.matrix(tab, stringsAsFactors = FALSE)
  names(out) <- paste0("Predicted: ", names(out))
  data.frame(Actual = rownames(out), out, row.names = NULL, check.names = FALSE, stringsAsFactors = FALSE)
}

.r4vn_machine_confusion_matrix <- function(tab) {
  if (is.null(tab) || !is.data.frame(tab) || !nrow(tab) || ncol(tab) < 3L) return(NULL)
  z <- as.matrix(tab[, -1L, drop = FALSE])
  storage.mode(z) <- "numeric"
  rownames(z) <- as.character(tab[[1L]])
  colnames(z) <- sub("^Predicted: ?", "", sub("^Predicted ", "", colnames(z)))
  z
}

.r4vn_machine_roc_curve <- function(y,p) {
  cuts<-sort(unique(c(Inf,p,-Inf)),decreasing=TRUE);if(length(cuts)>1000L)cuts<-unique(stats::quantile(cuts[is.finite(cuts)],probs=seq(0,1,length.out=998),na.rm=TRUE));cuts<-c(Inf,sort(cuts,decreasing=TRUE),-Inf)
  rows<-lapply(cuts,function(t){m<-.r4vn_machine_binary_metrics(y,p,t);data.frame(threshold=t,fpr=1-m["specificity"],tpr=m["sensitivity"])})
  z<-do.call(rbind,rows);z[order(z$fpr,z$tpr),,drop=FALSE]
}

.r4vn_machine_pr_curve <- function(y,p) {
  o<-order(p,decreasing=TRUE);yy<-y[o];tp<-cumsum(yy==1);fp<-cumsum(yy==0);data.frame(recall=tp/max(1,sum(y==1)),precision=tp/pmax(tp+fp,1),threshold=p[o])
}

.r4vn_machine_plot_choices <- function() {
  c("roc", "pr", "calibration", "threshold", "confusion", "importance",
    "decision", "learning", "observed", "residual", "pdp", "shap")
}

.r4vn_machine_plot_title_map <- function() {
  c(
    roc = "ROC curve",
    pr = "Precision-recall curve",
    calibration = "Calibration plot",
    threshold = "Classification threshold",
    confusion = "Confusion matrix",
    importance = "Predictor importance",
    decision = "Decision curve analysis",
    learning = "Learning curve",
    observed = "Observed versus predicted",
    residual = "Residual plot",
    pdp = "Partial dependence",
    shap = "SHAP summary"
  )
}

.r4vn_machine_validate_plot_options <- function(plot, plot_display, plot_args, strict) {
  choices <- .r4vn_machine_plot_choices()
  validate_spec <- function(z, name, allow_auto = TRUE) {
    if (is.null(z) || identical(z, FALSE) || identical(z, TRUE)) return(invisible(TRUE))
    if (!is.character(z)) stop("`", name, "` must be TRUE, FALSE, or plot name(s).", call. = FALSE)
    q <- unique(tolower(z[!is.na(z) & nzchar(z)]))
    allowed <- c(choices, "all", "none", if (allow_auto) "auto")
    bad <- setdiff(q, allowed)
    if (length(bad)) stop("Unsupported `", name, "`: ", paste(bad, collapse = ", "), ".", call. = FALSE)
    invisible(TRUE)
  }
  validate_spec(plot, "plot")
  validate_spec(plot_display, "plot_display")
  if (!is.list(plot_args) || (length(plot_args) && (is.null(names(plot_args)) || any(!nzchar(names(plot_args)))))) {
    stop("`plot_args` must be a named list.", call. = FALSE)
  }
  nested <- intersect(names(plot_args), c("all", choices))
  if (length(nested) && any(!vapply(plot_args[nested], is.list, logical(1)))) {
    stop("Nested `plot_args` entries such as `all`, `roc`, or `calibration` must be lists.", call. = FALSE)
  }
  if (!is.logical(strict) || length(strict) != 1L || is.na(strict)) stop("`strict` must be TRUE or FALSE.", call. = FALSE)
  invisible(TRUE)
}

.r4vn_machine_plot_options <- function(plot_args, type) {
  if (!length(plot_args)) return(list())
  choices <- c("all", .r4vn_machine_plot_choices())
  nested <- intersect(names(plot_args), choices)
  common <- plot_args[setdiff(names(plot_args), nested)]
  if ("all" %in% names(plot_args)) common <- utils::modifyList(common, plot_args$all)
  if (type %in% names(plot_args)) common <- utils::modifyList(common, plot_args[[type]])
  common
}

.r4vn_machine_plot_available <- function(z) {
  if (is.null(z)) return(FALSE)
  if (is.data.frame(z) || is.matrix(z)) return(nrow(z) > 0L && ncol(z) > 0L)
  if (is.list(z)) return(length(z) > 0L && any(vapply(z, function(a) {
    if (is.data.frame(a) || is.matrix(a)) nrow(a) > 0L else length(a) > 0L
  }, logical(1))))
  length(z) > 0L
}

.r4vn_machine_plot_data <- function(out,truth,predictions) {
  z<-list()
  if(out$settings$task=="binary"){
    z$roc<-lapply(predictions,function(pr).r4vn_machine_roc_curve(as.numeric(truth[pr$rows%||%seq_along(truth)]),pr$prob))
    z$pr<-lapply(predictions,function(pr).r4vn_machine_pr_curve(as.numeric(truth[pr$rows%||%seq_along(truth)]),pr$prob))
    z$calibration<-out$calibration$curve%||%data.frame()
    z$threshold<-out$threshold$curve%||%data.frame()
    z$decision<-out$decision
    z$confusion<-.r4vn_machine_confusion_matrix(out$confusion)
  } else if(out$settings$task=="regression"){
    pr<-predictions[[out$best]]
    z$observed<-data.frame(observed=as.numeric(truth[pr$rows%||%seq_along(truth)]),predicted=pr$value)
    z$residual<-transform(z$observed,residual=observed-predicted)
  } else if(out$settings$task=="multiclass") {
    z$confusion<-.r4vn_machine_confusion_matrix(out$confusion)
  }
  z$importance<-out$importance
  z$learning<-out$learning
  z$pdp<-out$pdp
  if (!is.null(out$shap) && (is.matrix(out$shap) || is.data.frame(out$shap))) {
    sm <- as.matrix(out$shap)
    if (ncol(sm)) {
      keep <- !grepl("^(BIAS|Bias|bias)$", colnames(sm) %||% rep("", ncol(sm)))
      if (any(keep)) {
        sm <- sm[, keep, drop = FALSE]
        z$shap <- data.frame(
          feature = colnames(sm) %||% paste0("Feature ", seq_len(ncol(sm))),
          mean_abs_shap = colMeans(abs(sm), na.rm = TRUE),
          stringsAsFactors = FALSE, check.names = FALSE
        )
        z$shap <- z$shap[order(z$shap$mean_abs_shap, decreasing = TRUE), , drop = FALSE]
      }
    }
  }
  z
}

.r4vn_machine_available_plot_types <- function(out) {
  choices <- .r4vn_machine_plot_choices()
  intersect(choices, names(out$plots)[vapply(out$plots, .r4vn_machine_plot_available, logical(1))])
}

.r4vn_machine_plot_types <- function(out,plot) {
  avail <- .r4vn_machine_available_plot_types(out)
  if(identical(plot,FALSE)||is.null(plot))return(character())
  default <- if(out$settings$task=="binary") {
    c("roc","pr","calibration","threshold","confusion","importance","decision","learning","pdp","shap")
  } else if(out$settings$task=="regression") {
    c("observed","residual","importance","learning","pdp","shap")
  } else c("confusion","importance","learning","pdp","shap")
  if(isTRUE(plot))return(intersect(default,avail))
  q <- unique(tolower(as.character(plot)))
  if("none" %in% q)return(character())
  if("all" %in% q)return(avail)
  if("auto" %in% q)return(intersect(default,avail))
  intersect(q,avail)
}

.r4vn_machine_display_types <- function(out,plot_display) {
  avail <- .r4vn_machine_available_plot_types(out)
  if(is.null(plot_display)||identical(plot_display,FALSE))return(character())
  if(isTRUE(plot_display)) plot_display <- "auto"
  q <- unique(tolower(as.character(plot_display)))
  if("none" %in% q)return(character())
  if("all" %in% q)return(avail)
  if("auto" %in% q) {
    q <- if(out$settings$task=="binary") c("roc","calibration","importance") else
      if(out$settings$task=="regression") c("observed","residual","importance") else c("confusion","importance")
  }
  intersect(q,avail)
}

.r4vn_machine_metric_legend <- function(x, methods, metric, label) {
  vapply(methods, function(md) {
    r <- x$performance[x$performance$method == md & x$performance$metric == metric, , drop = FALSE]
    if (!nrow(r) || !is.finite(r$estimate[1L])) md else paste0(md, " (", label, " ", formatC(r$estimate[1L], format="f", digits=x$settings$digit), ")")
  }, character(1))
}

.r4vn_machine_plot_call <- function(fun, fixed, dots = list(), protected = names(fixed)) {
  protected <- unique(c(protected, names(fixed)))
  if (length(dots)) {
    dn <- names(dots)
    keep <- is.null(dn) || !(dn %in% protected)
    if (!is.null(dn)) dots <- dots[keep]
  }
  do.call(fun, c(fixed, dots))
}

#' Plot a tabmachine result
#' @param x A `r4vn_machine` object.
#' @param type Plot type: `roc`, `pr`, `calibration`, `threshold`, `confusion`,
#'   `importance`, `decision`, `learning`, `observed`, `residual`, `pdp`, or
#'   `shap`.
#' @param title Optional figure title. A publication-ready default is supplied.
#' @param font_family Base-graphics font family. Default `"sans"`, which is also
#'   the safest choice for HTML/SVG Viewer rendering.
#' @param ... Additional base-graphics arguments where applicable.
#' @return The input `r4vn_machine` object, invisibly. The requested figure is
#'   drawn on the current graphics device as a side effect; the fitted machine-
#'   learning result itself is not modified.
#' @export
plot.r4vn_machine <- function(x,type=NULL,title=NULL,font_family="sans",...) {
  choices<-.r4vn_machine_plot_choices()
  if(is.null(type)) type<-if(x$settings$task=="binary")"roc" else if(x$settings$task=="regression")"observed" else "confusion"
  type<-match.arg(type,choices);pd<-x$plots[[type]]
  if(!.r4vn_machine_plot_available(pd))stop("Plot data are not available for `",type,"`.",call.=FALSE)
  dots <- list(...)
  line_width <- if (!is.null(dots$lwd) && is.numeric(dots$lwd) && length(dots$lwd)) as.numeric(dots$lwd[1L]) else 2
  dots$lwd <- NULL
  if (is.null(title) && !is.null(dots$main)) { title <- dots$main; dots$main <- NULL }
  if (is.null(title)) title <- unname(.r4vn_machine_plot_title_map()[type])
  oldpar <- graphics::par(no.readonly = TRUE)
  on.exit(graphics::par(oldpar), add = TRUE)
  try(graphics::par(family = font_family), silent = TRUE)

  if(type=="roc"){
    mods<-names(pd); .r4vn_machine_plot_call(graphics::plot,
      list(x=c(0,1),y=c(0,1),type="n",xlab="1 - Specificity",ylab="Sensitivity",xlim=c(0,1),ylim=c(0,1),main=title),dots,
      protected=c("x","y","type","xlab","ylab","xlim","ylim","main"))
    graphics::abline(0,1,lty=2)
    lty<-seq_along(mods);for(i in seq_along(mods))graphics::lines(pd[[i]]$fpr,pd[[i]]$tpr,lty=lty[i],lwd=line_width)
    graphics::legend("bottomright",legend=.r4vn_machine_metric_legend(x,mods,"auc","AUC"),lty=lty,lwd=line_width,bty="n")
    return(invisible(x))
  }
  if(type=="pr"){
    mods<-names(pd); .r4vn_machine_plot_call(graphics::plot,
      list(x=c(0,1),y=c(0,1),type="n",xlab="Recall (Sensitivity)",ylab="Precision (PPV)",xlim=c(0,1),ylim=c(0,1),main=title),dots,
      protected=c("x","y","type","xlab","ylab","xlim","ylim","main"))
    lty<-seq_along(mods);for(i in seq_along(mods))graphics::lines(pd[[i]]$recall,pd[[i]]$precision,lty=lty[i],lwd=line_width)
    graphics::legend("bottomleft",legend=.r4vn_machine_metric_legend(x,mods,"pr_auc","PR-AUC"),lty=lty,lwd=line_width,bty="n")
    return(invisible(x))
  }
  if(type=="calibration"){
    .r4vn_machine_plot_call(graphics::plot,
      list(x=pd$predicted,y=pd$observed,xlim=c(0,1),ylim=c(0,1),xlab="Mean predicted probability",ylab="Observed event proportion",pch=19,main=title),dots,
      protected=c("x","y","xlim","ylim","xlab","ylab","main"))
    graphics::abline(0,1,lty=2)
    if(all(c("observed_lower","observed_upper")%in%names(pd))) graphics::segments(pd$predicted,pd$observed_lower,pd$predicted,pd$observed_upper)
    graphics::lines(pd$predicted,pd$observed,lwd=line_width);return(invisible(x))
  }
  if(type=="threshold"){
    .r4vn_machine_plot_call(graphics::plot,
      list(x=pd$threshold,y=pd$sensitivity,type="l",lwd=line_width,xlim=c(0,1),ylim=c(0,1),xlab="Probability threshold",ylab="Performance",main=title),dots,
      protected=c("x","y","type","xlim","ylim","xlab","ylab","main"))
    graphics::lines(pd$threshold,pd$specificity,lty=2,lwd=line_width)
    if(!is.null(x$threshold)&&is.finite(x$threshold$threshold))graphics::abline(v=x$threshold$threshold,lty=3)
    graphics::legend("bottomleft",c("Sensitivity","Specificity","Selected threshold"),lty=c(1,2,3),lwd=c(2,2,1),bty="n")
    return(invisible(x))
  }
  if(type=="confusion"){
    m<-pd; nr<-nrow(m);nc<-ncol(m); mx<-max(m,na.rm=TRUE); den<-if(is.finite(mx)&&mx>0)mx else 1
    graphics::plot(c(.5,nc+.5),c(.5,nr+.5),type="n",axes=FALSE,xlab="Predicted",ylab="Actual",main=title,xaxs="i",yaxs="i")
    for(i in seq_len(nr))for(j in seq_len(nc)){
      shade <- 1 - .35 * (m[i,j]/den)
      graphics::rect(j-.5,nr-i+.5,j+.5,nr-i+1.5,col=grDevices::gray(shade),border="white")
      graphics::text(j,nr-i+1,labels=format(m[i,j],trim=TRUE))
    }
    graphics::axis(1,at=seq_len(nc),labels=colnames(m),las=2)
    graphics::axis(2,at=seq_len(nr),labels=rev(rownames(m)),las=2)
    graphics::box();return(invisible(x))
  }
  if(type=="importance"){
    d<-head(pd[order(pd$importance,decreasing=TRUE),,drop=FALSE],20L)
    .r4vn_machine_plot_call(graphics::barplot,
      list(height=rev(d$importance),names.arg=rev(d$label),horiz=TRUE,las=1,xlab=paste("Permutation importance (",unique(d$metric)[1],")",sep=""),main=title),dots,
      protected=c("height","names.arg","horiz","las","xlab","main"));return(invisible(x))
  }
  if(type=="decision"){
    .r4vn_machine_plot_call(graphics::plot,
      list(x=pd$threshold,y=pd$net_benefit,type="l",lwd=line_width,xlab="Threshold probability",ylab="Net benefit",main=title),dots,
      protected=c("x","y","type","xlab","ylab","main"))
    if(all(c("lower","upper")%in%names(pd))&&any(is.finite(pd$lower))&&any(is.finite(pd$upper))){graphics::lines(pd$threshold,pd$lower,lty=3);graphics::lines(pd$threshold,pd$upper,lty=3)}
    graphics::lines(pd$threshold,pd$treat_all,lty=2);graphics::abline(h=0,lty=3);graphics::legend("topright",c("Model","Treat all","Treat none"),lty=c(1,2,3),bty="n");return(invisible(x))
  }
  if(type=="learning"){
    .r4vn_machine_plot_call(graphics::plot,
      list(x=pd$n,y=pd$estimate,type="b",pch=19,xlab="Training observations",ylab=.r4vn_machine_pretty_metric(unique(pd$metric)[1]),main=title),dots,
      protected=c("x","y","type","xlab","ylab","main"))
    if(all(is.finite(pd$lower))&&all(is.finite(pd$upper)))graphics::segments(pd$n,pd$lower,pd$n,pd$upper);return(invisible(x))
  }
  if(type=="observed"){
    .r4vn_machine_plot_call(graphics::plot,
      list(x=pd$observed,y=pd$predicted,xlab="Observed",ylab="Predicted",pch=19,main=title),dots,
      protected=c("x","y","xlab","ylab","main"));graphics::abline(0,1,lty=2);return(invisible(x))
  }
  if(type=="residual"){
    .r4vn_machine_plot_call(graphics::plot,
      list(x=pd$predicted,y=pd$residual,xlab="Predicted",ylab="Residual",pch=19,main=title),dots,
      protected=c("x","y","xlab","ylab","main"));graphics::abline(h=0,lty=2);return(invisible(x))
  }
  if(type=="pdp"){
    panels <- pd[vapply(pd, function(d) is.data.frame(d) && nrow(d) && all(c("value","prediction") %in% names(d)), logical(1))]
    if (!length(panels)) stop("Partial-dependence data are unavailable.", call. = FALSE)
    np <- length(panels); nc <- if (np == 1L) 1L else 2L; nr <- ceiling(np / nc)
    graphics::par(mfrow = c(nr, nc), mar = c(4.2, 4.2, 3.1, 1.1))
    for (nm in names(panels)) {
      d <- panels[[nm]]
      lab <- if ("label" %in% names(d) && any(nzchar(as.character(d$label)))) as.character(d$label[which(nzchar(as.character(d$label)))[1L]]) else nm
      cls <- if ("class" %in% names(d)) unique(as.character(d$class[!is.na(d$class)])) else character()
      panel_title <- if (np == 1L) paste0(title, ": ", lab) else lab
      if (!length(cls)) {
        graphics::plot(d$value, d$prediction, type="l", lwd=line_width, xlab=lab, ylab="Partial dependence", main=panel_title)
      } else {
        yr <- range(d$prediction, finite=TRUE); xr <- range(d$value, finite=TRUE)
        graphics::plot(xr, yr, type="n", xlab=lab, ylab="Partial dependence", main=panel_title)
        lt <- seq_along(cls)
        for (i in seq_along(cls)) {
          dd <- d[as.character(d$class) == cls[i], , drop=FALSE]
          graphics::lines(dd$value, dd$prediction, lty=lt[i], lwd=line_width)
        }
        graphics::legend("topright", legend=cls, lty=lt, lwd=line_width, bty="n")
      }
    }
    return(invisible(x))
  }
  if(type=="shap"){
    d <- head(pd[order(pd$mean_abs_shap, decreasing=TRUE), , drop=FALSE], 20L)
    .r4vn_machine_plot_call(graphics::barplot,
      list(height=rev(d$mean_abs_shap),names.arg=rev(d$feature),horiz=TRUE,las=1,xlab="Mean absolute SHAP value",main=title),dots,
      protected=c("height","names.arg","horiz","las","xlab","main"))
    return(invisible(x))
  }
  invisible(x)
}

.r4vn_machine_draw_display <- function(out, types, plot_args = list(), strict = FALSE) {
  for (tp in types) {
    opts <- .r4vn_machine_plot_options(plot_args, tp)
    ok <- tryCatch({ do.call(graphics::plot, c(list(x=out,type=tp),opts)); TRUE }, error=function(e) {
      if (isTRUE(strict)) stop(e)
      warning("Machine-learning Plot-pane figure `",tp,"` was unavailable: ",conditionMessage(e),call.=FALSE)
      FALSE
    })
  }
  invisible(out)
}

.r4vn_machine_html_escape <- function(x) {
  x<-as.character(x);x<-gsub("&","&amp;",x,fixed=TRUE);x<-gsub("<","&lt;",x,fixed=TRUE);x<-gsub(">","&gt;",x,fixed=TRUE);x<-gsub('"',"&quot;",x,fixed=TRUE);x
}

.r4vn_machine_html_table <- function(d,digit=3) {
  if(is.null(d)||!is.data.frame(d)||!nrow(d))return("")
  z<-d
  # Wide-and-shallow result tables are hard to read in the Viewer. When the
  # first column identifies a few models/groups, rotate only the HTML display;
  # the returned R data frame remains unchanged for reproducibility/export.
  if(ncol(z) > 8L && nrow(z) <= 6L && (is.character(z[[1L]]) || is.factor(z[[1L]]))) {
    ids <- make.unique(as.character(z[[1L]]))
    body <- z[, -1L, drop = FALSE]
    zz <- data.frame(Measure = names(body), stringsAsFactors = FALSE, check.names = FALSE)
    for(i in seq_len(nrow(z))) zz[[ids[i]]] <- as.character(unlist(body[i, , drop = FALSE], use.names = FALSE))
    z <- zz
  }
  for(j in seq_along(z))if(is.numeric(z[[j]]))z[[j]]<-ifelse(is.na(z[[j]]),"",formatC(z[[j]],format="fg",digits=digit,flag="#"))
  head<-paste0("<tr>",paste0("<th>",.r4vn_machine_html_escape(names(z)),"</th>",collapse=""),"</tr>")
  body<-vapply(seq_len(nrow(z)),function(i){vals<-unlist(z[i,,drop=FALSE],use.names=FALSE);paste0("<tr>",paste0("<td>",.r4vn_machine_html_escape(vals),"</td>",collapse=""),"</tr>")},character(1))
  paste0("<div class='table-wrap'><table><thead>",head,"</thead><tbody>",paste(body,collapse=""),"</tbody></table></div>")
}

.r4vn_machine_svg <- function(out,type,plot_args=list()) {
  f<-tempfile(fileext=".svg");ok<-FALSE
  opts <- .r4vn_machine_plot_options(plot_args, type)
  tryCatch({
    grDevices::svg(f,width=7.2,height=5.2,onefile=TRUE,family="sans")
    do.call(graphics::plot,c(list(x=out,type=type),opts))
    grDevices::dev.off();ok<-TRUE
  },error=function(e){try(grDevices::dev.off(),silent=TRUE)})
  if(!ok||!file.exists(f))return("")
  ln<-readLines(f,warn=FALSE,encoding="UTF-8");unlink(f)
  i<-grep("<svg",ln,fixed=TRUE)[1L];if(is.na(i))return("");paste(ln[i:length(ln)],collapse="\n")
}

.r4vn_machine_html <- function(out,plot=TRUE,plot_args=list(),title=NULL,digit=3) {
  ttl<-title%||%"Machine learning analysis"
  sections<-character()
  add<-function(name,content){if(nzchar(content))sections<<-c(sections,paste0("<section><h2>",.r4vn_machine_html_escape(name),"</h2>",content,"</section>"))}
  if(length(out$tables)) {
    for (nm in names(out$tables)) {
      heading <- if (identical(nm,"Final model performance")) paste0("Final model: ",out$best) else nm
      add(heading,.r4vn_machine_html_table(out$tables[[nm]],digit))
    }
  }
  pts<-.r4vn_machine_plot_types(out,plot)
  if(length(pts))for(tp in pts){
    sv<-.r4vn_machine_svg(out,tp,plot_args=plot_args)
    if(nzchar(sv)) {
      heading <- out$plot_titles[[tp]] %||% tools::toTitleCase(tp)
      add(paste0("Figure: ",heading),paste0("<div class='plot'>",sv,"</div>"))
    }
  }
  if(length(out$notes))add("Methodological notes",paste0("<ul>",paste0("<li>",.r4vn_machine_html_escape(out$notes),"</li>",collapse=""),"</ul>"))
  paste0("<!doctype html><html><head><meta charset='utf-8'><style>",
         "body{font-family:Arial,Helvetica,sans-serif;margin:24px;color:#222;line-height:1.45}h1{font-size:25px;margin-bottom:4px}h2{font-size:18px;margin-top:28px;border-bottom:1px solid #ddd;padding-bottom:6px}.subtitle{color:#666;margin-bottom:18px}.table-wrap{overflow-x:auto}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid #ddd;padding:7px 9px;text-align:left;vertical-align:top}th{background:#f5f5f5;font-weight:600}tr:nth-child(even)td{background:#fafafa}.plot{max-width:900px;margin:10px auto}.plot svg{display:block;max-width:100%;height:auto;margin:auto}ul{padding-left:22px}",
         "</style></head><body><h1>",.r4vn_machine_html_escape(ttl),"</h1><div class='subtitle'>R4VN comprehensive machine-learning report</div>",paste(sections,collapse="\n"),"</body></html>")
}

.r4vn_machine_show_html <- function(html) {
  if(!interactive())return(invisible(FALSE));f<-tempfile(fileext=".html");writeLines(html,f,useBytes=TRUE);viewer<-getOption("viewer");if(is.function(viewer))viewer(f) else utils::browseURL(f);invisible(TRUE)
}

#' Print a tabmachine result
#' @param x A `r4vn_machine` object.
#' @param ... Unused.
#' @return The input `r4vn_machine` object, invisibly, after printing a compact
#'   summary of the analysis and the final-model performance table.
#' @export
print.r4vn_machine <- function(x,...) {
  cat("Machine learning analysis\n")
  cat("Task: ",x$settings$task," | Best model: ",x$best," | Primary metric: ",x$settings$metric,"\n",sep="")
  if(x$settings$task=="binary")cat("Event: ",x$settings$event," | Threshold: ",formatC(x$settings$threshold_value,format="f",digits=3),"\n",sep="")
  print(.r4vn_machine_final_table(x$performance,x$best,x$settings$digit),row.names=FALSE)
  invisible(x)
}

#' Predict from a tabmachine model
#' @param object A fitted `r4vn_machine` object.
#' @param newdata New predictor data.
#' @param type `"response"`, `"prob"`, or `"class"`.
#' @param threshold Optional binary threshold overriding the training-derived
#'   final threshold.
#' @param ... Unused.
#' @return Predictions whose structure depends on the task and `type`. For a
#'   regression task, a numeric vector of predicted outcomes is returned. For a
#'   binary task, `type = "response"` or `"prob"` returns a numeric vector of
#'   probabilities for the modeled event, while `type = "class"` returns a
#'   factor of predicted classes. For a multiclass task, `type = "response"`
#'   or `"prob"` returns a numeric matrix of class probabilities and
#'   `type = "class"` returns a factor of predicted classes.
#' @method predict r4vn_machine
#' @export
predict.r4vn_machine <- function(object,newdata,type=c("response","prob","class"),threshold=NULL,...) {
  type<-match.arg(type);pr<-.r4vn_machine_predict_pipeline(object$final,newdata);task<-object$settings$task
  if(task=="regression")return(pr$value)
  if(task=="binary"){
    th<-threshold%||%object$settings$threshold_value
    if(type%in%c("response","prob"))return(pr$prob)
    return(factor(ifelse(pr$prob>=th,object$settings$event,object$final$outcome_info$negative),levels=object$final$outcome_info$levels))
  }
  if(type=="prob")return(pr$prob);if(type=="class")return(factor(pr$class,levels=object$final$outcome_info$levels));pr$prob
}

#' Convert a tabmachine result to a data frame
#' @param x A `r4vn_machine` object.
#' @param ... Unused.
#' @return A data frame containing the final evaluation performance stored in
#'   `x$performance`. Each row represents a model/metric combination and reports
#'   the evaluation data set, method, metric, estimate, confidence limits when
#'   available, and the confidence-interval method.
#' @export
as.data.frame.r4vn_machine <- function(x,...) x$performance

# Nested-CV helper used only when `nested = TRUE`.
.r4vn_machine_nested_cv <- function(raw,outcome_name,predictor_names,task,outcome_info,method,grid,
                                    preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,
                                    feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,folds,metric,seed) {
  outer<-.r4vn_machine_make_folds(raw[[outcome_name]],folds,1L,task,seed);rows<-list();ii<-0L
  for(s in outer){
    otr<-raw[s$train,,drop=FALSE];ova<-raw[s$val,,drop=FALSE];scores<-rep(NA_real_,length(grid))
    for(g in seq_along(grid)){
      inn<-tryCatch(.r4vn_machine_cv_one(otr,outcome_name,predictor_names,task,outcome_info,method,grid[[g]],preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,min(5L,max(2L,folds-1L)),1L,seed+s$fold*1000L+g*20L),error=function(e)NULL)
      if(!is.null(inn))scores[g]<-.r4vn_machine_metric_from_cv(inn,task,metric)
    }
    good<-which(is.finite(scores));if(!length(good))next;gsel<-if(.r4vn_machine_metric_direction(metric)=="max")good[which.max(scores[good])] else good[which.min(scores[good])]
    fit<-tryCatch(.r4vn_machine_fit_pipeline(otr,outcome_name,predictor_names,task,outcome_info,method,grid[[gsel]],preprocess,missing,missing_max,encode,standardize,transform,outlier,corr,feature,degree,reduce,variance,select,nfeatures,balance,balance_target,neighbors,seed=seed+s$fold*4000L),error=function(e)NULL);if(is.null(fit))next
    pr<-tryCatch(.r4vn_machine_predict_pipeline(fit,ova,outcome_name),error=function(e)NULL);if(is.null(pr))next;truth<-ova[[outcome_name]][pr$rows];ii<-ii+1L
    if(task=="binary")rows[[ii]]<-data.frame(truth=as.numeric(truth),prob=pr$prob,row_id=s$val[pr$rows],rep_id=1L,fold=s$fold)
    else if(task=="regression")rows[[ii]]<-data.frame(truth=as.numeric(truth),value=pr$value,row_id=s$val[pr$rows],rep_id=1L,fold=s$fold)
    else {pp<-as.data.frame(pr$prob,check.names=FALSE);names(pp)<-paste0("prob__",names(pp));rows[[ii]]<-cbind(data.frame(truth=as.character(truth),row_id=s$val[pr$rows],rep_id=1L,fold=s$fold),pp)}
  }
  if(!length(rows))return(NULL);do.call(rbind,rows)
}

Try the R4VN package in your browser

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

R4VN documentation built on Sept. 30, 2026, 5:13 p.m.