Skip to main content

runmat_runtime/builtins/array/
grouping.rs

1//! MATLAB-compatible array grouping, binning, and grouped-apply builtins.
2
3use std::cmp::Ordering;
4use std::collections::BTreeMap;
5
6use runmat_builtins::{
7    BuiltinCompletionPolicy, BuiltinDescriptor, BuiltinErrorDescriptor, BuiltinOutputMode,
8    BuiltinParamArity, BuiltinParamDescriptor, BuiltinParamType, BuiltinSignatureDescriptor,
9    CellArray, LogicalArray, ObjectInstance, SparseTensor, StringArray, Tensor, Value,
10};
11use runmat_macros::runtime_builtin;
12
13use crate::builtins::table::{
14    categorical_label_at, is_tabular_object, select_rows, table_from_columns, table_height,
15    table_variable_names_from_object, table_variables, value_row_count,
16};
17use crate::{
18    build_runtime_error, call_feval_async_with_outputs, gather_if_needed_async, BuiltinResult,
19    RuntimeError,
20};
21
22const MAX_MATERIALIZED_ELEMENTS: usize = 50_000_000;
23
24const OUTPUT_ANY: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
25    name: "varargout",
26    ty: BuiltinParamType::Any,
27    arity: BuiltinParamArity::Variadic,
28    default: None,
29    description: "Builtin outputs.",
30}];
31
32const INPUT_VARIADIC: [BuiltinParamDescriptor; 1] = [BuiltinParamDescriptor {
33    name: "args",
34    ty: BuiltinParamType::Any,
35    arity: BuiltinParamArity::Variadic,
36    default: None,
37    description: "MATLAB-compatible arguments.",
38}];
39
40const SIGNATURES: [BuiltinSignatureDescriptor; 1] = [BuiltinSignatureDescriptor {
41    label: "varargout = groupingBuiltin(args...)",
42    inputs: &INPUT_VARIADIC,
43    outputs: &OUTPUT_ANY,
44}];
45
46const ERROR_INVALID_INPUT: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
47    code: "RM.GROUPING.INVALID_INPUT",
48    identifier: Some("RunMat:grouping:InvalidInput"),
49    when: "Inputs are malformed, have incompatible lengths, or request unsupported grouped output.",
50    message: "grouping builtin: invalid input",
51};
52
53const ERROR_CALLBACK: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
54    code: "RM.GROUPING.CALLBACK",
55    identifier: Some("RunMat:grouping:CallbackFailed"),
56    when: "A grouped callback fails or returns incompatible outputs.",
57    message: "grouping builtin: callback failed",
58};
59
60const ERROR_TOO_LARGE: BuiltinErrorDescriptor = BuiltinErrorDescriptor {
61    code: "RM.GROUPING.TOO_LARGE",
62    identifier: Some("RunMat:grouping:TooLarge"),
63    when: "The requested dense or combinatorial output exceeds RunMat's materialization limit.",
64    message: "grouping builtin: output is too large",
65};
66
67const ERRORS: [BuiltinErrorDescriptor; 3] = [ERROR_INVALID_INPUT, ERROR_CALLBACK, ERROR_TOO_LARGE];
68
69pub const GROUPING_DESCRIPTOR: BuiltinDescriptor = BuiltinDescriptor {
70    signatures: &SIGNATURES,
71    output_mode: BuiltinOutputMode::ByRequestedOutputCount,
72    completion_policy: BuiltinCompletionPolicy::Public,
73    errors: &ERRORS,
74};
75
76#[derive(Clone, Debug)]
77enum Atom {
78    Missing,
79    Logical(bool),
80    Number(f64),
81    Text(String),
82}
83
84impl Atom {
85    fn rank(&self) -> u8 {
86        match self {
87            Self::Missing => 0,
88            Self::Logical(_) => 1,
89            Self::Number(_) => 2,
90            Self::Text(_) => 3,
91        }
92    }
93
94    fn label(&self) -> String {
95        match self {
96            Self::Missing => "<missing>".to_string(),
97            Self::Logical(flag) => {
98                if *flag {
99                    "true".to_string()
100                } else {
101                    "false".to_string()
102                }
103            }
104            Self::Number(value) => format_key_number(*value),
105            Self::Text(text) => text.clone(),
106        }
107    }
108}
109
110impl PartialEq for Atom {
111    fn eq(&self, other: &Self) -> bool {
112        self.cmp(other) == Ordering::Equal
113    }
114}
115
116impl Eq for Atom {}
117
118impl PartialOrd for Atom {
119    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
120        Some(self.cmp(other))
121    }
122}
123
124impl Ord for Atom {
125    fn cmp(&self, other: &Self) -> Ordering {
126        let rank = self.rank().cmp(&other.rank());
127        if rank != Ordering::Equal {
128            return rank;
129        }
130        match (self, other) {
131            (Self::Missing, Self::Missing) => Ordering::Equal,
132            (Self::Logical(a), Self::Logical(b)) => a.cmp(b),
133            (Self::Number(a), Self::Number(b)) => a.total_cmp(b),
134            (Self::Text(a), Self::Text(b)) => a.cmp(b),
135            _ => Ordering::Equal,
136        }
137    }
138}
139
140#[derive(Clone)]
141struct GroupColumn {
142    name: String,
143    value: Value,
144    rows: usize,
145}
146
147struct Grouping {
148    ids: Vec<f64>,
149    keys: Vec<Vec<Atom>>,
150    first_rows: Vec<usize>,
151    row_groups: Vec<Vec<usize>>,
152}
153
154#[derive(Clone, Copy, Debug)]
155struct GroupOptions {
156    include_missing: bool,
157}
158
159impl GroupOptions {
160    fn parse(args: &[Value], context: &str) -> BuiltinResult<Self> {
161        let mut include_missing = false;
162        let mut idx = 0usize;
163        while idx < args.len() {
164            if idx + 1 >= args.len() {
165                return Err(grouping_error(format!(
166                    "{context}: name-value options must be provided in pairs"
167                )));
168            }
169            let name = scalar_text(&args[idx], context)?;
170            if name.eq_ignore_ascii_case("IncludeMissingGroups") {
171                include_missing = bool_scalar(&args[idx + 1], "IncludeMissingGroups")?;
172            } else if name.eq_ignore_ascii_case("IncludeEmptyGroups") {
173                let include_empty = bool_scalar(&args[idx + 1], "IncludeEmptyGroups")?;
174                if include_empty {
175                    return Err(grouping_error(format!(
176                        "{context}: IncludeEmptyGroups=true is not supported until categorical level expansion is implemented"
177                    )));
178                }
179            } else {
180                return Err(grouping_error(format!(
181                    "{context}: unsupported option '{name}'"
182                )));
183            }
184            idx += 2;
185        }
186        Ok(Self { include_missing })
187    }
188}
189
190fn grouping_error(message: impl Into<String>) -> RuntimeError {
191    let mut builder = build_runtime_error(message).with_builtin("array_grouping");
192    if let Some(identifier) = ERROR_INVALID_INPUT.identifier {
193        builder = builder.with_identifier(identifier);
194    }
195    builder.build()
196}
197
198fn callback_error(message: impl Into<String>, source: Option<RuntimeError>) -> RuntimeError {
199    let mut builder = build_runtime_error(message).with_builtin("array_grouping");
200    if let Some(identifier) = ERROR_CALLBACK.identifier {
201        builder = builder.with_identifier(identifier);
202    }
203    if let Some(source) = source {
204        builder = builder.with_source(source);
205    }
206    builder.build()
207}
208
209fn too_large_error(message: impl Into<String>) -> RuntimeError {
210    let mut builder = build_runtime_error(message).with_builtin("array_grouping");
211    if let Some(identifier) = ERROR_TOO_LARGE.identifier {
212        builder = builder.with_identifier(identifier);
213    }
214    builder.build()
215}
216
217#[runtime_builtin(
218    name = "findgroups",
219    category = "array/grouping",
220    summary = "Find groups and return group numbers.",
221    keywords = "findgroups,groups,grouping,table,categorical",
222    accel = "cpu",
223    descriptor(crate::builtins::array::grouping::GROUPING_DESCRIPTOR),
224    builtin_path = "crate::builtins::array::grouping"
225)]
226pub(crate) async fn findgroups_builtin(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
227    let mut args = Vec::with_capacity(rest.len() + 1);
228    args.push(gather_if_needed_async(&first).await?);
229    for value in rest {
230        args.push(gather_if_needed_async(&value).await?);
231    }
232    let (columns, table_mode) = findgroups_columns(args)?;
233    let grouping = build_grouping(&columns)?;
234    let outputs = findgroups_outputs(&columns, &grouping, table_mode)?;
235    multi_output(outputs)
236}
237
238#[runtime_builtin(
239    name = "grp2idx",
240    category = "array/grouping",
241    summary = "Create an index vector from a grouping variable.",
242    keywords = "grp2idx,groups,index,categorical,statistics",
243    accel = "cpu",
244    descriptor(crate::builtins::array::grouping::GROUPING_DESCRIPTOR),
245    builtin_path = "crate::builtins::array::grouping"
246)]
247pub(crate) async fn grp2idx_builtin(value: Value) -> BuiltinResult<Value> {
248    let value = gather_if_needed_async(&value).await?;
249    let columns = columns_from_group_value("G", value, true)?;
250    if columns.len() != 1 {
251        return Err(grouping_error(
252            "grp2idx: expected one grouping vector, not a matrix of grouping columns",
253        ));
254    }
255    let grouping = build_grouping(&columns)?;
256    let g = Tensor::new(grouping.ids.clone(), vec![grouping.ids.len(), 1])
257        .map(Value::Tensor)
258        .map_err(grouping_error)?;
259    let names = grouping
260        .keys
261        .iter()
262        .map(|key| key.first().map(Atom::label).unwrap_or_default())
263        .collect::<Vec<_>>();
264    let gn = Value::StringArray(
265        StringArray::new(names.clone(), vec![names.len(), 1]).map_err(grouping_error)?,
266    );
267    let gl = Value::StringArray(
268        StringArray::new(names, vec![grouping.keys.len(), 1]).map_err(grouping_error)?,
269    );
270    multi_output(vec![g, gn, gl])
271}
272
273#[runtime_builtin(
274    name = "groupcounts",
275    category = "array/grouping",
276    summary = "Count the number of elements in each group.",
277    keywords = "groupcounts,groups,count,table,categorical",
278    accel = "cpu",
279    descriptor(crate::builtins::array::grouping::GROUPING_DESCRIPTOR),
280    builtin_path = "crate::builtins::array::grouping"
281)]
282pub(crate) async fn groupcounts_builtin(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
283    let first = gather_if_needed_async(&first).await?;
284    let rest = gather_values(rest).await?;
285    if let Value::Object(object) = first.clone() {
286        if is_tabular_object(&object) {
287            let (selector_args, option_args) = split_option_tail(rest)?;
288            return groupcounts_table(object, selector_args, option_args);
289        }
290    }
291    let (data_args, option_args) = split_option_tail(rest)?;
292    let options = GroupOptions::parse(&option_args, "groupcounts")?;
293    let columns = if matches!(first, Value::Cell(_)) {
294        columns_from_group_value("A", first, true)?
295    } else {
296        let mut args = vec![first];
297        args.extend(data_args);
298        columns_from_group_args(args)?
299    };
300    let grouping = build_grouping_with_options(&columns, options)?;
301    let counts = grouping
302        .row_groups
303        .iter()
304        .map(|rows| rows.len() as f64)
305        .collect::<Vec<_>>();
306    let b =
307        Value::Tensor(Tensor::new(counts, vec![grouping.keys.len(), 1]).map_err(grouping_error)?);
308    let bg = group_label_outputs(&columns, &grouping)?;
309    let bp = Value::Tensor(
310        Tensor::new(
311            grouping
312                .row_groups
313                .iter()
314                .map(|rows| {
315                    if grouping.ids.is_empty() {
316                        0.0
317                    } else {
318                        rows.len() as f64 * 100.0 / grouping.ids.len() as f64
319                    }
320                })
321                .collect(),
322            vec![grouping.keys.len(), 1],
323        )
324        .map_err(grouping_error)?,
325    );
326    let mut outputs = vec![b];
327    outputs.extend(bg);
328    outputs.push(bp);
329    multi_output(outputs)
330}
331
332#[runtime_builtin(
333    name = "splitapply",
334    category = "array/grouping",
335    summary = "Split data into groups and apply a function.",
336    keywords = "splitapply,groups,apply,function,table",
337    accel = "cpu",
338    descriptor(crate::builtins::array::grouping::GROUPING_DESCRIPTOR),
339    builtin_path = "crate::builtins::array::grouping"
340)]
341pub(crate) async fn splitapply_builtin(
342    func: Value,
343    first_data: Value,
344    rest: Vec<Value>,
345) -> BuiltinResult<Value> {
346    let func = gather_if_needed_async(&func).await?;
347    let first_data = gather_if_needed_async(&first_data).await?;
348    let rest = gather_values(rest).await?;
349    if rest.is_empty() {
350        return Err(grouping_error(
351            "splitapply: expected data arguments followed by group numbers",
352        ));
353    }
354    let (group_value, data_tail) = rest.split_last().expect("checked non-empty");
355    let mut data_values = Vec::with_capacity(data_tail.len() + 1);
356    data_values.push(first_data);
357    data_values.extend_from_slice(data_tail);
358    let group_columns = columns_from_group_value("G", group_value.clone(), true)?;
359    if group_columns.len() != 1 {
360        return Err(grouping_error("splitapply: G must be a grouping vector"));
361    }
362    let grouping = build_grouping(&group_columns)?;
363    let expected_rows = grouping.ids.len();
364    for value in &data_values {
365        let rows = value_row_count(value)?;
366        if rows != expected_rows {
367            return Err(grouping_error(format!(
368                "splitapply: data arguments must have {expected_rows} rows to match G, got {rows}"
369            )));
370        }
371    }
372    let requested_outputs = crate::output_count::current_output_count()
373        .unwrap_or(1)
374        .max(1);
375    let mut collectors = (0..requested_outputs)
376        .map(|_| Vec::<Value>::new())
377        .collect::<Vec<_>>();
378    for rows in &grouping.row_groups {
379        let mut callback_args = Vec::with_capacity(data_values.len());
380        for value in &data_values {
381            callback_args.push(select_rows(value, rows)?);
382        }
383        let result = call_feval_async_with_outputs(func.clone(), &callback_args, requested_outputs)
384            .await
385            .map_err(|err| callback_error("splitapply: callback failed", Some(err)))?;
386        let outputs = normalize_outputs(result, requested_outputs, "splitapply")?;
387        for (collector, output) in collectors.iter_mut().zip(outputs) {
388            collector.push(gather_if_needed_async(&output).await?);
389        }
390    }
391    let outputs = collectors
392        .into_iter()
393        .map(|values| collect_group_results(values, grouping.keys.len(), "splitapply"))
394        .collect::<BuiltinResult<Vec<_>>>()?;
395    multi_output(outputs)
396}
397
398#[runtime_builtin(
399    name = "accumarray",
400    category = "array/grouping",
401    summary = "Accumulate values into an array by subscript groups.",
402    keywords = "accumarray,accumulate,groups,sum,sparse",
403    accel = "cpu",
404    descriptor(crate::builtins::array::grouping::GROUPING_DESCRIPTOR),
405    builtin_path = "crate::builtins::array::grouping"
406)]
407pub(crate) async fn accumarray_builtin(
408    subs: Value,
409    data: Value,
410    rest: Vec<Value>,
411) -> BuiltinResult<Value> {
412    let subs = gather_if_needed_async(&subs).await?;
413    let data = gather_if_needed_async(&data).await?;
414    let rest = gather_values(rest).await?;
415    accumarray_impl(subs, data, rest).await
416}
417
418#[runtime_builtin(
419    name = "discretize",
420    category = "array/grouping",
421    summary = "Group numeric data into bins.",
422    keywords = "discretize,bins,edges,categorical,grouping",
423    accel = "cpu",
424    descriptor(crate::builtins::array::grouping::GROUPING_DESCRIPTOR),
425    builtin_path = "crate::builtins::array::grouping"
426)]
427pub(crate) async fn discretize_builtin(
428    x: Value,
429    edges_or_n: Value,
430    rest: Vec<Value>,
431) -> BuiltinResult<Value> {
432    let x = gather_if_needed_async(&x).await?;
433    let edges_or_n = gather_if_needed_async(&edges_or_n).await?;
434    let rest = gather_values(rest).await?;
435    discretize_impl(x, edges_or_n, rest)
436}
437
438#[runtime_builtin(
439    name = "combinations",
440    category = "array/grouping",
441    summary = "Generate all element combinations of arrays.",
442    keywords = "combinations,cartesian,table,combinatorics",
443    accel = "cpu",
444    descriptor(crate::builtins::array::grouping::GROUPING_DESCRIPTOR),
445    builtin_path = "crate::builtins::array::grouping"
446)]
447pub(crate) async fn combinations_builtin(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
448    let first = gather_if_needed_async(&first).await?;
449    let rest = gather_values(rest).await?;
450    combinations_impl(first, rest)
451}
452
453fn findgroups_columns(args: Vec<Value>) -> BuiltinResult<(Vec<GroupColumn>, bool)> {
454    if args.is_empty() {
455        return Err(grouping_error("findgroups: expected at least one input"));
456    }
457    if let Value::Object(object) = args[0].clone() {
458        if is_tabular_object(&object) {
459            let names = table_variable_names_from_object(&object)?;
460            let selected = if let Some(selector) = args.get(1) {
461                parse_name_selector(selector, &names, "findgroups")?
462            } else {
463                names
464            };
465            let variables = table_variables(&object)?;
466            let mut columns = Vec::with_capacity(selected.len());
467            for name in selected {
468                let value = variables.fields.get(&name).cloned().ok_or_else(|| {
469                    grouping_error(format!("findgroups: missing table variable '{name}'"))
470                })?;
471                columns.push(GroupColumn {
472                    rows: value_row_count(&value)?,
473                    name,
474                    value,
475                });
476            }
477            return Ok((columns, true));
478        }
479    }
480    Ok((columns_from_group_args(args)?, false))
481}
482
483fn columns_from_group_args(args: Vec<Value>) -> BuiltinResult<Vec<GroupColumn>> {
484    let mut columns = Vec::new();
485    for (idx, value) in args.into_iter().enumerate() {
486        columns.extend(columns_from_group_value(
487            &format!("Var{}", idx + 1),
488            value,
489            true,
490        )?);
491    }
492    Ok(columns)
493}
494
495fn columns_from_group_value(
496    base_name: &str,
497    value: Value,
498    split_matrix: bool,
499) -> BuiltinResult<Vec<GroupColumn>> {
500    match value {
501        Value::Tensor(tensor) => tensor_columns(base_name, tensor, split_matrix),
502        Value::LogicalArray(array) => logical_columns(base_name, array, split_matrix),
503        Value::StringArray(array) => string_columns(base_name, array, split_matrix),
504        Value::Cell(cell) if cell_is_group_vector_list(&cell) => {
505            let mut columns = Vec::with_capacity(cell.data.len());
506            for (idx, value) in cell.data.into_iter().enumerate() {
507                columns.extend(columns_from_group_value(
508                    &format!("{base_name}{}", idx + 1),
509                    value,
510                    false,
511                )?);
512            }
513            Ok(columns)
514        }
515        Value::Cell(cell) => Ok(vec![GroupColumn {
516            rows: cell.rows.max(cell.cols).max(cell.data.len()),
517            name: base_name.to_string(),
518            value: Value::Cell(cell),
519        }]),
520        Value::Object(object) if object.is_class("categorical") => {
521            let rows = value_row_count(&Value::Object(object.clone()))?;
522            Ok(vec![GroupColumn {
523                rows,
524                name: base_name.to_string(),
525                value: Value::Object(object),
526            }])
527        }
528        other => {
529            let rows = value_row_count(&other).unwrap_or(1);
530            Ok(vec![GroupColumn {
531                rows,
532                name: base_name.to_string(),
533                value: other,
534            }])
535        }
536    }
537}
538
539fn tensor_columns(
540    base_name: &str,
541    tensor: Tensor,
542    split_matrix: bool,
543) -> BuiltinResult<Vec<GroupColumn>> {
544    if !split_matrix || tensor.cols() <= 1 || tensor.rows() == 1 {
545        let rows = tensor.data.len();
546        return Ok(vec![GroupColumn {
547            name: base_name.to_string(),
548            rows,
549            value: Value::Tensor(Tensor::new(tensor.data, vec![rows, 1]).map_err(grouping_error)?),
550        }]);
551    }
552    let mut out = Vec::with_capacity(tensor.cols());
553    for col in 0..tensor.cols() {
554        let mut data = Vec::with_capacity(tensor.rows());
555        for row in 0..tensor.rows() {
556            data.push(tensor.get2(row, col).map_err(grouping_error)?);
557        }
558        out.push(GroupColumn {
559            name: format!("{base_name}{col_plus}", col_plus = col + 1),
560            rows: tensor.rows(),
561            value: Value::Tensor(
562                Tensor::new(data, vec![tensor.rows(), 1]).map_err(grouping_error)?,
563            ),
564        });
565    }
566    Ok(out)
567}
568
569fn logical_columns(
570    base_name: &str,
571    array: LogicalArray,
572    split_matrix: bool,
573) -> BuiltinResult<Vec<GroupColumn>> {
574    let rows = array.shape.first().copied().unwrap_or(array.data.len());
575    let cols = array.shape.get(1).copied().unwrap_or(1);
576    if !split_matrix || cols <= 1 || rows == 1 {
577        let len = array.data.len();
578        return Ok(vec![GroupColumn {
579            name: base_name.to_string(),
580            rows: len,
581            value: Value::LogicalArray(
582                LogicalArray::new(array.data, vec![len, 1]).map_err(grouping_error)?,
583            ),
584        }]);
585    }
586    let mut out = Vec::with_capacity(cols);
587    for col in 0..cols {
588        let mut data = Vec::with_capacity(rows);
589        for row in 0..rows {
590            data.push(*array.data.get(row + col * rows).ok_or_else(|| {
591                grouping_error("grouping: logical array shape/data length mismatch")
592            })?);
593        }
594        out.push(GroupColumn {
595            name: format!("{base_name}{col_plus}", col_plus = col + 1),
596            rows,
597            value: Value::LogicalArray(
598                LogicalArray::new(data, vec![rows, 1]).map_err(grouping_error)?,
599            ),
600        });
601    }
602    Ok(out)
603}
604
605fn string_columns(
606    base_name: &str,
607    array: StringArray,
608    split_matrix: bool,
609) -> BuiltinResult<Vec<GroupColumn>> {
610    let rows = array.rows();
611    let cols = array.cols();
612    if !split_matrix || cols <= 1 || rows == 1 {
613        let len = array.data.len();
614        return Ok(vec![GroupColumn {
615            name: base_name.to_string(),
616            rows: len,
617            value: Value::StringArray(
618                StringArray::new(array.data, vec![len, 1]).map_err(grouping_error)?,
619            ),
620        }]);
621    }
622    let mut out = Vec::with_capacity(cols);
623    for col in 0..cols {
624        let mut data = Vec::with_capacity(rows);
625        for row in 0..rows {
626            data.push(array.data[row + col * rows].clone());
627        }
628        out.push(GroupColumn {
629            name: format!("{base_name}{col_plus}", col_plus = col + 1),
630            rows,
631            value: Value::StringArray(
632                StringArray::new(data, vec![rows, 1]).map_err(grouping_error)?,
633            ),
634        });
635    }
636    Ok(out)
637}
638
639fn cell_is_group_vector_list(cell: &CellArray) -> bool {
640    !cell.data.is_empty()
641        && cell.data.iter().all(|value| {
642            matches!(
643                value,
644                Value::Tensor(_)
645                    | Value::LogicalArray(_)
646                    | Value::StringArray(_)
647                    | Value::Object(_)
648                    | Value::Cell(_)
649            )
650        })
651}
652
653fn build_grouping(columns: &[GroupColumn]) -> BuiltinResult<Grouping> {
654    build_grouping_with_options(
655        columns,
656        GroupOptions {
657            include_missing: false,
658        },
659    )
660}
661
662fn build_grouping_with_options(
663    columns: &[GroupColumn],
664    options: GroupOptions,
665) -> BuiltinResult<Grouping> {
666    if columns.is_empty() {
667        return Err(grouping_error(
668            "grouping: expected at least one grouping variable",
669        ));
670    }
671    let rows = columns[0].rows;
672    for column in columns {
673        if column.rows != rows {
674            return Err(grouping_error(format!(
675                "grouping: grouping variables must have matching row counts ({} vs {})",
676                rows, column.rows
677            )));
678        }
679    }
680    let mut buckets = BTreeMap::<Vec<Atom>, Vec<usize>>::new();
681    let mut row_keys = Vec::with_capacity(rows);
682    for row in 0..rows {
683        let key = columns
684            .iter()
685            .map(|column| atom_at(&column.value, row))
686            .collect::<BuiltinResult<Vec<_>>>()?;
687        if !options.include_missing && key.iter().any(|atom| matches!(atom, Atom::Missing)) {
688            row_keys.push(None);
689            continue;
690        }
691        buckets.entry(key.clone()).or_default().push(row);
692        row_keys.push(Some(key));
693    }
694    let keys = buckets.keys().cloned().collect::<Vec<_>>();
695    let mut key_to_index = BTreeMap::<Vec<Atom>, usize>::new();
696    let mut first_rows = Vec::<usize>::with_capacity(keys.len());
697    let mut row_groups = Vec::<Vec<usize>>::with_capacity(keys.len());
698    for (idx, key) in keys.iter().enumerate() {
699        key_to_index.insert(key.clone(), idx);
700        let rows = buckets
701            .get(key)
702            .cloned()
703            .expect("key collected from buckets must exist");
704        first_rows.push(*rows.first().expect("nonmissing group has rows"));
705        row_groups.push(rows);
706    }
707    let ids = row_keys
708        .into_iter()
709        .map(|key| {
710            key.and_then(|key| key_to_index.get(&key).copied())
711                .map(|idx| idx as f64 + 1.0)
712                .unwrap_or(f64::NAN)
713        })
714        .collect();
715    Ok(Grouping {
716        ids,
717        keys,
718        first_rows,
719        row_groups,
720    })
721}
722
723fn atom_at(value: &Value, row: usize) -> BuiltinResult<Atom> {
724    match value {
725        Value::Tensor(tensor) => {
726            let value = *tensor
727                .data
728                .get(row)
729                .ok_or_else(|| grouping_error("grouping: numeric row out of bounds"))?;
730            if value.is_nan() {
731                Ok(Atom::Missing)
732            } else {
733                Ok(Atom::Number(value))
734            }
735        }
736        Value::LogicalArray(array) => Ok(array
737            .data
738            .get(row)
739            .map(|flag| Atom::Logical(*flag != 0))
740            .unwrap_or(Atom::Missing)),
741        Value::StringArray(array) => Ok(array
742            .data
743            .get(row)
744            .map(|text| {
745                if is_missing_text(text) {
746                    Atom::Missing
747                } else {
748                    Atom::Text(text.clone())
749                }
750            })
751            .unwrap_or(Atom::Missing)),
752        Value::Cell(cell) => cell
753            .data
754            .get(row)
755            .map(scalar_atom)
756            .unwrap_or(Ok(Atom::Missing)),
757        Value::Object(object) if object.is_class("categorical") => {
758            let label = categorical_label_at(object, row);
759            Ok(match label.as_deref() {
760                None | Some("<undefined>") | Some("") => Atom::Missing,
761                Some(text) => Atom::Text(text.to_string()),
762            })
763        }
764        Value::Object(object) if object.is_class("datetime") => {
765            let serials = crate::builtins::datetime::serials_from_datetime_value(value)?;
766            let value = serials.data.get(row).copied().unwrap_or(f64::NAN);
767            if value.is_nan() {
768                Ok(Atom::Missing)
769            } else {
770                Ok(Atom::Number(value))
771            }
772        }
773        Value::Object(object) if object.is_class("duration") => {
774            let tensor = crate::builtins::duration::duration_tensor_from_duration_value(value)?;
775            let value = tensor.data.get(row).copied().unwrap_or(f64::NAN);
776            if value.is_nan() {
777                Ok(Atom::Missing)
778            } else {
779                Ok(Atom::Number(value))
780            }
781        }
782        other if row == 0 => scalar_atom(other),
783        _ => Ok(Atom::Missing),
784    }
785}
786
787fn scalar_atom(value: &Value) -> BuiltinResult<Atom> {
788    match value {
789        Value::Num(value) => {
790            if value.is_nan() {
791                Ok(Atom::Missing)
792            } else {
793                Ok(Atom::Number(*value))
794            }
795        }
796        Value::Int(value) => Ok(Atom::Number(value.to_f64())),
797        Value::Bool(flag) => Ok(Atom::Logical(*flag)),
798        Value::String(text) => {
799            if is_missing_text(text) {
800                Ok(Atom::Missing)
801            } else {
802                Ok(Atom::Text(text.clone()))
803            }
804        }
805        Value::CharArray(chars) if chars.rows == 1 => Ok(Atom::Text(chars.data.iter().collect())),
806        other => Ok(Atom::Text(format!("{other}"))),
807    }
808}
809
810fn findgroups_outputs(
811    columns: &[GroupColumn],
812    grouping: &Grouping,
813    table_mode: bool,
814) -> BuiltinResult<Vec<Value>> {
815    let g = Value::Tensor(
816        Tensor::new(grouping.ids.clone(), vec![grouping.ids.len(), 1]).map_err(grouping_error)?,
817    );
818    let mut outputs = vec![g];
819    if table_mode {
820        let mut names = Vec::with_capacity(columns.len());
821        let mut values = Vec::with_capacity(columns.len());
822        for column in columns {
823            names.push(column.name.clone());
824            values.push(select_rows(&column.value, &grouping.first_rows)?);
825        }
826        outputs.push(table_from_columns(names, values)?);
827    } else {
828        outputs.extend(group_label_outputs(columns, grouping)?);
829    }
830    Ok(outputs)
831}
832
833fn group_label_outputs(columns: &[GroupColumn], grouping: &Grouping) -> BuiltinResult<Vec<Value>> {
834    columns
835        .iter()
836        .map(|column| select_rows(&column.value, &grouping.first_rows))
837        .collect()
838}
839
840fn split_option_tail(args: Vec<Value>) -> BuiltinResult<(Vec<Value>, Vec<Value>)> {
841    let mut option_start = args.len();
842    for (idx, value) in args.iter().enumerate() {
843        if is_option_name(value) {
844            option_start = idx;
845            break;
846        }
847    }
848    if option_start < args.len() && !(args.len() - option_start).is_multiple_of(2) {
849        return Err(grouping_error(
850            "groupcounts: name-value options must be provided in pairs",
851        ));
852    }
853    Ok((args[..option_start].to_vec(), args[option_start..].to_vec()))
854}
855
856fn groupcounts_table(
857    object: ObjectInstance,
858    selector_args: Vec<Value>,
859    option_args: Vec<Value>,
860) -> BuiltinResult<Value> {
861    let all_names = table_variable_names_from_object(&object)?;
862    if selector_args.len() > 1 {
863        return Err(grouping_error(
864            "groupcounts: table input accepts one grouping variable selector before options",
865        ));
866    }
867    let selector = selector_args.first().ok_or_else(|| {
868        grouping_error("groupcounts: table input requires a grouping variable selector")
869    })?;
870    let options = GroupOptions::parse(&option_args, "groupcounts")?;
871    let selected = parse_name_selector(selector, &all_names, "groupcounts")?;
872    let variables = table_variables(&object)?;
873    let height = table_height(&object)?;
874    let mut columns = Vec::with_capacity(selected.len());
875    for name in &selected {
876        let value = variables
877            .fields
878            .get(name)
879            .cloned()
880            .ok_or_else(|| grouping_error(format!("groupcounts: missing variable '{name}'")))?;
881        columns.push(GroupColumn {
882            name: name.clone(),
883            rows: value_row_count(&value)?,
884            value,
885        });
886    }
887    let grouping = build_grouping_with_options(&columns, options)?;
888    let mut out_names = selected.clone();
889    let mut out_columns = group_label_outputs(&columns, &grouping)?;
890    out_names.push("GroupCount".to_string());
891    out_columns.push(Value::Tensor(
892        Tensor::new(
893            grouping
894                .row_groups
895                .iter()
896                .map(|rows| rows.len() as f64)
897                .collect(),
898            vec![grouping.keys.len(), 1],
899        )
900        .map_err(grouping_error)?,
901    ));
902    out_names.push("Percent".to_string());
903    out_columns.push(Value::Tensor(
904        Tensor::new(
905            grouping
906                .row_groups
907                .iter()
908                .map(|rows| {
909                    if height == 0 {
910                        0.0
911                    } else {
912                        rows.len() as f64 * 100.0 / height as f64
913                    }
914                })
915                .collect(),
916            vec![grouping.keys.len(), 1],
917        )
918        .map_err(grouping_error)?,
919    ));
920    table_from_columns(out_names, out_columns)
921}
922
923async fn accumarray_impl(subs: Value, data: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
924    let index_rows = accumarray_subscripts(subs)?;
925    let rows = index_rows.len();
926    let data_values = accumarray_data_values(data, rows)?;
927    let output_shape = if let Some(size_value) = rest.first() {
928        if is_empty_value(size_value) {
929            infer_accumarray_shape(&index_rows)
930        } else {
931            parse_size_vector(size_value, "accumarray")?
932        }
933    } else {
934        infer_accumarray_shape(&index_rows)
935    };
936    let fun = rest.get(1).filter(|value| !is_empty_value(value)).cloned();
937    let fill = rest.get(2).filter(|value| !is_empty_value(value)).cloned();
938    let issparse = rest
939        .get(3)
940        .map(|value| bool_scalar(value, "accumarray issparse"))
941        .transpose()?
942        .unwrap_or(false);
943    let output_len = checked_element_count(&output_shape, "accumarray")?;
944    if output_len > MAX_MATERIALIZED_ELEMENTS {
945        return Err(too_large_error("accumarray: output is too large"));
946    }
947    let mut buckets: BTreeMap<usize, Vec<f64>> = BTreeMap::new();
948    for (idx, subs) in index_rows.iter().enumerate() {
949        let lin = subscript_to_linear(subs, &output_shape)?;
950        buckets.entry(lin).or_default().push(data_values[idx]);
951    }
952    let fill_num = match fill.as_ref() {
953        None => 0.0,
954        Some(value) => numeric_scalar(value, "accumarray fill value")?,
955    };
956    let mut data_out = vec![fill_num; output_len];
957    if let Some(fun) = fun {
958        let mut cell_out = vec![Value::Num(fill_num); data_out.len()];
959        let mut all_numeric = true;
960        for (lin, values) in buckets {
961            let result = apply_accumarray_callback(fun.clone(), values).await?;
962            if let Some(num) = value_as_numeric_scalar(&result) {
963                data_out[lin] = num;
964                cell_out[lin] = Value::Num(num);
965            } else {
966                all_numeric = false;
967                cell_out[lin] = result;
968            }
969        }
970        if all_numeric {
971            return accumarray_numeric_output(data_out, output_shape, issparse);
972        }
973        if issparse {
974            return Err(grouping_error(
975                "accumarray: sparse output requires numeric scalar group results",
976            ));
977        }
978        let (rows, cols) = shape_to_rows_cols(&output_shape)?;
979        return CellArray::new(cell_out, rows, cols)
980            .map(Value::Cell)
981            .map_err(grouping_error);
982    }
983    for (lin, values) in buckets {
984        data_out[lin] = values.iter().sum();
985    }
986    accumarray_numeric_output(data_out, output_shape, issparse)
987}
988
989fn accumarray_subscripts(subs: Value) -> BuiltinResult<Vec<Vec<usize>>> {
990    match subs {
991        Value::Tensor(tensor) => {
992            if tensor.data.is_empty() {
993                return Ok(Vec::new());
994            }
995            if tensor.cols() <= 1 || tensor.rows() == 1 {
996                tensor
997                    .data
998                    .into_iter()
999                    .map(|value| Ok(vec![positive_integer(value, "accumarray subscript")?]))
1000                    .collect()
1001            } else {
1002                let mut out = Vec::with_capacity(tensor.rows());
1003                for row in 0..tensor.rows() {
1004                    let mut subs = Vec::with_capacity(tensor.cols());
1005                    for col in 0..tensor.cols() {
1006                        subs.push(positive_integer(
1007                            tensor.get2(row, col).map_err(grouping_error)?,
1008                            "accumarray subscript",
1009                        )?);
1010                    }
1011                    out.push(subs);
1012                }
1013                Ok(out)
1014            }
1015        }
1016        Value::Cell(cell) => {
1017            let mut columns = Vec::with_capacity(cell.data.len());
1018            for value in cell.data {
1019                let column = accumarray_subscripts(value)?;
1020                if column.iter().any(|row| row.len() != 1) {
1021                    return Err(grouping_error(
1022                        "accumarray: cell subscript entries must be vectors",
1023                    ));
1024                }
1025                columns.push(column.into_iter().map(|row| row[0]).collect::<Vec<_>>());
1026            }
1027            let rows = columns.first().map(Vec::len).unwrap_or(0);
1028            for column in &columns {
1029                if column.len() != rows {
1030                    return Err(grouping_error(
1031                        "accumarray: cell subscript vectors must have equal length",
1032                    ));
1033                }
1034            }
1035            let mut out = Vec::with_capacity(rows);
1036            for row in 0..rows {
1037                out.push(columns.iter().map(|column| column[row]).collect());
1038            }
1039            Ok(out)
1040        }
1041        other => Err(grouping_error(format!(
1042            "accumarray: unsupported subscript input {other:?}"
1043        ))),
1044    }
1045}
1046
1047fn accumarray_data_values(data: Value, rows: usize) -> BuiltinResult<Vec<f64>> {
1048    match data {
1049        Value::Num(value) => Ok(vec![value; rows]),
1050        Value::Int(value) => Ok(vec![value.to_f64(); rows]),
1051        Value::Bool(value) => Ok(vec![if value { 1.0 } else { 0.0 }; rows]),
1052        Value::Tensor(tensor) => {
1053            if tensor.data.len() == 1 {
1054                Ok(vec![tensor.data[0]; rows])
1055            } else if tensor.data.len() == rows {
1056                Ok(tensor.data)
1057            } else {
1058                Err(grouping_error(
1059                    "accumarray: data must be scalar or match subscript row count",
1060                ))
1061            }
1062        }
1063        Value::LogicalArray(array) => {
1064            if array.data.len() == 1 {
1065                Ok(vec![if array.data[0] != 0 { 1.0 } else { 0.0 }; rows])
1066            } else if array.data.len() == rows {
1067                Ok(array
1068                    .data
1069                    .into_iter()
1070                    .map(|flag| if flag != 0 { 1.0 } else { 0.0 })
1071                    .collect())
1072            } else {
1073                Err(grouping_error(
1074                    "accumarray: data must be scalar or match subscript row count",
1075                ))
1076            }
1077        }
1078        other => Err(grouping_error(format!(
1079            "accumarray: unsupported data input {other:?}"
1080        ))),
1081    }
1082}
1083
1084fn infer_accumarray_shape(index_rows: &[Vec<usize>]) -> Vec<usize> {
1085    let dims = index_rows.first().map(Vec::len).unwrap_or(1).max(1);
1086    let mut shape = vec![0usize; dims];
1087    for row in index_rows {
1088        for (dim, idx) in row.iter().enumerate() {
1089            shape[dim] = shape[dim].max(*idx);
1090        }
1091    }
1092    if dims == 1 {
1093        vec![shape[0], 1]
1094    } else {
1095        shape
1096    }
1097}
1098
1099fn subscript_to_linear(subs: &[usize], shape: &[usize]) -> BuiltinResult<usize> {
1100    if subs.len() > shape.len() {
1101        return Err(grouping_error("accumarray: too many subscript dimensions"));
1102    }
1103    let mut linear = 0usize;
1104    let mut stride = 1usize;
1105    for (dim, &size) in shape.iter().enumerate() {
1106        let sub = subs.get(dim).copied().unwrap_or(1);
1107        if sub == 0 || sub > size {
1108            return Err(grouping_error("accumarray: subscript exceeds output size"));
1109        }
1110        linear = linear
1111            .checked_add(
1112                (sub - 1)
1113                    .checked_mul(stride)
1114                    .ok_or_else(|| too_large_error("accumarray: output linear index overflow"))?,
1115            )
1116            .ok_or_else(|| too_large_error("accumarray: output linear index overflow"))?;
1117        stride = stride
1118            .checked_mul(size)
1119            .ok_or_else(|| too_large_error("accumarray: output size overflow"))?;
1120    }
1121    Ok(linear)
1122}
1123
1124async fn apply_accumarray_callback(func: Value, values: Vec<f64>) -> BuiltinResult<Value> {
1125    if let Some(name) = function_name(&func) {
1126        match name.to_ascii_lowercase().as_str() {
1127            "sum" => return Ok(Value::Num(values.iter().sum())),
1128            "mean" => {
1129                return Ok(Value::Num(if values.is_empty() {
1130                    f64::NAN
1131                } else {
1132                    values.iter().sum::<f64>() / values.len() as f64
1133                }))
1134            }
1135            "numel" | "length" => return Ok(Value::Num(values.len() as f64)),
1136            "min" => return Ok(Value::Num(values.into_iter().fold(f64::INFINITY, f64::min))),
1137            "max" => {
1138                return Ok(Value::Num(
1139                    values.into_iter().fold(f64::NEG_INFINITY, f64::max),
1140                ))
1141            }
1142            _ => {}
1143        }
1144    }
1145    let arg =
1146        Value::Tensor(Tensor::new(values.clone(), vec![values.len(), 1]).map_err(grouping_error)?);
1147    call_feval_async_with_outputs(func, &[arg], 1)
1148        .await
1149        .map_err(|err| callback_error("accumarray: callback failed", Some(err)))
1150}
1151
1152fn accumarray_numeric_output(
1153    data: Vec<f64>,
1154    shape: Vec<usize>,
1155    issparse: bool,
1156) -> BuiltinResult<Value> {
1157    if issparse {
1158        let (rows, cols) = shape_to_rows_cols(&shape)?;
1159        if shape.len() > 2 {
1160            return Err(grouping_error(
1161                "accumarray: sparse output is only supported for 2-D results",
1162            ));
1163        }
1164        let mut col_ptrs = Vec::with_capacity(cols + 1);
1165        let mut row_indices = Vec::new();
1166        let mut values = Vec::new();
1167        col_ptrs.push(0);
1168        for col in 0..cols {
1169            for row in 0..rows {
1170                let value = data[row + col * rows];
1171                if value != 0.0 {
1172                    row_indices.push(row);
1173                    values.push(value);
1174                }
1175            }
1176            col_ptrs.push(values.len());
1177        }
1178        return SparseTensor::new(rows, cols, col_ptrs, row_indices, values)
1179            .map(Value::SparseTensor)
1180            .map_err(grouping_error);
1181    }
1182    Tensor::new(data, shape)
1183        .map(Value::Tensor)
1184        .map_err(grouping_error)
1185}
1186
1187fn discretize_impl(x: Value, edges_or_n: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1188    let values = numeric_values(&x, "discretize X")?;
1189    let shape = value_shape(&x);
1190    let (edges, labels, included_right) = parse_discretize_args(&values, edges_or_n, rest)?;
1191    if edges.len() < 2 {
1192        return Err(grouping_error(
1193            "discretize: at least two bin edges are required",
1194        ));
1195    }
1196    let bins = values
1197        .iter()
1198        .map(|value| discretize_one(*value, &edges, included_right))
1199        .collect::<Vec<_>>();
1200    if let Some(labels) = labels {
1201        let data = bins
1202            .iter()
1203            .map(|bin| match bin {
1204                Some(idx) => labels.get(*idx - 1).cloned().unwrap_or_default(),
1205                None => String::new(),
1206            })
1207            .collect::<Vec<_>>();
1208        return StringArray::new(data, shape)
1209            .map(Value::StringArray)
1210            .map_err(grouping_error);
1211    }
1212    Tensor::new(
1213        bins.into_iter()
1214            .map(|bin| bin.map(|idx| idx as f64).unwrap_or(f64::NAN))
1215            .collect(),
1216        shape,
1217    )
1218    .map(Value::Tensor)
1219    .map_err(grouping_error)
1220}
1221
1222fn parse_discretize_args(
1223    values: &[f64],
1224    edges_or_n: Value,
1225    rest: Vec<Value>,
1226) -> BuiltinResult<(Vec<f64>, Option<Vec<String>>, bool)> {
1227    let mut labels = None;
1228    let mut included_right = false;
1229    let mut idx = 0usize;
1230    if let Some(first) = rest.first() {
1231        if !is_option_name(first) {
1232            labels = Some(string_list(first)?);
1233            idx = 1;
1234        }
1235    }
1236    while idx < rest.len() {
1237        if idx + 1 >= rest.len() {
1238            return Err(grouping_error(
1239                "discretize: name-value options must be provided in pairs",
1240            ));
1241        }
1242        let name = scalar_text(&rest[idx], "discretize option")?;
1243        if name.eq_ignore_ascii_case("IncludedEdge") {
1244            let edge = scalar_text(&rest[idx + 1], "IncludedEdge")?;
1245            included_right = match edge.to_ascii_lowercase().as_str() {
1246                "right" => true,
1247                "left" => false,
1248                other => {
1249                    return Err(grouping_error(format!(
1250                        "discretize: unsupported IncludedEdge '{other}'"
1251                    )))
1252                }
1253            };
1254        } else {
1255            return Err(grouping_error(format!(
1256                "discretize: unsupported option '{name}'"
1257            )));
1258        }
1259        idx += 2;
1260    }
1261    let edges = match edges_or_n {
1262        Value::Num(n) if is_positive_integer_f64(n) => equal_width_edges(values, n as usize)?,
1263        Value::Int(n) if n.to_i64() > 0 => equal_width_edges(values, n.to_i64() as usize)?,
1264        other => numeric_values(&other, "discretize edges")?,
1265    };
1266    if let Some(labels) = &labels {
1267        if labels.len() != edges.len().saturating_sub(1) {
1268            return Err(grouping_error(
1269                "discretize: number of labels must match number of bins",
1270            ));
1271        }
1272    }
1273    Ok((edges, labels, included_right))
1274}
1275
1276fn discretize_one(value: f64, edges: &[f64], included_right: bool) -> Option<usize> {
1277    if value.is_nan() {
1278        return None;
1279    }
1280    for bin in 0..edges.len() - 1 {
1281        let lower = edges[bin];
1282        let upper = edges[bin + 1];
1283        let hit = if included_right {
1284            (value > lower || (bin == 0 && value == lower)) && value <= upper
1285        } else {
1286            value >= lower && (value < upper || (bin == edges.len() - 2 && value == upper))
1287        };
1288        if hit {
1289            return Some(bin + 1);
1290        }
1291    }
1292    None
1293}
1294
1295fn equal_width_edges(values: &[f64], bins: usize) -> BuiltinResult<Vec<f64>> {
1296    if bins == 0 {
1297        return Err(grouping_error(
1298            "discretize: number of bins must be positive",
1299        ));
1300    }
1301    let finite = values
1302        .iter()
1303        .copied()
1304        .filter(|value| value.is_finite())
1305        .collect::<Vec<_>>();
1306    if finite.is_empty() {
1307        return Err(grouping_error(
1308            "discretize: cannot infer equal-width bins from all-missing data",
1309        ));
1310    }
1311    let min = finite.iter().copied().fold(f64::INFINITY, f64::min);
1312    let max = finite.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1313    if min == max {
1314        let half = 0.5;
1315        return Ok((0..=bins)
1316            .map(|idx| min - half + idx as f64 / bins as f64)
1317            .collect());
1318    }
1319    let step = (max - min) / bins as f64;
1320    Ok((0..=bins).map(|idx| min + idx as f64 * step).collect())
1321}
1322
1323fn combinations_impl(first: Value, rest: Vec<Value>) -> BuiltinResult<Value> {
1324    let mut values = vec![first];
1325    let mut options_start = rest.len();
1326    let mut idx = 0usize;
1327    while idx < rest.len() {
1328        if is_option_name(&rest[idx]) {
1329            options_start = idx;
1330            break;
1331        }
1332        values.push(rest[idx].clone());
1333        idx += 1;
1334    }
1335    let mut names = (0..values.len())
1336        .map(|idx| format!("Var{}", idx + 1))
1337        .collect::<Vec<_>>();
1338    if options_start < rest.len() {
1339        let mut opt = options_start;
1340        while opt < rest.len() {
1341            if opt + 1 >= rest.len() {
1342                return Err(grouping_error(
1343                    "combinations: name-value options must be provided in pairs",
1344                ));
1345            }
1346            let name = scalar_text(&rest[opt], "combinations option")?;
1347            if name.eq_ignore_ascii_case("VariableNames") {
1348                names = string_list(&rest[opt + 1])?;
1349                if names.len() != values.len() {
1350                    return Err(grouping_error(
1351                        "combinations: VariableNames length must match input count",
1352                    ));
1353                }
1354            } else {
1355                return Err(grouping_error(format!(
1356                    "combinations: unsupported option '{name}'"
1357                )));
1358            }
1359            opt += 2;
1360        }
1361    }
1362    let columns = values
1363        .into_iter()
1364        .map(vector_elements)
1365        .collect::<BuiltinResult<Vec<_>>>()?;
1366    let row_count = columns.iter().try_fold(1usize, |acc, column| {
1367        acc.checked_mul(column.len())
1368            .ok_or_else(|| too_large_error("combinations: output row count overflow"))
1369    })?;
1370    if row_count > MAX_MATERIALIZED_ELEMENTS {
1371        return Err(too_large_error("combinations: output is too large"));
1372    }
1373    let mut out_columns = Vec::with_capacity(columns.len());
1374    for col_idx in 0..columns.len() {
1375        let repeat_inner = columns[col_idx + 1..]
1376            .iter()
1377            .map(Vec::len)
1378            .product::<usize>()
1379            .max(1);
1380        let repeat_outer = columns[..col_idx]
1381            .iter()
1382            .map(Vec::len)
1383            .product::<usize>()
1384            .max(1);
1385        let mut values = Vec::with_capacity(row_count);
1386        for _ in 0..repeat_outer {
1387            for item in &columns[col_idx] {
1388                for _ in 0..repeat_inner {
1389                    values.push(item.clone());
1390                }
1391            }
1392        }
1393        out_columns.push(collect_column_values(values, row_count)?);
1394    }
1395    table_from_columns(names, out_columns)
1396}
1397
1398fn parse_name_selector(
1399    value: &Value,
1400    names: &[String],
1401    context: &str,
1402) -> BuiltinResult<Vec<String>> {
1403    match value {
1404        Value::String(text) => {
1405            if names.contains(text) {
1406                Ok(vec![text.clone()])
1407            } else {
1408                Err(grouping_error(format!(
1409                    "{context}: unknown variable '{text}'"
1410                )))
1411            }
1412        }
1413        Value::CharArray(chars) if chars.rows == 1 => {
1414            let text: String = chars.data.iter().collect();
1415            parse_name_selector(&Value::String(text), names, context)
1416        }
1417        Value::StringArray(array) => array
1418            .data
1419            .iter()
1420            .map(|name| {
1421                if names.contains(name) {
1422                    Ok(name.clone())
1423                } else {
1424                    Err(grouping_error(format!(
1425                        "{context}: unknown variable '{name}'"
1426                    )))
1427                }
1428            })
1429            .collect(),
1430        Value::Cell(cell) => cell
1431            .data
1432            .iter()
1433            .map(|value| scalar_text(value, context))
1434            .map(|res| {
1435                let name = res?;
1436                if names.contains(&name) {
1437                    Ok(name)
1438                } else {
1439                    Err(grouping_error(format!(
1440                        "{context}: unknown variable '{name}'"
1441                    )))
1442                }
1443            })
1444            .collect(),
1445        Value::Tensor(tensor) => tensor
1446            .data
1447            .iter()
1448            .map(|value| {
1449                let idx = positive_integer(*value, context)?;
1450                names.get(idx - 1).cloned().ok_or_else(|| {
1451                    grouping_error(format!("{context}: variable index out of range"))
1452                })
1453            })
1454            .collect(),
1455        other => Err(grouping_error(format!(
1456            "{context}: unsupported variable selector {other:?}"
1457        ))),
1458    }
1459}
1460
1461async fn gather_values(values: Vec<Value>) -> BuiltinResult<Vec<Value>> {
1462    let mut out = Vec::with_capacity(values.len());
1463    for value in values {
1464        out.push(gather_if_needed_async(&value).await?);
1465    }
1466    Ok(out)
1467}
1468
1469fn normalize_outputs(value: Value, requested: usize, context: &str) -> BuiltinResult<Vec<Value>> {
1470    match value {
1471        Value::OutputList(values) if values.len() == requested => Ok(values),
1472        Value::OutputList(values) => Err(callback_error(
1473            format!(
1474                "{context}: callback returned {} outputs but {} were requested",
1475                values.len(),
1476                requested
1477            ),
1478            None,
1479        )),
1480        value if requested == 1 => Ok(vec![value]),
1481        _ => Err(callback_error(
1482            format!("{context}: callback did not return the requested number of outputs"),
1483            None,
1484        )),
1485    }
1486}
1487
1488fn collect_group_results(values: Vec<Value>, rows: usize, context: &str) -> BuiltinResult<Value> {
1489    if values
1490        .iter()
1491        .all(|value| value_as_numeric_scalar(value).is_some())
1492    {
1493        return Tensor::new(
1494            values
1495                .iter()
1496                .map(|value| value_as_numeric_scalar(value).unwrap())
1497                .collect(),
1498            vec![rows, 1],
1499        )
1500        .map(Value::Tensor)
1501        .map_err(grouping_error);
1502    }
1503    CellArray::new(values, rows, 1)
1504        .map(Value::Cell)
1505        .map_err(|err| callback_error(format!("{context}: {err}"), None))
1506}
1507
1508fn collect_column_values(values: Vec<Value>, rows: usize) -> BuiltinResult<Value> {
1509    if values
1510        .iter()
1511        .all(|value| value_as_numeric_scalar(value).is_some())
1512    {
1513        return Tensor::new(
1514            values
1515                .iter()
1516                .map(|value| value_as_numeric_scalar(value).unwrap())
1517                .collect(),
1518            vec![rows, 1],
1519        )
1520        .map(Value::Tensor)
1521        .map_err(grouping_error);
1522    }
1523    if values.iter().all(|value| matches!(value, Value::String(_))) {
1524        return StringArray::new(
1525            values
1526                .into_iter()
1527                .map(|value| match value {
1528                    Value::String(text) => text,
1529                    _ => unreachable!("checked above"),
1530                })
1531                .collect(),
1532            vec![rows, 1],
1533        )
1534        .map(Value::StringArray)
1535        .map_err(grouping_error);
1536    }
1537    if values.iter().all(|value| matches!(value, Value::Bool(_))) {
1538        return LogicalArray::new(
1539            values
1540                .into_iter()
1541                .map(|value| match value {
1542                    Value::Bool(flag) => u8::from(flag),
1543                    _ => unreachable!("checked above"),
1544                })
1545                .collect(),
1546            vec![rows, 1],
1547        )
1548        .map(Value::LogicalArray)
1549        .map_err(grouping_error);
1550    }
1551    CellArray::new(values, rows, 1)
1552        .map(Value::Cell)
1553        .map_err(grouping_error)
1554}
1555
1556fn vector_elements(value: Value) -> BuiltinResult<Vec<Value>> {
1557    match value {
1558        Value::Tensor(tensor) => Ok(tensor.data.into_iter().map(Value::Num).collect()),
1559        Value::LogicalArray(array) => Ok(array
1560            .data
1561            .into_iter()
1562            .map(|flag| Value::Bool(flag != 0))
1563            .collect()),
1564        Value::StringArray(array) => Ok(array.data.into_iter().map(Value::String).collect()),
1565        Value::Cell(cell) => Ok(cell.data),
1566        Value::CharArray(chars) if chars.rows == 1 => Ok(chars
1567            .data
1568            .into_iter()
1569            .map(|ch| Value::String(ch.to_string()))
1570            .collect()),
1571        Value::String(text) => Ok(vec![Value::String(text)]),
1572        other => Ok(vec![other]),
1573    }
1574}
1575
1576fn multi_output(outputs: Vec<Value>) -> BuiltinResult<Value> {
1577    if let Some(out_count) = crate::output_count::current_output_count() {
1578        if out_count == 0 {
1579            return Ok(Value::OutputList(Vec::new()));
1580        }
1581        return Ok(crate::output_count::output_list_with_padding(
1582            out_count, outputs,
1583        ));
1584    }
1585    Ok(outputs
1586        .into_iter()
1587        .next()
1588        .unwrap_or(Value::OutputList(Vec::new())))
1589}
1590
1591fn numeric_values(value: &Value, context: &str) -> BuiltinResult<Vec<f64>> {
1592    match value {
1593        Value::Num(value) => Ok(vec![*value]),
1594        Value::Int(value) => Ok(vec![value.to_f64()]),
1595        Value::Bool(value) => Ok(vec![if *value { 1.0 } else { 0.0 }]),
1596        Value::Tensor(tensor) => Ok(tensor.data.clone()),
1597        Value::LogicalArray(array) => Ok(array
1598            .data
1599            .iter()
1600            .map(|flag| if *flag != 0 { 1.0 } else { 0.0 })
1601            .collect()),
1602        Value::SparseTensor(sparse) => sparse
1603            .to_dense()
1604            .map(|tensor| tensor.data)
1605            .map_err(grouping_error),
1606        other => Err(grouping_error(format!(
1607            "{context}: expected numeric input, got {other:?}"
1608        ))),
1609    }
1610}
1611
1612fn value_shape(value: &Value) -> Vec<usize> {
1613    match value {
1614        Value::Tensor(tensor) => tensor.shape.clone(),
1615        Value::LogicalArray(array) => array.shape.clone(),
1616        Value::StringArray(array) => array.shape.clone(),
1617        Value::SparseTensor(sparse) => sparse.shape(),
1618        Value::Num(_) | Value::Int(_) | Value::Bool(_) => vec![1, 1],
1619        _ => vec![1, 1],
1620    }
1621}
1622
1623fn parse_size_vector(value: &Value, context: &str) -> BuiltinResult<Vec<usize>> {
1624    let values = numeric_values(value, context)?;
1625    let mut dims = Vec::with_capacity(values.len());
1626    for value in values {
1627        dims.push(nonnegative_integer(value, context)?);
1628    }
1629    if dims.is_empty() {
1630        return Err(grouping_error(format!(
1631            "{context}: size vector must not be empty"
1632        )));
1633    }
1634    if dims.len() == 1 {
1635        dims.push(1);
1636    }
1637    Ok(dims)
1638}
1639
1640fn shape_to_rows_cols(shape: &[usize]) -> BuiltinResult<(usize, usize)> {
1641    let rows = shape.first().copied().unwrap_or(0);
1642    let cols = if shape.len() <= 1 {
1643        1
1644    } else if shape.len() == 2 {
1645        shape[1]
1646    } else {
1647        shape[1..].iter().product()
1648    };
1649    Ok((rows, cols))
1650}
1651
1652fn checked_element_count(shape: &[usize], context: &str) -> BuiltinResult<usize> {
1653    shape.iter().try_fold(1usize, |acc, dim| {
1654        acc.checked_mul(*dim)
1655            .ok_or_else(|| too_large_error(format!("{context}: output size overflow")))
1656    })
1657}
1658
1659fn scalar_text(value: &Value, context: &str) -> BuiltinResult<String> {
1660    match value {
1661        Value::String(text) => Ok(text.clone()),
1662        Value::CharArray(chars) if chars.rows == 1 => Ok(chars.data.iter().collect()),
1663        Value::StringArray(array) if array.data.len() == 1 => Ok(array.data[0].clone()),
1664        other => Err(grouping_error(format!(
1665            "{context}: expected text scalar, got {other:?}"
1666        ))),
1667    }
1668}
1669
1670fn string_list(value: &Value) -> BuiltinResult<Vec<String>> {
1671    match value {
1672        Value::String(text) => Ok(vec![text.clone()]),
1673        Value::CharArray(chars) if chars.rows == 1 => Ok(vec![chars.data.iter().collect()]),
1674        Value::StringArray(array) => Ok(array.data.clone()),
1675        Value::Cell(cell) => cell
1676            .data
1677            .iter()
1678            .map(|value| scalar_text(value, "string list"))
1679            .collect(),
1680        other => Err(grouping_error(format!("expected text list, got {other:?}"))),
1681    }
1682}
1683
1684fn bool_scalar(value: &Value, context: &str) -> BuiltinResult<bool> {
1685    match value {
1686        Value::Bool(flag) => Ok(*flag),
1687        Value::Num(value) => Ok(*value != 0.0),
1688        Value::Int(value) => Ok(value.to_i64() != 0),
1689        Value::LogicalArray(array) if array.data.len() == 1 => Ok(array.data[0] != 0),
1690        other => Err(grouping_error(format!(
1691            "{context}: expected logical scalar, got {other:?}"
1692        ))),
1693    }
1694}
1695
1696fn numeric_scalar(value: &Value, context: &str) -> BuiltinResult<f64> {
1697    value_as_numeric_scalar(value)
1698        .ok_or_else(|| grouping_error(format!("{context}: expected numeric scalar")))
1699}
1700
1701fn value_as_numeric_scalar(value: &Value) -> Option<f64> {
1702    match value {
1703        Value::Num(value) => Some(*value),
1704        Value::Int(value) => Some(value.to_f64()),
1705        Value::Bool(value) => Some(if *value { 1.0 } else { 0.0 }),
1706        Value::Tensor(tensor) if tensor.data.len() == 1 => Some(tensor.data[0]),
1707        Value::LogicalArray(array) if array.data.len() == 1 => {
1708            Some(if array.data[0] != 0 { 1.0 } else { 0.0 })
1709        }
1710        _ => None,
1711    }
1712}
1713
1714fn is_empty_value(value: &Value) -> bool {
1715    match value {
1716        Value::Tensor(tensor) => tensor.data.is_empty(),
1717        Value::StringArray(array) => array.data.is_empty(),
1718        Value::Cell(cell) => cell.data.is_empty(),
1719        Value::CharArray(chars) => chars.data.is_empty(),
1720        _ => false,
1721    }
1722}
1723
1724fn is_option_name(value: &Value) -> bool {
1725    scalar_text(value, "option")
1726        .map(|text| {
1727            matches!(
1728                text.to_ascii_lowercase().as_str(),
1729                "includededge" | "variablenames" | "includemissinggroups" | "includeemptygroups"
1730            )
1731        })
1732        .unwrap_or(false)
1733}
1734
1735fn function_name(value: &Value) -> Option<&str> {
1736    match value {
1737        Value::FunctionHandle(name)
1738        | Value::ExternalFunctionHandle(name)
1739        | Value::MethodFunctionHandle(name)
1740        | Value::BoundFunctionHandle { name, .. }
1741        | Value::String(name) => Some(name.as_str()),
1742        _ => None,
1743    }
1744}
1745
1746fn positive_integer(value: f64, context: &str) -> BuiltinResult<usize> {
1747    if is_positive_integer_f64(value) {
1748        Ok(value as usize)
1749    } else {
1750        Err(grouping_error(format!(
1751            "{context}: expected positive integer"
1752        )))
1753    }
1754}
1755
1756fn nonnegative_integer(value: f64, context: &str) -> BuiltinResult<usize> {
1757    if value.is_finite() && value >= 0.0 && value.fract() == 0.0 {
1758        Ok(value as usize)
1759    } else {
1760        Err(grouping_error(format!(
1761            "{context}: expected nonnegative integer"
1762        )))
1763    }
1764}
1765
1766fn is_positive_integer_f64(value: f64) -> bool {
1767    value.is_finite() && value > 0.0 && value.fract() == 0.0
1768}
1769
1770fn format_key_number(value: f64) -> String {
1771    if value.fract() == 0.0 && value.abs() < 1e15 {
1772        format!("{}", value as i64)
1773    } else {
1774        let mut text = format!("{value:.12}");
1775        while text.contains('.') && text.ends_with('0') {
1776            text.pop();
1777        }
1778        if text.ends_with('.') {
1779            text.pop();
1780        }
1781        text
1782    }
1783}
1784
1785fn is_missing_text(text: &str) -> bool {
1786    text.eq_ignore_ascii_case("<missing>")
1787}
1788
1789#[cfg(test)]
1790mod tests {
1791    use super::*;
1792    use futures::executor::block_on;
1793
1794    #[test]
1795    fn accumarray_sums_vector_and_matrix_subscripts() {
1796        let out = block_on(accumarray_builtin(
1797            Value::Tensor(Tensor::new(vec![1.0, 3.0, 4.0, 2.0, 4.0, 1.0], vec![6, 1]).unwrap()),
1798            Value::Tensor(Tensor::new(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![6, 1]).unwrap()),
1799            Vec::new(),
1800        ))
1801        .unwrap();
1802        match out {
1803            Value::Tensor(tensor) => assert_eq!(tensor.data, vec![7.0, 4.0, 2.0, 8.0]),
1804            other => panic!("expected tensor, got {other:?}"),
1805        }
1806
1807        let out = block_on(accumarray_builtin(
1808            Value::Tensor(
1809                Tensor::new(
1810                    vec![1.0, 2.0, 3.0, 1.0, 2.0, 4.0, 1.0, 2.0, 2.0, 1.0, 2.0, 1.0],
1811                    vec![6, 2],
1812                )
1813                .unwrap(),
1814            ),
1815            Value::Tensor(Tensor::new(vec![1.0, 2.0, 3.0, 4.0, 5.0, 6.0], vec![6, 1]).unwrap()),
1816            Vec::new(),
1817        ))
1818        .unwrap();
1819        match out {
1820            Value::Tensor(tensor) => {
1821                assert_eq!(tensor.shape, vec![4, 2]);
1822                assert_eq!(tensor.data, vec![5.0, 0.0, 0.0, 6.0, 0.0, 7.0, 3.0, 0.0]);
1823            }
1824            other => panic!("expected tensor, got {other:?}"),
1825        }
1826    }
1827
1828    #[test]
1829    fn discretize_assigns_bins_and_labels() {
1830        let out = block_on(discretize_builtin(
1831            Value::Tensor(Tensor::new(vec![0.0, 0.2, 1.0, 2.5], vec![4, 1]).unwrap()),
1832            Value::Tensor(Tensor::new(vec![0.0, 1.0, 2.0], vec![1, 3]).unwrap()),
1833            Vec::new(),
1834        ))
1835        .unwrap();
1836        match out {
1837            Value::Tensor(tensor) => {
1838                assert_eq!(tensor.data[0], 1.0);
1839                assert_eq!(tensor.data[1], 1.0);
1840                assert_eq!(tensor.data[2], 2.0);
1841                assert!(tensor.data[3].is_nan());
1842            }
1843            other => panic!("expected tensor, got {other:?}"),
1844        }
1845    }
1846
1847    #[test]
1848    fn findgroups_groupcounts_and_grp2idx_share_order() {
1849        let groups = Value::StringArray(
1850            StringArray::new(
1851                vec!["b".into(), "a".into(), "b".into(), "<missing>".into()],
1852                vec![4, 1],
1853            )
1854            .unwrap(),
1855        );
1856        let out = block_on(findgroups_builtin(groups.clone(), Vec::new())).unwrap();
1857        match out {
1858            Value::Tensor(tensor) => {
1859                assert_eq!(tensor.data[0], 2.0);
1860                assert_eq!(tensor.data[1], 1.0);
1861                assert_eq!(tensor.data[2], 2.0);
1862                assert!(tensor.data[3].is_nan());
1863            }
1864            other => panic!("expected tensor, got {other:?}"),
1865        }
1866        let counted = block_on(groupcounts_builtin(groups.clone(), Vec::new())).unwrap();
1867        match counted {
1868            Value::Tensor(tensor) => assert_eq!(tensor.data, vec![1.0, 2.0]),
1869            other => panic!("expected tensor, got {other:?}"),
1870        }
1871        let indexed = block_on(grp2idx_builtin(groups)).unwrap();
1872        match indexed {
1873            Value::Tensor(tensor) => assert!(tensor.data[3].is_nan()),
1874            other => panic!("expected tensor, got {other:?}"),
1875        }
1876
1877        let empty_is_group = block_on(groupcounts_builtin(
1878            Value::StringArray(
1879                StringArray::new(vec![String::new(), "a".into(), String::new()], vec![3, 1])
1880                    .unwrap(),
1881            ),
1882            Vec::new(),
1883        ))
1884        .unwrap();
1885        match empty_is_group {
1886            Value::Tensor(tensor) => assert_eq!(tensor.data, vec![2.0, 1.0]),
1887            other => panic!("expected tensor, got {other:?}"),
1888        }
1889    }
1890
1891    #[test]
1892    fn combinations_returns_table_columns() {
1893        let out = block_on(combinations_builtin(
1894            Value::Tensor(Tensor::new(vec![1.0, 2.0], vec![1, 2]).unwrap()),
1895            vec![Value::StringArray(
1896                StringArray::new(vec!["x".into(), "y".into()], vec![1, 2]).unwrap(),
1897            )],
1898        ))
1899        .unwrap();
1900        let Value::Object(object) = out else {
1901            panic!("expected table");
1902        };
1903        assert!(is_tabular_object(&object));
1904        assert_eq!(table_height(&object).unwrap(), 4);
1905    }
1906
1907    #[test]
1908    fn accumarray_supports_callbacks_and_sparse_output() {
1909        let out = block_on(accumarray_builtin(
1910            Value::Tensor(Tensor::new(vec![1.0, 1.0, 2.0, 2.0], vec![4, 1]).unwrap()),
1911            Value::Tensor(Tensor::new(vec![1.0, 3.0, 5.0, 7.0], vec![4, 1]).unwrap()),
1912            vec![
1913                Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).unwrap()),
1914                Value::FunctionHandle("mean".into()),
1915            ],
1916        ))
1917        .unwrap();
1918        match out {
1919            Value::Tensor(tensor) => assert_eq!(tensor.data, vec![2.0, 6.0]),
1920            other => panic!("expected tensor, got {other:?}"),
1921        }
1922
1923        let sparse = block_on(accumarray_builtin(
1924            Value::Tensor(Tensor::new(vec![1.0, 3.0], vec![2, 1]).unwrap()),
1925            Value::Num(1.0),
1926            vec![
1927                Value::Tensor(Tensor::new(vec![4.0, 1.0], vec![1, 2]).unwrap()),
1928                Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).unwrap()),
1929                Value::Tensor(Tensor::new(Vec::new(), vec![0, 0]).unwrap()),
1930                Value::Bool(true),
1931            ],
1932        ))
1933        .unwrap();
1934        match sparse {
1935            Value::SparseTensor(st) => {
1936                assert_eq!(st.rows, 4);
1937                assert_eq!(st.cols, 1);
1938                assert_eq!(st.nnz(), 2);
1939            }
1940            other => panic!("expected sparse tensor, got {other:?}"),
1941        }
1942    }
1943
1944    #[test]
1945    fn splitapply_invokes_callback_by_group() {
1946        let out = block_on(splitapply_builtin(
1947            Value::FunctionHandle("sum".into()),
1948            Value::Tensor(Tensor::new(vec![1.0, 2.0, 3.0, 4.0], vec![4, 1]).unwrap()),
1949            vec![Value::Tensor(
1950                Tensor::new(vec![2.0, 1.0, 2.0, 1.0], vec![4, 1]).unwrap(),
1951            )],
1952        ))
1953        .unwrap();
1954        match out {
1955            Value::Tensor(tensor) => assert_eq!(tensor.data, vec![6.0, 4.0]),
1956            other => panic!("expected tensor, got {other:?}"),
1957        }
1958
1959        let err = block_on(splitapply_builtin(
1960            Value::FunctionHandle("sum".into()),
1961            Value::Tensor(Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap()),
1962            vec![Value::Tensor(
1963                Tensor::new(vec![1.0, 1.0], vec![2, 1]).unwrap(),
1964            )],
1965        ))
1966        .unwrap_err();
1967        assert!(err.message().contains("must have 2 rows"));
1968    }
1969
1970    #[test]
1971    fn groupcounts_table_returns_count_and_percent_columns() {
1972        let table = table_from_columns(
1973            vec!["G".into(), "X".into()],
1974            vec![
1975                Value::StringArray(
1976                    StringArray::new(vec!["b".into(), "a".into(), "b".into()], vec![3, 1]).unwrap(),
1977                ),
1978                Value::Tensor(Tensor::new(vec![1.0, 2.0, 3.0], vec![3, 1]).unwrap()),
1979            ],
1980        )
1981        .unwrap();
1982        let out = block_on(groupcounts_builtin(table, vec![Value::from("G")])).unwrap();
1983        let Value::Object(object) = out else {
1984            panic!("expected table");
1985        };
1986        let names = table_variable_names_from_object(&object).unwrap();
1987        assert_eq!(names, vec!["G", "GroupCount", "Percent"]);
1988        assert_eq!(table_height(&object).unwrap(), 2);
1989
1990        let table = table_from_columns(
1991            vec!["G".into()],
1992            vec![Value::StringArray(
1993                StringArray::new(vec!["a".into(), "<missing>".into()], vec![2, 1]).unwrap(),
1994            )],
1995        )
1996        .unwrap();
1997        let out = block_on(groupcounts_builtin(
1998            table,
1999            vec![
2000                Value::from("G"),
2001                Value::from("IncludeMissingGroups"),
2002                Value::Bool(true),
2003            ],
2004        ))
2005        .unwrap();
2006        let Value::Object(object) = out else {
2007            panic!("expected table");
2008        };
2009        assert_eq!(table_height(&object).unwrap(), 2);
2010    }
2011}