Skip to main content

u_analytics/spc/
rules.rs

1//! Run rules for detecting non-random patterns in control charts.
2//!
3//! Implements Western Electric (4 rules) and Nelson (8 rules) run tests
4//! for identifying special causes of variation in control chart data.
5//!
6//! # References
7//!
8//! - Nelson, L.S. (1984). "The Shewhart Control Chart — Tests for Special Causes",
9//!   *Journal of Quality Technology* 16(4), pp. 237-239.
10//! - Western Electric (1956). *Statistical Quality Control Handbook*.
11//! - Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.
12
13use super::chart::{ChartPoint, ControlLimits, ViolationType};
14
15/// Trait for applying run rules to chart data.
16///
17/// Run rules detect non-random patterns that indicate special causes of
18/// variation even when individual points remain within control limits.
19pub trait RunRule {
20    /// Check points against this rule set and return violations per point index.
21    ///
22    /// Returns a vector of `(point_index, violation_type)` pairs. A single
23    /// point may appear multiple times if it triggers multiple rules.
24    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)>;
25}
26
27/// Western Electric rules (4 rules).
28///
29/// A subset of Nelson's rules, these are the original run tests from the
30/// Western Electric *Statistical Quality Control Handbook* (1956):
31///
32/// 1. Any point beyond 3 sigma (Nelson Rule 1)
33/// 2. 2 of 3 consecutive points beyond 2 sigma, same side (Nelson Rule 5)
34/// 3. 4 of 5 consecutive points beyond 1 sigma, same side (Nelson Rule 6)
35/// 4. 9 consecutive points on the same side of center line (Nelson Rule 2)
36pub struct WesternElectricRules;
37
38/// Nelson rules (8 rules, superset of Western Electric).
39///
40/// The full set of eight tests for special causes as defined by
41/// Nelson (1984). Rules 1, 2, 5, and 6 correspond to the Western
42/// Electric rules.
43///
44/// # Examples
45///
46/// ```
47/// use u_analytics::spc::{NelsonRules, RunRule, ChartPoint, ControlLimits};
48///
49/// let limits = ControlLimits { ucl: 28.0, cl: 25.0, lcl: 22.0 };
50/// let points: Vec<ChartPoint> = vec![
51///     ChartPoint { value: 25.0, index: 0, violations: vec![] },
52///     ChartPoint { value: 29.0, index: 1, violations: vec![] },
53/// ];
54///
55/// let violations = NelsonRules.check(&points, &limits);
56/// assert!(!violations.is_empty()); // Rule 1: point beyond UCL
57/// ```
58pub struct NelsonRules;
59
60// ---------------------------------------------------------------------------
61// Internal helpers
62// ---------------------------------------------------------------------------
63
64/// Compute 1-sigma and 2-sigma zone boundaries from control limits.
65///
66/// Returns `(one_sigma, two_sigma)` where `one_sigma = (UCL - CL) / 3`.
67fn zone_widths(limits: &ControlLimits) -> (f64, f64) {
68    let sigma = (limits.ucl - limits.cl) / 3.0;
69    (sigma, 2.0 * sigma)
70}
71
72/// Nelson Rule 1: Point beyond control limits.
73///
74/// A single point falls outside the UCL or LCL.
75fn check_rule1(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
76    let mut violations = Vec::new();
77    for point in points {
78        if point.value > limits.ucl || point.value < limits.lcl {
79            violations.push((point.index, ViolationType::BeyondLimits));
80        }
81    }
82    violations
83}
84
85/// Nelson Rule 2: 9 consecutive points on the same side of center line.
86///
87/// Indicates a sustained shift in the process mean.
88fn check_rule2(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
89    let mut violations = Vec::new();
90    if points.len() < 9 {
91        return violations;
92    }
93
94    let cl = limits.cl;
95    // Track consecutive runs above/below center line.
96    // +1 = above, -1 = below, 0 = exactly on center (counted as neither side).
97    let sides: Vec<i8> = points
98        .iter()
99        .map(|p| {
100            if p.value > cl {
101                1
102            } else if p.value < cl {
103                -1
104            } else {
105                0
106            }
107        })
108        .collect();
109
110    let mut run_length = 1_usize;
111    for i in 1..sides.len() {
112        if sides[i] != 0 && sides[i] == sides[i - 1] {
113            run_length += 1;
114        } else {
115            run_length = 1;
116        }
117        if run_length >= 9 {
118            violations.push((points[i].index, ViolationType::NineOneSide));
119        }
120    }
121    violations
122}
123
124/// Nelson Rule 3: 6 consecutive points steadily increasing or decreasing.
125///
126/// Indicates a trend in the process.
127fn check_rule3(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
128    let _ = limits; // Not used for trend detection
129    let mut violations = Vec::new();
130    if points.len() < 6 {
131        return violations;
132    }
133
134    // Direction: +1 = increasing, -1 = decreasing, 0 = equal
135    let dirs: Vec<i8> = points
136        .windows(2)
137        .map(|w| {
138            if w[1].value > w[0].value {
139                1
140            } else if w[1].value < w[0].value {
141                -1
142            } else {
143                0
144            }
145        })
146        .collect();
147
148    let mut run_length = 1_usize;
149    for i in 1..dirs.len() {
150        if dirs[i] != 0 && dirs[i] == dirs[i - 1] {
151            run_length += 1;
152        } else {
153            run_length = 1;
154        }
155        // 5 consecutive same-direction changes = 6 points forming a trend.
156        // The violation is reported at the last point of the trend.
157        if run_length >= 5 {
158            // dirs[i] corresponds to the transition between points[i] and points[i+1],
159            // so the last point of the 6-point trend is points[i+1].
160            violations.push((points[i + 1].index, ViolationType::SixTrend));
161        }
162    }
163    violations
164}
165
166/// Nelson Rule 4: 14 consecutive points alternating up and down.
167///
168/// Indicates systematic variation (e.g., two alternating streams).
169fn check_rule4(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
170    let _ = limits;
171    let mut violations = Vec::new();
172    if points.len() < 14 {
173        return violations;
174    }
175
176    // Direction changes: +1 = up, -1 = down, 0 = equal
177    let dirs: Vec<i8> = points
178        .windows(2)
179        .map(|w| {
180            if w[1].value > w[0].value {
181                1
182            } else if w[1].value < w[0].value {
183                -1
184            } else {
185                0
186            }
187        })
188        .collect();
189
190    // Count consecutive alternations
191    let mut alt_length = 1_usize;
192    for i in 1..dirs.len() {
193        if dirs[i] != 0 && dirs[i - 1] != 0 && dirs[i] == -dirs[i - 1] {
194            alt_length += 1;
195        } else {
196            alt_length = 1;
197        }
198        // 13 consecutive alternating directions = 14 points alternating.
199        // dirs[i] corresponds to points[i]→points[i+1],
200        // so the last point is points[i+1].
201        if alt_length >= 13 {
202            violations.push((points[i + 1].index, ViolationType::FourteenAlternating));
203        }
204    }
205    violations
206}
207
208/// Nelson Rule 5: 2 out of 3 consecutive points beyond 2 sigma, same side.
209///
210/// An early warning of a potential shift.
211fn check_rule5(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
212    let mut violations = Vec::new();
213    if points.len() < 3 {
214        return violations;
215    }
216
217    let (_, two_sigma) = zone_widths(limits);
218    let upper_2s = limits.cl + two_sigma;
219    let lower_2s = limits.cl - two_sigma;
220
221    for i in 2..points.len() {
222        let window = &points[i - 2..=i];
223
224        // Check upper side: count points above CL + 2σ
225        let above_count = window.iter().filter(|p| p.value > upper_2s).count();
226        if above_count >= 2 {
227            violations.push((points[i].index, ViolationType::TwoOfThreeBeyond2Sigma));
228            continue;
229        }
230
231        // Check lower side: count points below CL - 2σ
232        let below_count = window.iter().filter(|p| p.value < lower_2s).count();
233        if below_count >= 2 {
234            violations.push((points[i].index, ViolationType::TwoOfThreeBeyond2Sigma));
235        }
236    }
237    violations
238}
239
240/// Nelson Rule 6: 4 out of 5 consecutive points beyond 1 sigma, same side.
241///
242/// Indicates a small sustained shift.
243fn check_rule6(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
244    let mut violations = Vec::new();
245    if points.len() < 5 {
246        return violations;
247    }
248
249    let (one_sigma, _) = zone_widths(limits);
250    let upper_1s = limits.cl + one_sigma;
251    let lower_1s = limits.cl - one_sigma;
252
253    for i in 4..points.len() {
254        let window = &points[i - 4..=i];
255
256        // Check upper side: count points above CL + σ
257        let above_count = window.iter().filter(|p| p.value > upper_1s).count();
258        if above_count >= 4 {
259            violations.push((points[i].index, ViolationType::FourOfFiveBeyond1Sigma));
260            continue;
261        }
262
263        // Check lower side: count points below CL - σ
264        let below_count = window.iter().filter(|p| p.value < lower_1s).count();
265        if below_count >= 4 {
266            violations.push((points[i].index, ViolationType::FourOfFiveBeyond1Sigma));
267        }
268    }
269    violations
270}
271
272/// Nelson Rule 7: 15 consecutive points within 1 sigma of center line.
273///
274/// Indicates stratification — reduced variation suggesting mixed streams.
275fn check_rule7(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
276    let mut violations = Vec::new();
277    if points.len() < 15 {
278        return violations;
279    }
280
281    let (one_sigma, _) = zone_widths(limits);
282    let upper_1s = limits.cl + one_sigma;
283    let lower_1s = limits.cl - one_sigma;
284
285    let mut run_length = 0_usize;
286    for point in points {
287        if point.value >= lower_1s && point.value <= upper_1s {
288            run_length += 1;
289        } else {
290            run_length = 0;
291        }
292        if run_length >= 15 {
293            violations.push((point.index, ViolationType::FifteenWithin1Sigma));
294        }
295    }
296    violations
297}
298
299/// Nelson Rule 8: 8 consecutive points beyond 1 sigma on either side.
300///
301/// Indicates a mixture pattern — points avoid the center zone.
302fn check_rule8(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
303    let mut violations = Vec::new();
304    if points.len() < 8 {
305        return violations;
306    }
307
308    let (one_sigma, _) = zone_widths(limits);
309    let upper_1s = limits.cl + one_sigma;
310    let lower_1s = limits.cl - one_sigma;
311
312    let mut run_length = 0_usize;
313    for point in points {
314        // Point is beyond 1σ on either side (not within CL ± σ)
315        if point.value > upper_1s || point.value < lower_1s {
316            run_length += 1;
317        } else {
318            run_length = 0;
319        }
320        if run_length >= 8 {
321            violations.push((point.index, ViolationType::EightBeyond1Sigma));
322        }
323    }
324    violations
325}
326
327// ---------------------------------------------------------------------------
328// RunRule implementations
329// ---------------------------------------------------------------------------
330
331impl RunRule for WesternElectricRules {
332    /// Apply the 4 Western Electric run rules.
333    ///
334    /// These correspond to Nelson Rules 1, 2, 5, and 6.
335    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
336        let mut results = Vec::new();
337        results.extend(check_rule1(points, limits));
338        results.extend(check_rule2(points, limits));
339        results.extend(check_rule5(points, limits));
340        results.extend(check_rule6(points, limits));
341        results.sort_by_key(|&(idx, _)| idx);
342        results
343    }
344}
345
346impl RunRule for NelsonRules {
347    /// Apply all 8 Nelson run rules.
348    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
349        let mut results = Vec::new();
350        results.extend(check_rule1(points, limits));
351        results.extend(check_rule2(points, limits));
352        results.extend(check_rule3(points, limits));
353        results.extend(check_rule4(points, limits));
354        results.extend(check_rule5(points, limits));
355        results.extend(check_rule6(points, limits));
356        results.extend(check_rule7(points, limits));
357        results.extend(check_rule8(points, limits));
358        results.sort_by_key(|&(idx, _)| idx);
359        results
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    /// Helper: create chart points from a slice of values.
368    fn make_points(values: &[f64]) -> Vec<ChartPoint> {
369        values
370            .iter()
371            .enumerate()
372            .map(|(i, &v)| ChartPoint {
373                value: v,
374                index: i,
375                violations: Vec::new(),
376            })
377            .collect()
378    }
379
380    // --- Rule 1: Beyond limits ---
381
382    #[test]
383    fn test_rule1_point_above_ucl() {
384        let limits = ControlLimits {
385            ucl: 30.0,
386            cl: 25.0,
387            lcl: 20.0,
388        };
389        let points = make_points(&[25.0, 31.0, 25.0]);
390        let violations = check_rule1(&points, &limits);
391        assert_eq!(violations.len(), 1);
392        assert_eq!(violations[0].0, 1);
393        assert_eq!(violations[0].1, ViolationType::BeyondLimits);
394    }
395
396    #[test]
397    fn test_rule1_point_below_lcl() {
398        let limits = ControlLimits {
399            ucl: 30.0,
400            cl: 25.0,
401            lcl: 20.0,
402        };
403        let points = make_points(&[25.0, 19.0, 25.0]);
404        let violations = check_rule1(&points, &limits);
405        assert_eq!(violations.len(), 1);
406        assert_eq!(violations[0].0, 1);
407    }
408
409    #[test]
410    fn test_rule1_on_limit_is_not_violation() {
411        let limits = ControlLimits {
412            ucl: 30.0,
413            cl: 25.0,
414            lcl: 20.0,
415        };
416        let points = make_points(&[30.0, 20.0]);
417        let violations = check_rule1(&points, &limits);
418        assert!(violations.is_empty());
419    }
420
421    // --- Rule 2: 9 on same side ---
422
423    #[test]
424    fn test_rule2_nine_above() {
425        let limits = ControlLimits {
426            ucl: 30.0,
427            cl: 25.0,
428            lcl: 20.0,
429        };
430        // 9 points above center line
431        let values: Vec<f64> = (0..9).map(|_| 26.0).collect();
432        let points = make_points(&values);
433        let violations = check_rule2(&points, &limits);
434        assert_eq!(violations.len(), 1);
435        assert_eq!(violations[0].1, ViolationType::NineOneSide);
436    }
437
438    #[test]
439    fn test_rule2_eight_not_enough() {
440        let limits = ControlLimits {
441            ucl: 30.0,
442            cl: 25.0,
443            lcl: 20.0,
444        };
445        let values: Vec<f64> = (0..8).map(|_| 26.0).collect();
446        let points = make_points(&values);
447        let violations = check_rule2(&points, &limits);
448        assert!(violations.is_empty());
449    }
450
451    #[test]
452    fn test_rule2_nine_below() {
453        let limits = ControlLimits {
454            ucl: 30.0,
455            cl: 25.0,
456            lcl: 20.0,
457        };
458        let values: Vec<f64> = (0..9).map(|_| 24.0).collect();
459        let points = make_points(&values);
460        let violations = check_rule2(&points, &limits);
461        assert_eq!(violations.len(), 1);
462    }
463
464    // --- Rule 3: 6 trending ---
465
466    #[test]
467    fn test_rule3_six_increasing() {
468        let limits = ControlLimits {
469            ucl: 30.0,
470            cl: 25.0,
471            lcl: 20.0,
472        };
473        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0, 25.0]);
474        let violations = check_rule3(&points, &limits);
475        assert_eq!(violations.len(), 1);
476        assert_eq!(violations[0].1, ViolationType::SixTrend);
477    }
478
479    #[test]
480    fn test_rule3_six_decreasing() {
481        let limits = ControlLimits {
482            ucl: 30.0,
483            cl: 25.0,
484            lcl: 20.0,
485        };
486        let points = make_points(&[30.0, 29.0, 28.0, 27.0, 26.0, 25.0]);
487        let violations = check_rule3(&points, &limits);
488        assert_eq!(violations.len(), 1);
489    }
490
491    #[test]
492    fn test_rule3_five_not_enough() {
493        let limits = ControlLimits {
494            ucl: 30.0,
495            cl: 25.0,
496            lcl: 20.0,
497        };
498        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0]);
499        let violations = check_rule3(&points, &limits);
500        assert!(violations.is_empty());
501    }
502
503    // --- Rule 4: 14 alternating ---
504
505    #[test]
506    fn test_rule4_fourteen_alternating() {
507        let limits = ControlLimits {
508            ucl: 30.0,
509            cl: 25.0,
510            lcl: 20.0,
511        };
512        // Create alternating pattern: 24, 26, 24, 26, ...
513        let values: Vec<f64> = (0..14)
514            .map(|i| if i % 2 == 0 { 24.0 } else { 26.0 })
515            .collect();
516        let points = make_points(&values);
517        let violations = check_rule4(&points, &limits);
518        assert_eq!(violations.len(), 1);
519        assert_eq!(violations[0].1, ViolationType::FourteenAlternating);
520    }
521
522    #[test]
523    fn test_rule4_thirteen_not_enough() {
524        let limits = ControlLimits {
525            ucl: 30.0,
526            cl: 25.0,
527            lcl: 20.0,
528        };
529        let values: Vec<f64> = (0..13)
530            .map(|i| if i % 2 == 0 { 24.0 } else { 26.0 })
531            .collect();
532        let points = make_points(&values);
533        let violations = check_rule4(&points, &limits);
534        assert!(violations.is_empty());
535    }
536
537    // --- Rule 5: 2 of 3 beyond 2σ ---
538
539    #[test]
540    fn test_rule5_two_of_three_above() {
541        let limits = ControlLimits {
542            ucl: 28.0, // σ = (28-25)/3 ≈ 1.0, 2σ = 2.0
543            cl: 25.0,
544            lcl: 22.0,
545        };
546        // 2σ line upper = 25 + 2 = 27, lower = 25 - 2 = 23
547        // Two of three points above 27
548        let points = make_points(&[27.5, 25.0, 27.5]);
549        let violations = check_rule5(&points, &limits);
550        assert_eq!(violations.len(), 1);
551        assert_eq!(violations[0].1, ViolationType::TwoOfThreeBeyond2Sigma);
552    }
553
554    #[test]
555    fn test_rule5_two_of_three_below() {
556        let limits = ControlLimits {
557            ucl: 28.0,
558            cl: 25.0,
559            lcl: 22.0,
560        };
561        // Two of three below CL - 2σ = 23
562        let points = make_points(&[22.5, 25.0, 22.5]);
563        let violations = check_rule5(&points, &limits);
564        assert_eq!(violations.len(), 1);
565    }
566
567    // --- Rule 6: 4 of 5 beyond 1σ ---
568
569    #[test]
570    fn test_rule6_four_of_five_above() {
571        let limits = ControlLimits {
572            ucl: 28.0, // σ = 1.0
573            cl: 25.0,
574            lcl: 22.0,
575        };
576        // 1σ upper = 26. Four of five points above 26.
577        let points = make_points(&[26.5, 26.5, 25.0, 26.5, 26.5]);
578        let violations = check_rule6(&points, &limits);
579        assert_eq!(violations.len(), 1);
580        assert_eq!(violations[0].1, ViolationType::FourOfFiveBeyond1Sigma);
581    }
582
583    // --- Rule 7: 15 within 1σ ---
584
585    #[test]
586    fn test_rule7_fifteen_within() {
587        let limits = ControlLimits {
588            ucl: 28.0, // σ = 1.0
589            cl: 25.0,
590            lcl: 22.0,
591        };
592        // 15 points within CL ± σ = [24, 26]
593        let values: Vec<f64> = (0..15).map(|i| 24.5 + (i as f64 % 3.0) * 0.25).collect();
594        let points = make_points(&values);
595        let violations = check_rule7(&points, &limits);
596        assert_eq!(violations.len(), 1);
597        assert_eq!(violations[0].1, ViolationType::FifteenWithin1Sigma);
598    }
599
600    // --- Rule 8: 8 beyond 1σ on either side ---
601
602    #[test]
603    fn test_rule8_eight_beyond() {
604        let limits = ControlLimits {
605            ucl: 28.0, // σ = 1.0
606            cl: 25.0,
607            lcl: 22.0,
608        };
609        // 8 points beyond CL ± σ (alternating sides is fine)
610        let points = make_points(&[27.0, 23.0, 27.0, 23.0, 27.0, 23.0, 27.0, 23.0]);
611        let violations = check_rule8(&points, &limits);
612        assert_eq!(violations.len(), 1);
613        assert_eq!(violations[0].1, ViolationType::EightBeyond1Sigma);
614    }
615
616    #[test]
617    fn test_rule8_seven_not_enough() {
618        let limits = ControlLimits {
619            ucl: 28.0,
620            cl: 25.0,
621            lcl: 22.0,
622        };
623        let points = make_points(&[27.0, 23.0, 27.0, 23.0, 27.0, 23.0, 27.0]);
624        let violations = check_rule8(&points, &limits);
625        assert!(violations.is_empty());
626    }
627
628    // --- Western Electric Rules ---
629
630    #[test]
631    fn test_western_electric_combines_four_rules() {
632        let limits = ControlLimits {
633            ucl: 30.0,
634            cl: 25.0,
635            lcl: 20.0,
636        };
637        // A point beyond limits triggers WE rules
638        let points = make_points(&[25.0, 31.0, 25.0]);
639        let we = WesternElectricRules;
640        let violations = we.check(&points, &limits);
641        assert!(!violations.is_empty());
642        assert!(violations
643            .iter()
644            .any(|(_, v)| *v == ViolationType::BeyondLimits));
645    }
646
647    // --- Nelson Rules ---
648
649    #[test]
650    fn test_nelson_detects_trend() {
651        let limits = ControlLimits {
652            ucl: 30.0,
653            cl: 25.0,
654            lcl: 20.0,
655        };
656        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0, 25.0]);
657        let nelson = NelsonRules;
658        let violations = nelson.check(&points, &limits);
659        assert!(violations
660            .iter()
661            .any(|(_, v)| *v == ViolationType::SixTrend));
662    }
663
664    #[test]
665    fn test_nelson_no_violations_in_random_data() {
666        let limits = ControlLimits {
667            ucl: 28.0,
668            cl: 25.0,
669            lcl: 22.0,
670        };
671        // A short random-looking sequence should not trigger
672        let points = make_points(&[25.5, 24.8, 25.2, 24.9, 25.1]);
673        let nelson = NelsonRules;
674        let violations = nelson.check(&points, &limits);
675        assert!(violations.is_empty());
676    }
677
678    #[test]
679    fn test_nelson_rule2_continuation() {
680        // If 10 points on same side, rules fire at point 8 (index) and 9
681        let limits = ControlLimits {
682            ucl: 30.0,
683            cl: 25.0,
684            lcl: 20.0,
685        };
686        let values: Vec<f64> = (0..10).map(|_| 26.0).collect();
687        let points = make_points(&values);
688        let violations = check_rule2(&points, &limits);
689        // Should fire at index 8 and 9
690        assert_eq!(violations.len(), 2);
691        assert_eq!(violations[0].0, 8);
692        assert_eq!(violations[1].0, 9);
693    }
694
695    #[test]
696    fn test_rule5_not_triggered_mixed_sides() {
697        let limits = ControlLimits {
698            ucl: 28.0,
699            cl: 25.0,
700            lcl: 22.0,
701        };
702        // One point above 2σ upper (>27), one below 2σ lower (<23) — different sides
703        // Rule 5 requires 2 of 3 on the SAME side, so this should not trigger
704        let points = make_points(&[27.5, 25.0, 22.5]);
705        let violations = check_rule5(&points, &limits);
706        assert!(violations.is_empty());
707    }
708
709    #[test]
710    fn test_rule7_fourteen_not_enough() {
711        let limits = ControlLimits {
712            ucl: 28.0,
713            cl: 25.0,
714            lcl: 22.0,
715        };
716        let values: Vec<f64> = (0..14).map(|_| 25.5).collect();
717        let points = make_points(&values);
718        let violations = check_rule7(&points, &limits);
719        assert!(violations.is_empty());
720    }
721}