Skip to main content

consortium_cli/
fold.rs

1//! nD-aware nodeset folding honoring a fold-axis constraint (`--axis`).
2//!
3//! Ports the ClusterShell pieces needed by upstream commit 6ed71ac (GH#356):
4//!
5//! - [`parse_fold_axis`] — port of `CLI/Utils.py::parse_fold_axis`, translating
6//!   a 1-indexed command-line `--axis` value into 0-indexed axes suitable for
7//!   `NodeSet.fold_axis` / `Defaults.fold_axis`.
8//! - [`fold_nodes`] — port of `NodeSetBase.__str__` + `_iter_nd_pat` rendering
9//!   on top of `RangeSetND` vector folding (`_fold_univariate` /
10//!   `_fold_multivariate_merge`). The core `NodeSet` eagerly expands
11//!   multidimensional patterns, so the nD-aware folding needed for gathered
12//!   output display is reimplemented here from the raw node-name list.
13
14use std::collections::{BTreeMap, BTreeSet, HashSet};
15
16use consortium::range_set::RangeSet;
17
18/// Port of `CLI/Utils.py::parse_fold_axis`.
19///
20/// Translates a 1-indexed `--axis` value to 0-indexed axes:
21///
22/// - `"1"` / `"1-2"` / `"1,3"` → rangeset axes, `x - 1` for each `x > 0`
23///   (axis `0` is ignored, like upstream).
24/// - `"-1"` → negative axis index (only a single number supported),
25///   resolved modulo the dimension count at fold time.
26///
27/// Returns the raw axis list; callers treat an *empty* list like Python's
28/// empty tuple assigned to `DEFAULTS.fold_axis` (falsy → fold along all
29/// axes).
30///
31/// # Errors
32///
33/// Returns a `RangeSet` parse error string for malformed rangeset input, or
34/// an `invalid literal` style error for malformed negative input, mirroring
35/// the upstream `RangeSetParseError` / `ValueError` split.
36pub fn parse_fold_axis(axis: &str) -> Result<Vec<i64>, String> {
37    if !axis.starts_with('-') {
38        // axis are 1-indexed on the command line (0 ignored)
39        let rs = RangeSet::parse(axis, None).map_err(|e| e.to_string())?;
40        Ok(rs.intiter().filter(|x| *x > 0).map(|x| x - 1).collect())
41    } else {
42        // negative axis index (only single number supported)
43        axis.parse::<i64>()
44            .map(|v| vec![v])
45            .map_err(|_| format!("invalid literal for int() with base 10: '{axis}'"))
46    }
47}
48
49/// One parsed node name: a `%s`-placeholder template plus one `(value, pad)`
50/// pair per digit run, mirroring `node_set.rs::parse_node_indices`.
51fn scan_node(name: &str) -> (String, Vec<(i64, u32)>) {
52    let mut template = String::new();
53    let mut indices = Vec::new();
54
55    let chars: Vec<char> = name.chars().collect();
56    let len = chars.len();
57    let mut i = 0;
58
59    while i < len {
60        let prefix_start = i;
61        while i < len && !chars[i].is_ascii_digit() {
62            i += 1;
63        }
64        template.push_str(&name[prefix_start..i]);
65
66        let digit_start = i;
67        while i < len && chars[i].is_ascii_digit() {
68            i += 1;
69        }
70        let digits = &name[digit_start..i];
71
72        if !digits.is_empty() {
73            template.push_str("%s");
74            let value: i64 = digits.parse().unwrap_or(0);
75            let pad = if value != 0 {
76                let stripped = digits.trim_start_matches('0');
77                if digits.len() > stripped.len() {
78                    digits.len() as u32
79                } else {
80                    0
81                }
82            } else if digits.len() > 1 {
83                digits.len() as u32
84            } else {
85                0
86            };
87            indices.push((value, pad));
88        }
89    }
90
91    (template, indices)
92}
93
94/// Fold a list of node names into compact bracket notation, honoring a
95/// fold-axis constraint.
96///
97/// `fold_axis` is `None` to fold along all axes (upstream default), or
98/// `Some(axes)` with 0-indexed axes (negative values resolved modulo the
99/// dimension count), mirroring `NodeSet.fold_axis` semantics:
100///
101/// - 1D patterns fold only when `fold_axis` is `None` or contains an axis
102///   `x` with `-1 <= x < 1`; otherwise they expand.
103/// - nD patterns fold each axis in the resolved set and expand the others.
104///
105/// Patterns are emitted sorted alphabetically; elements within a pattern
106/// follow `RangeSet` ordering, like the Python oracle.
107pub fn fold_nodes<S: AsRef<str>>(names: &[S], fold_axis: Option<&[i64]>) -> String {
108    // Group index vectors by template (patterns sort alphabetically).
109    let mut groups: BTreeMap<String, BTreeSet<Vec<(i64, u32)>>> = BTreeMap::new();
110    for name in names {
111        let (template, indices) = scan_node(name.as_ref());
112        groups.entry(template).or_default().insert(indices);
113    }
114
115    let mut results: Vec<String> = Vec::new();
116    for (template, vectors) in &groups {
117        let dim = vectors.iter().next().map_or(0, Vec::len);
118        if dim == 0 {
119            // non-indexed node name
120            results.push(template.clone());
121        } else if dim == 1 {
122            results.extend(fold_1d(template, vectors, fold_axis));
123        } else {
124            results.extend(fold_nd(template, vectors, fold_axis));
125        }
126    }
127    results.join(",")
128}
129
130/// Build a `RangeSet` from `(value, pad)` pairs.
131fn rangeset_of<I: IntoIterator<Item = (i64, u32)>>(items: I) -> RangeSet {
132    let mut rs = RangeSet::new();
133    for (value, pad) in items {
134        rs.add_int(value, pad);
135    }
136    rs
137}
138
139/// 1D fold rule, port of the `rset.dim() == 1` branch of
140/// `NodeSetBase.__str__`.
141fn fold_1d(
142    template: &str,
143    vectors: &BTreeSet<Vec<(i64, u32)>>,
144    fold_axis: Option<&[i64]>,
145) -> Vec<String> {
146    let rs = rangeset_of(vectors.iter().map(|v| v[0]));
147
148    // fold if fold_axis is None or contains x with -1 <= x < 1
149    let may_fold = fold_axis.is_none()
150        || fold_axis
151            .map(|axes| axes.iter().any(|x| (-1..1).contains(x)))
152            .unwrap_or(false);
153
154    if may_fold {
155        if rs.len() > 1 {
156            vec![template.replacen("%s", &format!("[{rs}]"), 1)]
157        } else {
158            vec![template.replacen("%s", &rs.to_string(), 1)]
159        }
160    } else {
161        rs.striter()
162            .map(|s| template.replacen("%s", &s, 1))
163            .collect()
164    }
165}
166
167/// nD fold rule, port of `NodeSetBase._iter_nd_pat` on top of a folded
168/// `RangeSetND` veclist.
169fn fold_nd(
170    template: &str,
171    vectors: &BTreeSet<Vec<(i64, u32)>>,
172    fold_axis: Option<&[i64]>,
173) -> Vec<String> {
174    let dim = vectors.iter().next().map_or(0, Vec::len);
175
176    // Build the veclist: one vector of singleton RangeSets per node.
177    let mut veclist: Vec<Vec<RangeSet>> = Vec::new();
178    for ivec in vectors {
179        let singletons: Vec<RangeSet> = ivec.iter().map(|&(v, p)| rangeset_of([(v, p)])).collect();
180        if !veclist.contains(&singletons) {
181            veclist.push(singletons);
182        }
183    }
184
185    fold_veclist(&mut veclist);
186
187    // Resolved fold set: [int(x) % dimcnt for x in fold_axis
188    //                     if -dimcnt <= int(x) < dimcnt]
189    let dimcnt = dim as i64;
190    let fold_set: HashSet<usize> = match fold_axis {
191        None => (0..dim).collect(),
192        Some(axes) => axes
193            .iter()
194            .filter(|x| **x >= -dimcnt && **x < dimcnt)
195            .map(|x| x.rem_euclid(dimcnt) as usize)
196            .collect(),
197    };
198
199    let mut results = Vec::new();
200    for rgvec in &veclist {
201        // Per-axis string lists, then cross-product (last axis aggregated
202        // slowest, like the Python oracle).
203        let mut combos: Vec<Vec<String>> = vec![Vec::new()];
204        for (axis, rs) in rgvec.iter().enumerate() {
205            let strings: Vec<String> = if rs.len() > 1 {
206                if fold_set.contains(&axis) {
207                    vec![format!("[{rs}]")]
208                } else {
209                    rs.striter().collect()
210                }
211            } else {
212                vec![rs.to_string()]
213            };
214            let mut next = Vec::new();
215            for s in &strings {
216                for combo in &combos {
217                    let mut extended = combo.clone();
218                    extended.push(s.clone());
219                    next.push(extended);
220                }
221            }
222            combos = next;
223        }
224        for combo in combos {
225            let mut rendered = template.to_string();
226            for part in &combo {
227                rendered = rendered.replacen("%s", part, 1);
228            }
229            results.push(rendered);
230        }
231    }
232    results
233}
234
235/// In-place nD folding, port of `RangeSetND._fold`: univariate fast path,
236/// then the multivariate greedy merge.
237fn fold_veclist(veclist: &mut Vec<Vec<RangeSet>>) {
238    if veclist.len() > 1 && !fold_univariate(veclist) {
239        fold_multivariate_merge(veclist);
240    } else {
241        sort_veclist(veclist);
242    }
243}
244
245/// Univariate nD folding. Returns true when only one dimension varies and
246/// the veclist was merged along it (port of `RangeSetND._fold_univariate`).
247fn fold_univariate(veclist: &mut Vec<Vec<RangeSet>>) -> bool {
248    let dim = veclist[0].len();
249    let mut vardim = 0usize;
250    let mut dimdiff = 0;
251    if dim > 1 {
252        for i in 0..dim {
253            // Are all rangesets on this dimension the same?
254            if veclist.iter().any(|vec| vec[i] != veclist[0][i]) {
255                dimdiff += 1;
256                if dimdiff > 1 {
257                    break;
258                }
259                vardim = i;
260            }
261        }
262    }
263    let univar = dim == 1 || dimdiff == 1;
264    if univar {
265        for k in 1..veclist.len() {
266            let rs = veclist[k][vardim].clone();
267            veclist[0][vardim].update(&rs);
268        }
269        veclist.truncate(1);
270    }
271    univar
272}
273
274/// Multivariate nD folding, merge phase (port of
275/// `RangeSetND._fold_multivariate_merge`): easy O(n) passes first, then one
276/// full O(n^2) pass, merging vectors that differ in at most one position.
277fn fold_multivariate_merge(veclist: &mut Vec<Vec<RangeSet>>) {
278    let mut full = false; // try easy O(n) passes first
279    let mut chg = true; // new pass (eg. after change on veclist)
280    while chg {
281        chg = false;
282        sort_veclist(veclist); // sort veclist before new pass
283        let mut index1 = 0;
284        while index1 + 1 < veclist.len() {
285            // compare items by couples, the idea being to merge vectors if
286            // they differ only by one item
287            let mut index2 = index1 + 1;
288            while index2 < veclist.len() {
289                match try_merge_pair(&veclist[index1], &veclist[index2]) {
290                    Some(new_item) => {
291                        // one change has been done: use this new item to
292                        // compare with other
293                        chg = true;
294                        veclist[index1] = new_item;
295                        veclist.remove(index2);
296                    }
297                    None => {
298                        index2 += 1;
299                        if !full {
300                            // easy pass so break to avoid scanning all
301                            // index2; advance with next index1 for now
302                            break;
303                        }
304                    }
305                }
306            }
307            index1 += 1;
308        }
309        if !chg && !full {
310            // if no change was done during the last normal pass, we do a
311            // full O(n^2) pass. This pass is done only at the end in the
312            // hope that most vectors have already been merged by easy
313            // O(n) passes.
314            chg = true;
315            full = true;
316        }
317    }
318}
319
320/// Try to merge two rangeset vectors into one, following
321/// `_fold_multivariate_merge` per-position rules: equal positions are kept,
322/// disjoint ranges are unioned, a fully contained range yields the largest
323/// one, and a partial overlap aborts the merge. At most one position may
324/// differ.
325fn try_merge_pair(item1: &[RangeSet], item2: &[RangeSet]) -> Option<Vec<RangeSet>> {
326    let mut new_item: Vec<RangeSet> = Vec::with_capacity(item1.len());
327    let mut nb_diff = 0;
328    for (rg1, rg2) in item1.iter().zip(item2.iter()) {
329        if rg1 == rg2 {
330            new_item.push(rg1.clone());
331        } else if rg1.intersection(rg2).is_empty() {
332            // merge on disjoint ranges
333            nb_diff += 1;
334            if nb_diff > 1 {
335                return None;
336            }
337            new_item.push(rg1.union(rg2));
338        } else if strict_superset(rg1, rg2) {
339            // if fully contained, keep the largest one
340            nb_diff += 1;
341            if nb_diff > 1 {
342                return None;
343            }
344            new_item.push(rg1.clone());
345        } else if strict_superset(rg2, rg1) {
346            nb_diff += 1;
347            if nb_diff > 1 {
348                return None;
349            }
350            new_item.push(rg2.clone());
351        } else {
352            // intersection but do nothing
353            return None;
354        }
355    }
356    Some(new_item)
357}
358
359/// Python's `rg1 > rg2` rangeset comparison: strict superset by length and
360/// containment.
361fn strict_superset(rg1: &RangeSet, rg2: &RangeSet) -> bool {
362    rg1.len() > rg2.len() && rg1.intersection(rg2).len() == rg2.len()
363}
364
365/// nD sorting (port of `RangeSetND._sort`):
366/// (1) larger vector first (#elements)
367/// (2) larger dim first (#elements)
368/// (3) lower first index first
369/// (4) lower last index first
370fn sort_veclist(veclist: &mut [Vec<RangeSet>]) {
371    veclist.sort_by_key(|rgvec| rgvec_key(rgvec));
372}
373
374type RgvecKey = (
375    std::cmp::Reverse<u128>,
376    Vec<(std::cmp::Reverse<usize>, i64, i64)>,
377);
378
379fn rgvec_key(rgvec: &[RangeSet]) -> RgvecKey {
380    let product: u128 = rgvec.iter().map(|rg| rg.len() as u128).product();
381    let dims = rgvec
382        .iter()
383        .map(|rg| {
384            let first = rg.intiter().next().unwrap_or(0);
385            let last = rg.intiter().last().unwrap_or(first);
386            (std::cmp::Reverse(rg.len()), first, last)
387        })
388        .collect();
389    (std::cmp::Reverse(product), dims)
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    fn fold(names: &[&str], fold_axis: Option<&[i64]>) -> String {
397        fold_nodes(names, fold_axis)
398    }
399
400    // ── parse_fold_axis (CLI/Utils.py port) ─────────────────────────────
401
402    #[test]
403    fn parse_axis_single() {
404        assert_eq!(parse_fold_axis("1").unwrap(), vec![0]);
405        assert_eq!(parse_fold_axis("2").unwrap(), vec![1]);
406    }
407
408    #[test]
409    fn parse_axis_rangeset() {
410        assert_eq!(parse_fold_axis("1-2").unwrap(), vec![0, 1]);
411        assert_eq!(parse_fold_axis("1,3").unwrap(), vec![0, 2]);
412    }
413
414    #[test]
415    fn parse_axis_zero_ignored() {
416        // axis 0 is filtered out like upstream (x > 0)
417        assert_eq!(parse_fold_axis("0").unwrap(), Vec::<i64>::new());
418        assert_eq!(parse_fold_axis("0,2").unwrap(), vec![1]);
419    }
420
421    #[test]
422    fn parse_axis_negative() {
423        assert_eq!(parse_fold_axis("-1").unwrap(), vec![-1]);
424        assert_eq!(parse_fold_axis("-2").unwrap(), vec![-2]);
425    }
426
427    #[test]
428    fn parse_axis_errors() {
429        assert!(parse_fold_axis("abc").is_err());
430        assert!(parse_fold_axis("-x").is_err());
431        assert!(parse_fold_axis("-").is_err());
432    }
433
434    // ── 1D folding ───────────────────────────────────────────────────────
435
436    #[test]
437    fn fold_1d_default() {
438        assert_eq!(
439            fold(&["node1", "node2", "node3", "node4"], None),
440            "node[1-4]"
441        );
442    }
443
444    #[test]
445    fn fold_1d_axis1_folds() {
446        // axis 1 → 0-indexed 0 → 1D pattern may fold
447        assert_eq!(
448            fold(&["node1", "node2", "node3", "node4"], Some(&[0])),
449            "node[1-4]"
450        );
451    }
452
453    #[test]
454    fn fold_1d_axis_minus1_folds() {
455        // -1 <= x < 1 → 1D pattern may fold
456        assert_eq!(
457            fold(&["node1", "node2", "node3", "node4"], Some(&[-1])),
458            "node[1-4]"
459        );
460    }
461
462    #[test]
463    fn fold_1d_axis2_expands() {
464        // axis 2 → 0-indexed 1 → no x in {-1, 0} → expand
465        assert_eq!(
466            fold(&["node1", "node2", "node3", "node4"], Some(&[1])),
467            "node1,node2,node3,node4"
468        );
469    }
470
471    #[test]
472    fn fold_1d_single_element_no_brackets() {
473        assert_eq!(fold(&["node3"], None), "node3");
474    }
475
476    #[test]
477    fn fold_1d_padding_preserved() {
478        assert_eq!(fold(&["prune003", "prune004"], None), "prune[003-004]");
479    }
480
481    #[test]
482    fn fold_plain_nodes() {
483        assert_eq!(fold(&["switch"], None), "switch");
484        assert_eq!(fold(&["node1", "switch"], None), "node1,switch");
485    }
486
487    // ── nD folding (GH#356 / upstream --axis tests) ──────────────────────
488
489    #[test]
490    fn fold_nd_default_folds_all_axes() {
491        let names = ["foo1-1", "foo1-2", "foo2-1", "foo2-2"];
492        assert_eq!(fold(&names, None), "foo[1-2]-[1-2]");
493    }
494
495    #[test]
496    fn fold_nd_axis1() {
497        let names = ["foo1-1", "foo1-2", "foo2-1", "foo2-2"];
498        assert_eq!(fold(&names, Some(&[0])), "foo[1-2]-1,foo[1-2]-2");
499    }
500
501    #[test]
502    fn fold_nd_axis2() {
503        let names = ["foo1-1", "foo1-2", "foo2-1", "foo2-2"];
504        assert_eq!(fold(&names, Some(&[1])), "foo1-[1-2],foo2-[1-2]");
505    }
506
507    #[test]
508    fn fold_nd_axis_minus1() {
509        // -1 % 2 == 1 → same as axis=2
510        let names = ["foo1-1", "foo1-2", "foo2-1", "foo2-2"];
511        assert_eq!(fold(&names, Some(&[-1])), "foo1-[1-2],foo2-[1-2]");
512    }
513
514    #[test]
515    fn fold_nd_axis_minus2() {
516        // -2 % 2 == 0 → same as axis=1
517        let names = ["foo1-1", "foo1-2", "foo2-1", "foo2-2"];
518        assert_eq!(fold(&names, Some(&[-2])), "foo[1-2]-1,foo[1-2]-2");
519    }
520
521    #[test]
522    fn fold_nd_out_of_range_axis_expands_everything() {
523        // axis 3 on a 2D nodeset: filtered out (3 >= dimcnt) → no fold axis
524        // (expansion order verified against the Python oracle)
525        let names = ["foo1-1", "foo1-2", "foo2-1", "foo2-2"];
526        assert_eq!(fold(&names, Some(&[2])), "foo1-1,foo2-1,foo1-2,foo2-2");
527    }
528
529    #[test]
530    fn fold_nd_multivariate_keeps_larger_vector_first() {
531        // da[1-2]p[1-3] (6 elts) precedes da[5-6]p[1-2] (4 elts)
532        let names = [
533            "da1p1", "da1p2", "da1p3", "da2p1", "da2p2", "da2p3", "da5p1", "da5p2", "da6p1",
534            "da6p2",
535        ];
536        assert_eq!(fold(&names, None), "da[1-2]p[1-3],da[5-6]p[1-2]");
537    }
538
539    #[test]
540    fn fold_nd_univariate_merges_along_varying_dim() {
541        // only axis 1 varies → merge into a single vector
542        let names = ["r1n1", "r1n2", "r1n3"];
543        assert_eq!(fold(&names, None), "r1n[1-3]");
544    }
545
546    #[test]
547    fn fold_nd_partial_overlap_does_not_merge() {
548        // {1-2} x {1} and {2-3} x {2}: overlap on axis 0 without containment
549        let names = ["a1b1", "a2b1", "a2b2", "a3b2"];
550        assert_eq!(fold(&names, None), "a[1-2]b1,a[2-3]b2");
551    }
552
553    #[test]
554    fn fold_nd_contained_range_keeps_largest() {
555        // {1} x {1-2} and {2} x {1}: mergeable on neither axis without
556        // a second difference (verified against the Python oracle)
557        let names = ["a1b1", "a1b2", "a2b1"];
558        assert_eq!(fold(&names, None), "a1b[1-2],a2b1");
559    }
560
561    #[test]
562    fn fold_nd_3d_full_cartesian() {
563        let names = [
564            "a1b1c1", "a1b1c2", "a1b2c1", "a1b2c2", "a2b1c1", "a2b1c2", "a2b2c1", "a2b2c2",
565        ];
566        assert_eq!(fold(&names, None), "a[1-2]b[1-2]c[1-2]");
567        // expansion order verified against the Python oracle
568        assert_eq!(
569            fold(&names, Some(&[2])),
570            "a1b1c[1-2],a2b1c[1-2],a1b2c[1-2],a2b2c[1-2]"
571        );
572    }
573
574    #[test]
575    fn fold_nd_padding_preserved() {
576        let names = ["n01-1", "n01-2", "n02-1", "n02-2"];
577        assert_eq!(fold(&names, None), "n[01-02]-[1-2]");
578    }
579
580    #[test]
581    fn fold_mixed_templates_sorted() {
582        let names = ["node1", "bmc10", "node2", "bmc11"];
583        assert_eq!(fold(&names, None), "bmc[10-11],node[1-2]");
584    }
585}