Skip to main content

runmat_runtime/builtins/array/sorting_sets/
ismembertol.rs

1//! MATLAB-compatible `ismembertol` builtin for real numeric arrays.
2
3use runmat_builtins::{
4    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinExtensionDescriptor,
5    BuiltinExtensionMode, BuiltinIntegerBackendRule, BuiltinIntegerCapabilityDescriptor,
6    BuiltinIntegerClass, BuiltinIntegerComputationDomain, BuiltinIntegerInputAvailability,
7    BuiltinIntegerInputCapability, BuiltinIntegerOutputClassRule, BuiltinIntegerOverflowRule,
8    BuiltinIntegerOverloadKind, BuiltinIntegerScalarDoubleRule, BuiltinOutputMode,
9    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
10    LiteralValue, ResolveContext, Type,
11};
12use runmat_macros::runtime_builtin;
13use runmat_value::{CellArray, LogicalArray, NumericDType, Tensor, Value};
14
15use super::type_resolvers::logical_output_type;
16use crate::builtins::common::{
17    gpu_helpers,
18    spec::{
19        BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
20        ReductionNaN, ResidencyPolicy, ScalarType, ShapeRequirements,
21    },
22    tensor,
23};
24use crate::{build_runtime_error, BuiltinResult, RuntimeError};
25
26const NAME: &str = "ismembertol";
27const DEFAULT_TOL_DOUBLE: f64 = 1.0e-12;
28const DEFAULT_TOL_SINGLE: f64 = 1.0e-6;
29
30#[runmat_macros::register_gpu_spec(
31    builtin_path = "crate::builtins::array::sorting_sets::ismembertol"
32)]
33pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
34    name: NAME,
35    op_kind: GpuOpKind::Custom("ismembertol"),
36    supported_precisions: &[ScalarType::F32, ScalarType::F64],
37    broadcast: BroadcastSemantics::None,
38    provider_hooks: &[],
39    constant_strategy: ConstantStrategy::InlineLiteral,
40    residency: ResidencyPolicy::NewHandle,
41    nan_mode: ReductionNaN::Include,
42    two_pass_threshold: None,
43    workgroup_size: None,
44    accepts_nan_mode: false,
45    notes: "Tolerance membership uses authoritative typed gather fallback and restores documented logical and double outputs to the resident input owner; RunMat-only all-indices cell output remains host materialised.",
46};
47
48#[runmat_macros::register_fusion_spec(
49    builtin_path = "crate::builtins::array::sorting_sets::ismembertol"
50)]
51pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
52    name: NAME,
53    shape: ShapeRequirements::Any,
54    constant_strategy: ConstantStrategy::InlineLiteral,
55    elementwise: None,
56    reduction: None,
57    emits_nan: false,
58    notes: "`ismembertol` materialises logical and index outputs and terminates fusion chains.",
59};
60
61const OUTPUT_MASK: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
62    name: "LIA",
63    ty: BuiltinParamType::LogicalArray,
64    arity: BuiltinParamArity::Required,
65    default: None,
66    description: "Logical mask over A.",
67}];
68
69const OUTPUT_MASK_LOC: [BuiltinParamDescriptor; 2] = [
70    BuiltinParamDescriptor {
71        name: "LIA",
72        ty: BuiltinParamType::LogicalArray,
73        arity: BuiltinParamArity::Required,
74        default: None,
75        description: "Logical mask over A.",
76    },
77    BuiltinParamDescriptor {
78        name: "LocB",
79        ty: BuiltinParamType::Any,
80        arity: BuiltinParamArity::Required,
81        default: None,
82        description: "First or all matching indices into B.",
83    },
84];
85
86const INPUTS_AB: [BuiltinParamDescriptor; 2] = [
87    BuiltinParamDescriptor {
88        name: "A",
89        ty: BuiltinParamType::NumericArray,
90        arity: BuiltinParamArity::Required,
91        default: None,
92        description: "Query values or rows.",
93    },
94    BuiltinParamDescriptor {
95        name: "B",
96        ty: BuiltinParamType::NumericArray,
97        arity: BuiltinParamArity::Required,
98        default: None,
99        description: "Reference values or rows.",
100    },
101];
102
103const INPUTS_AB_OPTIONS: [BuiltinParamDescriptor; 4] = [
104    BuiltinParamDescriptor {
105        name: "A",
106        ty: BuiltinParamType::NumericArray,
107        arity: BuiltinParamArity::Required,
108        default: None,
109        description: "Query values or rows.",
110    },
111    BuiltinParamDescriptor {
112        name: "B",
113        ty: BuiltinParamType::NumericArray,
114        arity: BuiltinParamArity::Required,
115        default: None,
116        description: "Reference values or rows.",
117    },
118    BuiltinParamDescriptor {
119        name: "tol",
120        ty: BuiltinParamType::NumericScalar,
121        arity: BuiltinParamArity::Optional,
122        default: Some("1e-12 for double, 1e-6 for single"),
123        description: "Relative tolerance.",
124    },
125    BuiltinParamDescriptor {
126        name: "Name,Value",
127        ty: BuiltinParamType::Any,
128        arity: BuiltinParamArity::Variadic,
129        default: None,
130        description: "Name-value options: ByRows, DataScale, OutputAllIndices.",
131    },
132];
133
134const SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
135    BuiltinSignatureDescriptor {
136        label: "LIA = ismembertol(A, B)",
137        inputs: &INPUTS_AB,
138        outputs: &OUTPUT_MASK,
139    },
140    BuiltinSignatureDescriptor {
141        label: "LIA = ismembertol(A, B, tol, Name, Value)",
142        inputs: &INPUTS_AB_OPTIONS,
143        outputs: &OUTPUT_MASK,
144    },
145    BuiltinSignatureDescriptor {
146        label: "[LIA, LocB] = ismembertol(A, B)",
147        inputs: &INPUTS_AB,
148        outputs: &OUTPUT_MASK_LOC,
149    },
150    BuiltinSignatureDescriptor {
151        label: "[LIA, LocB] = ismembertol(A, B, tol, Name, Value)",
152        inputs: &INPUTS_AB_OPTIONS,
153        outputs: &OUTPUT_MASK_LOC,
154    },
155];
156
157const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
158    code: "RM.ISMEMBERTOL.INVALID_INPUT",
159    identifier: Some("RunMat:ismembertol:InvalidInput"),
160    when: "A or B is not a supported real full numeric input.",
161    message: "ismembertol: inputs must be real full numeric arrays",
162};
163
164const ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
165    code: "RM.ISMEMBERTOL.INVALID_ARGUMENT",
166    identifier: Some("RunMat:ismembertol:InvalidArgument"),
167    when: "Tolerance or name-value arguments are malformed.",
168    message: "ismembertol: invalid argument",
169};
170
171const ERROR_ROWS_COLUMN_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
172    code: "RM.ISMEMBERTOL.ROWS_COLUMN_MISMATCH",
173    identifier: Some("RunMat:ismembertol:RowsColumnMismatch"),
174    when: "ByRows is true and A/B column counts differ.",
175    message: "ismembertol: inputs must have the same number of columns when ByRows is true",
176};
177
178const ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
179    code: "RM.ISMEMBERTOL.INTERNAL",
180    identifier: Some("RunMat:ismembertol:Internal"),
181    when: "Internal conversion or allocation fails.",
182    message: "ismembertol: internal error",
183};
184
185const ERRORS: [BuiltinErrorDescriptor; 4] = [
186    ERROR_INVALID_INPUT,
187    ERROR_INVALID_ARGUMENT,
188    ERROR_ROWS_COLUMN_MISMATCH,
189    ERROR_INTERNAL,
190];
191
192const HOST_INTEGER_DATA_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
193    id: "ismembertol-host-integer-data",
194    mode: BuiltinExtensionMode::RunMatOnly,
195    description: "ismembertol with host typed-integer A or B data is a RunMat extension",
196    error_identifier: Some("RunMat:compatibility:IsmembertolHostIntegerDataExtension"),
197};
198
199const HOST_LOGICAL_DATA_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
200    id: "ismembertol-host-logical-data",
201    mode: BuiltinExtensionMode::RunMatOnly,
202    description: "ismembertol with host logical A or B data is a RunMat extension",
203    error_identifier: Some("RunMat:compatibility:IsmembertolHostLogicalDataExtension"),
204};
205
206const TYPED_TOLERANCE_CONTROL_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
207    id: "ismembertol-typed-tolerance-control",
208    mode: BuiltinExtensionMode::RunMatOnly,
209    description: "ismembertol with a typed-integer tolerance or DataScale is a RunMat extension",
210    error_identifier: Some("RunMat:compatibility:IsmembertolTypedToleranceControlExtension"),
211};
212
213const LOGICAL_TOLERANCE_CONTROL_EXTENSION: BuiltinExtensionDescriptor =
214    BuiltinExtensionDescriptor {
215        id: "ismembertol-logical-tolerance-control",
216        mode: BuiltinExtensionMode::RunMatOnly,
217        description: "ismembertol with a logical tolerance or DataScale is a RunMat extension",
218        error_identifier: Some("RunMat:compatibility:IsmembertolLogicalToleranceControlExtension"),
219    };
220
221const GPU_WIDE_INTEGER_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
222    id: "ismembertol-gpu-wide-integer-input",
223    mode: BuiltinExtensionMode::RunMatOnly,
224    description: "ismembertol with a resident 64-bit integer input is a RunMat extension",
225    error_identifier: Some("RunMat:compatibility:IsmembertolGpuWideIntegerInputExtension"),
226};
227
228const GPU_OPTIONS_EXTENSION: BuiltinExtensionDescriptor = BuiltinExtensionDescriptor {
229    id: "ismembertol-gpu-options",
230    mode: BuiltinExtensionMode::RunMatOnly,
231    description: "ismembertol ByRows or OutputAllIndices with resident input is a RunMat extension",
232    error_identifier: Some("RunMat:compatibility:IsmembertolGpuOptionsExtension"),
233};
234
235const EXTENSIONS: [BuiltinExtensionDescriptor; 6] = [
236    GPU_OPTIONS_EXTENSION,
237    GPU_WIDE_INTEGER_EXTENSION,
238    HOST_INTEGER_DATA_EXTENSION,
239    HOST_LOGICAL_DATA_EXTENSION,
240    LOGICAL_TOLERANCE_CONTROL_EXTENSION,
241    TYPED_TOLERANCE_CONTROL_EXTENSION,
242];
243
244const WIDE_INTEGER_CLASSES: [BuiltinIntegerClass; 2] =
245    [BuiltinIntegerClass::Int64, BuiltinIntegerClass::Uint64];
246
247const HOST_INTEGER_DATA_INPUTS: [BuiltinIntegerInputCapability; 2] = [
248    BuiltinIntegerInputCapability {
249        name: "A",
250        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
251        availability: BuiltinIntegerInputAvailability::RunMatOnly,
252        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
253        notes: "The documented host data domain is single or double; RunMat mode additionally accepts every real integer class.",
254    },
255    BuiltinIntegerInputCapability {
256        name: "B",
257        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
258        availability: BuiltinIntegerInputAvailability::RunMatOnly,
259        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
260        notes: "The documented host data domain is single or double; RunMat mode additionally accepts every real integer class.",
261    },
262];
263
264const GPU_INTEGER_DATA_INPUTS: [BuiltinIntegerInputCapability; 2] = [
265    BuiltinIntegerInputCapability {
266        name: "resident_A",
267        classes: &crate::builtins::common::integer_capability::INTEGER_CLASSES_THROUGH_32_BITS,
268        availability: BuiltinIntegerInputAvailability::Documented,
269        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
270        notes: "Interactive GPU arrays document signed and unsigned integer data through 32 bits.",
271    },
272    BuiltinIntegerInputCapability {
273        name: "resident_B",
274        classes: &crate::builtins::common::integer_capability::INTEGER_CLASSES_THROUGH_32_BITS,
275        availability: BuiltinIntegerInputAvailability::Documented,
276        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
277        notes: "Interactive GPU arrays document signed and unsigned integer data through 32 bits.",
278    },
279];
280
281const GPU_WIDE_INTEGER_DATA_INPUT: [BuiltinIntegerInputCapability; 1] =
282    [BuiltinIntegerInputCapability {
283        name: "resident_A_or_B",
284        classes: &WIDE_INTEGER_CLASSES,
285        availability: BuiltinIntegerInputAvailability::RunMatOnly,
286        scalar_double: BuiltinIntegerScalarDoubleRule::NotApplicable,
287        notes: "Public interactive GPU support excludes 64-bit integers; RunMat mode preserves them through typed gather before the tolerance boundary.",
288    }];
289
290const TYPED_TOLERANCE_INPUTS: [BuiltinIntegerInputCapability; 1] =
291    [BuiltinIntegerInputCapability {
292        name: "tol_or_DataScale",
293        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
294        availability: BuiltinIntegerInputAvailability::RunMatOnly,
295        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
296        notes: "The documented tolerance and DataScale classes are single and double; RunMat mode additionally accepts typed integers and converts them once into the floating tolerance domain.",
297    }];
298
299const INTEGER_BOOLEAN_OPTION_INPUTS: [BuiltinIntegerInputCapability; 1] =
300    [BuiltinIntegerInputCapability {
301        name: "ByRows_or_OutputAllIndices",
302        classes: &crate::builtins::common::integer_capability::ALL_INTEGER_CLASSES,
303        availability: BuiltinIntegerInputAvailability::Documented,
304        scalar_double: BuiltinIntegerScalarDoubleRule::Allowed,
305        notes: "The documented numeric boolean controls accept only zero or one; resident ByRows and OutputAllIndices forms remain independently restricted.",
306    }];
307
308const INTEGER_CAPABILITIES: [BuiltinIntegerCapabilityDescriptor; 5] = [
309    BuiltinIntegerCapabilityDescriptor {
310        form: "[LIA, LocB] = ismembertol(host_integer_A, host_integer_B, options)",
311        inputs: &HOST_INTEGER_DATA_INPUTS,
312        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
313        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
314        overflow: BuiltinIntegerOverflowRule::NotApplicable,
315        backend: BuiltinIntegerBackendRule::HostOnly,
316        overload: BuiltinIntegerOverloadKind::Multiple,
317        notes: "RunMat-only host integer observations retain authoritative storage until one explicit f64 tolerance/DataScale boundary; LIA is logical and first locations are double.",
318    },
319    BuiltinIntegerCapabilityDescriptor {
320        form: "[LIA, LocB] = ismembertol(resident_integer_A, resident_integer_B, options)",
321        inputs: &GPU_INTEGER_DATA_INPUTS,
322        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
323        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
324        overflow: BuiltinIntegerOverflowRule::NotApplicable,
325        backend: BuiltinIntegerBackendRule::GpuRestricted,
326        overload: BuiltinIntegerOverloadKind::Multiple,
327        notes: "Documented resident integer inputs through 32 bits use typed gather fallback and restore logical/double outputs to the input owner; ByRows and OutputAllIndices remain mode-gated.",
328    },
329    BuiltinIntegerCapabilityDescriptor {
330        form: "[LIA, LocB] = ismembertol(resident_int64_or_uint64_A_or_B, options)",
331        inputs: &GPU_WIDE_INTEGER_DATA_INPUT,
332        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
333        output_class: BuiltinIntegerOutputClassRule::FunctionSpecific,
334        overflow: BuiltinIntegerOverflowRule::NotApplicable,
335        backend: BuiltinIntegerBackendRule::GatherFallback,
336        overload: BuiltinIntegerOverloadKind::Multiple,
337        notes: "Resident 64-bit integer data is a RunMat-only extension and enters the same explicit floating tolerance domain after authoritative typed gather.",
338    },
339    BuiltinIntegerCapabilityDescriptor {
340        form: "LIA = ismembertol(A, B, integer_tol_or_DataScale, options)",
341        inputs: &TYPED_TOLERANCE_INPUTS,
342        computation_domain: BuiltinIntegerComputationDomain::FloatingPoint,
343        output_class: BuiltinIntegerOutputClassRule::Logical,
344        overflow: BuiltinIntegerOverflowRule::NotApplicable,
345        backend: BuiltinIntegerBackendRule::GatherFallback,
346        overload: BuiltinIntegerOverloadKind::StructuralParameter,
347        notes: "Typed integer tolerance controls are RunMat-only and convert once to f64 before threshold arithmetic.",
348    },
349    BuiltinIntegerCapabilityDescriptor {
350        form: "LIA = ismembertol(A, B, ByRows_or_OutputAllIndices=integer_zero_or_one)",
351        inputs: &INTEGER_BOOLEAN_OPTION_INPUTS,
352        computation_domain: BuiltinIntegerComputationDomain::Structural,
353        output_class: BuiltinIntegerOutputClassRule::Logical,
354        overflow: BuiltinIntegerOverflowRule::NotApplicable,
355        backend: BuiltinIntegerBackendRule::GpuRestricted,
356        overload: BuiltinIntegerOverloadKind::StructuralParameter,
357        notes: "Typed integer zero/one boolean controls are documented numeric forms; interactive GPU execution excludes enabled ByRows and OutputAllIndices.",
358    },
359];
360
361pub const ISMEMBERTOL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
362    signatures: &SIGNATURES,
363    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
364    completion_policy: BuiltinCompletionPolicy::Public,
365    errors: &ERRORS,
366};
367
368fn ismembertol_output_type(args: &[Type], ctx: &ResolveContext) -> Type {
369    if literal_by_rows_enabled(ctx) {
370        return match args.first().and_then(type_row_count) {
371            Some(rows) => Type::Logical {
372                shape: Some(vec![rows, Some(1)]),
373            },
374            None => Type::logical(),
375        };
376    }
377    logical_output_type(args, ctx)
378}
379
380fn literal_by_rows_enabled(ctx: &ResolveContext) -> bool {
381    let mut idx = 2usize;
382    if !matches!(ctx.literal_args.get(idx), Some(LiteralValue::String(_))) {
383        idx += 1;
384    }
385    while idx + 1 < ctx.literal_args.len() {
386        if matches!(
387            ctx.literal_args.get(idx),
388            Some(LiteralValue::String(name)) if name.eq_ignore_ascii_case("ByRows")
389        ) {
390            return literal_truthy(ctx.literal_args.get(idx + 1));
391        }
392        idx += 2;
393    }
394    false
395}
396
397fn literal_truthy(value: Option<&LiteralValue>) -> bool {
398    match value {
399        Some(LiteralValue::Bool(flag)) => *flag,
400        Some(LiteralValue::Number(num)) => *num == 1.0,
401        _ => false,
402    }
403}
404
405fn type_row_count(ty: &Type) -> Option<Option<usize>> {
406    match ty {
407        Type::Tensor { shape: Some(shape) } | Type::Logical { shape: Some(shape) } => {
408            if shape.is_empty() {
409                Some(Some(1))
410            } else {
411                Some(shape[0])
412            }
413        }
414        Type::Tensor { shape: None } | Type::Logical { shape: None } => Some(None),
415        Type::Num | Type::Int | Type::Bool => Some(Some(1)),
416        _ => None,
417    }
418}
419
420#[runtime_builtin(
421    name = "ismembertol",
422    category = "array/sorting_sets",
423    summary = "Identify numeric array elements or rows that are within tolerance of another array.",
424    keywords = "ismembertol,membership,tolerance,set,rows,indices",
425    accel = "array_construct",
426    sink = true,
427    type_resolver(ismembertol_output_type),
428    descriptor(crate::builtins::array::sorting_sets::ismembertol::ISMEMBERTOL_DESCRIPTOR),
429    extensions(EXTENSIONS),
430    integer_capabilities(INTEGER_CAPABILITIES),
431    builtin_path = "crate::builtins::array::sorting_sets::ismembertol"
432)]
433async fn ismembertol_builtin(a: Value, b: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
434    let requested_outputs = crate::output_count::current_output_count();
435    if matches!(requested_outputs, Some(count) if count > 2) {
436        return Err(error_with(
437            &ERROR_INVALID_ARGUMENT,
438            "ismembertol: too many output arguments; maximum is 2",
439        ));
440    }
441    let opts = parse_options(&rest)?;
442    ensure_compatibility(&a, &b, &opts)?;
443    let host_cell_output = opts.output_all_indices && requested_outputs == Some(2);
444    let output_provider = (!host_cell_output)
445        .then(|| super::set_output_provider(&a, &b))
446        .flatten();
447    let loc_request = match requested_outputs {
448        Some(0) | Some(1) | None => LocRequest::None,
449        Some(2) if opts.output_all_indices => LocRequest::All,
450        Some(2) => LocRequest::First,
451        Some(_) => unreachable!("excess output count rejected"),
452    };
453    let eval = evaluate_with_options(a, b, opts, loc_request).await?;
454    let mut outputs = match loc_request {
455        LocRequest::None => vec![eval.into_mask_value()],
456        LocRequest::First | LocRequest::All => {
457            let (mask, loc) = eval.into_pair();
458            vec![mask, loc]
459        }
460    };
461    if requested_outputs == Some(0) {
462        outputs.clear();
463    } else {
464        outputs =
465            super::restore_set_outputs(output_provider, NAME, outputs, internal_error_from_string)?;
466    }
467    if requested_outputs.is_some() {
468        Ok(Value::OutputList(outputs))
469    } else {
470        Ok(outputs.pop().expect("ismembertol mask output"))
471    }
472}
473
474pub async fn evaluate(a: Value, b: Value, rest: &[Value]) -> BuiltinResult<IsMemberTolEvaluation> {
475    let opts = parse_options(rest)?;
476    ensure_compatibility(&a, &b, &opts)?;
477    let loc_request = if opts.output_all_indices {
478        LocRequest::All
479    } else {
480        LocRequest::First
481    };
482    evaluate_with_options(a, b, opts, loc_request).await
483}
484
485async fn evaluate_with_options(
486    a: Value,
487    b: Value,
488    opts: IsMemberTolOptions,
489    loc_request: LocRequest,
490) -> BuiltinResult<IsMemberTolEvaluation> {
491    let a = gather_if_gpu(a).await?;
492    let b = gather_if_gpu(b).await?;
493    let tensor_a =
494        tensor::value_into_tensor_for(NAME, a).map_err(|_| error(&ERROR_INVALID_INPUT))?;
495    let tensor_b =
496        tensor::value_into_tensor_for(NAME, b).map_err(|_| error(&ERROR_INVALID_INPUT))?;
497    evaluate_tensors(tensor_a, tensor_b, opts, loc_request)
498}
499
500async fn gather_if_gpu(value: Value) -> BuiltinResult<Value> {
501    match value {
502        Value::GpuTensor(handle) => gpu_helpers::gather_value_async(&Value::GpuTensor(handle))
503            .await
504            .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}"))),
505        other => Ok(other),
506    }
507}
508
509fn evaluate_tensors(
510    a: Tensor,
511    b: Tensor,
512    mut opts: IsMemberTolOptions,
513    loc_request: LocRequest,
514) -> BuiltinResult<IsMemberTolEvaluation> {
515    if opts.tol.is_none() {
516        opts.tol = Some(default_tolerance(a.numeric_dtype(), b.numeric_dtype()));
517    }
518    if opts.by_rows {
519        evaluate_rows(a, b, opts, loc_request)
520    } else {
521        evaluate_elements(a, b, opts, loc_request)
522    }
523}
524
525#[derive(Debug, Clone)]
526struct IsMemberTolOptions {
527    tol: Option<f64>,
528    by_rows: bool,
529    data_scale: Option<Vec<f64>>,
530    output_all_indices: bool,
531    typed_tolerance: bool,
532    typed_data_scale: bool,
533    logical_tolerance: bool,
534    logical_data_scale: bool,
535}
536
537#[derive(Debug, Copy, Clone, Eq, PartialEq)]
538enum LocRequest {
539    None,
540    First,
541    All,
542}
543
544fn parse_options(rest: &[Value]) -> BuiltinResult<IsMemberTolOptions> {
545    let mut opts = IsMemberTolOptions {
546        tol: None,
547        by_rows: false,
548        data_scale: None,
549        output_all_indices: false,
550        typed_tolerance: false,
551        typed_data_scale: false,
552        logical_tolerance: false,
553        logical_data_scale: false,
554    };
555
556    let mut idx = 0usize;
557    if let Some(first) = rest.first() {
558        if option_name(first).is_none() {
559            opts.typed_tolerance = is_typed_integer_value(first);
560            opts.logical_tolerance = is_logical_value(first);
561            opts.tol = Some(parse_positive_scalar(first, "tolerance")?);
562            idx = 1;
563        }
564    }
565
566    let remaining = &rest[idx..];
567    if !remaining.len().is_multiple_of(2) {
568        return Err(error_with(
569            &ERROR_INVALID_ARGUMENT,
570            "ismembertol: name-value arguments must appear in pairs",
571        ));
572    }
573
574    for pair in remaining.chunks_exact(2) {
575        let name = option_name(&pair[0])
576            .ok_or_else(|| {
577                error_with(&ERROR_INVALID_ARGUMENT, "ismembertol: expected option name")
578            })?
579            .trim()
580            .to_ascii_lowercase();
581        match name.as_str() {
582            "byrows" => opts.by_rows = parse_bool_option(&pair[1])?,
583            "outputallindices" => opts.output_all_indices = parse_bool_option(&pair[1])?,
584            "datascale" => {
585                opts.typed_data_scale = is_typed_integer_value(&pair[1]);
586                opts.logical_data_scale = is_logical_value(&pair[1]);
587                opts.data_scale = Some(parse_data_scale(&pair[1])?);
588            }
589            other => {
590                return Err(error_with(
591                    &ERROR_INVALID_ARGUMENT,
592                    format!("ismembertol: unknown option '{other}'"),
593                ))
594            }
595        }
596    }
597
598    Ok(opts)
599}
600
601fn ensure_compatibility(a: &Value, b: &Value, opts: &IsMemberTolOptions) -> BuiltinResult<()> {
602    for value in [a, b] {
603        match value {
604            Value::GpuTensor(handle) if super::is_unsupported_set_gpu_integer(handle) => {
605                crate::compatibility::ensure_builtin_extension_enabled(
606                    &GPU_WIDE_INTEGER_EXTENSION,
607                    NAME,
608                )?;
609            }
610            Value::GpuTensor(_) => {}
611            _ if is_typed_integer_value(value) => {
612                crate::compatibility::ensure_builtin_extension_enabled(
613                    &HOST_INTEGER_DATA_EXTENSION,
614                    NAME,
615                )?;
616            }
617            _ if is_logical_value(value) => {
618                crate::compatibility::ensure_builtin_extension_enabled(
619                    &HOST_LOGICAL_DATA_EXTENSION,
620                    NAME,
621                )?;
622            }
623            _ => {}
624        }
625    }
626    if opts.typed_tolerance || opts.typed_data_scale {
627        crate::compatibility::ensure_builtin_extension_enabled(
628            &TYPED_TOLERANCE_CONTROL_EXTENSION,
629            NAME,
630        )?;
631    }
632    if opts.logical_tolerance || opts.logical_data_scale {
633        crate::compatibility::ensure_builtin_extension_enabled(
634            &LOGICAL_TOLERANCE_CONTROL_EXTENSION,
635            NAME,
636        )?;
637    }
638    if super::set_output_provider(a, b).is_some() && (opts.by_rows || opts.output_all_indices) {
639        crate::compatibility::ensure_builtin_extension_enabled(&GPU_OPTIONS_EXTENSION, NAME)?;
640    }
641    Ok(())
642}
643
644fn is_typed_integer_value(value: &Value) -> bool {
645    matches!(value, Value::Int(_))
646        || matches!(value, Value::Tensor(tensor) if tensor.integer_storage().is_some())
647        || matches!(
648            value,
649            Value::GpuTensor(handle)
650                if runmat_accelerate_api::handle_integer_type(handle).is_some()
651        )
652}
653
654fn is_logical_value(value: &Value) -> bool {
655    matches!(value, Value::Bool(_) | Value::LogicalArray(_))
656        || matches!(
657            value,
658            Value::GpuTensor(handle) if runmat_accelerate_api::handle_is_logical(handle)
659        )
660}
661
662fn option_name(value: &Value) -> Option<String> {
663    match value {
664        Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
665            tensor::value_to_string(value)
666        }
667        _ => None,
668    }
669}
670
671fn evaluate_elements(
672    a: Tensor,
673    b: Tensor,
674    opts: IsMemberTolOptions,
675    loc_request: LocRequest,
676) -> BuiltinResult<IsMemberTolEvaluation> {
677    let a_shape = a.shape.clone();
678    let a_values = materialize_tolerance_values(a)?;
679    let b_values = materialize_tolerance_values(b)?;
680    let tol = opts.tol.expect("defaulted tolerance");
681    let scale = opts
682        .data_scale
683        .as_ref()
684        .map(|values| {
685            if values.len() == 1 {
686                Ok(values[0])
687            } else {
688                Err(error_with(
689                    &ERROR_INVALID_ARGUMENT,
690                    "ismembertol: DataScale must be scalar unless ByRows is true",
691                ))
692            }
693        })
694        .transpose()?
695        .unwrap_or_else(|| default_element_scale(&a_values, &b_values));
696    let threshold = tol * scale.abs();
697
698    let mut mask_data = Vec::with_capacity(a_values.len());
699    let mut loc_data = match loc_request {
700        LocRequest::First => Vec::with_capacity(a_values.len()),
701        LocRequest::None | LocRequest::All => Vec::new(),
702    };
703    let mut all_cells = match loc_request {
704        LocRequest::All => Vec::with_capacity(a_values.len()),
705        LocRequest::None | LocRequest::First => Vec::new(),
706    };
707
708    for &value in a_values.iter() {
709        match loc_request {
710            LocRequest::None => {
711                mask_data.push(
712                    if first_element_match(value, &b_values, threshold).is_some() {
713                        1
714                    } else {
715                        0
716                    },
717                );
718            }
719            LocRequest::First => {
720                let first = first_element_match(value, &b_values, threshold);
721                mask_data.push(if first.is_some() { 1 } else { 0 });
722                loc_data.push(first.unwrap_or(0) as f64);
723            }
724            LocRequest::All => {
725                let matches = all_element_matches(value, &b_values, threshold);
726                mask_data.push(if matches.is_empty() { 0 } else { 1 });
727                all_cells.push(indices_cell(matches)?);
728            }
729        }
730    }
731
732    let mask = LogicalArray::new(mask_data, a_shape.clone())
733        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))?;
734    let loc = match loc_request {
735        LocRequest::None => None,
736        LocRequest::First => Some(LocOutput::First(
737            Tensor::new(loc_data, a_shape.clone())
738                .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))?,
739        )),
740        LocRequest::All => Some(LocOutput::All(cell_array_from_linear_matches(
741            all_cells, a_shape,
742        )?)),
743    };
744    Ok(IsMemberTolEvaluation { mask, loc })
745}
746
747fn evaluate_rows(
748    a: Tensor,
749    b: Tensor,
750    opts: IsMemberTolOptions,
751    loc_request: LocRequest,
752) -> BuiltinResult<IsMemberTolEvaluation> {
753    let (rows_a, cols_a) = rows_cols(&a)?;
754    let (rows_b, cols_b) = rows_cols(&b)?;
755    if cols_a != cols_b {
756        return Err(error(&ERROR_ROWS_COLUMN_MISMATCH));
757    }
758    let a_values = materialize_tolerance_values(a)?;
759    let b_values = materialize_tolerance_values(b)?;
760    let tol = opts.tol.expect("defaulted tolerance");
761    let scales = row_scales(
762        &a_values,
763        rows_a,
764        &b_values,
765        rows_b,
766        opts.data_scale.as_deref(),
767        cols_a,
768    )?;
769    let thresholds: Vec<f64> = scales.iter().map(|scale| tol * scale.abs()).collect();
770
771    let mut mask_data = vec![0u8; rows_a];
772    let mut loc_data = match loc_request {
773        LocRequest::First => vec![0.0f64; rows_a],
774        LocRequest::None | LocRequest::All => Vec::new(),
775    };
776    let mut all_cells = match loc_request {
777        LocRequest::All => Vec::with_capacity(rows_a),
778        LocRequest::None | LocRequest::First => Vec::new(),
779    };
780
781    for row_a in 0..rows_a {
782        match loc_request {
783            LocRequest::None => {
784                mask_data[row_a] = if first_row_match(
785                    &a_values,
786                    row_a,
787                    rows_a,
788                    &b_values,
789                    rows_b,
790                    cols_a,
791                    &thresholds,
792                )
793                .is_some()
794                {
795                    1
796                } else {
797                    0
798                };
799            }
800            LocRequest::First => {
801                let first = first_row_match(
802                    &a_values,
803                    row_a,
804                    rows_a,
805                    &b_values,
806                    rows_b,
807                    cols_a,
808                    &thresholds,
809                );
810                mask_data[row_a] = if first.is_some() { 1 } else { 0 };
811                loc_data[row_a] = first.unwrap_or(0) as f64;
812            }
813            LocRequest::All => {
814                let matches = all_row_matches(
815                    &a_values,
816                    row_a,
817                    rows_a,
818                    &b_values,
819                    rows_b,
820                    cols_a,
821                    &thresholds,
822                );
823                mask_data[row_a] = if matches.is_empty() { 0 } else { 1 };
824                all_cells.push(indices_cell(matches)?);
825            }
826        }
827    }
828
829    let shape = vec![rows_a, 1];
830    let mask = LogicalArray::new(mask_data, shape.clone())
831        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))?;
832    let loc = match loc_request {
833        LocRequest::None => None,
834        LocRequest::First => Some(LocOutput::First(
835            Tensor::new(loc_data, shape)
836                .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))?,
837        )),
838        LocRequest::All => Some(LocOutput::All(cell_array_from_linear_matches(
839            all_cells, shape,
840        )?)),
841    };
842    Ok(IsMemberTolEvaluation { mask, loc })
843}
844
845fn materialize_tolerance_values(tensor: Tensor) -> BuiltinResult<Vec<f64>> {
846    // Tolerance and DataScale arithmetic have one intentional f64 computation domain. Native single and exact integer storage remain authoritative until this boundary.
847    tensor
848        .into_numeric_storage()
849        .map(|storage| storage.materialize_f64())
850        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))
851}
852
853fn first_element_match(value: f64, candidates: &[f64], threshold: f64) -> Option<usize> {
854    candidates.iter().enumerate().find_map(|(idx, &candidate)| {
855        within_tolerance(value, candidate, threshold).then_some(idx + 1)
856    })
857}
858
859fn all_element_matches(value: f64, candidates: &[f64], threshold: f64) -> Vec<usize> {
860    candidates
861        .iter()
862        .enumerate()
863        .filter_map(|(idx, &candidate)| {
864            within_tolerance(value, candidate, threshold).then_some(idx + 1)
865        })
866        .collect()
867}
868
869fn first_row_match(
870    a: &[f64],
871    row_a: usize,
872    rows_a: usize,
873    b: &[f64],
874    rows_b: usize,
875    cols: usize,
876    thresholds: &[f64],
877) -> Option<usize> {
878    (0..rows_b).find_map(|row_b| {
879        rows_match(a, row_a, rows_a, b, row_b, rows_b, cols, thresholds).then_some(row_b + 1)
880    })
881}
882
883fn all_row_matches(
884    a: &[f64],
885    row_a: usize,
886    rows_a: usize,
887    b: &[f64],
888    rows_b: usize,
889    cols: usize,
890    thresholds: &[f64],
891) -> Vec<usize> {
892    (0..rows_b)
893        .filter_map(|row_b| {
894            rows_match(a, row_a, rows_a, b, row_b, rows_b, cols, thresholds).then_some(row_b + 1)
895        })
896        .collect()
897}
898
899fn rows_match(
900    a: &[f64],
901    row_a: usize,
902    rows_a: usize,
903    b: &[f64],
904    row_b: usize,
905    rows_b: usize,
906    cols: usize,
907    thresholds: &[f64],
908) -> bool {
909    for col in 0..cols {
910        let threshold = thresholds[col];
911        if threshold.is_infinite() {
912            continue;
913        }
914        let lhs = a[row_a + col * rows_a];
915        let rhs = b[row_b + col * rows_b];
916        if !within_tolerance(lhs, rhs, threshold) {
917            return false;
918        }
919    }
920    true
921}
922
923fn within_tolerance(lhs: f64, rhs: f64, threshold: f64) -> bool {
924    if lhs.is_nan() || rhs.is_nan() {
925        return false;
926    }
927    if lhs == rhs {
928        return true;
929    }
930    (lhs - rhs).abs() <= threshold
931}
932
933fn default_tolerance(a: NumericDType, b: NumericDType) -> f64 {
934    if matches!(a, NumericDType::F32) || matches!(b, NumericDType::F32) {
935        DEFAULT_TOL_SINGLE
936    } else {
937        DEFAULT_TOL_DOUBLE
938    }
939}
940
941fn default_element_scale(a: &[f64], b: &[f64]) -> f64 {
942    a.iter()
943        .chain(b.iter())
944        .filter_map(|value| (!value.is_nan()).then_some(value.abs()))
945        .fold(0.0, f64::max)
946}
947
948fn row_scales(
949    a: &[f64],
950    rows_a: usize,
951    b: &[f64],
952    rows_b: usize,
953    supplied: Option<&[f64]>,
954    cols: usize,
955) -> BuiltinResult<Vec<f64>> {
956    if let Some(values) = supplied {
957        if values.len() == 1 {
958            return Ok(vec![values[0]; cols]);
959        }
960        if values.len() == cols {
961            return Ok(values.to_vec());
962        }
963        return Err(error_with(
964            &ERROR_INVALID_ARGUMENT,
965            "ismembertol: DataScale vector length must match the number of columns",
966        ));
967    }
968
969    let mut scales = vec![0.0f64; cols];
970    for (col, scale) in scales.iter_mut().enumerate() {
971        for row in 0..rows_a {
972            let value = a[row + col * rows_a];
973            if !value.is_nan() {
974                *scale = (*scale).max(value.abs());
975            }
976        }
977        for row in 0..rows_b {
978            let value = b[row + col * rows_b];
979            if !value.is_nan() {
980                *scale = (*scale).max(value.abs());
981            }
982        }
983    }
984    Ok(scales)
985}
986
987fn rows_cols(tensor: &Tensor) -> BuiltinResult<(usize, usize)> {
988    match tensor.shape.len() {
989        0 => Ok((1, 1)),
990        1 => Ok((tensor.shape[0], 1)),
991        2 => Ok((tensor.shape[0], tensor.shape[1])),
992        _ => Err(error_with(
993            &ERROR_INVALID_ARGUMENT,
994            "ismembertol: ByRows requires 2-D arrays",
995        )),
996    }
997}
998
999fn indices_cell(indices: Vec<usize>) -> BuiltinResult<Value> {
1000    if indices.is_empty() {
1001        return Ok(Value::Tensor(Tensor::new(vec![0.0], vec![1, 1]).map_err(
1002            |e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")),
1003        )?));
1004    }
1005    let data = indices
1006        .into_iter()
1007        .map(|idx| idx as f64)
1008        .collect::<Vec<_>>();
1009    let len = data.len();
1010    Tensor::new(data, vec![len, 1])
1011        .map(Value::Tensor)
1012        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))
1013}
1014
1015fn cell_array_from_linear_matches(
1016    cells_column_major: Vec<Value>,
1017    shape: Vec<usize>,
1018) -> BuiltinResult<CellArray> {
1019    let cells = if shape.len() == 2 && shape[0] > 1 && shape[1] > 1 {
1020        let rows = shape[0];
1021        let cols = shape[1];
1022        let mut row_major = Vec::with_capacity(cells_column_major.len());
1023        for row in 0..rows {
1024            for col in 0..cols {
1025                row_major.push(cells_column_major[row + col * rows].clone());
1026            }
1027        }
1028        row_major
1029    } else {
1030        cells_column_major
1031    };
1032    CellArray::new_with_shape(cells, shape)
1033        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))
1034}
1035
1036fn parse_positive_scalar(value: &Value, label: &str) -> BuiltinResult<f64> {
1037    let scalar = numeric_scalar(value).ok_or_else(|| {
1038        error_with(
1039            &ERROR_INVALID_ARGUMENT,
1040            format!("ismembertol: {label} must be a positive real scalar"),
1041        )
1042    })?;
1043    if !scalar.is_finite() || scalar <= 0.0 {
1044        return Err(error_with(
1045            &ERROR_INVALID_ARGUMENT,
1046            format!("ismembertol: {label} must be a positive real scalar"),
1047        ));
1048    }
1049    Ok(scalar)
1050}
1051
1052fn parse_bool_option(value: &Value) -> BuiltinResult<bool> {
1053    match numeric_scalar(value) {
1054        Some(0.0) => Ok(false),
1055        Some(1.0) => Ok(true),
1056        _ => Err(error_with(
1057            &ERROR_INVALID_ARGUMENT,
1058            "ismembertol: boolean options must be true/false or 0/1",
1059        )),
1060    }
1061}
1062
1063fn parse_data_scale(value: &Value) -> BuiltinResult<Vec<f64>> {
1064    match value {
1065        Value::Num(n) => validate_data_scale(vec![*n]),
1066        Value::Int(i) => validate_data_scale(vec![i.to_f64()]),
1067        Value::Bool(flag) => validate_data_scale(vec![if *flag { 1.0 } else { 0.0 }]),
1068        Value::Tensor(tensor) => validate_data_scale(tensor::tensor_values_f64(tensor)),
1069        Value::LogicalArray(logical) => validate_data_scale(
1070            logical
1071                .data
1072                .iter()
1073                .map(|&v| if v != 0 { 1.0 } else { 0.0 })
1074                .collect(),
1075        ),
1076        _ => Err(error_with(
1077            &ERROR_INVALID_ARGUMENT,
1078            "ismembertol: DataScale must be a numeric scalar or vector",
1079        )),
1080    }
1081}
1082
1083fn validate_data_scale(values: Vec<f64>) -> BuiltinResult<Vec<f64>> {
1084    if values.is_empty() || values.iter().any(|value| value.is_nan() || *value < 0.0) {
1085        return Err(error_with(
1086            &ERROR_INVALID_ARGUMENT,
1087            "ismembertol: DataScale values must be non-negative or Inf",
1088        ));
1089    }
1090    Ok(values)
1091}
1092
1093fn numeric_scalar(value: &Value) -> Option<f64> {
1094    match value {
1095        Value::Num(n) => Some(*n),
1096        Value::Int(i) => Some(i.to_f64()),
1097        Value::Bool(flag) => Some(if *flag { 1.0 } else { 0.0 }),
1098        Value::Tensor(t) if tensor::is_scalar_tensor(t) => {
1099            tensor::tensor_values_f64(t).first().copied()
1100        }
1101        Value::LogicalArray(logical) if logical.data.len() == 1 => {
1102            Some(if logical.data[0] != 0 { 1.0 } else { 0.0 })
1103        }
1104        _ => None,
1105    }
1106}
1107
1108#[derive(Debug, Clone)]
1109pub struct IsMemberTolEvaluation {
1110    mask: LogicalArray,
1111    loc: Option<LocOutput>,
1112}
1113
1114#[derive(Debug, Clone)]
1115enum LocOutput {
1116    First(Tensor),
1117    All(CellArray),
1118}
1119
1120impl IsMemberTolEvaluation {
1121    pub fn into_mask_value(self) -> Value {
1122        logical_array_into_value(self.mask)
1123    }
1124
1125    pub fn mask_value(&self) -> Value {
1126        logical_array_into_value(self.mask.clone())
1127    }
1128
1129    pub fn into_pair(self) -> (Value, Value) {
1130        let mask = logical_array_into_value(self.mask);
1131        let loc = match self.loc.expect("LocB was not requested") {
1132            LocOutput::First(tensor) => tensor::tensor_into_value(tensor),
1133            LocOutput::All(cell) => Value::Cell(cell),
1134        };
1135        (mask, loc)
1136    }
1137}
1138
1139fn logical_array_into_value(logical: LogicalArray) -> Value {
1140    if logical.data.len() == 1 {
1141        Value::Bool(logical.data[0] != 0)
1142    } else {
1143        Value::LogicalArray(logical)
1144    }
1145}
1146
1147fn error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
1148    error_with(error, error.message)
1149}
1150
1151fn error_with(error: &'static BuiltinErrorDescriptor, message: impl Into<String>) -> RuntimeError {
1152    let mut builder = build_runtime_error(message).with_builtin(NAME);
1153    if let Some(identifier) = error.identifier {
1154        builder = builder.with_identifier(identifier);
1155    }
1156    builder.build()
1157}
1158
1159fn internal_error_from_string(message: String) -> RuntimeError {
1160    error_with(&ERROR_INTERNAL, message)
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    use super::*;
1166    use crate::builtins::common::test_support;
1167    use futures::executor::block_on;
1168    use runmat_builtins::{LiteralValue, ResolveContext, Type};
1169    use runmat_value::IntegerStorage;
1170
1171    fn eval(a: Value, b: Value, rest: &[Value]) -> BuiltinResult<IsMemberTolEvaluation> {
1172        block_on(evaluate(a, b, rest))
1173    }
1174
1175    fn builtin(a: Value, b: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1176        block_on(ismembertol_builtin(a, b, rest))
1177    }
1178
1179    #[test]
1180    fn type_resolver_returns_logical_shape() {
1181        assert_eq!(
1182            ismembertol_output_type(
1183                &[Type::Tensor {
1184                    shape: Some(vec![Some(1), Some(3)])
1185                }],
1186                &ResolveContext::new(Vec::new()),
1187            ),
1188            Type::Logical {
1189                shape: Some(vec![Some(1), Some(3)])
1190            }
1191        );
1192    }
1193
1194    #[test]
1195    fn type_resolver_returns_row_shape_for_literal_by_rows() {
1196        assert_eq!(
1197            ismembertol_output_type(
1198                &[Type::Tensor {
1199                    shape: Some(vec![Some(4), Some(3)])
1200                }],
1201                &ResolveContext::new(vec![
1202                    LiteralValue::Unknown,
1203                    LiteralValue::Unknown,
1204                    LiteralValue::Number(0.01),
1205                    LiteralValue::String("ByRows".to_string()),
1206                    LiteralValue::Bool(true),
1207                ]),
1208            ),
1209            Type::Logical {
1210                shape: Some(vec![Some(4), Some(1)])
1211            }
1212        );
1213    }
1214
1215    #[test]
1216    fn default_tolerance_uses_scaled_double_data() {
1217        let a = Tensor::new(vec![0.1, 1.0e10], vec![1, 2]).unwrap();
1218        let b = Tensor::new(vec![0.1 + 1.0e-14, 1.0e10 + 5.0e-3], vec![1, 2]).unwrap();
1219        let result = eval(Value::Tensor(a), Value::Tensor(b), &[]).unwrap();
1220        assert_eq!(result.mask.data, vec![1, 1]);
1221    }
1222
1223    #[test]
1224    fn default_tolerance_uses_native_single_class_before_materialization() {
1225        let single_a = Tensor::from_f32(vec![1.0], vec![1, 1]).unwrap();
1226        let single_b = Tensor::from_f32(vec![1.0 + 5.0e-7], vec![1, 1]).unwrap();
1227        let single = eval(Value::Tensor(single_a), Value::Tensor(single_b), &[]).unwrap();
1228        assert_eq!(single.mask_value(), Value::Bool(true));
1229
1230        let double_a = Tensor::new(vec![1.0], vec![1, 1]).unwrap();
1231        let double_b = Tensor::new(vec![1.0 + 5.0e-7], vec![1, 1]).unwrap();
1232        let double = eval(Value::Tensor(double_a), Value::Tensor(double_b), &[]).unwrap();
1233        assert_eq!(double.mask_value(), Value::Bool(false));
1234    }
1235
1236    #[test]
1237    fn data_scale_one_uses_absolute_tolerance() {
1238        let a = Tensor::new(vec![1.0e10], vec![1, 1]).unwrap();
1239        let b = Tensor::new(vec![1.0e10 + 5.0e-3], vec![1, 1]).unwrap();
1240        let result = eval(
1241            Value::Tensor(a),
1242            Value::Tensor(b),
1243            &[
1244                Value::Num(1.0e-6),
1245                Value::from("DataScale"),
1246                Value::Num(1.0),
1247            ],
1248        )
1249        .unwrap();
1250        assert_eq!(result.mask_value(), Value::Bool(false));
1251    }
1252
1253    #[test]
1254    fn typed_integer_inputs_are_compared_from_exact_storage() {
1255        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1256        let a = Tensor::new_integer(IntegerStorage::I16(vec![10, 20, 30]), vec![1, 3]).unwrap();
1257        let b = Tensor::new_integer(IntegerStorage::I16(vec![9, 31]), vec![1, 2]).unwrap();
1258
1259        let result = eval(
1260            Value::Tensor(a),
1261            Value::Tensor(b),
1262            &[Value::Num(0.2), Value::from("DataScale"), Value::Num(10.0)],
1263        )
1264        .unwrap();
1265
1266        assert_eq!(result.mask.data, vec![1, 0, 1]);
1267    }
1268
1269    #[test]
1270    fn wide_integers_enter_explicit_f64_tolerance_domain() {
1271        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1272        let a = Tensor::new_integer(IntegerStorage::U64(vec![9_007_199_254_740_993]), vec![1, 1])
1273            .unwrap();
1274        let b = Tensor::new_integer(IntegerStorage::U64(vec![9_007_199_254_740_992]), vec![1, 1])
1275            .unwrap();
1276
1277        let result = eval(
1278            Value::Tensor(a),
1279            Value::Tensor(b),
1280            &[
1281                Value::Num(1.0e-12),
1282                Value::from("DataScale"),
1283                Value::Num(1.0),
1284            ],
1285        )
1286        .unwrap();
1287
1288        assert_eq!(result.mask_value(), Value::Bool(true));
1289    }
1290
1291    #[test]
1292    fn typed_integer_tolerance_and_datascale_read_exact_storage() {
1293        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1294        let a = Tensor::new(vec![10.0, 20.0], vec![1, 2]).unwrap();
1295        let b = Tensor::new(vec![11.0, 24.0], vec![1, 2]).unwrap();
1296        let tolerance = Tensor::new_integer(IntegerStorage::U16(vec![1]), vec![1, 1]).unwrap();
1297        let scale = Tensor::new_integer(IntegerStorage::U16(vec![1, 4]), vec![1, 2]).unwrap();
1298
1299        let result = eval(
1300            Value::Tensor(a),
1301            Value::Tensor(b),
1302            &[
1303                Value::Tensor(tolerance),
1304                Value::from("DataScale"),
1305                Value::Tensor(scale),
1306                Value::from("ByRows"),
1307                Value::Bool(true),
1308            ],
1309        )
1310        .unwrap();
1311
1312        assert_eq!(result.mask.data, vec![1]);
1313    }
1314
1315    #[test]
1316    fn loc_returns_first_matching_index() {
1317        let a = Tensor::new(vec![1.0, 2.0, 4.0], vec![1, 3]).unwrap();
1318        let b = Tensor::new(vec![2.01, 2.02, 4.2], vec![1, 3]).unwrap();
1319        let (_, loc) = eval(Value::Tensor(a), Value::Tensor(b), &[Value::Num(0.05)])
1320            .unwrap()
1321            .into_pair();
1322        match loc {
1323            Value::Tensor(tensor) => assert_eq!(tensor.materialize_f64(), vec![0.0, 1.0, 3.0]),
1324            other => panic!("expected tensor loc, got {other:?}"),
1325        }
1326    }
1327
1328    #[test]
1329    fn mask_only_request_does_not_materialize_locations() {
1330        let a = Tensor::new(vec![2.0, 9.0], vec![1, 2]).unwrap();
1331        let b = Tensor::new(vec![1.99, 2.01, 9.5], vec![1, 3]).unwrap();
1332        let opts = parse_options(&[
1333            Value::Num(0.01),
1334            Value::from("OutputAllIndices"),
1335            Value::Bool(true),
1336        ])
1337        .unwrap();
1338        let result = block_on(evaluate_with_options(
1339            Value::Tensor(a),
1340            Value::Tensor(b),
1341            opts,
1342            LocRequest::None,
1343        ))
1344        .unwrap();
1345        assert_eq!(result.mask.data, vec![1, 0]);
1346        assert!(result.loc.is_none());
1347    }
1348
1349    #[test]
1350    fn output_all_indices_returns_cell_array() {
1351        let a = Tensor::new(vec![2.0, 9.0], vec![1, 2]).unwrap();
1352        let b = Tensor::new(vec![1.99, 2.01, 9.5], vec![1, 3]).unwrap();
1353        let (_, loc) = eval(
1354            Value::Tensor(a),
1355            Value::Tensor(b),
1356            &[
1357                Value::Num(0.01),
1358                Value::from("OutputAllIndices"),
1359                Value::Bool(true),
1360            ],
1361        )
1362        .unwrap()
1363        .into_pair();
1364        let Value::Cell(cell) = loc else {
1365            panic!("expected cell loc");
1366        };
1367        assert_eq!(cell.shape, vec![1, 2]);
1368        match &cell.data[0] {
1369            Value::Tensor(indices) => assert_eq!(indices.materialize_f64(), vec![1.0, 2.0]),
1370            other => panic!("expected tensor indices, got {other:?}"),
1371        }
1372        match &cell.data[1] {
1373            Value::Tensor(indices) => assert_eq!(indices.materialize_f64(), vec![0.0]),
1374            other => panic!("expected tensor zero indices, got {other:?}"),
1375        }
1376    }
1377
1378    #[test]
1379    fn output_all_indices_matrix_cells_follow_public_cell_indexing() {
1380        let a = Tensor::new(vec![1.0, 3.0, 2.0, 4.0], vec![2, 2]).unwrap();
1381        let b = Tensor::new(vec![10.0, 2.0, 3.0, 20.0], vec![1, 4]).unwrap();
1382        let (_, loc) = eval(
1383            Value::Tensor(a),
1384            Value::Tensor(b),
1385            &[
1386                Value::Num(1.0e-12),
1387                Value::from("OutputAllIndices"),
1388                Value::Bool(true),
1389            ],
1390        )
1391        .unwrap()
1392        .into_pair();
1393        let Value::Cell(cell) = loc else {
1394            panic!("expected cell loc");
1395        };
1396        assert_eq!(cell.shape, vec![2, 2]);
1397        let top_right = cell.get(0, 1).unwrap();
1398        match top_right {
1399            Value::Tensor(indices) => assert_eq!(indices.materialize_f64(), vec![2.0]),
1400            other => panic!("expected tensor indices, got {other:?}"),
1401        }
1402        let bottom_left = cell.get(1, 0).unwrap();
1403        match bottom_left {
1404            Value::Tensor(indices) => assert_eq!(indices.materialize_f64(), vec![3.0]),
1405            other => panic!("expected tensor indices, got {other:?}"),
1406        }
1407    }
1408
1409    #[test]
1410    fn by_rows_uses_column_scales_and_inf_ignores_column() {
1411        let a = Tensor::new(vec![0.0, 10.0, 0.5, 20.0], vec![2, 2]).unwrap();
1412        let b = Tensor::new(vec![0.05, 10.2, 999.0, -999.0], vec![2, 2]).unwrap();
1413        let result = eval(
1414            Value::Tensor(a),
1415            Value::Tensor(b),
1416            &[
1417                Value::Num(0.1),
1418                Value::from("ByRows"),
1419                Value::Bool(true),
1420                Value::from("DataScale"),
1421                Value::Tensor(Tensor::new(vec![1.0, f64::INFINITY], vec![1, 2]).unwrap()),
1422            ],
1423        )
1424        .unwrap();
1425        assert_eq!(result.mask.data, vec![1, 0]);
1426        let (_, loc) = result.into_pair();
1427        match loc {
1428            Value::Tensor(tensor) => {
1429                assert_eq!(tensor.shape, vec![2, 1]);
1430                assert_eq!(tensor.materialize_f64(), vec![1.0, 0.0]);
1431            }
1432            other => panic!("expected tensor loc, got {other:?}"),
1433        }
1434    }
1435
1436    #[test]
1437    fn by_rows_typed_integer_inputs_are_compared_from_exact_storage() {
1438        let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1439        let a = Tensor::new_integer(IntegerStorage::U16(vec![10, 20, 30, 40]), vec![2, 2]).unwrap();
1440        let b = Tensor::new_integer(IntegerStorage::U16(vec![11, 21, 31, 41]), vec![2, 2]).unwrap();
1441
1442        let result = eval(
1443            Value::Tensor(a),
1444            Value::Tensor(b),
1445            &[
1446                Value::Num(0.2),
1447                Value::from("ByRows"),
1448                Value::Bool(true),
1449                Value::from("DataScale"),
1450                Value::Num(10.0),
1451            ],
1452        )
1453        .unwrap();
1454
1455        assert_eq!(result.mask.data, vec![1, 1]);
1456    }
1457
1458    #[test]
1459    fn by_rows_output_all_indices_returns_row_cells() {
1460        let a = Tensor::new(vec![0.0, 1.0], vec![1, 2]).unwrap();
1461        let b = Tensor::new(vec![0.01, -0.01, 5.0, 1.0, 1.0, 5.0], vec![3, 2]).unwrap();
1462        let (_, loc) = eval(
1463            Value::Tensor(a),
1464            Value::Tensor(b),
1465            &[
1466                Value::Num(0.02),
1467                Value::from("ByRows"),
1468                Value::Bool(true),
1469                Value::from("OutputAllIndices"),
1470                Value::Bool(true),
1471            ],
1472        )
1473        .unwrap()
1474        .into_pair();
1475        let Value::Cell(cell) = loc else {
1476            panic!("expected cell loc");
1477        };
1478        assert_eq!(cell.shape, vec![1, 1]);
1479        match &cell.data[0] {
1480            Value::Tensor(indices) => assert_eq!(indices.materialize_f64(), vec![1.0, 2.0]),
1481            other => panic!("expected tensor indices, got {other:?}"),
1482        }
1483    }
1484
1485    #[test]
1486    fn rejects_bad_options_and_shape_mismatch() {
1487        let err = parse_options(&[Value::Num(-1.0)]).unwrap_err();
1488        assert_eq!(
1489            err.identifier.as_deref(),
1490            Some("RunMat:ismembertol:InvalidArgument")
1491        );
1492
1493        let a = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
1494        let b = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1495        let err = eval(
1496            Value::Tensor(a),
1497            Value::Tensor(b),
1498            &[Value::from("ByRows"), Value::Bool(true)],
1499        )
1500        .unwrap_err();
1501        assert_eq!(
1502            err.identifier.as_deref(),
1503            Some("RunMat:ismembertol:RowsColumnMismatch")
1504        );
1505    }
1506
1507    #[test]
1508    fn host_integer_logical_and_typed_tolerance_forms_are_mode_gated() {
1509        let integer = Tensor::new_integer(IntegerStorage::I16(vec![1, 2]), vec![2, 1]).unwrap();
1510        {
1511            let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
1512            let error = eval(
1513                Value::Tensor(integer.clone()),
1514                Value::Tensor(integer.clone()),
1515                &[],
1516            )
1517            .expect_err("host integer data must be gated");
1518            assert_eq!(
1519                error.identifier(),
1520                HOST_INTEGER_DATA_EXTENSION.error_identifier
1521            );
1522
1523            let error = eval(Value::Bool(true), Value::Bool(true), &[])
1524                .expect_err("host logical data must be gated");
1525            assert_eq!(
1526                error.identifier(),
1527                HOST_LOGICAL_DATA_EXTENSION.error_identifier
1528            );
1529
1530            let tolerance = Tensor::new_integer(IntegerStorage::U8(vec![1]), vec![1, 1]).unwrap();
1531            let error = eval(
1532                Value::Num(1.0),
1533                Value::Num(1.0),
1534                &[Value::Tensor(tolerance)],
1535            )
1536            .expect_err("typed tolerance must be gated");
1537            assert_eq!(
1538                error.identifier(),
1539                TYPED_TOLERANCE_CONTROL_EXTENSION.error_identifier
1540            );
1541
1542            let error = eval(
1543                Value::Num(1.0),
1544                Value::Num(1.0),
1545                &[Value::from("DataScale"), Value::Bool(true)],
1546            )
1547            .expect_err("logical DataScale must be gated");
1548            assert_eq!(
1549                error.identifier(),
1550                LOGICAL_TOLERANCE_CONTROL_EXTENSION.error_identifier
1551            );
1552        }
1553        {
1554            let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1555            assert_eq!(
1556                eval(Value::Tensor(integer.clone()), Value::Tensor(integer), &[])
1557                    .unwrap()
1558                    .mask,
1559                LogicalArray::new(vec![1, 1], vec![2, 1]).unwrap()
1560            );
1561            assert_eq!(
1562                eval(Value::Bool(true), Value::Bool(true), &[])
1563                    .unwrap()
1564                    .mask_value(),
1565                Value::Bool(true)
1566            );
1567        }
1568    }
1569
1570    #[test]
1571    fn documented_resident_integer_outputs_restore_and_excess_arity_rejects() {
1572        test_support::with_test_provider(|provider| {
1573            let a = Tensor::new_integer(IntegerStorage::I32(vec![1, 3]), vec![2, 1]).unwrap();
1574            let b = Tensor::new_integer(IntegerStorage::I32(vec![1, 2]), vec![2, 1]).unwrap();
1575            let a = Value::GpuTensor(gpu_helpers::upload_tensor(provider, &a).unwrap());
1576            let b = Value::GpuTensor(gpu_helpers::upload_tensor(provider, &b).unwrap());
1577            let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
1578            let _outputs = crate::output_count::push_output_count(Some(2));
1579            let Value::OutputList(outputs) = builtin(a, b, Vec::new()).unwrap() else {
1580                panic!("expected output list");
1581            };
1582            assert_eq!(outputs.len(), 2);
1583            let Value::GpuTensor(mask) = &outputs[0] else {
1584                panic!("expected resident logical mask");
1585            };
1586            assert!(runmat_accelerate_api::handle_is_logical(mask));
1587            assert!(matches!(outputs[1], Value::GpuTensor(_)));
1588            assert_eq!(
1589                test_support::gather(outputs[0].clone())
1590                    .unwrap()
1591                    .materialize_f64(),
1592                vec![1.0, 0.0]
1593            );
1594            assert_eq!(
1595                test_support::gather(outputs[1].clone())
1596                    .unwrap()
1597                    .materialize_f64(),
1598                vec![1.0, 0.0]
1599            );
1600        });
1601
1602        let _outputs = crate::output_count::push_output_count(Some(3));
1603        let error = builtin(Value::Num(1.0), Value::Num(1.0), Vec::new())
1604            .expect_err("excess outputs must reject");
1605        assert_eq!(error.identifier(), ERROR_INVALID_ARGUMENT.identifier);
1606    }
1607
1608    #[test]
1609    fn resident_wide_integer_and_restricted_options_are_mode_gated_before_gather() {
1610        test_support::with_test_provider(|provider| {
1611            let wide = Tensor::new_integer(IntegerStorage::U64(vec![1, 2]), vec![2, 1]).unwrap();
1612            let wide_handle = gpu_helpers::upload_tensor(provider, &wide).unwrap();
1613            {
1614                let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
1615                let error = eval(
1616                    Value::GpuTensor(wide_handle.clone()),
1617                    Value::GpuTensor(wide_handle.clone()),
1618                    &[],
1619                )
1620                .expect_err("resident uint64 must be gated");
1621                assert_eq!(
1622                    error.identifier(),
1623                    GPU_WIDE_INTEGER_EXTENSION.error_identifier
1624                );
1625            }
1626            {
1627                let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1628                assert_eq!(
1629                    eval(
1630                        Value::GpuTensor(wide_handle.clone()),
1631                        Value::GpuTensor(wide_handle),
1632                        &[]
1633                    )
1634                    .unwrap()
1635                    .mask
1636                    .data,
1637                    vec![1, 1]
1638                );
1639            }
1640
1641            let data = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1642            let data_handle = gpu_helpers::upload_tensor(provider, &data).unwrap();
1643            let options = [
1644                Value::from("ByRows"),
1645                Value::Int(runmat_value::IntValue::I8(1)),
1646            ];
1647            {
1648                let _compat = crate::compatibility::push_runmat_extensions_enabled(false);
1649                let error = eval(
1650                    Value::GpuTensor(data_handle.clone()),
1651                    Value::GpuTensor(data_handle.clone()),
1652                    &options,
1653                )
1654                .expect_err("resident ByRows must be gated");
1655                assert_eq!(error.identifier(), GPU_OPTIONS_EXTENSION.error_identifier);
1656            }
1657            {
1658                let _compat = crate::compatibility::push_runmat_extensions_enabled(true);
1659                assert_eq!(
1660                    eval(
1661                        Value::GpuTensor(data_handle.clone()),
1662                        Value::GpuTensor(data_handle.clone()),
1663                        &options,
1664                    )
1665                    .unwrap()
1666                    .mask
1667                    .data,
1668                    vec![1, 1]
1669                );
1670
1671                let _outputs = crate::output_count::push_output_count(Some(2));
1672                let Value::OutputList(outputs) = builtin(
1673                    Value::GpuTensor(data_handle.clone()),
1674                    Value::GpuTensor(data_handle),
1675                    vec![
1676                        Value::from("OutputAllIndices"),
1677                        Value::Int(runmat_value::IntValue::I8(1)),
1678                    ],
1679                )
1680                .unwrap() else {
1681                    panic!("expected output list");
1682                };
1683                assert!(matches!(outputs[0], Value::LogicalArray(_)));
1684                assert!(matches!(outputs[1], Value::Cell(_)));
1685            }
1686        });
1687    }
1688
1689    #[test]
1690    fn gpu_inputs_gather_to_host() {
1691        test_support::with_test_provider(|provider| {
1692            let a = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1693            let b = Tensor::new(vec![1.0 + 1e-13, 3.0], vec![2, 1]).unwrap();
1694            let handle_a = provider
1695                .upload(&runmat_accelerate_api::HostTensorView {
1696                    data: &a.materialize_f64(),
1697                    shape: &a.shape,
1698                })
1699                .expect("upload a");
1700            let handle_b = provider
1701                .upload(&runmat_accelerate_api::HostTensorView {
1702                    data: &b.materialize_f64(),
1703                    shape: &b.shape,
1704                })
1705                .expect("upload b");
1706            let result = eval(Value::GpuTensor(handle_a), Value::GpuTensor(handle_b), &[]).unwrap();
1707            assert_eq!(result.mask.data, vec![1, 0]);
1708        });
1709    }
1710}