1use 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 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 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 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 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 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}