runmat_runtime/builtins/math/poly/
polyder.rs

1//! MATLAB-compatible `polyder` builtin with GPU-aware semantics for RunMat.
2
3use log::trace;
4use num_complex::Complex64;
5use runmat_builtins::{ComplexTensor, Tensor, Value};
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::random_args::complex_tensor_into_value;
9use crate::builtins::common::spec::{
10    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
11    ProviderHook, ReductionNaN, ResidencyPolicy, ScalarType, ShapeRequirements,
12};
13use crate::builtins::common::{tensor, tensor::tensor_into_value};
14use crate::dispatcher;
15#[cfg(feature = "doc_export")]
16use crate::register_builtin_doc_text;
17use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
18
19const EPS: f64 = 1.0e-12;
20
21#[cfg(feature = "doc_export")]
22pub const DOC_MD: &str = r#"---
23title: "polyder"
24category: "math/poly"
25keywords: ["polyder", "polynomial derivative", "product rule", "quotient rule", "gpu"]
26summary: "Differentiate polynomials, products, and ratios with MATLAB-compatible coefficient vectors."
27references:
28  - title: "MATLAB polyder documentation"
29    url: "https://www.mathworks.com/help/matlab/ref/polyder.html"
30gpu_support:
31  elementwise: false
32  reduction: false
33  precisions: ["f32", "f64"]
34  broadcasting: "none"
35  notes: "When the active provider implements the polyder hooks, differentiation runs entirely on the GPU; otherwise coefficients are gathered back to the host. Complex coefficients continue to fall back to the CPU."
36fusion:
37  elementwise: false
38  reduction: false
39  max_inputs: 2
40  constants: "inline"
41requires_feature: null
42tested:
43  unit: "builtins::math::poly::polyder::tests"
44  integration: "builtins::math::poly::polyder::tests::{gpu_inputs_remain_on_device,gpu_product_matches_cpu,gpu_quotient_matches_cpu,wgpu_polyder_single_matches_cpu,wgpu_polyder_product_matches_cpu,wgpu_polyder_quotient_matches_cpu}"
45  vm: "ignition::vm polyder multi-output dispatch"
46  doc: "builtins::math::poly::polyder::tests::doc_examples_present"
47---
48
49# What does the `polyder` function do in MATLAB / RunMat?
50`polyder` differentiates polynomials represented by their coefficient vectors. The coefficients
51follow MATLAB’s convention: the first element corresponds to the highest power of `x`. The builtin
52supports three related operations:
53
541. `polyder(p)` returns the derivative of a polynomial `p`.
552. `polyder(p, a)` applies the product rule to the convolution `conv(p, a)`.
563. `[num, den] = polyder(u, v)` returns the derivative of a rational function `u(x) / v(x)`
57   using the quotient rule, yielding the numerator `num` and denominator `den`.
58
59## How does the `polyder` function behave in MATLAB / RunMat?
60- Accepts real or complex scalars, row vectors, column vectors, or empty vectors. Inputs with more
61  than one non-singleton dimension raise MATLAB-compatible errors.
62- Logical and integer coefficients are promoted to double precision before differentiation.
63- Leading zeros are removed in outputs (unless the polynomial is identically zero, in which case a
64  single zero coefficient is returned).
65- The orientation of the first input polynomial is preserved for the derivative of a single
66  polynomial or a product; the denominator in the quotient rule preserves the orientation of `v`.
67- Calling `polyder(p, a)` with a single output applies the product rule. Capturing two outputs
68  alongside two input polynomials returns the quotient-rule numerator and denominator
69  (`u' * v - u * v'`, `v * v`).
70- Empty inputs are treated as the zero polynomial.
71- When inputs live on the GPU (e.g., `gpuArray`) and the active provider exposes the polyder
72  hooks, differentiation runs entirely on-device and returns trimmed GPU tensors. Providers that
73  lack the hooks fall back to gathering coefficients to the host, executing the reference CPU
74  implementation, and returning host-resident results. Explicit calls to `gpuArray` remain
75  available if you need to force residency.
76
77## `polyder` Function GPU Execution Behaviour
78When a provider advertises the `polyder` hooks (the in-process and WGPU backends both do), single
79polynomials and the product/quotient forms execute fully on the GPU. Outputs preserve the
80orientation of the leading input while trimming leading zeros exactly as the CPU path does. If the
81provider declines the request—because the coefficients are complex or the backend lacks support—
82RunMat automatically gathers the inputs and falls back to the CPU reference implementation to
83preserve MATLAB compatibility.
84
85## Examples of using the `polyder` function in MATLAB / RunMat
86
87### Differentiating a cubic polynomial
88
89```matlab
90p = [3 -2 5 7];   % 3x^3 - 2x^2 + 5x + 7
91dp = polyder(p);
92```
93
94Expected output:
95
96```matlab
97dp = [9 -4 5];
98```
99
100### Applying the product rule
101
102```matlab
103p = [1 0 -2];    % x^2 - 2
104a = [1 1];       % x + 1
105dp = polyder(p, a);   % derivative of conv(p, a)
106```
107
108Expected output:
109
110```matlab
111dp = [3 2 -2];
112```
113
114### Differentiating a rational function
115
116```matlab
117u = [1 0 -4];      % x^2 - 4
118v = [1 -1];        % x - 1
119[num, den] = polyder(u, v);    % derivative of (u / v)
120```
121
122Expected output:
123
124```matlab
125num = [1 -2 4];
126den = [1 -2 1];
127```
128
129### Preserving column-vector orientation
130
131```matlab
132p = [1; 0; -3];    % column vector coefficients
133dp = polyder(p);
134```
135
136Expected output:
137
138```matlab
139dp =
140     2
141     0
142```
143
144### Differentiating complex-valued coefficients
145
146```matlab
147p = [1+2i, -3, 4i];
148dp = polyder(p);
149```
150
151Expected output:
152
153```matlab
154dp = [2+4i, -3];
155```
156
157### Working with gpuArray inputs
158
159```matlab
160g = gpuArray([2 0 -5 4]);
161dp = polyder(g);         % stays on the GPU when provider hooks are available
162result = gather(dp);
163```
164
165Expected behaviour:
166
167```matlab
168result = [6 0 -5];
169```
170
171## FAQ
172
173### What happens if I pass an empty coefficient vector?
174The empty vector represents the zero polynomial. `polyder([])` returns `[0]`, and the product and
175quotient forms treat empty inputs as zeros.
176
177### Does `polyder` support column-vector coefficients?
178Yes. The orientation of the first polynomial is preserved for single-polynomial and product
179derivatives. For the quotient rule, the numerator inherits the orientation of `u` and the
180denominator inherits the orientation of `v`.
181
182### How are leading zeros handled in the result?
183Leading zeros are removed automatically to mirror MATLAB. If all coefficients cancel out, a single
184zero coefficient is returned.
185
186### Can I differentiate logical or integer coefficient vectors?
187Yes. Logical and integer inputs are promoted to double precision before differentiation, matching
188MATLAB semantics.
189
190### How do I compute the derivative of a rational function?
191Call `[num, den] = polyder(u, v)`. The numerator and denominator are the coefficients of
192`(u' * v - u * v')` and `v * v`, respectively, with leading zeros removed.
193
194### Does the builtin launch GPU kernels?
195Yes whenever the active acceleration provider implements the `polyder` hooks. The in-process and
196WGPU backends both execute the derivative on the device. Providers that lack the hooks—or inputs that
197require complex arithmetic—fall back to the CPU reference implementation, returning the result on the
198host. Wrap the output in `gpuArray` manually if you need to restore device residency in that case.
199
200### What if I request more than two outputs?
201`polyder` only defines one or two outputs. Additional requested outputs are filled with `0`, just as
202RunMat currently does for other MATLAB builtins whose extra outputs are ignored.
203
204### Are complex coefficients supported in the quotient rule?
205Yes. Both the numerator and denominator are computed using full complex arithmetic, so mixed
206real/complex inputs work without additional steps.
207
208### Do I need to normalise or pad my coefficient vectors?
209No. Provide coefficients exactly as MATLAB expects (highest power first). The builtin takes care of
210padding, trimming, and orientation.
211
212### How precise is the computation?
213All arithmetic uses IEEE 754 double precision (`f64`), matching MATLAB’s default numeric type.
214
215## See Also
216[polyval](./polyval), [polyfit](./polyfit), [conv](../signal/conv), [deconv](../signal/deconv), [gpuArray](../../acceleration/gpu/gpuArray), [gather](../../acceleration/gpu/gather)
217"#;
218
219pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
220    name: "polyder",
221    op_kind: GpuOpKind::Custom("polynomial-derivative"),
222    supported_precisions: &[ScalarType::F32, ScalarType::F64],
223    broadcast: BroadcastSemantics::None,
224    provider_hooks: &[
225        ProviderHook::Custom("polyder-single"),
226        ProviderHook::Custom("polyder-product"),
227        ProviderHook::Custom("polyder-quotient"),
228    ],
229    constant_strategy: ConstantStrategy::InlineLiteral,
230    residency: ResidencyPolicy::NewHandle,
231    nan_mode: ReductionNaN::Include,
232    two_pass_threshold: None,
233    workgroup_size: None,
234    accepts_nan_mode: false,
235    notes: "Runs on-device when providers expose polyder hooks; falls back to the host for complex coefficients or unsupported shapes.",
236};
237
238register_builtin_gpu_spec!(GPU_SPEC);
239
240pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
241    name: "polyder",
242    shape: ShapeRequirements::Any,
243    constant_strategy: ConstantStrategy::InlineLiteral,
244    elementwise: None,
245    reduction: None,
246    emits_nan: false,
247    notes: "Symbolic operation on coefficient vectors; fusion bypasses this builtin.",
248};
249
250register_builtin_fusion_spec!(FUSION_SPEC);
251
252#[cfg(feature = "doc_export")]
253register_builtin_doc_text!("polyder", DOC_MD);
254
255#[runtime_builtin(
256    name = "polyder",
257    category = "math/poly",
258    summary = "Differentiate polynomials, products, and ratios with MATLAB-compatible coefficient vectors.",
259    keywords = "polyder,polynomial,derivative,product,quotient"
260)]
261fn polyder_builtin(first: Value, rest: Vec<Value>) -> Result<Value, String> {
262    match rest.len() {
263        0 => derivative_single(first),
264        1 => derivative_product(first, rest.into_iter().next().unwrap()),
265        _ => Err("polyder: too many input arguments".to_string()),
266    }
267}
268
269fn try_gpu_derivative_single(value: &Value) -> Result<Option<Value>, String> {
270    let Value::GpuTensor(handle) = value else {
271        return Ok(None);
272    };
273    let Some(provider) = runmat_accelerate_api::provider() else {
274        return Ok(None);
275    };
276    match provider.polyder_single(handle) {
277        Ok(out) => Ok(Some(Value::GpuTensor(out))),
278        Err(err) => {
279            trace!("polyder: provider polyder_single fallback: {err}");
280            Ok(None)
281        }
282    }
283}
284
285fn try_gpu_derivative_product(first: &Value, second: &Value) -> Result<Option<Value>, String> {
286    match (first, second) {
287        (Value::GpuTensor(p), Value::GpuTensor(q)) => {
288            let Some(provider) = runmat_accelerate_api::provider() else {
289                return Ok(None);
290            };
291            match provider.polyder_product(p, q) {
292                Ok(out) => Ok(Some(Value::GpuTensor(out))),
293                Err(err) => {
294                    trace!("polyder: provider polyder_product fallback: {err}");
295                    Ok(None)
296                }
297            }
298        }
299        _ => Ok(None),
300    }
301}
302
303fn try_gpu_quotient(u: &Value, v: &Value) -> Result<Option<PolyderEval>, String> {
304    match (u, v) {
305        (Value::GpuTensor(uh), Value::GpuTensor(vh)) => {
306            let Some(provider) = runmat_accelerate_api::provider() else {
307                return Ok(None);
308            };
309            match provider.polyder_quotient(uh, vh) {
310                Ok(result) => Ok(Some(PolyderEval {
311                    numerator: Value::GpuTensor(result.numerator),
312                    denominator: Value::GpuTensor(result.denominator),
313                })),
314                Err(err) => {
315                    trace!("polyder: provider polyder_quotient fallback: {err}");
316                    Ok(None)
317                }
318            }
319        }
320        _ => Ok(None),
321    }
322}
323
324/// Evaluate the quotient rule derivative `[num, den] = polyder(u, v)`.
325pub fn evaluate_quotient(u: Value, v: Value) -> Result<PolyderEval, String> {
326    if let Some(eval) = try_gpu_quotient(&u, &v)? {
327        return Ok(eval);
328    }
329    let u_poly = parse_polynomial("polyder", "U", u)?;
330    let v_poly = parse_polynomial("polyder", "V", v)?;
331    let numerator = quotient_numerator(&u_poly, &v_poly)?;
332    let denominator = quotient_denominator(&v_poly)?;
333    Ok(PolyderEval {
334        numerator,
335        denominator,
336    })
337}
338
339/// Differentiated outputs for the quotient rule.
340#[derive(Clone)]
341pub struct PolyderEval {
342    numerator: Value,
343    denominator: Value,
344}
345
346impl PolyderEval {
347    /// Numerator coefficients of the derivative `(u' * v - u * v')`.
348    pub fn numerator(&self) -> Value {
349        self.numerator.clone()
350    }
351
352    /// Denominator coefficients `v^2`.
353    pub fn denominator(&self) -> Value {
354        self.denominator.clone()
355    }
356}
357
358pub fn derivative_single(value: Value) -> Result<Value, String> {
359    if let Some(out) = try_gpu_derivative_single(&value)? {
360        return Ok(out);
361    }
362    let poly = parse_polynomial("polyder", "P", value)?;
363    differentiate_polynomial(&poly)
364}
365
366pub fn derivative_product(first: Value, second: Value) -> Result<Value, String> {
367    if let Some(out) = try_gpu_derivative_product(&first, &second)? {
368        return Ok(out);
369    }
370    let p = parse_polynomial("polyder", "P", first)?;
371    let q = parse_polynomial("polyder", "A", second)?;
372    product_derivative(&p, &q)
373}
374
375fn quotient_numerator(u: &Polynomial, v: &Polynomial) -> Result<Value, String> {
376    let du = raw_derivative(&u.coeffs);
377    let dv = raw_derivative(&v.coeffs);
378    let term1 = poly_convolve(&du, &v.coeffs);
379    let term2 = poly_convolve(&u.coeffs, &dv);
380    let mut numerator = poly_sub(&term1, &term2);
381    numerator = trim_leading_zeros(&numerator);
382    coeffs_to_value(&numerator, u.orientation)
383}
384
385fn quotient_denominator(v: &Polynomial) -> Result<Value, String> {
386    let mut denominator = poly_convolve(&v.coeffs, &v.coeffs);
387    denominator = trim_leading_zeros(&denominator);
388    coeffs_to_value(&denominator, v.orientation)
389}
390
391fn differentiate_polynomial(poly: &Polynomial) -> Result<Value, String> {
392    let mut coeffs = raw_derivative(&poly.coeffs);
393    coeffs = trim_leading_zeros(&coeffs);
394    coeffs_to_value(&coeffs, poly.orientation)
395}
396
397fn product_derivative(p: &Polynomial, q: &Polynomial) -> Result<Value, String> {
398    let dp = raw_derivative(&p.coeffs);
399    let dq = raw_derivative(&q.coeffs);
400    let term1 = poly_convolve(&dp, &q.coeffs);
401    let term2 = poly_convolve(&p.coeffs, &dq);
402    let mut result = poly_add(&term1, &term2);
403    result = trim_leading_zeros(&result);
404    coeffs_to_value(&result, p.orientation)
405}
406
407fn raw_derivative(coeffs: &[Complex64]) -> Vec<Complex64> {
408    if coeffs.len() <= 1 {
409        return vec![Complex64::new(0.0, 0.0)];
410    }
411    let mut output = Vec::with_capacity(coeffs.len() - 1);
412    let mut power = coeffs.len() - 1;
413    for coeff in coeffs.iter().take(coeffs.len() - 1) {
414        output.push(*coeff * (power as f64));
415        power -= 1;
416    }
417    output
418}
419
420fn poly_convolve(a: &[Complex64], b: &[Complex64]) -> Vec<Complex64> {
421    if a.is_empty() || b.is_empty() {
422        return Vec::new();
423    }
424    let mut result = vec![Complex64::new(0.0, 0.0); a.len() + b.len() - 1];
425    for (i, &ai) in a.iter().enumerate() {
426        for (j, &bj) in b.iter().enumerate() {
427            result[i + j] += ai * bj;
428        }
429    }
430    result
431}
432
433fn poly_add(a: &[Complex64], b: &[Complex64]) -> Vec<Complex64> {
434    let len = a.len().max(b.len());
435    let mut result = vec![Complex64::new(0.0, 0.0); len];
436    for (idx, &value) in a.iter().enumerate() {
437        result[len - a.len() + idx] += value;
438    }
439    for (idx, &value) in b.iter().enumerate() {
440        result[len - b.len() + idx] += value;
441    }
442    result
443}
444
445fn poly_sub(a: &[Complex64], b: &[Complex64]) -> Vec<Complex64> {
446    let len = a.len().max(b.len());
447    let mut result = vec![Complex64::new(0.0, 0.0); len];
448    for (idx, &value) in a.iter().enumerate() {
449        result[len - a.len() + idx] += value;
450    }
451    for (idx, &value) in b.iter().enumerate() {
452        result[len - b.len() + idx] -= value;
453    }
454    result
455}
456
457fn trim_leading_zeros(coeffs: &[Complex64]) -> Vec<Complex64> {
458    let mut first = None;
459    for (idx, coeff) in coeffs.iter().enumerate() {
460        if coeff.norm() > EPS {
461            first = Some(idx);
462            break;
463        }
464    }
465    match first {
466        Some(idx) => coeffs[idx..].to_vec(),
467        None => vec![Complex64::new(0.0, 0.0)],
468    }
469}
470
471fn coeffs_to_value(coeffs: &[Complex64], orientation: Orientation) -> Result<Value, String> {
472    if coeffs.iter().all(|c| c.im.abs() <= EPS) {
473        let data: Vec<f64> = coeffs.iter().map(|c| c.re).collect();
474        let shape = orientation.shape_for_len(data.len());
475        let tensor = Tensor::new(data, shape).map_err(|e| format!("polyder: {e}"))?;
476        Ok(tensor_into_value(tensor))
477    } else {
478        let data: Vec<(f64, f64)> = coeffs.iter().map(|c| (c.re, c.im)).collect();
479        let shape = orientation.shape_for_len(data.len());
480        let tensor = ComplexTensor::new(data, shape).map_err(|e| format!("polyder: {e}"))?;
481        Ok(complex_tensor_into_value(tensor))
482    }
483}
484
485fn parse_polynomial(context: &str, label: &str, value: Value) -> Result<Polynomial, String> {
486    let gathered = dispatcher::gather_if_needed(&value).map_err(|e| format!("{context}: {e}"))?;
487    let (coeffs, orientation) = match gathered {
488        Value::Tensor(tensor) => {
489            ensure_vector_shape(context, label, &tensor.shape)?;
490            let orientation = orientation_from_shape(&tensor.shape);
491            if tensor.data.is_empty() {
492                (vec![Complex64::new(0.0, 0.0)], orientation)
493            } else {
494                (
495                    tensor
496                        .data
497                        .into_iter()
498                        .map(|re| Complex64::new(re, 0.0))
499                        .collect(),
500                    orientation,
501                )
502            }
503        }
504        Value::ComplexTensor(tensor) => {
505            ensure_vector_shape(context, label, &tensor.shape)?;
506            let orientation = orientation_from_shape(&tensor.shape);
507            if tensor.data.is_empty() {
508                (vec![Complex64::new(0.0, 0.0)], orientation)
509            } else {
510                (
511                    tensor
512                        .data
513                        .into_iter()
514                        .map(|(re, im)| Complex64::new(re, im))
515                        .collect(),
516                    orientation,
517                )
518            }
519        }
520        Value::LogicalArray(logical) => {
521            let tensor = tensor::logical_to_tensor(&logical)?;
522            ensure_vector_shape(context, label, &tensor.shape)?;
523            let orientation = orientation_from_shape(&tensor.shape);
524            if tensor.data.is_empty() {
525                (vec![Complex64::new(0.0, 0.0)], orientation)
526            } else {
527                (
528                    tensor
529                        .data
530                        .into_iter()
531                        .map(|re| Complex64::new(re, 0.0))
532                        .collect(),
533                    orientation,
534                )
535            }
536        }
537        Value::Num(n) => (vec![Complex64::new(n, 0.0)], Orientation::Scalar),
538        Value::Int(i) => (vec![Complex64::new(i.to_f64(), 0.0)], Orientation::Scalar),
539        Value::Bool(b) => (
540            vec![Complex64::new(if b { 1.0 } else { 0.0 }, 0.0)],
541            Orientation::Scalar,
542        ),
543        Value::Complex(re, im) => (vec![Complex64::new(re, im)], Orientation::Scalar),
544        other => {
545            return Err(format!(
546                "{context}: expected {label} to be a numeric vector, got {other:?}"
547            ));
548        }
549    };
550
551    Ok(Polynomial {
552        coeffs,
553        orientation,
554    })
555}
556
557fn ensure_vector_shape(context: &str, label: &str, shape: &[usize]) -> Result<(), String> {
558    let non_unit = shape.iter().copied().filter(|&dim| dim > 1).count();
559    if non_unit <= 1 {
560        Ok(())
561    } else {
562        Err(format!(
563            "{context}: {label} must be a vector of coefficients"
564        ))
565    }
566}
567
568#[derive(Clone, Copy)]
569enum Orientation {
570    Scalar,
571    Row,
572    Column,
573}
574
575impl Orientation {
576    fn shape_for_len(self, len: usize) -> Vec<usize> {
577        if len <= 1 {
578            return vec![1, 1];
579        }
580        match self {
581            Orientation::Scalar => vec![1, len],
582            Orientation::Row => vec![1, len],
583            Orientation::Column => vec![len, 1],
584        }
585    }
586}
587
588fn orientation_from_shape(shape: &[usize]) -> Orientation {
589    for (idx, &dim) in shape.iter().enumerate() {
590        if dim != 1 {
591            return match idx {
592                0 => Orientation::Column,
593                1 => Orientation::Row,
594                _ => Orientation::Column,
595            };
596        }
597    }
598    Orientation::Scalar
599}
600
601#[derive(Clone)]
602struct Polynomial {
603    coeffs: Vec<Complex64>,
604    orientation: Orientation,
605}
606
607#[cfg(test)]
608mod tests {
609    use super::*;
610    use crate::builtins::common::test_support;
611    use runmat_builtins::{IntValue, Tensor};
612
613    #[test]
614    fn derivative_of_cubic_polynomial_is_correct() {
615        let tensor = Tensor::new(vec![3.0, -2.0, 5.0, 7.0], vec![1, 4]).unwrap();
616        let result = derivative_single(Value::Tensor(tensor)).expect("polyder");
617        match result {
618            Value::Tensor(t) => {
619                assert_eq!(t.shape, vec![1, 3]);
620                assert!(t
621                    .data
622                    .iter()
623                    .zip([9.0, -4.0, 5.0])
624                    .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
625            }
626            other => panic!("expected tensor result, got {other:?}"),
627        }
628    }
629
630    #[test]
631    fn derivative_of_product_matches_manual_rule() {
632        let p = Tensor::new(vec![1.0, 0.0, -2.0], vec![1, 3]).unwrap();
633        let a = Tensor::new(vec![1.0, 1.0], vec![1, 2]).unwrap();
634        let result =
635            derivative_product(Value::Tensor(p), Value::Tensor(a)).expect("polyder product");
636        match result {
637            Value::Tensor(t) => {
638                assert_eq!(t.shape, vec![1, 3]);
639                assert!(t
640                    .data
641                    .iter()
642                    .zip([3.0, 2.0, -2.0])
643                    .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
644            }
645            other => panic!("expected tensor result, got {other:?}"),
646        }
647    }
648
649    #[test]
650    fn quotient_rule_produces_expected_num_and_den() {
651        let u = Tensor::new(vec![1.0, 0.0, -4.0], vec![1, 3]).unwrap();
652        let v = Tensor::new(vec![1.0, -1.0], vec![1, 2]).unwrap();
653        let eval = evaluate_quotient(Value::Tensor(u), Value::Tensor(v)).expect("polyder quotient");
654        match eval.numerator() {
655            Value::Tensor(t) => {
656                assert_eq!(t.shape, vec![1, 3]);
657                assert!(t
658                    .data
659                    .iter()
660                    .zip([1.0, -2.0, 4.0])
661                    .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
662            }
663            other => panic!("expected tensor numerator, got {other:?}"),
664        }
665        match eval.denominator() {
666            Value::Tensor(t) => {
667                assert_eq!(t.shape, vec![1, 3]);
668                assert!(t
669                    .data
670                    .iter()
671                    .zip([1.0, -2.0, 1.0])
672                    .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
673            }
674            other => panic!("expected tensor denominator, got {other:?}"),
675        }
676    }
677
678    #[test]
679    fn column_vector_orientation_is_preserved() {
680        let tensor = Tensor::new(vec![1.0, 0.0, -3.0], vec![3, 1]).unwrap();
681        let result = derivative_single(Value::Tensor(tensor)).expect("polyder column");
682        match result {
683            Value::Tensor(t) => {
684                assert_eq!(t.shape, vec![2, 1]);
685                assert!(t
686                    .data
687                    .iter()
688                    .zip([2.0, 0.0])
689                    .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
690            }
691            other => panic!("expected column tensor, got {other:?}"),
692        }
693    }
694
695    #[test]
696    fn complex_coefficients_are_supported() {
697        let tensor =
698            ComplexTensor::new(vec![(1.0, 2.0), (-3.0, 0.0), (0.0, 4.0)], vec![1, 3]).unwrap();
699        let result = derivative_single(Value::ComplexTensor(tensor)).expect("polyder complex");
700        match result {
701            Value::ComplexTensor(t) => {
702                assert_eq!(t.shape, vec![1, 2]);
703                let expected = [Complex64::new(2.0, 4.0), Complex64::new(-3.0, 0.0)];
704                assert!(t
705                    .data
706                    .iter()
707                    .zip(expected.iter())
708                    .all(|((re, im), expected)| {
709                        (re - expected.re).abs() < 1e-12 && (im - expected.im).abs() < 1e-12
710                    }));
711            }
712            other => panic!("expected complex tensor, got {other:?}"),
713        }
714    }
715
716    #[test]
717    fn empty_polynomial_returns_zero() {
718        let tensor = Tensor::new(Vec::new(), vec![1, 0]).unwrap();
719        let result = derivative_single(Value::Tensor(tensor)).expect("polyder empty");
720        assert_eq!(result, Value::Num(0.0));
721    }
722
723    #[test]
724    fn rejects_matrix_input() {
725        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2]).unwrap();
726        let err = derivative_single(Value::Tensor(tensor)).unwrap_err();
727        assert!(
728            err.contains("vector of coefficients"),
729            "unexpected error: {err}"
730        );
731    }
732
733    #[test]
734    fn rejects_string_input() {
735        let err = derivative_single(Value::String("abc".into())).unwrap_err();
736        assert!(
737            err.contains("numeric vector"),
738            "unexpected error message: {err}"
739        );
740    }
741
742    #[test]
743    fn mixed_gpu_cpu_product_falls_back_to_host() {
744        test_support::with_test_provider(|provider| {
745            let p = Tensor::new(vec![1.0, 0.0, -2.0], vec![1, 3]).unwrap();
746            let q = Tensor::new(vec![1.0, 1.0], vec![1, 2]).unwrap();
747            let cpu_expected =
748                derivative_product(Value::Tensor(p.clone()), Value::Tensor(q.clone()))
749                    .expect("cpu product");
750            let Value::Tensor(cpu_tensor) = cpu_expected else {
751                panic!("expected tensor result");
752            };
753
754            let view_p = runmat_accelerate_api::HostTensorView {
755                data: &p.data,
756                shape: &p.shape,
757            };
758            let handle_p = provider.upload(&view_p).expect("upload p");
759            let result = derivative_product(Value::GpuTensor(handle_p), Value::Tensor(q))
760                .expect("mixed product");
761            let Value::Tensor(host_tensor) = result else {
762                panic!("expected host tensor result");
763            };
764            assert_eq!(host_tensor.shape, cpu_tensor.shape);
765            assert!(host_tensor
766                .data
767                .iter()
768                .zip(cpu_tensor.data.iter())
769                .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
770        });
771    }
772
773    #[test]
774    fn builtin_rejects_too_many_inputs() {
775        let err = super::polyder_builtin(Value::Num(1.0), vec![Value::Num(2.0), Value::Num(3.0)])
776            .unwrap_err();
777        assert!(
778            err.contains("too many input arguments"),
779            "unexpected error: {err}"
780        );
781    }
782
783    #[test]
784    fn gpu_inputs_remain_on_device() {
785        test_support::with_test_provider(|provider| {
786            let tensor = Tensor::new(vec![2.0, 0.0, -5.0, 4.0], vec![1, 4]).unwrap();
787            let view = runmat_accelerate_api::HostTensorView {
788                data: &tensor.data,
789                shape: &tensor.shape,
790            };
791            let handle = provider.upload(&view).expect("upload");
792            let result = derivative_single(Value::GpuTensor(handle)).expect("polyder gpu");
793            let Value::GpuTensor(out_handle) = result else {
794                panic!("expected GPU tensor result");
795            };
796            let gathered = test_support::gather(Value::GpuTensor(out_handle)).expect("gather");
797            assert_eq!(gathered.shape, vec![1, 3]);
798            assert!(gathered
799                .data
800                .iter()
801                .zip([6.0, 0.0, -5.0])
802                .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
803        });
804    }
805
806    #[test]
807    fn gpu_product_matches_cpu() {
808        test_support::with_test_provider(|provider| {
809            let p = Tensor::new(vec![1.0, 0.0, -2.0], vec![1, 3]).unwrap();
810            let q = Tensor::new(vec![1.0, 1.0], vec![1, 2]).unwrap();
811            let expected = derivative_product(Value::Tensor(p.clone()), Value::Tensor(q.clone()))
812                .expect("cpu product");
813            let Value::Tensor(expected_tensor) = expected else {
814                panic!("expected tensor output");
815            };
816
817            let view_p = runmat_accelerate_api::HostTensorView {
818                data: &p.data,
819                shape: &p.shape,
820            };
821            let view_q = runmat_accelerate_api::HostTensorView {
822                data: &q.data,
823                shape: &q.shape,
824            };
825            let handle_p = provider.upload(&view_p).expect("upload p");
826            let handle_q = provider.upload(&view_q).expect("upload q");
827            let gpu_result =
828                derivative_product(Value::GpuTensor(handle_p), Value::GpuTensor(handle_q))
829                    .expect("gpu product");
830            let Value::GpuTensor(gpu_handle) = gpu_result else {
831                panic!("expected GPU tensor");
832            };
833            let gathered = test_support::gather(Value::GpuTensor(gpu_handle)).expect("gather");
834            assert_eq!(gathered.shape, expected_tensor.shape);
835            assert!(gathered
836                .data
837                .iter()
838                .zip(expected_tensor.data.iter())
839                .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
840        });
841    }
842
843    #[test]
844    fn gpu_quotient_matches_cpu() {
845        test_support::with_test_provider(|provider| {
846            let u = Tensor::new(vec![1.0, 0.0, -4.0], vec![1, 3]).unwrap();
847            let v = Tensor::new(vec![1.0, -1.0], vec![1, 2]).unwrap();
848            let expected = evaluate_quotient(Value::Tensor(u.clone()), Value::Tensor(v.clone()))
849                .expect("cpu quotient");
850            let Value::Tensor(expected_num) = expected.numerator() else {
851                panic!("expected tensor numerator");
852            };
853            let Value::Tensor(expected_den) = expected.denominator() else {
854                panic!("expected tensor denominator");
855            };
856
857            let view_u = runmat_accelerate_api::HostTensorView {
858                data: &u.data,
859                shape: &u.shape,
860            };
861            let view_v = runmat_accelerate_api::HostTensorView {
862                data: &v.data,
863                shape: &v.shape,
864            };
865            let handle_u = provider.upload(&view_u).expect("upload u");
866            let handle_v = provider.upload(&view_v).expect("upload v");
867            let gpu_eval =
868                evaluate_quotient(Value::GpuTensor(handle_u), Value::GpuTensor(handle_v))
869                    .expect("gpu quotient");
870            let gpu_num = test_support::gather(gpu_eval.numerator()).expect("gather num");
871            let gpu_den = test_support::gather(gpu_eval.denominator()).expect("gather den");
872            assert_eq!(gpu_num.shape, expected_num.shape);
873            assert_eq!(gpu_den.shape, expected_den.shape);
874            assert!(gpu_num
875                .data
876                .iter()
877                .zip(expected_num.data.iter())
878                .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
879            assert!(gpu_den
880                .data
881                .iter()
882                .zip(expected_den.data.iter())
883                .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
884        });
885    }
886
887    #[test]
888    #[cfg(feature = "wgpu")]
889    fn wgpu_polyder_single_matches_cpu() {
890        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
891            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
892        );
893        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
894        let tensor = Tensor::new(vec![3.0, -2.0, 5.0, 7.0], vec![1, 4]).unwrap();
895        let expected = derivative_single(Value::Tensor(tensor.clone())).expect("cpu polyder");
896        let Value::Tensor(expected_tensor) = expected else {
897            panic!("expected tensor");
898        };
899        let view = runmat_accelerate_api::HostTensorView {
900            data: &tensor.data,
901            shape: &tensor.shape,
902        };
903        let handle = provider.upload(&view).expect("upload");
904        let gpu_result = derivative_single(Value::GpuTensor(handle)).expect("gpu polyder");
905        let gathered = test_support::gather(gpu_result).expect("gather");
906        assert_eq!(gathered.shape, expected_tensor.shape);
907        assert!(gathered
908            .data
909            .iter()
910            .zip(expected_tensor.data.iter())
911            .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
912    }
913
914    #[test]
915    #[cfg(feature = "wgpu")]
916    fn wgpu_polyder_product_matches_cpu() {
917        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
918            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
919        );
920        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
921        let p = Tensor::new(vec![1.0, 0.0, -2.0], vec![1, 3]).unwrap();
922        let q = Tensor::new(vec![1.0, 1.0], vec![1, 2]).unwrap();
923        let expected = derivative_product(Value::Tensor(p.clone()), Value::Tensor(q.clone()))
924            .expect("cpu product");
925        let Value::Tensor(expected_tensor) = expected else {
926            panic!("expected tensor");
927        };
928        let view_p = runmat_accelerate_api::HostTensorView {
929            data: &p.data,
930            shape: &p.shape,
931        };
932        let view_q = runmat_accelerate_api::HostTensorView {
933            data: &q.data,
934            shape: &q.shape,
935        };
936        let handle_p = provider.upload(&view_p).expect("upload p");
937        let handle_q = provider.upload(&view_q).expect("upload q");
938        let gpu_result = derivative_product(Value::GpuTensor(handle_p), Value::GpuTensor(handle_q))
939            .expect("gpu product");
940        let gathered = test_support::gather(gpu_result).expect("gather");
941        assert_eq!(gathered.shape, expected_tensor.shape);
942        assert!(gathered
943            .data
944            .iter()
945            .zip(expected_tensor.data.iter())
946            .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
947    }
948
949    #[test]
950    #[cfg(feature = "wgpu")]
951    fn wgpu_polyder_quotient_matches_cpu() {
952        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
953            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
954        );
955        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
956        let u = Tensor::new(vec![1.0, 0.0, -4.0], vec![1, 3]).unwrap();
957        let v = Tensor::new(vec![1.0, -1.0], vec![1, 2]).unwrap();
958        let expected = evaluate_quotient(Value::Tensor(u.clone()), Value::Tensor(v.clone()))
959            .expect("cpu quotient");
960        let expected_num = match expected.numerator() {
961            Value::Tensor(t) => t,
962            other => panic!("expected tensor numerator, got {other:?}"),
963        };
964        let expected_den = match expected.denominator() {
965            Value::Tensor(t) => t,
966            other => panic!("expected tensor denominator, got {other:?}"),
967        };
968        let view_u = runmat_accelerate_api::HostTensorView {
969            data: &u.data,
970            shape: &u.shape,
971        };
972        let view_v = runmat_accelerate_api::HostTensorView {
973            data: &v.data,
974            shape: &v.shape,
975        };
976        let handle_u = provider.upload(&view_u).expect("upload u");
977        let handle_v = provider.upload(&view_v).expect("upload v");
978        let gpu_eval = evaluate_quotient(Value::GpuTensor(handle_u), Value::GpuTensor(handle_v))
979            .expect("gpu quotient");
980        let gpu_num = test_support::gather(gpu_eval.numerator()).expect("gather num");
981        let gpu_den = test_support::gather(gpu_eval.denominator()).expect("gather den");
982        assert_eq!(gpu_num.shape, expected_num.shape);
983        assert_eq!(gpu_den.shape, expected_den.shape);
984        assert!(gpu_num
985            .data
986            .iter()
987            .zip(expected_num.data.iter())
988            .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
989        assert!(gpu_den
990            .data
991            .iter()
992            .zip(expected_den.data.iter())
993            .all(|(lhs, rhs)| (lhs - rhs).abs() < 1e-12));
994    }
995
996    #[test]
997    fn derivative_promotes_integers() {
998        let value = Value::Int(IntValue::I32(5));
999        let result = derivative_single(value).expect("polyder int");
1000        assert_eq!(result, Value::Num(0.0));
1001    }
1002
1003    #[test]
1004    #[cfg(feature = "doc_export")]
1005    fn doc_examples_present() {
1006        let blocks = test_support::doc_examples(DOC_MD);
1007        assert!(!blocks.is_empty());
1008    }
1009}