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// Selectable rule sets
329// ---------------------------------------------------------------------------
330
331/// A chosen subset of the eight run tests.
332///
333/// Each test reports exactly one [`ViolationType`], so a rule is named by what
334/// it would report rather than by a parallel enum that would have to be kept in
335/// step with this one.
336///
337/// [`nelson`](RuleSet::nelson) and [`western_electric`](RuleSet::western_electric)
338/// are the two named sets in the literature; any other combination is built from
339/// the tests a process actually warrants. A rule that a process is known to
340/// trigger for a benign reason -- a deliberately drifting tool, a bimodal
341/// fixture -- otherwise has to be filtered out downstream, after it has already
342/// been counted as an out-of-control signal.
343///
344/// # Examples
345///
346/// ```
347/// use u_analytics::spc::{RuleSet, RunRule, ChartPoint, ControlLimits, ViolationType};
348///
349/// // Only the 3-sigma test, and nothing else.
350/// let rules = RuleSet::from_iter([ViolationType::BeyondLimits]);
351///
352/// let limits = ControlLimits { ucl: 28.0, cl: 25.0, lcl: 22.0 };
353/// let points: Vec<ChartPoint> = (0..12)
354///     .map(|i| ChartPoint { value: 26.0, index: i, violations: vec![] })
355///     .collect();
356///
357/// // Twelve points above the centre line would trip Nelson rule 2, but that
358/// // test is not in this set.
359/// assert!(rules.check(&points, &limits).is_empty());
360/// assert!(RuleSet::nelson().check(&points, &limits).len() > 0);
361/// ```
362#[derive(Debug, Clone, Copy, PartialEq, Eq)]
363pub struct RuleSet {
364    mask: u8,
365}
366
367/// The eight tests, in Nelson's numbering.
368const ALL_RULES: [ViolationType; 8] = [
369    ViolationType::BeyondLimits,
370    ViolationType::NineOneSide,
371    ViolationType::SixTrend,
372    ViolationType::FourteenAlternating,
373    ViolationType::TwoOfThreeBeyond2Sigma,
374    ViolationType::FourOfFiveBeyond1Sigma,
375    ViolationType::FifteenWithin1Sigma,
376    ViolationType::EightBeyond1Sigma,
377];
378
379fn bit_of(v: ViolationType) -> u8 {
380    match v {
381        ViolationType::BeyondLimits => 1 << 0,
382        ViolationType::NineOneSide => 1 << 1,
383        ViolationType::SixTrend => 1 << 2,
384        ViolationType::FourteenAlternating => 1 << 3,
385        ViolationType::TwoOfThreeBeyond2Sigma => 1 << 4,
386        ViolationType::FourOfFiveBeyond1Sigma => 1 << 5,
387        ViolationType::FifteenWithin1Sigma => 1 << 6,
388        ViolationType::EightBeyond1Sigma => 1 << 7,
389    }
390}
391
392impl RuleSet {
393    /// All eight tests -- Nelson (1984). This is what every chart applies unless
394    /// told otherwise, so the default behaviour is unchanged.
395    pub fn nelson() -> Self {
396        Self { mask: u8::MAX }
397    }
398
399    /// The four Western Electric tests: Nelson rules 1, 2, 5 and 6.
400    pub fn western_electric() -> Self {
401        Self::from_iter([
402            ViolationType::BeyondLimits,
403            ViolationType::NineOneSide,
404            ViolationType::TwoOfThreeBeyond2Sigma,
405            ViolationType::FourOfFiveBeyond1Sigma,
406        ])
407    }
408
409    /// The empty set: no run test is applied.
410    ///
411    /// Useful as a base to add to, and as the honest way to say "limits only".
412    pub fn none() -> Self {
413        Self { mask: 0 }
414    }
415
416    /// Whether this set applies the test that reports `rule`.
417    pub fn contains(&self, rule: ViolationType) -> bool {
418        self.mask & bit_of(rule) != 0
419    }
420
421    /// This set with `rule` added.
422    #[must_use]
423    pub fn with(mut self, rule: ViolationType) -> Self {
424        self.mask |= bit_of(rule);
425        self
426    }
427
428    /// This set with `rule` removed.
429    #[must_use]
430    pub fn without(mut self, rule: ViolationType) -> Self {
431        self.mask &= !bit_of(rule);
432        self
433    }
434
435    /// The tests in this set, in Nelson's numbering.
436    pub fn iter(&self) -> impl Iterator<Item = ViolationType> + '_ {
437        ALL_RULES.into_iter().filter(|&r| self.contains(r))
438    }
439
440    /// How many tests this set applies.
441    pub fn len(&self) -> usize {
442        self.mask.count_ones() as usize
443    }
444
445    /// Whether no test is applied.
446    pub fn is_empty(&self) -> bool {
447        self.mask == 0
448    }
449}
450
451impl Default for RuleSet {
452    /// [`RuleSet::nelson`] -- the behaviour every chart had before rule
453    /// selection existed.
454    fn default() -> Self {
455        Self::nelson()
456    }
457}
458
459impl FromIterator<ViolationType> for RuleSet {
460    fn from_iter<I: IntoIterator<Item = ViolationType>>(iter: I) -> Self {
461        let mut mask = 0u8;
462        for v in iter {
463            mask |= bit_of(v);
464        }
465        Self { mask }
466    }
467}
468
469impl RunRule for RuleSet {
470    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
471        let mut results = Vec::new();
472        for rule in self.iter() {
473            results.extend(match rule {
474                ViolationType::BeyondLimits => check_rule1(points, limits),
475                ViolationType::NineOneSide => check_rule2(points, limits),
476                ViolationType::SixTrend => check_rule3(points, limits),
477                ViolationType::FourteenAlternating => check_rule4(points, limits),
478                ViolationType::TwoOfThreeBeyond2Sigma => check_rule5(points, limits),
479                ViolationType::FourOfFiveBeyond1Sigma => check_rule6(points, limits),
480                ViolationType::FifteenWithin1Sigma => check_rule7(points, limits),
481                ViolationType::EightBeyond1Sigma => check_rule8(points, limits),
482            });
483        }
484        results.sort_by_key(|&(idx, _)| idx);
485        results
486    }
487}
488
489// ---------------------------------------------------------------------------
490// RunRule implementations
491// ---------------------------------------------------------------------------
492
493impl RunRule for WesternElectricRules {
494    /// Apply the 4 Western Electric run rules.
495    ///
496    /// These correspond to Nelson Rules 1, 2, 5, and 6.
497    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
498        RuleSet::western_electric().check(points, limits)
499    }
500}
501
502impl RunRule for NelsonRules {
503    /// Apply all 8 Nelson run rules.
504    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
505        RuleSet::nelson().check(points, limits)
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use super::*;
512
513    // --- RuleSet ---
514
515    /// Twelve points above the centre line: rule 2 fires, rule 1 does not.
516    /// The distinguishing case for "did the selection actually take effect".
517    fn nine_on_one_side() -> (Vec<ChartPoint>, ControlLimits) {
518        let limits = ControlLimits {
519            ucl: 28.0,
520            cl: 25.0,
521            lcl: 22.0,
522        };
523        let points = (0..12)
524            .map(|i| ChartPoint {
525                value: 26.0,
526                index: i,
527                violations: vec![],
528            })
529            .collect();
530        (points, limits)
531    }
532
533    #[test]
534    fn ruleset_applies_only_the_tests_it_holds() {
535        let (points, limits) = nine_on_one_side();
536
537        // Nelson catches the run; a set without that test does not.
538        assert!(!RuleSet::nelson().check(&points, &limits).is_empty());
539        assert!(RuleSet::nelson()
540            .without(ViolationType::NineOneSide)
541            .check(&points, &limits)
542            .is_empty());
543
544        // And a set holding only that test still catches it -- so the empty
545        // result above is the selection working, not the data failing to trip.
546        assert!(!RuleSet::from_iter([ViolationType::NineOneSide])
547            .check(&points, &limits)
548            .is_empty());
549    }
550
551    #[test]
552    fn ruleset_empty_reports_nothing_even_beyond_limits() {
553        let limits = ControlLimits {
554            ucl: 28.0,
555            cl: 25.0,
556            lcl: 22.0,
557        };
558        let points = vec![ChartPoint {
559            value: 99.0,
560            index: 0,
561            violations: vec![],
562        }];
563        assert!(!RuleSet::nelson().check(&points, &limits).is_empty());
564        assert!(RuleSet::none().check(&points, &limits).is_empty());
565    }
566
567    #[test]
568    fn named_sets_match_their_definitions() {
569        assert_eq!(RuleSet::nelson().len(), 8);
570        assert_eq!(RuleSet::western_electric().len(), 4);
571        for r in [
572            ViolationType::BeyondLimits,
573            ViolationType::NineOneSide,
574            ViolationType::TwoOfThreeBeyond2Sigma,
575            ViolationType::FourOfFiveBeyond1Sigma,
576        ] {
577            assert!(RuleSet::western_electric().contains(r), "{r:?} missing");
578        }
579        for r in [
580            ViolationType::SixTrend,
581            ViolationType::FourteenAlternating,
582            ViolationType::FifteenWithin1Sigma,
583            ViolationType::EightBeyond1Sigma,
584        ] {
585            assert!(!RuleSet::western_electric().contains(r), "{r:?} present");
586        }
587        assert!(RuleSet::none().is_empty());
588    }
589
590    /// The named unit structs delegate to `RuleSet`, so they must still agree
591    /// with it -- otherwise the delegation quietly changed public behaviour.
592    #[test]
593    fn named_unit_structs_agree_with_their_rule_sets() {
594        let (points, limits) = nine_on_one_side();
595        assert_eq!(
596            NelsonRules.check(&points, &limits),
597            RuleSet::nelson().check(&points, &limits)
598        );
599        assert_eq!(
600            WesternElectricRules.check(&points, &limits),
601            RuleSet::western_electric().check(&points, &limits)
602        );
603    }
604
605    /// Helper: create chart points from a slice of values.
606    fn make_points(values: &[f64]) -> Vec<ChartPoint> {
607        values
608            .iter()
609            .enumerate()
610            .map(|(i, &v)| ChartPoint {
611                value: v,
612                index: i,
613                violations: Vec::new(),
614            })
615            .collect()
616    }
617
618    // --- Rule 1: Beyond limits ---
619
620    #[test]
621    fn test_rule1_point_above_ucl() {
622        let limits = ControlLimits {
623            ucl: 30.0,
624            cl: 25.0,
625            lcl: 20.0,
626        };
627        let points = make_points(&[25.0, 31.0, 25.0]);
628        let violations = check_rule1(&points, &limits);
629        assert_eq!(violations.len(), 1);
630        assert_eq!(violations[0].0, 1);
631        assert_eq!(violations[0].1, ViolationType::BeyondLimits);
632    }
633
634    #[test]
635    fn test_rule1_point_below_lcl() {
636        let limits = ControlLimits {
637            ucl: 30.0,
638            cl: 25.0,
639            lcl: 20.0,
640        };
641        let points = make_points(&[25.0, 19.0, 25.0]);
642        let violations = check_rule1(&points, &limits);
643        assert_eq!(violations.len(), 1);
644        assert_eq!(violations[0].0, 1);
645    }
646
647    #[test]
648    fn test_rule1_on_limit_is_not_violation() {
649        let limits = ControlLimits {
650            ucl: 30.0,
651            cl: 25.0,
652            lcl: 20.0,
653        };
654        let points = make_points(&[30.0, 20.0]);
655        let violations = check_rule1(&points, &limits);
656        assert!(violations.is_empty());
657    }
658
659    // --- Rule 2: 9 on same side ---
660
661    #[test]
662    fn test_rule2_nine_above() {
663        let limits = ControlLimits {
664            ucl: 30.0,
665            cl: 25.0,
666            lcl: 20.0,
667        };
668        // 9 points above center line
669        let values: Vec<f64> = (0..9).map(|_| 26.0).collect();
670        let points = make_points(&values);
671        let violations = check_rule2(&points, &limits);
672        assert_eq!(violations.len(), 1);
673        assert_eq!(violations[0].1, ViolationType::NineOneSide);
674    }
675
676    #[test]
677    fn test_rule2_eight_not_enough() {
678        let limits = ControlLimits {
679            ucl: 30.0,
680            cl: 25.0,
681            lcl: 20.0,
682        };
683        let values: Vec<f64> = (0..8).map(|_| 26.0).collect();
684        let points = make_points(&values);
685        let violations = check_rule2(&points, &limits);
686        assert!(violations.is_empty());
687    }
688
689    #[test]
690    fn test_rule2_nine_below() {
691        let limits = ControlLimits {
692            ucl: 30.0,
693            cl: 25.0,
694            lcl: 20.0,
695        };
696        let values: Vec<f64> = (0..9).map(|_| 24.0).collect();
697        let points = make_points(&values);
698        let violations = check_rule2(&points, &limits);
699        assert_eq!(violations.len(), 1);
700    }
701
702    // --- Rule 3: 6 trending ---
703
704    #[test]
705    fn test_rule3_six_increasing() {
706        let limits = ControlLimits {
707            ucl: 30.0,
708            cl: 25.0,
709            lcl: 20.0,
710        };
711        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0, 25.0]);
712        let violations = check_rule3(&points, &limits);
713        assert_eq!(violations.len(), 1);
714        assert_eq!(violations[0].1, ViolationType::SixTrend);
715    }
716
717    #[test]
718    fn test_rule3_six_decreasing() {
719        let limits = ControlLimits {
720            ucl: 30.0,
721            cl: 25.0,
722            lcl: 20.0,
723        };
724        let points = make_points(&[30.0, 29.0, 28.0, 27.0, 26.0, 25.0]);
725        let violations = check_rule3(&points, &limits);
726        assert_eq!(violations.len(), 1);
727    }
728
729    #[test]
730    fn test_rule3_five_not_enough() {
731        let limits = ControlLimits {
732            ucl: 30.0,
733            cl: 25.0,
734            lcl: 20.0,
735        };
736        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0]);
737        let violations = check_rule3(&points, &limits);
738        assert!(violations.is_empty());
739    }
740
741    // --- Rule 4: 14 alternating ---
742
743    #[test]
744    fn test_rule4_fourteen_alternating() {
745        let limits = ControlLimits {
746            ucl: 30.0,
747            cl: 25.0,
748            lcl: 20.0,
749        };
750        // Create alternating pattern: 24, 26, 24, 26, ...
751        let values: Vec<f64> = (0..14)
752            .map(|i| if i % 2 == 0 { 24.0 } else { 26.0 })
753            .collect();
754        let points = make_points(&values);
755        let violations = check_rule4(&points, &limits);
756        assert_eq!(violations.len(), 1);
757        assert_eq!(violations[0].1, ViolationType::FourteenAlternating);
758    }
759
760    #[test]
761    fn test_rule4_thirteen_not_enough() {
762        let limits = ControlLimits {
763            ucl: 30.0,
764            cl: 25.0,
765            lcl: 20.0,
766        };
767        let values: Vec<f64> = (0..13)
768            .map(|i| if i % 2 == 0 { 24.0 } else { 26.0 })
769            .collect();
770        let points = make_points(&values);
771        let violations = check_rule4(&points, &limits);
772        assert!(violations.is_empty());
773    }
774
775    // --- Rule 5: 2 of 3 beyond 2σ ---
776
777    #[test]
778    fn test_rule5_two_of_three_above() {
779        let limits = ControlLimits {
780            ucl: 28.0, // σ = (28-25)/3 ≈ 1.0, 2σ = 2.0
781            cl: 25.0,
782            lcl: 22.0,
783        };
784        // 2σ line upper = 25 + 2 = 27, lower = 25 - 2 = 23
785        // Two of three points above 27
786        let points = make_points(&[27.5, 25.0, 27.5]);
787        let violations = check_rule5(&points, &limits);
788        assert_eq!(violations.len(), 1);
789        assert_eq!(violations[0].1, ViolationType::TwoOfThreeBeyond2Sigma);
790    }
791
792    #[test]
793    fn test_rule5_two_of_three_below() {
794        let limits = ControlLimits {
795            ucl: 28.0,
796            cl: 25.0,
797            lcl: 22.0,
798        };
799        // Two of three below CL - 2σ = 23
800        let points = make_points(&[22.5, 25.0, 22.5]);
801        let violations = check_rule5(&points, &limits);
802        assert_eq!(violations.len(), 1);
803    }
804
805    // --- Rule 6: 4 of 5 beyond 1σ ---
806
807    #[test]
808    fn test_rule6_four_of_five_above() {
809        let limits = ControlLimits {
810            ucl: 28.0, // σ = 1.0
811            cl: 25.0,
812            lcl: 22.0,
813        };
814        // 1σ upper = 26. Four of five points above 26.
815        let points = make_points(&[26.5, 26.5, 25.0, 26.5, 26.5]);
816        let violations = check_rule6(&points, &limits);
817        assert_eq!(violations.len(), 1);
818        assert_eq!(violations[0].1, ViolationType::FourOfFiveBeyond1Sigma);
819    }
820
821    // --- Rule 7: 15 within 1σ ---
822
823    #[test]
824    fn test_rule7_fifteen_within() {
825        let limits = ControlLimits {
826            ucl: 28.0, // σ = 1.0
827            cl: 25.0,
828            lcl: 22.0,
829        };
830        // 15 points within CL ± σ = [24, 26]
831        let values: Vec<f64> = (0..15).map(|i| 24.5 + (i as f64 % 3.0) * 0.25).collect();
832        let points = make_points(&values);
833        let violations = check_rule7(&points, &limits);
834        assert_eq!(violations.len(), 1);
835        assert_eq!(violations[0].1, ViolationType::FifteenWithin1Sigma);
836    }
837
838    // --- Rule 8: 8 beyond 1σ on either side ---
839
840    #[test]
841    fn test_rule8_eight_beyond() {
842        let limits = ControlLimits {
843            ucl: 28.0, // σ = 1.0
844            cl: 25.0,
845            lcl: 22.0,
846        };
847        // 8 points beyond CL ± σ (alternating sides is fine)
848        let points = make_points(&[27.0, 23.0, 27.0, 23.0, 27.0, 23.0, 27.0, 23.0]);
849        let violations = check_rule8(&points, &limits);
850        assert_eq!(violations.len(), 1);
851        assert_eq!(violations[0].1, ViolationType::EightBeyond1Sigma);
852    }
853
854    #[test]
855    fn test_rule8_seven_not_enough() {
856        let limits = ControlLimits {
857            ucl: 28.0,
858            cl: 25.0,
859            lcl: 22.0,
860        };
861        let points = make_points(&[27.0, 23.0, 27.0, 23.0, 27.0, 23.0, 27.0]);
862        let violations = check_rule8(&points, &limits);
863        assert!(violations.is_empty());
864    }
865
866    // --- Western Electric Rules ---
867
868    #[test]
869    fn test_western_electric_combines_four_rules() {
870        let limits = ControlLimits {
871            ucl: 30.0,
872            cl: 25.0,
873            lcl: 20.0,
874        };
875        // A point beyond limits triggers WE rules
876        let points = make_points(&[25.0, 31.0, 25.0]);
877        let we = WesternElectricRules;
878        let violations = we.check(&points, &limits);
879        assert!(!violations.is_empty());
880        assert!(violations
881            .iter()
882            .any(|(_, v)| *v == ViolationType::BeyondLimits));
883    }
884
885    // --- Nelson Rules ---
886
887    #[test]
888    fn test_nelson_detects_trend() {
889        let limits = ControlLimits {
890            ucl: 30.0,
891            cl: 25.0,
892            lcl: 20.0,
893        };
894        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0, 25.0]);
895        let nelson = NelsonRules;
896        let violations = nelson.check(&points, &limits);
897        assert!(violations
898            .iter()
899            .any(|(_, v)| *v == ViolationType::SixTrend));
900    }
901
902    #[test]
903    fn test_nelson_no_violations_in_random_data() {
904        let limits = ControlLimits {
905            ucl: 28.0,
906            cl: 25.0,
907            lcl: 22.0,
908        };
909        // A short random-looking sequence should not trigger
910        let points = make_points(&[25.5, 24.8, 25.2, 24.9, 25.1]);
911        let nelson = NelsonRules;
912        let violations = nelson.check(&points, &limits);
913        assert!(violations.is_empty());
914    }
915
916    #[test]
917    fn test_nelson_rule2_continuation() {
918        // If 10 points on same side, rules fire at point 8 (index) and 9
919        let limits = ControlLimits {
920            ucl: 30.0,
921            cl: 25.0,
922            lcl: 20.0,
923        };
924        let values: Vec<f64> = (0..10).map(|_| 26.0).collect();
925        let points = make_points(&values);
926        let violations = check_rule2(&points, &limits);
927        // Should fire at index 8 and 9
928        assert_eq!(violations.len(), 2);
929        assert_eq!(violations[0].0, 8);
930        assert_eq!(violations[1].0, 9);
931    }
932
933    #[test]
934    fn test_rule5_not_triggered_mixed_sides() {
935        let limits = ControlLimits {
936            ucl: 28.0,
937            cl: 25.0,
938            lcl: 22.0,
939        };
940        // One point above 2σ upper (>27), one below 2σ lower (<23) — different sides
941        // Rule 5 requires 2 of 3 on the SAME side, so this should not trigger
942        let points = make_points(&[27.5, 25.0, 22.5]);
943        let violations = check_rule5(&points, &limits);
944        assert!(violations.is_empty());
945    }
946
947    #[test]
948    fn test_rule7_fourteen_not_enough() {
949        let limits = ControlLimits {
950            ucl: 28.0,
951            cl: 25.0,
952            lcl: 22.0,
953        };
954        let values: Vec<f64> = (0..14).map(|_| 25.5).collect();
955        let points = make_points(&values);
956        let violations = check_rule7(&points, &limits);
957        assert!(violations.is_empty());
958    }
959}