runmat_runtime/builtins/array/sorting_sets/
issorted.rs

1//! MATLAB-compatible `issorted` builtin with GPU-aware semantics.
2
3use std::cmp::Ordering;
4
5use runmat_builtins::{CharArray, ComplexTensor, StringArray, Tensor, Value};
6use runmat_macros::runtime_builtin;
7
8use crate::builtins::common::gpu_helpers;
9use crate::builtins::common::spec::{
10    BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
11    ReductionNaN, ResidencyPolicy, ScalarType, ShapeRequirements,
12};
13use crate::builtins::common::tensor;
14#[cfg(feature = "doc_export")]
15use crate::register_builtin_doc_text;
16use crate::{register_builtin_fusion_spec, register_builtin_gpu_spec};
17
18#[cfg(feature = "doc_export")]
19pub const DOC_MD: &str = r#"---
20title: "issorted"
21category: "array/sorting_sets"
22keywords: ["issorted", "sorted", "monotonic", "strictascend", "rows", "comparisonmethod", "missingplacement"]
23summary: "Determine whether an array is already sorted along a dimension or across rows."
24references:
25  - https://www.mathworks.com/help/matlab/ref/double.issorted.html
26gpu_support:
27  elementwise: false
28  reduction: false
29  precisions: ["f32", "f64"]
30  broadcasting: "none"
31  notes: "GPU inputs are gathered to the host while providers gain native predicate support."
32fusion:
33  elementwise: false
34  reduction: false
35  max_inputs: 1
36  constants: "inline"
37requires_feature: null
38tested:
39  unit: "builtins::array::sorting_sets::issorted::tests"
40  integration: "builtins::array::sorting_sets::issorted::tests::issorted_gpu_roundtrip"
41  doc: "builtins::array::sorting_sets::issorted::tests::issorted_doc_examples"
42---
43
44# What does `issorted` do in MATLAB / RunMat?
45`issorted(A)` checks whether the elements of `A` already appear in sorted order.  
46You can examine a specific dimension, enforce strict monotonicity, require descending order, and
47control how missing values are positioned. The function also supports row-wise checks that match
48`issorted(A,'rows')` from MATLAB.
49
50## How does `issorted` behave in MATLAB / RunMat?
51- `issorted(A)` examines the first non-singleton dimension and returns a logical scalar.
52- `issorted(A, dim)` selects the dimension explicitly (1-based).
53- Direction flags include `'ascend'`, `'descend'`, `'monotonic'`, `'strictascend'`,
54  `'strictdescend'`, and `'strictmonotonic'`.
55- Name-value options mirror MATLAB:
56  - `'MissingPlacement'` accepts `'auto'`, `'first'`, or `'last'`.
57  - `'ComparisonMethod'` accepts `'auto'`, `'real'`, or `'abs'` for numeric and complex data.
58- `issorted(A,'rows', ...)` verifies lexicographic ordering of rows.
59- Logical and character arrays are promoted to double precision for comparison; string arrays are
60  compared lexicographically, recognising the literal `<missing>` token as a missing element.
61- Empty arrays, scalars, and singleton dimensions are always reported as sorted.
62
63## GPU execution in RunMat
64- `issorted` is registered as a sink builtin. When the input tensor lives on the GPU the runtime
65  gathers it to host memory and performs the check there, guaranteeing MATLAB-compatible semantics.
66- Future providers may implement a predicate hook so that simple monotonic checks can execute
67  entirely on device. Until then the behaviour is identical for CPU and GPU arrays.
68- The result is a logical scalar (`true` or `false`) regardless of input residency.
69
70## Examples of using `issorted` in MATLAB / RunMat
71
72### Checking an ascending vector
73```matlab
74A = [5 12 33 39 78 90 95 107];
75tf = issorted(A);
76```
77Expected output:
78```matlab
79tf =
80     1
81```
82
83### Verifying strict increase
84```matlab
85B = [1 1 2 3];
86tf = issorted(B, 'strictascend');
87```
88Expected output:
89```matlab
90tf =
91     0
92```
93
94### Using a specific dimension
95```matlab
96C = [1 4 2; 3 6 5];
97tf = issorted(C, 2, 'descend');
98```
99Expected output:
100```matlab
101tf =
102     0
103```
104
105### Allowing either ascending or descending order
106```matlab
107D = [10 8 8 5 1];
108tf = issorted(D, 'monotonic');
109```
110Expected output:
111```matlab
112tf =
113     1
114```
115
116### Controlling missing placements
117```matlab
118E = [NaN NaN 4 7];
119tf = issorted(E, 'MissingPlacement', 'first');
120```
121Expected output:
122```matlab
123tf =
124     0
125```
126
127### Comparing complex data by magnitude
128```matlab
129Z = [1+1i, 2+3i, 4+0i];
130tf = issorted(Z, 'ComparisonMethod', 'abs');
131```
132Expected output:
133```matlab
134tf =
135     1
136```
137
138### Checking row order
139```matlab
140R = [1 2 3; 1 2 4; 2 0 1];
141tf = issorted(R, 'rows');
142```
143Expected output:
144```matlab
145tf =
146     1
147```
148
149### Working with string arrays
150```matlab
151str = [ "apple" "banana"; "apple" "carrot" ];
152tf = issorted(str, 2);
153```
154Expected output:
155```matlab
156tf =
157     0
158```
159
160## FAQ
161
162### Does `issorted` modify its input?
163No. The function inspects the data in-place and returns a logical scalar.
164
165### What is the difference between `'ascend'` and `'strictascend'`?
166`'ascend'` allows consecutive equal values, while `'strictascend'` requires every element to be
167strictly greater than its predecessor and rejects missing values.
168
169### How does `'monotonic'` work?
170`'monotonic'` succeeds when the data is entirely non-decreasing *or* non-increasing. Use
171`'strictmonotonic'` to forbid repeated or missing values.
172
173### Can I control where NaN values appear?
174Yes. `'MissingPlacement','first'` requires missing values to precede finite ones, `'last'` requires
175them to trail, and `'auto'` follows MATLAB’s default (end for ascending checks, start for descending).
176
177### Is `'ComparisonMethod'` relevant for real data?
178For real data `'auto'` and `'real'` are identical. `'abs'` compares magnitudes first and breaks ties
179using the signed value, matching MATLAB.
180
181### Does the function support GPU arrays?
182Yes. GPU inputs are gathered automatically and the result is computed on the host to guarantee
183correctness until dedicated provider hooks are available.
184
185### How are string arrays treated?
186Strings are compared lexicographically using Unicode code-point order. The literal `<missing>` is
187treated as a missing value so `'MissingPlacement'` rules apply.
188
189### What about empty arrays or dimensions of length 0?
190Empty slices are considered sorted. Passing a dimension larger than `ndims(A)` also returns `true`.
191
192## See Also
193- [sort](./sort)
194- [sortrows](./sortrows)
195- [unique](./unique)
196- [issortedrows](https://www.mathworks.com/help/matlab/ref/issortedrows.html) (MATLAB reference)
197
198## Source & Feedback
199- Source code: [`crates/runmat-runtime/src/builtins/array/sorting_sets/issorted.rs`](https://github.com/runmat-org/runmat/blob/main/crates/runmat-runtime/src/builtins/array/sorting_sets/issorted.rs)
200- Found a bug? [Open an issue](https://github.com/runmat-org/runmat/issues/new/choose) with a minimal reproduction.
201"#;
202
203pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
204    name: "issorted",
205    op_kind: GpuOpKind::Custom("predicate"),
206    supported_precisions: &[ScalarType::F32, ScalarType::F64],
207    broadcast: BroadcastSemantics::None,
208    provider_hooks: &[],
209    constant_strategy: ConstantStrategy::InlineLiteral,
210    residency: ResidencyPolicy::GatherImmediately,
211    nan_mode: ReductionNaN::Include,
212    two_pass_threshold: None,
213    workgroup_size: None,
214    accepts_nan_mode: true,
215    notes: "GPU inputs gather to the host until providers implement dedicated predicate kernels.",
216};
217
218register_builtin_gpu_spec!(GPU_SPEC);
219
220pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
221    name: "issorted",
222    shape: ShapeRequirements::Any,
223    constant_strategy: ConstantStrategy::InlineLiteral,
224    elementwise: None,
225    reduction: None,
226    emits_nan: false,
227    notes: "Predicate builtin evaluated outside fusion; planner prevents kernel generation.",
228};
229
230register_builtin_fusion_spec!(FUSION_SPEC);
231
232#[cfg(feature = "doc_export")]
233register_builtin_doc_text!("issorted", DOC_MD);
234
235#[runtime_builtin(
236    name = "issorted",
237    category = "array/sorting_sets",
238    summary = "Determine whether an array is already sorted.",
239    keywords = "issorted,sorted,monotonic,rows",
240    accel = "sink",
241    sink = true
242)]
243fn issorted_builtin(value: Value, rest: Vec<Value>) -> Result<Value, String> {
244    let input = normalize_input(value)?;
245    let shape = input.shape();
246    let args = IssortedArgs::parse(&rest, &shape)?;
247
248    let result = match input {
249        InputArray::Real(tensor) => issorted_real(&tensor, &args)?,
250        InputArray::Complex(tensor) => issorted_complex(&tensor, &args)?,
251        InputArray::String(array) => issorted_string(&array, &args)?,
252    };
253
254    Ok(Value::Bool(result))
255}
256
257struct IssortedArgs {
258    mode: CheckMode,
259    direction: Direction,
260    comparison: ComparisonMethod,
261    missing: MissingPlacement,
262}
263
264#[derive(Clone, Copy, Debug, PartialEq, Eq)]
265enum CheckMode {
266    Dimension(usize),
267    Rows,
268}
269
270#[derive(Clone, Copy, Debug, PartialEq, Eq)]
271enum Direction {
272    Ascend,
273    Descend,
274    Monotonic,
275    StrictAscend,
276    StrictDescend,
277    StrictMonotonic,
278}
279
280#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
281enum ComparisonMethod {
282    #[default]
283    Auto,
284    Real,
285    Abs,
286}
287
288#[derive(Clone, Copy, Debug, PartialEq, Eq)]
289enum MissingPlacement {
290    Auto,
291    First,
292    Last,
293}
294
295#[derive(Clone, Copy, Debug, PartialEq, Eq)]
296enum MissingPlacementResolved {
297    First,
298    Last,
299}
300
301impl MissingPlacement {
302    fn resolve(self, direction: SortDirection) -> MissingPlacementResolved {
303        match self {
304            MissingPlacement::First => MissingPlacementResolved::First,
305            MissingPlacement::Last => MissingPlacementResolved::Last,
306            MissingPlacement::Auto => match direction {
307                SortDirection::Ascend => MissingPlacementResolved::Last,
308                SortDirection::Descend => MissingPlacementResolved::First,
309            },
310        }
311    }
312}
313
314#[derive(Clone, Copy, Debug, PartialEq, Eq)]
315enum SortDirection {
316    Ascend,
317    Descend,
318}
319
320#[derive(Clone, Copy)]
321struct OrderSpec {
322    direction: SortDirection,
323    strict: bool,
324}
325
326enum InputArray {
327    Real(Tensor),
328    Complex(ComplexTensor),
329    String(StringArray),
330}
331
332impl InputArray {
333    fn shape(&self) -> Vec<usize> {
334        match self {
335            InputArray::Real(t) => t.shape.clone(),
336            InputArray::Complex(t) => t.shape.clone(),
337            InputArray::String(sa) => sa.shape.clone(),
338        }
339    }
340}
341
342impl IssortedArgs {
343    fn parse(args: &[Value], shape: &[usize]) -> Result<Self, String> {
344        let mut dim_arg: Option<usize> = None;
345        let mut direction: Option<Direction> = None;
346        let mut comparison: ComparisonMethod = ComparisonMethod::Auto;
347        let mut missing: MissingPlacement = MissingPlacement::Auto;
348        let mut mode = CheckMode::Dimension(default_dimension(shape));
349        let mut saw_rows = false;
350
351        let mut idx = 0;
352        while idx < args.len() {
353            let arg = &args[idx];
354            if let Some(token) = value_to_string_lower(arg) {
355                match token.as_str() {
356                    "rows" => {
357                        if saw_rows {
358                            return Err("issorted: 'rows' specified more than once".to_string());
359                        }
360                        if dim_arg.is_some() {
361                            return Err(
362                                "issorted: cannot combine 'rows' with a dimension argument"
363                                    .to_string(),
364                            );
365                        }
366                        saw_rows = true;
367                        mode = CheckMode::Rows;
368                        idx += 1;
369                        continue;
370                    }
371                    "ascend" => {
372                        ensure_unique_direction(&direction)?;
373                        direction = Some(Direction::Ascend);
374                        idx += 1;
375                        continue;
376                    }
377                    "descend" => {
378                        ensure_unique_direction(&direction)?;
379                        direction = Some(Direction::Descend);
380                        idx += 1;
381                        continue;
382                    }
383                    "monotonic" => {
384                        ensure_unique_direction(&direction)?;
385                        direction = Some(Direction::Monotonic);
386                        idx += 1;
387                        continue;
388                    }
389                    "strictascend" => {
390                        ensure_unique_direction(&direction)?;
391                        direction = Some(Direction::StrictAscend);
392                        idx += 1;
393                        continue;
394                    }
395                    "strictdescend" => {
396                        ensure_unique_direction(&direction)?;
397                        direction = Some(Direction::StrictDescend);
398                        idx += 1;
399                        continue;
400                    }
401                    "strictmonotonic" => {
402                        ensure_unique_direction(&direction)?;
403                        direction = Some(Direction::StrictMonotonic);
404                        idx += 1;
405                        continue;
406                    }
407                    "comparisonmethod" => {
408                        idx += 1;
409                        if idx >= args.len() {
410                            return Err(
411                                "issorted: expected a value for 'ComparisonMethod'".to_string()
412                            );
413                        }
414                        let value = value_to_string_lower(&args[idx]).ok_or_else(|| {
415                            "issorted: 'ComparisonMethod' expects a string value".to_string()
416                        })?;
417                        comparison = match value.as_str() {
418                            "auto" => ComparisonMethod::Auto,
419                            "real" => ComparisonMethod::Real,
420                            "abs" | "magnitude" => ComparisonMethod::Abs,
421                            other => {
422                                return Err(format!(
423                                    "issorted: unsupported ComparisonMethod '{other}'"
424                                ));
425                            }
426                        };
427                        idx += 1;
428                        continue;
429                    }
430                    "missingplacement" => {
431                        idx += 1;
432                        if idx >= args.len() {
433                            return Err(
434                                "issorted: expected a value for 'MissingPlacement'".to_string()
435                            );
436                        }
437                        let value = value_to_string_lower(&args[idx]).ok_or_else(|| {
438                            "issorted: 'MissingPlacement' expects a string value".to_string()
439                        })?;
440                        missing = match value.as_str() {
441                            "auto" => MissingPlacement::Auto,
442                            "first" => MissingPlacement::First,
443                            "last" => MissingPlacement::Last,
444                            other => {
445                                return Err(format!(
446                                    "issorted: unsupported MissingPlacement '{other}'"
447                                ));
448                            }
449                        };
450                        idx += 1;
451                        continue;
452                    }
453                    _ => {}
454                }
455            }
456
457            if !saw_rows && dim_arg.is_none() {
458                if let Ok(dim) = tensor::parse_dimension(arg, "issorted") {
459                    dim_arg = Some(dim);
460                    idx += 1;
461                    continue;
462                }
463            }
464
465            return Err(format!("issorted: unrecognised argument {:?}", arg));
466        }
467
468        if let Some(dim) = dim_arg {
469            mode = CheckMode::Dimension(dim);
470        }
471
472        Ok(IssortedArgs {
473            mode,
474            direction: direction.unwrap_or(Direction::Ascend),
475            comparison,
476            missing,
477        })
478    }
479}
480
481fn ensure_unique_direction(direction: &Option<Direction>) -> Result<(), String> {
482    if direction.is_some() {
483        Err("issorted: sorting direction specified more than once".to_string())
484    } else {
485        Ok(())
486    }
487}
488
489fn normalize_input(value: Value) -> Result<InputArray, String> {
490    match value {
491        Value::Tensor(tensor) => Ok(InputArray::Real(tensor)),
492        Value::LogicalArray(logical) => {
493            let tensor = tensor::logical_to_tensor(&logical)?;
494            Ok(InputArray::Real(tensor))
495        }
496        Value::Num(_) | Value::Int(_) | Value::Bool(_) => {
497            let tensor = tensor::value_into_tensor_for("issorted", value)?;
498            Ok(InputArray::Real(tensor))
499        }
500        Value::ComplexTensor(ct) => Ok(InputArray::Complex(ct)),
501        Value::Complex(re, im) => {
502            let tensor = ComplexTensor::new(vec![(re, im)], vec![1, 1])
503                .map_err(|e| format!("issorted: {e}"))?;
504            Ok(InputArray::Complex(tensor))
505        }
506        Value::CharArray(ca) => {
507            let tensor = char_array_to_tensor(&ca)?;
508            Ok(InputArray::Real(tensor))
509        }
510        Value::StringArray(sa) => Ok(InputArray::String(sa)),
511        Value::String(s) => {
512            let array =
513                StringArray::new(vec![s], vec![1, 1]).map_err(|e| format!("issorted: {e}"))?;
514            Ok(InputArray::String(array))
515        }
516        Value::GpuTensor(handle) => {
517            let tensor = gpu_helpers::gather_tensor(&handle)?;
518            Ok(InputArray::Real(tensor))
519        }
520        other => Err(format!(
521            "issorted: unsupported input type {:?}; expected numeric, logical, complex, char, or string arrays",
522            other
523        )),
524    }
525}
526
527fn issorted_real(tensor: &Tensor, args: &IssortedArgs) -> Result<bool, String> {
528    if tensor.data.is_empty() {
529        return Ok(true);
530    }
531    match args.mode {
532        CheckMode::Dimension(dim) => Ok(check_real_dimension(tensor, dim, args)),
533        CheckMode::Rows => check_real_rows(tensor, args),
534    }
535}
536
537fn issorted_complex(tensor: &ComplexTensor, args: &IssortedArgs) -> Result<bool, String> {
538    if tensor.data.is_empty() {
539        return Ok(true);
540    }
541    match args.mode {
542        CheckMode::Dimension(dim) => Ok(check_complex_dimension(tensor, dim, args)),
543        CheckMode::Rows => check_complex_rows(tensor, args),
544    }
545}
546
547fn issorted_string(array: &StringArray, args: &IssortedArgs) -> Result<bool, String> {
548    if array.data.is_empty() {
549        return Ok(true);
550    }
551    if !matches!(args.comparison, ComparisonMethod::Auto) {
552        return Err("issorted: 'ComparisonMethod' is not supported for string arrays".to_string());
553    }
554    match args.mode {
555        CheckMode::Dimension(dim) => Ok(check_string_dimension(array, dim, args)),
556        CheckMode::Rows => check_string_rows(array, args),
557    }
558}
559
560fn check_real_dimension(tensor: &Tensor, dim: usize, args: &IssortedArgs) -> bool {
561    let dim_index = dim.saturating_sub(1);
562    if dim_index >= tensor.shape.len() {
563        return true;
564    }
565    let len_dim = tensor.shape[dim_index];
566    if len_dim <= 1 {
567        return true;
568    }
569
570    let before = product(&tensor.shape[..dim_index]);
571    let after = product(&tensor.shape[dim_index + 1..]);
572    let effective_comp = match args.comparison {
573        ComparisonMethod::Auto => ComparisonMethod::Real,
574        other => other,
575    };
576    let mut slice = Vec::with_capacity(len_dim);
577    for after_idx in 0..after {
578        for before_idx in 0..before {
579            slice.clear();
580            for k in 0..len_dim {
581                let idx = before_idx + k * before + after_idx * before * len_dim;
582                slice.push(tensor.data[idx]);
583            }
584            if !check_real_slice(&slice, args.direction, effective_comp, args.missing) {
585                return false;
586            }
587        }
588    }
589    true
590}
591
592fn check_complex_dimension(tensor: &ComplexTensor, dim: usize, args: &IssortedArgs) -> bool {
593    let dim_index = dim.saturating_sub(1);
594    if dim_index >= tensor.shape.len() {
595        return true;
596    }
597    let len_dim = tensor.shape[dim_index];
598    if len_dim <= 1 {
599        return true;
600    }
601    let before = product(&tensor.shape[..dim_index]);
602    let after = product(&tensor.shape[dim_index + 1..]);
603    let effective_comp = match args.comparison {
604        ComparisonMethod::Auto => ComparisonMethod::Abs,
605        other => other,
606    };
607    let mut slice = Vec::with_capacity(len_dim);
608    for after_idx in 0..after {
609        for before_idx in 0..before {
610            slice.clear();
611            for k in 0..len_dim {
612                let idx = before_idx + k * before + after_idx * before * len_dim;
613                slice.push(tensor.data[idx]);
614            }
615            if !check_complex_slice(&slice, args.direction, effective_comp, args.missing) {
616                return false;
617            }
618        }
619    }
620    true
621}
622
623fn check_string_dimension(array: &StringArray, dim: usize, args: &IssortedArgs) -> bool {
624    let dim_index = dim.saturating_sub(1);
625    if dim_index >= array.shape.len() {
626        return true;
627    }
628    let len_dim = array.shape[dim_index];
629    if len_dim <= 1 {
630        return true;
631    }
632    let before = product(&array.shape[..dim_index]);
633    let after = product(&array.shape[dim_index + 1..]);
634    let mut slice = Vec::with_capacity(len_dim);
635    for after_idx in 0..after {
636        for before_idx in 0..before {
637            slice.clear();
638            for k in 0..len_dim {
639                let idx = before_idx + k * before + after_idx * before * len_dim;
640                slice.push(array.data[idx].as_str());
641            }
642            if !check_string_slice(&slice, args.direction, args.missing) {
643                return false;
644            }
645        }
646    }
647    true
648}
649
650fn check_real_rows(tensor: &Tensor, args: &IssortedArgs) -> Result<bool, String> {
651    if tensor.shape.len() > 2 {
652        return Err("issorted: 'rows' expects a 2-D matrix".to_string());
653    }
654    let rows = tensor.rows();
655    let cols = tensor.cols();
656    if rows <= 1 || cols == 0 {
657        return Ok(true);
658    }
659    let effective_comp = match args.comparison {
660        ComparisonMethod::Auto => ComparisonMethod::Real,
661        other => other,
662    };
663    let orders = direction_orders(args.direction);
664    for &order in orders {
665        if real_rows_in_order(tensor, rows, cols, order, effective_comp, args.missing) {
666            return Ok(true);
667        }
668    }
669    Ok(false)
670}
671
672fn check_complex_rows(tensor: &ComplexTensor, args: &IssortedArgs) -> Result<bool, String> {
673    if tensor.shape.len() > 2 {
674        return Err("issorted: 'rows' expects a 2-D matrix".to_string());
675    }
676    let rows = tensor.rows;
677    let cols = tensor.cols;
678    if rows <= 1 || cols == 0 {
679        return Ok(true);
680    }
681    let effective_comp = match args.comparison {
682        ComparisonMethod::Auto => ComparisonMethod::Abs,
683        other => other,
684    };
685    let orders = direction_orders(args.direction);
686    for &order in orders {
687        if complex_rows_in_order(tensor, rows, cols, order, effective_comp, args.missing) {
688            return Ok(true);
689        }
690    }
691    Ok(false)
692}
693
694fn check_string_rows(array: &StringArray, args: &IssortedArgs) -> Result<bool, String> {
695    if array.shape.len() > 2 {
696        return Err("issorted: 'rows' expects a 2-D matrix".to_string());
697    }
698    let rows = array.rows;
699    let cols = array.cols;
700    if rows <= 1 || cols == 0 {
701        return Ok(true);
702    }
703    let orders = direction_orders(args.direction);
704    for &order in orders {
705        if string_rows_in_order(array, rows, cols, order, args.missing) {
706            return Ok(true);
707        }
708    }
709    Ok(false)
710}
711
712fn real_rows_in_order(
713    tensor: &Tensor,
714    rows: usize,
715    cols: usize,
716    order: OrderSpec,
717    comparison: ComparisonMethod,
718    missing: MissingPlacement,
719) -> bool {
720    if order.strict && tensor.data.iter().any(|v| v.is_nan()) {
721        return false;
722    }
723    let missing_resolved = missing.resolve(order.direction);
724    for row in 0..rows - 1 {
725        let ord = compare_real_row_pair(
726            tensor,
727            rows,
728            cols,
729            row,
730            row + 1,
731            order.direction,
732            comparison,
733            missing_resolved,
734        );
735        if !order_satisfied(ord, order) {
736            return false;
737        }
738    }
739    true
740}
741
742fn complex_rows_in_order(
743    tensor: &ComplexTensor,
744    rows: usize,
745    cols: usize,
746    order: OrderSpec,
747    comparison: ComparisonMethod,
748    missing: MissingPlacement,
749) -> bool {
750    if order.strict && tensor.data.iter().any(|v| complex_is_nan(*v)) {
751        return false;
752    }
753    let missing_resolved = missing.resolve(order.direction);
754    for row in 0..rows - 1 {
755        let ord = compare_complex_row_pair(
756            tensor,
757            rows,
758            cols,
759            row,
760            row + 1,
761            order.direction,
762            comparison,
763            missing_resolved,
764        );
765        if !order_satisfied(ord, order) {
766            return false;
767        }
768    }
769    true
770}
771
772fn string_rows_in_order(
773    array: &StringArray,
774    rows: usize,
775    cols: usize,
776    order: OrderSpec,
777    missing: MissingPlacement,
778) -> bool {
779    if order.strict && array.data.iter().any(|s| is_string_missing(s)) {
780        return false;
781    }
782    let missing_resolved = missing.resolve(order.direction);
783    for row in 0..rows - 1 {
784        let ord = compare_string_row_pair(
785            array,
786            rows,
787            cols,
788            row,
789            row + 1,
790            order.direction,
791            missing_resolved,
792        );
793        if !order_satisfied(ord, order) {
794            return false;
795        }
796    }
797    true
798}
799
800#[allow(clippy::too_many_arguments)]
801fn compare_real_row_pair(
802    tensor: &Tensor,
803    rows: usize,
804    cols: usize,
805    a: usize,
806    b: usize,
807    direction: SortDirection,
808    comparison: ComparisonMethod,
809    missing: MissingPlacementResolved,
810) -> Ordering {
811    for col in 0..cols {
812        let idx_a = a + col * rows;
813        let idx_b = b + col * rows;
814        let ord = compare_real_scalars(
815            tensor.data[idx_a],
816            tensor.data[idx_b],
817            direction,
818            comparison,
819            missing,
820        );
821        if ord != Ordering::Equal {
822            return ord;
823        }
824    }
825    Ordering::Equal
826}
827
828#[allow(clippy::too_many_arguments)]
829fn compare_complex_row_pair(
830    tensor: &ComplexTensor,
831    rows: usize,
832    cols: usize,
833    a: usize,
834    b: usize,
835    direction: SortDirection,
836    comparison: ComparisonMethod,
837    missing: MissingPlacementResolved,
838) -> Ordering {
839    for col in 0..cols {
840        let idx_a = a + col * rows;
841        let idx_b = b + col * rows;
842        let ord = compare_complex_scalars(
843            tensor.data[idx_a],
844            tensor.data[idx_b],
845            direction,
846            comparison,
847            missing,
848        );
849        if ord != Ordering::Equal {
850            return ord;
851        }
852    }
853    Ordering::Equal
854}
855
856fn compare_string_row_pair(
857    array: &StringArray,
858    rows: usize,
859    cols: usize,
860    a: usize,
861    b: usize,
862    direction: SortDirection,
863    missing: MissingPlacementResolved,
864) -> Ordering {
865    for col in 0..cols {
866        let idx_a = a + col * rows;
867        let idx_b = b + col * rows;
868        let ord = compare_string_scalars(
869            array.data[idx_a].as_str(),
870            array.data[idx_b].as_str(),
871            direction,
872            missing,
873        );
874        if ord != Ordering::Equal {
875            return ord;
876        }
877    }
878    Ordering::Equal
879}
880
881fn order_satisfied(ord: Ordering, order: OrderSpec) -> bool {
882    match order.direction {
883        SortDirection::Ascend => match ord {
884            Ordering::Greater => false,
885            Ordering::Equal => !order.strict,
886            Ordering::Less => true,
887        },
888        SortDirection::Descend => match ord {
889            Ordering::Less => true,
890            Ordering::Equal => !order.strict,
891            Ordering::Greater => false,
892        },
893    }
894}
895
896fn check_real_slice(
897    slice: &[f64],
898    direction: Direction,
899    comparison: ComparisonMethod,
900    missing: MissingPlacement,
901) -> bool {
902    if slice.len() <= 1 {
903        return true;
904    }
905    let orders = direction_orders(direction);
906    for &order in orders {
907        if order.strict && slice.iter().any(|v| v.is_nan()) {
908            continue;
909        }
910        let missing_resolved = missing.resolve(order.direction);
911        if real_slice_in_order(slice, order, comparison, missing_resolved) {
912            return true;
913        }
914    }
915    false
916}
917
918fn check_complex_slice(
919    slice: &[(f64, f64)],
920    direction: Direction,
921    comparison: ComparisonMethod,
922    missing: MissingPlacement,
923) -> bool {
924    if slice.len() <= 1 {
925        return true;
926    }
927    let orders = direction_orders(direction);
928    for &order in orders {
929        if order.strict && slice.iter().any(|v| complex_is_nan(*v)) {
930            continue;
931        }
932        let missing_resolved = missing.resolve(order.direction);
933        if complex_slice_in_order(slice, order, comparison, missing_resolved) {
934            return true;
935        }
936    }
937    false
938}
939
940fn check_string_slice(slice: &[&str], direction: Direction, missing: MissingPlacement) -> bool {
941    if slice.len() <= 1 {
942        return true;
943    }
944    let orders = direction_orders(direction);
945    for &order in orders {
946        if order.strict && slice.iter().any(|s| is_string_missing(s)) {
947            continue;
948        }
949        let missing_resolved = missing.resolve(order.direction);
950        if string_slice_in_order(slice, order, missing_resolved) {
951            return true;
952        }
953    }
954    false
955}
956
957fn real_slice_in_order(
958    slice: &[f64],
959    order: OrderSpec,
960    comparison: ComparisonMethod,
961    missing: MissingPlacementResolved,
962) -> bool {
963    for pair in slice.windows(2) {
964        let ord = compare_real_scalars(pair[0], pair[1], order.direction, comparison, missing);
965        if !order_satisfied(ord, order) {
966            return false;
967        }
968    }
969    true
970}
971
972fn complex_slice_in_order(
973    slice: &[(f64, f64)],
974    order: OrderSpec,
975    comparison: ComparisonMethod,
976    missing: MissingPlacementResolved,
977) -> bool {
978    for pair in slice.windows(2) {
979        let ord = compare_complex_scalars(pair[0], pair[1], order.direction, comparison, missing);
980        if !order_satisfied(ord, order) {
981            return false;
982        }
983    }
984    true
985}
986
987fn string_slice_in_order(
988    slice: &[&str],
989    order: OrderSpec,
990    missing: MissingPlacementResolved,
991) -> bool {
992    for pair in slice.windows(2) {
993        let ord = compare_string_scalars(pair[0], pair[1], order.direction, missing);
994        if !order_satisfied(ord, order) {
995            return false;
996        }
997    }
998    true
999}
1000
1001fn compare_real_scalars(
1002    a: f64,
1003    b: f64,
1004    direction: SortDirection,
1005    comparison: ComparisonMethod,
1006    missing: MissingPlacementResolved,
1007) -> Ordering {
1008    match (a.is_nan(), b.is_nan()) {
1009        (true, true) => Ordering::Equal,
1010        (true, false) => match missing {
1011            MissingPlacementResolved::First => Ordering::Less,
1012            MissingPlacementResolved::Last => Ordering::Greater,
1013        },
1014        (false, true) => match missing {
1015            MissingPlacementResolved::First => Ordering::Greater,
1016            MissingPlacementResolved::Last => Ordering::Less,
1017        },
1018        (false, false) => compare_real_finite_scalars(a, b, direction, comparison),
1019    }
1020}
1021
1022fn compare_real_finite_scalars(
1023    a: f64,
1024    b: f64,
1025    direction: SortDirection,
1026    comparison: ComparisonMethod,
1027) -> Ordering {
1028    if matches!(comparison, ComparisonMethod::Abs) {
1029        let abs_cmp = a.abs().partial_cmp(&b.abs()).unwrap_or(Ordering::Equal);
1030        if abs_cmp != Ordering::Equal {
1031            return match direction {
1032                SortDirection::Ascend => abs_cmp,
1033                SortDirection::Descend => abs_cmp.reverse(),
1034            };
1035        }
1036    }
1037    match direction {
1038        SortDirection::Ascend => a.partial_cmp(&b).unwrap_or(Ordering::Equal),
1039        SortDirection::Descend => b.partial_cmp(&a).unwrap_or(Ordering::Equal),
1040    }
1041}
1042
1043fn compare_complex_scalars(
1044    a: (f64, f64),
1045    b: (f64, f64),
1046    direction: SortDirection,
1047    comparison: ComparisonMethod,
1048    missing: MissingPlacementResolved,
1049) -> Ordering {
1050    match (complex_is_nan(a), complex_is_nan(b)) {
1051        (true, true) => Ordering::Equal,
1052        (true, false) => match missing {
1053            MissingPlacementResolved::First => Ordering::Less,
1054            MissingPlacementResolved::Last => Ordering::Greater,
1055        },
1056        (false, true) => match missing {
1057            MissingPlacementResolved::First => Ordering::Greater,
1058            MissingPlacementResolved::Last => Ordering::Less,
1059        },
1060        (false, false) => compare_complex_finite_scalars(a, b, direction, comparison),
1061    }
1062}
1063
1064fn compare_complex_finite_scalars(
1065    a: (f64, f64),
1066    b: (f64, f64),
1067    direction: SortDirection,
1068    comparison: ComparisonMethod,
1069) -> Ordering {
1070    match comparison {
1071        ComparisonMethod::Real => compare_complex_real_first(a, b, direction),
1072        ComparisonMethod::Abs | ComparisonMethod::Auto => {
1073            let abs_cmp = complex_abs(a)
1074                .partial_cmp(&complex_abs(b))
1075                .unwrap_or(Ordering::Equal);
1076            if abs_cmp != Ordering::Equal {
1077                return match direction {
1078                    SortDirection::Ascend => abs_cmp,
1079                    SortDirection::Descend => abs_cmp.reverse(),
1080                };
1081            }
1082            compare_complex_real_first(a, b, direction)
1083        }
1084    }
1085}
1086
1087fn compare_complex_real_first(a: (f64, f64), b: (f64, f64), direction: SortDirection) -> Ordering {
1088    let real_cmp = match direction {
1089        SortDirection::Ascend => a.0.partial_cmp(&b.0),
1090        SortDirection::Descend => b.0.partial_cmp(&a.0),
1091    }
1092    .unwrap_or(Ordering::Equal);
1093    if real_cmp != Ordering::Equal {
1094        return real_cmp;
1095    }
1096    match direction {
1097        SortDirection::Ascend => a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal),
1098        SortDirection::Descend => b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal),
1099    }
1100}
1101
1102fn compare_string_scalars(
1103    a: &str,
1104    b: &str,
1105    direction: SortDirection,
1106    missing: MissingPlacementResolved,
1107) -> Ordering {
1108    let missing_a = is_string_missing(a);
1109    let missing_b = is_string_missing(b);
1110    match (missing_a, missing_b) {
1111        (true, true) => Ordering::Equal,
1112        (true, false) => match missing {
1113            MissingPlacementResolved::First => Ordering::Less,
1114            MissingPlacementResolved::Last => Ordering::Greater,
1115        },
1116        (false, true) => match missing {
1117            MissingPlacementResolved::First => Ordering::Greater,
1118            MissingPlacementResolved::Last => Ordering::Less,
1119        },
1120        (false, false) => match direction {
1121            SortDirection::Ascend => a.cmp(b),
1122            SortDirection::Descend => b.cmp(a),
1123        },
1124    }
1125}
1126
1127fn complex_is_nan(value: (f64, f64)) -> bool {
1128    value.0.is_nan() || value.1.is_nan()
1129}
1130
1131fn complex_abs(value: (f64, f64)) -> f64 {
1132    value.0.hypot(value.1)
1133}
1134
1135fn is_string_missing(value: &str) -> bool {
1136    value.eq_ignore_ascii_case("<missing>")
1137}
1138
1139fn direction_orders(direction: Direction) -> &'static [OrderSpec] {
1140    match direction {
1141        Direction::Ascend => &[OrderSpec {
1142            direction: SortDirection::Ascend,
1143            strict: false,
1144        }],
1145        Direction::Descend => &[OrderSpec {
1146            direction: SortDirection::Descend,
1147            strict: false,
1148        }],
1149        Direction::Monotonic => &[
1150            OrderSpec {
1151                direction: SortDirection::Ascend,
1152                strict: false,
1153            },
1154            OrderSpec {
1155                direction: SortDirection::Descend,
1156                strict: false,
1157            },
1158        ],
1159        Direction::StrictAscend => &[OrderSpec {
1160            direction: SortDirection::Ascend,
1161            strict: true,
1162        }],
1163        Direction::StrictDescend => &[OrderSpec {
1164            direction: SortDirection::Descend,
1165            strict: true,
1166        }],
1167        Direction::StrictMonotonic => &[
1168            OrderSpec {
1169                direction: SortDirection::Ascend,
1170                strict: true,
1171            },
1172            OrderSpec {
1173                direction: SortDirection::Descend,
1174                strict: true,
1175            },
1176        ],
1177    }
1178}
1179
1180fn default_dimension(shape: &[usize]) -> usize {
1181    if shape.is_empty() {
1182        return 1;
1183    }
1184    shape
1185        .iter()
1186        .position(|&extent| extent > 1)
1187        .map(|idx| idx + 1)
1188        .unwrap_or(1)
1189}
1190
1191fn product(slice: &[usize]) -> usize {
1192    slice
1193        .iter()
1194        .copied()
1195        .fold(1usize, |acc, value| acc.saturating_mul(value.max(1)))
1196}
1197
1198fn value_to_string_lower(value: &Value) -> Option<String> {
1199    match String::try_from(value) {
1200        Ok(text) => Some(text.trim().to_ascii_lowercase()),
1201        Err(_) => None,
1202    }
1203}
1204
1205fn char_array_to_tensor(array: &CharArray) -> Result<Tensor, String> {
1206    let rows = array.rows;
1207    let cols = array.cols;
1208    let mut data = vec![0.0f64; rows * cols];
1209    for r in 0..rows {
1210        for c in 0..cols {
1211            let ch = array.data[r * cols + c];
1212            let idx = r + c * rows;
1213            data[idx] = ch as u32 as f64;
1214        }
1215    }
1216    Tensor::new(data, vec![rows, cols]).map_err(|e| format!("issorted: {e}"))
1217}
1218
1219#[cfg(test)]
1220mod tests {
1221    use super::*;
1222    use crate::builtins::common::test_support;
1223    use runmat_builtins::{IntValue, LogicalArray, Value};
1224
1225    #[test]
1226    fn issorted_numeric_vector_true() {
1227        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1228        let result = issorted_builtin(Value::Tensor(tensor), vec![]).expect("issorted");
1229        assert_eq!(result, Value::Bool(true));
1230    }
1231
1232    #[test]
1233    fn issorted_numeric_vector_false() {
1234        let tensor = Tensor::new(vec![3.0, 2.0, 1.0], vec![3, 1]).unwrap();
1235        let result = issorted_builtin(Value::Tensor(tensor), vec![]).expect("issorted");
1236        assert_eq!(result, Value::Bool(false));
1237    }
1238
1239    #[test]
1240    fn issorted_logical_vector() {
1241        let logical = LogicalArray::new(vec![0, 1, 1], vec![3, 1]).unwrap();
1242        let result =
1243            issorted_builtin(Value::LogicalArray(logical), vec![]).expect("issorted logical");
1244        assert_eq!(result, Value::Bool(true));
1245    }
1246
1247    #[test]
1248    fn issorted_dimension_argument() {
1249        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 3.0], vec![2, 2]).unwrap();
1250        let args = vec![Value::Int(IntValue::I32(2))];
1251        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1252        assert_eq!(result, Value::Bool(true));
1253    }
1254
1255    #[test]
1256    fn issorted_strictascend_rejects_duplicates() {
1257        let tensor = Tensor::new(vec![1.0, 1.0, 2.0], vec![3, 1]).unwrap();
1258        let args = vec![Value::from("strictascend")];
1259        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1260        assert_eq!(result, Value::Bool(false));
1261    }
1262
1263    #[test]
1264    fn issorted_strictmonotonic_true_with_descend() {
1265        let tensor = Tensor::new(vec![9.0, 4.0, 1.0], vec![3, 1]).unwrap();
1266        let args = vec![Value::from("strictmonotonic")];
1267        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1268        assert_eq!(result, Value::Bool(true));
1269    }
1270
1271    #[test]
1272    fn issorted_strictmonotonic_rejects_plateaus() {
1273        let tensor = Tensor::new(vec![4.0, 4.0, 2.0, 1.0], vec![4, 1]).unwrap();
1274        let args = vec![Value::from("strictmonotonic")];
1275        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1276        assert_eq!(result, Value::Bool(false));
1277    }
1278
1279    #[test]
1280    fn issorted_monotonic_accepts_descending() {
1281        let tensor = Tensor::new(vec![5.0, 4.0, 4.0, 1.0], vec![4, 1]).unwrap();
1282        let args = vec![Value::from("monotonic")];
1283        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1284        assert_eq!(result, Value::Bool(true));
1285    }
1286
1287    #[test]
1288    fn issorted_monotonic_rejects_unsorted_data() {
1289        let tensor = Tensor::new(vec![1.0, 3.0, 2.0], vec![3, 1]).unwrap();
1290        let args = vec![Value::from("monotonic")];
1291        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1292        assert_eq!(result, Value::Bool(false));
1293    }
1294
1295    #[test]
1296    fn issorted_missingplacement_first() {
1297        let tensor = Tensor::new(vec![f64::NAN, 2.0, 3.0], vec![3, 1]).unwrap();
1298        let args = vec![Value::from("MissingPlacement"), Value::from("first")];
1299        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1300        assert_eq!(result, Value::Bool(true));
1301    }
1302
1303    #[test]
1304    fn issorted_missingplacement_first_violation() {
1305        let tensor = Tensor::new(vec![2.0, f64::NAN, 3.0], vec![3, 1]).unwrap();
1306        let args = vec![Value::from("MissingPlacement"), Value::from("first")];
1307        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1308        assert_eq!(result, Value::Bool(false));
1309    }
1310
1311    #[test]
1312    fn issorted_missingplacement_auto_descend_prefers_front() {
1313        let tensor = Tensor::new(vec![f64::NAN, 5.0, 3.0], vec![3, 1]).unwrap();
1314        let args = vec![Value::from("descend")];
1315        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1316        assert_eq!(result, Value::Bool(true));
1317    }
1318
1319    #[test]
1320    fn issorted_comparison_abs() {
1321        let tensor = Tensor::new(vec![-1.0, 1.5, -2.0], vec![3, 1]).unwrap();
1322        let args = vec![Value::from("ComparisonMethod"), Value::from("abs")];
1323        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1324        assert_eq!(result, Value::Bool(true));
1325    }
1326
1327    #[test]
1328    fn issorted_complex_abs_method() {
1329        let tensor =
1330            ComplexTensor::new(vec![(1.0, 1.0), (2.0, 0.0), (2.0, 3.0)], vec![3, 1]).unwrap();
1331        let args = vec![Value::from("ComparisonMethod"), Value::from("abs")];
1332        let result = issorted_builtin(Value::ComplexTensor(tensor), args).expect("issorted");
1333        assert_eq!(result, Value::Bool(true));
1334    }
1335
1336    #[test]
1337    fn issorted_complex_real_method() {
1338        let tensor =
1339            ComplexTensor::new(vec![(1.0, 1.0), (1.0, 1.0), (2.0, 0.0)], vec![3, 1]).unwrap();
1340        let args = vec![
1341            Value::from("ComparisonMethod"),
1342            Value::from("real"),
1343            Value::from("strictascend"),
1344        ];
1345        let result = issorted_builtin(Value::ComplexTensor(tensor), args).expect("issorted");
1346        assert_eq!(result, Value::Bool(false));
1347    }
1348
1349    #[test]
1350    fn issorted_rows_true() {
1351        let tensor = Tensor::new(vec![1.0, 2.0, 1.0, 3.0], vec![2, 2]).unwrap();
1352        let args = vec![Value::from("rows")];
1353        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1354        assert_eq!(result, Value::Bool(true));
1355    }
1356
1357    #[test]
1358    fn issorted_rows_dimension_error() {
1359        let tensor = Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![2, 2, 1]).unwrap();
1360        let result = issorted_builtin(Value::Tensor(tensor), vec![Value::from("rows")]);
1361        assert!(result.is_err());
1362    }
1363
1364    #[test]
1365    fn issorted_rows_descend_false() {
1366        let tensor = Tensor::new(vec![1.0, 2.0, 4.0, 0.0], vec![2, 2]).unwrap();
1367        let args = vec![Value::from("rows"), Value::from("descend")];
1368        let result = issorted_builtin(Value::Tensor(tensor), args).expect("issorted");
1369        assert_eq!(result, Value::Bool(false));
1370    }
1371
1372    #[test]
1373    fn issorted_string_dimension() {
1374        let array = StringArray::new(
1375            vec![
1376                "pear".into(),
1377                "plum".into(),
1378                "apple".into(),
1379                "banana".into(),
1380            ],
1381            vec![2, 2],
1382        )
1383        .unwrap();
1384        let args = vec![Value::Int(IntValue::I32(2))];
1385        let result =
1386            issorted_builtin(Value::StringArray(array), args).expect("issorted string dim");
1387        assert_eq!(result, Value::Bool(false));
1388    }
1389
1390    #[test]
1391    fn issorted_string_missingplacement_last() {
1392        let array = StringArray::new(
1393            vec!["apple".into(), "banana".into(), "<missing>".into()],
1394            vec![3, 1],
1395        )
1396        .unwrap();
1397        let args = vec![Value::from("MissingPlacement"), Value::from("last")];
1398        let result =
1399            issorted_builtin(Value::StringArray(array), args).expect("issorted string placement");
1400        assert_eq!(result, Value::Bool(true));
1401    }
1402
1403    #[test]
1404    fn issorted_string_missingplacement_last_violation() {
1405        let array = StringArray::new(vec!["<missing>".into(), "apple".into()], vec![2, 1]).unwrap();
1406        let args = vec![Value::from("MissingPlacement"), Value::from("last")];
1407        let result =
1408            issorted_builtin(Value::StringArray(array), args).expect("issorted string placement");
1409        assert_eq!(result, Value::Bool(false));
1410    }
1411
1412    #[test]
1413    fn issorted_string_comparison_method_error() {
1414        let array = StringArray::new(vec!["apple".into(), "berry".into()], vec![2, 1]).unwrap();
1415        let args = vec![Value::from("ComparisonMethod"), Value::from("real")];
1416        let result = issorted_builtin(Value::StringArray(array), args);
1417        assert!(result.is_err());
1418    }
1419
1420    #[test]
1421    fn issorted_char_array_input() {
1422        let chars = CharArray::new(vec!['a', 'c', 'e'], 1, 3).unwrap();
1423        let result = issorted_builtin(Value::CharArray(chars), vec![]).expect("issorted char");
1424        assert_eq!(result, Value::Bool(true));
1425    }
1426
1427    #[test]
1428    fn issorted_duplicate_direction_error() {
1429        let tensor = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1430        let args = vec![Value::from("ascend"), Value::from("descend")];
1431        let result = issorted_builtin(Value::Tensor(tensor), args);
1432        assert!(result.is_err());
1433    }
1434
1435    #[test]
1436    fn issorted_gpu_roundtrip() {
1437        test_support::with_test_provider(|provider| {
1438            let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1439            let view = runmat_accelerate_api::HostTensorView {
1440                data: &tensor.data,
1441                shape: &tensor.shape,
1442            };
1443            let handle = provider.upload(&view).expect("upload");
1444            let result = issorted_builtin(Value::GpuTensor(handle), vec![]).expect("issorted gpu");
1445            assert_eq!(result, Value::Bool(true));
1446        });
1447    }
1448
1449    #[test]
1450    #[cfg(feature = "wgpu")]
1451    fn issorted_wgpu_matches_cpu() {
1452        let _ = runmat_accelerate::backend::wgpu::provider::register_wgpu_provider(
1453            runmat_accelerate::backend::wgpu::provider::WgpuProviderOptions::default(),
1454        );
1455        let tensor = Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap();
1456        let cpu = issorted_builtin(Value::Tensor(tensor.clone()), vec![]).expect("cpu issorted");
1457        let view = runmat_accelerate_api::HostTensorView {
1458            data: &tensor.data,
1459            shape: &tensor.shape,
1460        };
1461        let handle = runmat_accelerate_api::provider()
1462            .expect("wgpu provider")
1463            .upload(&view)
1464            .expect("upload");
1465        let gpu = issorted_builtin(Value::GpuTensor(handle), vec![]).expect("gpu issorted");
1466        assert_eq!(gpu, cpu);
1467    }
1468
1469    #[test]
1470    #[cfg(feature = "doc_export")]
1471    fn issorted_doc_examples() {
1472        let blocks = test_support::doc_examples(DOC_MD);
1473        assert!(!blocks.is_empty());
1474    }
1475}