Skip to main content

runmat_runtime/builtins/math/optim/
optimset.rs

1//! Minimal MATLAB-compatible `optimset` options struct builder.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
5    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
6    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
7    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
8    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
9    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10};
11use runmat_macros::runtime_builtin;
12use runmat_value::{StructValue, Value};
13
14use crate::builtins::common::spec::{
15    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
16    ReductionNaN, ResidencyPolicy, ShapeRequirements,
17};
18use crate::builtins::math::optim::common::{canonical_option_name, field_name};
19use crate::builtins::math::optim::type_resolvers::optim_options_type;
20use crate::{build_runtime_error, BuiltinResult, RuntimeError};
21
22const NAME: &str = "optimset";
23
24const INTEGER_OPTION_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
25    id: "optimset-integer-option",
26    mode: BuiltinExtensionMode::RunMatOnly,
27    description: "optimset with native-class integer option payloads is a RunMat extension",
28    error_identifier: Some("RunMat:compatibility:OptimsetIntegerOptionExtension"),
29};
30const RESIDENT_OPTION_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
31    id: "optimset-resident-option",
32    mode: BuiltinExtensionMode::RunMatOnly,
33    description: "optimset preserving explicit gpuArray option payloads is a RunMat extension",
34    error_identifier: Some("RunMat:compatibility:OptimsetResidentOptionExtension"),
35};
36pub const EXTENSIONS: [BuiltinExtensionDescriptor; 2] =
37    [INTEGER_OPTION_EXTENSION, RESIDENT_OPTION_EXTENSION];
38
39const INTEGER_OPTION_INPUT: [BuiltinIntegerInputCapability; 1] = [BuiltinIntegerInputCapability {
40    name: "option value",
41    classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
42    availability: BuiltinIntegerInputAvailability::RunMatOnly,
43    scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
44    notes: "Documented numeric optimset options use single or double; RunMat can preserve exact native integer payloads in its struct extension.",
45}];
46pub const INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
47    [BuiltinIntegerCapabilityDescriptor { form: "options = optimset(___, name, integer_value, ___)", inputs: &INTEGER_OPTION_INPUT, computation_domain: BuiltinIntegerComputationDomain::Structural, output_class: BuiltinIntegerOutputClassRule::PreserveInput, overflow: BuiltinIntegerOverflowRule::NotApplicable, backend: BuiltinIntegerBackendRule::GatherFallback, overload: BuiltinIntegerOverloadKind::StructuralParameter, notes: "Typed integer payloads are gated and retained exactly in the RunMat options struct; automatic resident payloads gather while explicit resident preservation is separately gated." }];
48
49const OPTIMSET_OUTPUT_OPTIONS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
50    name: "options",
51    ty: BuiltinParamType::Any,
52    arity: BuiltinParamArity::Required,
53    default: None,
54    description: "Options struct for optimization solvers.",
55}];
56
57const OPTIMSET_INPUTS_PAIRS: [BuiltinParamDescriptor; 2] = [
58    BuiltinParamDescriptor {
59        name: "name",
60        ty: BuiltinParamType::Any,
61        arity: BuiltinParamArity::Required,
62        default: None,
63        description: "Option field name.",
64    },
65    BuiltinParamDescriptor {
66        name: "value",
67        ty: BuiltinParamType::Any,
68        arity: BuiltinParamArity::Required,
69        default: None,
70        description: "Option value.",
71    },
72];
73
74const OPTIMSET_INPUTS_EXISTING_AND_PAIRS: [BuiltinParamDescriptor; 3] = [
75    BuiltinParamDescriptor {
76        name: "oldopts",
77        ty: BuiltinParamType::Any,
78        arity: BuiltinParamArity::Required,
79        default: None,
80        description: "Existing options struct to update.",
81    },
82    BuiltinParamDescriptor {
83        name: "name",
84        ty: BuiltinParamType::Any,
85        arity: BuiltinParamArity::Optional,
86        default: None,
87        description: "Option field name.",
88    },
89    BuiltinParamDescriptor {
90        name: "value",
91        ty: BuiltinParamType::Any,
92        arity: BuiltinParamArity::Variadic,
93        default: None,
94        description: "Option value(s) and additional name/value pairs.",
95    },
96];
97
98const OPTIMSET_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
99    BuiltinSignatureDescriptor {
100        label: "options = optimset()",
101        inputs: &[],
102        outputs: &OPTIMSET_OUTPUT_OPTIONS,
103    },
104    BuiltinSignatureDescriptor {
105        label: "options = optimset(name, value, ...)",
106        inputs: &OPTIMSET_INPUTS_PAIRS,
107        outputs: &OPTIMSET_OUTPUT_OPTIONS,
108    },
109    BuiltinSignatureDescriptor {
110        label: "options = optimset(oldopts, name, value, ...)",
111        inputs: &OPTIMSET_INPUTS_EXISTING_AND_PAIRS,
112        outputs: &OPTIMSET_OUTPUT_OPTIONS,
113    },
114];
115
116const OPTIMSET_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
117    code: "RM.OPTIMSET.INVALID_ARGUMENT",
118    identifier: Some("RunMat:optimset:InvalidArgument"),
119    when: "Name/value argument grammar is invalid.",
120    message: "optimset: invalid argument",
121};
122
123const OPTIMSET_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
124    code: "RM.OPTIMSET.INVALID_INPUT",
125    identifier: Some("RunMat:optimset:InvalidInput"),
126    when: "Option field names are not valid string scalars.",
127    message: "optimset: invalid input",
128};
129
130const OPTIMSET_ERRORS: [BuiltinErrorDescriptor; 2] = [
131    OPTIMSET_ERROR_INVALID_ARGUMENT,
132    OPTIMSET_ERROR_INVALID_INPUT,
133];
134
135pub const OPTIMSET_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
136    signatures: &OPTIMSET_SIGNATURES,
137    output_mode: BuiltinOutputMode::Fixed,
138    completion_policy: BuiltinCompletionPolicy::Public,
139    errors: &OPTIMSET_ERRORS,
140};
141
142fn optimset_error_with_detail(
143    error: &'static BuiltinErrorDescriptor,
144    detail: impl AsRef<str>,
145) -> RuntimeError {
146    let detail = detail.as_ref();
147    let message = if detail.starts_with("optimset:") {
148        detail.to_string()
149    } else {
150        format!("{}: {detail}", error.message)
151    };
152    let mut builder = build_runtime_error(message).with_builtin(NAME);
153    if let Some(identifier) = error.identifier {
154        builder = builder.with_identifier(identifier);
155    }
156    builder.build()
157}
158
159#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::optim::optimset")]
160pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
161    name: "optimset",
162    op_kind: GpuOpKind::Custom("options"),
163    supported_precisions: &[],
164    broadcast: BroadcastSemantics::None,
165    provider_hooks: &[],
166    constant_strategy: ConstantStrategy::InlineLiteral,
167    residency: ResidencyPolicy::InheritInputs,
168    nan_mode: ReductionNaN::Include,
169    two_pass_threshold: None,
170    workgroup_size: None,
171    accepts_nan_mode: false,
172    notes: "Host metadata construction. Automatic resident option payloads gather transparently; explicitly resident payload preservation is a gated RunMat extension.",
173};
174
175#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::optim::optimset")]
176pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
177    name: "optimset",
178    shape: ShapeRequirements::Any,
179    constant_strategy: ConstantStrategy::InlineLiteral,
180    elementwise: None,
181    reduction: None,
182    emits_nan: false,
183    notes: "Option struct construction is host metadata work and does not fuse.",
184};
185
186#[runtime_builtin(
187    name = "optimset",
188    category = "math/optim",
189    summary = "Create or update optimization options structures.",
190    keywords = "optimset,options,TolX,TolFun,MaxIter,Display",
191    type_resolver(optim_options_type),
192    descriptor(crate::builtins::math::optim::optimset::OPTIMSET_DESCRIPTOR),
193    extensions(crate::builtins::math::optim::optimset::EXTENSIONS),
194    integer_capabilities(crate::builtins::math::optim::optimset::INTEGER_CAPABILITIES),
195    builtin_path = "crate::builtins::math::optim::optimset"
196)]
197async fn optimset_builtin(rest: Vec<Value>) -> BuiltinResult<Value> {
198    ensure_optimset_extensions(&rest)?;
199    let mut fields = StructValue::new();
200    let mut args = rest.into_iter();
201
202    if let Some(first) = args.next() {
203        match first {
204            Value::Struct(existing) => {
205                for (name, value) in existing.fields {
206                    fields.insert(name, prepare_optimset_payload(value).await?);
207                }
208            }
209            other => {
210                let second = args.next().ok_or_else(|| {
211                    optimset_error_with_detail(
212                        &OPTIMSET_ERROR_INVALID_ARGUMENT,
213                        "expected option name/value pairs",
214                    )
215                })?;
216                let name = field_name(&other).map_err(|err| {
217                    optimset_error_with_detail(&OPTIMSET_ERROR_INVALID_INPUT, err.message())
218                })?;
219                fields.insert(
220                    canonical_option_name(&name),
221                    prepare_optimset_payload(second).await?,
222                );
223            }
224        }
225    }
226
227    let remaining = args.collect::<Vec<_>>();
228    if remaining.len() % 2 != 0 {
229        return Err(optimset_error_with_detail(
230            &OPTIMSET_ERROR_INVALID_ARGUMENT,
231            "expected option name/value pairs",
232        ));
233    }
234    for pair in remaining.chunks(2) {
235        let name = field_name(&pair[0]).map_err(|err| {
236            optimset_error_with_detail(&OPTIMSET_ERROR_INVALID_INPUT, err.message())
237        })?;
238        fields.insert(
239            canonical_option_name(&name),
240            prepare_optimset_payload(pair[1].clone()).await?,
241        );
242    }
243
244    Ok(Value::Struct(fields))
245}
246
247fn ensure_optimset_extensions(args: &[Value]) -> BuiltinResult<()> {
248    let mut payloads = Vec::new();
249    let mut pair_start = 0usize;
250    if let Some(Value::Struct(existing)) = args.first() {
251        payloads.extend(existing.fields.values());
252        pair_start = 1;
253    }
254    let mut index = pair_start + 1;
255    while index < args.len() {
256        payloads.push(&args[index]);
257        index += 2;
258    }
259    let integer = payloads.iter().any(|value| {
260        crate::builtins::common::validation::value_contains_native_integer_class(value)
261    });
262    if integer {
263        crate::compatibility::ensure_builtin_extension_enabled(&INTEGER_OPTION_EXTENSION, NAME)?;
264    }
265    let explicit = payloads
266        .iter()
267        .any(|value| crate::builtins::common::validation::value_contains_explicit_gpu(value));
268    if explicit {
269        crate::compatibility::ensure_builtin_extension_enabled(&RESIDENT_OPTION_EXTENSION, NAME)?;
270    }
271    Ok(())
272}
273
274async fn prepare_optimset_payload(value: Value) -> BuiltinResult<Value> {
275    if crate::builtins::common::validation::value_contains_explicit_gpu(&value) {
276        return Ok(value);
277    }
278    crate::dispatcher::gather_if_needed_async(&value)
279        .await
280        .map_err(|error| {
281            optimset_error_with_detail(
282                &OPTIMSET_ERROR_INVALID_INPUT,
283                format!("failed to gather option value: {error}"),
284            )
285        })
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::builtins::common::test_support;
292    use futures::executor::block_on;
293    use runmat_accelerate_api::HostTensorView;
294    use runmat_value::{IntegerStorage, Tensor};
295
296    #[test]
297    fn optimset_builds_struct_from_pairs() {
298        let value = block_on(optimset_builtin(vec![
299            Value::from("TolX"),
300            Value::Num(1.0e-8),
301            Value::from("Display"),
302            Value::from("off"),
303        ]))
304        .unwrap();
305        match value {
306            Value::Struct(options) => {
307                assert!(matches!(options.fields.get("TolX"), Some(Value::Num(_))));
308                assert!(matches!(
309                    options.fields.get("Display"),
310                    Some(Value::String(_))
311                ));
312            }
313            other => panic!("unexpected value {other:?}"),
314        }
315    }
316
317    #[test]
318    fn optimset_descriptor_signatures_cover_core_forms() {
319        let labels: Vec<&str> = OPTIMSET_DESCRIPTOR
320            .signatures
321            .iter()
322            .map(|signature| signature.label)
323            .collect();
324        assert_eq!(
325            labels,
326            vec![
327                "options = optimset()",
328                "options = optimset(name, value, ...)",
329                "options = optimset(oldopts, name, value, ...)",
330            ]
331        );
332
333        let codes: Vec<&str> = OPTIMSET_DESCRIPTOR
334            .errors
335            .iter()
336            .map(|error| error.code)
337            .collect();
338        assert_eq!(
339            codes,
340            vec!["RM.OPTIMSET.INVALID_ARGUMENT", "RM.OPTIMSET.INVALID_INPUT"]
341        );
342    }
343
344    #[test]
345    fn optimset_odd_name_value_pairs_use_stable_identifier() {
346        let err = block_on(optimset_builtin(vec![Value::from("TolX")])).unwrap_err();
347        assert_eq!(err.identifier(), Some("RunMat:optimset:InvalidArgument"));
348    }
349
350    #[test]
351    fn optimset_strict_mode_rejects_integer_option_payload() {
352        let _strict = crate::compatibility::push_runmat_extensions_enabled(false);
353        let value = Tensor::new_integer(IntegerStorage::I64(vec![17]), vec![1, 1]).unwrap();
354
355        let error = block_on(optimset_builtin(vec![
356            Value::from("MaxIter"),
357            Value::Tensor(value),
358        ]))
359        .expect_err("integer payload is a RunMat-only extension");
360
361        assert_eq!(
362            error.identifier(),
363            INTEGER_OPTION_EXTENSION.error_identifier
364        );
365    }
366
367    #[test]
368    fn optimset_runmat_mode_preserves_wide_integer_payload_exactly() {
369        let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
370        let expected = u64::MAX;
371        let value = Tensor::new_integer(IntegerStorage::U64(vec![expected]), vec![1, 1]).unwrap();
372
373        let result = block_on(optimset_builtin(vec![
374            Value::from("MaxIter"),
375            Value::Tensor(value),
376        ]))
377        .expect("RunMat integer payload");
378
379        let Value::Struct(options) = result else {
380            panic!("expected options struct")
381        };
382        let Some(Value::Tensor(stored)) = options.fields.get("MaxIter") else {
383            panic!("expected exact MaxIter tensor")
384        };
385        assert!(matches!(
386            stored.numeric_value_at(0),
387            Some(runmat_value::NumericScalar::U64(value)) if value == expected
388        ));
389    }
390
391    #[test]
392    fn optimset_automatic_resident_payload_gathers_but_explicit_payload_is_gated() {
393        test_support::with_test_provider(|provider| {
394            let values = [3.5];
395            let shape = [1, 1];
396            let automatic = provider
397                .upload(&HostTensorView {
398                    data: &values,
399                    shape: &shape,
400                })
401                .expect("automatic upload");
402            let automatic =
403                automatic.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Automatic);
404
405            let automatic_result = block_on(optimset_builtin(vec![
406                Value::from("TolX"),
407                Value::GpuTensor(automatic),
408            ]))
409            .expect("automatic residency gathers");
410            assert!(matches!(
411                automatic_result,
412                Value::Struct(ref options)
413                    if matches!(options.fields.get("TolX"), Some(Value::Tensor(tensor)) if tensor.materialize_f64() == [3.5])
414            ));
415
416            let explicit = provider
417                .upload(&HostTensorView {
418                    data: &values,
419                    shape: &shape,
420                })
421                .expect("explicit upload");
422            let explicit =
423                explicit.with_provenance(runmat_accelerate_api::GpuHandleProvenance::Explicit);
424            let strict = crate::compatibility::push_runmat_extensions_enabled(false);
425            let error = block_on(optimset_builtin(vec![
426                Value::from("TolX"),
427                Value::GpuTensor(explicit.clone()),
428            ]))
429            .expect_err("explicit payload must be gated");
430            assert_eq!(
431                error.identifier(),
432                RESIDENT_OPTION_EXTENSION.error_identifier
433            );
434            drop(strict);
435
436            let _extensions = crate::compatibility::push_runmat_extensions_enabled(true);
437            let mixed_automatic = provider
438                .upload(&HostTensorView {
439                    data: &values,
440                    shape: &shape,
441                })
442                .expect("mixed automatic upload");
443            let mixed_automatic = mixed_automatic
444                .with_provenance(runmat_accelerate_api::GpuHandleProvenance::Automatic);
445            let explicit_result = block_on(optimset_builtin(vec![
446                Value::from("TolX"),
447                Value::GpuTensor(explicit),
448                Value::from("MaxFunEvals"),
449                Value::GpuTensor(mixed_automatic),
450            ]))
451            .expect("RunMat preserves explicit and gathers automatic payloads independently");
452            assert!(matches!(
453                explicit_result,
454                Value::Struct(ref options)
455                    if matches!(options.fields.get("TolX"), Some(Value::GpuTensor(_)))
456                        && matches!(options.fields.get("MaxFunEvals"), Some(Value::Tensor(tensor)) if tensor.materialize_f64() == [3.5])
457            ));
458        });
459    }
460}