Skip to main content

runmat_runtime/builtins/math/rounding/
mod.rs

1//! MATLAB-compatible `mod` builtin plus rounding helpers for RunMat.
2
3pub(crate) mod ceil;
4pub(crate) mod fix;
5pub(crate) mod floor;
6pub(crate) mod rem;
7pub(crate) mod round;
8
9use runmat_accelerate_api::GpuTensorHandle;
10use runmat_builtins::{
11    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinIntegerBackendRule,
12    BuiltinIntegerCapabilityDescriptor, BuiltinIntegerComputationDomain,
13    BuiltinIntegerInputAvailability, BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule,
14    BuiltinIntegerOverflowRule, BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule,
15    BuiltinOutputMode, BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType,
16    BuiltinSignatureDescriptor,
17};
18use runmat_macros::runtime_builtin;
19use runmat_value::{ComplexTensor, NumericDType, Tensor, Value};
20
21use crate::builtins::common::broadcast::BroadcastPlan;
22use crate::builtins::common::spec::{
23    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, FusionError,
24    FusionExprContext, FusionKernelTemplate, GpuOpKind, ProviderHook, ReductionNaN,
25    ResidencyPolicy, ScalarType, ShapeRequirements,
26};
27use crate::builtins::common::{gpu_helpers, tensor};
28use crate::builtins::math::elementwise::integer_arithmetic::{
29    reject_integer_logical_operands, try_integer_remainder, IntegerRemainderOp,
30};
31use crate::builtins::math::type_resolvers::numeric_binary_type;
32use crate::{build_runtime_error, BuiltinResult, RuntimeError};
33
34#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::rounding")]
35pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
36    name: "mod",
37    op_kind: GpuOpKind::Elementwise,
38    supported_precisions: &[ScalarType::F32, ScalarType::F64],
39    broadcast: BroadcastSemantics::Matlab,
40    provider_hooks: &[
41        ProviderHook::Binary {
42            name: "elem_div",
43            commutative: false,
44        },
45        ProviderHook::Unary { name: "unary_floor" },
46        ProviderHook::Binary {
47            name: "elem_mul",
48            commutative: false,
49        },
50        ProviderHook::Binary {
51            name: "elem_sub",
52            commutative: false,
53        },
54    ],
55    constant_strategy: ConstantStrategy::InlineLiteral,
56    residency: ResidencyPolicy::NewHandle,
57    nan_mode: ReductionNaN::Include,
58    two_pass_threshold: None,
59    workgroup_size: None,
60    accepts_nan_mode: false,
61    notes:
62        "Native integer providers may execute exact mod directly; floating fallback gathers and reuploads when a dedicated semantically complete provider hook is unavailable.",
63};
64
65#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::rounding")]
66pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
67    name: "mod",
68    shape: ShapeRequirements::BroadcastCompatible,
69    constant_strategy: ConstantStrategy::InlineLiteral,
70    elementwise: Some(FusionKernelTemplate {
71        scalar_precisions: &[ScalarType::F32, ScalarType::F64],
72        wgsl_body: |ctx: &FusionExprContext| {
73            let a = ctx
74                .inputs
75                .first()
76                .ok_or(FusionError::MissingInput(0))?;
77            let b = ctx.inputs.get(1).ok_or(FusionError::MissingInput(1))?;
78            Ok(format!(
79                "select({a} - {b} * floor({a} / {b}), {a}, {b} == 0.0)"
80            ))
81        },
82    }),
83    reduction: None,
84    emits_nan: true,
85    notes: "Fusion applies a - b * floor(a / b), including the documented mod(a, 0) = a convention; providers may substitute specialised kernels when available.",
86};
87
88const BUILTIN_NAME: &str = "mod";
89
90const MOD_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
91    name: "R",
92    ty: BuiltinParamType::NumericArray,
93    arity: BuiltinParamArity::Required,
94    default: None,
95    description: "Element-wise modulus result.",
96}];
97const MOD_INPUTS: [BuiltinParamDescriptor; 2] = [
98    BuiltinParamDescriptor {
99        name: "A",
100        ty: BuiltinParamType::Any,
101        arity: BuiltinParamArity::Required,
102        default: None,
103        description: "Real dividend input (numeric/logical/char).",
104    },
105    BuiltinParamDescriptor {
106        name: "B",
107        ty: BuiltinParamType::Any,
108        arity: BuiltinParamArity::Required,
109        default: None,
110        description: "Real divisor input (numeric/logical/char).",
111    },
112];
113const MOD_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
114    label: "R = mod(A, B)",
115    inputs: &MOD_INPUTS,
116    outputs: &MOD_OUTPUT,
117}];
118const MOD_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
119    code: "RM.MOD.INVALID_INPUT",
120    identifier: Some("RunMat:mod:InvalidInput"),
121    when: "Inputs are complex or cannot be interpreted as real numeric, logical, or char operands.",
122    message: "mod: invalid input",
123};
124const MOD_ERROR_SIZE_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
125    code: "RM.MOD.SIZE_MISMATCH",
126    identifier: Some("RunMat:mod:SizeMismatch"),
127    when: "Operands are not broadcast-compatible.",
128    message: "mod: array sizes are not compatible for broadcasting",
129};
130const MOD_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
131    code: "RM.MOD.INTERNAL",
132    identifier: Some("RunMat:mod:Internal"),
133    when: "Internal tensor conversion, allocation, or provider composition failed.",
134    message: "mod: internal error",
135};
136const MOD_ERRORS: [BuiltinErrorDescriptor; 3] = [
137    MOD_ERROR_INVALID_INPUT,
138    MOD_ERROR_SIZE_MISMATCH,
139    MOD_ERROR_INTERNAL,
140];
141pub const MOD_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
142    signatures: &MOD_SIGNATURES,
143    output_mode: BuiltinOutputMode::Fixed,
144    completion_policy: BuiltinCompletionPolicy::Public,
145    errors: &MOD_ERRORS,
146};
147
148const MOD_INTEGER_INPUTS: [BuiltinIntegerInputCapability; 2] = [
149    BuiltinIntegerInputCapability {
150        name: "A",
151        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
152        availability: BuiltinIntegerInputAvailability::Documented,
153        scalar_double: BuiltinIntegerScalarDoubleRule::AllowedExceptWith64BitInteger,
154        notes: "A is an integer array or a compatible real scalar double.",
155    },
156    BuiltinIntegerInputCapability {
157        name: "B",
158        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
159        availability: BuiltinIntegerInputAvailability::Documented,
160        scalar_double: BuiltinIntegerScalarDoubleRule::AllowedExceptWith64BitInteger,
161        notes: "B is an integer array or a compatible real scalar double.",
162    },
163];
164
165pub const INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
166    [BuiltinIntegerCapabilityDescriptor {
167        form: "R = mod(A, B)",
168        inputs: &MOD_INTEGER_INPUTS,
169        computation_domain: BuiltinIntegerComputationDomain::ExactInteger,
170        output_class: BuiltinIntegerOutputClassRule::PreserveNondoubleInput,
171        overflow: BuiltinIntegerOverflowRule::NotApplicable,
172        backend: BuiltinIntegerBackendRule::HostAndGpu,
173        overload: BuiltinIntegerOverloadKind::BroadcastCompatible,
174        notes: "Native integer storage uses exact floor-remainder semantics; providers may execute exact integer mod directly or gather for a semantically complete fallback.",
175    }];
176
177fn mod_error_with_detail(
178    error: &'static BuiltinErrorDescriptor,
179    detail: impl AsRef<str>,
180) -> RuntimeError {
181    mod_error_with_message(format!("{}: {}", error.message, detail.as_ref()), error)
182}
183
184fn mod_error_with_message(
185    message: impl Into<String>,
186    error: &'static BuiltinErrorDescriptor,
187) -> RuntimeError {
188    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
189    if let Some(identifier) = error.identifier {
190        builder = builder.with_identifier(identifier);
191    }
192    builder.build()
193}
194
195#[runtime_builtin(
196    name = "mod",
197    category = "math/rounding",
198    summary = "MATLAB-compatible real modulus a - b .* floor(a./b) with broadcasting.",
199    keywords = "mod,modulus,remainder,gpu",
200    accel = "binary",
201    type_resolver(numeric_binary_type),
202    descriptor(crate::builtins::math::rounding::MOD_DESCRIPTOR),
203    integer_capabilities(crate::builtins::math::rounding::INTEGER_CAPABILITIES),
204    builtin_path = "crate::builtins::math::rounding"
205)]
206async fn mod_builtin(lhs: Value, rhs: Value) -> BuiltinResult<Value> {
207    if matches!(&lhs, Value::Complex(_, _) | Value::ComplexTensor(_))
208        || matches!(&rhs, Value::Complex(_, _) | Value::ComplexTensor(_))
209    {
210        return Err(mod_error_with_detail(
211            &MOD_ERROR_INVALID_INPUT,
212            "inputs must be real",
213        ));
214    }
215    crate::builtins::common::validation::reject_typed_complex_integer(&lhs, BUILTIN_NAME)?;
216    crate::builtins::common::validation::reject_typed_complex_integer(&rhs, BUILTIN_NAME)?;
217    reject_integer_logical_operands(&lhs, &rhs, BUILTIN_NAME)
218        .map_err(|error| mod_error_with_detail(&MOD_ERROR_INVALID_INPUT, error))?;
219    match (lhs, rhs) {
220        (Value::GpuTensor(a), Value::GpuTensor(b)) => mod_gpu_pair(a, b).await,
221        (Value::GpuTensor(a), other) => {
222            let gathered = gpu_helpers::gather_tensor_async(&a).await?;
223            mod_host(Value::Tensor(gathered), other)
224        }
225        (other, Value::GpuTensor(b)) => {
226            let gathered = gpu_helpers::gather_tensor_async(&b).await?;
227            mod_host(other, Value::Tensor(gathered))
228        }
229        (left, right) => mod_host(left, right),
230    }
231}
232
233async fn mod_gpu_pair(a: GpuTensorHandle, b: GpuTensorHandle) -> BuiltinResult<Value> {
234    if runmat_accelerate_api::handle_integer_type(&a).is_some()
235        && runmat_accelerate_api::handle_integer_type(&b).is_some()
236        && a.device_id == b.device_id
237    {
238        if let Some(provider) = runmat_accelerate_api::provider_for_handle(&a) {
239            if let Ok(out) = provider.elem_mod(&a, &b).await {
240                return Ok(gpu_helpers::resident_gpu_value(out));
241            }
242        }
243    }
244    let left = gpu_helpers::gather_tensor_async(&a).await?;
245    let right = gpu_helpers::gather_tensor_async(&b).await?;
246    let result = mod_host(Value::Tensor(left), Value::Tensor(right))?;
247    if a.device_id == b.device_id {
248        if let Some(provider) = runmat_accelerate_api::provider_for_handle(&a) {
249            return upload_mod_result(provider, result);
250        }
251    }
252    Ok(result)
253}
254
255fn upload_mod_result(
256    provider: &dyn runmat_accelerate_api::AccelProvider,
257    value: Value,
258) -> BuiltinResult<Value> {
259    let tensor = match value {
260        Value::Tensor(tensor) => tensor,
261        Value::Num(value) => Tensor::new(vec![value], vec![1, 1])
262            .map_err(|err| mod_error_with_detail(&MOD_ERROR_INTERNAL, err))?,
263        other => return Ok(other),
264    };
265    let handle = gpu_helpers::upload_tensor(provider, &tensor)
266        .map_err(|err| mod_error_with_detail(&MOD_ERROR_INTERNAL, err))?;
267    Ok(gpu_helpers::resident_gpu_value(handle))
268}
269
270fn mod_host(lhs: Value, rhs: Value) -> BuiltinResult<Value> {
271    if let Some(result) = try_integer_remainder(&lhs, &rhs, IntegerRemainderOp::Mod, BUILTIN_NAME)
272        .map_err(|error| mod_error_with_detail(&MOD_ERROR_INVALID_INPUT, error))?
273    {
274        return Ok(result);
275    }
276    if let Some(result) = scalar_mod_value(&lhs, &rhs) {
277        return Ok(result);
278    }
279    let left = value_into_numeric_array(lhs)?;
280    let right = value_into_numeric_array(rhs)?;
281    match align_numeric_arrays(left, right)? {
282        NumericPair::Real(a, b) => compute_mod_real(&a, &b),
283        NumericPair::Complex(a, b) => compute_mod_complex(&a, &b),
284    }
285}
286
287fn compute_mod_real(a: &Tensor, b: &Tensor) -> BuiltinResult<Value> {
288    let plan = BroadcastPlan::new(&a.shape, &b.shape)
289        .map_err(|err| mod_error_with_detail(&MOD_ERROR_SIZE_MISMATCH, err))?;
290    let dtype = if a.numeric_dtype() == NumericDType::F32 && b.numeric_dtype() == NumericDType::F32
291    {
292        NumericDType::F32
293    } else {
294        NumericDType::F64
295    };
296    if plan.is_empty() {
297        let tensor = Tensor::new_with_dtype(Vec::new(), plan.output_shape().to_vec(), dtype)
298            .map_err(|e| mod_error_with_detail(&MOD_ERROR_INTERNAL, e))?;
299        return Ok(tensor::tensor_into_value(tensor));
300    }
301    let mut result = vec![0.0f64; plan.len()];
302    for (out_idx, idx_a, idx_b) in plan.iter() {
303        let aval = tensor::tensor_value_f64(a, idx_a);
304        let bval = tensor::tensor_value_f64(b, idx_b);
305        result[out_idx] = mod_real_scalar(aval, bval);
306    }
307    let tensor = Tensor::new_with_dtype(result, plan.output_shape().to_vec(), dtype)
308        .map_err(|e| mod_error_with_detail(&MOD_ERROR_INTERNAL, e))?;
309    Ok(tensor::tensor_into_value(tensor))
310}
311
312fn compute_mod_complex(a: &ComplexTensor, b: &ComplexTensor) -> BuiltinResult<Value> {
313    let plan = BroadcastPlan::new(&a.shape, &b.shape)
314        .map_err(|err| mod_error_with_detail(&MOD_ERROR_SIZE_MISMATCH, err))?;
315    if plan.is_empty() {
316        let tensor = ComplexTensor::new(Vec::new(), plan.output_shape().to_vec())
317            .map_err(|e| mod_error_with_detail(&MOD_ERROR_INTERNAL, e))?;
318        return Ok(complex_tensor_into_value(tensor));
319    }
320    let mut result = vec![(0.0f64, 0.0f64); plan.len()];
321    for (out_idx, idx_a, idx_b) in plan.iter() {
322        let (ar, ai) = a.materialize_f64()[idx_a];
323        let (br, bi) = b.materialize_f64()[idx_b];
324        result[out_idx] = mod_complex_scalar(ar, ai, br, bi);
325    }
326    let tensor = ComplexTensor::new(result, plan.output_shape().to_vec())
327        .map_err(|e| mod_error_with_detail(&MOD_ERROR_INTERNAL, e))?;
328    Ok(complex_tensor_into_value(tensor))
329}
330
331fn mod_real_scalar(a: f64, b: f64) -> f64 {
332    if a.is_nan() || b.is_nan() {
333        return f64::NAN;
334    }
335    if b == 0.0 {
336        return a;
337    }
338    if !a.is_finite() && b.is_finite() {
339        return f64::NAN;
340    }
341    let quotient = (a / b).floor();
342    let mut remainder = a - b * quotient;
343    if remainder == 0.0 {
344        remainder = 0.0;
345    }
346    if b.is_infinite() && a.is_finite() {
347        // MATLAB sign-correction: mod(a, ±Inf) returns a when signs match, ±Inf otherwise.
348        if a == 0.0 {
349            return 0.0;
350        }
351        return if a.signum() == b.signum() { a } else { b };
352    }
353    if !remainder.is_finite() && !a.is_finite() {
354        return f64::NAN;
355    }
356    let same_sign = remainder == 0.0 || remainder.signum() == b.signum();
357    if !same_sign {
358        remainder += b;
359    }
360    if remainder == -0.0 {
361        remainder = 0.0;
362    }
363    remainder
364}
365
366fn mod_complex_scalar(ar: f64, ai: f64, br: f64, bi: f64) -> (f64, f64) {
367    if (ar.is_nan() || ai.is_nan()) || (br.is_nan() || bi.is_nan()) {
368        return (f64::NAN, f64::NAN);
369    }
370    if br == 0.0 && bi == 0.0 {
371        return (f64::NAN, f64::NAN);
372    }
373    if !ar.is_finite() || !ai.is_finite() {
374        return (f64::NAN, f64::NAN);
375    }
376    let (qr, qi) = complex_div(ar, ai, br, bi);
377    if !qr.is_finite() && !qi.is_finite() && br.is_finite() && bi.is_finite() {
378        return (f64::NAN, f64::NAN);
379    }
380    let (fr, fi) = (qr.floor(), qi.floor());
381    let (mulr, muli) = complex_mul(br, bi, fr, fi);
382    let (rr, ri) = (ar - mulr, ai - muli);
383    (normalize_zero(rr), normalize_zero(ri))
384}
385
386fn scalar_real_value(value: &Value) -> Option<f64> {
387    match value {
388        Value::Num(n) => Some(*n),
389        Value::Int(i) => Some(i.to_f64()),
390        Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
391        Value::Tensor(t) if tensor::is_scalar_tensor(t) => Some(tensor::tensor_value_f64(t, 0)),
392        Value::LogicalArray(l) if l.data.len() == 1 => Some(if l.data[0] != 0 { 1.0 } else { 0.0 }),
393        Value::CharArray(ca) if ca.rows * ca.cols == 1 => {
394            Some(ca.data.first().map(|&ch| ch as u32 as f64).unwrap_or(0.0))
395        }
396        _ => None,
397    }
398}
399
400fn scalar_complex_value(value: &Value) -> Option<(f64, f64)> {
401    match value {
402        Value::Complex(re, im) => Some((*re, *im)),
403        Value::ComplexTensor(ct) if tensor::complex_tensor_element_len(ct) == 1 => {
404            let value = tensor::complex_tensor_value_complex64(ct, 0);
405            Some((value.re, value.im))
406        }
407        _ => None,
408    }
409}
410
411fn scalar_mod_value(lhs: &Value, rhs: &Value) -> Option<Value> {
412    let left = scalar_complex_value(lhs).or_else(|| scalar_real_value(lhs).map(|v| (v, 0.0)))?;
413    let right = scalar_complex_value(rhs).or_else(|| scalar_real_value(rhs).map(|v| (v, 0.0)))?;
414    let (ar, ai) = left;
415    let (br, bi) = right;
416    if ai != 0.0 || bi != 0.0 {
417        let (re, im) = mod_complex_scalar(ar, ai, br, bi);
418        return Some(Value::Complex(re, im));
419    }
420    Some(Value::Num(mod_real_scalar(ar, br)))
421}
422
423fn normalize_zero(value: f64) -> f64 {
424    if value == -0.0 {
425        0.0
426    } else {
427        value
428    }
429}
430
431fn complex_mul(ar: f64, ai: f64, br: f64, bi: f64) -> (f64, f64) {
432    (ar * br - ai * bi, ar * bi + ai * br)
433}
434
435fn complex_div(ar: f64, ai: f64, br: f64, bi: f64) -> (f64, f64) {
436    let denom = br * br + bi * bi;
437    if denom == 0.0 {
438        return (f64::NAN, f64::NAN);
439    }
440    ((ar * br + ai * bi) / denom, (ai * br - ar * bi) / denom)
441}
442
443fn complex_tensor_into_value(tensor: ComplexTensor) -> Value {
444    if tensor::complex_tensor_element_len(&tensor) == 1 {
445        let value = tensor::complex_tensor_value_complex64(&tensor, 0);
446        Value::Complex(value.re, value.im)
447    } else {
448        Value::ComplexTensor(tensor)
449    }
450}
451
452fn value_into_numeric_array(value: Value) -> BuiltinResult<NumericArray> {
453    match value {
454        Value::Complex(re, im) => {
455            let tensor = ComplexTensor::new(vec![(re, im)], vec![1, 1])
456                .map_err(|e| mod_error_with_detail(&MOD_ERROR_INTERNAL, e))?;
457            Ok(NumericArray::Complex(tensor))
458        }
459        Value::ComplexTensor(ct) => Ok(NumericArray::Complex(ct)),
460        Value::CharArray(ca) => {
461            let data: Vec<f64> = ca.data.iter().map(|&ch| ch as u32 as f64).collect();
462            let tensor = Tensor::new(data, vec![ca.rows, ca.cols])
463                .map_err(|e| mod_error_with_detail(&MOD_ERROR_INTERNAL, e))?;
464            Ok(NumericArray::Real(tensor))
465        }
466        Value::String(_) | Value::StringArray(_) => Err(mod_error_with_detail(
467            &MOD_ERROR_INVALID_INPUT,
468            "expected numeric input, got string",
469        )),
470        Value::GpuTensor(_) => Err(mod_error_with_detail(
471            &MOD_ERROR_INTERNAL,
472            "internal error converting GPU tensor",
473        )),
474        other => {
475            let tensor = tensor::value_into_tensor_for(BUILTIN_NAME, other)
476                .map_err(|err| mod_error_with_detail(&MOD_ERROR_INVALID_INPUT, err))?;
477            Ok(NumericArray::Real(tensor))
478        }
479    }
480}
481
482enum NumericArray {
483    Real(Tensor),
484    Complex(ComplexTensor),
485}
486
487enum NumericPair {
488    Real(Tensor, Tensor),
489    Complex(ComplexTensor, ComplexTensor),
490}
491
492fn align_numeric_arrays(lhs: NumericArray, rhs: NumericArray) -> BuiltinResult<NumericPair> {
493    match (lhs, rhs) {
494        (NumericArray::Real(a), NumericArray::Real(b)) => Ok(NumericPair::Real(a, b)),
495        (left, right) => {
496            let lc = into_complex(left)?;
497            let rc = into_complex(right)?;
498            Ok(NumericPair::Complex(lc, rc))
499        }
500    }
501}
502
503fn into_complex(input: NumericArray) -> BuiltinResult<ComplexTensor> {
504    match input {
505        NumericArray::Real(t) => {
506            let shape = t.shape.clone();
507            let complex = t
508                .into_numeric_storage()
509                .map_err(|e| mod_error_with_detail(&MOD_ERROR_INTERNAL, e))?
510                .materialize_f64()
511                .into_iter()
512                .map(|real| (real, 0.0))
513                .collect();
514            ComplexTensor::new(complex, shape)
515                .map_err(|e| mod_error_with_detail(&MOD_ERROR_INTERNAL, e))
516        }
517        NumericArray::Complex(ct) => Ok(ct),
518    }
519}
520
521#[cfg(test)]
522pub(crate) mod tests {
523    use super::*;
524    use crate::builtins::common::test_support;
525    use crate::RuntimeError;
526    use futures::executor::block_on;
527    use runmat_builtins::{ResolveContext, Type};
528    use runmat_value::{CharArray, ComplexTensor, IntValue, IntegerStorage, LogicalArray, Tensor};
529
530    fn mod_builtin(lhs: Value, rhs: Value) -> BuiltinResult<Value> {
531        block_on(super::mod_builtin(lhs, rhs))
532    }
533
534    #[test]
535    fn mod_real_arrays_preserve_native_single_storage_including_empty() {
536        let lhs = Tensor::from_f32(vec![5.5, -5.5], vec![1, 2]).unwrap();
537        let rhs = Tensor::from_f32(vec![2.0, 2.0], vec![1, 2]).unwrap();
538        let output = compute_mod_real(&lhs, &rhs).unwrap();
539        let Value::Tensor(output) = output else {
540            panic!("expected native-single tensor")
541        };
542        assert_eq!(
543            output.into_numeric_storage().unwrap(),
544            runmat_value::NumericStorage::F32(vec![1.5, 0.5])
545        );
546
547        let lhs = Tensor::from_f32(Vec::new(), vec![0, 2]).unwrap();
548        let rhs = Tensor::from_f32(Vec::new(), vec![0, 2]).unwrap();
549        let Value::Tensor(output) = compute_mod_real(&lhs, &rhs).unwrap() else {
550            panic!("expected empty native-single tensor")
551        };
552        assert_eq!(
553            output.into_numeric_storage().unwrap(),
554            runmat_value::NumericStorage::F32(Vec::new())
555        );
556    }
557
558    fn assert_error_contains(error: RuntimeError, needle: &str) {
559        assert!(
560            error.message().contains(needle),
561            "unexpected error: {}",
562            error.message()
563        );
564    }
565
566    #[test]
567    fn mod_descriptor_signatures_cover_core_forms() {
568        let labels: Vec<&str> = MOD_DESCRIPTOR
569            .signatures
570            .iter()
571            .map(|sig| sig.label)
572            .collect();
573        assert!(labels.contains(&"R = mod(A, B)"));
574    }
575
576    #[test]
577    fn mod_type_preserves_tensor_shape() {
578        let out = numeric_binary_type(
579            &[
580                Type::Tensor {
581                    shape: Some(vec![Some(2), Some(3)]),
582                },
583                Type::Tensor {
584                    shape: Some(vec![Some(2), Some(3)]),
585                },
586            ],
587            &ResolveContext::new(Vec::new()),
588        );
589        assert_eq!(
590            out,
591            Type::Tensor {
592                shape: Some(vec![Some(2), Some(3)])
593            }
594        );
595    }
596
597    #[test]
598    fn mod_type_scalar_and_tensor_returns_tensor() {
599        let out = numeric_binary_type(
600            &[
601                Type::Num,
602                Type::Tensor {
603                    shape: Some(vec![Some(4), Some(1)]),
604                },
605            ],
606            &ResolveContext::new(Vec::new()),
607        );
608        assert_eq!(
609            out,
610            Type::Tensor {
611                shape: Some(vec![Some(4), Some(1)])
612            }
613        );
614    }
615
616    #[test]
617    fn mod_type_scalar_returns_num() {
618        let out = numeric_binary_type(&[Type::Num, Type::Int], &ResolveContext::new(Vec::new()));
619        assert_eq!(out, Type::Num);
620    }
621
622    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
623    #[test]
624    fn mod_positive_values() {
625        let result = mod_builtin(Value::Num(17.0), Value::Num(5.0)).expect("mod");
626        match result {
627            Value::Num(v) => assert!((v - 2.0).abs() < 1e-12),
628            other => panic!("expected scalar result, got {other:?}"),
629        }
630    }
631
632    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
633    #[test]
634    fn mod_negative_divisor_keeps_sign() {
635        let tensor = Tensor::new(vec![-7.0, -3.0, 4.0, 9.0], vec![4, 1]).unwrap();
636        let divisor = Tensor::new(vec![-4.0], vec![1, 1]).unwrap();
637        let result =
638            mod_builtin(Value::Tensor(tensor), Value::Tensor(divisor)).expect("mod broadcast");
639        match result {
640            Value::Tensor(out) => {
641                assert_eq!(out.materialize_f64(), vec![-3.0, -3.0, 0.0, -3.0]);
642            }
643            other => panic!("expected tensor result, got {other:?}"),
644        }
645    }
646
647    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
648    #[test]
649    fn mod_negative_numerator_positive_divisor() {
650        let result = mod_builtin(Value::Num(-3.0), Value::Num(2.0)).expect("mod");
651        match result {
652            Value::Num(v) => assert!((v - 1.0).abs() < 1e-12),
653            other => panic!("expected scalar result, got {other:?}"),
654        }
655    }
656
657    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
658    #[test]
659    fn mod_zero_divisor_returns_dividend() {
660        let result = mod_builtin(Value::Num(3.0), Value::Num(0.0)).expect("mod");
661        match result {
662            Value::Num(v) => assert_eq!(v, 3.0),
663            other => panic!("expected dividend, got {other:?}"),
664        }
665    }
666
667    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
668    #[test]
669    fn mod_matrix_scalar_broadcast() {
670        let matrix = Tensor::new(vec![4.5, 7.1, -2.3, 0.4], vec![2, 2]).unwrap();
671        let result = mod_builtin(Value::Tensor(matrix), Value::Num(2.0)).expect("broadcast");
672        match result {
673            Value::Tensor(t) => {
674                assert_eq!(t.shape, vec![2, 2]);
675                let expected = [0.5, 1.1, 1.7, 0.4];
676                for (a, b) in t.materialize_f64().iter().zip(expected.iter()) {
677                    assert!((a - b).abs() < 1e-12);
678                }
679            }
680            other => panic!("expected tensor result, got {other:?}"),
681        }
682    }
683
684    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
685    #[test]
686    fn mod_rejects_complex_operands() {
687        let complex =
688            ComplexTensor::new(vec![(3.0, 4.0), (-2.0, 5.0)], vec![1, 2]).expect("complex tensor");
689        let divisor = ComplexTensor::new(vec![(2.0, 1.0)], vec![1, 1]).expect("divisor");
690        assert!(mod_builtin(Value::ComplexTensor(complex), Value::ComplexTensor(divisor)).is_err());
691        assert!(mod_builtin(Value::Complex(2.0, 0.0), Value::Num(1.0)).is_err());
692    }
693
694    #[test]
695    fn mod_complex_scalar_helpers_read_typed_integer_complex_storage_without_mirror() {
696        let complex = ComplexTensor::new_integer(
697            runmat_value::IntegerComplexStorage::new(
698                IntegerStorage::I16(vec![9]),
699                IntegerStorage::I16(vec![-2]),
700            )
701            .expect("integer complex storage"),
702            vec![1, 1],
703        )
704        .expect("integer complex tensor");
705
706        assert_eq!(
707            scalar_complex_value(&Value::ComplexTensor(complex.clone())),
708            Some((9.0, -2.0))
709        );
710        assert_eq!(
711            complex_tensor_into_value(complex),
712            Value::Complex(9.0, -2.0)
713        );
714    }
715
716    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
717    #[test]
718    fn mod_char_array_support() {
719        let chars = CharArray::new("ABC".chars().collect(), 1, 3).unwrap();
720        let result = mod_builtin(Value::CharArray(chars), Value::Num(5.0)).expect("mod");
721        match result {
722            Value::Tensor(t) => assert_eq!(t.materialize_f64(), vec![0.0, 1.0, 2.0]),
723            other => panic!("expected tensor result, got {other:?}"),
724        }
725    }
726
727    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
728    #[test]
729    fn mod_string_input_errors() {
730        let err = mod_builtin(Value::from("abc"), Value::Num(3.0))
731            .expect_err("string inputs should error");
732        let identifier = err.identifier().map(str::to_string);
733        assert_error_contains(err, "expected numeric input");
734        assert_eq!(identifier.as_deref(), MOD_ERROR_INVALID_INPUT.identifier);
735    }
736
737    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
738    #[test]
739    fn mod_logical_array_support() {
740        let logical = LogicalArray::new(vec![1, 0, 1, 0], vec![2, 2]).unwrap();
741        let value =
742            mod_builtin(Value::LogicalArray(logical), Value::Num(2.0)).expect("logical mod");
743        match value {
744            Value::Tensor(t) => assert_eq!(t.materialize_f64(), vec![1.0, 0.0, 1.0, 0.0]),
745            other => panic!("expected tensor result, got {other:?}"),
746        }
747    }
748
749    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
750    #[test]
751    fn mod_vector_broadcasting() {
752        let lhs = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
753        let rhs = Tensor::new(vec![3.0, 4.0, 5.0], vec![1, 3]).unwrap();
754        let result = mod_builtin(Value::Tensor(lhs), Value::Tensor(rhs)).expect("vector broadcast");
755        match result {
756            Value::Tensor(t) => {
757                assert_eq!(t.shape, vec![2, 3]);
758                assert_eq!(t.materialize_f64(), vec![1.0, 2.0, 1.0, 2.0, 1.0, 2.0]);
759            }
760            other => panic!("expected tensor result, got {other:?}"),
761        }
762    }
763
764    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
765    #[test]
766    fn mod_nan_inputs_propagate() {
767        let result = mod_builtin(Value::Num(f64::NAN), Value::Num(3.0)).expect("mod");
768        match result {
769            Value::Num(v) => assert!(v.is_nan()),
770            other => panic!("expected NaN result, got {other:?}"),
771        }
772    }
773
774    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
775    #[test]
776    fn mod_gpu_pair_roundtrip() {
777        test_support::with_test_provider(|provider| {
778            let tensor = Tensor::new(vec![-5.0, -3.0, 0.0, 1.0, 6.0, 9.0], vec![3, 2]).unwrap();
779            let divisor = Tensor::new(vec![4.0, 4.0, 4.0, 4.0, 4.0, 4.0], vec![3, 2]).unwrap();
780            let a_view = runmat_accelerate_api::HostTensorView {
781                data: &tensor.materialize_f64(),
782                shape: &tensor.shape,
783            };
784            let b_view = runmat_accelerate_api::HostTensorView {
785                data: &divisor.materialize_f64(),
786                shape: &divisor.shape,
787            };
788            let a_handle = provider.upload(&a_view).expect("upload a");
789            let b_handle = provider.upload(&b_view).expect("upload b");
790            let result =
791                mod_builtin(Value::GpuTensor(a_handle), Value::GpuTensor(b_handle)).expect("mod");
792            let gathered = test_support::gather(result).expect("gather result");
793            assert_eq!(gathered.shape, vec![3, 2]);
794            assert_eq!(
795                gathered.materialize_f64(),
796                vec![3.0, 1.0, 0.0, 1.0, 2.0, 1.0]
797            );
798        });
799    }
800
801    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
802    #[test]
803    fn mod_int_scalar_preserves_exact_class() {
804        let result =
805            mod_builtin(Value::Int(IntValue::I32(-7)), Value::Int(IntValue::I32(4))).expect("mod");
806        match result {
807            Value::Int(IntValue::I32(v)) => assert_eq!(v, 1),
808            other => panic!("expected int32 scalar result, got {other:?}"),
809        }
810    }
811
812    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
813    #[test]
814    fn mod_scalar_fast_path_reads_typed_integer_storage_exactly() {
815        let lhs =
816            Tensor::new_integer(IntegerStorage::I16(vec![7]), vec![1, 1]).expect("lhs tensor");
817
818        assert_eq!(scalar_real_value(&Value::Tensor(lhs.clone())), Some(7.0));
819
820        let result = mod_builtin(Value::Tensor(lhs), Value::Num(4.0)).expect("mod");
821        match result {
822            Value::Int(IntValue::I16(v)) => assert_eq!(v, 3),
823            other => panic!("expected int16 scalar result, got {other:?}"),
824        }
825    }
826
827    #[test]
828    fn mod_dense_integer_arrays_preserve_exact_storage_without_mirror() {
829        let lhs = Tensor::new_integer(IntegerStorage::I64(vec![-7, 7]), vec![2, 1]).expect("lhs");
830        let rhs =
831            Tensor::new_integer(IntegerStorage::I64(vec![4, -4, 0]), vec![1, 3]).expect("rhs");
832
833        let result = mod_builtin(Value::Tensor(lhs), Value::Tensor(rhs)).expect("mod");
834        let Value::Tensor(result) = result else {
835            panic!("expected integer tensor");
836        };
837        assert_eq!(result.shape, vec![2, 3]);
838        assert_eq!(
839            result.integer_storage(),
840            Some(&IntegerStorage::I64(vec![1, 3, -3, -1, -7, 7]))
841        );
842
843        let lhs =
844            Tensor::new_integer(IntegerStorage::U64(vec![u64::MAX]), vec![1, 1]).expect("lhs");
845        assert_eq!(
846            mod_builtin(Value::Tensor(lhs), Value::Num(3.0)).expect("mod"),
847            Value::Int(IntValue::U64(0))
848        );
849    }
850
851    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
852    #[test]
853    #[cfg(feature = "wgpu")]
854    fn mod_wgpu_matches_cpu() {
855        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
856            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
857        );
858        let numer = Tensor::new(vec![-5.0, -3.25, 0.0, 1.75, 6.5, 9.0], vec![3, 2]).unwrap();
859        let denom = Tensor::new(vec![4.0, -2.5, 3.0, 3.0, 2.0, -5.0], vec![3, 2]).unwrap();
860        let cpu_value =
861            mod_host(Value::Tensor(numer.clone()), Value::Tensor(denom.clone())).expect("cpu mod");
862
863        let provider = runmat_accelerate_api::provider().expect("wgpu provider registered");
864        let numer_handle = provider
865            .upload(&runmat_accelerate_api::HostTensorView {
866                data: &numer.materialize_f64(),
867                shape: &numer.shape,
868            })
869            .expect("upload numer");
870        let denom_handle = provider
871            .upload(&runmat_accelerate_api::HostTensorView {
872                data: &denom.materialize_f64(),
873                shape: &denom.shape,
874            })
875            .expect("upload denom");
876
877        let gpu_value = block_on(mod_gpu_pair(numer_handle, denom_handle)).expect("gpu mod");
878        let gpu_tensor = test_support::gather(gpu_value).expect("gather gpu result");
879
880        let cpu_tensor = match cpu_value {
881            Value::Tensor(t) => t,
882            Value::Num(n) => Tensor::new(vec![n], vec![1, 1]).expect("scalar tensor"),
883            other => panic!("unexpected CPU result {other:?}"),
884        };
885
886        assert_eq!(gpu_tensor.shape, cpu_tensor.shape);
887        let tol = match provider.precision() {
888            runmat_accelerate_api::ProviderPrecision::F64 => 1e-12,
889            runmat_accelerate_api::ProviderPrecision::F32 => 1e-5,
890        };
891        for (gpu, cpu) in gpu_tensor
892            .materialize_f64()
893            .iter()
894            .zip(cpu_tensor.materialize_f64().iter())
895        {
896            assert!(
897                (gpu - cpu).abs() <= tol,
898                "|{gpu} - {cpu}| exceeded tolerance {tol}"
899            );
900        }
901    }
902}