Skip to main content

runmat_runtime/builtins/stats/ml/
classification_linear.rs

1//! Linear classification model 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_LINEAR_CLASS: &str = "ClassificationLinear";
20
21const FITCLINEAR_NAME: &str = "fitclinear";
22const PREDICT_NAME: &str = "predict";
23const DEFAULT_ITERATIONS: usize = 500;
24const EPS: f64 = 1.0e-12;
25
26const OUTPUT_MDL: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
27    name: "Mdl",
28    ty: BuiltinParamType::Any,
29    arity: BuiltinParamArity::Required,
30    default: None,
31    description:
32        "ClassificationLinear object containing coefficients, class names, and fit metadata.",
33}];
34
35const OUTPUT_MDL_FITINFO: [BuiltinParamDescriptor; 2] = [
36    BuiltinParamDescriptor {
37        name: "Mdl",
38        ty: BuiltinParamType::Any,
39        arity: BuiltinParamArity::Required,
40        default: None,
41        description: "ClassificationLinear object.",
42    },
43    BuiltinParamDescriptor {
44        name: "FitInfo",
45        ty: BuiltinParamType::Any,
46        arity: BuiltinParamArity::Required,
47        default: None,
48        description: "Training diagnostics for each Lambda value.",
49    },
50];
51
52const PARAM_TBL_OR_X: BuiltinParamDescriptor = BuiltinParamDescriptor {
53    name: "tblOrX",
54    ty: BuiltinParamType::Any,
55    arity: BuiltinParamArity::Required,
56    default: None,
57    description: "Input table or predictor matrix.",
58};
59
60const PARAM_Y_OR_RESPONSE: BuiltinParamDescriptor = BuiltinParamDescriptor {
61    name: "yOrResponse",
62    ty: BuiltinParamType::Any,
63    arity: BuiltinParamArity::Optional,
64    default: None,
65    description: "Response vector, response variable name, or table formula.",
66};
67
68const PARAM_REST: BuiltinParamDescriptor = BuiltinParamDescriptor {
69    name: "options",
70    ty: BuiltinParamType::Any,
71    arity: BuiltinParamArity::Variadic,
72    default: None,
73    description: "Name-value options such as Learner, Lambda, Regularization, Solver, ObservationsIn, FitBias, ClassNames, PredictorNames, ResponseName, Weights, Prior, and ScoreTransform.",
74};
75
76const FITCLINEAR_INPUTS_X_Y: [BuiltinParamDescriptor; 2] = [PARAM_TBL_OR_X, PARAM_Y_OR_RESPONSE];
77const FITCLINEAR_INPUTS_FULL: [BuiltinParamDescriptor; 3] =
78    [PARAM_TBL_OR_X, PARAM_Y_OR_RESPONSE, PARAM_REST];
79
80const FITCLINEAR_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
81    BuiltinSignatureDescriptor {
82        label: "Mdl = fitclinear(X, Y)",
83        inputs: &FITCLINEAR_INPUTS_X_Y,
84        outputs: &OUTPUT_MDL,
85    },
86    BuiltinSignatureDescriptor {
87        label: "Mdl = fitclinear(Tbl, ResponseVarName)",
88        inputs: &FITCLINEAR_INPUTS_X_Y,
89        outputs: &OUTPUT_MDL,
90    },
91    BuiltinSignatureDescriptor {
92        label: "Mdl = fitclinear(Tbl, formula)",
93        inputs: &FITCLINEAR_INPUTS_X_Y,
94        outputs: &OUTPUT_MDL,
95    },
96    BuiltinSignatureDescriptor {
97        label: "Mdl = fitclinear(Tbl, Y)",
98        inputs: &FITCLINEAR_INPUTS_X_Y,
99        outputs: &OUTPUT_MDL,
100    },
101    BuiltinSignatureDescriptor {
102        label: "Mdl = fitclinear(___, Name, Value)",
103        inputs: &FITCLINEAR_INPUTS_FULL,
104        outputs: &OUTPUT_MDL,
105    },
106    BuiltinSignatureDescriptor {
107        label: "[Mdl, FitInfo] = fitclinear(___)",
108        inputs: &FITCLINEAR_INPUTS_FULL,
109        outputs: &OUTPUT_MDL_FITINFO,
110    },
111];
112
113const ERROR_FITCLINEAR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
114    code: "RM.FITCLINEAR.INVALID_ARGUMENT",
115    identifier: Some("RunMat:fitclinear:InvalidArgument"),
116    when: "Inputs, binary response labels, dimensions, or name-value options are malformed or unsupported.",
117    message: "fitclinear: invalid argument",
118};
119
120const ERROR_FITCLINEAR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
121    code: "RM.FITCLINEAR.INTERNAL",
122    identifier: Some("RunMat:fitclinear:Internal"),
123    when: "RunMat cannot construct the ClassificationLinear result.",
124    message: "fitclinear: internal error",
125};
126
127const ERROR_PREDICT_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
128    code: "RM.PREDICT.INVALID_ARGUMENT",
129    identifier: Some("RunMat:predict:InvalidArgument"),
130    when: "Linear-classifier prediction inputs are malformed or incompatible with the model.",
131    message: "predict: invalid argument",
132};
133
134const ERROR_PREDICT_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
135    code: "RM.PREDICT.INTERNAL",
136    identifier: Some("RunMat:predict:Internal"),
137    when: "Linear-classifier prediction cannot construct its output.",
138    message: "predict: internal error",
139};
140
141const FITCLINEAR_ERRORS: [BuiltinErrorDescriptor; 2] =
142    [ERROR_FITCLINEAR_INVALID_ARGUMENT, ERROR_FITCLINEAR_INTERNAL];
143
144pub const FITCLINEAR_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
145    signatures: &FITCLINEAR_SIGNATURES,
146    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
147    completion_policy: BuiltinCompletionPolicy::Public,
148    errors: &FITCLINEAR_ERRORS,
149};
150
151fn fitclinear_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
152    Type::Unknown
153}
154
155fn fitclinear_error(
156    message: impl Into<String>,
157    descriptor: &'static BuiltinErrorDescriptor,
158) -> RuntimeError {
159    let mut builder = build_runtime_error(message).with_builtin(FITCLINEAR_NAME);
160    if let Some(identifier) = descriptor.identifier {
161        builder = builder.with_identifier(identifier);
162    }
163    builder.build()
164}
165
166fn fitclinear_invalid(message: impl Into<String>) -> RuntimeError {
167    fitclinear_error(message, &ERROR_FITCLINEAR_INVALID_ARGUMENT)
168}
169
170fn fitclinear_internal(message: impl Into<String>) -> RuntimeError {
171    fitclinear_error(message, &ERROR_FITCLINEAR_INTERNAL)
172}
173
174fn predict_invalid(message: impl Into<String>) -> RuntimeError {
175    predict_error(message, &ERROR_PREDICT_INVALID_ARGUMENT)
176}
177
178fn predict_internal(message: impl Into<String>) -> RuntimeError {
179    predict_error(message, &ERROR_PREDICT_INTERNAL)
180}
181
182fn predict_error(
183    message: impl Into<String>,
184    descriptor: &'static BuiltinErrorDescriptor,
185) -> RuntimeError {
186    let mut builder = build_runtime_error(message).with_builtin(PREDICT_NAME);
187    if let Some(identifier) = descriptor.identifier {
188        builder = builder.with_identifier(identifier);
189    }
190    builder.build()
191}
192
193#[derive(Clone, Copy, Debug, Eq, PartialEq)]
194enum Learner {
195    Svm,
196    Logistic,
197}
198
199impl Learner {
200    fn as_str(self) -> &'static str {
201        match self {
202            Learner::Svm => "svm",
203            Learner::Logistic => "logistic",
204        }
205    }
206}
207
208#[derive(Clone, Copy, Debug, Eq, PartialEq)]
209enum Regularization {
210    Ridge,
211    Lasso,
212}
213
214impl Regularization {
215    fn as_str(self) -> &'static str {
216        match self {
217            Regularization::Ridge => "ridge",
218            Regularization::Lasso => "lasso",
219        }
220    }
221}
222
223#[derive(Clone, Copy, Debug, Eq, PartialEq)]
224enum ObservationsIn {
225    Rows,
226    Columns,
227}
228
229impl ObservationsIn {
230    fn as_str(self) -> &'static str {
231        match self {
232            ObservationsIn::Rows => "rows",
233            ObservationsIn::Columns => "columns",
234        }
235    }
236}
237
238#[derive(Clone, Copy, Debug, Eq, PartialEq)]
239enum ScoreTransform {
240    None,
241    Logit,
242}
243
244impl ScoreTransform {
245    fn as_str(self) -> &'static str {
246        match self {
247            ScoreTransform::None => "none",
248            ScoreTransform::Logit => "logit",
249        }
250    }
251}
252
253#[derive(Clone, Debug, PartialEq)]
254enum ClassLabel {
255    Numeric(f64),
256    Text(String),
257    Logical(bool),
258}
259
260#[derive(Clone, Copy, Debug, Eq, PartialEq)]
261enum LabelKind {
262    Numeric,
263    Text,
264    Logical,
265}
266
267#[derive(Clone, Debug)]
268struct LabelVector {
269    labels: Vec<ClassLabel>,
270    kind: LabelKind,
271}
272
273#[derive(Clone, Debug)]
274enum WeightSpec {
275    Values(Vec<f64>),
276    Variable(String),
277}
278
279#[derive(Clone, Debug)]
280struct FitOptions {
281    learner: Learner,
282    lambdas: Option<Vec<f64>>,
283    regularization: Regularization,
284    solver: String,
285    observations_in: ObservationsIn,
286    fit_bias: bool,
287    class_names: Option<Vec<ClassLabel>>,
288    predictor_names: Option<Vec<String>>,
289    response_name: Option<String>,
290    weights: Option<WeightSpec>,
291    prior: Option<Vec<f64>>,
292    score_transform: Option<ScoreTransform>,
293    iteration_limit: usize,
294    beta0: Option<Vec<f64>>,
295    bias0: Option<f64>,
296}
297
298impl Default for FitOptions {
299    fn default() -> Self {
300        Self {
301            learner: Learner::Svm,
302            lambdas: None,
303            regularization: Regularization::Ridge,
304            solver: "auto".to_string(),
305            observations_in: ObservationsIn::Rows,
306            fit_bias: true,
307            class_names: None,
308            predictor_names: None,
309            response_name: None,
310            weights: None,
311            prior: None,
312            score_transform: None,
313            iteration_limit: DEFAULT_ITERATIONS,
314            beta0: None,
315            bias0: None,
316        }
317    }
318}
319
320#[derive(Clone, Debug)]
321struct FitSpec {
322    x: Tensor,
323    class_indices: Vec<usize>,
324    class_names: Vec<ClassLabel>,
325    class_kind: LabelKind,
326    predictor_names: Vec<String>,
327    response_name: String,
328    weights: Vec<f64>,
329    priors: Vec<f64>,
330    options: FitOptions,
331}
332
333#[derive(Clone, Debug)]
334struct TrainedModel {
335    beta: Vec<f64>,
336    bias: f64,
337    objective: f64,
338    iterations: usize,
339    gradient_norm: f64,
340}
341
342#[derive(Clone, Debug)]
343struct FitResult {
344    object: ObjectInstance,
345    fit_info: Value,
346}
347
348#[runtime_builtin(
349    name = "fitclinear",
350    category = "stats/ml",
351    summary = "Fit a binary linear classification model.",
352    keywords = "fitclinear,classification linear,svm,logistic regression,machine learning,statistics",
353    type_resolver(fitclinear_type),
354    descriptor(crate::builtins::stats::ml::classification_linear::FITCLINEAR_DESCRIPTOR),
355    builtin_path = "crate::builtins::stats::ml::classification_linear"
356)]
357pub(crate) async fn fitclinear_builtin(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
358    let first = gather(first).await?;
359    let rest = gather_all(rest).await?;
360    let spec = build_fit_spec(first, rest)?;
361    let result = fit_models(&spec)?;
362    match crate::output_count::current_output_count() {
363        Some(0) => Ok(Value::OutputList(Vec::new())),
364        Some(1) => Ok(Value::OutputList(vec![Value::Object(result.object)])),
365        Some(out_count) => Ok(crate::output_count::output_list_with_padding(
366            out_count,
367            vec![Value::Object(result.object), result.fit_info],
368        )),
369        None => Ok(Value::Object(result.object)),
370    }
371}
372
373async fn gather(value: Value) -> BuiltinResult<Value> {
374    gather_if_needed_async(&value)
375        .await
376        .map_err(|err| fitclinear_invalid(format!("fitclinear: {err}")))
377}
378
379async fn gather_all(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
380    let mut out = Vec::with_capacity(values.len());
381    for value in values {
382        out.push(gather(value).await?);
383    }
384    Ok(out)
385}
386
387fn build_fit_spec(first: Value, mut rest: Vec<Value>) -> BuiltinResult<FitSpec> {
388    if is_table_value(&first) {
389        build_table_fit_spec(first, rest)
390    } else {
391        if rest.is_empty() {
392            return Err(fitclinear_invalid(
393                "fitclinear: matrix input requires response vector Y",
394            ));
395        }
396        let y = rest.remove(0);
397        build_matrix_fit_spec(first, y, rest)
398    }
399}
400
401fn is_table_value(value: &Value) -> bool {
402    matches!(value, Value::Object(object) if is_tabular_object(object))
403}
404
405fn build_matrix_fit_spec(
406    x_value: Value,
407    y_value: Value,
408    rest: Vec<Value>,
409) -> BuiltinResult<FitSpec> {
410    let mut options = FitOptions::default();
411    parse_fit_options(&rest, &mut options)?;
412    if let Some(WeightSpec::Variable(_)) = options.weights {
413        return Err(fitclinear_invalid(
414            "fitclinear: weights variable names require table input",
415        ));
416    }
417    let x = normalize_observation_matrix(numeric_matrix_for_fit(x_value)?, &options)?;
418    let labels = label_vector(y_value, "Y")?;
419    if labels.labels.len() != x.rows {
420        return Err(fitclinear_invalid(
421            "fitclinear: length(Y) must match the number of observations in X",
422        ));
423    }
424    let predictor_names = if let Some(names) = options.predictor_names.clone() {
425        if names.len() != x.cols {
426            return Err(fitclinear_invalid(format!(
427                "fitclinear: PredictorNames must contain {} names",
428                x.cols
429            )));
430        }
431        names
432    } else {
433        (1..=x.cols).map(|idx| format!("x{idx}")).collect()
434    };
435    let response_name = options
436        .response_name
437        .clone()
438        .unwrap_or_else(|| "Y".to_string());
439    let weights = match &options.weights {
440        Some(WeightSpec::Values(values)) => values.clone(),
441        Some(WeightSpec::Variable(_)) => unreachable!("checked above"),
442        None => vec![1.0; x.rows],
443    };
444    finalize_fit_spec(x, labels, predictor_names, response_name, weights, options)
445}
446
447fn build_table_fit_spec(first: Value, rest: Vec<Value>) -> BuiltinResult<FitSpec> {
448    let object = match first {
449        Value::Object(object) => object,
450        _ => unreachable!("table checked"),
451    };
452    let variable_names = table_variable_names_from_object(&object)
453        .map_err(|err| fitclinear_invalid(format!("fitclinear: {err}")))?;
454    if variable_names.len() < 2 {
455        return Err(fitclinear_invalid(
456            "fitclinear: table input must contain at least one predictor and one response",
457        ));
458    }
459    let variables =
460        table_variables(&object).map_err(|err| fitclinear_invalid(format!("fitclinear: {err}")))?;
461    let mut options = FitOptions::default();
462    let mut rest_start = 0usize;
463    let mut response_name = variable_names
464        .last()
465        .cloned()
466        .ok_or_else(|| fitclinear_invalid("fitclinear: response variable missing"))?;
467    let mut external_y = None;
468    let mut formula_predictors = None;
469    if let Some(first_arg) = rest.first() {
470        if !is_name_value_option(first_arg) {
471            if let Some(text) = scalar_text(first_arg) {
472                if text.contains('~') {
473                    let parsed = parse_formula(&text, &variable_names)?;
474                    response_name = parsed.0;
475                    formula_predictors = Some(parsed.1);
476                } else {
477                    response_name = text;
478                }
479            } else {
480                external_y = Some(label_vector(first_arg.clone(), "Y")?);
481                response_name = options
482                    .response_name
483                    .clone()
484                    .unwrap_or_else(|| "Y".to_string());
485            }
486            rest_start = 1;
487        }
488    }
489    parse_fit_options(&rest[rest_start..], &mut options)?;
490    if options.observations_in == ObservationsIn::Columns {
491        return Err(fitclinear_invalid(
492            "fitclinear: ObservationsIn='columns' is not supported for table input",
493        ));
494    }
495    response_name = options.response_name.clone().unwrap_or(response_name);
496    let has_external_y = external_y.is_some();
497    let labels = if let Some(labels) = external_y {
498        labels
499    } else {
500        let value = variables.fields.get(&response_name).ok_or_else(|| {
501            fitclinear_invalid(format!(
502                "fitclinear: response variable '{response_name}' is not in the table"
503            ))
504        })?;
505        label_vector(value.clone(), &response_name)?
506    };
507    let predictor_names = if let Some(names) = options.predictor_names.clone() {
508        names
509    } else if let Some(names) = formula_predictors {
510        names
511    } else {
512        let weight_variable = match &options.weights {
513            Some(WeightSpec::Variable(name)) => Some(name.as_str()),
514            _ => None,
515        };
516        variable_names
517            .iter()
518            .filter(|name| {
519                (has_external_y || **name != response_name)
520                    && weight_variable != Some(name.as_str())
521            })
522            .cloned()
523            .collect()
524    };
525    if predictor_names.is_empty() {
526        return Err(fitclinear_invalid(
527            "fitclinear: at least one predictor variable is required",
528        ));
529    }
530    let mut columns = Vec::with_capacity(predictor_names.len());
531    for name in &predictor_names {
532        let value = variables.fields.get(name).ok_or_else(|| {
533            fitclinear_invalid(format!(
534                "fitclinear: predictor variable '{name}' is not in the table"
535            ))
536        })?;
537        let tensor = numeric_matrix_for_fit(value.clone()).map_err(|_| {
538            fitclinear_invalid(format!(
539                "fitclinear: predictor variable '{name}' must be numeric"
540            ))
541        })?;
542        columns.push(vector_values(&tensor, name)?);
543    }
544    let x = columns_to_tensor(columns)?;
545    let weights = match &options.weights {
546        Some(WeightSpec::Values(values)) => values.clone(),
547        Some(WeightSpec::Variable(name)) => {
548            let value = variables.fields.get(name).ok_or_else(|| {
549                fitclinear_invalid(format!(
550                    "fitclinear: weights variable '{name}' is not in the table"
551                ))
552            })?;
553            numeric_vector(value, "Weights")?
554        }
555        None => vec![1.0; x.rows],
556    };
557    finalize_fit_spec(x, labels, predictor_names, response_name, weights, options)
558}
559
560fn finalize_fit_spec(
561    x: Tensor,
562    labels: LabelVector,
563    predictor_names: Vec<String>,
564    response_name: String,
565    weights: Vec<f64>,
566    mut options: FitOptions,
567) -> BuiltinResult<FitSpec> {
568    if labels.labels.len() != x.rows {
569        return Err(fitclinear_invalid(
570            "fitclinear: response length must match the number of observations",
571        ));
572    }
573    if weights.len() != x.rows {
574        return Err(fitclinear_invalid(
575            "fitclinear: Weights length must match the number of observations",
576        ));
577    }
578    if predictor_names.len() != x.cols {
579        return Err(fitclinear_invalid(format!(
580            "fitclinear: PredictorNames must contain {} names",
581            x.cols
582        )));
583    }
584    for weight in &weights {
585        if !weight.is_finite() || *weight < 0.0 {
586            return Err(fitclinear_invalid(
587                "fitclinear: weights must be nonnegative finite values",
588            ));
589        }
590    }
591    let class_names = if let Some(names) = options.class_names.clone() {
592        validate_class_names(names, labels.kind)?
593    } else {
594        unique_labels(&labels.labels, labels.kind)
595    };
596    if class_names.len() != 2 {
597        return Err(fitclinear_invalid(
598            "fitclinear: exactly two classes are required",
599        ));
600    }
601    let mut clean_rows = Vec::new();
602    let mut clean_classes = Vec::new();
603    let mut clean_weights = Vec::new();
604    for row in 0..x.rows {
605        let weight = weights[row];
606        if weight == 0.0 || is_missing_label(&labels.labels[row]) {
607            continue;
608        }
609        let class_index = class_names
610            .iter()
611            .position(|label| same_label(label, &labels.labels[row]))
612            .ok_or_else(|| {
613                fitclinear_invalid("fitclinear: response contains a class outside ClassNames")
614            })?;
615        let mut row_values = Vec::with_capacity(x.cols);
616        let mut complete = true;
617        for col in 0..x.cols {
618            let value = x_value(&x, row, col);
619            if value.is_infinite() {
620                return Err(fitclinear_invalid(
621                    "fitclinear: predictor values must not contain Inf",
622                ));
623            }
624            if value.is_nan() {
625                complete = false;
626                break;
627            }
628            row_values.push(value);
629        }
630        if complete {
631            clean_rows.push(row_values);
632            clean_classes.push(class_index);
633            clean_weights.push(weight);
634        }
635    }
636    if clean_classes.is_empty() {
637        return Err(fitclinear_invalid(
638            "fitclinear: at least one complete weighted observation is required",
639        ));
640    }
641    if !(clean_classes.contains(&0) && clean_classes.contains(&1)) {
642        return Err(fitclinear_invalid(
643            "fitclinear: both classes must be observed after removing missing rows",
644        ));
645    }
646    let clean_x = rows_to_tensor(clean_rows)?;
647    let lambdas = options
648        .lambdas
649        .clone()
650        .unwrap_or_else(|| vec![1.0 / clean_x.rows.max(1) as f64]);
651    if lambdas.is_empty() {
652        return Err(fitclinear_invalid(
653            "fitclinear: Lambda must contain at least one value",
654        ));
655    }
656    for lambda in &lambdas {
657        if !lambda.is_finite() || *lambda < 0.0 {
658            return Err(fitclinear_invalid(
659                "fitclinear: Lambda values must be nonnegative finite scalars",
660            ));
661        }
662    }
663    options.lambdas = Some(lambdas);
664    if let Some(beta0) = &options.beta0 {
665        if beta0.len() != clean_x.cols {
666            return Err(fitclinear_invalid(format!(
667                "fitclinear: Beta must contain {} coefficients",
668                clean_x.cols
669            )));
670        }
671    }
672    let priors = class_priors(&clean_classes, &clean_weights, options.prior.clone())?;
673    Ok(FitSpec {
674        x: clean_x,
675        class_indices: clean_classes,
676        class_names,
677        class_kind: labels.kind,
678        predictor_names,
679        response_name,
680        weights: clean_weights,
681        priors,
682        options,
683    })
684}
685
686fn class_priors(
687    class_indices: &[usize],
688    weights: &[f64],
689    explicit: Option<Vec<f64>>,
690) -> BuiltinResult<Vec<f64>> {
691    if let Some(prior) = explicit {
692        if prior.len() != 2 {
693            return Err(fitclinear_invalid(
694                "fitclinear: Prior must contain two class probabilities",
695            ));
696        }
697        if prior.iter().any(|value| !value.is_finite() || *value < 0.0) {
698            return Err(fitclinear_invalid(
699                "fitclinear: Prior values must be nonnegative finite values",
700            ));
701        }
702        let sum = prior.iter().sum::<f64>();
703        if sum <= 0.0 {
704            return Err(fitclinear_invalid(
705                "fitclinear: Prior values must have positive total",
706            ));
707        }
708        return Ok(prior.into_iter().map(|value| value / sum).collect());
709    }
710    let mut counts = [0.0, 0.0];
711    for (idx, weight) in class_indices.iter().zip(weights.iter()) {
712        counts[*idx] += *weight;
713    }
714    let total = counts[0] + counts[1];
715    Ok(vec![counts[0] / total, counts[1] / total])
716}
717
718fn parse_fit_options(values: &[Value], options: &mut FitOptions) -> BuiltinResult<()> {
719    if !values.len().is_multiple_of(2) {
720        return Err(fitclinear_invalid(
721            "fitclinear: name-value options must be paired",
722        ));
723    }
724    let mut index = 0usize;
725    while index < values.len() {
726        let name = scalar_text(&values[index])
727            .ok_or_else(|| fitclinear_invalid("fitclinear: option name must be text"))?;
728        let value = &values[index + 1];
729        match name.to_ascii_lowercase().as_str() {
730            "learner" => {
731                let text = scalar_text(value)
732                    .ok_or_else(|| fitclinear_invalid("fitclinear: Learner must be text"))?;
733                options.learner = match text.to_ascii_lowercase().as_str() {
734                    "svm" => Learner::Svm,
735                    "logistic" => Learner::Logistic,
736                    other => {
737                        return Err(fitclinear_invalid(format!(
738                            "fitclinear: unsupported Learner '{other}'"
739                        )));
740                    }
741                };
742            }
743            "lambda" => options.lambdas = Some(numeric_vector(value, "Lambda")?),
744            "regularization" => {
745                let text = scalar_text(value)
746                    .ok_or_else(|| fitclinear_invalid("fitclinear: Regularization must be text"))?;
747                options.regularization = match text.to_ascii_lowercase().as_str() {
748                    "ridge" => Regularization::Ridge,
749                    "lasso" => Regularization::Lasso,
750                    other => {
751                        return Err(fitclinear_invalid(format!(
752                            "fitclinear: unsupported Regularization '{other}'"
753                        )));
754                    }
755                };
756            }
757            "solver" => {
758                let text = scalar_text(value)
759                    .ok_or_else(|| fitclinear_invalid("fitclinear: Solver must be text"))?;
760                match text.to_ascii_lowercase().as_str() {
761                    "auto" | "sgd" | "asgd" | "bfgs" | "lbfgs" | "sparsa" | "dual" => {
762                        options.solver = text.to_ascii_lowercase();
763                    }
764                    other => {
765                        return Err(fitclinear_invalid(format!(
766                            "fitclinear: unsupported Solver '{other}'"
767                        )));
768                    }
769                }
770            }
771            "observationsin" => {
772                let text = scalar_text(value)
773                    .ok_or_else(|| fitclinear_invalid("fitclinear: ObservationsIn must be text"))?;
774                options.observations_in = match text.to_ascii_lowercase().as_str() {
775                    "rows" => ObservationsIn::Rows,
776                    "columns" => ObservationsIn::Columns,
777                    other => {
778                        return Err(fitclinear_invalid(format!(
779                            "fitclinear: unsupported ObservationsIn '{other}'"
780                        )));
781                    }
782                };
783            }
784            "fitbias" => options.fit_bias = logical_scalar(value, "FitBias")?,
785            "classnames" => {
786                let labels = label_vector(value.clone(), "ClassNames")?;
787                options.class_names = Some(labels.labels);
788            }
789            "predictornames" => {
790                options.predictor_names = Some(text_list(value, "PredictorNames")?);
791            }
792            "responsename" => {
793                options.response_name =
794                    Some(scalar_text(value).ok_or_else(|| {
795                        fitclinear_invalid("fitclinear: ResponseName must be text")
796                    })?);
797            }
798            "weights" => {
799                options.weights = Some(if let Some(name) = scalar_text(value) {
800                    WeightSpec::Variable(name)
801                } else {
802                    WeightSpec::Values(numeric_vector(value, "Weights")?)
803                });
804            }
805            "prior" => {
806                if let Some(text) = scalar_text(value) {
807                    match text.to_ascii_lowercase().as_str() {
808                        "empirical" => options.prior = None,
809                        "uniform" => options.prior = Some(vec![0.5, 0.5]),
810                        other => {
811                            return Err(fitclinear_invalid(format!(
812                                "fitclinear: unsupported Prior '{other}'"
813                            )));
814                        }
815                    }
816                } else {
817                    options.prior = Some(numeric_vector(value, "Prior")?);
818                }
819            }
820            "scoretransform" => {
821                let text = scalar_text(value)
822                    .ok_or_else(|| fitclinear_invalid("fitclinear: ScoreTransform must be text"))?;
823                options.score_transform = Some(match text.to_ascii_lowercase().as_str() {
824                    "none" => ScoreTransform::None,
825                    "logit" => ScoreTransform::Logit,
826                    other => {
827                        return Err(fitclinear_invalid(format!(
828                            "fitclinear: unsupported ScoreTransform '{other}'"
829                        )));
830                    }
831                });
832            }
833            "iterationlimit" | "passlimit" => {
834                options.iteration_limit = positive_integer(value, &name)?;
835            }
836            "beta" => options.beta0 = Some(numeric_vector(value, "Beta")?),
837            "bias" => options.bias0 = Some(numeric_scalar(value, "Bias")?),
838            "batchlimit"
839            | "betatolerance"
840            | "deltagradienttolerance"
841            | "gradienttolerance"
842            | "numcheckconvergence"
843            | "postfitbias"
844            | "verbose" => {
845                // Accepted for script compatibility. The CPU solver below is deterministic
846                // batch gradient descent and records convergence diagnostics in FitInfo.
847            }
848            "cost"
849            | "crossval"
850            | "cvpartition"
851            | "holdout"
852            | "kfold"
853            | "leaveout"
854            | "optimizehyperparameters"
855            | "hyperparameteroptimizationoptions" => {
856                return Err(fitclinear_invalid(format!(
857                    "fitclinear: option '{name}' is not supported yet"
858                )));
859            }
860            other => {
861                return Err(fitclinear_invalid(format!(
862                    "fitclinear: unknown option '{other}'"
863                )))
864            }
865        }
866        index += 2;
867    }
868    Ok(())
869}
870
871fn parse_formula(text: &str, variables: &[String]) -> BuiltinResult<(String, Vec<String>)> {
872    let Some((lhs, rhs)) = text.split_once('~') else {
873        return Err(fitclinear_invalid("fitclinear: formula must contain '~'"));
874    };
875    let response = lhs.trim().to_string();
876    if response.is_empty() {
877        return Err(fitclinear_invalid("fitclinear: formula response is empty"));
878    }
879    let mut predictors = Vec::new();
880    for term in rhs.split('+') {
881        let term = term.trim();
882        if term.is_empty() || term == "1" {
883            continue;
884        }
885        if term.contains('*') || term.contains(':') || term.contains('^') {
886            return Err(fitclinear_invalid(
887                "fitclinear: formula interactions are not supported yet",
888            ));
889        }
890        if !variables.iter().any(|name| name == term) {
891            return Err(fitclinear_invalid(format!(
892                "fitclinear: formula predictor '{term}' is not in the table"
893            )));
894        }
895        predictors.push(term.to_string());
896    }
897    if predictors.is_empty() {
898        return Err(fitclinear_invalid(
899            "fitclinear: formula must contain at least one predictor",
900        ));
901    }
902    Ok((response, predictors))
903}
904
905fn fit_models(spec: &FitSpec) -> BuiltinResult<FitResult> {
906    let lambdas = spec
907        .options
908        .lambdas
909        .as_ref()
910        .expect("lambdas populated during finalize");
911    let mut models = Vec::with_capacity(lambdas.len());
912    for lambda in lambdas {
913        models.push(train_for_lambda(spec, *lambda)?);
914    }
915    let beta_value = beta_value(&models, spec.x.cols)?;
916    let bias_value = row_tensor(models.iter().map(|model| model.bias).collect())?;
917    let mut object = ObjectInstance::new(CLASSIFICATION_LINEAR_CLASS.to_string());
918    object
919        .properties
920        .insert("Beta".to_string(), beta_value.clone());
921    object
922        .properties
923        .insert("Bias".to_string(), bias_value.clone());
924    object
925        .properties
926        .insert("Lambda".to_string(), row_tensor(lambdas.clone())?);
927    object.properties.insert(
928        "Learner".to_string(),
929        Value::String(spec.options.learner.as_str().to_string()),
930    );
931    object.properties.insert(
932        "Regularization".to_string(),
933        Value::String(spec.options.regularization.as_str().to_string()),
934    );
935    object.properties.insert(
936        "Solver".to_string(),
937        Value::String(spec.options.solver.clone()),
938    );
939    object.properties.insert(
940        "ScoreTransform".to_string(),
941        Value::String(score_transform(spec).as_str().to_string()),
942    );
943    object
944        .properties
945        .insert("FitBias".to_string(), Value::Bool(spec.options.fit_bias));
946    object.properties.insert(
947        "ResponseName".to_string(),
948        Value::String(spec.response_name.clone()),
949    );
950    object.properties.insert(
951        "PredictorNames".to_string(),
952        Value::StringArray(StringArray {
953            data: spec.predictor_names.clone(),
954            shape: vec![1, spec.predictor_names.len()],
955            rows: 1,
956            cols: spec.predictor_names.len(),
957        }),
958    );
959    object.properties.insert(
960        "ClassNames".to_string(),
961        labels_value(&spec.class_names, spec.class_kind)?,
962    );
963    object
964        .properties
965        .insert("Prior".to_string(), row_tensor(spec.priors.clone())?);
966    object.properties.insert(
967        "NumObservations".to_string(),
968        Value::Num(spec.x.rows as f64),
969    );
970    object
971        .properties
972        .insert("NumPredictors".to_string(), Value::Num(spec.x.cols as f64));
973    object.properties.insert(
974        "__RunMatLinearClassKind".to_string(),
975        Value::String(label_kind_name(spec.class_kind).to_string()),
976    );
977    let mut parameters = StructValue::new();
978    parameters.insert(
979        "Learner",
980        Value::String(spec.options.learner.as_str().to_string()),
981    );
982    parameters.insert(
983        "Regularization",
984        Value::String(spec.options.regularization.as_str().to_string()),
985    );
986    parameters.insert("Solver", Value::String(spec.options.solver.clone()));
987    parameters.insert(
988        "ObservationsIn",
989        Value::String(spec.options.observations_in.as_str().to_string()),
990    );
991    parameters.insert("FitBias", Value::Bool(spec.options.fit_bias));
992    parameters.insert(
993        "IterationLimit",
994        Value::Num(spec.options.iteration_limit as f64),
995    );
996    object
997        .properties
998        .insert("ModelParameters".to_string(), Value::Struct(parameters));
999    let fit_info = fit_info_value(spec, lambdas, &models)?;
1000    Ok(FitResult { object, fit_info })
1001}
1002
1003fn train_for_lambda(spec: &FitSpec, lambda: f64) -> BuiltinResult<TrainedModel> {
1004    let predictors = spec.x.cols;
1005    let mut beta = spec
1006        .options
1007        .beta0
1008        .clone()
1009        .unwrap_or_else(|| vec![0.0; predictors]);
1010    let mut bias = spec.options.bias0.unwrap_or_else(|| {
1011        if spec.options.fit_bias {
1012            (spec.priors[1] / spec.priors[0]).ln() / 2.0
1013        } else {
1014            0.0
1015        }
1016    });
1017    let total_weight = spec.weights.iter().sum::<f64>().max(EPS);
1018    let base_step = if spec.options.learner == Learner::Logistic {
1019        0.25
1020    } else {
1021        0.15
1022    };
1023    let mut last_grad_norm = f64::INFINITY;
1024    for iter in 0..spec.options.iteration_limit.max(1) {
1025        let mut grad_beta = vec![0.0; predictors];
1026        let mut grad_bias = 0.0;
1027        for row in 0..spec.x.rows {
1028            let y = if spec.class_indices[row] == 1 {
1029                1.0
1030            } else {
1031                -1.0
1032            };
1033            let margin = dot_row(&spec.x, row, &beta) + bias;
1034            let coeff = match spec.options.learner {
1035                Learner::Logistic => {
1036                    let ym = (y * margin).clamp(-40.0, 40.0);
1037                    -y / (1.0 + ym.exp())
1038                }
1039                Learner::Svm => {
1040                    if y * margin < 1.0 {
1041                        -y
1042                    } else {
1043                        0.0
1044                    }
1045                }
1046            } * spec.weights[row]
1047                / total_weight;
1048            if coeff != 0.0 {
1049                for (col, grad) in grad_beta.iter_mut().enumerate() {
1050                    *grad += coeff * x_value(&spec.x, row, col);
1051                }
1052                if spec.options.fit_bias {
1053                    grad_bias += coeff;
1054                }
1055            }
1056        }
1057        match spec.options.regularization {
1058            Regularization::Ridge => {
1059                for (grad, coef) in grad_beta.iter_mut().zip(beta.iter()) {
1060                    *grad += lambda * *coef;
1061                }
1062            }
1063            Regularization::Lasso => {}
1064        }
1065        last_grad_norm = grad_beta
1066            .iter()
1067            .map(|value| value * value)
1068            .sum::<f64>()
1069            .sqrt()
1070            .hypot(grad_bias.abs());
1071        let step = base_step / (1.0 + lambda + 0.01 * iter as f64).sqrt();
1072        for (coef, grad) in beta.iter_mut().zip(grad_beta.iter()) {
1073            *coef -= step * *grad;
1074            if spec.options.regularization == Regularization::Lasso && lambda > 0.0 {
1075                *coef = soft_threshold(*coef, step * lambda);
1076            }
1077        }
1078        if spec.options.fit_bias {
1079            bias -= step * grad_bias;
1080        } else {
1081            bias = 0.0;
1082        }
1083        if last_grad_norm < 1.0e-7 {
1084            break;
1085        }
1086    }
1087    Ok(TrainedModel {
1088        objective: objective(spec, &beta, bias, lambda),
1089        beta,
1090        bias,
1091        iterations: spec.options.iteration_limit.max(1),
1092        gradient_norm: last_grad_norm,
1093    })
1094}
1095
1096fn objective(spec: &FitSpec, beta: &[f64], bias: f64, lambda: f64) -> f64 {
1097    let total_weight = spec.weights.iter().sum::<f64>().max(EPS);
1098    let mut loss = 0.0;
1099    for row in 0..spec.x.rows {
1100        let y = if spec.class_indices[row] == 1 {
1101            1.0
1102        } else {
1103            -1.0
1104        };
1105        let margin = dot_row(&spec.x, row, beta) + bias;
1106        let row_loss = match spec.options.learner {
1107            Learner::Logistic => (1.0 + (-y * margin).clamp(-40.0, 40.0).exp()).ln(),
1108            Learner::Svm => (1.0 - y * margin).max(0.0),
1109        };
1110        loss += spec.weights[row] * row_loss / total_weight;
1111    }
1112    let penalty = match spec.options.regularization {
1113        Regularization::Ridge => 0.5 * lambda * beta.iter().map(|value| value * value).sum::<f64>(),
1114        Regularization::Lasso => lambda * beta.iter().map(|value| value.abs()).sum::<f64>(),
1115    };
1116    loss + penalty
1117}
1118
1119fn fit_info_value(
1120    spec: &FitSpec,
1121    lambdas: &[f64],
1122    models: &[TrainedModel],
1123) -> BuiltinResult<Value> {
1124    let mut info = StructValue::new();
1125    info.insert("Lambda", row_tensor(lambdas.to_vec())?);
1126    info.insert(
1127        "Objective",
1128        row_tensor(models.iter().map(|model| model.objective).collect())?,
1129    );
1130    info.insert(
1131        "NumIterations",
1132        row_tensor(models.iter().map(|model| model.iterations as f64).collect())?,
1133    );
1134    info.insert(
1135        "NumPasses",
1136        row_tensor(models.iter().map(|model| model.iterations as f64).collect())?,
1137    );
1138    info.insert(
1139        "GradientNorm",
1140        row_tensor(models.iter().map(|model| model.gradient_norm).collect())?,
1141    );
1142    info.insert("PassLimit", Value::Num(spec.options.iteration_limit as f64));
1143    info.insert("Solver", Value::String(spec.options.solver.clone()));
1144    info.insert(
1145        "Learner",
1146        Value::String(spec.options.learner.as_str().to_string()),
1147    );
1148    info.insert(
1149        "Regularization",
1150        Value::String(spec.options.regularization.as_str().to_string()),
1151    );
1152    info.insert("TerminationCode", row_tensor(vec![1.0; models.len()])?);
1153    info.insert(
1154        "TerminationStatus",
1155        Value::String("Iteration limit reached or gradient tolerance satisfied".to_string()),
1156    );
1157    Ok(Value::Struct(info))
1158}
1159
1160pub(crate) fn predict_classification_linear_object(
1161    object: ObjectInstance,
1162    xnew: Value,
1163    rest: Vec<Value>,
1164) -> BuiltinResult<Vec<Value>> {
1165    let options = parse_predict_options(&rest)?;
1166    let predictor_names = string_array_property(&object, "PredictorNames")?;
1167    let x = predictors_for_prediction(xnew, &predictor_names, options.observations_in)?;
1168    let beta = tensor_property(&object, "Beta")?;
1169    if beta.shape.len() > 2 || beta.rows != predictor_names.len() {
1170        return Err(predict_invalid("predict: model Beta has invalid shape"));
1171    }
1172    let bias = numeric_vector_property(&object, "Bias")?;
1173    if bias.len() != beta.cols {
1174        return Err(predict_invalid(
1175            "predict: model Bias length does not match Beta",
1176        ));
1177    }
1178    let class_names = labels_from_value(
1179        object
1180            .properties
1181            .get("ClassNames")
1182            .cloned()
1183            .ok_or_else(|| predict_invalid("predict: model is missing ClassNames"))?,
1184        "ClassNames",
1185    )?;
1186    if class_names.labels.len() != 2 {
1187        return Err(predict_invalid(
1188            "predict: model must contain two class names",
1189        ));
1190    }
1191    let score_transform = score_transform_property(&object)?;
1192    let mut label_indices = Vec::with_capacity(x.rows * beta.cols);
1193    let mut score_data = Vec::with_capacity(x.rows * 2 * beta.cols);
1194    for model_idx in 0..beta.cols {
1195        let model_beta = (0..beta.rows)
1196            .map(|row| beta.data[row + model_idx * beta.rows])
1197            .collect::<Vec<_>>();
1198        for row in 0..x.rows {
1199            let margin = dot_row(&x, row, &model_beta) + bias[model_idx];
1200            label_indices.push(if margin >= 0.0 { 1 } else { 0 });
1201        }
1202        for class_col in 0..2 {
1203            for row in 0..x.rows {
1204                let margin = dot_row(&x, row, &model_beta) + bias[model_idx];
1205                let value = match score_transform {
1206                    ScoreTransform::Logit => {
1207                        let p = sigmoid(margin);
1208                        if class_col == 0 {
1209                            1.0 - p
1210                        } else {
1211                            p
1212                        }
1213                    }
1214                    ScoreTransform::None => {
1215                        if class_col == 0 {
1216                            -margin
1217                        } else {
1218                            margin
1219                        }
1220                    }
1221                };
1222                score_data.push(value);
1223            }
1224        }
1225    }
1226    let label = predicted_labels_value(
1227        &class_names.labels,
1228        class_names.kind,
1229        &label_indices,
1230        x.rows,
1231        beta.cols,
1232    )?;
1233    let score_shape = if beta.cols == 1 {
1234        vec![x.rows, 2]
1235    } else {
1236        vec![x.rows, 2, beta.cols]
1237    };
1238    let score = Value::Tensor(
1239        Tensor::new(score_data, score_shape)
1240            .map_err(|err| predict_internal(format!("predict: {err}")))?,
1241    );
1242    Ok(vec![label, score])
1243}
1244
1245#[derive(Clone, Copy, Debug)]
1246struct PredictOptions {
1247    observations_in: ObservationsIn,
1248}
1249
1250fn parse_predict_options(values: &[Value]) -> BuiltinResult<PredictOptions> {
1251    if !values.len().is_multiple_of(2) {
1252        return Err(predict_invalid(
1253            "predict: name-value options must be paired",
1254        ));
1255    }
1256    let mut options = PredictOptions {
1257        observations_in: ObservationsIn::Rows,
1258    };
1259    let mut index = 0usize;
1260    while index < values.len() {
1261        let name = scalar_text(&values[index])
1262            .ok_or_else(|| predict_invalid("predict: option name must be text"))?;
1263        let value = &values[index + 1];
1264        match name.to_ascii_lowercase().as_str() {
1265            "observationsin" => {
1266                let text = scalar_text(value)
1267                    .ok_or_else(|| predict_invalid("predict: ObservationsIn must be text"))?;
1268                options.observations_in = match text.to_ascii_lowercase().as_str() {
1269                    "rows" => ObservationsIn::Rows,
1270                    "columns" => ObservationsIn::Columns,
1271                    other => {
1272                        return Err(predict_invalid(format!(
1273                            "predict: unsupported ObservationsIn '{other}'"
1274                        )));
1275                    }
1276                };
1277            }
1278            other => {
1279                return Err(predict_invalid(format!(
1280                    "predict: unknown option '{other}'"
1281                )));
1282            }
1283        }
1284        index += 2;
1285    }
1286    Ok(options)
1287}
1288
1289fn predictors_for_prediction(
1290    value: Value,
1291    predictor_names: &[String],
1292    observations_in: ObservationsIn,
1293) -> BuiltinResult<Tensor> {
1294    if let Value::Object(object) = &value {
1295        if is_tabular_object(object) {
1296            if observations_in == ObservationsIn::Columns {
1297                return Err(predict_invalid(
1298                    "predict: ObservationsIn='columns' is not supported for table input",
1299                ));
1300            }
1301            let variables = table_variables(object)
1302                .map_err(|err| predict_invalid(format!("predict: {err}")))?;
1303            let mut columns = Vec::with_capacity(predictor_names.len());
1304            for name in predictor_names {
1305                let raw = variables.fields.get(name).ok_or_else(|| {
1306                    predict_invalid(format!("predict: missing predictor '{name}'"))
1307                })?;
1308                let tensor = numeric_matrix_for_predict(raw.clone()).map_err(|_| {
1309                    predict_invalid(format!("predict: predictor '{name}' must be numeric"))
1310                })?;
1311                columns.push(vector_values_predict(&tensor, name)?);
1312            }
1313            return columns_to_tensor_predict(columns);
1314        }
1315    }
1316    let tensor = normalize_prediction_matrix(numeric_matrix_for_predict(value)?, observations_in)?;
1317    if tensor.shape.len() > 2 || tensor.cols != predictor_names.len() {
1318        return Err(predict_invalid(format!(
1319            "predict: X must have {} predictor columns",
1320            predictor_names.len()
1321        )));
1322    }
1323    Ok(tensor)
1324}
1325
1326fn numeric_matrix_for_fit(value: Value) -> BuiltinResult<Tensor> {
1327    match value {
1328        Value::SparseTensor(sparse) => sparse
1329            .to_dense()
1330            .map_err(|err| fitclinear_invalid(format!("fitclinear: {err}"))),
1331        other => tensor::value_into_tensor_for(FITCLINEAR_NAME, other)
1332            .map_err(|err| fitclinear_invalid(format!("fitclinear: {err}"))),
1333    }
1334}
1335
1336fn numeric_matrix_for_predict(value: Value) -> BuiltinResult<Tensor> {
1337    match value {
1338        Value::SparseTensor(sparse) => sparse
1339            .to_dense()
1340            .map_err(|err| predict_invalid(format!("predict: {err}"))),
1341        other => tensor::value_into_tensor_for(PREDICT_NAME, other)
1342            .map_err(|err| predict_invalid(format!("predict: {err}"))),
1343    }
1344}
1345
1346fn normalize_observation_matrix(x: Tensor, options: &FitOptions) -> BuiltinResult<Tensor> {
1347    normalize_prediction_matrix(x, options.observations_in)
1348        .map_err(|err| fitclinear_invalid(err.message))
1349}
1350
1351fn normalize_prediction_matrix(
1352    x: Tensor,
1353    observations_in: ObservationsIn,
1354) -> BuiltinResult<Tensor> {
1355    if x.shape.len() > 2 {
1356        return Err(predict_invalid("predict: X must be a 2-D numeric matrix"));
1357    }
1358    match observations_in {
1359        ObservationsIn::Rows => Ok(x),
1360        ObservationsIn::Columns => {
1361            transpose_tensor(&x).map_err(|err| predict_internal(format!("predict: {err}")))
1362        }
1363    }
1364}
1365
1366fn transpose_tensor(x: &Tensor) -> Result<Tensor, String> {
1367    let mut data = vec![0.0; x.rows * x.cols];
1368    for row in 0..x.rows {
1369        for col in 0..x.cols {
1370            data[col + row * x.cols] = x_value(x, row, col);
1371        }
1372    }
1373    Tensor::new(data, vec![x.cols, x.rows])
1374}
1375
1376fn label_vector(value: Value, name: &str) -> BuiltinResult<LabelVector> {
1377    labels_from_value(value, name).map_err(|err| fitclinear_invalid(err.message))
1378}
1379
1380fn labels_from_value(value: Value, name: &str) -> BuiltinResult<LabelVector> {
1381    match value {
1382        Value::Tensor(tensor) => Ok(LabelVector {
1383            labels: vector_values_predict(&tensor, name)?
1384                .into_iter()
1385                .map(ClassLabel::Numeric)
1386                .collect(),
1387            kind: LabelKind::Numeric,
1388        }),
1389        Value::Num(value) => Ok(LabelVector {
1390            labels: vec![ClassLabel::Numeric(value)],
1391            kind: LabelKind::Numeric,
1392        }),
1393        Value::String(text) => Ok(LabelVector {
1394            labels: vec![ClassLabel::Text(text)],
1395            kind: LabelKind::Text,
1396        }),
1397        Value::CharArray(array) => Ok(LabelVector {
1398            labels: char_rows(&array).into_iter().map(ClassLabel::Text).collect(),
1399            kind: LabelKind::Text,
1400        }),
1401        Value::StringArray(array) => Ok(LabelVector {
1402            labels: array.data.into_iter().map(ClassLabel::Text).collect(),
1403            kind: LabelKind::Text,
1404        }),
1405        Value::Cell(cell) => {
1406            let mut labels = Vec::with_capacity(cell.data.len());
1407            for item in cell.data {
1408                labels.push(ClassLabel::Text(scalar_text(&item).ok_or_else(|| {
1409                    predict_invalid(format!("predict: {name} cell entries must be text"))
1410                })?));
1411            }
1412            Ok(LabelVector {
1413                labels,
1414                kind: LabelKind::Text,
1415            })
1416        }
1417        Value::Bool(value) => Ok(LabelVector {
1418            labels: vec![ClassLabel::Logical(value)],
1419            kind: LabelKind::Logical,
1420        }),
1421        Value::LogicalArray(array) => Ok(LabelVector {
1422            labels: array
1423                .data
1424                .into_iter()
1425                .map(|value| ClassLabel::Logical(value != 0))
1426                .collect(),
1427            kind: LabelKind::Logical,
1428        }),
1429        other => Err(predict_invalid(format!(
1430            "predict: {name} must be numeric, logical, string, character, or cellstr labels; got {other:?}"
1431        ))),
1432    }
1433}
1434
1435fn validate_class_names(names: Vec<ClassLabel>, kind: LabelKind) -> BuiltinResult<Vec<ClassLabel>> {
1436    if names.len() != 2 {
1437        return Err(fitclinear_invalid(
1438            "fitclinear: ClassNames must contain exactly two values",
1439        ));
1440    }
1441    let mut out = Vec::new();
1442    for name in names {
1443        if is_missing_label(&name) {
1444            return Err(fitclinear_invalid(
1445                "fitclinear: ClassNames must not contain missing values",
1446            ));
1447        }
1448        if label_kind(&name) != kind {
1449            return Err(fitclinear_invalid(
1450                "fitclinear: ClassNames must have the same type as the response",
1451            ));
1452        }
1453        if out.iter().any(|existing| same_label(existing, &name)) {
1454            return Err(fitclinear_invalid(
1455                "fitclinear: ClassNames must contain unique values",
1456            ));
1457        }
1458        out.push(name);
1459    }
1460    Ok(out)
1461}
1462
1463fn unique_labels(labels: &[ClassLabel], kind: LabelKind) -> Vec<ClassLabel> {
1464    let mut out = Vec::new();
1465    for label in labels {
1466        if is_missing_label(label) {
1467            continue;
1468        }
1469        if !out.iter().any(|existing| same_label(existing, label)) {
1470            out.push(label.clone());
1471        }
1472    }
1473    match kind {
1474        LabelKind::Numeric => out.sort_by(|left, right| match (left, right) {
1475            (ClassLabel::Numeric(a), ClassLabel::Numeric(b)) => {
1476                a.partial_cmp(b).unwrap_or(Ordering::Equal)
1477            }
1478            _ => Ordering::Equal,
1479        }),
1480        LabelKind::Text => out.sort_by(|left, right| match (left, right) {
1481            (ClassLabel::Text(a), ClassLabel::Text(b)) => a.cmp(b),
1482            _ => Ordering::Equal,
1483        }),
1484        LabelKind::Logical => out.sort_by_key(|label| match label {
1485            ClassLabel::Logical(value) => *value,
1486            _ => false,
1487        }),
1488    }
1489    out
1490}
1491
1492fn label_kind(label: &ClassLabel) -> LabelKind {
1493    match label {
1494        ClassLabel::Numeric(_) => LabelKind::Numeric,
1495        ClassLabel::Text(_) => LabelKind::Text,
1496        ClassLabel::Logical(_) => LabelKind::Logical,
1497    }
1498}
1499
1500fn label_kind_name(kind: LabelKind) -> &'static str {
1501    match kind {
1502        LabelKind::Numeric => "numeric",
1503        LabelKind::Text => "text",
1504        LabelKind::Logical => "logical",
1505    }
1506}
1507
1508fn same_label(left: &ClassLabel, right: &ClassLabel) -> bool {
1509    match (left, right) {
1510        (ClassLabel::Numeric(a), ClassLabel::Numeric(b)) => (a == b) || (a.is_nan() && b.is_nan()),
1511        (ClassLabel::Text(a), ClassLabel::Text(b)) => a == b,
1512        (ClassLabel::Logical(a), ClassLabel::Logical(b)) => a == b,
1513        _ => false,
1514    }
1515}
1516
1517fn is_missing_label(label: &ClassLabel) -> bool {
1518    matches!(label, ClassLabel::Numeric(value) if value.is_nan())
1519}
1520
1521fn labels_value(labels: &[ClassLabel], kind: LabelKind) -> BuiltinResult<Value> {
1522    predicted_labels_value(
1523        labels,
1524        kind,
1525        &(0..labels.len()).collect::<Vec<_>>(),
1526        labels.len(),
1527        1,
1528    )
1529}
1530
1531fn predicted_labels_value(
1532    labels: &[ClassLabel],
1533    kind: LabelKind,
1534    indices: &[usize],
1535    rows: usize,
1536    cols: usize,
1537) -> BuiltinResult<Value> {
1538    let shape = vec![rows, cols];
1539    match kind {
1540        LabelKind::Numeric => Ok(Value::Tensor(
1541            Tensor::new(
1542                indices
1543                    .iter()
1544                    .map(|idx| match labels.get(*idx) {
1545                        Some(ClassLabel::Numeric(value)) => *value,
1546                        _ => f64::NAN,
1547                    })
1548                    .collect(),
1549                shape,
1550            )
1551            .map_err(|err| predict_internal(format!("predict: {err}")))?,
1552        )),
1553        LabelKind::Text => Ok(Value::StringArray(StringArray {
1554            data: indices
1555                .iter()
1556                .map(|idx| match labels.get(*idx) {
1557                    Some(ClassLabel::Text(value)) => value.clone(),
1558                    _ => String::new(),
1559                })
1560                .collect(),
1561            shape,
1562            rows,
1563            cols,
1564        })),
1565        LabelKind::Logical => Ok(Value::LogicalArray(
1566            LogicalArray::new(
1567                indices
1568                    .iter()
1569                    .map(|idx| match labels.get(*idx) {
1570                        Some(ClassLabel::Logical(value)) if *value => 1,
1571                        _ => 0,
1572                    })
1573                    .collect(),
1574                shape,
1575            )
1576            .map_err(|err| predict_internal(format!("predict: {err}")))?,
1577        )),
1578    }
1579}
1580
1581fn score_transform(spec: &FitSpec) -> ScoreTransform {
1582    spec.options
1583        .score_transform
1584        .unwrap_or(match spec.options.learner {
1585            Learner::Logistic => ScoreTransform::Logit,
1586            Learner::Svm => ScoreTransform::None,
1587        })
1588}
1589
1590fn score_transform_property(object: &ObjectInstance) -> BuiltinResult<ScoreTransform> {
1591    match object.properties.get("ScoreTransform") {
1592        Some(Value::String(text)) if text.eq_ignore_ascii_case("none") => Ok(ScoreTransform::None),
1593        Some(Value::String(text)) if text.eq_ignore_ascii_case("logit") => {
1594            Ok(ScoreTransform::Logit)
1595        }
1596        _ => Err(predict_invalid(
1597            "predict: model has unsupported ScoreTransform",
1598        )),
1599    }
1600}
1601
1602fn string_array_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<String>> {
1603    match object.properties.get(name) {
1604        Some(Value::StringArray(array)) => Ok(array.data.clone()),
1605        Some(Value::String(text)) => Ok(vec![text.clone()]),
1606        _ => Err(predict_invalid(format!("predict: model is missing {name}"))),
1607    }
1608}
1609
1610fn tensor_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Tensor> {
1611    match object.properties.get(name) {
1612        Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
1613        Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
1614            .map_err(|err| predict_internal(format!("predict: {err}"))),
1615        _ => Err(predict_invalid(format!("predict: model is missing {name}"))),
1616    }
1617}
1618
1619fn numeric_vector_property(object: &ObjectInstance, name: &str) -> BuiltinResult<Vec<f64>> {
1620    match object.properties.get(name) {
1621        Some(Value::Tensor(tensor)) => vector_values_predict(tensor, name),
1622        Some(Value::Num(value)) => Ok(vec![*value]),
1623        _ => Err(predict_invalid(format!("predict: model is missing {name}"))),
1624    }
1625}
1626
1627fn scalar_text(value: &Value) -> Option<String> {
1628    match value {
1629        Value::String(text) => Some(text.clone()),
1630        Value::CharArray(array) if array.rows == 1 => Some(array.data.iter().collect()),
1631        Value::StringArray(array) if array.data.len() == 1 => Some(array.data[0].clone()),
1632        _ => None,
1633    }
1634}
1635
1636fn text_list(value: &Value, name: &str) -> BuiltinResult<Vec<String>> {
1637    match value {
1638        Value::String(text) => Ok(vec![text.clone()]),
1639        Value::CharArray(array) => Ok(char_rows(array)),
1640        Value::StringArray(array) => Ok(array.data.clone()),
1641        Value::Cell(cell) => cell
1642            .data
1643            .iter()
1644            .map(|value| {
1645                scalar_text(value).ok_or_else(|| {
1646                    fitclinear_invalid(format!("fitclinear: {name} entries must be text"))
1647                })
1648            })
1649            .collect(),
1650        _ => Err(fitclinear_invalid(format!(
1651            "fitclinear: {name} must be text or a text collection"
1652        ))),
1653    }
1654}
1655
1656fn char_rows(array: &CharArray) -> Vec<String> {
1657    let mut out = Vec::with_capacity(array.rows);
1658    for row in 0..array.rows {
1659        let start = row * array.cols;
1660        out.push(array.data[start..start + array.cols].iter().collect());
1661    }
1662    out
1663}
1664
1665fn numeric_vector(value: &Value, name: &str) -> BuiltinResult<Vec<f64>> {
1666    match value {
1667        Value::Num(value) => Ok(vec![*value]),
1668        Value::Tensor(tensor) => vector_values(tensor, name),
1669        Value::LogicalArray(array) => Ok(array
1670            .data
1671            .iter()
1672            .map(|value| if *value == 0 { 0.0 } else { 1.0 })
1673            .collect()),
1674        Value::Bool(value) => Ok(vec![if *value { 1.0 } else { 0.0 }]),
1675        _ => Err(fitclinear_invalid(format!(
1676            "fitclinear: {name} must be numeric"
1677        ))),
1678    }
1679}
1680
1681fn vector_values(tensor: &Tensor, name: &str) -> BuiltinResult<Vec<f64>> {
1682    vector_values_predict(tensor, name).map_err(|err| fitclinear_invalid(err.message))
1683}
1684
1685fn vector_values_predict(tensor: &Tensor, name: &str) -> BuiltinResult<Vec<f64>> {
1686    if tensor.shape.len() > 2 || (tensor.rows != 1 && tensor.cols != 1) {
1687        return Err(predict_invalid(format!("predict: {name} must be a vector")));
1688    }
1689    Ok(tensor.data.clone())
1690}
1691
1692fn numeric_scalar(value: &Value, name: &str) -> BuiltinResult<f64> {
1693    match value {
1694        Value::Num(value) => Ok(*value),
1695        Value::Tensor(tensor) if tensor.data.len() == 1 => Ok(tensor.data[0]),
1696        Value::Bool(value) => Ok(if *value { 1.0 } else { 0.0 }),
1697        _ => Err(fitclinear_invalid(format!(
1698            "fitclinear: {name} must be a numeric scalar"
1699        ))),
1700    }
1701}
1702
1703fn logical_scalar(value: &Value, name: &str) -> BuiltinResult<bool> {
1704    match value {
1705        Value::Bool(value) => Ok(*value),
1706        Value::LogicalArray(array) if array.data.len() == 1 => Ok(array.data[0] != 0),
1707        Value::Num(value) if *value == 0.0 || *value == 1.0 => Ok(*value != 0.0),
1708        Value::Tensor(tensor)
1709            if tensor.data.len() == 1 && (tensor.data[0] == 0.0 || tensor.data[0] == 1.0) =>
1710        {
1711            Ok(tensor.data[0] != 0.0)
1712        }
1713        _ => Err(fitclinear_invalid(format!(
1714            "fitclinear: {name} must be logical scalar"
1715        ))),
1716    }
1717}
1718
1719fn positive_integer(value: &Value, name: &str) -> BuiltinResult<usize> {
1720    let value = numeric_scalar(value, name)?;
1721    if !value.is_finite() || value < 1.0 || value.fract() != 0.0 {
1722        return Err(fitclinear_invalid(format!(
1723            "fitclinear: {name} must be a positive integer"
1724        )));
1725    }
1726    Ok(value as usize)
1727}
1728
1729fn is_name_value_option(value: &Value) -> bool {
1730    scalar_text(value).is_some_and(|text| {
1731        matches!(
1732            text.to_ascii_lowercase().as_str(),
1733            "batchlimit"
1734                | "beta"
1735                | "betatolerance"
1736                | "bias"
1737                | "classnames"
1738                | "cost"
1739                | "crossval"
1740                | "cvpartition"
1741                | "deltagradienttolerance"
1742                | "fitbias"
1743                | "gradienttolerance"
1744                | "holdout"
1745                | "hyperparameteroptimizationoptions"
1746                | "iterationlimit"
1747                | "kfold"
1748                | "lambda"
1749                | "learner"
1750                | "leaveout"
1751                | "numcheckconvergence"
1752                | "observationsin"
1753                | "optimizehyperparameters"
1754                | "passlimit"
1755                | "postfitbias"
1756                | "predictornames"
1757                | "prior"
1758                | "regularization"
1759                | "responsename"
1760                | "scoretransform"
1761                | "solver"
1762                | "verbose"
1763                | "weights"
1764        )
1765    })
1766}
1767
1768fn columns_to_tensor(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1769    if columns.is_empty() {
1770        return Err(fitclinear_invalid("fitclinear: no predictor columns"));
1771    }
1772    let rows = columns[0].len();
1773    let cols = columns.len();
1774    let mut data = Vec::with_capacity(rows * cols);
1775    for column in columns {
1776        if column.len() != rows {
1777            return Err(fitclinear_invalid(
1778                "fitclinear: predictor variables must have the same height",
1779            ));
1780        }
1781        data.extend(column);
1782    }
1783    Tensor::new(data, vec![rows, cols])
1784        .map_err(|err| fitclinear_internal(format!("fitclinear: {err}")))
1785}
1786
1787fn columns_to_tensor_predict(columns: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1788    columns_to_tensor(columns).map_err(|err| predict_invalid(err.message))
1789}
1790
1791fn rows_to_tensor(rows: Vec<Vec<f64>>) -> BuiltinResult<Tensor> {
1792    if rows.is_empty() {
1793        return Err(fitclinear_invalid("fitclinear: no complete predictor rows"));
1794    }
1795    let row_count = rows.len();
1796    let col_count = rows[0].len();
1797    let mut data = Vec::with_capacity(row_count * col_count);
1798    for col in 0..col_count {
1799        for row in &rows {
1800            data.push(row[col]);
1801        }
1802    }
1803    Tensor::new(data, vec![row_count, col_count])
1804        .map_err(|err| fitclinear_internal(format!("fitclinear: {err}")))
1805}
1806
1807fn beta_value(models: &[TrainedModel], predictors: usize) -> BuiltinResult<Value> {
1808    let mut data = Vec::with_capacity(predictors * models.len());
1809    for model in models {
1810        data.extend(model.beta.iter().copied());
1811    }
1812    Ok(Value::Tensor(
1813        Tensor::new(data, vec![predictors, models.len()])
1814            .map_err(|err| fitclinear_internal(format!("fitclinear: {err}")))?,
1815    ))
1816}
1817
1818fn row_tensor(values: Vec<f64>) -> BuiltinResult<Value> {
1819    let cols = values.len();
1820    Ok(Value::Tensor(Tensor::new(values, vec![1, cols]).map_err(
1821        |err| fitclinear_internal(format!("fitclinear: {err}")),
1822    )?))
1823}
1824
1825fn x_value(x: &Tensor, row: usize, col: usize) -> f64 {
1826    x.data[row + col * x.rows]
1827}
1828
1829fn dot_row(x: &Tensor, row: usize, beta: &[f64]) -> f64 {
1830    beta.iter()
1831        .enumerate()
1832        .map(|(col, coef)| x_value(x, row, col) * coef)
1833        .sum()
1834}
1835
1836fn soft_threshold(value: f64, threshold: f64) -> f64 {
1837    if value > threshold {
1838        value - threshold
1839    } else if value < -threshold {
1840        value + threshold
1841    } else {
1842        0.0
1843    }
1844}
1845
1846fn sigmoid(value: f64) -> f64 {
1847    if value >= 0.0 {
1848        1.0 / (1.0 + (-value).exp())
1849    } else {
1850        let exp_value = value.exp();
1851        exp_value / (1.0 + exp_value)
1852    }
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858    use crate::builtins::table::table_from_columns;
1859
1860    fn tensor(data: Vec<f64>, shape: Vec<usize>) -> Value {
1861        Value::Tensor(Tensor::new(data, shape).unwrap())
1862    }
1863
1864    #[tokio::test]
1865    async fn fitclinear_svm_predicts_binary_numeric_labels() {
1866        let model = fitclinear_builtin(
1867            tensor(vec![0.0, 1.0, 2.0, 3.0], vec![4, 1]),
1868            vec![
1869                tensor(vec![0.0, 0.0, 1.0, 1.0], vec![4, 1]),
1870                Value::String("Lambda".into()),
1871                Value::Num(0.0),
1872            ],
1873        )
1874        .await
1875        .unwrap();
1876        let outputs = predict_classification_linear_object(
1877            match model {
1878                Value::Object(object) => object,
1879                _ => panic!("expected object"),
1880            },
1881            tensor(vec![0.2, 2.8], vec![2, 1]),
1882            Vec::new(),
1883        )
1884        .unwrap();
1885        match &outputs[0] {
1886            Value::Tensor(labels) => assert_eq!(labels.data, vec![0.0, 1.0]),
1887            other => panic!("expected labels, got {other:?}"),
1888        }
1889        match &outputs[1] {
1890            Value::Tensor(score) => assert_eq!(score.shape, vec![2, 2]),
1891            other => panic!("expected score, got {other:?}"),
1892        }
1893    }
1894
1895    #[tokio::test]
1896    async fn fitclinear_logistic_returns_fitinfo_and_probability_scores() {
1897        let _guard = crate::output_count::push_output_count(Some(2));
1898        let value = fitclinear_builtin(
1899            tensor(vec![0.0, 1.0, 2.0, 3.0], vec![4, 1]),
1900            vec![
1901                tensor(vec![0.0, 0.0, 1.0, 1.0], vec![4, 1]),
1902                Value::String("Learner".into()),
1903                Value::String("logistic".into()),
1904                Value::String("Lambda".into()),
1905                Value::Num(0.0),
1906            ],
1907        )
1908        .await
1909        .unwrap();
1910        let (object, info) = match value {
1911            Value::OutputList(mut outputs) => {
1912                let info = outputs.pop().unwrap();
1913                let object = match outputs.pop().unwrap() {
1914                    Value::Object(object) => object,
1915                    other => panic!("expected object, got {other:?}"),
1916                };
1917                (object, info)
1918            }
1919            other => panic!("expected outputs, got {other:?}"),
1920        };
1921        assert!(matches!(info, Value::Struct(_)));
1922        let outputs = predict_classification_linear_object(
1923            object,
1924            tensor(vec![0.2, 2.8], vec![2, 1]),
1925            Vec::new(),
1926        )
1927        .unwrap();
1928        match &outputs[1] {
1929            Value::Tensor(score) => {
1930                assert_eq!(score.shape, vec![2, 2]);
1931                assert!((score.data[0] + score.data[2] - 1.0).abs() < 1.0e-8);
1932            }
1933            other => panic!("expected score, got {other:?}"),
1934        }
1935    }
1936
1937    #[tokio::test]
1938    async fn fitclinear_observations_in_columns_and_text_classnames() {
1939        let model = fitclinear_builtin(
1940            tensor(vec![0.0, 1.0, 2.0, 3.0], vec![1, 4]),
1941            vec![
1942                Value::StringArray(StringArray {
1943                    data: vec!["low".into(), "low".into(), "high".into(), "high".into()],
1944                    shape: vec![4, 1],
1945                    rows: 4,
1946                    cols: 1,
1947                }),
1948                Value::String("ObservationsIn".into()),
1949                Value::String("columns".into()),
1950                Value::String("ClassNames".into()),
1951                Value::StringArray(StringArray {
1952                    data: vec!["low".into(), "high".into()],
1953                    shape: vec![2, 1],
1954                    rows: 2,
1955                    cols: 1,
1956                }),
1957            ],
1958        )
1959        .await
1960        .unwrap();
1961        let outputs = predict_classification_linear_object(
1962            match model {
1963                Value::Object(object) => object,
1964                _ => panic!("expected object"),
1965            },
1966            tensor(vec![0.2, 2.8], vec![1, 2]),
1967            vec![
1968                Value::String("ObservationsIn".into()),
1969                Value::String("columns".into()),
1970            ],
1971        )
1972        .unwrap();
1973        match &outputs[0] {
1974            Value::StringArray(labels) => assert_eq!(labels.data, vec!["low", "high"]),
1975            other => panic!("expected string labels, got {other:?}"),
1976        }
1977    }
1978
1979    #[tokio::test]
1980    async fn fitclinear_rejects_multiclass_response() {
1981        let err = fitclinear_builtin(
1982            tensor(vec![0.0, 1.0, 2.0], vec![3, 1]),
1983            vec![tensor(vec![0.0, 1.0, 2.0], vec![3, 1])],
1984        )
1985        .await
1986        .unwrap_err();
1987        assert!(err.message.contains("exactly two classes"));
1988    }
1989
1990    #[tokio::test]
1991    async fn fitclinear_table_external_y_honors_response_name() {
1992        let table = table_from_columns(
1993            vec!["A".into(), "B".into()],
1994            vec![
1995                tensor(vec![0.0, 1.0, 2.0, 3.0], vec![4, 1]),
1996                tensor(vec![1.0, 1.0, 1.0, 1.0], vec![4, 1]),
1997            ],
1998        )
1999        .unwrap();
2000        let model = fitclinear_builtin(
2001            table,
2002            vec![
2003                tensor(vec![0.0, 0.0, 1.0, 1.0], vec![4, 1]),
2004                Value::String("ResponseName".into()),
2005                Value::String("Outcome".into()),
2006            ],
2007        )
2008        .await
2009        .unwrap();
2010        match model {
2011            Value::Object(object) => {
2012                assert_eq!(
2013                    object.properties.get("ResponseName"),
2014                    Some(&Value::String("Outcome".into()))
2015                );
2016            }
2017            other => panic!("expected object, got {other:?}"),
2018        }
2019    }
2020}