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, BuiltinOutputMode,
5    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
6    CellArray, LiteralValue, LogicalArray, NumericDType, ResolveContext, Tensor, Type, Value,
7};
8use runmat_macros::runtime_builtin;
9
10use super::type_resolvers::logical_output_type;
11use crate::builtins::common::{
12    gpu_helpers,
13    spec::{
14        BroadcastSemantics, BuiltinFusionSpec, BuiltinGpuSpec, ConstantStrategy, GpuOpKind,
15        ReductionNaN, ResidencyPolicy, ShapeRequirements,
16    },
17    tensor,
18};
19use crate::{build_runtime_error, BuiltinResult, RuntimeError};
20
21const NAME: &str = "ismembertol";
22const DEFAULT_TOL_DOUBLE: f64 = 1.0e-12;
23const DEFAULT_TOL_SINGLE: f64 = 1.0e-6;
24
25#[runmat_macros::register_gpu_spec(
26    builtin_path = "crate::builtins::array::sorting_sets::ismembertol"
27)]
28pub const GPU_SPEC: BuiltinGpuSpec = BuiltinGpuSpec {
29    name: NAME,
30    op_kind: GpuOpKind::Custom("ismembertol"),
31    supported_precisions: &[],
32    broadcast: BroadcastSemantics::None,
33    provider_hooks: &[],
34    constant_strategy: ConstantStrategy::InlineLiteral,
35    residency: ResidencyPolicy::GatherImmediately,
36    nan_mode: ReductionNaN::Include,
37    two_pass_threshold: None,
38    workgroup_size: None,
39    accepts_nan_mode: false,
40    notes: "Tolerance membership is host materialised today; gpuArray inputs are gathered before comparison.",
41};
42
43#[runmat_macros::register_fusion_spec(
44    builtin_path = "crate::builtins::array::sorting_sets::ismembertol"
45)]
46pub const FUSION_SPEC: BuiltinFusionSpec = BuiltinFusionSpec {
47    name: NAME,
48    shape: ShapeRequirements::Any,
49    constant_strategy: ConstantStrategy::InlineLiteral,
50    elementwise: None,
51    reduction: None,
52    emits_nan: false,
53    notes: "`ismembertol` materialises logical and index outputs and terminates fusion chains.",
54};
55
56const OUTPUT_MASK: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
57    name: "LIA",
58    ty: BuiltinParamType::LogicalArray,
59    arity: BuiltinParamArity::Required,
60    default: None,
61    description: "Logical mask over A.",
62}];
63
64const OUTPUT_MASK_LOC: [BuiltinParamDescriptor; 2] = [
65    BuiltinParamDescriptor {
66        name: "LIA",
67        ty: BuiltinParamType::LogicalArray,
68        arity: BuiltinParamArity::Required,
69        default: None,
70        description: "Logical mask over A.",
71    },
72    BuiltinParamDescriptor {
73        name: "LocB",
74        ty: BuiltinParamType::Any,
75        arity: BuiltinParamArity::Required,
76        default: None,
77        description: "First or all matching indices into B.",
78    },
79];
80
81const INPUTS_AB: [BuiltinParamDescriptor; 2] = [
82    BuiltinParamDescriptor {
83        name: "A",
84        ty: BuiltinParamType::NumericArray,
85        arity: BuiltinParamArity::Required,
86        default: None,
87        description: "Query values or rows.",
88    },
89    BuiltinParamDescriptor {
90        name: "B",
91        ty: BuiltinParamType::NumericArray,
92        arity: BuiltinParamArity::Required,
93        default: None,
94        description: "Reference values or rows.",
95    },
96];
97
98const INPUTS_AB_OPTIONS: [BuiltinParamDescriptor; 4] = [
99    BuiltinParamDescriptor {
100        name: "A",
101        ty: BuiltinParamType::NumericArray,
102        arity: BuiltinParamArity::Required,
103        default: None,
104        description: "Query values or rows.",
105    },
106    BuiltinParamDescriptor {
107        name: "B",
108        ty: BuiltinParamType::NumericArray,
109        arity: BuiltinParamArity::Required,
110        default: None,
111        description: "Reference values or rows.",
112    },
113    BuiltinParamDescriptor {
114        name: "tol",
115        ty: BuiltinParamType::NumericScalar,
116        arity: BuiltinParamArity::Optional,
117        default: Some("1e-12 for double, 1e-6 for single"),
118        description: "Relative tolerance.",
119    },
120    BuiltinParamDescriptor {
121        name: "Name,Value",
122        ty: BuiltinParamType::Any,
123        arity: BuiltinParamArity::Variadic,
124        default: None,
125        description: "Name-value options: ByRows, DataScale, OutputAllIndices.",
126    },
127];
128
129const SIGNATURES: [BuiltinSignatureDescriptor; 4] = [
130    BuiltinSignatureDescriptor {
131        label: "LIA = ismembertol(A, B)",
132        inputs: &INPUTS_AB,
133        outputs: &OUTPUT_MASK,
134    },
135    BuiltinSignatureDescriptor {
136        label: "LIA = ismembertol(A, B, tol, Name, Value)",
137        inputs: &INPUTS_AB_OPTIONS,
138        outputs: &OUTPUT_MASK,
139    },
140    BuiltinSignatureDescriptor {
141        label: "[LIA, LocB] = ismembertol(A, B)",
142        inputs: &INPUTS_AB,
143        outputs: &OUTPUT_MASK_LOC,
144    },
145    BuiltinSignatureDescriptor {
146        label: "[LIA, LocB] = ismembertol(A, B, tol, Name, Value)",
147        inputs: &INPUTS_AB_OPTIONS,
148        outputs: &OUTPUT_MASK_LOC,
149    },
150];
151
152const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
153    code: "RM.ISMEMBERTOL.INVALID_INPUT",
154    identifier: Some("RunMat:ismembertol:InvalidInput"),
155    when: "A or B is not a supported real full numeric input.",
156    message: "ismembertol: inputs must be real full numeric arrays",
157};
158
159const ERROR_INVALID_ARGUMENT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
160    code: "RM.ISMEMBERTOL.INVALID_ARGUMENT",
161    identifier: Some("RunMat:ismembertol:InvalidArgument"),
162    when: "Tolerance or name-value arguments are malformed.",
163    message: "ismembertol: invalid argument",
164};
165
166const ERROR_ROWS_COLUMN_MISMATCH: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
167    code: "RM.ISMEMBERTOL.ROWS_COLUMN_MISMATCH",
168    identifier: Some("RunMat:ismembertol:RowsColumnMismatch"),
169    when: "ByRows is true and A/B column counts differ.",
170    message: "ismembertol: inputs must have the same number of columns when ByRows is true",
171};
172
173const ERROR_INTERNAL: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
174    code: "RM.ISMEMBERTOL.INTERNAL",
175    identifier: Some("RunMat:ismembertol:Internal"),
176    when: "Internal conversion or allocation fails.",
177    message: "ismembertol: internal error",
178};
179
180const ERRORS: [BuiltinErrorDescriptor; 4] = [
181    ERROR_INVALID_INPUT,
182    ERROR_INVALID_ARGUMENT,
183    ERROR_ROWS_COLUMN_MISMATCH,
184    ERROR_INTERNAL,
185];
186
187pub const ISMEMBERTOL_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
188    signatures: &SIGNATURES,
189    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
190    completion_policy: BuiltinCompletionPolicy::Public,
191    errors: &ERRORS,
192};
193
194fn ismembertol_output_type(args: &[Type], ctx: &ResolveContext) -> Type {
195    if literal_by_rows_enabled(ctx) {
196        return match args.first().and_then(type_row_count) {
197            Some(rows) => Type::Logical {
198                shape: Some(vec![rows, Some(1)]),
199            },
200            None => Type::logical(),
201        };
202    }
203    logical_output_type(args, ctx)
204}
205
206fn literal_by_rows_enabled(ctx: &ResolveContext) -> bool {
207    let mut idx = 2usize;
208    if !matches!(ctx.literal_args.get(idx), Some(LiteralValue::String(_))) {
209        idx += 1;
210    }
211    while idx + 1 < ctx.literal_args.len() {
212        if matches!(
213            ctx.literal_args.get(idx),
214            Some(LiteralValue::String(name)) if name.eq_ignore_ascii_case("ByRows")
215        ) {
216            return literal_truthy(ctx.literal_args.get(idx + 1));
217        }
218        idx += 2;
219    }
220    false
221}
222
223fn literal_truthy(value: Option<&LiteralValue>) -> bool {
224    match value {
225        Some(LiteralValue::Bool(flag)) => *flag,
226        Some(LiteralValue::Number(num)) => *num == 1.0,
227        _ => false,
228    }
229}
230
231fn type_row_count(ty: &Type) -> Option<Option<usize>> {
232    match ty {
233        Type::Tensor { shape: Some(shape) } | Type::Logical { shape: Some(shape) } => {
234            if shape.is_empty() {
235                Some(Some(1))
236            } else {
237                Some(shape[0])
238            }
239        }
240        Type::Tensor { shape: None } | Type::Logical { shape: None } => Some(None),
241        Type::Num | Type::Int | Type::Bool => Some(Some(1)),
242        _ => None,
243    }
244}
245
246#[runtime_builtin(
247    name = "ismembertol",
248    category = "array/sorting_sets",
249    summary = "Identify numeric array elements or rows that are within tolerance of another array.",
250    keywords = "ismembertol,membership,tolerance,set,rows,indices",
251    accel = "array_construct",
252    sink = true,
253    type_resolver(ismembertol_output_type),
254    descriptor(crate::builtins::array::sorting_sets::ismembertol::ISMEMBERTOL_DESCRIPTOR),
255    builtin_path = "crate::builtins::array::sorting_sets::ismembertol"
256)]
257async fn ismembertol_builtin(a: Value, b: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
258    if let Some(out_count) = crate::output_count::current_output_count() {
259        if out_count == 0 {
260            return Ok(Value::OutputList(Vec::new()));
261        }
262        let opts = parse_options(&rest)?;
263        if out_count == 1 {
264            let eval = evaluate_with_options(a, b, opts, LocRequest::None).await?;
265            return Ok(Value::OutputList(vec![eval.into_mask_value()]));
266        }
267        let loc_request = if opts.output_all_indices {
268            LocRequest::All
269        } else {
270            LocRequest::First
271        };
272        let eval = evaluate_with_options(a, b, opts, loc_request).await?;
273        let (mask, loc) = eval.into_pair();
274        return Ok(crate::output_count::output_list_with_padding(
275            out_count,
276            vec![mask, loc],
277        ));
278    }
279    let opts = parse_options(&rest)?;
280    let eval = evaluate_with_options(a, b, opts, LocRequest::None).await?;
281    Ok(eval.into_mask_value())
282}
283
284pub async fn evaluate(a: Value, b: Value, rest: &[Value]) -> BuiltinResult<IsMemberTolEvaluation> {
285    let opts = parse_options(rest)?;
286    let loc_request = if opts.output_all_indices {
287        LocRequest::All
288    } else {
289        LocRequest::First
290    };
291    evaluate_with_options(a, b, opts, loc_request).await
292}
293
294async fn evaluate_with_options(
295    a: Value,
296    b: Value,
297    opts: IsMemberTolOptions,
298    loc_request: LocRequest,
299) -> BuiltinResult<IsMemberTolEvaluation> {
300    let a = gather_if_gpu(a).await?;
301    let b = gather_if_gpu(b).await?;
302    let tensor_a =
303        tensor::value_into_tensor_for(NAME, a).map_err(|_| error(&ERROR_INVALID_INPUT))?;
304    let tensor_b =
305        tensor::value_into_tensor_for(NAME, b).map_err(|_| error(&ERROR_INVALID_INPUT))?;
306    evaluate_tensors(tensor_a, tensor_b, opts, loc_request)
307}
308
309async fn gather_if_gpu(value: Value) -> BuiltinResult<Value> {
310    match value {
311        Value::GpuTensor(handle) => gpu_helpers::gather_value_async(&Value::GpuTensor(handle))
312            .await
313            .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}"))),
314        other => Ok(other),
315    }
316}
317
318fn evaluate_tensors(
319    a: Tensor,
320    b: Tensor,
321    mut opts: IsMemberTolOptions,
322    loc_request: LocRequest,
323) -> BuiltinResult<IsMemberTolEvaluation> {
324    if opts.tol.is_none() {
325        opts.tol = Some(default_tolerance(a.dtype, b.dtype));
326    }
327    if opts.by_rows {
328        evaluate_rows(a, b, opts, loc_request)
329    } else {
330        evaluate_elements(a, b, opts, loc_request)
331    }
332}
333
334#[derive(Debug, Clone)]
335struct IsMemberTolOptions {
336    tol: Option<f64>,
337    by_rows: bool,
338    data_scale: Option<Vec<f64>>,
339    output_all_indices: bool,
340}
341
342#[derive(Debug, Copy, Clone, Eq, PartialEq)]
343enum LocRequest {
344    None,
345    First,
346    All,
347}
348
349fn parse_options(rest: &[Value]) -> BuiltinResult<IsMemberTolOptions> {
350    let mut opts = IsMemberTolOptions {
351        tol: None,
352        by_rows: false,
353        data_scale: None,
354        output_all_indices: false,
355    };
356
357    let mut idx = 0usize;
358    if let Some(first) = rest.first() {
359        if option_name(first).is_none() {
360            opts.tol = Some(parse_positive_scalar(first, "tolerance")?);
361            idx = 1;
362        }
363    }
364
365    let remaining = &rest[idx..];
366    if !remaining.len().is_multiple_of(2) {
367        return Err(error_with(
368            &ERROR_INVALID_ARGUMENT,
369            "ismembertol: name-value arguments must appear in pairs",
370        ));
371    }
372
373    for pair in remaining.chunks_exact(2) {
374        let name = option_name(&pair[0])
375            .ok_or_else(|| {
376                error_with(&ERROR_INVALID_ARGUMENT, "ismembertol: expected option name")
377            })?
378            .trim()
379            .to_ascii_lowercase();
380        match name.as_str() {
381            "byrows" => opts.by_rows = parse_bool_option(&pair[1])?,
382            "outputallindices" => opts.output_all_indices = parse_bool_option(&pair[1])?,
383            "datascale" => opts.data_scale = Some(parse_data_scale(&pair[1])?),
384            other => {
385                return Err(error_with(
386                    &ERROR_INVALID_ARGUMENT,
387                    format!("ismembertol: unknown option '{other}'"),
388                ))
389            }
390        }
391    }
392
393    Ok(opts)
394}
395
396fn option_name(value: &Value) -> Option<String> {
397    match value {
398        Value::String(_) | Value::StringArray(_) | Value::CharArray(_) => {
399            tensor::value_to_string(value)
400        }
401        _ => None,
402    }
403}
404
405fn evaluate_elements(
406    a: Tensor,
407    b: Tensor,
408    opts: IsMemberTolOptions,
409    loc_request: LocRequest,
410) -> BuiltinResult<IsMemberTolEvaluation> {
411    let tol = opts.tol.expect("defaulted tolerance");
412    let scale = opts
413        .data_scale
414        .as_ref()
415        .map(|values| {
416            if values.len() == 1 {
417                Ok(values[0])
418            } else {
419                Err(error_with(
420                    &ERROR_INVALID_ARGUMENT,
421                    "ismembertol: DataScale must be scalar unless ByRows is true",
422                ))
423            }
424        })
425        .transpose()?
426        .unwrap_or_else(|| default_element_scale(&a, &b));
427    let threshold = tol * scale.abs();
428
429    let mut mask_data = Vec::with_capacity(a.data.len());
430    let mut loc_data = match loc_request {
431        LocRequest::First => Vec::with_capacity(a.data.len()),
432        LocRequest::None | LocRequest::All => Vec::new(),
433    };
434    let mut all_cells = match loc_request {
435        LocRequest::All => Vec::with_capacity(a.data.len()),
436        LocRequest::None | LocRequest::First => Vec::new(),
437    };
438
439    for &value in &a.data {
440        match loc_request {
441            LocRequest::None => {
442                mask_data.push(
443                    if first_element_match(value, &b.data, threshold).is_some() {
444                        1
445                    } else {
446                        0
447                    },
448                );
449            }
450            LocRequest::First => {
451                let first = first_element_match(value, &b.data, threshold);
452                mask_data.push(if first.is_some() { 1 } else { 0 });
453                loc_data.push(first.unwrap_or(0) as f64);
454            }
455            LocRequest::All => {
456                let matches = all_element_matches(value, &b.data, threshold);
457                mask_data.push(if matches.is_empty() { 0 } else { 1 });
458                all_cells.push(indices_cell(matches)?);
459            }
460        }
461    }
462
463    let mask = LogicalArray::new(mask_data, a.shape.clone())
464        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))?;
465    let loc = match loc_request {
466        LocRequest::None => None,
467        LocRequest::First => Some(LocOutput::First(
468            Tensor::new(loc_data, a.shape.clone())
469                .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))?,
470        )),
471        LocRequest::All => Some(LocOutput::All(cell_array_from_linear_matches(
472            all_cells,
473            a.shape.clone(),
474        )?)),
475    };
476    Ok(IsMemberTolEvaluation { mask, loc })
477}
478
479fn evaluate_rows(
480    a: Tensor,
481    b: Tensor,
482    opts: IsMemberTolOptions,
483    loc_request: LocRequest,
484) -> BuiltinResult<IsMemberTolEvaluation> {
485    let (rows_a, cols_a) = rows_cols(&a)?;
486    let (rows_b, cols_b) = rows_cols(&b)?;
487    if cols_a != cols_b {
488        return Err(error(&ERROR_ROWS_COLUMN_MISMATCH));
489    }
490    let tol = opts.tol.expect("defaulted tolerance");
491    let scales = row_scales(&a, rows_a, &b, rows_b, opts.data_scale.as_deref(), cols_a)?;
492    let thresholds: Vec<f64> = scales.iter().map(|scale| tol * scale.abs()).collect();
493
494    let mut mask_data = vec![0u8; rows_a];
495    let mut loc_data = match loc_request {
496        LocRequest::First => vec![0.0f64; rows_a],
497        LocRequest::None | LocRequest::All => Vec::new(),
498    };
499    let mut all_cells = match loc_request {
500        LocRequest::All => Vec::with_capacity(rows_a),
501        LocRequest::None | LocRequest::First => Vec::new(),
502    };
503
504    for row_a in 0..rows_a {
505        match loc_request {
506            LocRequest::None => {
507                mask_data[row_a] = if first_row_match(
508                    &a,
509                    row_a,
510                    rows_a,
511                    &b,
512                    rows_b,
513                    cols_a,
514                    &thresholds,
515                )
516                .is_some()
517                {
518                    1
519                } else {
520                    0
521                };
522            }
523            LocRequest::First => {
524                let first = first_row_match(&a, row_a, rows_a, &b, rows_b, cols_a, &thresholds);
525                mask_data[row_a] = if first.is_some() { 1 } else { 0 };
526                loc_data[row_a] = first.unwrap_or(0) as f64;
527            }
528            LocRequest::All => {
529                let matches = all_row_matches(&a, row_a, rows_a, &b, rows_b, cols_a, &thresholds);
530                mask_data[row_a] = if matches.is_empty() { 0 } else { 1 };
531                all_cells.push(indices_cell(matches)?);
532            }
533        }
534    }
535
536    let shape = vec![rows_a, 1];
537    let mask = LogicalArray::new(mask_data, shape.clone())
538        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))?;
539    let loc = match loc_request {
540        LocRequest::None => None,
541        LocRequest::First => Some(LocOutput::First(
542            Tensor::new(loc_data, shape)
543                .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))?,
544        )),
545        LocRequest::All => Some(LocOutput::All(cell_array_from_linear_matches(
546            all_cells, shape,
547        )?)),
548    };
549    Ok(IsMemberTolEvaluation { mask, loc })
550}
551
552fn first_element_match(value: f64, candidates: &[f64], threshold: f64) -> Option<usize> {
553    candidates.iter().enumerate().find_map(|(idx, &candidate)| {
554        within_tolerance(value, candidate, threshold).then_some(idx + 1)
555    })
556}
557
558fn all_element_matches(value: f64, candidates: &[f64], threshold: f64) -> Vec<usize> {
559    candidates
560        .iter()
561        .enumerate()
562        .filter_map(|(idx, &candidate)| {
563            within_tolerance(value, candidate, threshold).then_some(idx + 1)
564        })
565        .collect()
566}
567
568fn first_row_match(
569    a: &Tensor,
570    row_a: usize,
571    rows_a: usize,
572    b: &Tensor,
573    rows_b: usize,
574    cols: usize,
575    thresholds: &[f64],
576) -> Option<usize> {
577    (0..rows_b).find_map(|row_b| {
578        rows_match(a, row_a, rows_a, b, row_b, rows_b, cols, thresholds).then_some(row_b + 1)
579    })
580}
581
582fn all_row_matches(
583    a: &Tensor,
584    row_a: usize,
585    rows_a: usize,
586    b: &Tensor,
587    rows_b: usize,
588    cols: usize,
589    thresholds: &[f64],
590) -> Vec<usize> {
591    (0..rows_b)
592        .filter_map(|row_b| {
593            rows_match(a, row_a, rows_a, b, row_b, rows_b, cols, thresholds).then_some(row_b + 1)
594        })
595        .collect()
596}
597
598fn rows_match(
599    a: &Tensor,
600    row_a: usize,
601    rows_a: usize,
602    b: &Tensor,
603    row_b: usize,
604    rows_b: usize,
605    cols: usize,
606    thresholds: &[f64],
607) -> bool {
608    for col in 0..cols {
609        let threshold = thresholds[col];
610        if threshold.is_infinite() {
611            continue;
612        }
613        let lhs = a.data[row_a + col * rows_a];
614        let rhs = b.data[row_b + col * rows_b];
615        if !within_tolerance(lhs, rhs, threshold) {
616            return false;
617        }
618    }
619    true
620}
621
622fn within_tolerance(lhs: f64, rhs: f64, threshold: f64) -> bool {
623    if lhs.is_nan() || rhs.is_nan() {
624        return false;
625    }
626    if lhs == rhs {
627        return true;
628    }
629    (lhs - rhs).abs() <= threshold
630}
631
632fn default_tolerance(a: NumericDType, b: NumericDType) -> f64 {
633    if matches!(a, NumericDType::F32) || matches!(b, NumericDType::F32) {
634        DEFAULT_TOL_SINGLE
635    } else {
636        DEFAULT_TOL_DOUBLE
637    }
638}
639
640fn default_element_scale(a: &Tensor, b: &Tensor) -> f64 {
641    a.data
642        .iter()
643        .chain(b.data.iter())
644        .filter_map(|value| (!value.is_nan()).then_some(value.abs()))
645        .fold(0.0, f64::max)
646}
647
648fn row_scales(
649    a: &Tensor,
650    rows_a: usize,
651    b: &Tensor,
652    rows_b: usize,
653    supplied: Option<&[f64]>,
654    cols: usize,
655) -> BuiltinResult<Vec<f64>> {
656    if let Some(values) = supplied {
657        if values.len() == 1 {
658            return Ok(vec![values[0]; cols]);
659        }
660        if values.len() == cols {
661            return Ok(values.to_vec());
662        }
663        return Err(error_with(
664            &ERROR_INVALID_ARGUMENT,
665            "ismembertol: DataScale vector length must match the number of columns",
666        ));
667    }
668
669    let mut scales = vec![0.0f64; cols];
670    for (col, scale) in scales.iter_mut().enumerate() {
671        for row in 0..rows_a {
672            let value = a.data[row + col * rows_a];
673            if !value.is_nan() {
674                *scale = (*scale).max(value.abs());
675            }
676        }
677        for row in 0..rows_b {
678            let value = b.data[row + col * rows_b];
679            if !value.is_nan() {
680                *scale = (*scale).max(value.abs());
681            }
682        }
683    }
684    Ok(scales)
685}
686
687fn rows_cols(tensor: &Tensor) -> BuiltinResult<(usize, usize)> {
688    match tensor.shape.len() {
689        0 => Ok((1, 1)),
690        1 => Ok((tensor.shape[0], 1)),
691        2 => Ok((tensor.shape[0], tensor.shape[1])),
692        _ => Err(error_with(
693            &ERROR_INVALID_ARGUMENT,
694            "ismembertol: ByRows requires 2-D arrays",
695        )),
696    }
697}
698
699fn indices_cell(indices: Vec<usize>) -> BuiltinResult<Value> {
700    if indices.is_empty() {
701        return Ok(Value::Tensor(Tensor::new(vec![0.0], vec![1, 1]).map_err(
702            |e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")),
703        )?));
704    }
705    let data = indices
706        .into_iter()
707        .map(|idx| idx as f64)
708        .collect::<Vec<_>>();
709    let len = data.len();
710    Tensor::new(data, vec![len, 1])
711        .map(Value::Tensor)
712        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))
713}
714
715fn cell_array_from_linear_matches(
716    cells_column_major: Vec<Value>,
717    shape: Vec<usize>,
718) -> BuiltinResult<CellArray> {
719    let cells = if shape.len() == 2 && shape[0] > 1 && shape[1] > 1 {
720        let rows = shape[0];
721        let cols = shape[1];
722        let mut row_major = Vec::with_capacity(cells_column_major.len());
723        for row in 0..rows {
724            for col in 0..cols {
725                row_major.push(cells_column_major[row + col * rows].clone());
726            }
727        }
728        row_major
729    } else {
730        cells_column_major
731    };
732    CellArray::new_with_shape(cells, shape)
733        .map_err(|e| error_with(&ERROR_INTERNAL, format!("{NAME}: {e}")))
734}
735
736fn parse_positive_scalar(value: &Value, label: &str) -> BuiltinResult<f64> {
737    let scalar = numeric_scalar(value).ok_or_else(|| {
738        error_with(
739            &ERROR_INVALID_ARGUMENT,
740            format!("ismembertol: {label} must be a positive real scalar"),
741        )
742    })?;
743    if !scalar.is_finite() || scalar <= 0.0 {
744        return Err(error_with(
745            &ERROR_INVALID_ARGUMENT,
746            format!("ismembertol: {label} must be a positive real scalar"),
747        ));
748    }
749    Ok(scalar)
750}
751
752fn parse_bool_option(value: &Value) -> BuiltinResult<bool> {
753    match numeric_scalar(value) {
754        Some(0.0) => Ok(false),
755        Some(1.0) => Ok(true),
756        _ => Err(error_with(
757            &ERROR_INVALID_ARGUMENT,
758            "ismembertol: boolean options must be true/false or 0/1",
759        )),
760    }
761}
762
763fn parse_data_scale(value: &Value) -> BuiltinResult<Vec<f64>> {
764    match value {
765        Value::Num(n) => validate_data_scale(vec![*n]),
766        Value::Int(i) => validate_data_scale(vec![i.to_i64() as f64]),
767        Value::Bool(flag) => validate_data_scale(vec![if *flag { 1.0 } else { 0.0 }]),
768        Value::Tensor(tensor) => validate_data_scale(tensor.data.clone()),
769        Value::LogicalArray(logical) => validate_data_scale(
770            logical
771                .data
772                .iter()
773                .map(|&v| if v != 0 { 1.0 } else { 0.0 })
774                .collect(),
775        ),
776        _ => Err(error_with(
777            &ERROR_INVALID_ARGUMENT,
778            "ismembertol: DataScale must be a numeric scalar or vector",
779        )),
780    }
781}
782
783fn validate_data_scale(values: Vec<f64>) -> BuiltinResult<Vec<f64>> {
784    if values.is_empty() || values.iter().any(|value| value.is_nan() || *value < 0.0) {
785        return Err(error_with(
786            &ERROR_INVALID_ARGUMENT,
787            "ismembertol: DataScale values must be non-negative or Inf",
788        ));
789    }
790    Ok(values)
791}
792
793fn numeric_scalar(value: &Value) -> Option<f64> {
794    match value {
795        Value::Num(n) => Some(*n),
796        Value::Int(i) => Some(i.to_i64() as f64),
797        Value::Bool(flag) => Some(if *flag { 1.0 } else { 0.0 }),
798        Value::Tensor(t) if t.data.len() == 1 => Some(t.data[0]),
799        Value::LogicalArray(logical) if logical.data.len() == 1 => {
800            Some(if logical.data[0] != 0 { 1.0 } else { 0.0 })
801        }
802        _ => None,
803    }
804}
805
806#[derive(Debug, Clone)]
807pub struct IsMemberTolEvaluation {
808    mask: LogicalArray,
809    loc: Option<LocOutput>,
810}
811
812#[derive(Debug, Clone)]
813enum LocOutput {
814    First(Tensor),
815    All(CellArray),
816}
817
818impl IsMemberTolEvaluation {
819    pub fn into_mask_value(self) -> Value {
820        logical_array_into_value(self.mask)
821    }
822
823    pub fn mask_value(&self) -> Value {
824        logical_array_into_value(self.mask.clone())
825    }
826
827    pub fn into_pair(self) -> (Value, Value) {
828        let mask = logical_array_into_value(self.mask);
829        let loc = match self.loc.expect("LocB was not requested") {
830            LocOutput::First(tensor) => tensor::tensor_into_value(tensor),
831            LocOutput::All(cell) => Value::Cell(cell),
832        };
833        (mask, loc)
834    }
835}
836
837fn logical_array_into_value(logical: LogicalArray) -> Value {
838    if logical.data.len() == 1 {
839        Value::Bool(logical.data[0] != 0)
840    } else {
841        Value::LogicalArray(logical)
842    }
843}
844
845fn error(error: &'static BuiltinErrorDescriptor) -> RuntimeError {
846    error_with(error, error.message)
847}
848
849fn error_with(error: &'static BuiltinErrorDescriptor, message: impl Into<String>) -> RuntimeError {
850    let mut builder = build_runtime_error(message).with_builtin(NAME);
851    if let Some(identifier) = error.identifier {
852        builder = builder.with_identifier(identifier);
853    }
854    builder.build()
855}
856
857#[cfg(test)]
858mod tests {
859    use super::*;
860    use crate::builtins::common::test_support;
861    use futures::executor::block_on;
862    use runmat_builtins::{LiteralValue, ResolveContext, Type};
863
864    fn eval(a: Value, b: Value, rest: &[Value]) -> BuiltinResult<IsMemberTolEvaluation> {
865        block_on(evaluate(a, b, rest))
866    }
867
868    #[test]
869    fn type_resolver_returns_logical_shape() {
870        assert_eq!(
871            ismembertol_output_type(
872                &[Type::Tensor {
873                    shape: Some(vec![Some(1), Some(3)])
874                }],
875                &ResolveContext::new(Vec::new()),
876            ),
877            Type::Logical {
878                shape: Some(vec![Some(1), Some(3)])
879            }
880        );
881    }
882
883    #[test]
884    fn type_resolver_returns_row_shape_for_literal_by_rows() {
885        assert_eq!(
886            ismembertol_output_type(
887                &[Type::Tensor {
888                    shape: Some(vec![Some(4), Some(3)])
889                }],
890                &ResolveContext::new(vec![
891                    LiteralValue::Unknown,
892                    LiteralValue::Unknown,
893                    LiteralValue::Number(0.01),
894                    LiteralValue::String("ByRows".to_string()),
895                    LiteralValue::Bool(true),
896                ]),
897            ),
898            Type::Logical {
899                shape: Some(vec![Some(4), Some(1)])
900            }
901        );
902    }
903
904    #[test]
905    fn default_tolerance_uses_scaled_double_data() {
906        let a = Tensor::new(vec![0.1, 1.0e10], vec![1, 2]).unwrap();
907        let b = Tensor::new(vec![0.1 + 1.0e-14, 1.0e10 + 5.0e-3], vec![1, 2]).unwrap();
908        let result = eval(Value::Tensor(a), Value::Tensor(b), &[]).unwrap();
909        assert_eq!(result.mask.data, vec![1, 1]);
910    }
911
912    #[test]
913    fn data_scale_one_uses_absolute_tolerance() {
914        let a = Tensor::new(vec![1.0e10], vec![1, 1]).unwrap();
915        let b = Tensor::new(vec![1.0e10 + 5.0e-3], vec![1, 1]).unwrap();
916        let result = eval(
917            Value::Tensor(a),
918            Value::Tensor(b),
919            &[
920                Value::Num(1.0e-6),
921                Value::from("DataScale"),
922                Value::Num(1.0),
923            ],
924        )
925        .unwrap();
926        assert_eq!(result.mask_value(), Value::Bool(false));
927    }
928
929    #[test]
930    fn loc_returns_first_matching_index() {
931        let a = Tensor::new(vec![1.0, 2.0, 4.0], vec![1, 3]).unwrap();
932        let b = Tensor::new(vec![2.01, 2.02, 4.2], vec![1, 3]).unwrap();
933        let (_, loc) = eval(Value::Tensor(a), Value::Tensor(b), &[Value::Num(0.05)])
934            .unwrap()
935            .into_pair();
936        match loc {
937            Value::Tensor(tensor) => assert_eq!(tensor.data, vec![0.0, 1.0, 3.0]),
938            other => panic!("expected tensor loc, got {other:?}"),
939        }
940    }
941
942    #[test]
943    fn mask_only_request_does_not_materialize_locations() {
944        let a = Tensor::new(vec![2.0, 9.0], vec![1, 2]).unwrap();
945        let b = Tensor::new(vec![1.99, 2.01, 9.5], vec![1, 3]).unwrap();
946        let opts = parse_options(&[
947            Value::Num(0.01),
948            Value::from("OutputAllIndices"),
949            Value::Bool(true),
950        ])
951        .unwrap();
952        let result = block_on(evaluate_with_options(
953            Value::Tensor(a),
954            Value::Tensor(b),
955            opts,
956            LocRequest::None,
957        ))
958        .unwrap();
959        assert_eq!(result.mask.data, vec![1, 0]);
960        assert!(result.loc.is_none());
961    }
962
963    #[test]
964    fn output_all_indices_returns_cell_array() {
965        let a = Tensor::new(vec![2.0, 9.0], vec![1, 2]).unwrap();
966        let b = Tensor::new(vec![1.99, 2.01, 9.5], vec![1, 3]).unwrap();
967        let (_, loc) = eval(
968            Value::Tensor(a),
969            Value::Tensor(b),
970            &[
971                Value::Num(0.01),
972                Value::from("OutputAllIndices"),
973                Value::Bool(true),
974            ],
975        )
976        .unwrap()
977        .into_pair();
978        let Value::Cell(cell) = loc else {
979            panic!("expected cell loc");
980        };
981        assert_eq!(cell.shape, vec![1, 2]);
982        match &cell.data[0] {
983            Value::Tensor(indices) => assert_eq!(indices.data, vec![1.0, 2.0]),
984            other => panic!("expected tensor indices, got {other:?}"),
985        }
986        match &cell.data[1] {
987            Value::Tensor(indices) => assert_eq!(indices.data, vec![0.0]),
988            other => panic!("expected tensor zero indices, got {other:?}"),
989        }
990    }
991
992    #[test]
993    fn output_all_indices_matrix_cells_follow_public_cell_indexing() {
994        let a = Tensor::new(vec![1.0, 3.0, 2.0, 4.0], vec![2, 2]).unwrap();
995        let b = Tensor::new(vec![10.0, 2.0, 3.0, 20.0], vec![1, 4]).unwrap();
996        let (_, loc) = eval(
997            Value::Tensor(a),
998            Value::Tensor(b),
999            &[
1000                Value::Num(1.0e-12),
1001                Value::from("OutputAllIndices"),
1002                Value::Bool(true),
1003            ],
1004        )
1005        .unwrap()
1006        .into_pair();
1007        let Value::Cell(cell) = loc else {
1008            panic!("expected cell loc");
1009        };
1010        assert_eq!(cell.shape, vec![2, 2]);
1011        let top_right = cell.get(0, 1).unwrap();
1012        match top_right {
1013            Value::Tensor(indices) => assert_eq!(indices.data, vec![2.0]),
1014            other => panic!("expected tensor indices, got {other:?}"),
1015        }
1016        let bottom_left = cell.get(1, 0).unwrap();
1017        match bottom_left {
1018            Value::Tensor(indices) => assert_eq!(indices.data, vec![3.0]),
1019            other => panic!("expected tensor indices, got {other:?}"),
1020        }
1021    }
1022
1023    #[test]
1024    fn by_rows_uses_column_scales_and_inf_ignores_column() {
1025        let a = Tensor::new(vec![0.0, 10.0, 0.5, 20.0], vec![2, 2]).unwrap();
1026        let b = Tensor::new(vec![0.05, 10.2, 999.0, -999.0], vec![2, 2]).unwrap();
1027        let result = eval(
1028            Value::Tensor(a),
1029            Value::Tensor(b),
1030            &[
1031                Value::Num(0.1),
1032                Value::from("ByRows"),
1033                Value::Bool(true),
1034                Value::from("DataScale"),
1035                Value::Tensor(Tensor::new(vec![1.0, f64::INFINITY], vec![1, 2]).unwrap()),
1036            ],
1037        )
1038        .unwrap();
1039        assert_eq!(result.mask.data, vec![1, 0]);
1040        let (_, loc) = result.into_pair();
1041        match loc {
1042            Value::Tensor(tensor) => {
1043                assert_eq!(tensor.shape, vec![2, 1]);
1044                assert_eq!(tensor.data, vec![1.0, 0.0]);
1045            }
1046            other => panic!("expected tensor loc, got {other:?}"),
1047        }
1048    }
1049
1050    #[test]
1051    fn by_rows_output_all_indices_returns_row_cells() {
1052        let a = Tensor::new(vec![0.0, 1.0], vec![1, 2]).unwrap();
1053        let b = Tensor::new(vec![0.01, -0.01, 5.0, 1.0, 1.0, 5.0], vec![3, 2]).unwrap();
1054        let (_, loc) = eval(
1055            Value::Tensor(a),
1056            Value::Tensor(b),
1057            &[
1058                Value::Num(0.02),
1059                Value::from("ByRows"),
1060                Value::Bool(true),
1061                Value::from("OutputAllIndices"),
1062                Value::Bool(true),
1063            ],
1064        )
1065        .unwrap()
1066        .into_pair();
1067        let Value::Cell(cell) = loc else {
1068            panic!("expected cell loc");
1069        };
1070        assert_eq!(cell.shape, vec![1, 1]);
1071        match &cell.data[0] {
1072            Value::Tensor(indices) => assert_eq!(indices.data, vec![1.0, 2.0]),
1073            other => panic!("expected tensor indices, got {other:?}"),
1074        }
1075    }
1076
1077    #[test]
1078    fn rejects_bad_options_and_shape_mismatch() {
1079        let err = parse_options(&[Value::Num(-1.0)]).unwrap_err();
1080        assert_eq!(
1081            err.identifier.as_deref(),
1082            Some("RunMat:ismembertol:InvalidArgument")
1083        );
1084
1085        let a = Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap();
1086        let b = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1087        let err = eval(
1088            Value::Tensor(a),
1089            Value::Tensor(b),
1090            &[Value::from("ByRows"), Value::Bool(true)],
1091        )
1092        .unwrap_err();
1093        assert_eq!(
1094            err.identifier.as_deref(),
1095            Some("RunMat:ismembertol:RowsColumnMismatch")
1096        );
1097    }
1098
1099    #[test]
1100    fn gpu_inputs_gather_to_host() {
1101        test_support::with_test_provider(|provider| {
1102            let a = Tensor::new(vec![1.0, 2.0], vec![2, 1]).unwrap();
1103            let b = Tensor::new(vec![1.0 + 1e-13, 3.0], vec![2, 1]).unwrap();
1104            let handle_a = provider
1105                .upload(&runmat_accelerate_api::HostTensorView {
1106                    data: &a.data,
1107                    shape: &a.shape,
1108                })
1109                .expect("upload a");
1110            let handle_b = provider
1111                .upload(&runmat_accelerate_api::HostTensorView {
1112                    data: &b.data,
1113                    shape: &b.shape,
1114                })
1115                .expect("upload b");
1116            let result = eval(Value::GpuTensor(handle_a), Value::GpuTensor(handle_b), &[]).unwrap();
1117            assert_eq!(result.mask.data, vec![1, 0]);
1118        });
1119    }
1120}