Skip to main content

runmat_runtime/builtins/stats/ml/
classification_tree.rs

1//! Numeric classification tree compatibility surface.
2
3use std::cmp::Ordering;
4
5use runmat_builtins::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8    CharArray, LogicalArray, ObjectInstance, ResolveContext, StringArray, StructValue, Tensor,
9    Type, Value,
10};
11use runmat_macros::runtime_builtin;
12
13use crate::builtins::common::tensor;
14use crate::builtins::table::{
15    is_tabular_object, table_variable_names_from_object, table_variables,
16};
17use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
18
19pub(crate) const CLASSIFICATION_TREE_CLASS: &str = "ClassificationTree";
20
21const FITCTREE_NAME: &str = "fitctree";
22const PREDICT_NAME: &str = "predict";
23const EPS: f64 = 1.0e-12;
24const MAX_TREE_DEPTH: usize = 4096;
25
26const OUTPUT_TREE: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27    name: "Mdl",
28    ty: BuiltinParamType::Any,
29    arity: BuiltinParamArity::Required,
30    default: None,
31    description: "ClassificationTree object containing class names, split rules, and posterior probabilities.",
32}];
33
34const PARAM_TBL_OR_X: BuiltinParamDescriptor = BuiltinParamDescriptor {
35    name: "tblOrX",
36    ty: BuiltinParamType::Any,
37    arity: BuiltinParamArity::Required,
38    default: None,
39    description: "Input table or numeric predictor matrix.",
40};
41
42const PARAM_Y_OR_RESPONSE: BuiltinParamDescriptor = BuiltinParamDescriptor {
43    name: "yOrResponse",
44    ty: BuiltinParamType::Any,
45    arity: BuiltinParamArity::Optional,
46    default: None,
47    description: "Response vector, response variable name, or table formula.",
48};
49
50const PARAM_REST: BuiltinParamDescriptor = BuiltinParamDescriptor {
51    name: "options",
52    ty: BuiltinParamType::Any,
53    arity: BuiltinParamArity::Variadic,
54    default: None,
55    description: "Name-value options such as ClassNames, PredictorNames, ResponseName, MaxNumSplits, MinLeafSize, MinParentSize, SplitCriterion, and Weights.",
56};
57
58const FITCTREE_INPUTS_X_Y: [BuiltinParamDescriptor; 2] = [PARAM_TBL_OR_X, PARAM_Y_OR_RESPONSE];
59const FITCTREE_INPUTS_FULL: [BuiltinParamDescriptor; 3] =
60    [PARAM_TBL_OR_X, PARAM_Y_OR_RESPONSE, PARAM_REST];
61
62const FITCTREE_SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
63    BuiltinSignatureDescriptor {
64        label: "Mdl = fitctree(Tbl, ResponseVarName)",
65        inputs: &FITCTREE_INPUTS_X_Y,
66        outputs: &OUTPUT_TREE,
67    },
68    BuiltinSignatureDescriptor {
69        label: "Mdl = fitctree(Tbl, formula)",
70        inputs: &FITCTREE_INPUTS_X_Y,
71        outputs: &OUTPUT_TREE,
72    },
73    BuiltinSignatureDescriptor {
74        label: "Mdl = fitctree(X, Y)",
75        inputs: &FITCTREE_INPUTS_X_Y,
76        outputs: &OUTPUT_TREE,
77    },
78    BuiltinSignatureDescriptor {
79        label: "Mdl = fitctree(___, Name, Value)",
80        inputs: &FITCTREE_INPUTS_FULL,
81        outputs: &OUTPUT_TREE,
82    },
83];
84
85const ERROR_FITCTREE_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
86    code: "RM.FITCTREE.INVALID_ARGUMENT",
87    identifier: Some("RunMat:fitctree:InvalidArgument"),
88    when:
89        "Inputs, response labels, dimensions, or name-value options are malformed or unsupported.",
90    message: "fitctree: invalid argument",
91};
92
93const ERROR_FITCTREE_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
94    code: "RM.FITCTREE.INTERNAL",
95    identifier: Some("RunMat:fitctree:Internal"),
96    when: "RunMat cannot construct the ClassificationTree result.",
97    message: "fitctree: internal error",
98};
99
100const ERROR_PREDICT_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
101    code: "RM.PREDICT.INVALID_ARGUMENT",
102    identifier: Some("RunMat:predict:InvalidArgument"),
103    when: "Classification-tree prediction inputs are malformed or incompatible with the model.",
104    message: "predict: invalid argument",
105};
106
107const ERROR_PREDICT_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
108    code: "RM.PREDICT.INTERNAL",
109    identifier: Some("RunMat:predict:Internal"),
110    when: "Classification-tree prediction cannot construct its output.",
111    message: "predict: internal error",
112};
113
114const FITCTREE_ERRORS: [BuiltinErrorDescriptor; 2] =
115    [ERROR_FITCTREE_INVALID_ARGUMENT, ERROR_FITCTREE_INTERNAL];
116
117pub const FITCTREE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
118    signatures: &FITCTREE_SIGNATURES,
119    output_mode: BuiltinOutputMode::Fixed,
120    completion_policy: BuiltinCompletionPolicy::Public,
121    errors: &FITCTREE_ERRORS,
122};
123
124fn fitctree_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
125    Type::Unknown
126}
127
128fn fitctree_error(
129    message: impl Into<String>,
130    descriptor: &'static BuiltinErrorDescriptor,
131) -> RuntimeError {
132    let mut builder = build_runtime_error(message).with_builtin(FITCTREE_NAME);
133    if let Some(identifier) = descriptor.identifier {
134        builder = builder.with_identifier(identifier);
135    }
136    builder.build()
137}
138
139fn fitctree_invalid(message: impl Into<String>) -> RuntimeError {
140    fitctree_error(message, &ERROR_FITCTREE_INVALID_ARGUMENT)
141}
142
143fn fitctree_internal(message: impl Into<String>) -> RuntimeError {
144    fitctree_error(message, &ERROR_FITCTREE_INTERNAL)
145}
146
147fn predict_invalid(message: impl Into<String>) -> RuntimeError {
148    predict_error(message, &ERROR_PREDICT_INVALID_ARGUMENT)
149}
150
151fn predict_internal(message: impl Into<String>) -> RuntimeError {
152    predict_error(message, &ERROR_PREDICT_INTERNAL)
153}
154
155fn predict_error(
156    message: impl Into<String>,
157    descriptor: &'static BuiltinErrorDescriptor,
158) -> RuntimeError {
159    let mut builder = build_runtime_error(message).with_builtin(PREDICT_NAME);
160    if let Some(identifier) = descriptor.identifier {
161        builder = builder.with_identifier(identifier);
162    }
163    builder.build()
164}
165
166#[derive(Clone, Debug)]
167struct FitOptions {
168    class_names: Option<Vec<ClassLabel>>,
169    predictor_names: Option<Vec<String>>,
170    response_name: Option<String>,
171    max_num_splits: Option<usize>,
172    min_leaf_size: usize,
173    min_parent_size: usize,
174    split_criterion: SplitCriterion,
175    weights: Option<WeightSpec>,
176}
177
178impl Default for FitOptions {
179    fn default() -> Self {
180        Self {
181            class_names: None,
182            predictor_names: None,
183            response_name: None,
184            max_num_splits: None,
185            min_leaf_size: 1,
186            min_parent_size: 10,
187            split_criterion: SplitCriterion::Gdi,
188            weights: None,
189        }
190    }
191}
192
193#[derive(Clone, Debug)]
194enum WeightSpec {
195    Values(Vec<f64>),
196    Variable(String),
197}
198
199#[derive(Clone, Copy, Debug, Eq, PartialEq)]
200enum SplitCriterion {
201    Gdi,
202    Deviance,
203}
204
205#[derive(Clone, Debug, PartialEq)]
206enum ClassLabel {
207    Numeric(f64),
208    Text(String),
209    Logical(bool),
210}
211
212#[derive(Clone, Copy, Debug, Eq, PartialEq)]
213enum LabelKind {
214    Numeric,
215    Text,
216    Logical,
217}
218
219#[derive(Clone, Debug)]
220struct LabelVector {
221    labels: Vec<ClassLabel>,
222    kind: LabelKind,
223}
224
225#[derive(Clone, Debug)]
226struct FitSpec {
227    x: Tensor,
228    class_indices: Vec<usize>,
229    class_names: Vec<ClassLabel>,
230    class_kind: LabelKind,
231    predictor_names: Vec<String>,
232    response_name: String,
233    weights: Vec<f64>,
234    options: FitOptions,
235}
236
237#[derive(Clone, Debug)]
238struct Node {
239    left: Option<usize>,
240    right: Option<usize>,
241    predictor: Option<usize>,
242    threshold: f64,
243    class_index: usize,
244    probabilities: Vec<f64>,
245}
246
247#[derive(Clone, Debug)]
248struct TreeFit {
249    nodes: Vec<Node>,
250    num_splits: usize,
251}
252
253#[derive(Clone, Debug)]
254struct SplitCandidate {
255    predictor: usize,
256    threshold: f64,
257    gain: f64,
258    left_rows: Vec<usize>,
259    right_rows: Vec<usize>,
260}
261
262#[runtime_builtin(
263    name = "fitctree",
264    category = "stats/ml",
265    summary = "Fit a binary decision tree for multiclass classification.",
266    keywords = "fitctree,classification tree,decision tree,machine learning,statistics",
267    type_resolver(fitctree_type),
268    descriptor(crate::builtins::stats::ml::classification_tree::FITCTREE_DESCRIPTOR),
269    builtin_path = "crate::builtins::stats::ml::classification_tree"
270)]
271async fn fitctree_builtin(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
272    let first = gather(first)
273        .await
274        .map_err(|err| fitctree_invalid(err.message))?;
275    let rest = gather_all(rest)
276        .await
277        .map_err(|err| fitctree_invalid(err.message))?;
278    let spec = build_fit_spec(first, rest)?;
279    let fit = fit_tree(&spec)?;
280    model_object(spec, fit).map(Value::Object)
281}
282
283async fn gather(value: Value) -> BuiltinResult<Value> {
284    gather_if_needed_async(&value)
285        .await
286        .map_err(|err| fitctree_invalid(format!("fitctree: {err}")))
287}
288
289async fn gather_all(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
290    let mut out = Vec::with_capacity(values.len());
291    for value in values {
292        out.push(gather(value).await?);
293    }
294    Ok(out)
295}
296
297fn build_fit_spec(first: Value, mut rest: Vec<Value>) -> BuiltinResult<FitSpec> {
298    if is_table_value(&first) {
299        build_table_fit_spec(first, rest)
300    } else {
301        if rest.is_empty() {
302            return Err(fitctree_invalid(
303                "fitctree: matrix input requires response vector Y",
304            ));
305        }
306        let y = rest.remove(0);
307        build_matrix_fit_spec(first, y, rest)
308    }
309}
310
311fn is_table_value(value: &Value) -> bool {
312    matches!(value, Value::Object(object) if is_tabular_object(object))
313}
314
315fn build_matrix_fit_spec(
316    x_value: Value,
317    y_value: Value,
318    rest: Vec<Value>,
319) -> BuiltinResult<FitSpec> {
320    let x = tensor::value_into_tensor_for(FITCTREE_NAME, x_value)
321        .map_err(|err| fitctree_invalid(format!("fitctree: {err}")))?;
322    if x.shape.len() > 2 {
323        return Err(fitctree_invalid("fitctree: X must be a 2-D numeric matrix"));
324    }
325    let labels = label_vector(y_value, "Y")?;
326    if labels.labels.len() != x.rows {
327        return Err(fitctree_invalid(
328            "fitctree: length(Y) must match the number of rows in X",
329        ));
330    }
331    let mut options = FitOptions::default();
332    parse_fit_options(&rest, &mut options)?;
333    if let Some(WeightSpec::Variable(_)) = options.weights {
334        return Err(fitctree_invalid(
335            "fitctree: weights variable names require table input",
336        ));
337    }
338    let predictor_names = if let Some(names) = options.predictor_names.clone() {
339        if names.len() != x.cols {
340            return Err(fitctree_invalid(format!(
341                "fitctree: PredictorNames must contain {} names",
342                x.cols
343            )));
344        }
345        names
346    } else {
347        (1..=x.cols).map(|idx| format!("x{idx}")).collect()
348    };
349    let response_name = options
350        .response_name
351        .clone()
352        .unwrap_or_else(|| "Y".to_string());
353    let weights = match &options.weights {
354        Some(WeightSpec::Values(values)) => values.clone(),
355        Some(WeightSpec::Variable(_)) => unreachable!("checked above"),
356        None => vec![1.0; x.rows],
357    };
358    finalize_fit_spec(x, labels, predictor_names, response_name, weights, options)
359}
360
361fn build_table_fit_spec(first: Value, rest: Vec<Value>) -> BuiltinResult<FitSpec> {
362    let object = match first {
363        Value::Object(object) => object,
364        _ => unreachable!("table checked"),
365    };
366    let variable_names = table_variable_names_from_object(&object)
367        .map_err(|err| fitctree_invalid(format!("fitctree: {err}")))?;
368    if variable_names.len() < 2 {
369        return Err(fitctree_invalid(
370            "fitctree: table input must contain at least one predictor and one response",
371        ));
372    }
373    let variables =
374        table_variables(&object).map_err(|err| fitctree_invalid(format!("fitctree: {err}")))?;
375    let mut options = FitOptions::default();
376    let mut rest_start = 0usize;
377    let mut response_name = variable_names
378        .last()
379        .cloned()
380        .ok_or_else(|| fitctree_invalid("fitctree: response variable missing"))?;
381    let mut external_y = None;
382    let mut formula_predictors = None;
383    if let Some(first_arg) = rest.first() {
384        if !is_name_value_option(first_arg) {
385            if let Some(text) = scalar_text(first_arg) {
386                if text.contains('~') {
387                    let parsed = parse_formula(&text, &variable_names)?;
388                    response_name = parsed.0;
389                    formula_predictors = Some(parsed.1);
390                } else {
391                    response_name = text;
392                }
393            } else {
394                external_y = Some(label_vector(first_arg.clone(), "Y")?);
395                response_name = options
396                    .response_name
397                    .clone()
398                    .unwrap_or_else(|| "Y".to_string());
399            }
400            rest_start = 1;
401        }
402    }
403    parse_fit_options(&rest[rest_start..], &mut options)?;
404    if external_y.is_none() {
405        response_name = options.response_name.clone().unwrap_or(response_name);
406    }
407    let has_external_y = external_y.is_some();
408    let labels = if let Some(labels) = external_y {
409        labels
410    } else {
411        let value = variables.fields.get(&response_name).ok_or_else(|| {
412            fitctree_invalid(format!(
413                "fitctree: response variable '{response_name}' is not in the table"
414            ))
415        })?;
416        label_vector(value.clone(), &response_name)?
417    };
418    let predictor_names = if let Some(names) = options.predictor_names.clone() {
419        names
420    } else if let Some(names) = formula_predictors {
421        names
422    } else {
423        let weight_variable = match &options.weights {
424            Some(WeightSpec::Variable(name)) => Some(name.as_str()),
425            _ => None,
426        };
427        variable_names
428            .iter()
429            .filter(|name| {
430                (has_external_y || **name != response_name)
431                    && weight_variable != Some(name.as_str())
432            })
433            .cloned()
434            .collect()
435    };
436    if predictor_names.is_empty() {
437        return Err(fitctree_invalid(
438            "fitctree: at least one predictor variable is required",
439        ));
440    }
441    let mut columns = Vec::with_capacity(predictor_names.len());
442    for name in &predictor_names {
443        let value = variables.fields.get(name).ok_or_else(|| {
444            fitctree_invalid(format!(
445                "fitctree: predictor variable '{name}' is not in the table"
446            ))
447        })?;
448        let tensor = tensor::value_into_tensor_for(FITCTREE_NAME, value.clone()).map_err(|_| {
449            fitctree_invalid(format!(
450                "fitctree: predictor variable '{name}' must be numeric"
451            ))
452        })?;
453        columns.push(vector_values(&tensor, name)?);
454    }
455    let x = columns_to_tensor(columns)?;
456    let weights = match &options.weights {
457        Some(WeightSpec::Values(values)) => values.clone(),
458        Some(WeightSpec::Variable(name)) => {
459            let value = variables.fields.get(name).ok_or_else(|| {
460                fitctree_invalid(format!(
461                    "fitctree: weights variable '{name}' is not in the table"
462                ))
463            })?;
464            numeric_vector(value, "Weights")?
465        }
466        None => vec![1.0; x.rows],
467    };
468    finalize_fit_spec(x, labels, predictor_names, response_name, weights, options)
469}
470
471fn finalize_fit_spec(
472    x: Tensor,
473    labels: LabelVector,
474    predictor_names: Vec<String>,
475    response_name: String,
476    weights: Vec<f64>,
477    mut options: FitOptions,
478) -> BuiltinResult<FitSpec> {
479    if labels.labels.len() != x.rows {
480        return Err(fitctree_invalid(
481            "fitctree: response length must match the number of observations",
482        ));
483    }
484    if weights.len() != x.rows {
485        return Err(fitctree_invalid(
486            "fitctree: Weights length must match the number of observations",
487        ));
488    }
489    for weight in &weights {
490        if !weight.is_finite() || *weight < 0.0 {
491            return Err(fitctree_invalid(
492                "fitctree: weights must be nonnegative finite values",
493            ));
494        }
495    }
496    let class_names = if let Some(names) = options.class_names.clone() {
497        validate_class_names(names, labels.kind)?
498    } else {
499        unique_labels(&labels.labels, labels.kind)
500    };
501    if class_names.len() < 2 {
502        return Err(fitctree_invalid(
503            "fitctree: at least two classes are required",
504        ));
505    }
506    let mut clean_rows = Vec::new();
507    let mut clean_classes = Vec::new();
508    let mut clean_weights = Vec::new();
509    for row in 0..x.rows {
510        let weight = weights[row];
511        if weight == 0.0 {
512            continue;
513        }
514        if is_missing_label(&labels.labels[row]) {
515            continue;
516        }
517        let class_index = class_names
518            .iter()
519            .position(|label| same_label(label, &labels.labels[row]))
520            .ok_or_else(|| {
521                fitctree_invalid("fitctree: response contains a class outside ClassNames")
522            })?;
523        let mut row_values = Vec::with_capacity(x.cols);
524        let mut complete = true;
525        for col in 0..x.cols {
526            let value = x_value(&x, row, col);
527            if value.is_infinite() {
528                return Err(fitctree_invalid(
529                    "fitctree: predictor values must not contain Inf",
530                ));
531            }
532            if value.is_nan() {
533                complete = false;
534                break;
535            }
536            row_values.push(value);
537        }
538        if complete {
539            clean_rows.push(row_values);
540            clean_classes.push(class_index);
541            clean_weights.push(weight);
542        }
543    }
544    if clean_classes.is_empty() {
545        return Err(fitctree_invalid(
546            "fitctree: at least one complete weighted observation is required",
547        ));
548    }
549    let mut observed_classes = vec![false; class_names.len()];
550    for class in &clean_classes {
551        observed_classes[*class] = true;
552    }
553    if observed_classes
554        .iter()
555        .filter(|observed| **observed)
556        .count()
557        < 2
558    {
559        return Err(fitctree_invalid(
560            "fitctree: at least two observed classes are required",
561        ));
562    }
563    let mut clean_data = Vec::with_capacity(clean_rows.len() * x.cols);
564    for col in 0..x.cols {
565        for row in &clean_rows {
566            clean_data.push(row[col]);
567        }
568    }
569    let clean_x = Tensor::new(clean_data, vec![clean_rows.len(), x.cols])
570        .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?;
571    if options.max_num_splits.is_none() {
572        options.max_num_splits = Some(clean_classes.len().saturating_sub(1));
573    }
574    Ok(FitSpec {
575        x: clean_x,
576        class_indices: clean_classes,
577        class_names,
578        class_kind: labels.kind,
579        predictor_names,
580        response_name,
581        weights: clean_weights,
582        options,
583    })
584}
585
586fn parse_fit_options(values: &[Value], options: &mut FitOptions) -> BuiltinResult<()> {
587    if !values.len().is_multiple_of(2) {
588        return Err(fitctree_invalid(
589            "fitctree: name-value options must be paired",
590        ));
591    }
592    let mut index = 0usize;
593    while index < values.len() {
594        let name = scalar_text(&values[index])
595            .ok_or_else(|| fitctree_invalid("fitctree: option name must be text"))?;
596        let value = &values[index + 1];
597        match name.to_ascii_lowercase().as_str() {
598            "classnames" => {
599                let labels = label_vector(value.clone(), "ClassNames")?;
600                options.class_names = Some(labels.labels);
601            }
602            "predictornames" => {
603                options.predictor_names = Some(text_list(value, "PredictorNames")?);
604            }
605            "responsename" => {
606                options.response_name = Some(
607                    scalar_text(value)
608                        .ok_or_else(|| fitctree_invalid("fitctree: ResponseName must be text"))?,
609                );
610            }
611            "maxnumsplits" => {
612                options.max_num_splits = Some(nonnegative_integer(value, "MaxNumSplits")?);
613            }
614            "minleafsize" => {
615                options.min_leaf_size = positive_integer(value, "MinLeafSize")?;
616            }
617            "minparentsize" => {
618                options.min_parent_size = positive_integer(value, "MinParentSize")?;
619            }
620            "splitcriterion" => {
621                let text = scalar_text(value)
622                    .ok_or_else(|| fitctree_invalid("fitctree: SplitCriterion must be text"))?;
623                options.split_criterion = match text.to_ascii_lowercase().as_str() {
624                    "gdi" => SplitCriterion::Gdi,
625                    "deviance" => SplitCriterion::Deviance,
626                    other => {
627                        return Err(fitctree_invalid(format!(
628                            "fitctree: unsupported SplitCriterion '{other}'"
629                        )));
630                    }
631                };
632            }
633            "weights" => {
634                options.weights = Some(if let Some(name) = scalar_text(value) {
635                    WeightSpec::Variable(name)
636                } else {
637                    WeightSpec::Values(numeric_vector(value, "Weights")?)
638                });
639            }
640            "scoretransform" => {
641                let text = scalar_text(value)
642                    .ok_or_else(|| fitctree_invalid("fitctree: ScoreTransform must be text"))?;
643                if !text.eq_ignore_ascii_case("none") {
644                    return Err(fitctree_invalid(
645                        "fitctree: only ScoreTransform='none' is supported",
646                    ));
647                }
648            }
649            "algorithmforcategorical"
650            | "categoricalpredictors"
651            | "cost"
652            | "crossval"
653            | "cvpartition"
654            | "holdout"
655            | "kfold"
656            | "leaveout"
657            | "maxnumcategories"
658            | "mergeleaves"
659            | "numbins"
660            | "numvariablestosample"
661            | "optimizehyperparameters"
662            | "hyperparameteroptimizationoptions"
663            | "predictorselection"
664            | "prior"
665            | "prune"
666            | "prunecriterion"
667            | "reproducible"
668            | "surrogate" => {
669                return Err(fitctree_invalid(format!(
670                    "fitctree: option '{name}' is not supported yet"
671                )));
672            }
673            other => {
674                return Err(fitctree_invalid(format!(
675                    "fitctree: unknown option '{other}'"
676                )))
677            }
678        }
679        index += 2;
680    }
681    Ok(())
682}
683
684fn parse_formula(text: &str, variables: &[String]) -> BuiltinResult<(String, Vec<String>)> {
685    let Some((lhs, rhs)) = text.split_once('~') else {
686        return Err(fitctree_invalid("fitctree: formula must contain '~'"));
687    };
688    let response = lhs.trim().to_string();
689    if response.is_empty() {
690        return Err(fitctree_invalid("fitctree: formula response is empty"));
691    }
692    let mut predictors = Vec::new();
693    for term in rhs.split('+') {
694        let term = term.trim();
695        if term.is_empty() {
696            continue;
697        }
698        if term == "1" {
699            continue;
700        }
701        if term.contains('*') || term.contains(':') || term.contains('^') {
702            return Err(fitctree_invalid(
703                "fitctree: formula interactions are not supported yet",
704            ));
705        }
706        if !variables.iter().any(|name| name == term) {
707            return Err(fitctree_invalid(format!(
708                "fitctree: formula predictor '{term}' is not in the table"
709            )));
710        }
711        predictors.push(term.to_string());
712    }
713    if predictors.is_empty() {
714        return Err(fitctree_invalid(
715            "fitctree: formula must contain at least one predictor",
716        ));
717    }
718    Ok((response, predictors))
719}
720
721fn fit_tree(spec: &FitSpec) -> BuiltinResult<TreeFit> {
722    let mut nodes = Vec::new();
723    let mut splits = 0usize;
724    let rows = (0..spec.x.rows).collect::<Vec<_>>();
725    build_node(spec, &rows, &mut nodes, &mut splits, 0)?;
726    Ok(TreeFit {
727        nodes,
728        num_splits: splits,
729    })
730}
731
732fn build_node(
733    spec: &FitSpec,
734    rows: &[usize],
735    nodes: &mut Vec<Node>,
736    split_count: &mut usize,
737    depth: usize,
738) -> BuiltinResult<usize> {
739    if depth > MAX_TREE_DEPTH {
740        return Err(fitctree_invalid(
741            "fitctree: tree depth exceeds RunMat safety limit",
742        ));
743    }
744    let counts = class_counts(
745        rows,
746        &spec.class_indices,
747        &spec.weights,
748        spec.class_names.len(),
749    );
750    let total_weight = counts.iter().sum::<f64>();
751    let probabilities = if total_weight > 0.0 {
752        counts.iter().map(|value| value / total_weight).collect()
753    } else {
754        vec![0.0; counts.len()]
755    };
756    let class_index = probabilities
757        .iter()
758        .enumerate()
759        .max_by(|left, right| {
760            left.1
761                .partial_cmp(right.1)
762                .unwrap_or(Ordering::Equal)
763                .then_with(|| right.0.cmp(&left.0))
764        })
765        .map(|(idx, _)| idx)
766        .unwrap_or(0);
767    let node_index = nodes.len();
768    nodes.push(Node {
769        left: None,
770        right: None,
771        predictor: None,
772        threshold: f64::NAN,
773        class_index,
774        probabilities,
775    });
776    let max_splits = spec.options.max_num_splits.unwrap_or(0);
777    if *split_count >= max_splits
778        || rows.len() < spec.options.min_parent_size
779        || rows.len() < spec.options.min_leaf_size.saturating_mul(2)
780        || counts.iter().filter(|value| **value > 0.0).count() <= 1
781    {
782        return Ok(node_index);
783    }
784    if let Some(best) = best_split(spec, rows, &counts)? {
785        if best.gain <= EPS {
786            return Ok(node_index);
787        }
788        *split_count += 1;
789        let left = build_node(spec, &best.left_rows, nodes, split_count, depth + 1)?;
790        let right = build_node(spec, &best.right_rows, nodes, split_count, depth + 1)?;
791        nodes[node_index].left = Some(left);
792        nodes[node_index].right = Some(right);
793        nodes[node_index].predictor = Some(best.predictor);
794        nodes[node_index].threshold = best.threshold;
795    }
796    Ok(node_index)
797}
798
799fn best_split(
800    spec: &FitSpec,
801    rows: &[usize],
802    parent_counts: &[f64],
803) -> BuiltinResult<Option<SplitCandidate>> {
804    let parent_weight = parent_counts.iter().sum::<f64>();
805    let parent_impurity = impurity(parent_counts, spec.options.split_criterion);
806    let mut best = None;
807    for col in 0..spec.x.cols {
808        let mut entries = rows
809            .iter()
810            .map(|row| {
811                (
812                    x_value(&spec.x, *row, col),
813                    spec.class_indices[*row],
814                    spec.weights[*row],
815                    *row,
816                )
817            })
818            .collect::<Vec<_>>();
819        entries.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal));
820        if entries.len() < 2 {
821            continue;
822        }
823        let mut left_counts = vec![0.0; parent_counts.len()];
824        let mut right_counts = parent_counts.to_vec();
825        let mut left_weight = 0.0;
826        let mut right_weight = parent_weight;
827        let mut left_len = 0usize;
828        let mut right_len = entries.len();
829        for idx in 0..entries.len() - 1 {
830            let (_, class_idx, weight, _) = entries[idx];
831            left_counts[class_idx] += weight;
832            right_counts[class_idx] -= weight;
833            left_weight += weight;
834            right_weight -= weight;
835            left_len += 1;
836            right_len -= 1;
837            let current = entries[idx].0;
838            let next = entries[idx + 1].0;
839            if (current - next).abs() <= EPS {
840                continue;
841            }
842            if left_len < spec.options.min_leaf_size || right_len < spec.options.min_leaf_size {
843                continue;
844            }
845            if left_weight <= 0.0 || right_weight <= 0.0 {
846                continue;
847            }
848            let left_impurity = impurity(&left_counts, spec.options.split_criterion);
849            let right_impurity = impurity(&right_counts, spec.options.split_criterion);
850            let gain = parent_impurity
851                - (left_weight / parent_weight) * left_impurity
852                - (right_weight / parent_weight) * right_impurity;
853            if gain
854                > best
855                    .as_ref()
856                    .map(|candidate: &SplitCandidate| candidate.gain)
857                    .unwrap_or(-1.0)
858            {
859                let threshold = current + (next - current) / 2.0;
860                let mut left_rows = Vec::with_capacity(left_len);
861                let mut right_rows = Vec::with_capacity(right_len);
862                for (value, _, _, row) in &entries {
863                    if *value <= threshold {
864                        left_rows.push(*row);
865                    } else {
866                        right_rows.push(*row);
867                    }
868                }
869                best = Some(SplitCandidate {
870                    predictor: col,
871                    threshold,
872                    gain,
873                    left_rows,
874                    right_rows,
875                });
876            }
877        }
878    }
879    Ok(best)
880}
881
882fn impurity(counts: &[f64], criterion: SplitCriterion) -> f64 {
883    let total = counts.iter().sum::<f64>();
884    if total <= 0.0 {
885        return 0.0;
886    }
887    match criterion {
888        SplitCriterion::Gdi => {
889            1.0 - counts
890                .iter()
891                .map(|count| {
892                    let p = count / total;
893                    p * p
894                })
895                .sum::<f64>()
896        }
897        SplitCriterion::Deviance => counts
898            .iter()
899            .filter(|count| **count > 0.0)
900            .map(|count| {
901                let p = count / total;
902                -p * p.ln()
903            })
904            .sum(),
905    }
906}
907
908fn class_counts(
909    rows: &[usize],
910    classes: &[usize],
911    weights: &[f64],
912    class_count: usize,
913) -> Vec<f64> {
914    let mut counts = vec![0.0; class_count];
915    for row in rows {
916        counts[classes[*row]] += weights[*row];
917    }
918    counts
919}
920
921fn model_object(spec: FitSpec, fit: TreeFit) -> BuiltinResult<ObjectInstance> {
922    let mut object = ObjectInstance::new(CLASSIFICATION_TREE_CLASS.to_string());
923    object.properties.insert(
924        "ResponseName".to_string(),
925        Value::String(spec.response_name),
926    );
927    object.properties.insert(
928        "PredictorNames".to_string(),
929        string_column(&spec.predictor_names)?,
930    );
931    object.properties.insert(
932        "ClassNames".to_string(),
933        labels_value(&spec.class_names, spec.class_kind)?,
934    );
935    object.properties.insert(
936        "CategoricalPredictors".to_string(),
937        Value::Tensor(Tensor::new(Vec::new(), vec![0, 1]).map_err(|err| {
938            fitctree_internal(format!(
939                "fitctree: categorical predictor shape error: {err}"
940            ))
941        })?),
942    );
943    object.properties.insert(
944        "ScoreTransform".to_string(),
945        Value::String("none".to_string()),
946    );
947    object.properties.insert(
948        "NumObservations".to_string(),
949        Value::Num(spec.x.rows as f64),
950    );
951    object
952        .properties
953        .insert("NumPredictors".to_string(), Value::Num(spec.x.cols as f64));
954    object
955        .properties
956        .insert("NumNodes".to_string(), Value::Num(fit.nodes.len() as f64));
957    object
958        .properties
959        .insert("NumSplits".to_string(), Value::Num(fit.num_splits as f64));
960    let mut params = StructValue::new();
961    params.insert(
962        "MaxNumSplits",
963        Value::Num(spec.options.max_num_splits.unwrap_or(0) as f64),
964    );
965    params.insert("MinLeaf", Value::Num(spec.options.min_leaf_size as f64));
966    params.insert("MinParent", Value::Num(spec.options.min_parent_size as f64));
967    params.insert(
968        "SplitCriterion",
969        Value::String(
970            match spec.options.split_criterion {
971                SplitCriterion::Gdi => "gdi",
972                SplitCriterion::Deviance => "deviance",
973            }
974            .to_string(),
975        ),
976    );
977    object
978        .properties
979        .insert("ModelParameters".to_string(), Value::Struct(params));
980    object.properties.insert(
981        "__RunMatTreeClassKind".to_string(),
982        Value::String(
983            match spec.class_kind {
984                LabelKind::Numeric => "numeric",
985                LabelKind::Text => "text",
986                LabelKind::Logical => "logical",
987            }
988            .to_string(),
989        ),
990    );
991    object.properties.insert(
992        "__RunMatTreeClassNames".to_string(),
993        labels_value(&spec.class_names, spec.class_kind)?,
994    );
995    object.properties.insert(
996        "__RunMatTreeChildren".to_string(),
997        tree_children_tensor(&fit.nodes)?,
998    );
999    object.properties.insert(
1000        "__RunMatTreePredictor".to_string(),
1001        tree_predictor_tensor(&fit.nodes)?,
1002    );
1003    object.properties.insert(
1004        "__RunMatTreeThreshold".to_string(),
1005        tree_threshold_tensor(&fit.nodes)?,
1006    );
1007    object.properties.insert(
1008        "__RunMatTreeNodeClass".to_string(),
1009        tree_class_tensor(&fit.nodes)?,
1010    );
1011    object.properties.insert(
1012        "__RunMatTreeNodeProb".to_string(),
1013        tree_prob_tensor(&fit.nodes, spec.class_names.len())?,
1014    );
1015    Ok(object)
1016}
1017
1018pub(crate) fn predict_classification_tree_object(
1019    object: ObjectInstance,
1020    xnew: Value,
1021    rest: Vec<Value>,
1022) -> BuiltinResult<Vec<Value>> {
1023    if !rest.is_empty() {
1024        return Err(predict_invalid(
1025            "predict: ClassificationTree Subtrees and name-value options are not supported yet",
1026        ));
1027    }
1028    let predictor_names = string_property(&object, "PredictorNames")?;
1029    let x = predictors_for_prediction(xnew, &predictor_names)?;
1030    let children = matrix_property(&object, "__RunMatTreeChildren")?;
1031    let predictors = numeric_property(&object, "__RunMatTreePredictor")?;
1032    let thresholds = numeric_property(&object, "__RunMatTreeThreshold")?;
1033    let node_classes = numeric_property(&object, "__RunMatTreeNodeClass")?;
1034    let node_probs = matrix_property(&object, "__RunMatTreeNodeProb")?;
1035    let class_names_value = object
1036        .properties
1037        .get("__RunMatTreeClassNames")
1038        .cloned()
1039        .ok_or_else(|| predict_invalid("predict: tree is missing class names"))?;
1040    let class_kind = class_kind_property(&object)?;
1041    let class_names = labels_from_value(class_names_value, "ClassNames")?;
1042    let class_count = class_names.labels.len();
1043    let node_count = predictors.len();
1044    if children.len() != node_count * 2
1045        || thresholds.len() != node_count
1046        || node_classes.len() != node_count
1047        || node_probs.len() != node_count * class_count
1048    {
1049        return Err(predict_invalid("predict: tree metadata is inconsistent"));
1050    }
1051    let mut label_indices = Vec::with_capacity(x.rows);
1052    let mut score_data = vec![0.0; x.rows * class_count];
1053    let mut node_data = Vec::with_capacity(x.rows);
1054    let mut class_number_data = Vec::with_capacity(x.rows);
1055    for row in 0..x.rows {
1056        let node = traverse_tree(&x, row, &children, &predictors, &thresholds, node_count)?;
1057        let class_index = checked_one_based_index(node_classes[node], class_count, "class number")?;
1058        label_indices.push(class_index);
1059        node_data.push(node as f64 + 1.0);
1060        class_number_data.push(class_index as f64 + 1.0);
1061        for class in 0..class_count {
1062            score_data[class * x.rows + row] = node_probs[class * node_count + node];
1063        }
1064    }
1065    let labels = predicted_labels_value(&class_names.labels, class_kind, &label_indices)?;
1066    let scores = Value::Tensor(
1067        Tensor::new(score_data, vec![x.rows, class_count])
1068            .map_err(|err| predict_internal(format!("predict: {err}")))?,
1069    );
1070    let node = Value::Tensor(
1071        Tensor::new(node_data, vec![x.rows, 1])
1072            .map_err(|err| predict_internal(format!("predict: {err}")))?,
1073    );
1074    let cnum = Value::Tensor(
1075        Tensor::new(class_number_data, vec![x.rows, 1])
1076            .map_err(|err| predict_internal(format!("predict: {err}")))?,
1077    );
1078    Ok(vec![labels, scores, node, cnum])
1079}
1080
1081fn traverse_tree(
1082    x: &Tensor,
1083    row: usize,
1084    children: &[f64],
1085    predictors: &[f64],
1086    thresholds: &[f64],
1087    node_count: usize,
1088) -> BuiltinResult<usize> {
1089    let mut node = 0usize;
1090    for _ in 0..node_count {
1091        let predictor = predictors[node];
1092        if predictor <= 0.0 {
1093            return Ok(node);
1094        }
1095        let predictor_idx = checked_one_based_index(predictor, x.cols, "predictor")?;
1096        let value = x_value(x, row, predictor_idx);
1097        if value.is_nan() {
1098            return Ok(node);
1099        }
1100        let child_col = if value <= thresholds[node] { 0 } else { 1 };
1101        let child = children[child_col * node_count + node];
1102        if child <= 0.0 {
1103            return Ok(node);
1104        }
1105        node = checked_one_based_index(child, node_count, "child node")?;
1106    }
1107    Err(predict_invalid("predict: tree traversal did not terminate"))
1108}
1109
1110fn checked_one_based_index(value: f64, len: usize, label: &str) -> BuiltinResult<usize> {
1111    if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
1112        return Err(predict_invalid(format!("predict: invalid {label}")));
1113    }
1114    let index = value as usize - 1;
1115    if index >= len {
1116        return Err(predict_invalid(format!("predict: {label} out of range")));
1117    }
1118    Ok(index)
1119}
1120
1121fn predictors_for_prediction(value: Value, predictor_names: &[String]) -> BuiltinResult<Tensor> {
1122    if let Value::Object(object) = &value {
1123        if is_tabular_object(object) {
1124            let variables = table_variables(object)
1125                .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1126            let mut columns = Vec::with_capacity(predictor_names.len());
1127            for name in predictor_names {
1128                let raw = variables.fields.get(name).ok_or_else(|| {
1129                    predict_invalid(format!("predict: missing predictor '{name}'"))
1130                })?;
1131                let tensor =
1132                    tensor::value_into_tensor_for(PREDICT_NAME, raw.clone()).map_err(|_| {
1133                        predict_invalid(format!("predict: predictor '{name}' must be numeric"))
1134                    })?;
1135                columns.push(vector_values_predict(&tensor, name)?);
1136            }
1137            return columns_to_tensor_predict(columns);
1138        }
1139    }
1140    let tensor = tensor::value_into_tensor_for(PREDICT_NAME, value)
1141        .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1142    if tensor.shape.len() > 2 || tensor.cols != predictor_names.len() {
1143        return Err(predict_invalid(format!(
1144            "predict: X must have {} predictor columns",
1145            predictor_names.len()
1146        )));
1147    }
1148    Ok(tensor)
1149}
1150
1151fn label_vector(value: Value, name: &str) -> BuiltinResult<LabelVector> {
1152    labels_from_value(value, name).map_err(|err| fitctree_invalid(err.message))
1153}
1154
1155fn labels_from_value(value: Value, name: &str) -> BuiltinResult<LabelVector> {
1156    match value {
1157        Value::Tensor(tensor) => Ok(LabelVector {
1158            labels: vector_values_predict(&tensor, name)?
1159                .into_iter()
1160                .map(ClassLabel::Numeric)
1161                .collect(),
1162            kind: LabelKind::Numeric,
1163        }),
1164        Value::Num(value) => Ok(LabelVector {
1165            labels: vec![ClassLabel::Numeric(value)],
1166            kind: LabelKind::Numeric,
1167        }),
1168        Value::String(text) => Ok(LabelVector {
1169            labels: vec![ClassLabel::Text(text)],
1170            kind: LabelKind::Text,
1171        }),
1172        Value::CharArray(array) => Ok(LabelVector {
1173            labels: char_rows(&array).into_iter().map(ClassLabel::Text).collect(),
1174            kind: LabelKind::Text,
1175        }),
1176        Value::StringArray(array) => Ok(LabelVector {
1177            labels: array.data.into_iter().map(ClassLabel::Text).collect(),
1178            kind: LabelKind::Text,
1179        }),
1180        Value::Cell(cell) => {
1181            let mut labels = Vec::with_capacity(cell.data.len());
1182            for item in cell.data {
1183                labels.push(ClassLabel::Text(
1184                    scalar_text(&item).ok_or_else(|| {
1185                        predict_invalid(format!("predict: {name} cell entries must be text"))
1186                    })?,
1187                ));
1188            }
1189            Ok(LabelVector {
1190                labels,
1191                kind: LabelKind::Text,
1192            })
1193        }
1194        Value::Bool(value) => Ok(LabelVector {
1195            labels: vec![ClassLabel::Logical(value)],
1196            kind: LabelKind::Logical,
1197        }),
1198        Value::LogicalArray(array) => Ok(LabelVector {
1199            labels: array
1200                .data
1201                .into_iter()
1202                .map(|value| ClassLabel::Logical(value != 0))
1203                .collect(),
1204            kind: LabelKind::Logical,
1205        }),
1206        other => Err(predict_invalid(format!(
1207            "predict: {name} must be numeric, logical, string, character, or cellstr labels; got {other:?}"
1208        ))),
1209    }
1210}
1211
1212fn validate_class_names(names: Vec<ClassLabel>, kind: LabelKind) -> BuiltinResult<Vec<ClassLabel>> {
1213    if names.is_empty() {
1214        return Err(fitctree_invalid("fitctree: ClassNames must not be empty"));
1215    }
1216    for name in &names {
1217        if is_missing_label(name) {
1218            return Err(fitctree_invalid(
1219                "fitctree: ClassNames must not contain missing values",
1220            ));
1221        }
1222        if label_kind(name) != kind {
1223            return Err(fitctree_invalid(
1224                "fitctree: ClassNames must have the same type as the response",
1225            ));
1226        }
1227    }
1228    let mut out = Vec::new();
1229    for name in names {
1230        if out.iter().any(|existing| same_label(existing, &name)) {
1231            return Err(fitctree_invalid(
1232                "fitctree: ClassNames must contain unique values",
1233            ));
1234        }
1235        out.push(name);
1236    }
1237    Ok(out)
1238}
1239
1240fn unique_labels(labels: &[ClassLabel], kind: LabelKind) -> Vec<ClassLabel> {
1241    let mut out = Vec::new();
1242    for label in labels {
1243        if is_missing_label(label) {
1244            continue;
1245        }
1246        if !out.iter().any(|existing| same_label(existing, label)) {
1247            out.push(label.clone());
1248        }
1249    }
1250    match kind {
1251        LabelKind::Numeric => out.sort_by(|left, right| match (left, right) {
1252            (ClassLabel::Numeric(a), ClassLabel::Numeric(b)) => {
1253                a.partial_cmp(b).unwrap_or(Ordering::Equal)
1254            }
1255            _ => Ordering::Equal,
1256        }),
1257        LabelKind::Text => out.sort_by(|left, right| match (left, right) {
1258            (ClassLabel::Text(a), ClassLabel::Text(b)) => a.cmp(b),
1259            _ => Ordering::Equal,
1260        }),
1261        LabelKind::Logical => out.sort_by_key(|label| match label {
1262            ClassLabel::Logical(value) => *value,
1263            _ => false,
1264        }),
1265    }
1266    out
1267}
1268
1269fn label_kind(label: &ClassLabel) -> LabelKind {
1270    match label {
1271        ClassLabel::Numeric(_) => LabelKind::Numeric,
1272        ClassLabel::Text(_) => LabelKind::Text,
1273        ClassLabel::Logical(_) => LabelKind::Logical,
1274    }
1275}
1276
1277fn same_label(left: &ClassLabel, right: &ClassLabel) -> bool {
1278    match (left, right) {
1279        (ClassLabel::Numeric(a), ClassLabel::Numeric(b)) => (a == b) || (a.is_nan() && b.is_nan()),
1280        (ClassLabel::Text(a), ClassLabel::Text(b)) => a == b,
1281        (ClassLabel::Logical(a), ClassLabel::Logical(b)) => a == b,
1282        _ => false,
1283    }
1284}
1285
1286fn is_missing_label(label: &ClassLabel) -> bool {
1287    matches!(label, ClassLabel::Numeric(value) if value.is_nan())
1288}
1289
1290fn labels_value(labels: &[ClassLabel], kind: LabelKind) -> BuiltinResult<Value> {
1291    predicted_labels_value(labels, kind, &(0..labels.len()).collect::<Vec<_>>())
1292}
1293
1294fn predicted_labels_value(
1295    labels: &[ClassLabel],
1296    kind: LabelKind,
1297    indices: &[usize],
1298) -> BuiltinResult<Value> {
1299    match kind {
1300        LabelKind::Numeric => {
1301            let data = indices
1302                .iter()
1303                .map(|idx| match labels.get(*idx) {
1304                    Some(ClassLabel::Numeric(value)) => *value,
1305                    _ => f64::NAN,
1306                })
1307                .collect::<Vec<_>>();
1308            Ok(Value::Tensor(
1309                Tensor::new(data, vec![indices.len(), 1])
1310                    .map_err(|err| predict_internal(format!("predict: {err}")))?,
1311            ))
1312        }
1313        LabelKind::Text => {
1314            let data = indices
1315                .iter()
1316                .map(|idx| match labels.get(*idx) {
1317                    Some(ClassLabel::Text(value)) => value.clone(),
1318                    _ => String::new(),
1319                })
1320                .collect::<Vec<_>>();
1321            Ok(Value::StringArray(StringArray {
1322                data,
1323                shape: vec![indices.len(), 1],
1324                rows: indices.len(),
1325                cols: 1,
1326            }))
1327        }
1328        LabelKind::Logical => {
1329            let data = indices
1330                .iter()
1331                .map(|idx| match labels.get(*idx) {
1332                    Some(ClassLabel::Logical(value)) if *value => 1,
1333                    _ => 0,
1334                })
1335                .collect::<Vec<_>>();
1336            Ok(Value::LogicalArray(
1337                LogicalArray::new(data, vec![indices.len(), 1])
1338                    .map_err(|err| predict_internal(format!("predict: {err}")))?,
1339            ))
1340        }
1341    }
1342}
1343
1344fn class_kind_property(object: &ObjectInstance) -> BuiltinResult<LabelKind> {
1345    match object.properties.get("__RunMatTreeClassKind") {
1346        Some(Value::String(text)) if text == "numeric" => Ok(LabelKind::Numeric),
1347        Some(Value::String(text)) if text == "text" => Ok(LabelKind::Text),
1348        Some(Value::String(text)) if text == "logical" => Ok(LabelKind::Logical),
1349        _ => Err(predict_invalid(
1350            "predict: tree is missing class type metadata",
1351        )),
1352    }
1353}
1354
1355fn scalar_text(value: &Value) -> Option<String> {
1356    match value {
1357        Value::String(text) => Some(text.clone()),
1358        Value::CharArray(array) if array.rows == 1 => Some(array.data.iter().collect()),
1359        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
1360        _ => None,
1361    }
1362}
1363
1364fn text_list(value: &Value, name: &str) -> BuiltinResult<Vec<String>> {
1365    match value {
1366        Value::String(text) => Ok(vec![text.clone()]),
1367        Value::CharArray(array) => Ok(char_rows(array)),
1368        Value::StringArray(array) => Ok(array.data.clone()),
1369        Value::Cell(cell) => cell
1370            .data
1371            .iter()
1372            .map(|value| {
1373                scalar_text(value).ok_or_else(|| {
1374                    fitctree_invalid(format!("fitctree: {name} entries must be text"))
1375                })
1376            })
1377            .collect(),
1378        _ => Err(fitctree_invalid(format!(
1379            "fitctree: {name} must be text or a text collection"
1380        ))),
1381    }
1382}
1383
1384fn char_rows(array: &CharArray) -> Vec<String> {
1385    let mut out = Vec::with_capacity(array.rows);
1386    for row in 0..array.rows {
1387        let start = row * array.cols;
1388        out.push(array.data[start..start + array.cols].iter().collect());
1389    }
1390    out
1391}
1392
1393fn numeric_vector(value: &Value, name: &str) -> BuiltinResult<Vec<f64>> {
1394    match value {
1395        Value::Num(value) => Ok(vec![*value]),
1396        Value::Tensor(tensor) => vector_values(tensor, name),
1397        Value::LogicalArray(array) => Ok(array
1398            .data
1399            .iter()
1400            .map(|value| if *value == 0 { 0.0 } else { 1.0 })
1401            .collect()),
1402        Value::Bool(value) => Ok(vec![if *value { 1.0 } else { 0.0 }]),
1403        _ => Err(fitctree_invalid(format!(
1404            "fitctree: {name} must be numeric"
1405        ))),
1406    }
1407}
1408
1409fn vector_values(tensor: &Tensor, name: &str) -> BuiltinResult<Vec<f64>> {
1410    vector_values_predict(tensor, name).map_err(|err| fitctree_invalid(err.message))
1411}
1412
1413fn vector_values_predict(tensor: &Tensor, name: &str) -> BuiltinResult<Vec<f64>> {
1414    if tensor.shape.len() > 2 || (tensor.rows != 1 && tensor.cols != 1) {
1415        return Err(predict_invalid(format!("predict: {name} must be a vector")));
1416    }
1417    Ok(tensor.data.clone())
1418}
1419
1420fn nonnegative_integer(value: &Value, name: &str) -> BuiltinResult<usize> {
1421    let value = numeric_scalar(value, name)?;
1422    if !value.is_finite() || value < 0.0 || value.fract() != 0.0 {
1423        return Err(fitctree_invalid(format!(
1424            "fitctree: {name} must be a nonnegative integer"
1425        )));
1426    }
1427    Ok(value as usize)
1428}
1429
1430fn positive_integer(value: &Value, name: &str) -> BuiltinResult<usize> {
1431    let value = numeric_scalar(value, name)?;
1432    if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
1433        return Err(fitctree_invalid(format!(
1434            "fitctree: {name} must be a positive integer"
1435        )));
1436    }
1437    Ok(value as usize)
1438}
1439
1440fn numeric_scalar(value: &Value, name: &str) -> BuiltinResult<f64> {
1441    match value {
1442        Value::Num(value) => Ok(*value),
1443        Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0]),
1444        Value::Bool(value) => Ok(if *value { 1.0 } else { 0.0 }),
1445        _ => Err(fitctree_invalid(format!(
1446            "fitctree: {name} must be a numeric scalar"
1447        ))),
1448    }
1449}
1450
1451fn is_name_value_option(value: &Value) -> bool {
1452    scalar_text(value).is_some_and(|text| {
1453        matches!(
1454            text.to_ascii_lowercase().as_str(),
1455            "algorithmforcategorical"
1456                | "categoricalpredictors"
1457                | "classnames"
1458                | "cost"
1459                | "crossval"
1460                | "cvpartition"
1461                | "holdout"
1462                | "hyperparameteroptimizationoptions"
1463                | "kfold"
1464                | "leaveout"
1465                | "maxnumcategories"
1466                | "maxnumsplits"
1467                | "mergeleaves"
1468                | "minleafsize"
1469                | "minparentsize"
1470                | "numbins"
1471                | "numvariablestosample"
1472                | "optimizehyperparameters"
1473                | "predictornames"
1474                | "predictorselection"
1475                | "prior"
1476                | "prune"
1477                | "prunecriterion"
1478                | "reproducible"
1479                | "responsename"
1480                | "scoretransform"
1481                | "splitcriterion"
1482                | "surrogate"
1483                | "weights"
1484        )
1485    })
1486}
1487
1488fn columns_to_tensor(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1489    if columns.is_empty() {
1490        return Err(fitctree_invalid("fitctree: no predictor columns"));
1491    }
1492    let rows = columns[0].len();
1493    let cols = columns.len();
1494    let mut data = Vec::with_capacity(rows * cols);
1495    for column in columns {
1496        if column.len() != rows {
1497            return Err(fitctree_invalid(
1498                "fitctree: predictor variables must have the same height",
1499            ));
1500        }
1501        data.extend(column);
1502    }
1503    Tensor::new(data, vec![rows, cols]).map_err(|err| fitctree_internal(format!("fitctree: {err}")))
1504}
1505
1506fn columns_to_tensor_predict(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1507    if columns.is_empty() {
1508        return Err(predict_invalid("predict: no predictor columns"));
1509    }
1510    let rows = columns[0].len();
1511    let cols = columns.len();
1512    let mut data = Vec::with_capacity(rows * cols);
1513    for column in columns {
1514        if column.len() != rows {
1515            return Err(predict_invalid(
1516                "predict: predictor table variables must have the same height",
1517            ));
1518        }
1519        data.extend(column);
1520    }
1521    Tensor::new(data, vec![rows, cols]).map_err(|err| predict_internal(format!("predict: {err}")))
1522}
1523
1524fn x_value(tensor: &Tensor, row: usize, col: usize) -> f64 {
1525    tensor.data[col * tensor.rows + row]
1526}
1527
1528fn string_column(values: &[String]) -> BuiltinResult<Value> {
1529    Ok(Value::StringArray(StringArray {
1530        data: values.to_vec(),
1531        shape: vec![values.len(), 1],
1532        rows: values.len(),
1533        cols: 1,
1534    }))
1535}
1536
1537fn numeric_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
1538    match object.properties.get(name) {
1539        Some(Value::Tensor(tensor)) => Ok(tensor.data.clone()),
1540        _ => Err(predict_invalid(format!("predict: tree is missing {name}"))),
1541    }
1542}
1543
1544fn matrix_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
1545    numeric_property(object, name)
1546}
1547
1548fn string_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<String>> {
1549    match object.properties.get(name) {
1550        Some(Value::StringArray(array)) => Ok(array.data.clone()),
1551        Some(Value::String(text)) => Ok(vec![text.clone()]),
1552        _ => Err(predict_invalid(format!("predict: tree is missing {name}"))),
1553    }
1554}
1555
1556fn tree_children_tensor(nodes: &[Node]) -> BuiltinResult<Value> {
1557    let rows = nodes.len();
1558    let mut data = Vec::with_capacity(rows * 2);
1559    for node in nodes {
1560        data.push(node.left.map(|idx| idx as f64 + 1.0).unwrap_or(0.0));
1561    }
1562    for node in nodes {
1563        data.push(node.right.map(|idx| idx as f64 + 1.0).unwrap_or(0.0));
1564    }
1565    Ok(Value::Tensor(Tensor::new(data, vec![rows, 2]).map_err(
1566        |err| fitctree_internal(format!("fitctree: {err}")),
1567    )?))
1568}
1569
1570fn tree_predictor_tensor(nodes: &[Node]) -> BuiltinResult<Value> {
1571    Ok(Value::Tensor(
1572        Tensor::new(
1573            nodes
1574                .iter()
1575                .map(|node| node.predictor.map(|idx| idx as f64 + 1.0).unwrap_or(0.0))
1576                .collect(),
1577            vec![nodes.len(), 1],
1578        )
1579        .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?,
1580    ))
1581}
1582
1583fn tree_threshold_tensor(nodes: &[Node]) -> BuiltinResult<Value> {
1584    Ok(Value::Tensor(
1585        Tensor::new(
1586            nodes.iter().map(|node| node.threshold).collect(),
1587            vec![nodes.len(), 1],
1588        )
1589        .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?,
1590    ))
1591}
1592
1593fn tree_class_tensor(nodes: &[Node]) -> BuiltinResult<Value> {
1594    Ok(Value::Tensor(
1595        Tensor::new(
1596            nodes
1597                .iter()
1598                .map(|node| node.class_index as f64 + 1.0)
1599                .collect(),
1600            vec![nodes.len(), 1],
1601        )
1602        .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?,
1603    ))
1604}
1605
1606fn tree_prob_tensor(nodes: &[Node], class_count: usize) -> BuiltinResult<Value> {
1607    let rows = nodes.len();
1608    let mut data = Vec::with_capacity(rows * class_count);
1609    for class in 0..class_count {
1610        for node in nodes {
1611            data.push(node.probabilities.get(class).copied().unwrap_or(0.0));
1612        }
1613    }
1614    Ok(Value::Tensor(
1615        Tensor::new(data, vec![rows, class_count])
1616            .map_err(|err| fitctree_internal(format!("fitctree: {err}")))?,
1617    ))
1618}
1619
1620#[cfg(test)]
1621mod tests {
1622    use super::*;
1623    use futures::executor::block_on;
1624
1625    fn tensor_value(data: Vec<f64>, rows: usize, cols: usize) -> Value {
1626        Value::Tensor(Tensor::new(data, vec![rows, cols]).unwrap())
1627    }
1628
1629    fn object(value: Value) -> ObjectInstance {
1630        match value {
1631            Value::Object(object) => object,
1632            other => panic!("expected object, got {other:?}"),
1633        }
1634    }
1635
1636    fn tensor(value: &Value) -> &Tensor {
1637        match value {
1638            Value::Tensor(tensor) => tensor,
1639            other => panic!("expected tensor, got {other:?}"),
1640        }
1641    }
1642
1643    #[test]
1644    fn fitctree_matrix_predicts_numeric_labels_and_scores() {
1645        let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1646        let y = tensor_value(vec![0.0, 0.0, 1.0, 1.0], 4, 1);
1647        let model = block_on(fitctree_builtin(
1648            x,
1649            vec![
1650                y,
1651                Value::from("MaxNumSplits"),
1652                Value::Num(1.0),
1653                Value::from("MinParentSize"),
1654                Value::Num(2.0),
1655            ],
1656        ))
1657        .unwrap();
1658        let output_count = crate::output_count::push_output_count(Some(4));
1659        let predicted = crate::builtins::stats::ml::linear_model::predict_builtin(
1660            model,
1661            tensor_value(vec![0.5, 2.5], 2, 1),
1662            Vec::new(),
1663        );
1664        let predicted = block_on(predicted).unwrap();
1665        drop(output_count);
1666        let Value::OutputList(values) = predicted else {
1667            panic!("expected output list");
1668        };
1669        assert_eq!(tensor(&values[0]).data, vec![0.0, 1.0]);
1670        assert_eq!(tensor(&values[1]).shape, vec![2, 2]);
1671        assert_eq!(tensor(&values[2]).shape, vec![2, 1]);
1672        assert_eq!(tensor(&values[3]).data, vec![1.0, 2.0]);
1673    }
1674
1675    #[test]
1676    fn fitctree_table_response_name_predicts_string_labels() {
1677        let table = crate::builtins::table::table_from_columns(
1678            vec!["A".into(), "Y".into()],
1679            vec![
1680                tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1681                Value::StringArray(StringArray {
1682                    data: vec!["low".into(), "low".into(), "high".into(), "high".into()],
1683                    shape: vec![4, 1],
1684                    rows: 4,
1685                    cols: 1,
1686                }),
1687            ],
1688        )
1689        .unwrap();
1690        let model = block_on(fitctree_builtin(
1691            table.clone(),
1692            vec![
1693                Value::String("Y".into()),
1694                Value::from("MaxNumSplits"),
1695                Value::Num(1.0),
1696                Value::from("MinParentSize"),
1697                Value::Num(2.0),
1698            ],
1699        ))
1700        .unwrap();
1701        let predicted = block_on(crate::builtins::stats::ml::linear_model::predict_builtin(
1702            model,
1703            table,
1704            Vec::new(),
1705        ))
1706        .unwrap();
1707        let Value::StringArray(labels) = predicted else {
1708            panic!("expected string labels");
1709        };
1710        assert_eq!(labels.shape, vec![4, 1]);
1711        assert_eq!(labels.data[0], "low");
1712        assert_eq!(labels.data[3], "high");
1713    }
1714
1715    #[test]
1716    fn fitctree_respects_class_names_and_depth_controls() {
1717        let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1718        let y = tensor_value(vec![2.0, 2.0, 5.0, 5.0], 4, 1);
1719        let model = object(
1720            block_on(fitctree_builtin(
1721                x,
1722                vec![
1723                    y,
1724                    Value::from("ClassNames"),
1725                    tensor_value(vec![5.0, 2.0], 2, 1),
1726                    Value::from("MaxNumSplits"),
1727                    Value::Num(0.0),
1728                ],
1729            ))
1730            .unwrap(),
1731        );
1732        assert_eq!(model.properties.get("NumSplits"), Some(&Value::Num(0.0)));
1733        let classes = tensor(model.properties.get("ClassNames").unwrap());
1734        assert_eq!(classes.data, vec![5.0, 2.0]);
1735    }
1736
1737    #[test]
1738    fn fitctree_omits_nan_responses_and_requires_two_observed_classes() {
1739        let model = object(
1740            block_on(fitctree_builtin(
1741                tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1742                vec![
1743                    tensor_value(vec![0.0, f64::NAN, 1.0, 1.0], 4, 1),
1744                    Value::from("MinParentSize"),
1745                    Value::Num(2.0),
1746                ],
1747            ))
1748            .unwrap(),
1749        );
1750        assert_eq!(
1751            model.properties.get("NumObservations"),
1752            Some(&Value::Num(3.0))
1753        );
1754        assert_eq!(
1755            tensor(model.properties.get("ClassNames").unwrap()).data,
1756            vec![0.0, 1.0]
1757        );
1758
1759        let err = block_on(fitctree_builtin(
1760            tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1761            vec![
1762                tensor_value(vec![0.0, f64::NAN, f64::NAN, 0.0], 4, 1),
1763                Value::from("ClassNames"),
1764                tensor_value(vec![0.0, 1.0], 2, 1),
1765            ],
1766        ))
1767        .unwrap_err();
1768        assert!(err.message().contains("at least two observed classes"));
1769    }
1770
1771    #[test]
1772    fn fitctree_excludes_table_weight_variable_from_default_predictors() {
1773        let table = crate::builtins::table::table_from_columns(
1774            vec!["A".into(), "W".into(), "Y".into()],
1775            vec![
1776                tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1777                tensor_value(vec![1.0, 2.0, 1.0, 2.0], 4, 1),
1778                tensor_value(vec![0.0, 0.0, 1.0, 1.0], 4, 1),
1779            ],
1780        )
1781        .unwrap();
1782        let model = object(
1783            block_on(fitctree_builtin(
1784                table,
1785                vec![Value::from("Y"), Value::from("Weights"), Value::from("W")],
1786            ))
1787            .unwrap(),
1788        );
1789        assert_eq!(
1790            model.properties.get("NumPredictors"),
1791            Some(&Value::Num(1.0))
1792        );
1793        let Value::StringArray(names) = model.properties.get("PredictorNames").unwrap() else {
1794            panic!("expected predictor names");
1795        };
1796        assert_eq!(names.data, vec!["A"]);
1797    }
1798
1799    #[test]
1800    fn fitctree_predict_rejects_too_many_outputs() {
1801        let model = block_on(fitctree_builtin(
1802            tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1),
1803            vec![
1804                tensor_value(vec![0.0, 0.0, 1.0, 1.0], 4, 1),
1805                Value::from("MaxNumSplits"),
1806                Value::Num(1.0),
1807                Value::from("MinParentSize"),
1808                Value::Num(2.0),
1809            ],
1810        ))
1811        .unwrap();
1812        let output_count = crate::output_count::push_output_count(Some(5));
1813        let err = block_on(crate::builtins::stats::ml::linear_model::predict_builtin(
1814            model,
1815            tensor_value(vec![0.5, 2.5], 2, 1),
1816            Vec::new(),
1817        ))
1818        .unwrap_err();
1819        drop(output_count);
1820        assert!(err.message().contains("too many output"));
1821    }
1822
1823    #[test]
1824    fn fitctree_rejects_unsupported_cross_validation_options() {
1825        let x = tensor_value(vec![0.0, 1.0, 2.0, 3.0], 4, 1);
1826        let y = tensor_value(vec![0.0, 0.0, 1.0, 1.0], 4, 1);
1827        let err = block_on(fitctree_builtin(
1828            x,
1829            vec![y, Value::from("CrossVal"), Value::String("on".into())],
1830        ))
1831        .unwrap_err();
1832        assert!(err.message.contains("not supported"));
1833    }
1834}