Skip to main content

whitaker_common/
complexity_signal.rs

1//! Per-line complexity signal helpers used by higher-level lints.
2//!
3//! The Bumpy Road detector described in `docs/whitaker-dylint-suite-design.md`
4//! models complexity as a per-line signal, then applies smoothing to highlight
5//! sustained "bumps" rather than short spikes. This module provides the
6//! low-level building blocks: rasterizing weighted line segments into a
7//! per-line vector and applying a centred moving-average smoothing window.
8
9use std::ops::RangeInclusive;
10
11use thiserror::Error;
12
13/// Describes a constant contribution spanning an inclusive set of one-based
14/// line numbers.
15#[derive(Clone, Copy, Debug, PartialEq)]
16pub struct LineSegment {
17    start_line: usize,
18    end_line: usize,
19    value: f64,
20}
21
22impl LineSegment {
23    /// Creates a new line segment.
24    ///
25    /// Line numbers are one-based and ranges are inclusive.
26    ///
27    /// # Examples
28    ///
29    /// ```
30    /// use whitaker_common::complexity_signal::LineSegment;
31    ///
32    /// let segment = LineSegment::new(3, 5, 1.25).expect("segment should be valid");
33    /// assert_eq!(segment.start_line(), 3);
34    /// assert_eq!(segment.end_line(), 5);
35    /// ```
36    #[must_use = "Inspect the segment creation result to handle invalid ranges"]
37    pub fn new(start_line: usize, end_line: usize, value: f64) -> Result<Self, SegmentError> {
38        if start_line == 0 || end_line == 0 {
39            return Err(SegmentError::LineNumberMustBeOneBased {
40                start_line,
41                end_line,
42            });
43        }
44
45        if start_line > end_line {
46            return Err(SegmentError::StartAfterEnd {
47                start_line,
48                end_line,
49            });
50        }
51
52        Ok(Self {
53            start_line,
54            end_line,
55            value,
56        })
57    }
58
59    /// Returns the first line covered by the segment (inclusive).
60    #[must_use]
61    pub const fn start_line(self) -> usize {
62        self.start_line
63    }
64
65    /// Returns the last line covered by the segment (inclusive).
66    #[must_use]
67    pub const fn end_line(self) -> usize {
68        self.end_line
69    }
70
71    /// Returns the per-line contribution.
72    #[must_use]
73    pub const fn value(self) -> f64 {
74        self.value
75    }
76}
77
78/// Errors emitted when constructing a [`LineSegment`].
79#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
80pub enum SegmentError {
81    /// Line numbers must be one-based (line 0 is invalid).
82    #[error("line numbers must be one-based (got start_line={start_line}, end_line={end_line})")]
83    LineNumberMustBeOneBased { start_line: usize, end_line: usize },
84
85    /// The segment start must not occur after its end.
86    #[error(
87        "segment start must not occur after end (start_line={start_line}, end_line={end_line})"
88    )]
89    StartAfterEnd { start_line: usize, end_line: usize },
90}
91
92/// Errors emitted when building a per-line signal.
93#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
94pub enum SignalBuildError {
95    /// The function span must be expressed with one-based line numbers.
96    #[error(
97        "function line range must be one-based (got start_line={start_line}, end_line={end_line})"
98    )]
99    FunctionLineRangeMustBeOneBased { start_line: usize, end_line: usize },
100
101    /// The function start must not occur after its end.
102    #[error(
103        "function start must not occur after end (start_line={start_line}, end_line={end_line})"
104    )]
105    FunctionStartAfterEnd { start_line: usize, end_line: usize },
106
107    /// A segment does not intersect the function's line range.
108    #[error(
109        "segment lies outside function range (segment={segment_start}..={segment_end}, function={function_start}..={function_end})"
110    )]
111    SegmentOutsideFunctionRange {
112        segment_start: usize,
113        segment_end: usize,
114        function_start: usize,
115        function_end: usize,
116    },
117}
118
119fn validate_function_range(
120    function_start: usize,
121    function_end: usize,
122) -> Result<(), SignalBuildError> {
123    if function_start == 0 || function_end == 0 {
124        return Err(SignalBuildError::FunctionLineRangeMustBeOneBased {
125            start_line: function_start,
126            end_line: function_end,
127        });
128    }
129
130    if function_start > function_end {
131        return Err(SignalBuildError::FunctionStartAfterEnd {
132            start_line: function_start,
133            end_line: function_end,
134        });
135    }
136
137    Ok(())
138}
139
140fn validate_segment_in_range(
141    segment: &LineSegment,
142    function_start: usize,
143    function_end: usize,
144) -> Result<(), SignalBuildError> {
145    if segment.end_line() < function_start || segment.start_line() > function_end {
146        return Err(SignalBuildError::SegmentOutsideFunctionRange {
147            segment_start: segment.start_line(),
148            segment_end: segment.end_line(),
149            function_start,
150            function_end,
151        });
152    }
153
154    Ok(())
155}
156
157fn apply_segment_to_diff(segment: &LineSegment, diff: &mut [f64], function_start: usize) {
158    let segment_start = segment.start_line().saturating_sub(function_start);
159    let segment_end = segment.end_line().saturating_sub(function_start);
160
161    if let Some(cell) = diff.get_mut(segment_start) {
162        *cell += segment.value();
163    }
164
165    if let Some(cell) = diff.get_mut(segment_end + 1) {
166        *cell -= segment.value();
167    }
168}
169
170fn accumulate_signal_from_diff(diff: &[f64], len: usize) -> Vec<f64> {
171    let mut signal = Vec::with_capacity(len);
172    let mut running = 0.0_f64;
173    for delta in diff.iter().take(len) {
174        running += delta;
175        signal.push(running);
176    }
177    signal
178}
179
180/// Rasterizes weighted [`LineSegment`] values into a per-line signal.
181///
182/// The returned vector has length `function_end - function_start + 1`, where the
183/// value at index `0` corresponds to `function_start`.
184///
185/// # Errors
186///
187/// - Returns [`SignalBuildError::FunctionLineRangeMustBeOneBased`] when the
188///   provided range includes line `0`.
189/// - Returns [`SignalBuildError::FunctionStartAfterEnd`] when the range is
190///   inverted.
191/// - Returns [`SignalBuildError::SegmentOutsideFunctionRange`] when any segment
192///   does not overlap the function range.
193///
194/// # Examples
195///
196/// ```
197/// use whitaker_common::complexity_signal::{LineSegment, rasterize_signal};
198///
199/// let segments = [
200///     LineSegment::new(10, 12, 1.0).expect("segment should be valid"),
201///     LineSegment::new(12, 14, 2.0).expect("segment should be valid"),
202/// ];
203///
204/// let signal = rasterize_signal(10..=14, &segments).expect("signal should build");
205/// assert_eq!(signal, vec![1.0, 1.0, 3.0, 2.0, 2.0]);
206/// ```
207#[must_use = "Inspect the signal build result to handle invalid ranges"]
208pub fn rasterize_signal(
209    function_lines: RangeInclusive<usize>,
210    segments: &[LineSegment],
211) -> Result<Vec<f64>, SignalBuildError> {
212    let function_start = *function_lines.start();
213    let function_end = *function_lines.end();
214
215    validate_function_range(function_start, function_end)?;
216
217    let len = function_end - function_start + 1;
218    let mut diff = vec![0.0_f64; len + 1];
219
220    for segment in segments {
221        validate_segment_in_range(segment, function_start, function_end)?;
222        apply_segment_to_diff(segment, diff.as_mut_slice(), function_start);
223    }
224
225    Ok(accumulate_signal_from_diff(diff.as_slice(), len))
226}
227
228#[deprecated(note = "Use rasterize_signal instead.")]
229#[must_use = "Inspect the signal build result to handle invalid ranges"]
230pub fn rasterise_signal(
231    function_lines: RangeInclusive<usize>,
232    segments: &[LineSegment],
233) -> Result<Vec<f64>, SignalBuildError> {
234    rasterize_signal(function_lines, segments)
235}
236
237/// Errors emitted when smoothing a signal.
238#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
239pub enum SmoothingError {
240    /// The moving average window must be positive.
241    #[error("smoothing window must be positive (got {window})")]
242    WindowMustBePositive { window: usize },
243
244    /// The moving average window must be odd so the average is centred.
245    #[error("smoothing window must be odd (got {window})")]
246    WindowMustBeOdd { window: usize },
247}
248
249/// Applies a centred moving-average smoothing window.
250///
251/// The smoothing window is centred on each element. Near the start/end of the
252/// signal the window contracts to include only the available samples. This
253/// avoids padding or introducing edge-specific artefacts.
254///
255/// # Errors
256///
257/// Returns [`SmoothingError`] when the window size is invalid.
258///
259/// # Examples
260///
261/// ```
262/// use whitaker_common::complexity_signal::smooth_moving_average;
263///
264/// let signal = vec![0.0, 0.0, 3.0, 0.0, 0.0];
265/// let smoothed = smooth_moving_average(&signal, 3).expect("window should be valid");
266/// assert_eq!(smoothed, vec![0.0, 1.0, 1.0, 1.0, 0.0]);
267/// ```
268#[must_use = "Inspect the smoothing result to handle invalid window sizes"]
269pub fn smooth_moving_average(signal: &[f64], window: usize) -> Result<Vec<f64>, SmoothingError> {
270    if window == 0 {
271        return Err(SmoothingError::WindowMustBePositive { window });
272    }
273
274    fn is_even(value: usize) -> bool {
275        (value & 1) == 0
276    }
277
278    if is_even(window) {
279        return Err(SmoothingError::WindowMustBeOdd { window });
280    }
281
282    if signal.is_empty() {
283        return Ok(Vec::new());
284    }
285
286    let half_window = window / 2;
287    let mut prefix = Vec::with_capacity(signal.len() + 1);
288    prefix.push(0.0_f64);
289    for &value in signal {
290        let next = prefix[prefix.len() - 1] + value;
291        prefix.push(next);
292    }
293
294    let last_index = signal.len() - 1;
295    let mut smoothed = Vec::with_capacity(signal.len());
296    for idx in 0..signal.len() {
297        let start = idx.saturating_sub(half_window);
298        let end = (idx + half_window).min(last_index);
299        let sum = prefix[end + 1] - prefix[start];
300        let count = (end - start + 1) as f64;
301        smoothed.push(sum / count);
302    }
303
304    Ok(smoothed)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use rstest::rstest;
311
312    #[rstest]
313    fn rasterize_signal_accumulates_overlapping_segments() {
314        let segments = vec![
315            LineSegment::new(10, 12, 1.0).expect("segment should be valid"),
316            LineSegment::new(12, 14, 2.0).expect("segment should be valid"),
317        ];
318
319        let signal = rasterize_signal(10..=14, &segments).expect("signal should build");
320        assert_eq!(signal, vec![1.0, 1.0, 3.0, 2.0, 2.0]);
321    }
322
323    #[rstest]
324    fn rasterize_signal_rejects_segments_outside_function_range() {
325        let segments = vec![LineSegment::new(9, 10, 1.0).expect("segment should be valid")];
326
327        let err = rasterize_signal(11..=14, &segments).expect_err("segment should be rejected");
328        assert!(matches!(
329            err,
330            SignalBuildError::SegmentOutsideFunctionRange { .. }
331        ));
332    }
333
334    #[rstest]
335    fn moving_average_smoothing_uses_central_window() {
336        let signal = vec![0.0, 0.0, 3.0, 0.0, 0.0];
337        let smoothed = smooth_moving_average(&signal, 3).expect("window should be valid");
338        assert_eq!(smoothed, vec![0.0, 1.0, 1.0, 1.0, 0.0]);
339    }
340
341    #[rstest]
342    fn moving_average_window_must_be_positive() {
343        let err = smooth_moving_average(&[1.0, 2.0], 0).expect_err("window should be rejected");
344        assert_eq!(err, SmoothingError::WindowMustBePositive { window: 0 });
345    }
346
347    #[rstest]
348    fn moving_average_window_must_be_odd() {
349        let err = smooth_moving_average(&[1.0, 2.0], 2).expect_err("window should be rejected");
350        assert_eq!(err, SmoothingError::WindowMustBeOdd { window: 2 });
351    }
352
353    #[rstest]
354    fn segment_validation_rejects_start_after_end() {
355        let err = LineSegment::new(6, 4, 1.0).expect_err("segment should be invalid");
356        assert_eq!(
357            err,
358            SegmentError::StartAfterEnd {
359                start_line: 6,
360                end_line: 4,
361            }
362        );
363    }
364}