Skip to main content

runmat_runtime/builtins/math/poly/
polyint.rs

1//! MATLAB-compatible `polyint` builtin with GPU-aware semantics for RunMat.
2
3use log::{trace, warn};
4use num_complex::Complex64;
5use runmat_builtins::{
6    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinIntegerBackendRule,
7    BuiltinIntegerCapabilityDescriptor, BuiltinIntegerComputationDomain,
8    BuiltinIntegerInputAvailability, BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule,
9    BuiltinIntegerOverflowRule, BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule,
10    BuiltinOutputMode, BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType,
11    BuiltinSignatureDescriptor,
12};
13use runmat_macros::runtime_builtin;
14use runmat_value::{ComplexTensor, NumericDType, NumericStorage, Tensor, Value};
15
16use crate::builtins::common::gpu_helpers;
17use crate::builtins::common::spec::{
18    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
19    ProviderHook, ReductionNaN, ResidencyPolicy, ScalarType, ShapeRequirements,
20};
21use crate::builtins::common::tensor;
22use crate::builtins::math::poly::type_resolvers::polyint_type;
23use crate::dispatcher;
24use crate::{build_runtime_error, BuiltinResult, RuntimeError};
25
26const EPS: f64 = 1.0e-12;
27const BUILTIN_NAME: &str = "polyint";
28
29const POLYINT_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
30    name: "q",
31    ty: BuiltinParamType::Any,
32    arity: BuiltinParamArity::Required,
33    default: None,
34    description: "Integrated polynomial coefficient vector.",
35}];
36
37const POLYINT_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
38    name: "p",
39    ty: BuiltinParamType::Any,
40    arity: BuiltinParamArity::Required,
41    default: None,
42    description: "Polynomial coefficient vector.",
43}];
44
45const POLYINT_INPUTS_WITH_K: [BuiltinParamDescriptor; 2] = [
46    BuiltinParamDescriptor {
47        name: "p",
48        ty: BuiltinParamType::Any,
49        arity: BuiltinParamArity::Required,
50        default: None,
51        description: "Polynomial coefficient vector.",
52    },
53    BuiltinParamDescriptor {
54        name: "k",
55        ty: BuiltinParamType::Any,
56        arity: BuiltinParamArity::Optional,
57        default: None,
58        description: "Constant of integration.",
59    },
60];
61
62const POLYINT_SIGNATURES: [BuiltinSignatureDescriptor; 2] = [
63    BuiltinSignatureDescriptor {
64        label: "q = polyint(p)",
65        inputs: &POLYINT_INPUTS,
66        outputs: &POLYINT_OUTPUT,
67    },
68    BuiltinSignatureDescriptor {
69        label: "q = polyint(p, k)",
70        inputs: &POLYINT_INPUTS_WITH_K,
71        outputs: &POLYINT_OUTPUT,
72    },
73];
74
75const POLYINT_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
76    code: "RM.POLYINT.INVALID_ARGUMENT",
77    identifier: Some("RunMat:polyint:InvalidArgument"),
78    when: "Input arity or integration-constant argument is malformed.",
79    message: "polyint: invalid argument",
80};
81
82const POLYINT_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
83    code: "RM.POLYINT.INVALID_INPUT",
84    identifier: Some("RunMat:polyint:InvalidInput"),
85    when: "Inputs are not single- or double-precision coefficient/constant values, or coefficients do not form a vector.",
86    message: "polyint: invalid input",
87};
88
89const POLYINT_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
90    code: "RM.POLYINT.INTERNAL",
91    identifier: Some("RunMat:polyint:Internal"),
92    when: "Runtime fails while building output tensors or provider fallback paths.",
93    message: "polyint: internal runtime failure",
94};
95
96const POLYINT_ERRORS: [BuiltinErrorDescriptor; 3] = [
97    POLYINT_ERROR_INVALID_ARGUMENT,
98    POLYINT_ERROR_INVALID_INPUT,
99    POLYINT_ERROR_INTERNAL,
100];
101
102pub const POLYINT_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
103    signatures: &POLYINT_SIGNATURES,
104    output_mode: BuiltinOutputMode::Fixed,
105    completion_policy: BuiltinCompletionPolicy::Public,
106    errors: &POLYINT_ERRORS,
107};
108
109const INTEGER_INPUTS: [BuiltinIntegerInputCapability; 2] = [
110    BuiltinIntegerInputCapability {
111        name: "p",
112        classes: &[],
113        availability: BuiltinIntegerInputAvailability::Rejected,
114        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
115        notes: "Integer and logical coefficient vectors are rejected before floating host/provider dispatch.",
116    },
117    BuiltinIntegerInputCapability {
118        name: "k",
119        classes: &[],
120        availability: BuiltinIntegerInputAvailability::Rejected,
121        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
122        notes: "Integer and logical integration constants are rejected; only single/double real or complex values are supported.",
123    },
124];
125
126pub const INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 1] =
127    [BuiltinIntegerCapabilityDescriptor {
128        form: "q = polyint(p, k)",
129        inputs: &INTEGER_INPUTS,
130        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
131        output_class: BuiltinIntegerOutputClassRule::NotApplicable,
132        overflow: BuiltinIntegerOverflowRule::NotApplicable,
133        backend: BuiltinIntegerBackendRule::HostAndGpu,
134        overload: BuiltinIntegerOverloadKind::Multiple,
135        notes: "This builtin has no integer overload; the empty accepted-class masks are intentional and prevent generic numeric coercion from admitting integers.",
136    }];
137
138#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::poly::polyint")]
139pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
140    name: "polyint",
141    op_kind: GpuOpKind::Custom("polynomial-integral"),
142    supported_precisions: &[ScalarType::F32, ScalarType::F64],
143    broadcast: BroadcastSemantics::None,
144    provider_hooks: &[ProviderHook::Custom("polyint")],
145    constant_strategy: ConstantStrategy::InlineLiteral,
146    residency: ResidencyPolicy::NewHandle,
147    nan_mode: ReductionNaN::Include,
148    two_pass_threshold: None,
149    workgroup_size: None,
150    accepts_nan_mode: false,
151    notes: "Providers implement the polyint hook for real and complex-interleaved coefficient vectors; complex integration constants fall back to host integration and re-upload.",
152};
153
154fn polyint_error(message: impl Into<String>) -> RuntimeError {
155    polyint_error_with(message, &POLYINT_ERROR_INVALID_INPUT)
156}
157
158fn polyint_argument_error(message: impl Into<String>) -> RuntimeError {
159    polyint_error_with(message, &POLYINT_ERROR_INVALID_ARGUMENT)
160}
161
162fn polyint_error_with(
163    message: impl Into<String>,
164    error: &'static BuiltinErrorDescriptor,
165) -> RuntimeError {
166    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
167    if let Some(identifier) = error.identifier {
168        builder = builder.with_identifier(identifier);
169    }
170    builder.build()
171}
172
173#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::poly::polyint")]
174pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
175    name: "polyint",
176    shape: ShapeRequirements::Any,
177    constant_strategy: ConstantStrategy::InlineLiteral,
178    elementwise: None,
179    reduction: None,
180    emits_nan: false,
181    notes: "Symbolic operation on coefficient vectors; fusion does not apply.",
182};
183
184#[runtime_builtin(
185    name = "polyint",
186    category = "math/poly",
187    summary = "Integrate polynomial coefficient vectors and append a constant of integration.",
188    keywords = "polyint,polynomial,integral,antiderivative",
189    type_resolver(polyint_type),
190    descriptor(crate::builtins::math::poly::polyint::POLYINT_DESCRIPTOR),
191    integer_capabilities(crate::builtins::math::poly::polyint::INTEGER_CAPABILITIES),
192    builtin_path = "crate::builtins::math::poly::polyint"
193)]
194async fn polyint_builtin(coeffs: Value, rest: Vec<Value>) -> crate::BuiltinResult<Value> {
195    if rest.len() > 1 {
196        return Err(polyint_argument_error("polyint: too many input arguments"));
197    }
198    reject_unsupported_numeric_class(&coeffs, "coefficient")?;
199    for value in &rest {
200        reject_unsupported_numeric_class(value, "constant")?;
201    }
202
203    let constant = match rest.into_iter().next() {
204        Some(value) => parse_constant(value).await?,
205        None => Complex64::new(0.0, 0.0),
206    };
207
208    if let Value::GpuTensor(handle) = &coeffs {
209        if let Some(device_result) = try_polyint_gpu(handle, constant)? {
210            return Ok(Value::GpuTensor(device_result));
211        }
212    }
213
214    let source_gpu = match &coeffs {
215        Value::GpuTensor(handle) => Some(handle.clone()),
216        _ => None,
217    };
218    polyint_host_value(coeffs, constant, source_gpu).await
219}
220
221async fn polyint_host_value(
222    coeffs: Value,
223    constant: Complex64,
224    source_gpu: Option<runmat_accelerate_api::GpuTensorHandle>,
225) -> BuiltinResult<Value> {
226    let polynomial = parse_polynomial(coeffs).await?;
227    let mut integrated = integrate_coeffs(&polynomial.coeffs);
228    if integrated.is_empty() {
229        integrated.push(constant);
230    } else if let Some(last) = integrated.last_mut() {
231        *last += constant;
232    }
233    let value = coeffs_to_value(&integrated, polynomial.class)?;
234    maybe_return_gpu(value, source_gpu.as_ref())
235}
236
237fn reject_unsupported_numeric_class(value: &Value, role: &str) -> BuiltinResult<()> {
238    let unsupported = match value {
239        Value::Int(_) | Value::Bool(_) | Value::LogicalArray(_) => true,
240        Value::Tensor(tensor) => !matches!(
241            tensor.numeric_dtype(),
242            NumericDType::F64 | NumericDType::F32
243        ),
244        Value::ComplexTensor(tensor) => tensor.integer_storage().is_some(),
245        Value::GpuTensor(handle) => {
246            runmat_accelerate_api::handle_integer_type(handle).is_some()
247                || runmat_accelerate_api::handle_is_logical(handle)
248        }
249        _ => false,
250    };
251    if unsupported {
252        return Err(polyint_error(format!(
253            "polyint: {role} input must be single or double"
254        )));
255    }
256    Ok(())
257}
258
259fn try_polyint_gpu(
260    handle: &runmat_accelerate_api::GpuTensorHandle,
261    constant: Complex64,
262) -> BuiltinResult<Option<runmat_accelerate_api::GpuTensorHandle>> {
263    if constant.im.abs() > EPS {
264        return Ok(None);
265    }
266    ensure_vector_shape(&handle.shape)?;
267    let Some(provider) =
268        runmat_accelerate_api::provider_for_handle(handle).or_else(runmat_accelerate_api::provider)
269    else {
270        return Ok(None);
271    };
272    match provider.polyint(handle, constant.re) {
273        Ok(result) => Ok(Some(result)),
274        Err(err) => {
275            trace!("polyint: provider hook unavailable, falling back to host: {err}");
276            Ok(None)
277        }
278    }
279}
280
281fn integrate_coeffs(coeffs: &[Complex64]) -> Vec<Complex64> {
282    if coeffs.is_empty() {
283        return Vec::new();
284    }
285    let mut result = Vec::with_capacity(coeffs.len() + 1);
286    for (idx, coeff) in coeffs.iter().enumerate() {
287        let power = (coeffs.len() - idx) as f64;
288        if power <= 0.0 {
289            result.push(Complex64::new(0.0, 0.0));
290        } else {
291            result.push(*coeff / Complex64::new(power, 0.0));
292        }
293    }
294    result.push(Complex64::new(0.0, 0.0));
295    result
296}
297
298fn maybe_return_gpu(
299    value: Value,
300    source_gpu: Option<&runmat_accelerate_api::GpuTensorHandle>,
301) -> BuiltinResult<Value> {
302    let Some(source_gpu) = source_gpu else {
303        return Ok(value);
304    };
305    let provider = runmat_accelerate_api::provider_for_handle(source_gpu);
306    match value {
307        Value::Tensor(tensor) => {
308            if let Some(provider) = provider {
309                match gpu_helpers::upload_tensor(provider, &tensor) {
310                    Ok(handle) => return Ok(Value::GpuTensor(handle)),
311                    Err(err) => {
312                        warn!("polyint: provider upload failed, keeping result on host: {err}");
313                    }
314                }
315            } else {
316                trace!("polyint: no provider available to re-upload result");
317            }
318            Ok(Value::Tensor(tensor))
319        }
320        Value::ComplexTensor(tensor) => {
321            if let Some(provider) = provider {
322                match gpu_helpers::upload_complex_tensor(provider, &tensor) {
323                    Ok(handle) => return Ok(gpu_helpers::complex_gpu_value(handle)),
324                    Err(err) => {
325                        warn!(
326                            "polyint: provider complex upload failed, keeping result on host: {err}"
327                        );
328                    }
329                }
330            } else {
331                trace!("polyint: no provider available to re-upload complex result");
332            }
333            Ok(Value::ComplexTensor(tensor))
334        }
335        other => Ok(other),
336    }
337}
338
339fn coeffs_to_value(coeffs: &[Complex64], class: FloatingClass) -> BuiltinResult<Value> {
340    let shape = vec![1, coeffs.len()];
341    if coeffs.iter().all(|c| c.im.abs() <= EPS) {
342        let tensor = match class {
343            FloatingClass::Double => {
344                let data = coeffs.iter().map(|c| c.re).collect();
345                Tensor::new(data, shape)
346            }
347            FloatingClass::Single => {
348                let data = coeffs.iter().map(|c| c.re as f32).collect();
349                Tensor::from_f32(data, shape)
350            }
351        }
352        .map_err(|e| polyint_error(format!("polyint: {e}")))?;
353        Ok(tensor::tensor_into_value(tensor))
354    } else {
355        let data: Vec<(f64, f64)> = coeffs.iter().map(|c| (c.re, c.im)).collect();
356        let tensor =
357            ComplexTensor::new(data, shape).map_err(|e| polyint_error(format!("polyint: {e}")))?;
358        Ok(Value::ComplexTensor(tensor))
359    }
360}
361
362async fn parse_polynomial(value: Value) -> BuiltinResult<Polynomial> {
363    let gathered = dispatcher::gather_if_needed_async(&value).await?;
364    match gathered {
365        Value::Tensor(tensor) => parse_tensor_coeffs(tensor),
366        Value::ComplexTensor(tensor) => parse_complex_tensor_coeffs(&tensor),
367        Value::Num(n) => Ok(Polynomial {
368            coeffs: vec![Complex64::new(n, 0.0)],
369            class: FloatingClass::Double,
370        }),
371        Value::Complex(re, im) => Ok(Polynomial {
372            coeffs: vec![Complex64::new(re, im)],
373            class: FloatingClass::Double,
374        }),
375        other => Err(polyint_error(format!(
376            "polyint: expected a numeric coefficient vector, got {:?}",
377            other
378        ))),
379    }
380}
381
382fn parse_tensor_coeffs(tensor: Tensor) -> BuiltinResult<Polynomial> {
383    ensure_vector_shape(&tensor.shape)?;
384    let storage = tensor
385        .into_numeric_storage()
386        .map_err(|error| polyint_error(format!("polyint: {error}")))?;
387    let (coeffs, class) = match storage {
388        NumericStorage::F64(values) => (values, FloatingClass::Double),
389        NumericStorage::F32(values) => (
390            values.into_iter().map(f64::from).collect(),
391            FloatingClass::Single,
392        ),
393        storage => {
394            return Err(polyint_error(format!(
395                "polyint: coefficient input must be single or double, got {}",
396                storage.class_name()
397            )))
398        }
399    };
400    Ok(Polynomial {
401        coeffs: coeffs.into_iter().map(|v| Complex64::new(v, 0.0)).collect(),
402        class,
403    })
404}
405
406fn parse_complex_tensor_coeffs(tensor: &ComplexTensor) -> BuiltinResult<Polynomial> {
407    ensure_vector_shape(&tensor.shape)?;
408    Ok(Polynomial {
409        coeffs: tensor
410            .materialize_f64()
411            .iter()
412            .map(|&(re, im)| Complex64::new(re, im))
413            .collect(),
414        class: FloatingClass::Double,
415    })
416}
417
418async fn parse_constant(value: Value) -> BuiltinResult<Complex64> {
419    let gathered = dispatcher::gather_if_needed_async(&value).await?;
420    match gathered {
421        Value::Tensor(tensor) => {
422            if !tensor::is_scalar_tensor(&tensor) {
423                return Err(polyint_error(
424                    "polyint: constant of integration must be a scalar",
425                ));
426            }
427            let value = match tensor
428                .into_numeric_storage()
429                .map_err(|error| polyint_error(format!("polyint: {error}")))?
430            {
431                NumericStorage::F64(values) => values[0],
432                NumericStorage::F32(values) => f64::from(values[0]),
433                storage => {
434                    return Err(polyint_error(format!(
435                        "polyint: constant input must be single or double, got {}",
436                        storage.class_name()
437                    )))
438                }
439            };
440            Ok(Complex64::new(value, 0.0))
441        }
442        Value::ComplexTensor(tensor) => {
443            if tensor.materialize_f64().len() != 1 {
444                return Err(polyint_error(
445                    "polyint: constant of integration must be a scalar",
446                ));
447            }
448            let (re, im) = tensor.materialize_f64()[0];
449            Ok(Complex64::new(re, im))
450        }
451        Value::Num(n) => Ok(Complex64::new(n, 0.0)),
452        Value::Complex(re, im) => Ok(Complex64::new(re, im)),
453        other => Err(polyint_error(format!(
454            "polyint: constant of integration must be numeric, got {:?}",
455            other
456        ))),
457    }
458}
459
460fn ensure_vector_shape(shape: &[usize]) -> BuiltinResult<()> {
461    let non_unit = shape.iter().filter(|&&dim| dim > 1).count();
462    if non_unit <= 1 {
463        Ok(())
464    } else {
465        Err(polyint_error("polyint: coefficients must form a vector"))
466    }
467}
468
469#[derive(Clone)]
470struct Polynomial {
471    coeffs: Vec<Complex64>,
472    class: FloatingClass,
473}
474
475#[derive(Clone, Copy)]
476enum FloatingClass {
477    Double,
478    Single,
479}
480
481#[cfg(test)]
482pub(crate) mod tests {
483    use super::*;
484    use crate::builtins::common::gpu_helpers;
485    use crate::builtins::common::test_support;
486    use futures::executor::block_on;
487    #[cfg(feature = "wgpu")]
488    use runmat_accelerate_api::AccelProvider;
489    use runmat_value::{IntegerStorage, LogicalArray};
490
491    fn assert_error_contains(err: crate::RuntimeError, needle: &str) {
492        assert!(
493            err.message().contains(needle),
494            "expected error containing '{needle}', got '{}'",
495            err.message()
496        );
497    }
498
499    #[test]
500    fn polyint_descriptor_signatures_cover_core_forms() {
501        let labels: Vec<&str> = POLYINT_DESCRIPTOR
502            .signatures
503            .iter()
504            .map(|signature| signature.label)
505            .collect();
506        assert!(labels.contains(&"q = polyint(p)"));
507        assert!(labels.contains(&"q = polyint(p, k)"));
508    }
509
510    #[test]
511    fn polyint_descriptor_errors_have_stable_codes() {
512        let codes: Vec<&str> = POLYINT_DESCRIPTOR
513            .errors
514            .iter()
515            .map(|error| error.code)
516            .collect();
517        assert!(codes.contains(&"RM.POLYINT.INVALID_ARGUMENT"));
518        assert!(codes.contains(&"RM.POLYINT.INVALID_INPUT"));
519        assert!(codes.contains(&"RM.POLYINT.INTERNAL"));
520    }
521
522    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
523    #[test]
524    fn integrates_polynomial_without_constant() {
525        let tensor = Tensor::new(vec![3.0, -2.0, 5.0, 7.0], vec![1, 4]).unwrap();
526        let result = polyint_builtin(Value::Tensor(tensor), Vec::new()).expect("polyint");
527        match result {
528            Value::Tensor(t) => {
529                assert_eq!(t.shape, vec![1, 5]);
530                let expected = [0.75, -2.0 / 3.0, 2.5, 7.0, 0.0];
531                assert!(tensor::tensor_values_f64(&t)
532                    .iter()
533                    .zip(expected.iter())
534                    .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
535            }
536            other => panic!("expected tensor result, got {other:?}"),
537        }
538    }
539
540    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
541    #[test]
542    fn integrates_with_constant() {
543        let tensor = Tensor::new(vec![4.0, 0.0, -8.0], vec![1, 3]).unwrap();
544        let args = vec![Value::Num(3.0)];
545        let result = polyint_builtin(Value::Tensor(tensor), args).expect("polyint");
546        match result {
547            Value::Tensor(t) => {
548                assert_eq!(t.shape, vec![1, 4]);
549                let expected = [4.0 / 3.0, 0.0, -8.0, 3.0];
550                assert!(tensor::tensor_values_f64(&t)
551                    .iter()
552                    .zip(expected.iter())
553                    .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
554            }
555            other => panic!("expected tensor result, got {other:?}"),
556        }
557    }
558
559    #[test]
560    fn polyint_rejects_every_integer_class_for_coefficients_and_constant() {
561        let cases = [
562            IntegerStorage::I8(vec![1]),
563            IntegerStorage::I16(vec![1]),
564            IntegerStorage::I32(vec![1]),
565            IntegerStorage::I64(vec![1]),
566            IntegerStorage::U8(vec![1]),
567            IntegerStorage::U16(vec![1]),
568            IntegerStorage::U32(vec![1]),
569            IntegerStorage::U64(vec![1]),
570        ];
571        for storage in cases {
572            let integer = Tensor::new_integer(storage, vec![1, 1]).unwrap();
573            let err = polyint_builtin(Value::Tensor(integer.clone()), Vec::new())
574                .expect_err("integer coefficients must be rejected");
575            assert_error_contains(err, "must be single or double");
576
577            let coefficients = Tensor::new(vec![4.0, 0.0, -8.0], vec![1, 3]).unwrap();
578            let err = polyint_builtin(Value::Tensor(coefficients), vec![Value::Tensor(integer)])
579                .expect_err("integer constant must be rejected");
580            assert_error_contains(err, "must be single or double");
581        }
582    }
583
584    #[test]
585    fn polyint_preserves_native_single_output_storage() {
586        let tensor = Tensor::from_f32(vec![3.0, -2.0, 5.0], vec![3, 1]).unwrap();
587        let constant = Tensor::from_f32(vec![2.0], vec![1, 1]).unwrap();
588        let result =
589            polyint_builtin(Value::Tensor(tensor), vec![Value::Tensor(constant)]).expect("polyint");
590        let Value::Tensor(tensor) = result else {
591            panic!("expected native-single tensor");
592        };
593        assert_eq!(tensor.shape, vec![1, 4]);
594        assert_eq!(tensor.numeric_dtype(), NumericDType::F32);
595        assert_eq!(
596            tensor.into_numeric_storage().unwrap(),
597            NumericStorage::F32(vec![1.0, -1.0, 5.0, 2.0])
598        );
599    }
600
601    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
602    #[test]
603    fn integrates_scalar_value() {
604        let result = polyint_builtin(Value::Num(5.0), Vec::new()).expect("polyint");
605        match result {
606            Value::Tensor(t) => {
607                assert_eq!(t.shape, vec![1, 2]);
608                let values = tensor::tensor_values_f64(&t);
609                assert!((values[0] - 5.0).abs() < 1e-12);
610                assert!(values[1].abs() < 1e-12);
611            }
612            other => panic!("expected tensor result, got {other:?}"),
613        }
614    }
615
616    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
617    #[test]
618    fn rejects_logical_coefficients_and_constant() {
619        let logical = LogicalArray::new(vec![1, 0, 1], vec![1, 3]).unwrap();
620        let err = polyint_builtin(Value::LogicalArray(logical), Vec::new())
621            .expect_err("logical coefficients must be rejected");
622        assert_error_contains(err, "must be single or double");
623
624        let coefficients = Tensor::new(vec![1.0, 0.0], vec![1, 2]).unwrap();
625        let constant = LogicalArray::new(vec![1], vec![1, 1]).unwrap();
626        let err = polyint_builtin(
627            Value::Tensor(coefficients),
628            vec![Value::LogicalArray(constant)],
629        )
630        .expect_err("logical constant must be rejected");
631        assert_error_contains(err, "must be single or double");
632    }
633
634    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
635    #[test]
636    fn returns_row_vector_for_column_input() {
637        let tensor = Tensor::new(vec![2.0, 0.0, -6.0], vec![3, 1]).unwrap();
638        let result = polyint_builtin(Value::Tensor(tensor), Vec::new()).expect("polyint");
639        match result {
640            Value::Tensor(t) => {
641                assert_eq!(t.shape, vec![1, 4]);
642                let expected = [2.0 / 3.0, 0.0, -6.0, 0.0];
643                assert!(tensor::tensor_values_f64(&t)
644                    .iter()
645                    .zip(expected.iter())
646                    .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
647            }
648            other => panic!("expected row tensor, got {other:?}"),
649        }
650    }
651
652    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
653    #[test]
654    fn integrates_complex_coefficients() {
655        let tensor =
656            ComplexTensor::new(vec![(1.0, 2.0), (-3.0, 0.0), (0.0, 4.0)], vec![1, 3]).unwrap();
657        let args = vec![Value::Complex(0.0, -1.0)];
658        let result = polyint_builtin(Value::ComplexTensor(tensor), args).expect("polyint");
659        match result {
660            Value::ComplexTensor(t) => {
661                assert_eq!(t.shape, vec![1, 4]);
662                let expected = [(1.0 / 3.0, 2.0 / 3.0), (-1.5, 0.0), (0.0, 4.0), (0.0, -1.0)];
663                assert!(t.materialize_f64().iter().zip(expected.iter()).all(
664                    |((lre, lim), (rre, rim))| {
665                        (lre - rre).abs() < 1e-12 && (lim - rim).abs() < 1e-12
666                    }
667                ));
668            }
669            other => panic!("expected complex tensor, got {other:?}"),
670        }
671    }
672
673    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
674    #[test]
675    fn rejects_matrix_coefficients() {
676        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
677        let err = polyint_builtin(Value::Tensor(tensor), Vec::new()).expect_err("expected error");
678        assert_error_contains(err, "vector");
679    }
680
681    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
682    #[test]
683    fn rejects_non_scalar_constant() {
684        let coeffs = Tensor::new(vec![1.0, -4.0, 6.0], vec![1, 3]).unwrap();
685        let constant = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
686        let err = polyint_builtin(Value::Tensor(coeffs), vec![Value::Tensor(constant)])
687            .expect_err("expected error");
688        assert_error_contains(err, "constant of integration must be a scalar");
689    }
690
691    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
692    #[test]
693    fn rejects_excess_arguments() {
694        let tensor = Tensor::new(vec![1.0, 0.0], vec![1, 2]).unwrap();
695        let err = polyint_builtin(
696            Value::Tensor(tensor),
697            vec![Value::Num(1.0), Value::Num(2.0)],
698        )
699        .expect_err("expected error");
700        assert_eq!(err.identifier(), POLYINT_ERROR_INVALID_ARGUMENT.identifier);
701        assert_error_contains(err, "too many input arguments");
702    }
703
704    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
705    #[test]
706    fn handles_empty_input_as_zero_polynomial() {
707        let tensor = Tensor::new(vec![], vec![1, 0]).unwrap();
708        let result = polyint_builtin(Value::Tensor(tensor), Vec::new()).expect("polyint");
709        match result {
710            Value::Num(v) => assert!(v.abs() < 1e-12),
711            Value::Tensor(t) => {
712                // Allow tensor fallback if scalar auto-boxing changes in future
713                assert_eq!(t.len(), 1);
714                assert!(tensor::tensor_value_f64(&t, 0).abs() < 1e-12);
715            }
716            other => panic!("expected numeric result, got {other:?}"),
717        }
718    }
719
720    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
721    #[test]
722    fn empty_input_with_constant() {
723        let tensor = Tensor::new(vec![], vec![1, 0]).unwrap();
724        let result = polyint_builtin(Value::Tensor(tensor), vec![Value::Complex(1.5, -2.0)])
725            .expect("polyint");
726        match result {
727            Value::ComplexTensor(t) => {
728                assert_eq!(t.shape, vec![1, 1]);
729                assert_eq!(t.materialize_f64().len(), 1);
730                let (re, im) = t.materialize_f64()[0];
731                assert!((re - 1.5).abs() < 1e-12);
732                assert!((im + 2.0).abs() < 1e-12);
733            }
734            other => panic!("expected complex tensor result, got {other:?}"),
735        }
736    }
737
738    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
739    #[test]
740    fn polyint_gpu_roundtrip() {
741        test_support::with_test_provider(|provider| {
742            let tensor = Tensor::new(vec![1.0, -4.0, 6.0], vec![1, 3]).unwrap();
743            let handle = gpu_helpers::upload_tensor(provider, &tensor).expect("upload");
744            let result = polyint_builtin(Value::GpuTensor(handle), Vec::new()).expect("polyint");
745            match result {
746                Value::GpuTensor(handle) => {
747                    let gathered = test_support::gather(Value::GpuTensor(handle)).expect("gather");
748                    assert_eq!(gathered.shape, vec![1, 4]);
749                    let expected = [1.0 / 3.0, -2.0, 6.0, 0.0];
750                    assert!(tensor::tensor_values_f64(&gathered)
751                        .iter()
752                        .zip(expected.iter())
753                        .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
754                }
755                other => panic!("expected GPU tensor result, got {other:?}"),
756            }
757        });
758    }
759
760    #[test]
761    fn polyint_gpu_rejects_every_integer_class_before_dispatch() {
762        test_support::with_test_provider(|provider| {
763            let cases = [
764                IntegerStorage::I8(vec![1]),
765                IntegerStorage::I16(vec![1]),
766                IntegerStorage::I32(vec![1]),
767                IntegerStorage::I64(vec![1]),
768                IntegerStorage::U8(vec![1]),
769                IntegerStorage::U16(vec![1]),
770                IntegerStorage::U32(vec![1]),
771                IntegerStorage::U64(vec![1]),
772            ];
773            for storage in cases {
774                let tensor = Tensor::new_integer(storage, vec![1, 1]).unwrap();
775                let handle = gpu_helpers::upload_tensor(provider, &tensor).expect("integer upload");
776                let err = polyint_builtin(Value::GpuTensor(handle), Vec::new())
777                    .expect_err("integer gpuArray coefficients must be rejected");
778                assert_error_contains(err, "must be single or double");
779            }
780        });
781    }
782
783    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
784    #[test]
785    fn polyint_gpu_complex_constant_reuploads_complex_result() {
786        test_support::with_test_provider(|provider| {
787            let tensor = Tensor::new(vec![1.0, 0.0], vec![1, 2]).unwrap();
788            let handle = gpu_helpers::upload_tensor(provider, &tensor).expect("upload");
789            let result = polyint_builtin(Value::GpuTensor(handle), vec![Value::Complex(0.0, 2.0)])
790                .expect("polyint");
791            match result {
792                Value::GpuTensor(handle) => {
793                    assert_eq!(
794                        runmat_accelerate_api::handle_storage(&handle),
795                        runmat_accelerate_api::GpuTensorStorage::ComplexInterleaved
796                    );
797                    let gathered =
798                        block_on(gpu_helpers::gather_value_async(&Value::GpuTensor(handle)))
799                            .expect("gather");
800                    let Value::ComplexTensor(ct) = gathered else {
801                        panic!("expected complex tensor");
802                    };
803                    assert_eq!(ct.shape, vec![1, 3]);
804                    let expected = [(0.5, 0.0), (0.0, 0.0), (0.0, 2.0)];
805                    assert!(ct.materialize_f64().iter().zip(expected.iter()).all(
806                        |((lre, lim), (rre, rim))| {
807                            (lre - rre).abs() < 1e-12 && (lim - rim).abs() < 1e-12
808                        }
809                    ));
810                }
811                other => panic!("expected complex gpu tensor, got {other:?}"),
812            }
813        });
814    }
815
816    #[test]
817    fn polyint_complex_gpu_coefficients_stay_resident() {
818        test_support::with_test_provider(|provider| {
819            let coeffs = ComplexTensor::new(vec![(1.0, 1.0), (2.0, -1.0)], vec![1, 2]).unwrap();
820            let handle = gpu_helpers::upload_complex_tensor(provider, &coeffs).expect("upload");
821            let result =
822                polyint_builtin(Value::GpuTensor(handle), vec![Value::Num(2.0)]).expect("polyint");
823            let Value::GpuTensor(handle) = result else {
824                panic!("expected complex gpu tensor");
825            };
826            assert_eq!(
827                runmat_accelerate_api::handle_storage(&handle),
828                runmat_accelerate_api::GpuTensorStorage::ComplexInterleaved
829            );
830            let gathered = block_on(gpu_helpers::gather_value_async(&Value::GpuTensor(handle)))
831                .expect("gather");
832            let Value::ComplexTensor(ct) = gathered else {
833                panic!("expected complex tensor");
834            };
835            assert_eq!(ct.shape, vec![1, 3]);
836            let expected = [(0.5, 0.5), (2.0, -1.0), (2.0, 0.0)];
837            assert!(ct.materialize_f64().iter().zip(expected.iter()).all(
838                |((lre, lim), (rre, rim))| {
839                    (lre - rre).abs() < 1e-12 && (lim - rim).abs() < 1e-12
840                }
841            ));
842        });
843    }
844
845    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
846    #[test]
847    fn polyint_gpu_with_gpu_constant() {
848        test_support::with_test_provider(|provider| {
849            let coeffs = Tensor::new(vec![2.0, 0.0], vec![1, 2]).unwrap();
850            let coeff_handle =
851                gpu_helpers::upload_tensor(provider, &coeffs).expect("upload coeffs");
852            let constant = Tensor::new(vec![3.0], vec![1, 1]).unwrap();
853            let constant_handle =
854                gpu_helpers::upload_tensor(provider, &constant).expect("upload constant");
855            let result = polyint_builtin(
856                Value::GpuTensor(coeff_handle),
857                vec![Value::GpuTensor(constant_handle)],
858            )
859            .expect("polyint");
860            match result {
861                Value::GpuTensor(handle) => {
862                    let gathered =
863                        test_support::gather(Value::GpuTensor(handle)).expect("gather result");
864                    assert_eq!(gathered.shape, vec![1, 3]);
865                    let expected = [1.0, 0.0, 3.0];
866                    assert!(tensor::tensor_values_f64(&gathered)
867                        .iter()
868                        .zip(expected.iter())
869                        .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
870                }
871                other => panic!("expected gpu tensor result, got {other:?}"),
872            }
873        });
874    }
875
876    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
877    #[test]
878    #[cfg(feature = "wgpu")]
879    fn polyint_wgpu_matches_cpu() {
880        let _guard = test_support::accel_test_lock();
881        let Ok(provider) = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
882            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
883        ) else {
884            return;
885        };
886        let tensor = Tensor::new(vec![3.0, -2.0, 5.0, 7.0], vec![1, 4]).unwrap();
887        let handle = gpu_helpers::upload_tensor(provider, &tensor).expect("upload");
888        let gpu_value = polyint_builtin(Value::GpuTensor(handle), Vec::new()).expect("polyint gpu");
889        let gathered = test_support::gather(gpu_value).expect("gather");
890        let cpu_value =
891            polyint_builtin(Value::Tensor(tensor.clone()), Vec::new()).expect("polyint cpu");
892        let expected = match cpu_value {
893            Value::Tensor(t) => t,
894            Value::Num(n) => Tensor::new(vec![n], vec![1, 1]).unwrap(),
895            other => panic!("unexpected cpu result {other:?}"),
896        };
897        assert_eq!(gathered.shape, expected.shape);
898        let tol = match provider.precision() {
899            runmat_accelerate_api::ProviderPrecision::F64 => 1e-12,
900            runmat_accelerate_api::ProviderPrecision::F32 => 1e-5,
901        };
902        tensor::tensor_values_f64(&gathered)
903            .iter()
904            .zip(tensor::tensor_values_f64(&expected).iter())
905            .for_each(|(lhs, rhs)| assert!((lhs - rhs).abs() < tol));
906    }
907
908    #[test]
909    #[cfg(feature = "wgpu")]
910    fn polyint_wgpu_complex_coefficients_match_cpu() {
911        let _guard = test_support::accel_test_lock();
912        let Ok(provider) = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
913            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
914        ) else {
915            return;
916        };
917        let coeffs =
918            ComplexTensor::new(vec![(3.0, 1.5), (-2.0, 0.5), (5.0, -1.0)], vec![1, 3]).unwrap();
919        let cpu_value =
920            polyint_builtin(Value::ComplexTensor(coeffs.clone()), vec![Value::Num(2.0)])
921                .expect("polyint cpu");
922        let cpu = match cpu_value {
923            Value::ComplexTensor(t) => t,
924            other => panic!("unexpected cpu result {other:?}"),
925        };
926
927        let handle = gpu_helpers::upload_complex_tensor(provider, &coeffs).expect("upload");
928        let gpu_value =
929            polyint_builtin(Value::GpuTensor(handle), vec![Value::Num(2.0)]).expect("polyint gpu");
930        let Value::GpuTensor(handle) = gpu_value else {
931            panic!("expected gpu tensor");
932        };
933        assert_eq!(
934            runmat_accelerate_api::handle_storage(&handle),
935            runmat_accelerate_api::GpuTensorStorage::ComplexInterleaved
936        );
937        let gathered =
938            block_on(gpu_helpers::gather_value_async(&Value::GpuTensor(handle))).expect("gather");
939        let Value::ComplexTensor(gpu) = gathered else {
940            panic!("expected complex tensor");
941        };
942        assert_eq!(gpu.shape, cpu.shape);
943        let tol = match provider.precision() {
944            runmat_accelerate_api::ProviderPrecision::F64 => 1e-12,
945            runmat_accelerate_api::ProviderPrecision::F32 => 1e-5,
946        };
947        gpu.materialize_f64()
948            .iter()
949            .zip(cpu.materialize_f64().iter())
950            .for_each(|((lre, lim), (rre, rim))| {
951                assert!((lre - rre).abs() < tol);
952                assert!((lim - rim).abs() < tol);
953            });
954    }
955
956    fn polyint_builtin(coeffs: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
957        block_on(super::polyint_builtin(coeffs, rest))
958    }
959}