Skip to main content

runmat_runtime/builtins/math/linalg/solve/
null.rs

1//! MATLAB-compatible `null` builtin for matrix null-space bases.
2
3use std::convert::TryFrom;
4use std::mem::size_of;
5
6use nalgebra::{linalg::SVD, DMatrix};
7use num_complex::Complex64;
8use runmat_accelerate_api::{GpuTensorHandle, HostTensorView};
9use runmat_builtins::{
10    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
11    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
12    ComplexTensor, Tensor, Value,
13};
14use runmat_macros::runtime_builtin;
15
16use crate::builtins::common::linalg::{
17    matrix_dimensions_for, parse_tolerance_arg, svd_default_tolerance,
18};
19use crate::builtins::common::random_args::complex_tensor_into_value;
20use crate::builtins::common::spec::{
21    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
22    ReductionNaN, ResidencyPolicy, ScalarType, ShapeRequirements,
23};
24use crate::builtins::common::{gpu_helpers, tensor};
25use crate::builtins::math::linalg::solve::rref::{
26    default_complex_tolerance, default_real_tolerance, rref_complex_impl, rref_real_impl,
27};
28use crate::builtins::math::linalg::type_resolvers::null_type;
29use crate::{build_runtime_error, BuiltinResult, RuntimeError};
30
31const NAME: &str = "null";
32
33const NULL_OUTPUT: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
34    name: "Z",
35    ty: BuiltinParamType::NumericArray,
36    arity: BuiltinParamArity::Required,
37    default: None,
38    description: "Basis for the null space of A.",
39}];
40
41const NULL_INPUTS_A: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
42    name: "A",
43    ty: BuiltinParamType::Any,
44    arity: BuiltinParamArity::Required,
45    default: None,
46    description: "Input matrix.",
47}];
48
49const NULL_INPUTS_A_TOL: [BuiltinParamDescriptor; 2] = [
50    BuiltinParamDescriptor {
51        name: "A",
52        ty: BuiltinParamType::Any,
53        arity: BuiltinParamArity::Required,
54        default: None,
55        description: "Input matrix.",
56    },
57    BuiltinParamDescriptor {
58        name: "tol",
59        ty: BuiltinParamType::NumericScalar,
60        arity: BuiltinParamArity::Optional,
61        default: None,
62        description: "Rank tolerance.",
63    },
64];
65
66const NULL_INPUTS_A_OPTION: [BuiltinParamDescriptor; 2] = [
67    BuiltinParamDescriptor {
68        name: "A",
69        ty: BuiltinParamType::Any,
70        arity: BuiltinParamArity::Required,
71        default: None,
72        description: "Input matrix.",
73    },
74    BuiltinParamDescriptor {
75        name: "option",
76        ty: BuiltinParamType::StringScalar,
77        arity: BuiltinParamArity::Optional,
78        default: None,
79        description: "`\"r\"` requests a row-reduction basis.",
80    },
81];
82
83const NULL_SIGNATURES: [BuiltinSignatureDescriptor; 3] = [
84    BuiltinSignatureDescriptor {
85        label: "Z = null(A)",
86        inputs: &NULL_INPUTS_A,
87        outputs: &NULL_OUTPUT,
88    },
89    BuiltinSignatureDescriptor {
90        label: "Z = null(A, tol)",
91        inputs: &NULL_INPUTS_A_TOL,
92        outputs: &NULL_OUTPUT,
93    },
94    BuiltinSignatureDescriptor {
95        label: "Z = null(A, \"r\")",
96        inputs: &NULL_INPUTS_A_OPTION,
97        outputs: &NULL_OUTPUT,
98    },
99];
100
101const NULL_ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
102    code: "RM.NULL.INVALID_ARGUMENT",
103    identifier: Some("RunMat:null:InvalidArgument"),
104    when: "Optional tolerance or basis option is malformed or outside accepted bounds.",
105    message: "null: invalid argument",
106};
107
108const NULL_ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
109    code: "RM.NULL.INVALID_INPUT",
110    identifier: Some("RunMat:null:InvalidInput"),
111    when: "Input shape/type cannot be processed for null-space evaluation.",
112    message: "null: invalid input",
113};
114
115const NULL_ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
116    code: "RM.NULL.INTERNAL",
117    identifier: Some("RunMat:null:Internal"),
118    when: "Runtime fails while computing the null space or executing fallback/upload paths.",
119    message: "null: internal runtime failure",
120};
121
122const NULL_ERRORS: [BuiltinErrorDescriptor; 3] = [
123    NULL_ERROR_INVALID_ARGUMENT,
124    NULL_ERROR_INVALID_INPUT,
125    NULL_ERROR_INTERNAL,
126];
127
128pub const NULL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
129    signatures: &NULL_SIGNATURES,
130    output_mode: BuiltinOutputMode::Fixed,
131    completion_policy: BuiltinCompletionPolicy::Public,
132    errors: &NULL_ERRORS,
133};
134
135#[runmat_macros::register_gpu_spec(builtin_path = "crate::builtins::math::linalg::solve::null")]
136pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
137    name: NAME,
138    op_kind: GpuOpKind::Custom("null-space"),
139    supported_precisions: &[ScalarType::F32, ScalarType::F64],
140    broadcast: BroadcastSemantics::None,
141    provider_hooks: &[],
142    constant_strategy: ConstantStrategy::InlineLiteral,
143    residency: ResidencyPolicy::NewHandle,
144    nan_mode: ReductionNaN::Include,
145    two_pass_threshold: None,
146    workgroup_size: None,
147    accepts_nan_mode: false,
148    notes: "`null` gathers GPU inputs to the host null-space implementation and re-uploads real bases when a provider is available.",
149};
150
151fn null_error_with_message(
152    message: impl Into<String>,
153    error: &'static BuiltinErrorDescriptor,
154) -> RuntimeError {
155    let mut builder = build_runtime_error(message).with_builtin(NAME);
156    if let Some(identifier) = error.identifier {
157        builder = builder.with_identifier(identifier);
158    }
159    builder.build()
160}
161
162fn input_error(message: impl Into<String>) -> RuntimeError {
163    null_error_with_message(message, &NULL_ERROR_INVALID_INPUT)
164}
165
166fn argument_error(message: impl Into<String>) -> RuntimeError {
167    null_error_with_message(message, &NULL_ERROR_INVALID_ARGUMENT)
168}
169
170fn internal_error(message: impl Into<String>) -> RuntimeError {
171    null_error_with_message(message, &NULL_ERROR_INTERNAL)
172}
173
174fn map_control_flow(err: RuntimeError) -> RuntimeError {
175    if err.message() == "interaction pending..." {
176        return build_runtime_error("interaction pending...")
177            .with_builtin(NAME)
178            .build();
179    }
180    let mut builder = build_runtime_error(err.message()).with_builtin(NAME);
181    if let Some(identifier) = err.identifier() {
182        builder = builder.with_identifier(identifier.to_string());
183    }
184    if let Some(task_id) = err.context.task_id.clone() {
185        builder = builder.with_task_id(task_id);
186    }
187    if !err.context.call_stack.is_empty() {
188        builder = builder.with_call_stack(err.context.call_stack.clone());
189    }
190    if let Some(phase) = err.context.phase.clone() {
191        builder = builder.with_phase(phase);
192    }
193    builder.with_source(err).build()
194}
195
196#[runmat_macros::register_fusion_spec(builtin_path = "crate::builtins::math::linalg::solve::null")]
197pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
198    name: NAME,
199    shape: ShapeRequirements::Any,
200    constant_strategy: ConstantStrategy::InlineLiteral,
201    elementwise: None,
202    reduction: None,
203    emits_nan: true,
204    notes: "`null` is an eager null-space solve and terminates fusion plans.",
205};
206
207#[derive(Clone, Copy, Debug)]
208enum NullMode {
209    Orthonormal { tolerance: Option<f64> },
210    RowReduction,
211}
212
213#[runtime_builtin(
214    name = "null",
215    category = "math/linalg/solve",
216    summary = "Compute a basis for a matrix null space.",
217    keywords = "null,null space,kernel,svd,rref,rational,gpu",
218    accel = "sink",
219    type_resolver(null_type),
220    descriptor(crate::builtins::math::linalg::solve::null::NULL_DESCRIPTOR),
221    builtin_path = "crate::builtins::math::linalg::solve::null"
222)]
223async fn null_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
224    let mode = parse_null_mode(&rest).map_err(argument_error)?;
225    match value {
226        Value::GpuTensor(handle) => null_gpu(handle, mode).await,
227        Value::ComplexTensor(tensor) => null_complex_value(tensor, mode),
228        Value::Complex(re, im) => {
229            let tensor = ComplexTensor::new(vec![(re, im)], vec![1, 1]).map_err(input_error)?;
230            null_complex_value(tensor, mode)
231        }
232        other => {
233            let tensor = tensor::value_into_tensor_for(NAME, other).map_err(input_error)?;
234            null_real_value(tensor, mode)
235        }
236    }
237}
238
239fn parse_null_mode(args: &[Value]) -> Result<NullMode, String> {
240    match args.len() {
241        0 => Ok(NullMode::Orthonormal { tolerance: None }),
242        1 => {
243            if let Some(option) = string_option_from_value(&args[0]) {
244                let option = option?;
245                let normalized = option.trim().to_ascii_lowercase();
246                return match normalized.as_str() {
247                    "r" | "rational" => Ok(NullMode::RowReduction),
248                    _ => Err(format!("{NAME}: unsupported basis option '{option}'")),
249                };
250            }
251            let tolerance = parse_tolerance_arg(NAME, args)?;
252            Ok(NullMode::Orthonormal { tolerance })
253        }
254        _ => Err(format!("{NAME}: too many input arguments")),
255    }
256}
257
258fn string_option_from_value(value: &Value) -> Option<Result<String, String>> {
259    match value {
260        Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
261            Some(String::try_from(value))
262        }
263        _ => None,
264    }
265}
266
267async fn null_gpu(handle: GpuTensorHandle, mode: NullMode) -> BuiltinResult<Value> {
268    let gathered = gpu_helpers::gather_tensor_async(&handle)
269        .await
270        .map_err(map_control_flow)?;
271    let basis = null_real_tensor(&gathered, mode)?;
272
273    if let Some(provider) = runmat_accelerate_api::provider() {
274        if let Ok(uploaded) = provider.upload(&HostTensorView {
275            data: &basis.data,
276            shape: &basis.shape,
277        }) {
278            return Ok(Value::GpuTensor(uploaded));
279        }
280    }
281
282    Ok(tensor::tensor_into_value(basis))
283}
284
285fn null_real_value(matrix: Tensor, mode: NullMode) -> BuiltinResult<Value> {
286    let basis = null_real_tensor(&matrix, mode)?;
287    Ok(tensor::tensor_into_value(basis))
288}
289
290fn null_complex_value(matrix: ComplexTensor, mode: NullMode) -> BuiltinResult<Value> {
291    let basis = null_complex_tensor(&matrix, mode)?;
292    Ok(complex_tensor_into_value(basis))
293}
294
295fn null_real_tensor(matrix: &Tensor, mode: NullMode) -> BuiltinResult<Tensor> {
296    match mode {
297        NullMode::Orthonormal { tolerance } => null_real_orthonormal_tensor(matrix, tolerance),
298        NullMode::RowReduction => null_real_row_reduction_tensor(matrix),
299    }
300}
301
302fn null_complex_tensor(matrix: &ComplexTensor, mode: NullMode) -> BuiltinResult<ComplexTensor> {
303    match mode {
304        NullMode::Orthonormal { tolerance } => null_complex_orthonormal_tensor(matrix, tolerance),
305        NullMode::RowReduction => null_complex_row_reduction_tensor(matrix),
306    }
307}
308
309fn null_real_orthonormal_tensor(matrix: &Tensor, tol: Option<f64>) -> BuiltinResult<Tensor> {
310    let (rows, cols) = matrix_dimensions_for(NAME, matrix.shape.as_slice()).map_err(input_error)?;
311    if cols == 0 {
312        return Tensor::new(Vec::new(), vec![0, 0])
313            .map_err(|e| internal_error(format!("{NAME}: {e}")));
314    }
315    if rows == 0 {
316        return Tensor::new(identity_basis_real(cols)?, vec![cols, cols])
317            .map_err(|e| internal_error(format!("{NAME}: {e}")));
318    }
319
320    let (basis, basis_cols) = real_orthonormal_basis_from_svd(&matrix.data, rows, cols, tol)?;
321    Tensor::new(basis, vec![cols, basis_cols]).map_err(|e| internal_error(format!("{NAME}: {e}")))
322}
323
324fn null_complex_orthonormal_tensor(
325    matrix: &ComplexTensor,
326    tol: Option<f64>,
327) -> BuiltinResult<ComplexTensor> {
328    let (rows, cols) = matrix_dimensions_for(NAME, matrix.shape.as_slice()).map_err(input_error)?;
329    if cols == 0 {
330        return ComplexTensor::new(Vec::new(), vec![0, 0])
331            .map_err(|e| internal_error(format!("{NAME}: {e}")));
332    }
333    if rows == 0 {
334        let data = identity_basis_complex(cols)?
335            .into_iter()
336            .map(|value| (value.re, value.im))
337            .collect();
338        return ComplexTensor::new(data, vec![cols, cols])
339            .map_err(|e| internal_error(format!("{NAME}: {e}")));
340    }
341
342    let data: Vec<Complex64> = matrix
343        .data
344        .iter()
345        .map(|&(re, im)| Complex64::new(re, im))
346        .collect();
347    let (basis, basis_cols) = complex_orthonormal_basis_from_svd(&data, rows, cols, tol)?;
348    let data: Vec<(f64, f64)> = basis
349        .into_iter()
350        .map(|value| (value.re, value.im))
351        .collect();
352    ComplexTensor::new(data, vec![cols, basis_cols])
353        .map_err(|e| internal_error(format!("{NAME}: {e}")))
354}
355
356fn null_real_row_reduction_tensor(matrix: &Tensor) -> BuiltinResult<Tensor> {
357    let (rows, cols) = matrix_dimensions_for(NAME, matrix.shape.as_slice()).map_err(input_error)?;
358    let tolerance = default_real_tolerance(&matrix.data, rows, cols);
359    let (basis, basis_cols) = real_row_reduction_basis(matrix.data.clone(), rows, cols, tolerance)?;
360    Tensor::new(basis, vec![cols, basis_cols]).map_err(|e| internal_error(format!("{NAME}: {e}")))
361}
362
363fn null_complex_row_reduction_tensor(matrix: &ComplexTensor) -> BuiltinResult<ComplexTensor> {
364    let (rows, cols) = matrix_dimensions_for(NAME, matrix.shape.as_slice()).map_err(input_error)?;
365    let tolerance = default_complex_tolerance(&matrix.data, rows, cols);
366    let data: Vec<Complex64> = matrix
367        .data
368        .iter()
369        .map(|&(re, im)| Complex64::new(re, im))
370        .collect();
371    let (basis, basis_cols) = complex_row_reduction_basis(data, rows, cols, tolerance)?;
372    let data: Vec<(f64, f64)> = basis
373        .into_iter()
374        .map(|value| (value.re, value.im))
375        .collect();
376    ComplexTensor::new(data, vec![cols, basis_cols])
377        .map_err(|e| internal_error(format!("{NAME}: {e}")))
378}
379
380fn real_orthonormal_basis_from_svd(
381    data: &[f64],
382    rows: usize,
383    cols: usize,
384    tol: Option<f64>,
385) -> BuiltinResult<(Vec<f64>, usize)> {
386    let matrix = DMatrix::from_column_slice(rows, cols, data);
387    let svd = SVD::new(matrix, false, true);
388    let tolerance =
389        tol.unwrap_or_else(|| svd_default_tolerance(svd.singular_values.as_slice(), rows, cols));
390    let v_t = svd.v_t.ok_or_else(|| {
391        internal_error(format!("{NAME}: failed to compute right singular vectors"))
392    })?;
393    let v = v_t.adjoint();
394    let mut row_space = Vec::new();
395    let mut basis = Vec::new();
396    for idx in 0..svd.singular_values.len() {
397        let vector: Vec<f64> = (0..cols).map(|row| v[(row, idx)]).collect();
398        if is_rank_singular_value(svd.singular_values[idx], tolerance) {
399            row_space.push(vector);
400        } else {
401            push_orthonormal_real(&mut basis, &row_space, vector, cols);
402        }
403    }
404    let target_nullity = cols.saturating_sub(row_space.len());
405    complete_real_null_basis(&mut basis, &row_space, cols, target_nullity);
406    let basis_cols = basis.len();
407    Ok((flatten_real_columns(basis, cols)?, basis_cols))
408}
409
410fn complex_orthonormal_basis_from_svd(
411    data: &[Complex64],
412    rows: usize,
413    cols: usize,
414    tol: Option<f64>,
415) -> BuiltinResult<(Vec<Complex64>, usize)> {
416    let matrix = DMatrix::from_column_slice(rows, cols, data);
417    let svd = SVD::new(matrix, false, true);
418    let tolerance =
419        tol.unwrap_or_else(|| svd_default_tolerance(svd.singular_values.as_slice(), rows, cols));
420    let v_t = svd.v_t.ok_or_else(|| {
421        internal_error(format!("{NAME}: failed to compute right singular vectors"))
422    })?;
423    let v = v_t.adjoint();
424    let mut row_space = Vec::new();
425    let mut basis = Vec::new();
426    for idx in 0..svd.singular_values.len() {
427        let vector: Vec<Complex64> = (0..cols).map(|row| v[(row, idx)]).collect();
428        if is_rank_singular_value(svd.singular_values[idx], tolerance) {
429            row_space.push(vector);
430        } else {
431            push_orthonormal_complex(&mut basis, &row_space, vector, cols);
432        }
433    }
434    let target_nullity = cols.saturating_sub(row_space.len());
435    complete_complex_null_basis(&mut basis, &row_space, cols, target_nullity);
436    let basis_cols = basis.len();
437    Ok((flatten_complex_columns(basis, cols)?, basis_cols))
438}
439
440fn complete_real_null_basis(
441    basis: &mut Vec<Vec<f64>>,
442    row_space: &[Vec<f64>],
443    dim: usize,
444    target_cols: usize,
445) {
446    for idx in 0..dim {
447        if basis.len() >= target_cols {
448            break;
449        }
450        let mut vector = vec![0.0; dim];
451        vector[idx] = 1.0;
452        push_orthonormal_real(basis, row_space, vector, dim);
453    }
454}
455
456fn complete_complex_null_basis(
457    basis: &mut Vec<Vec<Complex64>>,
458    row_space: &[Vec<Complex64>],
459    dim: usize,
460    target_cols: usize,
461) {
462    for idx in 0..dim {
463        if basis.len() >= target_cols {
464            break;
465        }
466        let mut vector = vec![Complex64::new(0.0, 0.0); dim];
467        vector[idx] = Complex64::new(1.0, 0.0);
468        push_orthonormal_complex(basis, row_space, vector, dim);
469    }
470}
471
472fn push_orthonormal_real(
473    basis: &mut Vec<Vec<f64>>,
474    row_space: &[Vec<f64>],
475    vector: Vec<f64>,
476    dim: usize,
477) {
478    let mut vector = vector;
479    orthogonalize_real(&mut vector, row_space);
480    orthogonalize_real(&mut vector, basis);
481    orthogonalize_real(&mut vector, row_space);
482    orthogonalize_real(&mut vector, basis);
483    let norm = real_vector_norm(&vector);
484    if norm > completion_threshold(dim) {
485        let inv_norm = 1.0 / norm;
486        for value in &mut vector {
487            *value *= inv_norm;
488        }
489        basis.push(vector);
490    }
491}
492
493fn push_orthonormal_complex(
494    basis: &mut Vec<Vec<Complex64>>,
495    row_space: &[Vec<Complex64>],
496    vector: Vec<Complex64>,
497    dim: usize,
498) {
499    let mut vector = vector;
500    orthogonalize_complex(&mut vector, row_space);
501    orthogonalize_complex(&mut vector, basis);
502    orthogonalize_complex(&mut vector, row_space);
503    orthogonalize_complex(&mut vector, basis);
504    let norm = complex_vector_norm(&vector);
505    if norm > completion_threshold(dim) {
506        let inv_norm = 1.0 / norm;
507        for value in &mut vector {
508            *value *= inv_norm;
509        }
510        basis.push(vector);
511    }
512}
513
514fn orthogonalize_real(vector: &mut [f64], columns: &[Vec<f64>]) {
515    for column in columns {
516        let dot = vector
517            .iter()
518            .zip(column.iter())
519            .map(|(lhs, rhs)| lhs * rhs)
520            .sum::<f64>();
521        for (value, basis_value) in vector.iter_mut().zip(column.iter()) {
522            *value -= dot * basis_value;
523        }
524    }
525}
526
527fn orthogonalize_complex(vector: &mut [Complex64], columns: &[Vec<Complex64>]) {
528    for column in columns {
529        let dot = column
530            .iter()
531            .zip(vector.iter())
532            .map(|(basis_value, value)| basis_value.conj() * value)
533            .sum::<Complex64>();
534        for (value, basis_value) in vector.iter_mut().zip(column.iter()) {
535            *value -= basis_value * dot;
536        }
537    }
538}
539
540fn real_vector_norm(vector: &[f64]) -> f64 {
541    vector.iter().map(|value| value * value).sum::<f64>().sqrt()
542}
543
544fn complex_vector_norm(vector: &[Complex64]) -> f64 {
545    vector
546        .iter()
547        .map(|value| value.norm_sqr())
548        .sum::<f64>()
549        .sqrt()
550}
551
552fn completion_threshold(dim: usize) -> f64 {
553    (dim.max(1) as f64) * f64::EPSILON.sqrt()
554}
555
556fn is_rank_singular_value(singular_value: f64, tolerance: f64) -> bool {
557    singular_value.is_infinite() || singular_value > tolerance
558}
559
560fn checked_output_len(rows: usize, cols: usize, context: &str) -> BuiltinResult<usize> {
561    rows.checked_mul(cols)
562        .ok_or_else(|| input_error(format!("{NAME}: {context} dimension product overflow")))
563}
564
565fn zeroed_vec<T: Clone>(len: usize, value: T, context: &str) -> BuiltinResult<Vec<T>> {
566    let max_len = isize::MAX as usize / size_of::<T>();
567    if len > max_len {
568        return Err(input_error(format!(
569            "{NAME}: {context} allocation is too large"
570        )));
571    }
572    let mut data = Vec::new();
573    data.try_reserve_exact(len)
574        .map_err(|err| internal_error(format!("{NAME}: {context} allocation failed: {err}")))?;
575    data.resize(len, value);
576    Ok(data)
577}
578
579fn flatten_real_columns(columns: Vec<Vec<f64>>, rows: usize) -> BuiltinResult<Vec<f64>> {
580    let len = checked_output_len(rows, columns.len(), "orthonormal basis")?;
581    let mut data = Vec::new();
582    data.try_reserve_exact(len).map_err(|err| {
583        internal_error(format!(
584            "{NAME}: orthonormal basis allocation failed: {err}"
585        ))
586    })?;
587    for column in columns {
588        data.extend(column);
589    }
590    Ok(data)
591}
592
593fn flatten_complex_columns(
594    columns: Vec<Vec<Complex64>>,
595    rows: usize,
596) -> BuiltinResult<Vec<Complex64>> {
597    let len = checked_output_len(rows, columns.len(), "orthonormal basis")?;
598    let mut data = Vec::new();
599    data.try_reserve_exact(len).map_err(|err| {
600        internal_error(format!(
601            "{NAME}: orthonormal basis allocation failed: {err}"
602        ))
603    })?;
604    for column in columns {
605        data.extend(column);
606    }
607    Ok(data)
608}
609
610fn identity_basis_real(cols: usize) -> BuiltinResult<Vec<f64>> {
611    let len = checked_output_len(cols, cols, "identity basis")?;
612    let mut data = zeroed_vec(len, 0.0, "identity basis")?;
613    for idx in 0..cols {
614        data[idx + idx * cols] = 1.0;
615    }
616    Ok(data)
617}
618
619fn identity_basis_complex(cols: usize) -> BuiltinResult<Vec<Complex64>> {
620    let len = checked_output_len(cols, cols, "identity basis")?;
621    let mut data = zeroed_vec(len, Complex64::new(0.0, 0.0), "identity basis")?;
622    for idx in 0..cols {
623        data[idx + idx * cols] = Complex64::new(1.0, 0.0);
624    }
625    Ok(data)
626}
627
628fn real_row_reduction_basis(
629    data: Vec<f64>,
630    rows: usize,
631    cols: usize,
632    tolerance: f64,
633) -> BuiltinResult<(Vec<f64>, usize)> {
634    let (reduced, pivots) = rref_real_impl(data, rows, cols, tolerance)
635        .map_err(|err| internal_error(format!("{NAME}: row reduction failed ({err})")))?;
636    basis_from_real_rref(&reduced, rows, cols, &pivots)
637}
638
639fn complex_row_reduction_basis(
640    data: Vec<Complex64>,
641    rows: usize,
642    cols: usize,
643    tolerance: f64,
644) -> BuiltinResult<(Vec<Complex64>, usize)> {
645    let (reduced, pivots) = rref_complex_impl(data, rows, cols, tolerance)
646        .map_err(|err| internal_error(format!("{NAME}: row reduction failed ({err})")))?;
647    basis_from_complex_rref(&reduced, rows, cols, &pivots)
648}
649
650fn basis_from_real_rref(
651    reduced: &[f64],
652    rows: usize,
653    cols: usize,
654    pivots: &[usize],
655) -> BuiltinResult<(Vec<f64>, usize)> {
656    let pivot_cols = normalized_pivot_columns(cols, pivots);
657    let free_len = cols.saturating_sub(pivot_cols.len());
658    let basis_len = checked_output_len(cols, free_len, "row-reduction basis")?;
659    let free_cols = free_columns(cols, &pivot_cols)?;
660    let mut basis = zeroed_vec(basis_len, 0.0, "row-reduction basis")?;
661    for (basis_col, &free_col) in free_cols.iter().enumerate() {
662        basis[free_col + basis_col * cols] = 1.0;
663        for (pivot_row, &pivot_one_based) in pivots.iter().enumerate() {
664            let pivot_col = pivot_one_based - 1;
665            basis[pivot_col + basis_col * cols] = -reduced[pivot_row + free_col * rows];
666        }
667    }
668    Ok((basis, free_cols.len()))
669}
670
671fn basis_from_complex_rref(
672    reduced: &[Complex64],
673    rows: usize,
674    cols: usize,
675    pivots: &[usize],
676) -> BuiltinResult<(Vec<Complex64>, usize)> {
677    let pivot_cols = normalized_pivot_columns(cols, pivots);
678    let free_len = cols.saturating_sub(pivot_cols.len());
679    let basis_len = checked_output_len(cols, free_len, "row-reduction basis")?;
680    let free_cols = free_columns(cols, &pivot_cols)?;
681    let mut basis = zeroed_vec(basis_len, Complex64::new(0.0, 0.0), "row-reduction basis")?;
682    for (basis_col, &free_col) in free_cols.iter().enumerate() {
683        basis[free_col + basis_col * cols] = Complex64::new(1.0, 0.0);
684        for (pivot_row, &pivot_one_based) in pivots.iter().enumerate() {
685            let pivot_col = pivot_one_based - 1;
686            basis[pivot_col + basis_col * cols] = -reduced[pivot_row + free_col * rows];
687        }
688    }
689    Ok((basis, free_cols.len()))
690}
691
692fn normalized_pivot_columns(cols: usize, pivots: &[usize]) -> Vec<usize> {
693    let mut pivot_cols = Vec::new();
694    for &pivot in pivots {
695        if (1..=cols).contains(&pivot) {
696            pivot_cols.push(pivot - 1);
697        }
698    }
699    pivot_cols.sort_unstable();
700    pivot_cols.dedup();
701    pivot_cols
702}
703
704fn free_columns(cols: usize, pivot_cols: &[usize]) -> BuiltinResult<Vec<usize>> {
705    let free_len = cols.saturating_sub(pivot_cols.len());
706    let mut free_cols = Vec::new();
707    free_cols.try_reserve_exact(free_len).map_err(|err| {
708        internal_error(format!("{NAME}: free-column list allocation failed: {err}"))
709    })?;
710    let mut pivot_idx = 0;
711    for col in 0..cols {
712        if pivot_idx < pivot_cols.len() && pivot_cols[pivot_idx] == col {
713            pivot_idx += 1;
714        } else {
715            free_cols.push(col);
716        }
717    }
718    Ok(free_cols)
719}
720
721#[cfg(test)]
722pub(crate) mod tests {
723    use super::*;
724    use crate::builtins::common::test_support;
725    use futures::executor::block_on;
726    use runmat_builtins::{CharArray, IntValue, ResolveContext, StringArray, Type};
727
728    fn unwrap_error(err: crate::RuntimeError) -> crate::RuntimeError {
729        err
730    }
731
732    fn assert_close(actual: &[f64], expected: &[f64], tol: f64) {
733        assert_eq!(actual.len(), expected.len(), "length mismatch");
734        for (lhs, rhs) in actual.iter().zip(expected.iter()) {
735            assert!(
736                (lhs - rhs).abs() <= tol,
737                "expected {lhs} ~= {rhs} within {tol}"
738            );
739        }
740    }
741
742    fn assert_complex_close(actual: &[(f64, f64)], expected: &[(f64, f64)], tol: f64) {
743        assert_eq!(actual.len(), expected.len(), "length mismatch");
744        for ((ar, ai), (er, ei)) in actual.iter().zip(expected.iter()) {
745            assert!(
746                (ar - er).abs() <= tol && (ai - ei).abs() <= tol,
747                "expected ({ar}, {ai}) ~= ({er}, {ei}) within {tol}"
748            );
749        }
750    }
751
752    fn assert_az_zero(a: &Tensor, z: &Tensor, tol: f64) {
753        assert_eq!(a.shape.len(), 2);
754        assert_eq!(z.shape.len(), 2);
755        assert_eq!(a.shape[1], z.shape[0], "inner dimension mismatch");
756        let rows = a.shape[0];
757        let inner = a.shape[1];
758        let cols = z.shape[1];
759        for col in 0..cols {
760            for row in 0..rows {
761                let mut sum = 0.0;
762                for k in 0..inner {
763                    sum += a.data[row + k * rows] * z.data[k + col * z.shape[0]];
764                }
765                assert!(
766                    sum.abs() <= tol,
767                    "expected A*Z ~= 0 at ({row},{col}), got {sum}"
768                );
769            }
770        }
771    }
772
773    fn assert_complex_az_zero(a: &ComplexTensor, z: &ComplexTensor, tol: f64) {
774        assert_eq!(a.shape.len(), 2);
775        assert_eq!(z.shape.len(), 2);
776        assert_eq!(a.shape[1], z.shape[0], "inner dimension mismatch");
777        let rows = a.shape[0];
778        let inner = a.shape[1];
779        let cols = z.shape[1];
780        for col in 0..cols {
781            for row in 0..rows {
782                let mut sum = Complex64::new(0.0, 0.0);
783                for k in 0..inner {
784                    let lhs = a.data[row + k * rows];
785                    let rhs = z.data[k + col * z.shape[0]];
786                    sum += Complex64::new(lhs.0, lhs.1) * Complex64::new(rhs.0, rhs.1);
787                }
788                assert!(
789                    sum.norm() <= tol,
790                    "expected A*Z ~= 0 at ({row},{col}), got {sum}"
791                );
792            }
793        }
794    }
795
796    fn assert_columns_orthonormal(z: &Tensor, tol: f64) {
797        let rows = z.shape[0];
798        let cols = z.shape[1];
799        for col in 0..cols {
800            let norm = (0..rows)
801                .map(|row| z.data[row + col * rows] * z.data[row + col * rows])
802                .sum::<f64>()
803                .sqrt();
804            assert!((norm - 1.0).abs() <= tol, "column norm {norm}");
805            for other in 0..col {
806                let dot = (0..rows)
807                    .map(|row| z.data[row + col * rows] * z.data[row + other * rows])
808                    .sum::<f64>();
809                assert!(dot.abs() <= tol, "column dot {dot}");
810            }
811        }
812    }
813
814    fn assert_complex_columns_orthonormal(z: &ComplexTensor, tol: f64) {
815        let rows = z.shape[0];
816        let cols = z.shape[1];
817        for col in 0..cols {
818            let norm = (0..rows)
819                .map(|row| {
820                    let value = z.data[row + col * rows];
821                    value.0 * value.0 + value.1 * value.1
822                })
823                .sum::<f64>()
824                .sqrt();
825            assert!((norm - 1.0).abs() <= tol, "column norm {norm}");
826            for other in 0..col {
827                let dot = (0..rows)
828                    .map(|row| {
829                        let lhs = z.data[row + other * rows];
830                        let rhs = z.data[row + col * rows];
831                        Complex64::new(lhs.0, -lhs.1) * Complex64::new(rhs.0, rhs.1)
832                    })
833                    .sum::<Complex64>();
834                assert!(dot.norm() <= tol, "column dot {dot}");
835            }
836        }
837    }
838
839    #[test]
840    fn null_type_uses_input_column_count_and_unknown_nullity() {
841        let out = null_type(
842            &[Type::Tensor {
843                shape: Some(vec![Some(3), Some(4)]),
844            }],
845            &ResolveContext::new(Vec::new()),
846        );
847        assert_eq!(
848            out,
849            Type::Tensor {
850                shape: Some(vec![Some(4), None])
851            }
852        );
853    }
854
855    #[test]
856    fn null_descriptor_signatures_cover_core_forms() {
857        let labels: Vec<&str> = NULL_DESCRIPTOR
858            .signatures
859            .iter()
860            .map(|signature| signature.label)
861            .collect();
862        assert!(labels.contains(&"Z = null(A)"));
863        assert!(labels.contains(&"Z = null(A, tol)"));
864        assert!(labels.contains(&"Z = null(A, \"r\")"));
865    }
866
867    #[test]
868    fn null_descriptor_errors_have_stable_codes() {
869        let codes: Vec<&str> = NULL_DESCRIPTOR.errors.iter().map(|err| err.code).collect();
870        assert!(codes.contains(&"RM.NULL.INVALID_ARGUMENT"));
871        assert!(codes.contains(&"RM.NULL.INVALID_INPUT"));
872        assert!(codes.contains(&"RM.NULL.INTERNAL"));
873    }
874
875    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
876    #[test]
877    fn null_full_rank_square_returns_empty_basis() {
878        let tensor = Tensor::new(vec![1.0, 0.0, 0.0, 2.0], vec![2, 2]).unwrap();
879        let result = null_builtin(Value::Tensor(tensor), Vec::new()).expect("null");
880        match result {
881            Value::Tensor(out) => {
882                assert_eq!(out.shape, vec![2, 0]);
883                assert!(out.data.is_empty());
884            }
885            other => panic!("expected empty tensor basis, got {other:?}"),
886        }
887    }
888
889    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
890    #[test]
891    fn null_rank_deficient_square_returns_orthonormal_basis() {
892        let tensor = Tensor::new(vec![1.0, 2.0, 2.0, 4.0], vec![2, 2]).unwrap();
893        let result = null_builtin(Value::Tensor(tensor.clone()), Vec::new()).expect("null");
894        match result {
895            Value::Tensor(out) => {
896                assert_eq!(out.shape, vec![2, 1]);
897                assert_az_zero(&tensor, &out, 1e-12);
898                assert_columns_orthonormal(&out, 1e-12);
899            }
900            other => panic!("expected tensor basis, got {other:?}"),
901        }
902    }
903
904    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
905    #[test]
906    fn null_rectangular_wide_matrix_returns_full_nullity() {
907        let tensor = Tensor::new(vec![1.0, 0.0, 0.0, 1.0, 1.0, 1.0], vec![2, 3]).unwrap();
908        let result = null_builtin(Value::Tensor(tensor.clone()), Vec::new()).expect("null");
909        match result {
910            Value::Tensor(out) => {
911                assert_eq!(out.shape, vec![3, 1]);
912                assert_az_zero(&tensor, &out, 1e-12);
913                assert_columns_orthonormal(&out, 1e-12);
914            }
915            other => panic!("expected tensor basis, got {other:?}"),
916        }
917    }
918
919    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
920    #[test]
921    fn null_large_scale_default_tolerance_keeps_exact_null_column() {
922        let tensor = Tensor::new(vec![1e20, 0.0], vec![1, 2]).unwrap();
923        let result = null_builtin(Value::Tensor(tensor.clone()), Vec::new()).expect("null");
924        match result {
925            Value::Tensor(out) => {
926                assert_eq!(out.shape, vec![2, 1]);
927                assert_az_zero(&tensor, &out, 1e-6);
928                assert_columns_orthonormal(&out, 1e-12);
929                assert!(out.data[0].abs() <= 1e-12);
930                assert!((out.data[1].abs() - 1.0).abs() <= 1e-12);
931            }
932            other => panic!("expected tensor basis, got {other:?}"),
933        }
934    }
935
936    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
937    #[test]
938    fn null_row_reduction_option_returns_pivot_basis() {
939        let tensor = Tensor::new(vec![1.0, 2.0, 2.0, 4.0, 3.0, 6.0], vec![2, 3]).unwrap();
940        let chars = CharArray::new("r".chars().collect(), 1, 1).unwrap();
941        let result = null_builtin(Value::Tensor(tensor), vec![Value::CharArray(chars)])
942            .expect("null rref basis");
943        match result {
944            Value::Tensor(out) => {
945                assert_eq!(out.shape, vec![3, 2]);
946                assert_close(&out.data, &[-2.0, 1.0, 0.0, -3.0, 0.0, 1.0], 1e-12);
947            }
948            other => panic!("expected tensor basis, got {other:?}"),
949        }
950    }
951
952    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
953    #[test]
954    fn null_row_reduction_accepts_string_array_option() {
955        let tensor = Tensor::new(vec![1.0, 2.0, 2.0, 4.0], vec![2, 2]).unwrap();
956        let option = StringArray::new(vec!["r".to_string()], vec![1, 1]).unwrap();
957        let result = null_builtin(Value::Tensor(tensor), vec![Value::StringArray(option)])
958            .expect("null rref basis");
959        match result {
960            Value::Tensor(out) => {
961                assert_eq!(out.shape, vec![2, 1]);
962                assert_close(&out.data, &[-2.0, 1.0], 1e-12);
963            }
964            other => panic!("expected tensor basis, got {other:?}"),
965        }
966    }
967
968    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
969    #[test]
970    fn null_custom_tolerance_expands_null_space() {
971        let tensor = Tensor::new(vec![1.0, 0.0, 0.0, 1e-8], vec![2, 2]).unwrap();
972        let result = null_builtin(Value::Tensor(tensor), vec![Value::Num(1e-6)]).expect("null");
973        match result {
974            Value::Tensor(out) => {
975                assert_eq!(out.shape, vec![2, 1]);
976                assert_close(&out.data, &[0.0, 1.0], 1e-12);
977            }
978            other => panic!("expected tensor basis, got {other:?}"),
979        }
980    }
981
982    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
983    #[test]
984    fn null_custom_large_tolerance_keeps_full_zero_matrix_basis() {
985        let tensor = Tensor::new(vec![0.0, 0.0], vec![1, 2]).unwrap();
986        let result = null_builtin(Value::Tensor(tensor.clone()), vec![Value::Num(2.0)])
987            .expect("null with large tolerance");
988        match result {
989            Value::Tensor(out) => {
990                assert_eq!(out.shape, vec![2, 2]);
991                assert_az_zero(&tensor, &out, 1e-12);
992                assert_columns_orthonormal(&out, 1e-12);
993            }
994            other => panic!("expected tensor basis, got {other:?}"),
995        }
996    }
997
998    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
999    #[test]
1000    fn null_complex_rank_deficient_matrix() {
1001        let tensor = ComplexTensor::new(
1002            vec![(1.0, 1.0), (2.0, 2.0), (2.0, 0.0), (4.0, 0.0)],
1003            vec![2, 2],
1004        )
1005        .unwrap();
1006        let result = null_builtin(Value::ComplexTensor(tensor.clone()), Vec::new()).expect("null");
1007        match result {
1008            Value::ComplexTensor(out) => {
1009                assert_eq!(out.shape, vec![2, 1]);
1010                assert_complex_az_zero(&tensor, &out, 1e-12);
1011                assert_complex_columns_orthonormal(&out, 1e-12);
1012            }
1013            other => panic!("expected complex tensor basis, got {other:?}"),
1014        }
1015    }
1016
1017    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1018    #[test]
1019    fn null_complex_row_reduction_option_returns_complex_basis() {
1020        let tensor = ComplexTensor::new(
1021            vec![(1.0, 0.0), (2.0, 0.0), (0.0, 1.0), (0.0, 2.0)],
1022            vec![2, 2],
1023        )
1024        .unwrap();
1025        let chars = CharArray::new("r".chars().collect(), 1, 1).unwrap();
1026        let result = null_builtin(Value::ComplexTensor(tensor), vec![Value::CharArray(chars)])
1027            .expect("null rref basis");
1028        match result {
1029            Value::ComplexTensor(out) => {
1030                assert_eq!(out.shape, vec![2, 1]);
1031                assert_complex_close(&out.data, &[(-0.0, -1.0), (1.0, 0.0)], 1e-12);
1032            }
1033            other => panic!("expected complex tensor basis, got {other:?}"),
1034        }
1035    }
1036
1037    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1038    #[test]
1039    fn null_empty_matrices_have_expected_basis_shapes() {
1040        let zero_by_three = Tensor::new(Vec::<f64>::new(), vec![0, 3]).unwrap();
1041        let result = null_builtin(Value::Tensor(zero_by_three), Vec::new()).expect("null");
1042        match result {
1043            Value::Tensor(out) => {
1044                assert_eq!(out.shape, vec![3, 3]);
1045                assert_close(
1046                    &out.data,
1047                    &[1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0],
1048                    1e-12,
1049                );
1050            }
1051            other => panic!("expected identity basis, got {other:?}"),
1052        }
1053
1054        let three_by_zero = Tensor::new(Vec::<f64>::new(), vec![3, 0]).unwrap();
1055        let result = null_builtin(Value::Tensor(three_by_zero), Vec::new()).expect("null");
1056        match result {
1057            Value::Tensor(out) => {
1058                assert_eq!(out.shape, vec![0, 0]);
1059                assert!(out.data.is_empty());
1060            }
1061            other => panic!("expected 0x0 tensor basis, got {other:?}"),
1062        }
1063    }
1064
1065    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1066    #[test]
1067    fn null_empty_wide_matrix_rejects_identity_basis_overflow() {
1068        let tensor = Tensor::new(Vec::<f64>::new(), vec![0, usize::MAX]).unwrap();
1069        let err = unwrap_error(null_builtin(Value::Tensor(tensor), Vec::new()).unwrap_err());
1070        assert!(err
1071            .message()
1072            .contains("identity basis dimension product overflow"));
1073        assert_eq!(err.identifier(), NULL_ERROR_INVALID_INPUT.identifier);
1074    }
1075
1076    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1077    #[test]
1078    fn null_rref_basis_rejects_output_size_overflow() {
1079        let err = unwrap_error(basis_from_real_rref(&[], 0, usize::MAX, &[]).unwrap_err());
1080        assert!(err
1081            .message()
1082            .contains("row-reduction basis dimension product overflow"));
1083        assert_eq!(err.identifier(), NULL_ERROR_INVALID_INPUT.identifier);
1084    }
1085
1086    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1087    #[test]
1088    fn null_scalars_follow_matrix_null_space_rules() {
1089        let zero = null_builtin(Value::Bool(false), Vec::new()).expect("null zero");
1090        let nonzero = null_builtin(Value::Int(IntValue::I32(5)), Vec::new()).expect("null int");
1091        match zero {
1092            Value::Num(value) => assert_eq!(value, 1.0),
1093            other => panic!("expected scalar one basis, got {other:?}"),
1094        }
1095        match nonzero {
1096            Value::Tensor(out) => {
1097                assert_eq!(out.shape, vec![1, 0]);
1098                assert!(out.data.is_empty());
1099            }
1100            other => panic!("expected empty scalar null basis, got {other:?}"),
1101        }
1102    }
1103
1104    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1105    #[test]
1106    fn null_rejects_invalid_option() {
1107        let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
1108        let chars = CharArray::new("q".chars().collect(), 1, 1).unwrap();
1109        let err = unwrap_error(
1110            null_builtin(Value::Tensor(tensor), vec![Value::CharArray(chars)]).unwrap_err(),
1111        );
1112        assert!(err.message().contains("unsupported basis option"));
1113        assert_eq!(err.identifier(), NULL_ERROR_INVALID_ARGUMENT.identifier);
1114    }
1115
1116    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1117    #[test]
1118    fn null_rejects_negative_tolerance() {
1119        let tensor = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
1120        let err = unwrap_error(
1121            null_builtin(Value::Tensor(tensor), vec![Value::Int(IntValue::I32(-1))]).unwrap_err(),
1122        );
1123        assert!(err.message().contains("tolerance"));
1124        assert_eq!(err.identifier(), NULL_ERROR_INVALID_ARGUMENT.identifier);
1125    }
1126
1127    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1128    #[test]
1129    fn null_gpu_round_trip_matches_cpu() {
1130        test_support::with_test_provider(|provider| {
1131            let tensor = Tensor::new(vec![1.0, 2.0, 2.0, 4.0], vec![2, 2]).unwrap();
1132            let view = HostTensorView {
1133                data: &tensor.data,
1134                shape: &tensor.shape,
1135            };
1136            let handle = provider.upload(&view).expect("upload");
1137            let result = null_builtin(Value::GpuTensor(handle), Vec::new()).expect("gpu null");
1138            let gathered = test_support::gather(result).expect("gather");
1139            let cpu = null_real_tensor(&tensor, NullMode::Orthonormal { tolerance: None })
1140                .expect("cpu null");
1141            assert_eq!(gathered.shape, cpu.shape);
1142            assert_close(&gathered.data, &cpu.data, 1e-12);
1143        });
1144    }
1145
1146    #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)]
1147    #[test]
1148    #[cfg(feature = "wgpu")]
1149    fn null_wgpu_matches_cpu() {
1150        use runmat_accelerate::backend::wgpu::provider::{
1151            register_wgpu_provider, WgpuProviderOptions,
1152        };
1153
1154        let _ = register_wgpu_provider(WgpuProviderOptions::default());
1155        let tensor = Tensor::new(vec![1.0, 2.0, 2.0, 4.0], vec![2, 2]).unwrap();
1156        let cpu =
1157            null_real_tensor(&tensor, NullMode::Orthonormal { tolerance: None }).expect("cpu null");
1158
1159        let view = HostTensorView {
1160            data: &tensor.data,
1161            shape: &tensor.shape,
1162        };
1163        let provider = runmat_accelerate_api::provider().expect("wgpu provider");
1164        let handle = provider.upload(&view).expect("upload");
1165
1166        let gpu_value = null_builtin(Value::GpuTensor(handle), Vec::new()).expect("gpu null");
1167        let gathered = test_support::gather(gpu_value).expect("gather");
1168        assert_eq!(gathered.shape, cpu.shape);
1169        assert_close(&gathered.data, &cpu.data, 5e-5);
1170    }
1171
1172    fn null_builtin(value: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1173        block_on(super::null_builtin(value, rest))
1174    }
1175}