Skip to main content

runmat_runtime/builtins/stats/ml/
optimizable_variable.rs

1//! MATLAB-compatible `optimizableVariable` metadata constructor.
2
3use std::collections::{BTreeMap, HashSet};
4
5use runmat_builtins::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
7    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
8    CellArray, CharArray, LogicalArray, ObjectInstance, ResolveContext, StringArray, Tensor, Type,
9    Value,
10};
11use runmat_macros::runtime_builtin;
12
13use crate::builtins::common::spec::{
14    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
15    ReductionNaN, ResidencyPolicy, ShapeRequirements,
16};
17use crate::{build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError};
18
19const NAME: &str = "optimizableVariable";
20const CLASS_NAME: &str = "optimizableVariable";
21
22const OUTPUT_VAR: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
23    name: "variable",
24    ty: BuiltinParamType::Any,
25    arity: BuiltinParamArity::Required,
26    default: None,
27    description: "Bayesian optimization variable metadata object.",
28}];
29
30const INPUTS_BASE: [BuiltinParamDescriptor; 2] = [
31    BuiltinParamDescriptor {
32        name: "Name",
33        ty: BuiltinParamType::StringScalar,
34        arity: BuiltinParamArity::Required,
35        default: None,
36        description: "Variable name.",
37    },
38    BuiltinParamDescriptor {
39        name: "Range",
40        ty: BuiltinParamType::Any,
41        arity: BuiltinParamArity::Required,
42        default: None,
43        description: "Numeric two-element bounds or categorical choices.",
44    },
45];
46
47const INPUTS_OPTIONS: [BuiltinParamDescriptor; 3] = [
48    BuiltinParamDescriptor {
49        name: "Name",
50        ty: BuiltinParamType::StringScalar,
51        arity: BuiltinParamArity::Required,
52        default: None,
53        description: "Variable name.",
54    },
55    BuiltinParamDescriptor {
56        name: "Range",
57        ty: BuiltinParamType::Any,
58        arity: BuiltinParamArity::Required,
59        default: None,
60        description: "Numeric two-element bounds or categorical choices.",
61    },
62    BuiltinParamDescriptor {
63        name: "NameValue",
64        ty: BuiltinParamType::Any,
65        arity: BuiltinParamArity::Variadic,
66        default: None,
67        description: "Name-value options Type, Transform, and Optimize.",
68    },
69];
70
71const SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
72    BuiltinSignatureDescriptor {
73        label: "v = optimizableVariable(Name, Range)",
74        inputs: &INPUTS_BASE,
75        outputs: &OUTPUT_VAR,
76    },
77    BuiltinSignatureDescriptor {
78        label: "v = optimizableVariable(Name, Range, Name, Value)",
79        inputs: &INPUTS_OPTIONS,
80        outputs: &OUTPUT_VAR,
81    },
82];
83
84const ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
85    code: "RM.OPTIMIZABLE_VARIABLE.INVALID_ARGUMENT",
86    identifier: Some("RunMat:optimizableVariable:InvalidArgument"),
87    when: "The variable name, range, or name-value arguments are malformed.",
88    message: "optimizableVariable: invalid argument",
89};
90
91const ERROR_INVALID_RANGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
92    code: "RM.OPTIMIZABLE_VARIABLE.INVALID_RANGE",
93    identifier: Some("RunMat:optimizableVariable:InvalidRange"),
94    when: "The supplied range is incompatible with the requested variable type.",
95    message: "optimizableVariable: invalid range",
96};
97
98const ERROR_INVALID_OPTION: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
99    code: "RM.OPTIMIZABLE_VARIABLE.INVALID_OPTION",
100    identifier: Some("RunMat:optimizableVariable:InvalidOption"),
101    when: "A name-value option is unknown or has an unsupported value.",
102    message: "optimizableVariable: invalid option",
103};
104
105const ERROR_FLOW: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
106    code: "RM.OPTIMIZABLE_VARIABLE.FLOW",
107    identifier: Some("RunMat:optimizableVariable:Flow"),
108    when: "Gathering a metadata input fails.",
109    message: "optimizableVariable: flow failure",
110};
111
112const ERRORS: [BuiltinErrorDescriptor; 4] = [
113    ERROR_INVALID_ARGUMENT,
114    ERROR_INVALID_RANGE,
115    ERROR_INVALID_OPTION,
116    ERROR_FLOW,
117];
118
119pub const OPTIMIZABLE_VARIABLE_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
120    signatures: &SIGNATURES,
121    output_mode: BuiltinOutputMode::Fixed,
122    completion_policy: BuiltinCompletionPolicy::Public,
123    errors: &ERRORS,
124};
125
126#[runmat_macros::register_gpu_spec(
127    builtin_path = "crate::builtins::stats::ml::optimizable_variable"
128)]
129pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
130    name: NAME,
131    op_kind: GpuOpKind::Custom("optimizable-variable-metadata"),
132    supported_precisions: &[],
133    broadcast: BroadcastSemantics::None,
134    provider_hooks: &[],
135    constant_strategy: ConstantStrategy::InlineLiteral,
136    residency: ResidencyPolicy::GatherImmediately,
137    nan_mode: ReductionNaN::Include,
138    two_pass_threshold: None,
139    workgroup_size: None,
140    accepts_nan_mode: false,
141    notes: "Host metadata construction for bayesopt; gpuArray range inputs are gathered before validation.",
142};
143
144#[runmat_macros::register_fusion_spec(
145    builtin_path = "crate::builtins::stats::ml::optimizable_variable"
146)]
147pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
148    name: NAME,
149    shape: ShapeRequirements::Any,
150    constant_strategy: ConstantStrategy::InlineLiteral,
151    elementwise: None,
152    reduction: None,
153    emits_nan: false,
154    notes: "optimizableVariable constructs host metadata and terminates fusion planning.",
155};
156
157#[runtime_builtin(
158    name = "optimizableVariable",
159    category = "stats/ml",
160    summary = "Create Bayesian optimization variable metadata.",
161    keywords = "optimizableVariable,bayesopt,optimization,variable,Type,Transform,Optimize",
162    accel = "cpu",
163    type_resolver(optimizable_variable_type),
164    descriptor(crate::builtins::stats::ml::optimizable_variable::OPTIMIZABLE_VARIABLE_DESCRIPTOR),
165    builtin_path = "crate::builtins::stats::ml::optimizable_variable"
166)]
167async fn optimizable_variable_builtin(
168    name: Value,
169    range: Value,
170    rest: Vec<Value>,
171) -> BuiltinResult<Value> {
172    let name = gather_if_needed_async(&name)
173        .await
174        .map_err(|err| remap_flow(err, "name"))?;
175    let range = gather_if_needed_async(&range)
176        .await
177        .map_err(|err| remap_flow(err, "range"))?;
178    let mut gathered = Vec::with_capacity(rest.len());
179    for value in rest {
180        gathered.push(
181            gather_if_needed_async(&value)
182                .await
183                .map_err(|err| remap_flow(err, "name-value argument"))?,
184        );
185    }
186
187    let variable_name = parse_text_scalar(&name, "Name", &ERROR_INVALID_ARGUMENT)?;
188    if variable_name.trim().is_empty() {
189        return Err(error(
190            "optimizableVariable: Name must be a nonempty text scalar",
191            &ERROR_INVALID_ARGUMENT,
192        ));
193    }
194    let options = Options::parse(gathered)?;
195    let range = OptimizableRange::parse(range, options.var_type)?;
196    let var_type = options.var_type.unwrap_or(range.inferred_type());
197    range.validate_for(var_type, options.transform)?;
198
199    let mut object = ObjectInstance::new(CLASS_NAME.to_string());
200    object
201        .properties
202        .insert("Name".to_string(), Value::String(variable_name));
203    object
204        .properties
205        .insert("Range".to_string(), range.value().clone());
206    object.properties.insert(
207        "Type".to_string(),
208        Value::String(var_type.as_str().to_string()),
209    );
210    object.properties.insert(
211        "Transform".to_string(),
212        Value::String(options.transform.as_str().to_string()),
213    );
214    object
215        .properties
216        .insert("Optimize".to_string(), Value::Bool(options.optimize));
217    Ok(Value::Object(object))
218}
219
220fn optimizable_variable_type(_args: &[Type], _ctx: &ResolveContext) -> Type {
221    Type::Unknown
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq)]
225enum VariableType {
226    Real,
227    Integer,
228    Categorical,
229}
230
231impl VariableType {
232    fn parse(value: &Value) -> BuiltinResult<Self> {
233        match parse_text_scalar(value, "Type", &ERROR_INVALID_OPTION)?
234            .trim()
235            .to_ascii_lowercase()
236            .as_str()
237        {
238            "real" => Ok(Self::Real),
239            "integer" => Ok(Self::Integer),
240            "categorical" => Ok(Self::Categorical),
241            other => Err(error(
242                format!("optimizableVariable: unsupported Type '{other}'"),
243                &ERROR_INVALID_OPTION,
244            )),
245        }
246    }
247
248    fn as_str(self) -> &'static str {
249        match self {
250            Self::Real => "real",
251            Self::Integer => "integer",
252            Self::Categorical => "categorical",
253        }
254    }
255}
256
257#[derive(Debug, Clone, Copy, PartialEq, Eq)]
258enum Transform {
259    None,
260    Log,
261}
262
263impl Transform {
264    fn parse(value: &Value) -> BuiltinResult<Self> {
265        match parse_text_scalar(value, "Transform", &ERROR_INVALID_OPTION)?
266            .trim()
267            .to_ascii_lowercase()
268            .as_str()
269        {
270            "none" => Ok(Self::None),
271            "log" => Ok(Self::Log),
272            other => Err(error(
273                format!("optimizableVariable: unsupported Transform '{other}'"),
274                &ERROR_INVALID_OPTION,
275            )),
276        }
277    }
278
279    fn as_str(self) -> &'static str {
280        match self {
281            Self::None => "none",
282            Self::Log => "log",
283        }
284    }
285}
286
287#[derive(Debug)]
288struct Options {
289    var_type: Option<VariableType>,
290    transform: Transform,
291    optimize: bool,
292}
293
294impl Options {
295    fn parse(args: Vec<Value>) -> BuiltinResult<Self> {
296        if !args.len().is_multiple_of(2) {
297            return Err(error(
298                "optimizableVariable: name-value options must be paired",
299                &ERROR_INVALID_ARGUMENT,
300            ));
301        }
302        let mut options = Self {
303            var_type: None,
304            transform: Transform::None,
305            optimize: true,
306        };
307        let mut pairs = BTreeMap::new();
308        for pair in args.chunks_exact(2) {
309            let name = parse_text_scalar(&pair[0], "option name", &ERROR_INVALID_OPTION)?
310                .trim()
311                .to_ascii_lowercase();
312            pairs.insert(name, pair[1].clone());
313        }
314        for (name, value) in pairs {
315            match name.as_str() {
316                "type" => options.var_type = Some(VariableType::parse(&value)?),
317                "transform" => options.transform = Transform::parse(&value)?,
318                "optimize" => options.optimize = parse_bool(&value, "Optimize")?,
319                other => {
320                    return Err(error(
321                        format!("optimizableVariable: unknown option '{other}'"),
322                        &ERROR_INVALID_OPTION,
323                    ));
324                }
325            }
326        }
327        Ok(options)
328    }
329}
330
331#[derive(Debug, Clone)]
332enum OptimizableRange {
333    Numeric {
334        value: Value,
335        lower: f64,
336        upper: f64,
337    },
338    Categorical {
339        value: Value,
340        categories: Vec<String>,
341    },
342}
343
344impl OptimizableRange {
345    fn parse(value: Value, explicit_type: Option<VariableType>) -> BuiltinResult<Self> {
346        match value {
347            Value::Tensor(tensor) => {
348                Self::numeric_from_values(Value::Tensor(tensor.clone()), &tensor.data)
349            }
350            Value::Num(n) => Self::numeric_from_values(
351                Value::Tensor(Tensor::new(vec![n], vec![1, 1]).unwrap()),
352                &[n],
353            ),
354            Value::Int(i) => {
355                let n = i.to_f64();
356                Self::numeric_from_values(
357                    Value::Tensor(Tensor::new(vec![n], vec![1, 1]).unwrap()),
358                    &[n],
359                )
360            }
361            Value::StringArray(array) => {
362                let categories = text_categories_from_string_array(&array)?;
363                Ok(Self::Categorical {
364                    value: Value::StringArray(array),
365                    categories,
366                })
367            }
368            Value::String(text) if explicit_type == Some(VariableType::Categorical) => {
369                check_categories(std::slice::from_ref(&text))?;
370                Ok(Self::Categorical {
371                    value: Value::String(text.clone()),
372                    categories: vec![text],
373                })
374            }
375            Value::Cell(cell) => {
376                let categories = text_categories_from_cell(&cell)?;
377                Ok(Self::Categorical {
378                    value: Value::Cell(cell),
379                    categories,
380                })
381            }
382            Value::CharArray(chars) if explicit_type == Some(VariableType::Categorical) => {
383                let category = char_row_to_string(&chars, "Range", &ERROR_INVALID_RANGE)?;
384                check_categories(std::slice::from_ref(&category))?;
385                let value = Value::Cell(
386                    CellArray::new(vec![Value::String(category.clone())], 1, 1)
387                        .map_err(|err| error(err, &ERROR_INVALID_RANGE))?,
388                );
389                Ok(Self::Categorical {
390                    value,
391                    categories: vec![category],
392                })
393            }
394            other => Err(error(
395                format!("optimizableVariable: unsupported Range value {other:?}"),
396                &ERROR_INVALID_RANGE,
397            )),
398        }
399    }
400
401    fn numeric_from_values(value: Value, values: &[f64]) -> BuiltinResult<Self> {
402        if values.len() != 2 {
403            return Err(error(
404                "optimizableVariable: numeric Range must contain exactly two elements",
405                &ERROR_INVALID_RANGE,
406            ));
407        }
408        let lower = values[0];
409        let upper = values[1];
410        if !lower.is_finite() || !upper.is_finite() || lower >= upper {
411            return Err(error(
412                "optimizableVariable: numeric Range bounds must be finite and increasing",
413                &ERROR_INVALID_RANGE,
414            ));
415        }
416        Ok(Self::Numeric {
417            value,
418            lower,
419            upper,
420        })
421    }
422
423    fn inferred_type(&self) -> VariableType {
424        match self {
425            Self::Numeric { .. } => VariableType::Real,
426            Self::Categorical { .. } => VariableType::Categorical,
427        }
428    }
429
430    fn value(&self) -> &Value {
431        match self {
432            Self::Numeric { value, .. } | Self::Categorical { value, .. } => value,
433        }
434    }
435
436    fn validate_for(&self, var_type: VariableType, transform: Transform) -> BuiltinResult<()> {
437        match (self, var_type) {
438            (Self::Numeric { lower, .. }, VariableType::Real) => {
439                validate_real_transform(*lower, transform)
440            }
441            (Self::Numeric { lower, upper, .. }, VariableType::Integer) => {
442                if lower.fract().abs() > f64::EPSILON || upper.fract().abs() > f64::EPSILON {
443                    return Err(error(
444                        "optimizableVariable: integer Range bounds must be whole numbers",
445                        &ERROR_INVALID_RANGE,
446                    ));
447                }
448                validate_integer_transform(*lower, transform)
449            }
450            (Self::Numeric { .. }, VariableType::Categorical) => Err(error(
451                "optimizableVariable: categorical variables require a categorical text Range",
452                &ERROR_INVALID_RANGE,
453            )),
454            (Self::Categorical { categories, .. }, VariableType::Categorical) => {
455                check_categories(categories)?;
456                if transform != Transform::None {
457                    return Err(error(
458                        "optimizableVariable: categorical variables only support Transform 'none'",
459                        &ERROR_INVALID_OPTION,
460                    ));
461                }
462                Ok(())
463            }
464            (Self::Categorical { .. }, _) => Err(error(
465                "optimizableVariable: real and integer variables require a two-element numeric Range",
466                &ERROR_INVALID_RANGE,
467            )),
468        }
469    }
470}
471
472fn validate_real_transform(lower: f64, transform: Transform) -> BuiltinResult<()> {
473    if transform == Transform::Log && lower <= 0.0 {
474        return Err(error(
475            "optimizableVariable: log transform requires positive real Range bounds",
476            &ERROR_INVALID_RANGE,
477        ));
478    }
479    Ok(())
480}
481
482fn validate_integer_transform(lower: f64, transform: Transform) -> BuiltinResult<()> {
483    if transform == Transform::Log && lower < 0.0 {
484        return Err(error(
485            "optimizableVariable: log transform requires nonnegative integer Range bounds",
486            &ERROR_INVALID_RANGE,
487        ));
488    }
489    Ok(())
490}
491
492fn text_categories_from_string_array(array: &StringArray) -> BuiltinResult<Vec<String>> {
493    let categories = array.data.clone();
494    check_categories(&categories)?;
495    Ok(categories)
496}
497
498fn text_categories_from_cell(cell: &CellArray) -> BuiltinResult<Vec<String>> {
499    let mut categories = Vec::with_capacity(cell.data.len());
500    for value in &cell.data {
501        categories.push(parse_text_scalar(value, "Range", &ERROR_INVALID_RANGE)?);
502    }
503    check_categories(&categories)?;
504    Ok(categories)
505}
506
507fn check_categories(categories: &[String]) -> BuiltinResult<()> {
508    if categories.is_empty() {
509        return Err(error(
510            "optimizableVariable: categorical Range must contain at least one category",
511            &ERROR_INVALID_RANGE,
512        ));
513    }
514    let mut seen = HashSet::with_capacity(categories.len());
515    for category in categories {
516        if category.trim().is_empty() {
517            return Err(error(
518                "optimizableVariable: categorical Range values must be nonempty text",
519                &ERROR_INVALID_RANGE,
520            ));
521        }
522        if !seen.insert(category.clone()) {
523            return Err(error(
524                "optimizableVariable: categorical Range values must be unique",
525                &ERROR_INVALID_RANGE,
526            ));
527        }
528    }
529    Ok(())
530}
531
532fn parse_text_scalar(
533    value: &Value,
534    label: &str,
535    descriptor: &'static BuiltinErrorDescriptor,
536) -> BuiltinResult<String> {
537    match value {
538        Value::String(text) => Ok(text.clone()),
539        Value::CharArray(chars) if chars.rows == 1 => char_row_to_string(chars, label, descriptor),
540        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
541        other => Err(error(
542            format!("optimizableVariable: {label} must be a text scalar, got {other:?}"),
543            descriptor,
544        )),
545    }
546}
547
548fn char_row_to_string(
549    chars: &CharArray,
550    label: &str,
551    descriptor: &'static BuiltinErrorDescriptor,
552) -> BuiltinResult<String> {
553    if chars.rows != 1 {
554        return Err(error(
555            format!("optimizableVariable: {label} char array must be a row vector"),
556            descriptor,
557        ));
558    }
559    Ok(chars.data.iter().collect())
560}
561
562fn parse_bool(value: &Value, label: &str) -> BuiltinResult<bool> {
563    match value {
564        Value::Bool(flag) => Ok(*flag),
565        Value::LogicalArray(LogicalArray { data, .. }) if data.len() == 1 => Ok(data[0] != 0),
566        Value::Num(n) if *n == 0.0 || *n == 1.0 => Ok(*n != 0.0),
567        Value::Int(i) if i.to_f64() == 0.0 || i.to_f64() == 1.0 => Ok(i.to_f64() != 0.0),
568        other => Err(error(
569            format!("optimizableVariable: {label} must be a scalar logical value, got {other:?}"),
570            &ERROR_INVALID_OPTION,
571        )),
572    }
573}
574
575fn error(message: impl Into<String>, descriptor: &'static BuiltinErrorDescriptor) -> RuntimeError {
576    let mut builder = build_runtime_error(message).with_builtin(NAME);
577    if let Some(identifier) = descriptor.identifier {
578        builder = builder.with_identifier(identifier);
579    }
580    builder.build()
581}
582
583fn remap_flow(err: RuntimeError, label: &str) -> RuntimeError {
584    error(
585        format!(
586            "optimizableVariable: failed to gather {label}: {}",
587            err.message()
588        ),
589        &ERROR_FLOW,
590    )
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596    use futures::executor::block_on;
597
598    fn object(value: Value) -> ObjectInstance {
599        let Value::Object(object) = value else {
600            panic!("expected object");
601        };
602        object
603    }
604
605    #[test]
606    fn builds_real_variable_with_defaults() {
607        let out = block_on(optimizable_variable_builtin(
608            Value::String("depth".into()),
609            Value::Tensor(Tensor::new(vec![1.0, 10.0], vec![1, 2]).unwrap()),
610            vec![],
611        ))
612        .expect("optimizableVariable");
613        let object = object(out);
614        assert_eq!(object.class_name, CLASS_NAME);
615        assert_eq!(
616            object.properties.get("Name"),
617            Some(&Value::String("depth".into()))
618        );
619        assert_eq!(
620            object.properties.get("Type"),
621            Some(&Value::String("real".into()))
622        );
623        assert_eq!(
624            object.properties.get("Transform"),
625            Some(&Value::String("none".into()))
626        );
627        assert_eq!(object.properties.get("Optimize"), Some(&Value::Bool(true)));
628    }
629
630    #[test]
631    fn builds_integer_log_variable_with_optimize_false() {
632        let out = block_on(optimizable_variable_builtin(
633            Value::String("trees".into()),
634            Value::Tensor(Tensor::new(vec![0.0, 1000.0], vec![1, 2]).unwrap()),
635            vec![
636                Value::String("Type".into()),
637                Value::String("integer".into()),
638                Value::String("Transform".into()),
639                Value::String("log".into()),
640                Value::String("Optimize".into()),
641                Value::Bool(false),
642            ],
643        ))
644        .expect("optimizableVariable integer");
645        let object = object(out);
646        assert_eq!(
647            object.properties.get("Type"),
648            Some(&Value::String("integer".into()))
649        );
650        assert_eq!(
651            object.properties.get("Transform"),
652            Some(&Value::String("log".into()))
653        );
654        assert_eq!(object.properties.get("Optimize"), Some(&Value::Bool(false)));
655    }
656
657    #[test]
658    fn builds_categorical_variable_from_cellstr() {
659        let categories = Value::Cell(
660            CellArray::new(
661                vec![Value::String("linear".into()), Value::String("rbf".into())],
662                1,
663                2,
664            )
665            .unwrap(),
666        );
667        let out = block_on(optimizable_variable_builtin(
668            Value::String("kernel".into()),
669            categories.clone(),
670            vec![
671                Value::String("Type".into()),
672                Value::String("categorical".into()),
673            ],
674        ))
675        .expect("optimizableVariable categorical");
676        let object = object(out);
677        assert_eq!(
678            object.properties.get("Type"),
679            Some(&Value::String("categorical".into()))
680        );
681        assert_eq!(object.properties.get("Range"), Some(&categories));
682    }
683
684    #[test]
685    fn infers_categorical_type_from_text_range() {
686        let out = block_on(optimizable_variable_builtin(
687            Value::String("method".into()),
688            Value::StringArray(StringArray::new(vec!["a".into(), "b".into()], vec![1, 2]).unwrap()),
689            vec![],
690        ))
691        .expect("optimizableVariable categorical inferred");
692        let object = object(out);
693        assert_eq!(
694            object.properties.get("Type"),
695            Some(&Value::String("categorical".into()))
696        );
697    }
698
699    #[test]
700    fn accepts_explicit_categorical_scalar_string_range() {
701        let out = block_on(optimizable_variable_builtin(
702            Value::String("method".into()),
703            Value::String("rbf".into()),
704            vec![
705                Value::String("Type".into()),
706                Value::String("categorical".into()),
707            ],
708        ))
709        .expect("optimizableVariable scalar categorical");
710        let object = object(out);
711        assert_eq!(
712            object.properties.get("Range"),
713            Some(&Value::String("rbf".into()))
714        );
715        assert_eq!(
716            object.properties.get("Type"),
717            Some(&Value::String("categorical".into()))
718        );
719    }
720
721    #[test]
722    fn rejects_invalid_ranges_and_options() {
723        let err = block_on(optimizable_variable_builtin(
724            Value::String("bad".into()),
725            Value::Tensor(Tensor::new(vec![2.0, 1.0], vec![1, 2]).unwrap()),
726            vec![],
727        ))
728        .unwrap_err();
729        assert!(err.to_string().contains("finite and increasing"));
730
731        let err = block_on(optimizable_variable_builtin(
732            Value::String("bad".into()),
733            Value::Tensor(Tensor::new(vec![1.5, 3.0], vec![1, 2]).unwrap()),
734            vec![
735                Value::String("Type".into()),
736                Value::String("integer".into()),
737            ],
738        ))
739        .unwrap_err();
740        assert!(err.to_string().contains("whole numbers"));
741
742        let err = block_on(optimizable_variable_builtin(
743            Value::String("bad".into()),
744            Value::Tensor(Tensor::new(vec![0.0, 3.0], vec![1, 2]).unwrap()),
745            vec![
746                Value::String("Transform".into()),
747                Value::String("log".into()),
748            ],
749        ))
750        .unwrap_err();
751        assert!(err.to_string().contains("positive real"));
752
753        let err = block_on(optimizable_variable_builtin(
754            Value::String("bad".into()),
755            Value::Tensor(Tensor::new(vec![-1.0, 3.0], vec![1, 2]).unwrap()),
756            vec![
757                Value::String("Type".into()),
758                Value::String("integer".into()),
759                Value::String("Transform".into()),
760                Value::String("log".into()),
761            ],
762        ))
763        .unwrap_err();
764        assert!(err.to_string().contains("nonnegative integer"));
765
766        let err = block_on(optimizable_variable_builtin(
767            Value::String("bad".into()),
768            Value::Tensor(Tensor::new(vec![1.0, 3.0], vec![1, 2]).unwrap()),
769            vec![Value::String("Type".into()), Value::String("int".into())],
770        ))
771        .unwrap_err();
772        assert!(err.to_string().contains("unsupported Type"));
773
774        let err = block_on(optimizable_variable_builtin(
775            Value::String("bad".into()),
776            Value::StringArray(StringArray::new(vec!["a".into(), "a".into()], vec![1, 2]).unwrap()),
777            vec![
778                Value::String("Type".into()),
779                Value::String("categorical".into()),
780            ],
781        ))
782        .unwrap_err();
783        assert!(err.to_string().contains("unique"));
784    }
785}