Skip to main content

runmat_runtime/builtins/math/poly/
roots.rs

1//! MATLAB-compatible `roots` builtin with GPU-aware semantics for RunMat.
2//!
3//! This implementation mirrors MATLAB behaviour, including handling for leading
4//! zeros, constant polynomials, and complex-valued coefficients. GPU inputs are
5//! gathered to the host because companion matrix eigenvalue computations are
6//! currently performed on the CPU.
7
8use nalgebra::DMatrix;
9use num_complex::Complex64;
10use runmat_builtins::{
11    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
12    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
13    ComplexTensor, Tensor, Value,
14};
15use runmat_macros::runtime_builtin;
16
17use crate::builtins::common::spec::{
18    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
19    ReductionNaN, ResidencyPolicy, ShapeRequirements,
20};
21use crate::builtins::common::{gpu_helpers, tensor};
22use crate::builtins::math::poly::type_resolvers::roots_type;
23use crate::{build_runtime_error, BuiltinResult, RuntimeError};
24
25const LEADING_ZERO_TOL: f64 = 1.0e-12;
26const RESULT_ZERO_TOL: f64 = 1.0e-10;
27const BUILTIN_NAME: &str = "roots";
28
29const ROOTS_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
30    name: "r",
31    ty: BuiltinParamType::Any,
32    arity: BuiltinParamArity::Required,
33    default: None,
34    description: "Roots of the polynomial as a column vector.",
35}];
36
37const ROOTS_INPUTS: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
38    name: "c",
39    ty: BuiltinParamType::Any,
40    arity: BuiltinParamArity::Required,
41    default: None,
42    description: "Polynomial coefficient vector in descending power order.",
43}];
44
45const ROOTS_SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
46    label: "r = roots(c)",
47    inputs: &ROOTS_INPUTS,
48    outputs: &ROOTS_OUTPUT,
49}];
50
51const ROOTS_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
52    code: "RM.ROOTS.INVALID_INPUT",
53    identifier: Some("RunMat:roots:InvalidInput"),
54    when: "Input cannot be interpreted as a numeric coefficient vector.",
55    message: "roots: invalid input",
56};
57
58const ROOTS_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
59    code: "RM.ROOTS.INTERNAL",
60    identifier: Some("RunMat:roots:Internal"),
61    when: "Runtime fails while building companion matrix outputs or solving eigenvalues.",
62    message: "roots: internal runtime failure",
63};
64
65const ROOTS_ERRORS: [BuiltinErrorDescriptor; 2] = [ROOTS_ERROR_INVALID_INPUT, ROOTS_ERROR_INTERNAL];
66
67pub const ROOTS_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
68    signatures: &ROOTS_SIGNATURES,
69    output_mode: BuiltinOutputMode::Fixed,
70    completion_policy: BuiltinCompletionPolicy::Public,
71    errors: &ROOTS_ERRORS,
72};
73
74#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::poly::roots")]
75pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
76    name: "roots",
77    op_kind: GpuOpKind::Custom("polynomial-roots"),
78    supported_precisions: &[],
79    broadcast: BroadcastSemantics::None,
80    provider_hooks: &[],
81    constant_strategy: ConstantStrategy::InlineLiteral,
82    residency: ResidencyPolicy::GatherImmediately,
83    nan_mode: ReductionNaN::Include,
84    two_pass_threshold: None,
85    workgroup_size: None,
86    accepts_nan_mode: false,
87    notes: "Companion matrix eigenvalue solve executes on the host; providers currently fall back to the CPU implementation.",
88};
89
90fn roots_error(message: impl Into<String>) -> RuntimeError {
91    roots_error_with(message, &ROOTS_ERROR_INVALID_INPUT)
92}
93
94fn roots_error_with(
95    message: impl Into<String>,
96    error: &'static BuiltinErrorDescriptor,
97) -> RuntimeError {
98    let mut builder = build_runtime_error(message).with_builtin(BUILTIN_NAME);
99    if let Some(identifier) = error.identifier {
100        builder = builder.with_identifier(identifier);
101    }
102    builder.build()
103}
104
105#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::poly::roots")]
106pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
107    name: "roots",
108    shape: ShapeRequirements::Any,
109    constant_strategy: ConstantStrategy::InlineLiteral,
110    elementwise: None,
111    reduction: None,
112    emits_nan: true,
113    notes: "Non-elementwise builtin that terminates fusion and gathers inputs to the host.",
114};
115
116#[runtime_builtin(
117    name = "roots",
118    category = "math/poly",
119    summary = "Compute polynomial roots from a coefficient vector.",
120    keywords = "roots,polynomial,eigenvalues,companion",
121    accel = "sink",
122    type_resolver(roots_type),
123    descriptor(crate::builtins::math::poly::roots::ROOTS_DESCRIPTOR),
124    builtin_path = "crate::builtins::math::poly::roots"
125)]
126async fn roots_builtin(coefficients: Value) -> crate::BuiltinResult<Value> {
127    roots_value(coefficients).await
128}
129
130pub(crate) async fn roots_value(coefficients: Value) -> crate::BuiltinResult<Value> {
131    let coeffs = coefficients_to_complex(coefficients).await?;
132    let trimmed = trim_leading_zeros(coeffs);
133    if trimmed.is_empty() || trimmed.len() == 1 {
134        return empty_column();
135    }
136    let roots = solve_roots(&trimmed)?;
137    roots_to_value(&roots)
138}
139
140async fn coefficients_to_complex(value: Value) -> BuiltinResult<Vec<Complex64>> {
141    match value {
142        Value::GpuTensor(handle) => {
143            let tensor = gpu_helpers::gather_tensor_async(&handle).await?;
144            tensor_to_complex(tensor)
145        }
146        Value::Tensor(tensor) => tensor_to_complex(tensor),
147        Value::ComplexTensor(tensor) => complex_tensor_to_vec(tensor),
148        Value::LogicalArray(logical) => {
149            let tensor = tensor::logical_to_tensor(&logical).map_err(roots_error)?;
150            tensor_to_complex(tensor)
151        }
152        Value::Num(n) => {
153            let tensor =
154                Tensor::new(vec![n], vec![1, 1]).map_err(|e| roots_error(format!("roots: {e}")))?;
155            tensor_to_complex(tensor)
156        }
157        Value::Int(i) => {
158            let tensor = Tensor::new(vec![i.to_f64()], vec![1, 1])
159                .map_err(|e| roots_error(format!("roots: {e}")))?;
160            tensor_to_complex(tensor)
161        }
162        Value::Bool(b) => {
163            let tensor = Tensor::new(vec![if b { 1.0 } else { 0.0 }], vec![1, 1])
164                .map_err(|e| roots_error(format!("roots: {e}")))?;
165            tensor_to_complex(tensor)
166        }
167        other => Err(roots_error(format!(
168            "roots: expected a numeric vector of polynomial coefficients, got {other:?}"
169        ))),
170    }
171}
172
173fn tensor_to_complex(tensor: Tensor) -> BuiltinResult<Vec<Complex64>> {
174    ensure_vector_shape("roots", &tensor.shape)?;
175    Ok(tensor
176        .data
177        .into_iter()
178        .map(|value| Complex64::new(value, 0.0))
179        .collect())
180}
181
182fn complex_tensor_to_vec(tensor: ComplexTensor) -> BuiltinResult<Vec<Complex64>> {
183    ensure_vector_shape("roots", &tensor.shape)?;
184    Ok(tensor
185        .data
186        .into_iter()
187        .map(|(re, im)| Complex64::new(re, im))
188        .collect())
189}
190
191fn ensure_vector_shape(name: &str, shape: &[usize]) -> BuiltinResult<()> {
192    let is_vector = match shape.len() {
193        0 => true,
194        1 => true,
195        2 => shape[0] == 1 || shape[1] == 1 || shape.iter().product::<usize>() == 0,
196        _ => shape.iter().filter(|&&dim| dim > 1).count() <= 1,
197    };
198    if !is_vector {
199        return Err(roots_error(format!(
200            "{name}: coefficients must be a vector (row or column), got shape {:?}",
201            shape
202        )));
203    }
204    Ok(())
205}
206
207fn trim_leading_zeros(mut coeffs: Vec<Complex64>) -> Vec<Complex64> {
208    if coeffs.is_empty() {
209        return coeffs;
210    }
211    let scale = coeffs.iter().map(|c| c.norm()).fold(0.0_f64, f64::max);
212    let tol = if scale == 0.0 {
213        LEADING_ZERO_TOL
214    } else {
215        LEADING_ZERO_TOL * scale
216    };
217    let first_nonzero = coeffs
218        .iter()
219        .position(|c| c.norm() > tol)
220        .unwrap_or(coeffs.len());
221    coeffs.split_off(first_nonzero)
222}
223
224fn solve_roots(coeffs: &[Complex64]) -> BuiltinResult<Vec<Complex64>> {
225    if coeffs.len() <= 1 {
226        return Ok(Vec::new());
227    }
228    if coeffs.len() == 2 {
229        let a = coeffs[0];
230        let b = coeffs[1];
231        if a.norm() <= LEADING_ZERO_TOL {
232            return Err(roots_error(
233                "roots: leading coefficient must be non-zero after trimming",
234            ));
235        }
236        return Ok(vec![-b / a]);
237    }
238
239    let degree = coeffs.len() - 1;
240    if degree == 3 {
241        return Ok(cubic_roots(coeffs[0], coeffs[1], coeffs[2], coeffs[3]));
242    }
243    let leading = coeffs[0];
244    if leading.norm() <= LEADING_ZERO_TOL {
245        return Err(roots_error(
246            "roots: leading coefficient must be non-zero after trimming",
247        ));
248    }
249
250    let mut companion = DMatrix::<Complex64>::zeros(degree, degree);
251    for row in 1..degree {
252        companion[(row, row - 1)] = Complex64::new(1.0, 0.0);
253    }
254
255    for (idx, coeff) in coeffs.iter().enumerate().skip(1) {
256        let value = -(*coeff) / leading;
257        let column = idx - 1;
258        if column < degree {
259            companion[(0, column)] = value;
260        }
261    }
262
263    let eigenvalues = companion.clone().eigenvalues().ok_or_else(|| {
264        roots_error_with(
265            "roots: failed to compute eigenvalues of the companion matrix",
266            &ROOTS_ERROR_INTERNAL,
267        )
268    })?;
269    Ok(eigenvalues.iter().map(|&z| canonicalize_root(z)).collect())
270}
271
272fn cubic_roots(a: Complex64, b: Complex64, c: Complex64, d: Complex64) -> Vec<Complex64> {
273    // Depressed cubic via Cardano: x = y - b/(3a), y^3 + p y + q = 0
274    let three = 3.0;
275    let nine = 9.0;
276    let twenty_seven = 27.0;
277    let a2 = a * a;
278    let a3 = a2 * a;
279    let p = (three * a * c - b * b) / (three * a2);
280    let q = (twenty_seven * a2 * d - nine * a * b * c + Complex64::new(2.0, 0.0) * b * b * b)
281        / (twenty_seven * a3);
282    let half = Complex64::new(0.5, 0.0);
283    let disc = (q * q) * half * half + (p * p * p) / Complex64::new(27.0, 0.0);
284    let sqrt_disc = disc.sqrt();
285    let u = (-q * half + sqrt_disc).powf(1.0 / 3.0);
286    let v = (-q * half - sqrt_disc).powf(1.0 / 3.0);
287    let omega = Complex64::new(-0.5, (3.0f64).sqrt() * 0.5);
288    let omega2 = omega * omega;
289    let shift = b / (three * a);
290    let y0 = u + v;
291    let y1 = u * omega + v * omega.conj();
292    let y2 = u * omega2 + v * omega;
293    vec![y0 - shift, y1 - shift, y2 - shift]
294}
295
296fn canonicalize_root(z: Complex64) -> Complex64 {
297    if !z.re.is_finite() || !z.im.is_finite() {
298        return z;
299    }
300    let mut real = z.re;
301    let mut imag = z.im;
302    let scale = 1.0 + real.abs();
303    if imag.abs() <= RESULT_ZERO_TOL * scale {
304        imag = 0.0;
305    }
306    if real.abs() <= RESULT_ZERO_TOL {
307        real = 0.0;
308    }
309    Complex64::new(real, imag)
310}
311
312fn roots_to_value(roots: &[Complex64]) -> BuiltinResult<Value> {
313    if roots.is_empty() {
314        return empty_column();
315    }
316    let all_real = roots
317        .iter()
318        .all(|z| z.im.abs() <= RESULT_ZERO_TOL * (1.0 + z.re.abs()));
319    if all_real {
320        let mut data: Vec<f64> = Vec::with_capacity(roots.len());
321        for &root in roots {
322            data.push(root.re);
323        }
324        let tensor = Tensor::new(data, vec![roots.len(), 1])
325            .map_err(|e| roots_error_with(format!("roots: {e}"), &ROOTS_ERROR_INTERNAL))?;
326        Ok(Value::Tensor(tensor))
327    } else {
328        let data: Vec<(f64, f64)> = roots.iter().map(|z| (z.re, z.im)).collect();
329        let tensor = ComplexTensor::new(data, vec![roots.len(), 1])
330            .map_err(|e| roots_error_with(format!("roots: {e}"), &ROOTS_ERROR_INTERNAL))?;
331        Ok(Value::ComplexTensor(tensor))
332    }
333}
334
335fn empty_column() -> BuiltinResult<Value> {
336    let tensor = Tensor::new(Vec::new(), vec![0, 1])
337        .map_err(|e| roots_error_with(format!("roots: {e}"), &ROOTS_ERROR_INTERNAL))?;
338    Ok(Value::Tensor(tensor))
339}
340
341#[cfg(test)]
342pub(crate) mod tests {
343    use super::*;
344    use crate::builtins::common::test_support;
345    use futures::executor::block_on;
346    use runmat_accelerate_api::HostTensorView;
347    use runmat_builtins::{ComplexTensor, LogicalArray, Tensor};
348
349    fn assert_error_contains(err: crate::RuntimeError, needle: &str) {
350        assert!(
351            err.message().contains(needle),
352            "expected error containing '{needle}', got '{}'",
353            err.message()
354        );
355    }
356
357    #[test]
358    fn roots_descriptor_signatures_cover_core_forms() {
359        let labels: Vec<&str> = ROOTS_DESCRIPTOR
360            .signatures
361            .iter()
362            .map(|signature| signature.label)
363            .collect();
364        assert!(labels.contains(&"r = roots(c)"));
365    }
366
367    #[test]
368    fn roots_descriptor_errors_have_stable_codes() {
369        let codes: Vec<&str> = ROOTS_DESCRIPTOR
370            .errors
371            .iter()
372            .map(|error| error.code)
373            .collect();
374        assert!(codes.contains(&"RM.ROOTS.INVALID_INPUT"));
375        assert!(codes.contains(&"RM.ROOTS.INTERNAL"));
376    }
377
378    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
379    #[test]
380    fn roots_quadratic_real() {
381        let coeffs = Tensor::new(vec![1.0, -3.0, 2.0], vec![3, 1]).unwrap();
382        let result = roots_builtin(Value::Tensor(coeffs)).expect("roots");
383        match result {
384            Value::Tensor(t) => {
385                assert_eq!(t.shape, vec![2, 1]);
386                let mut roots = t.data;
387                roots.sort_by(|a, b| a.partial_cmp(b).unwrap());
388                assert!((roots[0] - 1.0).abs() < 1e-10);
389                assert!((roots[1] - 2.0).abs() < 1e-10);
390            }
391            other => panic!("expected real tensor, got {other:?}"),
392        }
393    }
394
395    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
396    #[test]
397    fn roots_leading_zeros_trimmed() {
398        let coeffs = Tensor::new(vec![0.0, 0.0, 1.0, -4.0], vec![4, 1]).unwrap();
399        let result = roots_builtin(Value::Tensor(coeffs)).expect("roots");
400        match result {
401            Value::Tensor(t) => {
402                assert_eq!(t.shape, vec![1, 1]);
403                assert!((t.data[0] - 4.0).abs() < 1e-10);
404            }
405            other => panic!("expected tensor, got {other:?}"),
406        }
407    }
408
409    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
410    #[test]
411    fn roots_complex_pair() {
412        let coeffs = Tensor::new(vec![1.0, 0.0, 1.0], vec![3, 1]).unwrap();
413        let result = roots_builtin(Value::Tensor(coeffs)).expect("roots");
414        match result {
415            Value::ComplexTensor(t) => {
416                assert_eq!(t.shape, vec![2, 1]);
417                let mut roots = t.data;
418                roots.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
419                assert!((roots[0].0).abs() < 1e-10);
420                assert!((roots[0].1 + 1.0).abs() < 1e-10);
421                assert!((roots[1].0).abs() < 1e-10);
422                assert!((roots[1].1 - 1.0).abs() < 1e-10);
423            }
424            other => panic!("expected complex tensor, got {other:?}"),
425        }
426    }
427
428    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
429    #[test]
430    fn roots_quartic_all_zero_roots() {
431        // p(x) = x^4 => 4 roots at 0
432        let coeffs = Tensor::new(vec![1.0, 0.0, 0.0, 0.0, 0.0], vec![5, 1]).unwrap();
433        let result = roots_builtin(Value::Tensor(coeffs)).expect("roots quartic");
434        match result {
435            Value::Tensor(t) => {
436                assert_eq!(t.shape, vec![4, 1]);
437                for &r in &t.data {
438                    assert!(r.abs() < 1e-8);
439                }
440            }
441            Value::ComplexTensor(t) => {
442                assert_eq!(t.shape, vec![4, 1]);
443                for &(re, im) in &t.data {
444                    assert!(re.abs() < 1e-7 && im.abs() < 1e-7);
445                }
446            }
447            other => panic!("unexpected output {other:?}"),
448        }
449    }
450
451    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
452    #[test]
453    fn roots_accepts_complex_coefficients_input() {
454        // p(x) = x^2 + 1 with complex coefficients path
455        let coeffs =
456            ComplexTensor::new(vec![(1.0, 0.0), (0.0, 0.0), (1.0, 0.0)], vec![3, 1]).unwrap();
457        let result = roots_builtin(Value::ComplexTensor(coeffs)).expect("roots complex input");
458        match result {
459            Value::ComplexTensor(t) => {
460                assert_eq!(t.shape, vec![2, 1]);
461                // roots at i and -i
462                let mut roots = t.data;
463                roots.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
464                assert!(roots[0].0.abs() < 1e-10 && (roots[0].1 + 1.0).abs() < 1e-6);
465                assert!(roots[1].0.abs() < 1e-10 && (roots[1].1 - 1.0).abs() < 1e-6);
466            }
467            other => panic!("expected complex tensor, got {other:?}"),
468        }
469    }
470
471    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
472    #[test]
473    fn roots_accepts_logical_coefficients() {
474        // p(x) = x with logical coefficients [1 0]
475        let la = LogicalArray::new(vec![1, 0], vec![1, 2]).unwrap();
476        let result = roots_builtin(Value::LogicalArray(la)).expect("roots logical");
477        match result {
478            Value::Tensor(t) => {
479                assert_eq!(t.shape, vec![1, 1]);
480                assert!(t.data[0].abs() < 1e-12);
481            }
482            other => panic!("expected real tensor, got {other:?}"),
483        }
484    }
485
486    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
487    #[test]
488    fn roots_scalar_num_returns_empty() {
489        let result = roots_builtin(Value::Num(5.0)).expect("roots scalar num");
490        match result {
491            Value::Tensor(t) => {
492                assert_eq!(t.shape, vec![0, 1]);
493                assert!(t.data.is_empty());
494            }
495            other => panic!("expected empty tensor, got {other:?}"),
496        }
497    }
498
499    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
500    #[test]
501    fn roots_rejects_non_vector_input() {
502        let coeffs = Tensor::new(vec![1.0, 0.0, 0.0, 1.0], vec![2, 2]).unwrap();
503        let err = roots_builtin(Value::Tensor(coeffs)).expect_err("expected vector-shape error");
504        assert_eq!(err.identifier(), ROOTS_ERROR_INVALID_INPUT.identifier);
505        assert_error_contains(err, "vector");
506    }
507
508    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
509    #[test]
510    fn roots_all_zero_coefficients_returns_empty() {
511        let coeffs = Tensor::new(vec![0.0, 0.0, 0.0], vec![3, 1]).unwrap();
512        let result = roots_builtin(Value::Tensor(coeffs)).expect("roots");
513        match result {
514            Value::Tensor(t) => {
515                assert_eq!(t.shape, vec![0, 1]);
516                assert!(t.data.is_empty());
517            }
518            other => panic!("expected empty tensor, got {other:?}"),
519        }
520    }
521
522    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
523    #[test]
524    fn roots_gpu_input_gathers_to_host() {
525        test_support::with_test_provider(|provider| {
526            let coeffs = Tensor::new(vec![1.0, 0.0, -9.0, 0.0], vec![4, 1]).unwrap();
527            let view = HostTensorView {
528                data: &coeffs.data,
529                shape: &coeffs.shape,
530            };
531            let handle = provider.upload(&view).expect("upload");
532            let result = roots_builtin(Value::GpuTensor(handle)).expect("roots");
533            let gathered = test_support::gather(result).expect("gather");
534            assert_eq!(gathered.shape, vec![3, 1]);
535            let mut roots = gathered.data;
536            roots.sort_by(|a, b| a.partial_cmp(b).unwrap());
537            assert!((roots[0] + 3.0).abs() < 1e-9);
538            assert!((roots[1]).abs() < 1e-9);
539            assert!((roots[2] - 3.0).abs() < 1e-9);
540        });
541    }
542
543    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
544    #[test]
545    fn roots_constant_polynomial_returns_empty() {
546        let coeffs = Tensor::new(vec![5.0], vec![1, 1]).unwrap();
547        let result = roots_builtin(Value::Tensor(coeffs)).expect("roots");
548        match result {
549            Value::Tensor(t) => {
550                assert_eq!(t.shape, vec![0, 1]);
551            }
552            other => panic!("expected empty tensor, got {other:?}"),
553        }
554    }
555
556    fn roots_builtin(coefficients: Value) -> BuiltinResult<Value> {
557        block_on(super::roots_builtin(coefficients))
558    }
559}