Skip to main content

runmat_runtime/builtins/duration/
mod.rs

1use runmat_types::MemberAccess;
2use std::collections::HashMap;
3
4use runmat_builtins::{
5    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
6    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
7    BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
8    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
9    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
10    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
11};
12use runmat_value::{CharArray, ObjectInstance, StringArray, Tensor, Value};
13
14use crate::builtins::common::tensor;
15use crate::{
16    build_runtime_error, gather_if_needed_async, BuiltinResult, RuntimeError, OBJECT_INDEX_MEMBER,
17    OBJECT_INDEX_PAREN, OBJECT_SUBSASGN_METHOD, OBJECT_SUBSREF_METHOD,
18};
19
20const BUILTIN_NAME: &str = "duration";
21const DURATION_CLASS: &str = "duration";
22const DAYS_FIELD: &str = "__days";
23const FORMAT_FIELD: &str = "Format";
24pub(crate) const DEFAULT_DURATION_FORMAT: &str = "hh:mm:ss";
25const SECONDS_PER_DAY: f64 = 86_400.0;
26
27const DURATION_SHORT_COMPONENT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
28    id: "duration-short-component-form",
29    mode: BuiltinExtensionMode::RunMatOnly,
30    description:
31        "duration with one hour component or two hour/minute components is a RunMat extension",
32    error_identifier: Some("RunMat:compatibility:DurationShortComponentFormExtension"),
33};
34const DURATION_GPU_INPUT_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
35    id: "duration-gpu-input",
36    mode: BuiltinExtensionMode::RunMatOnly,
37    description: "duration with resident numeric input is a RunMat extension",
38    error_identifier: Some("RunMat:compatibility:DurationGpuInputExtension"),
39};
40const DURATION_EXTENSIONS: [BuiltinExtensionDescriptor; 2] = [
41    DURATION_SHORT_COMPONENT_EXTENSION,
42    DURATION_GPU_INPUT_EXTENSION,
43];
44const DURATION_INTEGER_COMPONENT_INPUTS: [BuiltinIntegerInputCapability; 4] = [
45    BuiltinIntegerInputCapability {
46        name: "H",
47        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
48        availability: BuiltinIntegerInputAvailability::Documented,
49        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
50        notes: "Numeric hour arrays can be scalar-expanded; values enter the duration floating representation.",
51    },
52    BuiltinIntegerInputCapability {
53        name: "MI",
54        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
55        availability: BuiltinIntegerInputAvailability::Documented,
56        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
57        notes: "Numeric minute arrays can be scalar-expanded; nonscalars must match the other component sizes.",
58    },
59    BuiltinIntegerInputCapability {
60        name: "S",
61        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
62        availability: BuiltinIntegerInputAvailability::Documented,
63        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
64        notes: "Numeric second arrays can be scalar-expanded; nonscalars must match the other component sizes.",
65    },
66    BuiltinIntegerInputCapability {
67        name: "MS",
68        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
69        availability: BuiltinIntegerInputAvailability::Documented,
70        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
71        notes: "The optional fourth component contributes milliseconds and follows the same scalar-expansion rule.",
72    },
73];
74const DURATION_INTEGER_MATRIX_INPUT: [BuiltinIntegerInputCapability; 1] =
75    [BuiltinIntegerInputCapability {
76        name: "X",
77        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
78        availability: BuiltinIntegerInputAvailability::Documented,
79        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
80        notes: "X must be a numeric matrix with exactly three columns ordered as hours, minutes, and seconds.",
81    }];
82pub const DURATION_INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 2] = [
83    BuiltinIntegerCapabilityDescriptor {
84        form: "D = duration(integer_H, integer_MI, integer_S, integer_MS?)",
85        inputs: &DURATION_INTEGER_COMPONENT_INPUTS,
86        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
87        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
88        overflow: BuiltinIntegerOverflowRule::NotApplicable,
89        backend: BuiltinIntegerBackendRule::GatherFallback,
90        overload: BuiltinIntegerOverloadKind::Multiple,
91        notes: "All numeric classes share MATLAB scalar expansion and produce a host duration object backed by binary64 day counts; resident inputs are a separately gated RunMat extension and gather before construction.",
92    },
93    BuiltinIntegerCapabilityDescriptor {
94        form: "D = duration(integer_X)",
95        inputs: &DURATION_INTEGER_MATRIX_INPUT,
96        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
97        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
98        overflow: BuiltinIntegerOverflowRule::NotApplicable,
99        backend: BuiltinIntegerBackendRule::GatherFallback,
100        overload: BuiltinIntegerOverloadKind::Multiple,
101        notes: "Each row of the N-by-3 matrix creates one duration; output is an N-by-1 host duration array.",
102    },
103];
104
105static DURATION_CLASS_REGISTERED: crate::class_registry::ClassRegistration =
106    crate::class_registry::ClassRegistration::new(DURATION_CLASS);
107
108const DURATION_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
109    code: "RM.DURATION.INVALID_ARGUMENT",
110    identifier: Some("RunMat:duration:InvalidArgument"),
111    when: "Arguments or option grammar do not match supported duration forms.",
112    message: "duration: invalid argument",
113};
114const DURATION_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
115    code: "RM.DURATION.INVALID_INPUT",
116    identifier: Some("RunMat:duration:InvalidInput"),
117    when: "Input values cannot be converted/broadcast/formatted to a valid duration result.",
118    message: "duration: invalid input",
119};
120const DURATION_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
121    code: "RM.DURATION.INTERNAL",
122    identifier: Some("RunMat:duration:Internal"),
123    when: "Internal duration state or indexing/evaluation failed unexpectedly.",
124    message: "duration: internal operation failed",
125};
126const DURATION_ERRORS: [BuiltinErrorDescriptor; 3] = [
127    DURATION_ERROR_INVALID_ARGUMENT,
128    DURATION_ERROR_INVALID_INPUT,
129    DURATION_ERROR_INTERNAL,
130];
131
132const OUT_DURATION: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
133    name: "t",
134    ty: BuiltinParamType::Any,
135    arity: BuiltinParamArity::Required,
136    default: None,
137    description: "Duration object result.",
138}];
139const OUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
140    name: "out",
141    ty: BuiltinParamType::Any,
142    arity: BuiltinParamArity::Required,
143    default: None,
144    description: "Method result.",
145}];
146const DURATION_ARGS_ONLY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
147    name: "args",
148    ty: BuiltinParamType::Any,
149    arity: BuiltinParamArity::Variadic,
150    default: None,
151    description: "Duration constructor arguments.",
152}];
153const DURATION_FOUR_COMPONENT_INPUTS: [BuiltinParamDescriptor; 4] = [
154    BuiltinParamDescriptor {
155        name: "hours",
156        ty: BuiltinParamType::NumericArray,
157        arity: BuiltinParamArity::Required,
158        default: None,
159        description: "Hour component.",
160    },
161    BuiltinParamDescriptor {
162        name: "minutes",
163        ty: BuiltinParamType::NumericArray,
164        arity: BuiltinParamArity::Required,
165        default: None,
166        description: "Minute component.",
167    },
168    BuiltinParamDescriptor {
169        name: "seconds",
170        ty: BuiltinParamType::NumericArray,
171        arity: BuiltinParamArity::Required,
172        default: None,
173        description: "Second component.",
174    },
175    BuiltinParamDescriptor {
176        name: "milliseconds",
177        ty: BuiltinParamType::NumericArray,
178        arity: BuiltinParamArity::Required,
179        default: None,
180        description: "Millisecond component.",
181    },
182];
183const DURATION_BINARY_INPUTS: [BuiltinParamDescriptor; 2] = [
184    BuiltinParamDescriptor {
185        name: "lhs",
186        ty: BuiltinParamType::Any,
187        arity: BuiltinParamArity::Required,
188        default: None,
189        description: "Left duration operand.",
190    },
191    BuiltinParamDescriptor {
192        name: "rhs",
193        ty: BuiltinParamType::Any,
194        arity: BuiltinParamArity::Required,
195        default: None,
196        description: "Right duration/datetime operand.",
197    },
198];
199const DURATION_SUBSREF_INPUTS: [BuiltinParamDescriptor; 3] = [
200    BuiltinParamDescriptor {
201        name: "obj",
202        ty: BuiltinParamType::Any,
203        arity: BuiltinParamArity::Required,
204        default: None,
205        description: "Duration receiver object.",
206    },
207    BuiltinParamDescriptor {
208        name: "kind",
209        ty: BuiltinParamType::StringScalar,
210        arity: BuiltinParamArity::Required,
211        default: None,
212        description: "Indexing kind token.",
213    },
214    BuiltinParamDescriptor {
215        name: "payload",
216        ty: BuiltinParamType::Any,
217        arity: BuiltinParamArity::Required,
218        default: None,
219        description: "Index/member payload.",
220    },
221];
222const DURATION_SUBSASGN_INPUTS: [BuiltinParamDescriptor; 4] = [
223    BuiltinParamDescriptor {
224        name: "obj",
225        ty: BuiltinParamType::Any,
226        arity: BuiltinParamArity::Required,
227        default: None,
228        description: "Duration receiver object.",
229    },
230    BuiltinParamDescriptor {
231        name: "kind",
232        ty: BuiltinParamType::StringScalar,
233        arity: BuiltinParamArity::Required,
234        default: None,
235        description: "Indexing kind token.",
236    },
237    BuiltinParamDescriptor {
238        name: "payload",
239        ty: BuiltinParamType::Any,
240        arity: BuiltinParamArity::Required,
241        default: None,
242        description: "Index/member payload.",
243    },
244    BuiltinParamDescriptor {
245        name: "rhs",
246        ty: BuiltinParamType::Any,
247        arity: BuiltinParamArity::Required,
248        default: None,
249        description: "Assigned value.",
250    },
251];
252
253const DURATION_SIGNATURES: [BuiltinSignatureDescriptor; 6] = [
254    BuiltinSignatureDescriptor {
255        label: "t = duration(X)",
256        inputs: &[BuiltinParamDescriptor {
257            name: "X",
258            ty: BuiltinParamType::NumericArray,
259            arity: BuiltinParamArity::Required,
260            default: None,
261            description: "N-by-3 matrix of hour, minute, and second components.",
262        }],
263        outputs: &OUT_DURATION,
264    },
265    BuiltinSignatureDescriptor {
266        label: "t = duration(hours, minutes)",
267        inputs: &[
268            BuiltinParamDescriptor {
269                name: "hours",
270                ty: BuiltinParamType::NumericArray,
271                arity: BuiltinParamArity::Required,
272                default: None,
273                description: "Hour component.",
274            },
275            BuiltinParamDescriptor {
276                name: "minutes",
277                ty: BuiltinParamType::NumericArray,
278                arity: BuiltinParamArity::Required,
279                default: None,
280                description: "Minute component.",
281            },
282        ],
283        outputs: &OUT_DURATION,
284    },
285    BuiltinSignatureDescriptor {
286        label: "t = duration(hours, minutes, seconds)",
287        inputs: &[
288            BuiltinParamDescriptor {
289                name: "hours",
290                ty: BuiltinParamType::NumericArray,
291                arity: BuiltinParamArity::Required,
292                default: None,
293                description: "Hour component.",
294            },
295            BuiltinParamDescriptor {
296                name: "minutes",
297                ty: BuiltinParamType::NumericArray,
298                arity: BuiltinParamArity::Required,
299                default: None,
300                description: "Minute component.",
301            },
302            BuiltinParamDescriptor {
303                name: "seconds",
304                ty: BuiltinParamType::NumericArray,
305                arity: BuiltinParamArity::Required,
306                default: None,
307                description: "Second component.",
308            },
309        ],
310        outputs: &OUT_DURATION,
311    },
312    BuiltinSignatureDescriptor {
313        label: "t = duration(hours, minutes, seconds, milliseconds)",
314        inputs: &DURATION_FOUR_COMPONENT_INPUTS,
315        outputs: &OUT_DURATION,
316    },
317    BuiltinSignatureDescriptor {
318        label: "t = duration(___, \"Format\", format)",
319        inputs: &DURATION_ARGS_ONLY,
320        outputs: &OUT_DURATION,
321    },
322    BuiltinSignatureDescriptor {
323        label: "t = duration(___, Name, Value, ...)",
324        inputs: &DURATION_ARGS_ONLY,
325        outputs: &OUT_DURATION,
326    },
327];
328const DURATION_SUBSREF_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
329    label: "out = duration.subsref(obj, kind, payload)",
330    inputs: &DURATION_SUBSREF_INPUTS,
331    outputs: &OUT_ANY,
332}];
333const DURATION_SUBSASGN_SIGNATURES: [BuiltinSignatureDescriptor; 1] =
334    [BuiltinSignatureDescriptor {
335        label: "out = duration.subsasgn(obj, kind, payload, rhs)",
336        inputs: &DURATION_SUBSASGN_INPUTS,
337        outputs: &OUT_ANY,
338    }];
339const DURATION_BINARY_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
340    label: "out = duration.op(lhs, rhs)",
341    inputs: &DURATION_BINARY_INPUTS,
342    outputs: &OUT_ANY,
343}];
344
345pub const DURATION_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
346    signatures: &DURATION_SIGNATURES,
347    output_mode: BuiltinOutputMode::Fixed,
348    completion_policy: BuiltinCompletionPolicy::Public,
349    errors: &DURATION_ERRORS,
350};
351pub const DURATION_SUBSREF_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
352    signatures: &DURATION_SUBSREF_SIGNATURES,
353    output_mode: BuiltinOutputMode::Fixed,
354    completion_policy: BuiltinCompletionPolicy::MethodOnly,
355    errors: &DURATION_ERRORS,
356};
357pub const DURATION_SUBSASGN_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
358    signatures: &DURATION_SUBSASGN_SIGNATURES,
359    output_mode: BuiltinOutputMode::Fixed,
360    completion_policy: BuiltinCompletionPolicy::MethodOnly,
361    errors: &DURATION_ERRORS,
362};
363pub const DURATION_BINARY_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
364    signatures: &DURATION_BINARY_SIGNATURES,
365    output_mode: BuiltinOutputMode::Fixed,
366    completion_policy: BuiltinCompletionPolicy::MethodOnly,
367    errors: &DURATION_ERRORS,
368};
369
370fn duration_error(message: impl Into<String>) -> RuntimeError {
371    build_runtime_error(message)
372        .with_builtin(BUILTIN_NAME)
373        .build()
374}
375
376fn ensure_duration_class_registered() {
377    DURATION_CLASS_REGISTERED.ensure(|| {
378        let mut properties = HashMap::new();
379        properties.insert(
380            FORMAT_FIELD.to_string(),
381            crate::class_registry::RuntimeProperty {
382                name: FORMAT_FIELD.to_string(),
383                is_static: false,
384                is_constant: false,
385                is_dependent: false,
386                get_access: MemberAccess::Public,
387                set_access: MemberAccess::Public,
388                default_value: Some(Value::String(DEFAULT_DURATION_FORMAT.to_string())),
389            },
390        );
391
392        let mut methods = HashMap::new();
393        for name in [
394            OBJECT_SUBSREF_METHOD,
395            OBJECT_SUBSASGN_METHOD,
396            "plus",
397            "minus",
398            "eq",
399            "ne",
400            "lt",
401            "le",
402            "gt",
403            "ge",
404        ] {
405            methods.insert(
406                name.to_string(),
407                crate::class_registry::RuntimeMethod {
408                    name: name.to_string(),
409                    is_static: false,
410                    is_abstract: false,
411                    is_sealed: false,
412                    access: MemberAccess::Public,
413                    function_name: format!("{DURATION_CLASS}.{name}"),
414                    implicit_class_argument: None,
415                },
416            );
417        }
418
419        crate::class_registry::register_class(crate::class_registry::RuntimeClass {
420            name: DURATION_CLASS.to_string(),
421            parent: None,
422            properties,
423            methods,
424        });
425    });
426}
427
428pub fn is_duration_object(value: &Value) -> bool {
429    matches!(value, Value::Object(obj) if obj.is_class(DURATION_CLASS))
430}
431
432async fn gather_args(args: &[Value]) -> BuiltinResult<Vec<Value>> {
433    let mut out = Vec::with_capacity(args.len());
434    for arg in args {
435        out.push(
436            gather_if_needed_async(arg)
437                .await
438                .map_err(|err| duration_error(format!("duration: {}", err.message())))?,
439        );
440    }
441    Ok(out)
442}
443
444fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
445    match value {
446        Value::String(text) => Ok(text.clone()),
447        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
448        Value::CharArray(array) if array.rows == 1 => Ok(array.data.iter().collect()),
449        _ => Err(duration_error(format!(
450            "duration: {context} must be a string scalar or character vector"
451        ))),
452    }
453}
454
455fn parse_trailing_format(args: &[Value]) -> BuiltinResult<(usize, Option<String>)> {
456    let mut positional_end = args.len();
457    let mut format = None;
458
459    while positional_end >= 2 {
460        let name = match scalar_text(&args[positional_end - 2], "option name") {
461            Ok(text) => text,
462            Err(_) => break,
463        };
464        if !name.trim().eq_ignore_ascii_case("format") {
465            break;
466        }
467        format = Some(scalar_text(&args[positional_end - 1], "Format option")?);
468        positional_end -= 2;
469    }
470
471    Ok((positional_end, format))
472}
473
474fn tensor_from_numeric(value: Value, context: &str) -> BuiltinResult<Tensor> {
475    tensor::value_into_tensor_for(context, value)
476        .map_err(|message| duration_error(format!("duration: {message}")))
477}
478
479fn component_tensor(value: Value, context: &str) -> BuiltinResult<Tensor> {
480    let tensor = tensor_from_numeric(value, context)?;
481    let shape = tensor::default_shape_for(&tensor.shape, tensor.len());
482    let values = tensor::tensor_into_values_f64(tensor);
483    Tensor::new(values, shape).map_err(|err| duration_error(format!("duration: {err}")))
484}
485
486fn format_for_object(obj: &ObjectInstance) -> String {
487    match obj.properties.get(FORMAT_FIELD) {
488        Some(Value::String(text)) => text.clone(),
489        Some(Value::StringArray(array)) if array.data.len() == 1 => array.data[0].clone(),
490        Some(Value::CharArray(array)) if array.rows == 1 => array.data.iter().collect(),
491        _ => DEFAULT_DURATION_FORMAT.to_string(),
492    }
493}
494
495pub(crate) fn duration_tensor_from_duration_value(value: &Value) -> BuiltinResult<Tensor> {
496    match value {
497        Value::Object(obj) if obj.is_class(DURATION_CLASS) => {
498            match obj.properties.get(DAYS_FIELD) {
499                Some(Value::Tensor(tensor)) => Ok(tensor.clone()),
500                Some(Value::Num(value)) => Tensor::new(vec![*value], vec![1, 1])
501                    .map_err(|err| duration_error(format!("duration: {err}"))),
502                Some(other) => Err(duration_error(format!(
503                    "duration: invalid internal day storage {other:?}"
504                ))),
505                None => Err(duration_error("duration: missing internal day storage")),
506            }
507        }
508        _ => Err(duration_error("duration: expected a duration value")),
509    }
510}
511
512pub(crate) fn duration_format_from_value(value: &Value) -> String {
513    match value {
514        Value::Object(obj) if obj.is_class(DURATION_CLASS) => format_for_object(obj),
515        _ => DEFAULT_DURATION_FORMAT.to_string(),
516    }
517}
518
519pub(crate) fn duration_object_from_days_tensor(
520    days: Tensor,
521    format: impl Into<String>,
522) -> BuiltinResult<Value> {
523    ensure_duration_class_registered();
524    let mut object = ObjectInstance::new(DURATION_CLASS.to_string());
525    object
526        .properties
527        .insert(DAYS_FIELD.to_string(), Value::Tensor(days));
528    object
529        .properties
530        .insert(FORMAT_FIELD.to_string(), Value::String(format.into()));
531    Ok(Value::Object(object))
532}
533
534fn duration_object_from_days(
535    days: Vec<f64>,
536    shape: Vec<usize>,
537    format: impl Into<String>,
538) -> BuiltinResult<Value> {
539    let tensor =
540        Tensor::new(days, shape).map_err(|err| duration_error(format!("duration: {err}")))?;
541    duration_object_from_days_tensor(tensor, format)
542}
543
544async fn duration_unit_value(
545    value: Value,
546    unit_name: &str,
547    days_per_unit: f64,
548) -> BuiltinResult<Value> {
549    let value = gather_if_needed_async(&value)
550        .await
551        .map_err(|err| duration_error(format!("{unit_name}: {}", err.message())))?;
552    if is_duration_object(&value) {
553        let days = duration_tensor_from_duration_value(&value)?;
554        let day_values = tensor::tensor_values_f64_cow(&days);
555        let data = day_values
556            .iter()
557            .map(|day| day / days_per_unit)
558            .collect::<Vec<_>>();
559        return if data.len() == 1 {
560            Ok(Value::Num(data[0]))
561        } else {
562            Ok(Value::Tensor(
563                Tensor::new(
564                    data,
565                    tensor::default_shape_for(&days.shape, day_values.len()),
566                )
567                .map_err(|err| duration_error(format!("{unit_name}: {err}")))?,
568            ))
569        };
570    }
571    let numeric = component_tensor(value, unit_name)?;
572    let shape = tensor::default_shape_for(&numeric.shape, numeric.len());
573    let values = tensor::tensor_into_values_f64(numeric);
574    let days = values
575        .iter()
576        .map(|value| {
577            if !value.is_finite() {
578                Err(duration_error(format!(
579                    "{unit_name}: values must be finite"
580                )))
581            } else {
582                let days = value * days_per_unit;
583                if days.is_finite() {
584                    Ok(days)
585                } else {
586                    Err(duration_error(format!(
587                        "{unit_name}: resulting duration is outside supported range"
588                    )))
589                }
590            }
591        })
592        .collect::<BuiltinResult<Vec<_>>>()?;
593    duration_object_from_days(days, shape, DEFAULT_DURATION_FORMAT)
594}
595
596fn broadcast_component_data(
597    arrays: &[Tensor],
598    labels: &[&str],
599) -> BuiltinResult<(Vec<Vec<f64>>, Vec<usize>)> {
600    let mut target_shape = vec![1, 1];
601    let mut target_len = 1usize;
602
603    for array in arrays {
604        let len = array.len();
605        if len > 1 {
606            let shape = tensor::default_shape_for(&array.shape, len);
607            if target_len == 1 {
608                target_len = len;
609                target_shape = shape;
610            } else if len != target_len || shape != target_shape {
611                return Err(duration_error(
612                    "duration: non-scalar component inputs must have matching sizes",
613                ));
614            }
615        }
616    }
617
618    let mut broadcasted = Vec::with_capacity(arrays.len());
619    for (idx, array) in arrays.iter().enumerate() {
620        let values = array
621            .as_f64_slice()
622            .expect("duration components are normalized to double storage");
623        if values.len() == 1 {
624            broadcasted.push(vec![values[0]; target_len]);
625        } else if values.len() == target_len {
626            broadcasted.push(values.to_vec());
627        } else {
628            return Err(duration_error(format!(
629                "duration: {} input size does not match the other components",
630                labels[idx]
631            )));
632        }
633    }
634
635    Ok((broadcasted, target_shape))
636}
637
638fn build_from_components(args: Vec<Value>, format: Option<String>) -> BuiltinResult<Value> {
639    let labels = ["hours", "minutes", "seconds", "milliseconds"];
640    let mut arrays = Vec::with_capacity(args.len());
641    for (idx, arg) in args.into_iter().enumerate() {
642        arrays.push(component_tensor(arg, labels[idx])?);
643    }
644    while arrays.len() < 4 {
645        arrays.push(Tensor::new(vec![0.0], vec![1, 1]).unwrap());
646    }
647
648    let (broadcasted, shape) = broadcast_component_data(&arrays, &labels)?;
649    let len = broadcasted[0].len();
650    let mut days = Vec::with_capacity(len);
651    for idx in 0..len {
652        let total_seconds = broadcasted[0][idx] * 3600.0
653            + broadcasted[1][idx] * 60.0
654            + broadcasted[2][idx]
655            + broadcasted[3][idx] / 1000.0;
656        days.push(total_seconds / SECONDS_PER_DAY);
657    }
658
659    duration_object_from_days(
660        days,
661        shape,
662        format.unwrap_or_else(|| DEFAULT_DURATION_FORMAT.to_string()),
663    )
664}
665
666fn is_public_duration_matrix(value: &Value) -> bool {
667    match value {
668        Value::Tensor(tensor) => tensor.shape.len() <= 2 && tensor.cols() == 3,
669        Value::GpuTensor(handle) => {
670            handle.shape.len() <= 2 && handle.shape.get(1).copied().unwrap_or(1) == 3
671        }
672        _ => false,
673    }
674}
675
676fn is_numeric_duration_input(value: &Value) -> bool {
677    matches!(
678        value,
679        Value::Num(_) | Value::Int(_) | Value::Tensor(_) | Value::GpuTensor(_)
680    )
681}
682
683fn build_from_matrix(value: Value, format: Option<String>) -> BuiltinResult<Value> {
684    let matrix = component_tensor(value, "X")?;
685    if matrix.shape.len() > 2 || matrix.cols() != 3 {
686        return Err(duration_error(
687            "duration: X must be a numeric matrix with exactly three columns",
688        ));
689    }
690    let rows = matrix.rows();
691    let values = matrix
692        .as_f64_slice()
693        .expect("duration matrix is normalized to double storage");
694    let mut days = Vec::with_capacity(rows);
695    for row in 0..rows {
696        let total_seconds =
697            values[row] * 3600.0 + values[row + rows] * 60.0 + values[row + 2 * rows];
698        days.push(total_seconds / SECONDS_PER_DAY);
699    }
700    duration_object_from_days(
701        days,
702        vec![rows, 1],
703        format.unwrap_or_else(|| DEFAULT_DURATION_FORMAT.to_string()),
704    )
705}
706
707fn format_seconds_field(seconds: f64) -> String {
708    let whole = seconds.floor();
709    let fractional = seconds - whole;
710    if fractional.abs() <= 1e-9 {
711        format!("{:02}", whole as i64)
712    } else {
713        let mut text = format!("{:06.3}", seconds);
714        while text.contains('.') && text.ends_with('0') {
715            text.pop();
716        }
717        if text.ends_with('.') {
718            text.pop();
719        }
720        text
721    }
722}
723
724fn format_duration_value(days: f64, format: &str) -> BuiltinResult<String> {
725    if days.is_nan() {
726        return Ok("NaN".to_string());
727    }
728    if days == f64::INFINITY {
729        return Ok("Inf".to_string());
730    }
731    if days == f64::NEG_INFINITY {
732        return Ok("-Inf".to_string());
733    }
734
735    let total_seconds = days * SECONDS_PER_DAY;
736    let sign = if total_seconds < 0.0 { "-" } else { "" };
737    let total_seconds = total_seconds.abs();
738    let total_hours = (total_seconds / 3600.0).floor();
739    let total_minutes = (total_seconds / 60.0).floor();
740    let hours = total_hours as i64;
741    let minutes_component = ((total_seconds / 60.0).floor() as i64) % 60;
742    let seconds_component =
743        total_seconds - (hours as f64 * 3600.0) - (minutes_component as f64 * 60.0);
744
745    let rendered = match format {
746        "hh:mm:ss" => format!(
747            "{sign}{hours:02}:{minutes_component:02}:{}",
748            format_seconds_field(seconds_component)
749        ),
750        "hh:mm" => format!("{sign}{hours:02}:{minutes_component:02}"),
751        "mm:ss" => format!(
752            "{sign}{:02}:{}",
753            total_minutes as i64,
754            format_seconds_field(total_seconds - total_minutes * 60.0)
755        ),
756        "s" | "ss" => {
757            let mut text = format!("{:.3}", total_seconds);
758            while text.contains('.') && text.ends_with('0') {
759                text.pop();
760            }
761            if text.ends_with('.') {
762                text.pop();
763            }
764            format!("{sign}{text}")
765        }
766        other => {
767            return Err(duration_error(format!(
768                "duration: unsupported Format value '{other}'"
769            )))
770        }
771    };
772
773    Ok(rendered)
774}
775
776pub fn duration_string_array(value: &Value) -> BuiltinResult<Option<StringArray>> {
777    let Value::Object(obj) = value else {
778        return Ok(None);
779    };
780    if !obj.is_class(DURATION_CLASS) {
781        return Ok(None);
782    }
783    let days = duration_tensor_from_duration_value(value)?;
784    let format = format_for_object(obj);
785    let day_values = tensor::tensor_values_f64_cow(&days);
786    let mut strings = Vec::with_capacity(day_values.len());
787    for value in day_values.iter() {
788        strings.push(format_duration_value(*value, &format)?);
789    }
790    let shape = tensor::default_shape_for(&days.shape, day_values.len());
791    let array = StringArray::new(strings, shape)
792        .map_err(|err| duration_error(format!("duration: {err}")))?;
793    Ok(Some(array))
794}
795
796pub fn duration_display_text(value: &Value) -> BuiltinResult<Option<String>> {
797    let Some(array) = duration_string_array(value)? else {
798        return Ok(None);
799    };
800    if array.data.len() == 1 {
801        return Ok(Some(array.data[0].clone()));
802    }
803
804    let rows = array.rows;
805    let cols = array.cols;
806    let mut widths = vec![0usize; cols];
807    for col in 0..cols {
808        for row in 0..rows {
809            let idx = row + col * rows;
810            widths[col] = widths[col].max(array.data[idx].len());
811        }
812    }
813
814    let mut lines = Vec::with_capacity(rows);
815    for row in 0..rows {
816        let mut line = String::new();
817        for col in 0..cols {
818            if col > 0 {
819                line.push_str("  ");
820            }
821            let idx = row + col * rows;
822            let text = &array.data[idx];
823            line.push_str(text);
824            let padding = widths[col].saturating_sub(text.len());
825            if padding > 0 {
826                line.push_str(&" ".repeat(padding));
827            }
828        }
829        lines.push(line);
830    }
831
832    Ok(Some(lines.join("\n")))
833}
834
835pub fn duration_summary(value: &Value) -> BuiltinResult<Option<String>> {
836    let Value::Object(obj) = value else {
837        return Ok(None);
838    };
839    if !obj.is_class(DURATION_CLASS) {
840        return Ok(None);
841    }
842    let days = duration_tensor_from_duration_value(value)?;
843    let len = days.len();
844    if len == 1 {
845        return duration_display_text(value);
846    }
847    let shape = tensor::default_shape_for(&days.shape, len);
848    Ok(Some(format!(
849        "[{} duration]",
850        shape
851            .iter()
852            .map(|dim| dim.to_string())
853            .collect::<Vec<_>>()
854            .join("x")
855    )))
856}
857
858pub fn duration_char_array(value: &Value) -> BuiltinResult<Option<CharArray>> {
859    let Some(array) = duration_string_array(value)? else {
860        return Ok(None);
861    };
862    let width = array.data.iter().map(String::len).max().unwrap_or(0);
863    let rows = array.data.len();
864    let mut data = vec![' '; rows * width];
865    for (row, text) in array.data.iter().enumerate() {
866        for (col, ch) in text.chars().enumerate() {
867            data[row * width + col] = ch;
868        }
869    }
870    let out = CharArray::new(data, rows, width)
871        .map_err(|err| duration_error(format!("duration: {err}")))?;
872    Ok(Some(out))
873}
874
875fn compare_duration(
876    lhs: Value,
877    rhs: Value,
878    op: &str,
879    cmp: impl Fn(f64, f64) -> bool,
880) -> BuiltinResult<Value> {
881    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
882    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
883    let (left, right, shape) =
884        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, op, BUILTIN_NAME)?;
885    let out = left
886        .iter()
887        .zip(right.iter())
888        .map(|(a, b)| if cmp(*a, *b) { 1.0 } else { 0.0 })
889        .collect::<Vec<_>>();
890    if out.len() == 1 {
891        Ok(Value::Num(out[0]))
892    } else {
893        Ok(Value::Tensor(Tensor::new(out, shape).map_err(|err| {
894            duration_error(format!("duration: {err}"))
895        })?))
896    }
897}
898
899async fn duration_indexing(obj: Value, payload: Value) -> BuiltinResult<Value> {
900    let Value::Object(object) = obj else {
901        return Err(duration_error(
902            "duration.subsref: receiver must be a duration object",
903        ));
904    };
905    let format = format_for_object(&object);
906    let days = duration_tensor_from_duration_value(&Value::Object(object.clone()))?;
907
908    let Value::Cell(cell) = payload else {
909        return Err(duration_error(
910            "duration.subsref: indexing payload must be a cell array",
911        ));
912    };
913    if cell.data.is_empty() {
914        return duration_object_from_days_tensor(days, format);
915    }
916    if cell.data.len() != 1 {
917        return Err(duration_error(
918            "duration.subsref: only linear duration indexing is currently supported",
919        ));
920    }
921    let selector = cell.data[0].clone();
922    let selector = match selector {
923        Value::Tensor(tensor) => tensor,
924        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
925            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
926        Value::Int(value) => {
927            Tensor::new_integer(runmat_value::IntegerStorage::from_scalar(value), vec![1, 1])
928                .map_err(|err| duration_error(format!("duration.subsref: {err}")))?
929        }
930        Value::LogicalArray(logical) => tensor::logical_to_tensor(&logical)
931            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
932        other => {
933            return Err(duration_error(format!(
934                "duration.subsref: unsupported index value {other:?}"
935            )))
936        }
937    };
938    let indexed =
939        crate::perform_indexing(&Value::Tensor(days), &tensor::tensor_values_f64(&selector))
940            .await
941            .map_err(|err| duration_error(format!("duration.subsref: {}", err.message())))?;
942    let indexed_days = match indexed {
943        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
944            .map_err(|err| duration_error(format!("duration.subsref: {err}")))?,
945        Value::Tensor(tensor) => tensor,
946        other => {
947            return Err(duration_error(format!(
948                "duration.subsref: unexpected indexing result {other:?}"
949            )))
950        }
951    };
952    duration_object_from_days_tensor(indexed_days, format)
953}
954
955#[runmat_macros::runtime_builtin(
956    name = "duration",
957    descriptor(crate::builtins::duration::DURATION_DESCRIPTOR),
958    builtin_path = "crate::builtins::duration",
959    category = "datetime",
960    summary = "Create duration arrays from hour, minute, and second components.",
961    keywords = "duration,time span,elapsed time,Format",
962    related = "datetime,string,char,disp",
963    examples = "t = duration(1, 30, 45);",
964    extensions(DURATION_EXTENSIONS),
965    integer_capabilities(DURATION_INTEGER_CAPABILITIES)
966)]
967async fn duration_builtin(args: Vec<Value>) -> crate::BuiltinResult<Value> {
968    ensure_duration_class_registered();
969    let (raw_positional_end, _) = parse_trailing_format(&args)?;
970    let raw_positional = &args[..raw_positional_end];
971    if args.iter().any(crate::value_contains_gpu) {
972        crate::compatibility::ensure_builtin_extension_enabled(
973            &DURATION_GPU_INPUT_EXTENSION,
974            BUILTIN_NAME,
975        )?;
976    }
977    let short_numeric_form = match raw_positional {
978        [value] => is_numeric_duration_input(value) && !is_public_duration_matrix(value),
979        [first, second] => is_numeric_duration_input(first) && is_numeric_duration_input(second),
980        _ => false,
981    };
982    if short_numeric_form {
983        crate::compatibility::ensure_builtin_extension_enabled(
984            &DURATION_SHORT_COMPONENT_EXTENSION,
985            BUILTIN_NAME,
986        )?;
987    }
988    let args = gather_args(&args).await?;
989    let (positional_end, format) = parse_trailing_format(&args)?;
990    let mut positional = args[..positional_end].to_vec();
991
992    match positional.len() {
993        1 if is_public_duration_matrix(&positional[0]) => {
994            build_from_matrix(positional.remove(0), format)
995        }
996        1..=4 if positional.iter().all(is_numeric_duration_input) => {
997            build_from_components(positional, format)
998        }
999        _ => Err(duration_error(
1000            "duration: unsupported argument pattern; use X or H/MI/S/MS numeric inputs",
1001        )),
1002    }
1003}
1004
1005#[runmat_macros::runtime_builtin(
1006    name = "days",
1007    builtin_path = "crate::builtins::duration",
1008    category = "datetime",
1009    summary = "Create duration values from days or convert duration values to day counts.",
1010    keywords = "days,duration,datetime"
1011)]
1012async fn days_builtin(value: Value) -> crate::BuiltinResult<Value> {
1013    duration_unit_value(value, "days", 1.0).await
1014}
1015
1016#[runmat_macros::runtime_builtin(
1017    name = "hours",
1018    builtin_path = "crate::builtins::duration",
1019    category = "datetime",
1020    summary = "Create duration values from hours or convert duration values to hour counts.",
1021    keywords = "hours,duration,datetime"
1022)]
1023async fn hours_builtin(value: Value) -> crate::BuiltinResult<Value> {
1024    duration_unit_value(value, "hours", 1.0 / 24.0).await
1025}
1026
1027#[runmat_macros::runtime_builtin(
1028    name = "minutes",
1029    builtin_path = "crate::builtins::duration",
1030    category = "datetime",
1031    summary = "Create duration values from minutes or convert duration values to minute counts.",
1032    keywords = "minutes,duration,datetime"
1033)]
1034async fn minutes_builtin(value: Value) -> crate::BuiltinResult<Value> {
1035    duration_unit_value(value, "minutes", 1.0 / (24.0 * 60.0)).await
1036}
1037
1038#[runmat_macros::runtime_builtin(
1039    name = "seconds",
1040    builtin_path = "crate::builtins::duration",
1041    category = "datetime",
1042    summary = "Create duration values from seconds or convert duration values to second counts.",
1043    keywords = "seconds,duration,datetime"
1044)]
1045async fn seconds_builtin(value: Value) -> crate::BuiltinResult<Value> {
1046    duration_unit_value(value, "seconds", 1.0 / SECONDS_PER_DAY).await
1047}
1048
1049#[runmat_macros::runtime_builtin(
1050    name = "milliseconds",
1051    builtin_path = "crate::builtins::duration",
1052    category = "datetime",
1053    summary = "Create duration values from milliseconds or convert duration values to millisecond counts.",
1054    keywords = "milliseconds,duration,datetime"
1055)]
1056async fn milliseconds_builtin(value: Value) -> crate::BuiltinResult<Value> {
1057    duration_unit_value(value, "milliseconds", 1.0 / (SECONDS_PER_DAY * 1000.0)).await
1058}
1059
1060#[runmat_macros::runtime_builtin(
1061    name = "years",
1062    builtin_path = "crate::builtins::duration",
1063    category = "datetime",
1064    summary = "Create fixed-length duration values from years or convert durations to fixed-length years.",
1065    keywords = "years,duration,datetime"
1066)]
1067async fn years_builtin(value: Value) -> crate::BuiltinResult<Value> {
1068    duration_unit_value(value, "years", 365.2425).await
1069}
1070
1071#[runmat_macros::runtime_builtin(
1072    name = "isduration",
1073    builtin_path = "crate::builtins::duration",
1074    category = "datetime",
1075    summary = "Return true for duration values.",
1076    keywords = "isduration,duration,predicate"
1077)]
1078fn isduration_builtin(value: Value) -> crate::BuiltinResult<Value> {
1079    Ok(Value::Bool(is_duration_object(&value)))
1080}
1081
1082#[runmat_macros::runtime_builtin(
1083    name = "duration.subsref",
1084    descriptor(crate::builtins::duration::DURATION_SUBSREF_DESCRIPTOR),
1085    builtin_path = "crate::builtins::duration"
1086)]
1087async fn duration_subsref(obj: Value, kind: String, payload: Value) -> crate::BuiltinResult<Value> {
1088    match kind.as_str() {
1089        OBJECT_INDEX_PAREN => duration_indexing(obj, payload).await,
1090        OBJECT_INDEX_MEMBER => {
1091            let Value::Object(object) = obj else {
1092                return Err(duration_error(
1093                    "duration.subsref: receiver must be a duration object",
1094                ));
1095            };
1096            let field = scalar_text(&payload, "field selector")?;
1097            match field.as_str() {
1098                FORMAT_FIELD => Ok(Value::String(format_for_object(&object))),
1099                _ => Err(duration_error(format!(
1100                    "duration.subsref: unsupported duration property '{field}'"
1101                ))),
1102            }
1103        }
1104        other => Err(duration_error(format!(
1105            "duration.subsref: unsupported indexing kind '{other}'"
1106        ))),
1107    }
1108}
1109
1110#[runmat_macros::runtime_builtin(
1111    name = "duration.subsasgn",
1112    descriptor(crate::builtins::duration::DURATION_SUBSASGN_DESCRIPTOR),
1113    builtin_path = "crate::builtins::duration"
1114)]
1115async fn duration_subsasgn(
1116    obj: Value,
1117    kind: String,
1118    payload: Value,
1119    rhs: Value,
1120) -> crate::BuiltinResult<Value> {
1121    let Value::Object(mut object) = obj else {
1122        return Err(duration_error(
1123            "duration.subsasgn: receiver must be a duration object",
1124        ));
1125    };
1126    match kind.as_str() {
1127        OBJECT_INDEX_MEMBER => {
1128            let field = scalar_text(&payload, "field selector")?;
1129            match field.as_str() {
1130                FORMAT_FIELD => {
1131                    let text = scalar_text(&rhs, "Format value")?;
1132                    object
1133                        .properties
1134                        .insert(FORMAT_FIELD.to_string(), Value::String(text));
1135                    Ok(Value::Object(object))
1136                }
1137                _ => Err(duration_error(format!(
1138                    "duration.subsasgn: unsupported duration property '{field}'"
1139                ))),
1140            }
1141        }
1142        _ => Err(duration_error(format!(
1143            "duration.subsasgn: unsupported indexing kind '{kind}'"
1144        ))),
1145    }
1146}
1147
1148#[runmat_macros::runtime_builtin(
1149    name = "duration.eq",
1150    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1151    builtin_path = "crate::builtins::duration"
1152)]
1153async fn duration_eq(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1154    compare_duration(lhs, rhs, "eq", |a, b| (a - b).abs() <= 1e-12)
1155}
1156
1157#[runmat_macros::runtime_builtin(
1158    name = "duration.ne",
1159    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1160    builtin_path = "crate::builtins::duration"
1161)]
1162async fn duration_ne(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1163    compare_duration(lhs, rhs, "ne", |a, b| (a - b).abs() > 1e-12)
1164}
1165
1166#[runmat_macros::runtime_builtin(
1167    name = "duration.lt",
1168    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1169    builtin_path = "crate::builtins::duration"
1170)]
1171async fn duration_lt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1172    compare_duration(lhs, rhs, "lt", |a, b| a < b)
1173}
1174
1175#[runmat_macros::runtime_builtin(
1176    name = "duration.le",
1177    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1178    builtin_path = "crate::builtins::duration"
1179)]
1180async fn duration_le(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1181    compare_duration(lhs, rhs, "le", |a, b| a <= b)
1182}
1183
1184#[runmat_macros::runtime_builtin(
1185    name = "duration.gt",
1186    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1187    builtin_path = "crate::builtins::duration"
1188)]
1189async fn duration_gt(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1190    compare_duration(lhs, rhs, "gt", |a, b| a > b)
1191}
1192
1193#[runmat_macros::runtime_builtin(
1194    name = "duration.ge",
1195    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1196    builtin_path = "crate::builtins::duration"
1197)]
1198async fn duration_ge(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1199    compare_duration(lhs, rhs, "ge", |a, b| a >= b)
1200}
1201
1202#[runmat_macros::runtime_builtin(
1203    name = "duration.plus",
1204    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1205    builtin_path = "crate::builtins::duration"
1206)]
1207async fn duration_plus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1208    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
1209    if crate::builtins::datetime::is_datetime_object(&rhs) {
1210        let rhs_serials = crate::builtins::datetime::serials_from_datetime_value(&rhs)?;
1211        let (left, right, shape) =
1212            tensor::binary_numeric_tensors(&lhs_days, &rhs_serials, "plus", BUILTIN_NAME)?;
1213        let serials = left
1214            .iter()
1215            .zip(right.iter())
1216            .map(|(a, b)| a + b)
1217            .collect::<Vec<_>>();
1218        let tensor =
1219            Tensor::new(serials, shape).map_err(|err| duration_error(format!("plus: {err}")))?;
1220        return crate::builtins::datetime::datetime_object_from_serial_tensor(
1221            tensor,
1222            crate::builtins::datetime::datetime_format_from_value(&rhs),
1223        );
1224    }
1225
1226    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
1227    let (left, right, shape) =
1228        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "plus", BUILTIN_NAME)?;
1229    let days = left
1230        .iter()
1231        .zip(right.iter())
1232        .map(|(a, b)| a + b)
1233        .collect::<Vec<_>>();
1234    duration_object_from_days(days, shape, duration_format_from_value(&lhs))
1235}
1236
1237#[runmat_macros::runtime_builtin(
1238    name = "duration.minus",
1239    descriptor(crate::builtins::duration::DURATION_BINARY_DESCRIPTOR),
1240    builtin_path = "crate::builtins::duration"
1241)]
1242async fn duration_minus(lhs: Value, rhs: Value) -> crate::BuiltinResult<Value> {
1243    let lhs_days = duration_tensor_from_duration_value(&lhs)?;
1244    let rhs_days = duration_tensor_from_duration_value(&rhs)?;
1245    let (left, right, shape) =
1246        tensor::binary_numeric_tensors(&lhs_days, &rhs_days, "minus", BUILTIN_NAME)?;
1247    let days = left
1248        .iter()
1249        .zip(right.iter())
1250        .map(|(a, b)| a - b)
1251        .collect::<Vec<_>>();
1252    duration_object_from_days(days, shape, duration_format_from_value(&lhs))
1253}
1254
1255#[cfg(test)]
1256mod tests {
1257    use super::*;
1258
1259    fn run_duration(args: Vec<Value>) -> Value {
1260        futures::executor::block_on(duration_builtin(args)).expect("duration")
1261    }
1262
1263    fn integer_tensor(storage: runmat_value::IntegerStorage, shape: Vec<usize>) -> Value {
1264        Value::Tensor(Tensor::new_integer(storage, shape).expect("integer tensor"))
1265    }
1266
1267    #[test]
1268    fn duration_descriptor_signatures_cover_constructor_and_methods() {
1269        let labels: Vec<&str> = DURATION_DESCRIPTOR
1270            .signatures
1271            .iter()
1272            .map(|sig| sig.label)
1273            .collect();
1274        assert!(labels.contains(&"t = duration(X)"));
1275        assert!(labels.contains(&"t = duration(hours, minutes, seconds)"));
1276        assert!(labels.contains(&"t = duration(hours, minutes, seconds, milliseconds)"));
1277        assert!(labels.contains(&"t = duration(___, \"Format\", format)"));
1278
1279        let four_component = DURATION_DESCRIPTOR
1280            .signatures
1281            .iter()
1282            .find(|signature| {
1283                signature.label == "t = duration(hours, minutes, seconds, milliseconds)"
1284            })
1285            .expect("four-component duration signature");
1286        assert_eq!(
1287            four_component
1288                .inputs
1289                .iter()
1290                .map(|input| input.name)
1291                .collect::<Vec<_>>(),
1292            ["hours", "minutes", "seconds", "milliseconds"]
1293        );
1294        assert!(four_component.inputs.iter().all(|input| {
1295            matches!(input.ty, BuiltinParamType::NumericArray)
1296                && matches!(input.arity, BuiltinParamArity::Required)
1297        }));
1298
1299        assert_eq!(
1300            DURATION_SUBSREF_DESCRIPTOR.signatures[0].label,
1301            "out = duration.subsref(obj, kind, payload)"
1302        );
1303        assert_eq!(
1304            DURATION_BINARY_DESCRIPTOR.signatures[0].label,
1305            "out = duration.op(lhs, rhs)"
1306        );
1307    }
1308
1309    #[test]
1310    fn duration_builds_from_components() {
1311        let value = run_duration(vec![Value::Num(1.0), Value::Num(30.0), Value::Num(45.0)]);
1312        let rendered = duration_display_text(&value)
1313            .expect("display")
1314            .expect("duration text");
1315        assert_eq!(rendered, "01:30:45");
1316    }
1317
1318    #[test]
1319    fn duration_formats_arrays() {
1320        let hours = Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap());
1321        let minutes = Value::Tensor(Tensor::new(vec![15.0, 45.0], vec![1, 2]).unwrap());
1322        let value = run_duration(vec![hours, minutes, Value::Num(0.0)]);
1323        let rendered = duration_display_text(&value)
1324            .expect("display")
1325            .expect("duration text");
1326        assert!(rendered.contains("01:15:00"));
1327        assert!(rendered.contains("02:45:00"));
1328    }
1329
1330    #[test]
1331    fn duration_typed_integer_components_cross_double_boundary_exactly() {
1332        let hours = integer_tensor(runmat_value::IntegerStorage::U8(vec![1, 2]), vec![1, 2]);
1333        let minutes = integer_tensor(runmat_value::IntegerStorage::U16(vec![15, 45]), vec![1, 2]);
1334        let seconds = integer_tensor(runmat_value::IntegerStorage::I16(vec![0, 30]), vec![1, 2]);
1335        let value = run_duration(vec![hours, minutes, seconds]);
1336        let rendered = duration_display_text(&value)
1337            .expect("display")
1338            .expect("duration text");
1339        assert!(rendered.contains("01:15:00"));
1340        assert!(rendered.contains("02:45:30"));
1341    }
1342
1343    #[test]
1344    fn duration_integer_matrix_form_supports_all_classes_and_returns_column() {
1345        let storages = [
1346            runmat_value::IntegerStorage::I8(vec![1, 2, 15, 45, 0, 30]),
1347            runmat_value::IntegerStorage::I16(vec![1, 2, 15, 45, 0, 30]),
1348            runmat_value::IntegerStorage::I32(vec![1, 2, 15, 45, 0, 30]),
1349            runmat_value::IntegerStorage::I64(vec![1, 2, 15, 45, 0, 30]),
1350            runmat_value::IntegerStorage::U8(vec![1, 2, 15, 45, 0, 30]),
1351            runmat_value::IntegerStorage::U16(vec![1, 2, 15, 45, 0, 30]),
1352            runmat_value::IntegerStorage::U32(vec![1, 2, 15, 45, 0, 30]),
1353            runmat_value::IntegerStorage::U64(vec![1, 2, 15, 45, 0, 30]),
1354        ];
1355        for storage in storages {
1356            let value = run_duration(vec![integer_tensor(storage, vec![2, 3])]);
1357            let days = duration_tensor_from_duration_value(&value).expect("duration days");
1358            assert_eq!(days.shape, vec![2, 1]);
1359            let rendered = duration_display_text(&value)
1360                .expect("display")
1361                .expect("duration text");
1362            assert!(rendered.contains("01:15:00"));
1363            assert!(rendered.contains("02:45:30"));
1364        }
1365    }
1366
1367    #[test]
1368    fn duration_four_component_form_adds_milliseconds() {
1369        let value = run_duration(vec![
1370            Value::Tensor(Tensor::new(vec![0.0, 1.0], vec![1, 2]).unwrap()),
1371            Value::Num(0.0),
1372            Value::Num(1.0),
1373            Value::Int(runmat_value::IntValue::U16(250)),
1374        ]);
1375        let days = duration_tensor_from_duration_value(&value).expect("duration days");
1376        assert_eq!(days.shape, vec![1, 2]);
1377        let seconds: Vec<f64> = days
1378            .materialize_f64()
1379            .into_iter()
1380            .map(|days| days * SECONDS_PER_DAY)
1381            .collect();
1382        assert!((seconds[0] - 1.25).abs() < 1.0e-12);
1383        assert!((seconds[1] - 3601.25).abs() < 1.0e-9);
1384    }
1385
1386    #[test]
1387    fn duration_public_components_preserve_nan_and_infinity() {
1388        let value = run_duration(vec![Value::Num(f64::NAN), Value::Num(0.0), Value::Num(0.0)]);
1389        assert!(duration_tensor_from_duration_value(&value)
1390            .unwrap()
1391            .materialize_f64()[0]
1392            .is_nan());
1393        for infinite in [f64::INFINITY, f64::NEG_INFINITY] {
1394            let value = run_duration(vec![Value::Num(infinite), Value::Num(0.0), Value::Num(0.0)]);
1395            assert_eq!(
1396                duration_tensor_from_duration_value(&value)
1397                    .unwrap()
1398                    .materialize_f64()[0],
1399                infinite
1400            );
1401            let expected = if infinite.is_sign_negative() {
1402                "-Inf"
1403            } else {
1404                "Inf"
1405            };
1406            assert_eq!(
1407                duration_display_text(&value).expect("display"),
1408                Some(expected.to_string())
1409            );
1410            assert_eq!(
1411                duration_string_array(&value)
1412                    .expect("string conversion")
1413                    .expect("duration string array")
1414                    .data,
1415                vec![expected.to_string()]
1416            );
1417            let chars = duration_char_array(&value)
1418                .expect("char conversion")
1419                .expect("duration char array");
1420            assert_eq!(chars.data.iter().collect::<String>(), expected);
1421        }
1422    }
1423
1424    #[test]
1425    fn duration_short_numeric_forms_are_extension_gated_but_matrix_is_public() {
1426        let strict = crate::compatibility::push_runmat_extensions_enabled(false);
1427        let error = futures::executor::block_on(duration_builtin(vec![Value::Num(1.0)]))
1428            .expect_err("one-component hour extension");
1429        assert_eq!(
1430            error.identifier(),
1431            DURATION_SHORT_COMPONENT_EXTENSION.error_identifier
1432        );
1433        let matrix = integer_tensor(runmat_value::IntegerStorage::U8(vec![1, 30, 0]), vec![1, 3]);
1434        futures::executor::block_on(duration_builtin(vec![matrix]))
1435            .expect("documented matrix form remains public");
1436        drop(strict);
1437
1438        let extensions = crate::compatibility::push_runmat_extensions_enabled(true);
1439        futures::executor::block_on(duration_builtin(vec![Value::Num(1.0)]))
1440            .expect("one-component extension in RunMat mode");
1441        futures::executor::block_on(duration_builtin(vec![Value::Num(1.0), Value::Num(30.0)]))
1442            .expect("two-component extension in RunMat mode");
1443        drop(extensions);
1444    }
1445
1446    #[test]
1447    fn duration_gpu_extension_rejects_before_provider_access() {
1448        let strict = crate::compatibility::push_runmat_extensions_enabled(false);
1449        let resident = Value::GpuTensor(runmat_accelerate_api::GpuTensorHandle {
1450            shape: vec![1, 3],
1451            device_id: 0,
1452            buffer_id: 9_399_002,
1453            descriptor: Default::default(),
1454        });
1455        let error = futures::executor::block_on(duration_builtin(vec![resident]))
1456            .expect_err("GPU extension gate");
1457        assert_eq!(
1458            error.identifier(),
1459            DURATION_GPU_INPUT_EXTENSION.error_identifier
1460        );
1461        drop(strict);
1462    }
1463
1464    #[test]
1465    fn duration_missing_days_render_without_error() {
1466        let value = duration_object_from_days_tensor(
1467            Tensor::new(vec![f64::NAN], vec![1, 1]).unwrap(),
1468            DEFAULT_DURATION_FORMAT,
1469        )
1470        .expect("duration object");
1471        let rendered = duration_string_array(&value)
1472            .expect("string array")
1473            .expect("duration strings");
1474        assert_eq!(rendered.data, vec!["NaN".to_string()]);
1475        assert_eq!(
1476            duration_display_text(&value).expect("display"),
1477            Some("NaN".to_string())
1478        );
1479    }
1480
1481    #[test]
1482    fn duration_unit_helpers_create_and_convert_values() {
1483        let one_day = futures::executor::block_on(days_builtin(Value::Num(1.0))).expect("days");
1484        assert!(is_duration_object(&one_day));
1485        let as_hours = futures::executor::block_on(hours_builtin(one_day.clone())).expect("hours");
1486        assert_eq!(as_hours, Value::Num(24.0));
1487        let as_minutes =
1488            futures::executor::block_on(minutes_builtin(one_day.clone())).expect("minutes");
1489        assert_eq!(as_minutes, Value::Num(1440.0));
1490        let as_seconds =
1491            futures::executor::block_on(seconds_builtin(one_day.clone())).expect("seconds");
1492        assert_eq!(as_seconds, Value::Num(86_400.0));
1493        let as_millis =
1494            futures::executor::block_on(milliseconds_builtin(one_day.clone())).expect("millis");
1495        assert_eq!(as_millis, Value::Num(86_400_000.0));
1496
1497        let two_hours = futures::executor::block_on(hours_builtin(Value::Num(2.0))).expect("hours");
1498        let rendered = duration_display_text(&two_hours)
1499            .expect("display")
1500            .expect("duration text");
1501        assert_eq!(rendered, "02:00:00");
1502
1503        let year = futures::executor::block_on(years_builtin(Value::Num(1.0))).expect("years");
1504        let year_days = duration_tensor_from_duration_value(&year).expect("duration tensor");
1505        assert!((tensor::tensor_value_f64(&year_days, 0) - 365.2425).abs() < 1e-9);
1506        assert_eq!(
1507            isduration_builtin(year).expect("isduration"),
1508            Value::Bool(true)
1509        );
1510        assert_eq!(
1511            isduration_builtin(Value::Num(1.0)).expect("isduration"),
1512            Value::Bool(false)
1513        );
1514        assert!(futures::executor::block_on(years_builtin(Value::Num(f64::MAX))).is_err());
1515    }
1516
1517    #[test]
1518    fn duration_unit_helpers_read_typed_integer_days_exactly() {
1519        let days = Tensor::new_integer(runmat_value::IntegerStorage::I16(vec![1, 2]), vec![1, 2])
1520            .expect("integer tensor");
1521        let value = duration_object_from_days_tensor(days, DEFAULT_DURATION_FORMAT)
1522            .expect("duration object");
1523
1524        let hours = futures::executor::block_on(hours_builtin(value.clone())).expect("hours");
1525        assert_eq!(
1526            hours,
1527            Value::Tensor(Tensor::new(vec![24.0, 48.0], vec![1, 2]).unwrap())
1528        );
1529
1530        let rendered = duration_display_text(&value)
1531            .expect("display")
1532            .expect("duration text");
1533        assert!(rendered.contains("24:00:00"));
1534        assert!(rendered.contains("48:00:00"));
1535        assert_eq!(
1536            duration_summary(&value).expect("summary"),
1537            Some("[1x2 duration]".to_string())
1538        );
1539    }
1540
1541    #[test]
1542    fn duration_supports_format_assignment_and_indexing() {
1543        let value = run_duration(vec![Value::Num(1.0), Value::Num(5.0), Value::Num(0.0)]);
1544        let updated = futures::executor::block_on(duration_subsasgn(
1545            value.clone(),
1546            ".".to_string(),
1547            Value::String(FORMAT_FIELD.to_string()),
1548            Value::String("hh:mm".to_string()),
1549        ))
1550        .expect("subsasgn");
1551        let rendered = duration_display_text(&updated)
1552            .expect("display")
1553            .expect("duration text");
1554        assert_eq!(rendered, "01:05");
1555
1556        let array = run_duration(vec![
1557            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
1558            Value::Num(0.0),
1559            Value::Num(0.0),
1560        ]);
1561        let payload =
1562            Value::Cell(runmat_value::CellArray::new(vec![Value::Num(2.0)], 1, 1).unwrap());
1563        let indexed =
1564            futures::executor::block_on(duration_subsref(array, "()".to_string(), payload))
1565                .expect("subsref");
1566        let text = duration_display_text(&indexed)
1567            .expect("display")
1568            .expect("duration text");
1569        assert_eq!(text, "02:00:00");
1570    }
1571
1572    #[test]
1573    fn duration_typed_integer_index_selectors_are_exact() {
1574        let array = run_duration(vec![
1575            integer_tensor(runmat_value::IntegerStorage::U8(vec![1, 2]), vec![1, 2]),
1576            Value::Num(0.0),
1577            Value::Num(0.0),
1578        ]);
1579        let payload = Value::Cell(
1580            runmat_value::CellArray::new(
1581                vec![integer_tensor(
1582                    runmat_value::IntegerStorage::U64(vec![2]),
1583                    vec![1, 1],
1584                )],
1585                1,
1586                1,
1587            )
1588            .unwrap(),
1589        );
1590        let indexed =
1591            futures::executor::block_on(duration_subsref(array, "()".to_string(), payload))
1592                .expect("subsref");
1593        let text = duration_display_text(&indexed)
1594            .expect("display")
1595            .expect("duration text");
1596        assert_eq!(text, "02:00:00");
1597    }
1598}