Skip to main content

ferritin_core/data/
segmentation.rs

1//! CSR-style segmentation primitive for grouping elements into segments.
2//!
3//! `Segmentation` stores an offset array where `offsets[i]..offsets[i+1]`
4//! is the element range for segment `i`.  This is the same layout as a
5//! Compressed Sparse Row (CSR) row-pointer array.
6
7use std::ops::Range;
8
9/// CSR-style segmentation: groups elements into segments via offset array.
10///
11/// `offsets.len() == n_segments + 1`; segment `i` spans
12/// `offsets[i]..offsets[i+1]`.
13#[derive(Clone, Debug, PartialEq)]
14pub struct Segmentation {
15    offsets: Vec<u32>, // len = n_segments + 1
16}
17
18impl Segmentation {
19    /// Build a validated segmentation from pre-computed offsets.
20    pub fn try_from_offsets(offsets: Vec<u32>) -> Result<Self, String> {
21        if offsets.is_empty() {
22            return Err("offsets must have at least one element".to_string());
23        }
24        if offsets[0] != 0 {
25            return Err(format!("offsets must start at 0; found {}", offsets[0]));
26        }
27        for window in offsets.windows(2) {
28            if window[0] > window[1] {
29                return Err(format!(
30                    "offsets must be monotonically non-decreasing; found {} > {}",
31                    window[0], window[1]
32                ));
33            }
34        }
35        Ok(Self { offsets })
36    }
37
38    /// Build from pre-computed offsets (e.g., `[0, 5, 12, 20]` for 3 segments).
39    ///
40    /// # Panics
41    /// Panics if `offsets` is empty (needs at least the sentinel `[0]`).
42    pub fn from_offsets(offsets: Vec<u32>) -> Self {
43        Self::try_from_offsets(offsets).expect("invalid segmentation offsets")
44    }
45
46    /// Build by detecting change-points in a key sequence.
47    ///
48    /// Each run of equal consecutive keys becomes one segment.
49    ///
50    /// # Example
51    /// ```
52    /// # use ferritin_core::data::Segmentation;
53    /// let seg = Segmentation::from_change_points(["A","A","A","B","B","C"].iter().copied());
54    /// assert_eq!(seg.count(), 3);
55    /// ```
56    pub fn from_change_points<K: PartialEq>(keys: impl Iterator<Item = K>) -> Self {
57        let mut offsets: Vec<u32> = vec![0];
58        let mut prev: Option<K> = None;
59        let mut idx: u32 = 0;
60
61        for key in keys {
62            match &prev {
63                None => {}
64                Some(p) if *p == key => {}
65                Some(_) => {
66                    offsets.push(idx);
67                }
68            }
69            prev = Some(key);
70            idx += 1;
71        }
72        offsets.push(idx); // final sentinel
73        Self { offsets }
74    }
75
76    /// Raw CSR offsets (`count() + 1` entries, starting at 0).
77    pub fn offsets(&self) -> &[u32] {
78        &self.offsets
79    }
80
81    /// Number of segments.
82    pub fn count(&self) -> usize {
83        self.offsets.len().saturating_sub(1)
84    }
85
86    /// Total number of elements covered by the segmentation.
87    pub fn element_count(&self) -> usize {
88        self.offsets.last().copied().unwrap_or(0) as usize
89    }
90
91    /// Element range for segment `seg`.
92    ///
93    /// # Panics
94    /// Panics if `seg >= self.count()`.
95    pub fn segment(&self, seg: usize) -> Range<usize> {
96        let start = self.offsets[seg] as usize;
97        let end = self.offsets[seg + 1] as usize;
98        start..end
99    }
100
101    /// O(log n) binary-search lookup: which segment contains element `elem`?
102    ///
103    /// Returns the segment index such that `segment(idx)` contains `elem`.
104    ///
105    /// # Panics
106    /// Panics if `elem` is out of range (>= total element count).
107    pub fn segment_of(&self, elem: usize) -> usize {
108        assert!(
109            elem < self.element_count(),
110            "element {} is out of range (element_count={})",
111            elem,
112            self.element_count()
113        );
114        let elem_u32 = elem as u32;
115        // Find the last offset that is <= elem. The partition point gives the
116        // first index where offsets[i] > elem, so subtract 1.
117        let pos = self.offsets.partition_point(|&o| o <= elem_u32);
118        assert!(pos > 0, "element {} is out of range", elem);
119        pos - 1
120    }
121
122    /// Iterate over all segment ranges in order.
123    pub fn iter(&self) -> impl Iterator<Item = Range<usize>> + '_ {
124        self.offsets
125            .windows(2)
126            .map(|w| (w[0] as usize)..(w[1] as usize))
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133
134    #[test]
135    fn test_segmentation_from_change_points() {
136        let chain_ids = ["A", "A", "A", "B", "B", "C"];
137        let seg = Segmentation::from_change_points(chain_ids.iter().copied());
138        assert_eq!(seg.count(), 3);
139        assert_eq!(seg.segment(0), 0..3);
140        assert_eq!(seg.segment(1), 3..5);
141        assert_eq!(seg.segment(2), 5..6);
142    }
143
144    #[test]
145    fn test_segmentation_from_offsets() {
146        let seg = Segmentation::from_offsets(vec![0, 3, 5, 6]);
147        assert_eq!(seg.count(), 3);
148        assert_eq!(seg.segment(0), 0..3);
149        assert_eq!(seg.segment(1), 3..5);
150        assert_eq!(seg.segment(2), 5..6);
151    }
152
153    #[test]
154    fn test_segmentation_segment_of() {
155        let seg = Segmentation::from_offsets(vec![0, 3, 5, 6]);
156        // Segment 0: elements 0,1,2
157        assert_eq!(seg.segment_of(0), 0);
158        assert_eq!(seg.segment_of(1), 0);
159        assert_eq!(seg.segment_of(2), 0);
160        // Segment 1: elements 3,4
161        assert_eq!(seg.segment_of(3), 1);
162        assert_eq!(seg.segment_of(4), 1);
163        // Segment 2: element 5
164        assert_eq!(seg.segment_of(5), 2);
165    }
166
167    #[test]
168    #[should_panic(expected = "out of range")]
169    fn test_segmentation_segment_of_rejects_end_sentinel() {
170        Segmentation::from_offsets(vec![0, 3, 5]).segment_of(5);
171    }
172
173    #[test]
174    fn test_segmentation_round_trip() {
175        let chain_ids = ["A", "A", "A", "B", "B", "C"];
176        let seg = Segmentation::from_change_points(chain_ids.iter().copied());
177
178        // Build the expected mapping by hand
179        let expected = [0usize, 0, 0, 1, 1, 2];
180        for (elem, &exp_seg) in expected.iter().enumerate() {
181            assert_eq!(
182                seg.segment_of(elem),
183                exp_seg,
184                "elem {} should be in segment {}",
185                elem,
186                exp_seg
187            );
188        }
189    }
190
191    #[test]
192    fn test_segmentation_single_segment() {
193        let keys = ["X", "X", "X", "X"];
194        let seg = Segmentation::from_change_points(keys.iter().copied());
195        assert_eq!(seg.count(), 1);
196        assert_eq!(seg.segment(0), 0..4);
197        for elem in 0..4 {
198            assert_eq!(seg.segment_of(elem), 0);
199        }
200    }
201
202    #[test]
203    fn test_segmentation_all_different() {
204        let keys = ["A", "B", "C", "D"];
205        let seg = Segmentation::from_change_points(keys.iter().copied());
206        assert_eq!(seg.count(), 4);
207        for i in 0..4 {
208            assert_eq!(seg.segment(i), i..(i + 1));
209            assert_eq!(seg.segment_of(i), i);
210        }
211    }
212
213    #[test]
214    fn test_segmentation_iter() {
215        let seg = Segmentation::from_offsets(vec![0, 3, 5, 6]);
216        let ranges: Vec<Range<usize>> = seg.iter().collect();
217        assert_eq!(ranges, vec![0..3, 3..5, 5..6]);
218    }
219
220    #[test]
221    #[should_panic(expected = "monotonically non-decreasing")]
222    fn test_segmentation_from_offsets_non_monotonic_panics() {
223        Segmentation::from_offsets(vec![0, 10, 5, 15]);
224    }
225
226    #[test]
227    fn test_segmentation_try_from_offsets_rejects_nonzero_start() {
228        assert_eq!(
229            Segmentation::try_from_offsets(vec![2, 5]).unwrap_err(),
230            "offsets must start at 0; found 2"
231        );
232    }
233}