Skip to main content

runmat_runtime/builtins/table/object/
analytics.rs

1use super::selectors::parse_variable_selector;
2use super::*;
3use runmat_value::IntValue;
4
5mod grpstats;
6
7pub(in crate::builtins::table) use grpstats::grpstats_impl;
8
9pub fn sortrows_table(value: Value, rest: &[Value]) -> BuiltinResult<(Value, Tensor)> {
10    let object = into_table_object(value, "sortrows")?;
11    let names = table_variable_names_from_object(&object)?;
12    let sort_spec = SortSpec::parse(rest, &names)?;
13    let height = table_height(&object)?;
14    let variables = table_variables(&object)?;
15    let mut indices: Vec<usize> = (0..height).collect();
16    indices.sort_by(|&a, &b| {
17        for key in &sort_spec.keys {
18            let Some(value) = variables.fields.get(&key.name) else {
19                continue;
20            };
21            let ord = compare_table_cells(value, a, b).unwrap_or(Ordering::Equal);
22            let ord = if key.descending { ord.reverse() } else { ord };
23            if ord != Ordering::Equal {
24                return ord;
25            }
26        }
27        a.cmp(&b)
28    });
29    let mut sorted_columns = Vec::with_capacity(names.len());
30    for name in &names {
31        let value = variables
32            .fields
33            .get(name)
34            .ok_or_else(|| invalid_variable(format!("table: missing variable '{name}'")))?;
35        sorted_columns.push(select_rows(value, &indices)?);
36    }
37    let row_names = selected_row_names(&object, &indices)?;
38    let sorted = table_from_columns_with_properties(names, sorted_columns, row_names)?;
39    let indices_tensor = Tensor::new(
40        indices.iter().map(|idx| *idx as f64 + 1.0).collect(),
41        vec![indices.len(), 1],
42    )
43    .map_err(invalid_variable)?;
44    Ok((sorted, indices_tensor))
45}
46
47pub(in crate::builtins::table) struct SortSpec {
48    keys: Vec<SortKey>,
49}
50
51pub(in crate::builtins::table) struct SortKey {
52    name: String,
53    descending: bool,
54}
55
56impl SortSpec {
57    fn parse(rest: &[Value], names: &[String]) -> BuiltinResult<Self> {
58        let mut keys = if rest.is_empty() {
59            names
60                .iter()
61                .map(|name| SortKey {
62                    name: name.clone(),
63                    descending: false,
64                })
65                .collect::<Vec<_>>()
66        } else {
67            parse_variable_selector(rest.first(), names)?
68                .into_iter()
69                .map(|name| SortKey {
70                    name,
71                    descending: false,
72                })
73                .collect()
74        };
75        if let Some(direction) = rest.get(1) {
76            let directions = string_list(direction)?;
77            if directions.len() == 1 {
78                let descending = directions[0].eq_ignore_ascii_case("descend")
79                    || directions[0].eq_ignore_ascii_case("desc");
80                for key in &mut keys {
81                    key.descending = descending;
82                }
83            } else {
84                for (key, direction) in keys.iter_mut().zip(directions.iter()) {
85                    key.descending = direction.eq_ignore_ascii_case("descend")
86                        || direction.eq_ignore_ascii_case("desc");
87                }
88            }
89        }
90        Ok(Self { keys })
91    }
92}
93
94pub(in crate::builtins::table) fn compare_table_cells(
95    value: &Value,
96    a: usize,
97    b: usize,
98) -> BuiltinResult<Ordering> {
99    match value {
100        Value::Tensor(tensor) => {
101            if let Some(storage) = tensor.integer_storage() {
102                let left = storage
103                    .value_at(a)
104                    .ok_or_else(|| invalid_index("table: numeric row index out of bounds"))?;
105                let right = storage
106                    .value_at(b)
107                    .ok_or_else(|| invalid_index("table: numeric row index out of bounds"))?;
108                return Ok(compare_integer_values(&left, &right));
109            }
110            Ok(tensor
111                .get2(a, 0)
112                .map_err(invalid_index)?
113                .partial_cmp(&tensor.get2(b, 0).map_err(invalid_index)?)
114                .unwrap_or(Ordering::Greater))
115        }
116        Value::StringArray(array) => {
117            let av = array.data.get(a).cloned().unwrap_or_default();
118            let bv = array.data.get(b).cloned().unwrap_or_default();
119            Ok(av.cmp(&bv))
120        }
121        Value::LogicalArray(array) => {
122            let av = *array.data.get(a).unwrap_or(&0);
123            let bv = *array.data.get(b).unwrap_or(&0);
124            Ok(av.cmp(&bv))
125        }
126        Value::Object(obj) if obj.is_class("datetime") => {
127            let tensor = crate::builtins::datetime::serials_from_datetime_value(value)?;
128            Ok(double_value_at(&tensor, a)
129                .unwrap_or(f64::NAN)
130                .partial_cmp(&double_value_at(&tensor, b).unwrap_or(f64::NAN))
131                .unwrap_or(Ordering::Greater))
132        }
133        other => Ok(cell_key_string(other, a).cmp(&cell_key_string(other, b))),
134    }
135}
136
137#[derive(Clone, Debug)]
138pub(in crate::builtins::table) enum GroupAtom {
139    Number(f64),
140    Integer(IntValue),
141    Text(String),
142    Logical(bool),
143    Missing,
144}
145
146impl GroupAtom {
147    fn rank(&self) -> u8 {
148        match self {
149            Self::Logical(_) => 0,
150            Self::Number(_) => 1,
151            Self::Integer(_) => 2,
152            Self::Text(_) => 3,
153            Self::Missing => 4,
154        }
155    }
156}
157
158impl PartialEq for GroupAtom {
159    fn eq(&self, other: &Self) -> bool {
160        self.cmp(other) == Ordering::Equal
161    }
162}
163
164impl Eq for GroupAtom {}
165
166impl PartialOrd for GroupAtom {
167    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
168        Some(self.cmp(other))
169    }
170}
171
172impl Ord for GroupAtom {
173    fn cmp(&self, other: &Self) -> Ordering {
174        let rank = self.rank().cmp(&other.rank());
175        if rank != Ordering::Equal {
176            return rank;
177        }
178        match (self, other) {
179            (Self::Missing, Self::Missing) => Ordering::Equal,
180            (Self::Logical(a), Self::Logical(b)) => a.cmp(b),
181            (Self::Number(a), Self::Number(b)) => a.total_cmp(b),
182            (Self::Integer(a), Self::Integer(b)) => compare_integer_values(a, b),
183            (Self::Text(a), Self::Text(b)) => a.cmp(b),
184            _ => Ordering::Equal,
185        }
186    }
187}
188
189pub(in crate::builtins::table) fn group_atom_is_missing(atom: &GroupAtom) -> bool {
190    match atom {
191        GroupAtom::Missing => true,
192        GroupAtom::Number(value) => value.is_nan(),
193        GroupAtom::Integer(_) => false,
194        GroupAtom::Text(value) => value.is_empty(),
195        GroupAtom::Logical(_) => false,
196    }
197}
198
199fn compare_integer_values(left: &IntValue, right: &IntValue) -> Ordering {
200    let left = integer_sign_and_magnitude(left);
201    let right = integer_sign_and_magnitude(right);
202    match (left.0, right.0) {
203        (true, false) => Ordering::Less,
204        (false, true) => Ordering::Greater,
205        (false, false) => left.1.cmp(&right.1),
206        (true, true) => right.1.cmp(&left.1),
207    }
208}
209
210fn integer_sign_and_magnitude(value: &IntValue) -> (bool, u64) {
211    match value {
212        IntValue::I8(value) => (*value < 0, value.unsigned_abs() as u64),
213        IntValue::I16(value) => (*value < 0, value.unsigned_abs() as u64),
214        IntValue::I32(value) => (*value < 0, value.unsigned_abs() as u64),
215        IntValue::I64(value) => (*value < 0, value.unsigned_abs()),
216        IntValue::U8(value) => (false, *value as u64),
217        IntValue::U16(value) => (false, *value as u64),
218        IntValue::U32(value) => (false, *value as u64),
219        IntValue::U64(value) => (false, *value),
220    }
221}
222
223pub(in crate::builtins::table) fn cell_group_atom(value: &Value, row: usize) -> GroupAtom {
224    match value {
225        Value::Tensor(tensor) => {
226            if let Some(storage) = tensor.integer_storage() {
227                return storage
228                    .value_at(row)
229                    .map(GroupAtom::Integer)
230                    .unwrap_or(GroupAtom::Missing);
231            }
232            tensor
233                .get2(row, 0)
234                .map(number_group_atom)
235                .unwrap_or(GroupAtom::Missing)
236        }
237        Value::StringArray(array) => array
238            .data
239            .get(row)
240            .cloned()
241            .map(text_group_atom)
242            .unwrap_or(GroupAtom::Missing),
243        Value::LogicalArray(array) => array
244            .data
245            .get(row)
246            .map(|value| GroupAtom::Logical(*value != 0))
247            .unwrap_or(GroupAtom::Missing),
248        Value::Object(obj) if obj.is_class("datetime") => {
249            crate::builtins::datetime::serials_from_datetime_value(value)
250                .ok()
251                .and_then(|tensor| double_value_at(&tensor, row))
252                .map(number_group_atom)
253                .unwrap_or(GroupAtom::Missing)
254        }
255        Value::Object(obj) if obj.is_class("duration") => {
256            crate::builtins::duration::duration_tensor_from_duration_value(value)
257                .ok()
258                .and_then(|tensor| double_value_at(&tensor, row))
259                .map(number_group_atom)
260                .unwrap_or(GroupAtom::Missing)
261        }
262        Value::Object(obj) if obj.is_class("calendarDuration") => {
263            crate::builtins::datetime::calendar_duration_tensors_from_value(value)
264                .ok()
265                .and_then(|(months, days)| {
266                    let months = double_value_at(&months, row)?;
267                    let days = double_value_at(&days, row)?;
268                    if months.is_nan() || days.is_nan() {
269                        None
270                    } else {
271                        Some(text_group_atom(format!("{months}:{days}")))
272                    }
273                })
274                .unwrap_or(GroupAtom::Missing)
275        }
276        Value::Object(obj) if obj.is_class(CATEGORICAL_CLASS) => {
277            match categorical_label_at(obj, row) {
278                Some(label) if label != "<undefined>" => text_group_atom(label),
279                _ => GroupAtom::Missing,
280            }
281        }
282        other => text_group_atom(cell_key_string(other, row)),
283    }
284}
285
286fn number_group_atom(value: f64) -> GroupAtom {
287    if value.is_nan() {
288        GroupAtom::Missing
289    } else {
290        GroupAtom::Number(value)
291    }
292}
293
294fn text_group_atom(value: String) -> GroupAtom {
295    if value.is_empty() {
296        GroupAtom::Missing
297    } else {
298        GroupAtom::Text(value)
299    }
300}
301
302pub(in crate::builtins::table) fn pivot_impl(
303    table: Value,
304    rowvars: Value,
305    colvars: Value,
306    datavar: Value,
307    method: &str,
308) -> BuiltinResult<Value> {
309    let object = into_table_object(table, "pivot")?;
310    let names = table_variable_names_from_object(&object)?;
311    let row_names = parse_variable_selector_for_object(Some(&rowvars), &object, &names)?;
312    let col_names = parse_variable_selector_for_object(Some(&colvars), &object, &names)?;
313    let data_names = parse_variable_selector_for_object(Some(&datavar), &object, &names)?;
314    if row_names.is_empty() || col_names.is_empty() || data_names.is_empty() {
315        return Err(invalid_argument(
316            "pivot: rowvars, colvars, and datavar must select at least one variable",
317        ));
318    }
319    if data_names.len() != 1 {
320        return Err(invalid_argument(
321            "pivot: exactly one data variable is currently supported",
322        ));
323    }
324    let data_name = &data_names[0];
325    let variables = table_variables(&object)?;
326    let data_value = variables
327        .fields
328        .get(data_name)
329        .ok_or_else(|| invalid_variable(format!("pivot: missing data variable '{data_name}'")))?;
330    if !matches!(data_value, Value::Tensor(tensor) if tensor.cols() == 1) {
331        return Err(invalid_variable(
332            "pivot: data variable must be a numeric column vector",
333        ));
334    }
335
336    let height = table_height(&object)?;
337    let mut row_order = Vec::<Vec<GroupAtom>>::new();
338    let mut row_first_index = BTreeMap::<Vec<GroupAtom>, usize>::new();
339    let mut col_order = Vec::<Vec<GroupAtom>>::new();
340    let mut col_seen = BTreeMap::<Vec<GroupAtom>, ()>::new();
341    let mut buckets = BTreeMap::<(Vec<GroupAtom>, Vec<GroupAtom>), Vec<usize>>::new();
342    for row in 0..height {
343        let row_key = group_key_for_row(&variables, &row_names, row);
344        let col_key = group_key_for_row(&variables, &col_names, row);
345        if !row_first_index.contains_key(&row_key) {
346            row_first_index.insert(row_key.clone(), row);
347            row_order.push(row_key.clone());
348        }
349        if !col_seen.contains_key(&col_key) {
350            col_seen.insert(col_key.clone(), ());
351            col_order.push(col_key.clone());
352        }
353        buckets.entry((row_key, col_key)).or_default().push(row);
354    }
355
356    let mut out_names = row_names.clone();
357    let mut out_columns = Vec::with_capacity(row_names.len() + col_order.len());
358    for name in &row_names {
359        let value = variables
360            .fields
361            .get(name)
362            .ok_or_else(|| invalid_variable(format!("pivot: missing row variable '{name}'")))?;
363        let rows = row_order
364            .iter()
365            .filter_map(|key| row_first_index.get(key).copied())
366            .collect::<Vec<_>>();
367        out_columns.push(select_rows(value, &rows)?);
368    }
369    for col_key in &col_order {
370        let mut values = Vec::with_capacity(row_order.len());
371        for row_key in &row_order {
372            let summary_rows = buckets
373                .get(&(row_key.clone(), col_key.clone()))
374                .cloned()
375                .unwrap_or_default();
376            if summary_rows.is_empty() {
377                values.push(f64::NAN);
378            } else {
379                values.push(
380                    summarize_groups(data_value, std::iter::once(&summary_rows), method)?
381                        .into_iter()
382                        .next()
383                        .unwrap_or(f64::NAN),
384                );
385            }
386        }
387        out_names.push(format!(
388            "{}_{}",
389            make_valid_variable_name(&group_key_label(col_key), out_names.len() + 1),
390            data_name
391        ));
392        out_columns.push(Value::Tensor(
393            Tensor::new(values, vec![row_order.len(), 1]).map_err(invalid_variable)?,
394        ));
395    }
396    let out_names = make_unique_variable_names(out_names);
397    table_from_columns(out_names, out_columns)
398}
399
400pub(in crate::builtins::table) fn group_key_for_row(
401    variables: &StructValue,
402    names: &[String],
403    row: usize,
404) -> Vec<GroupAtom> {
405    names
406        .iter()
407        .map(|name| {
408            variables
409                .fields
410                .get(name)
411                .map(|value| cell_group_atom(value, row))
412                .unwrap_or(GroupAtom::Missing)
413        })
414        .collect()
415}
416
417pub(in crate::builtins::table) fn group_key_label(key: &[GroupAtom]) -> String {
418    if key.is_empty() {
419        return "missing".to_string();
420    }
421    key.iter()
422        .map(group_atom_label)
423        .collect::<Vec<_>>()
424        .join("_")
425}
426
427pub(in crate::builtins::table) fn group_atom_label(atom: &GroupAtom) -> String {
428    match atom {
429        GroupAtom::Number(value) => format_key_number(*value),
430        GroupAtom::Integer(value) => format_integer_key(value),
431        GroupAtom::Text(text) => text.clone(),
432        GroupAtom::Logical(flag) => flag.to_string(),
433        GroupAtom::Missing => "missing".to_string(),
434    }
435}
436
437fn format_integer_key(value: &IntValue) -> String {
438    match value {
439        IntValue::I8(value) => value.to_string(),
440        IntValue::I16(value) => value.to_string(),
441        IntValue::I32(value) => value.to_string(),
442        IntValue::I64(value) => value.to_string(),
443        IntValue::U8(value) => value.to_string(),
444        IntValue::U16(value) => value.to_string(),
445        IntValue::U32(value) => value.to_string(),
446        IntValue::U64(value) => value.to_string(),
447    }
448}
449
450pub(in crate::builtins::table) fn groupsummary_impl(
451    table: Value,
452    groupvars: Value,
453    method: Value,
454    rest: Vec<Value>,
455) -> BuiltinResult<Value> {
456    let object = into_table_object(table, "groupsummary")?;
457    let names = table_variable_names_from_object(&object)?;
458    let group_names = parse_variable_selector_for_object(Some(&groupvars), &object, &names)?;
459    let methods = string_list(&method)?;
460    if methods.is_empty() {
461        return Err(invalid_argument(
462            "groupsummary: method list must not be empty",
463        ));
464    }
465    let mut include_missing = true;
466    let mut rest_index = 0usize;
467    let data_selector = rest.first().filter(|value| {
468        scalar_text(value, "groupsummary argument")
469            .map(|name| {
470                !name.eq_ignore_ascii_case("IncludeMissingGroups")
471                    && !name.eq_ignore_ascii_case("IncludeEmptyGroups")
472            })
473            .unwrap_or(true)
474    });
475    if data_selector.is_some() {
476        rest_index = 1;
477    }
478    while rest_index < rest.len() {
479        if rest_index + 1 >= rest.len() {
480            return Err(invalid_argument(
481                "groupsummary: name-value options must be provided in pairs",
482            ));
483        }
484        let name = scalar_text(&rest[rest_index], "groupsummary option name")?;
485        let value = &rest[rest_index + 1];
486        if name.eq_ignore_ascii_case("IncludeMissingGroups") {
487            include_missing = zero_one_bool_scalar(value, "IncludeMissingGroups")?;
488        } else if name.eq_ignore_ascii_case("IncludeEmptyGroups") {
489            if zero_one_bool_scalar(value, "IncludeEmptyGroups")? {
490                return Err(invalid_argument(
491                    "groupsummary: IncludeEmptyGroups=true is not supported until categorical level expansion is implemented",
492                ));
493            }
494        } else {
495            return Err(invalid_argument(format!(
496                "groupsummary: unsupported option '{name}'"
497            )));
498        }
499        rest_index += 2;
500    }
501    let data_names = if let Some(value) = data_selector {
502        parse_variable_selector_for_object(Some(value), &object, &names)?
503    } else {
504        names
505            .iter()
506            .filter(|name| !group_names.contains(name))
507            .filter(|name| {
508                table_variables(&object)
509                    .ok()
510                    .and_then(|vars| vars.fields.get(*name).cloned())
511                    .map(|value| matches!(value, Value::Tensor(_)))
512                    .unwrap_or(false)
513            })
514            .cloned()
515            .collect()
516    };
517    let variables = table_variables(&object)?;
518    let height = table_height(&object)?;
519    let mut groups: BTreeMap<Vec<GroupAtom>, Vec<usize>> = BTreeMap::new();
520    for row in 0..height {
521        let key = group_names
522            .iter()
523            .map(|name| {
524                variables
525                    .fields
526                    .get(name)
527                    .map(|value| cell_group_atom(value, row))
528                    .unwrap_or(GroupAtom::Missing)
529            })
530            .collect::<Vec<_>>();
531        if include_missing || !key.iter().any(group_atom_is_missing) {
532            groups.entry(key).or_default().push(row);
533        }
534    }
535    let group_rows = groups
536        .values()
537        .filter_map(|rows| rows.first().copied())
538        .collect::<Vec<_>>();
539    let mut out_names = Vec::new();
540    let mut out_columns = Vec::new();
541    for name in &group_names {
542        let value = variables.fields.get(name).ok_or_else(|| {
543            invalid_variable(format!("groupsummary: missing group variable '{name}'"))
544        })?;
545        out_names.push(name.clone());
546        out_columns.push(select_rows(value, &group_rows)?);
547    }
548    out_names.push("GroupCount".to_string());
549    out_columns.push(Value::Tensor(
550        Tensor::new(
551            groups.values().map(|rows| rows.len() as f64).collect(),
552            vec![groups.len(), 1],
553        )
554        .map_err(invalid_variable)?,
555    ));
556    let grouped_rows = groups.values().collect::<Vec<_>>();
557    for method in &methods {
558        for name in &data_names {
559            let value = variables.fields.get(name).ok_or_else(|| {
560                invalid_variable(format!("groupsummary: missing data variable '{name}'"))
561            })?;
562            let summary = summarize_groups_value(value, &grouped_rows, method)?;
563            out_names.push(format!("{}_{}", method.to_ascii_lowercase(), name));
564            out_columns.push(summary);
565        }
566    }
567    table_from_columns(out_names, out_columns)
568}
569
570fn summarize_groups_value(
571    value: &Value,
572    groups: &[&Vec<usize>],
573    method: &str,
574) -> BuiltinResult<Value> {
575    let Value::Tensor(tensor) = value else {
576        return Err(invalid_variable(
577            "groupsummary: summary data variables must be numeric column vectors",
578        ));
579    };
580    if tensor.cols() != 1 {
581        return Err(invalid_variable(
582            "groupsummary: summary data variables must be numeric column vectors",
583        ));
584    }
585    let Some(storage) = tensor.integer_storage() else {
586        let values = summarize_groups(value, groups.iter().copied(), method)?;
587        return Tensor::new(values, vec![groups.len(), 1])
588            .map(Value::Tensor)
589            .map_err(invalid_variable);
590    };
591    let method = method.to_ascii_lowercase();
592    if method == "min" || method == "max" {
593        let mut extrema = Vec::with_capacity(groups.len());
594        for rows in groups {
595            let mut values = rows.iter().map(|row| {
596                storage
597                    .value_at(*row)
598                    .ok_or_else(|| invalid_index("groupsummary: integer row out of bounds"))
599            });
600            let mut selected = values.next().transpose()?.ok_or_else(|| {
601                invalid_argument("groupsummary: observed integer groups cannot be empty")
602            })?;
603            for value in values {
604                let value = value?;
605                let ordering = compare_integer_values(&value, &selected);
606                if (method == "min" && ordering == Ordering::Less)
607                    || (method == "max" && ordering == Ordering::Greater)
608                {
609                    selected = value;
610                }
611            }
612            extrema.push(selected);
613        }
614        let output = storage
615            .from_exact_values_like(extrema)
616            .map_err(invalid_variable)?;
617        return Tensor::new_integer(output, vec![groups.len(), 1])
618            .map(Value::Tensor)
619            .map_err(invalid_variable);
620    }
621    if method == "count" || method == "numel" {
622        return Tensor::new(
623            groups.iter().map(|rows| rows.len() as f64).collect(),
624            vec![groups.len(), 1],
625        )
626        .map(Value::Tensor)
627        .map_err(invalid_variable);
628    }
629    let mut output = Vec::with_capacity(groups.len());
630    for rows in groups {
631        let mut values = rows
632            .iter()
633            .map(|row| {
634                let value = storage
635                    .value_at(*row)
636                    .ok_or_else(|| invalid_index("groupsummary: integer row out of bounds"))?;
637                if !crate::builtins::math::trigonometry::cos::integer_is_exact_f64(&value) {
638                    return Err(invalid_argument(
639                        "groupsummary: integer data must be exactly representable as double for floating summary methods",
640                    ));
641                }
642                Ok(value.to_f64())
643            })
644            .collect::<BuiltinResult<Vec<_>>>()?;
645        let value = match method.as_str() {
646            "mean" => values.iter().sum::<f64>() / values.len() as f64,
647            "sum" => values.iter().sum(),
648            "median" => {
649                values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
650                let mid = values.len() / 2;
651                if values.len().is_multiple_of(2) {
652                    (values[mid - 1] + values[mid]) / 2.0
653                } else {
654                    values[mid]
655                }
656            }
657            other => {
658                return Err(invalid_argument(format!(
659                    "groupsummary: unsupported method '{other}'"
660                )))
661            }
662        };
663        output.push(value);
664    }
665    Tensor::new(output, vec![groups.len(), 1])
666        .map(Value::Tensor)
667        .map_err(invalid_variable)
668}
669
670pub(in crate::builtins::table) fn summarize_groups<'a>(
671    value: &Value,
672    groups: impl Iterator<Item = &'a Vec<usize>>,
673    method: &str,
674) -> BuiltinResult<Vec<f64>> {
675    let tensor = match value {
676        Value::Tensor(tensor) if tensor.cols() == 1 => tensor,
677        _ => {
678            return Err(invalid_variable(
679                "groupsummary: summary data variables must be numeric column vectors",
680            ))
681        }
682    };
683    groups
684        .map(|rows| {
685            let mut values = rows
686                .iter()
687                .map(|row| tensor.get2(*row, 0).map_err(invalid_index))
688                .collect::<BuiltinResult<Vec<_>>>()?;
689            values.retain(|value| !value.is_nan());
690            let result = match method.to_ascii_lowercase().as_str() {
691                "mean" => {
692                    if values.is_empty() {
693                        f64::NAN
694                    } else {
695                        values.iter().sum::<f64>() / values.len() as f64
696                    }
697                }
698                "sum" => values.iter().sum(),
699                "min" => values.into_iter().fold(f64::INFINITY, f64::min),
700                "max" => values.into_iter().fold(f64::NEG_INFINITY, f64::max),
701                "median" => {
702                    if values.is_empty() {
703                        f64::NAN
704                    } else {
705                        values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal));
706                        let mid = values.len() / 2;
707                        if values.len() % 2 == 0 {
708                            (values[mid - 1] + values[mid]) / 2.0
709                        } else {
710                            values[mid]
711                        }
712                    }
713                }
714                "count" | "numel" => values.len() as f64,
715                other => {
716                    return Err(invalid_argument(format!(
717                        "groupsummary: unsupported method '{other}'"
718                    )))
719                }
720            };
721            Ok(result)
722        })
723        .collect()
724}
725
726pub(in crate::builtins::table) fn cell_key_string(value: &Value, row: usize) -> String {
727    match value {
728        Value::Tensor(tensor) => {
729            if let Some(storage) = tensor.integer_storage() {
730                return storage
731                    .value_at(row)
732                    .map(|value| format_integer_key(&value))
733                    .unwrap_or_default();
734            }
735            tensor
736                .get2(row, 0)
737                .map(format_key_number)
738                .unwrap_or_default()
739        }
740        Value::StringArray(array) => array.data.get(row).cloned().unwrap_or_default(),
741        Value::LogicalArray(array) => array
742            .data
743            .get(row)
744            .map(|value| value.to_string())
745            .unwrap_or_default(),
746        Value::Object(obj) if obj.is_class("datetime") => {
747            crate::builtins::datetime::serials_from_datetime_value(value)
748                .ok()
749                .and_then(|tensor| double_value_at(&tensor, row))
750                .map(format_key_number)
751                .unwrap_or_default()
752        }
753        Value::Object(obj) if obj.is_class("duration") => {
754            crate::builtins::duration::duration_tensor_from_duration_value(value)
755                .ok()
756                .and_then(|tensor| double_value_at(&tensor, row))
757                .map(format_key_number)
758                .unwrap_or_default()
759        }
760        Value::Object(obj) if obj.is_class(CATEGORICAL_CLASS) => {
761            categorical_label_at(obj, row).unwrap_or_default()
762        }
763        Value::Cell(cell) => cell
764            .get(row, 0)
765            .map(|item| cell_to_text(&item))
766            .unwrap_or_default(),
767        other => format!("{other}"),
768    }
769}
770
771fn double_value_at(tensor: &Tensor, index: usize) -> Option<f64> {
772    tensor.as_f64_slice()?.get(index).copied()
773}
774
775#[cfg(test)]
776mod tests {
777    use super::*;
778    use runmat_value::IntegerStorage;
779
780    #[test]
781    fn typed_integer_group_atoms_and_table_ordering_remain_exact() {
782        let large = 9_007_199_254_740_992_u64;
783        let value = Value::Tensor(
784            Tensor::new_integer(IntegerStorage::U64(vec![large, large + 1]), vec![2, 1]).unwrap(),
785        );
786
787        let first = cell_group_atom(&value, 0);
788        let second = cell_group_atom(&value, 1);
789        assert_ne!(first, second);
790        assert_eq!(compare_table_cells(&value, 0, 1).unwrap(), Ordering::Less);
791        assert_eq!(group_atom_label(&second), (large + 1).to_string());
792    }
793
794    #[test]
795    fn cell_key_string_reads_typed_integer_storage_exactly() {
796        let large = 9_007_199_254_740_993_u64;
797        let tensor = Tensor::new_integer(IntegerStorage::U64(vec![large]), vec![1, 1]).unwrap();
798
799        assert_eq!(
800            cell_key_string(&Value::Tensor(tensor), 0),
801            "9007199254740993"
802        );
803    }
804}