Skip to main content

u_analytics/spc/
rules.rs

1//! Run rules for detecting non-random patterns in control charts.
2//!
3//! Implements the Western Electric (4 rules) and Nelson (8 rules) run tests
4//! for identifying special causes of variation in control chart data. The two
5//! sets share three tests and differ in the fourth: a run on one side of the
6//! centre line is **eight** points in the Western Electric handbook and
7//! **nine** in Nelson's list, so each has its own [`ViolationType`].
8//!
9//! # Choosing a set
10//!
11//! Every added test raises the false alarm rate of an in-control process. The
12//! supplementary tests help find a shift while a process is being brought into
13//! control (Phase I); in routine monitoring (Phase II) the 3-sigma test alone is
14//! the conventional choice (ISO 7870-2:2023, 8.2.7). That is why an unspecified
15//! set is [`RuleSet::shewhart`].
16//!
17//! # References
18//!
19//! - Nelson, L.S. (1984). "The Shewhart Control Chart — Tests for Special Causes",
20//!   *Journal of Quality Technology* 16(4), pp. 237-239.
21//! - Western Electric (1956). *Statistical Quality Control Handbook*.
22//! - NIST/SEMATECH e-Handbook of Statistical Methods, 6.3.2 ("What are the WECO rules").
23//! - ISO 7870-2:2023, *Control charts — Part 2: Shewhart control charts*, 8.2.7 and Annex B.
24//! - Montgomery, D.C. (2019). *Introduction to Statistical Quality Control*, 8th ed.
25
26use super::chart::{ChartPoint, ControlLimits, ViolationType};
27
28/// Trait for applying run rules to chart data.
29///
30/// Run rules detect non-random patterns that indicate special causes of
31/// variation even when individual points remain within control limits.
32pub trait RunRule {
33    /// Check points against this rule set and return violations per point index.
34    ///
35    /// Returns a vector of `(point_index, violation_type)` pairs. A single
36    /// point may appear multiple times if it triggers multiple rules.
37    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)>;
38}
39
40/// Western Electric rules (4 rules) -- [`RuleSet::western_electric`].
41///
42/// The original run tests of the Western Electric *Statistical Quality Control
43/// Handbook* (1956):
44///
45/// 1. Any point beyond 3 sigma (Nelson Rule 1)
46/// 2. 2 of 3 consecutive points beyond 2 sigma, same side (Nelson Rule 5)
47/// 3. 4 of 5 consecutive points beyond 1 sigma, same side (Nelson Rule 6)
48/// 4. **8** consecutive points on the same side of the center line
49///    ([`ViolationType::EightOneSide`] -- Nelson's Rule 2 asks for nine)
50pub struct WesternElectricRules;
51
52/// Nelson rules (8 rules) -- [`RuleSet::nelson`].
53///
54/// The full set of eight tests for special causes as defined by
55/// Nelson (1984). Rules 1, 5 and 6 are also Western Electric rules; Rule 2
56/// (nine points on one side) is a longer run than Western Electric's eight.
57///
58/// # Examples
59///
60/// ```
61/// use u_analytics::spc::{NelsonRules, RunRule, ChartPoint, ControlLimits};
62///
63/// let limits = ControlLimits { ucl: 28.0, cl: 25.0, lcl: 22.0 };
64/// let points: Vec<ChartPoint> = vec![
65///     ChartPoint { value: 25.0, index: 0, violations: vec![] },
66///     ChartPoint { value: 29.0, index: 1, violations: vec![] },
67/// ];
68///
69/// let violations = NelsonRules.check(&points, &limits);
70/// assert!(!violations.is_empty()); // Rule 1: point beyond UCL
71/// ```
72pub struct NelsonRules;
73
74// ---------------------------------------------------------------------------
75// Internal helpers
76// ---------------------------------------------------------------------------
77
78/// Compute 1-sigma and 2-sigma zone boundaries from control limits.
79///
80/// Returns `(one_sigma, two_sigma)` where `one_sigma = (UCL - CL) / 3`.
81fn zone_widths(limits: &ControlLimits) -> (f64, f64) {
82    let sigma = (limits.ucl - limits.cl) / 3.0;
83    (sigma, 2.0 * sigma)
84}
85
86/// Nelson Rule 1: Point beyond control limits.
87///
88/// A single point falls outside the UCL or LCL.
89fn check_rule1(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
90    let mut violations = Vec::new();
91    for point in points {
92        if point.value > limits.ucl || point.value < limits.lcl {
93            violations.push((point.index, ViolationType::BeyondLimits));
94        }
95    }
96    violations
97}
98
99/// `run` consecutive points on the same side of the center line, reported as
100/// `rule` -- Nelson Rule 2 (nine) and the Western Electric run rule (eight).
101///
102/// Indicates a sustained shift in the process mean.
103fn check_one_side(
104    points: &[ChartPoint],
105    limits: &ControlLimits,
106    run: usize,
107    rule: ViolationType,
108) -> Vec<(usize, ViolationType)> {
109    let mut violations = Vec::new();
110    if points.len() < run {
111        return violations;
112    }
113
114    let cl = limits.cl;
115    // Track consecutive runs above/below center line.
116    // +1 = above, -1 = below, 0 = exactly on center (counted as neither side).
117    let sides: Vec<i8> = points
118        .iter()
119        .map(|p| {
120            if p.value > cl {
121                1
122            } else if p.value < cl {
123                -1
124            } else {
125                0
126            }
127        })
128        .collect();
129
130    let mut run_length = 1_usize;
131    for i in 1..sides.len() {
132        if sides[i] != 0 && sides[i] == sides[i - 1] {
133            run_length += 1;
134        } else {
135            run_length = 1;
136        }
137        if run_length >= run {
138            violations.push((points[i].index, rule));
139        }
140    }
141    violations
142}
143
144/// Nelson Rule 3: 6 consecutive points steadily increasing or decreasing.
145///
146/// Indicates a trend in the process.
147fn check_rule3(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
148    let _ = limits; // Not used for trend detection
149    let mut violations = Vec::new();
150    if points.len() < 6 {
151        return violations;
152    }
153
154    // Direction: +1 = increasing, -1 = decreasing, 0 = equal
155    let dirs: Vec<i8> = points
156        .windows(2)
157        .map(|w| {
158            if w[1].value > w[0].value {
159                1
160            } else if w[1].value < w[0].value {
161                -1
162            } else {
163                0
164            }
165        })
166        .collect();
167
168    let mut run_length = 1_usize;
169    for i in 1..dirs.len() {
170        if dirs[i] != 0 && dirs[i] == dirs[i - 1] {
171            run_length += 1;
172        } else {
173            run_length = 1;
174        }
175        // 5 consecutive same-direction changes = 6 points forming a trend.
176        // The violation is reported at the last point of the trend.
177        if run_length >= 5 {
178            // dirs[i] corresponds to the transition between points[i] and points[i+1],
179            // so the last point of the 6-point trend is points[i+1].
180            violations.push((points[i + 1].index, ViolationType::SixTrend));
181        }
182    }
183    violations
184}
185
186/// Nelson Rule 4: 14 consecutive points alternating up and down.
187///
188/// Indicates systematic variation (e.g., two alternating streams).
189fn check_rule4(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
190    let _ = limits;
191    let mut violations = Vec::new();
192    if points.len() < 14 {
193        return violations;
194    }
195
196    // Direction changes: +1 = up, -1 = down, 0 = equal
197    let dirs: Vec<i8> = points
198        .windows(2)
199        .map(|w| {
200            if w[1].value > w[0].value {
201                1
202            } else if w[1].value < w[0].value {
203                -1
204            } else {
205                0
206            }
207        })
208        .collect();
209
210    // Count consecutive alternations
211    let mut alt_length = 1_usize;
212    for i in 1..dirs.len() {
213        if dirs[i] != 0 && dirs[i - 1] != 0 && dirs[i] == -dirs[i - 1] {
214            alt_length += 1;
215        } else {
216            alt_length = 1;
217        }
218        // 13 consecutive alternating directions = 14 points alternating.
219        // dirs[i] corresponds to points[i]→points[i+1],
220        // so the last point is points[i+1].
221        if alt_length >= 13 {
222            violations.push((points[i + 1].index, ViolationType::FourteenAlternating));
223        }
224    }
225    violations
226}
227
228/// Nelson Rule 5: 2 out of 3 consecutive points beyond 2 sigma, same side.
229///
230/// An early warning of a potential shift.
231fn check_rule5(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
232    let mut violations = Vec::new();
233    if points.len() < 3 {
234        return violations;
235    }
236
237    let (_, two_sigma) = zone_widths(limits);
238    let upper_2s = limits.cl + two_sigma;
239    let lower_2s = limits.cl - two_sigma;
240
241    for i in 2..points.len() {
242        let window = &points[i - 2..=i];
243
244        // Check upper side: count points above CL + 2σ
245        let above_count = window.iter().filter(|p| p.value > upper_2s).count();
246        if above_count >= 2 {
247            violations.push((points[i].index, ViolationType::TwoOfThreeBeyond2Sigma));
248            continue;
249        }
250
251        // Check lower side: count points below CL - 2σ
252        let below_count = window.iter().filter(|p| p.value < lower_2s).count();
253        if below_count >= 2 {
254            violations.push((points[i].index, ViolationType::TwoOfThreeBeyond2Sigma));
255        }
256    }
257    violations
258}
259
260/// Nelson Rule 6: 4 out of 5 consecutive points beyond 1 sigma, same side.
261///
262/// Indicates a small sustained shift.
263fn check_rule6(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
264    let mut violations = Vec::new();
265    if points.len() < 5 {
266        return violations;
267    }
268
269    let (one_sigma, _) = zone_widths(limits);
270    let upper_1s = limits.cl + one_sigma;
271    let lower_1s = limits.cl - one_sigma;
272
273    for i in 4..points.len() {
274        let window = &points[i - 4..=i];
275
276        // Check upper side: count points above CL + σ
277        let above_count = window.iter().filter(|p| p.value > upper_1s).count();
278        if above_count >= 4 {
279            violations.push((points[i].index, ViolationType::FourOfFiveBeyond1Sigma));
280            continue;
281        }
282
283        // Check lower side: count points below CL - σ
284        let below_count = window.iter().filter(|p| p.value < lower_1s).count();
285        if below_count >= 4 {
286            violations.push((points[i].index, ViolationType::FourOfFiveBeyond1Sigma));
287        }
288    }
289    violations
290}
291
292/// Nelson Rule 7: 15 consecutive points within 1 sigma of center line.
293///
294/// Indicates stratification — reduced variation suggesting mixed streams.
295fn check_rule7(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
296    let mut violations = Vec::new();
297    if points.len() < 15 {
298        return violations;
299    }
300
301    let (one_sigma, _) = zone_widths(limits);
302    let upper_1s = limits.cl + one_sigma;
303    let lower_1s = limits.cl - one_sigma;
304
305    let mut run_length = 0_usize;
306    for point in points {
307        if point.value >= lower_1s && point.value <= upper_1s {
308            run_length += 1;
309        } else {
310            run_length = 0;
311        }
312        if run_length >= 15 {
313            violations.push((point.index, ViolationType::FifteenWithin1Sigma));
314        }
315    }
316    violations
317}
318
319/// Nelson Rule 8: 8 consecutive points beyond 1 sigma on either side.
320///
321/// Indicates a mixture pattern — points avoid the center zone.
322fn check_rule8(points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
323    let mut violations = Vec::new();
324    if points.len() < 8 {
325        return violations;
326    }
327
328    let (one_sigma, _) = zone_widths(limits);
329    let upper_1s = limits.cl + one_sigma;
330    let lower_1s = limits.cl - one_sigma;
331
332    let mut run_length = 0_usize;
333    for point in points {
334        // Point is beyond 1σ on either side (not within CL ± σ)
335        if point.value > upper_1s || point.value < lower_1s {
336            run_length += 1;
337        } else {
338            run_length = 0;
339        }
340        if run_length >= 8 {
341            violations.push((point.index, ViolationType::EightBeyond1Sigma));
342        }
343    }
344    violations
345}
346
347// ---------------------------------------------------------------------------
348// Selectable rule sets
349// ---------------------------------------------------------------------------
350
351/// A chosen subset of the run tests.
352///
353/// Each test reports exactly one [`ViolationType`], so a rule is named by what
354/// it would report rather than by a parallel enum that would have to be kept in
355/// step with this one.
356///
357/// Three sets have names -- [`shewhart`](RuleSet::shewhart) (the 3-sigma test
358/// alone, the default), [`western_electric`](RuleSet::western_electric) and
359/// [`nelson`](RuleSet::nelson) -- and [`RuleSet::named`] looks them up by the
360/// names a wire request uses; any other combination is built from the tests a
361/// process actually warrants. A rule that a process is known to
362/// trigger for a benign reason -- a deliberately drifting tool, a bimodal
363/// fixture -- otherwise has to be filtered out downstream, after it has already
364/// been counted as an out-of-control signal.
365///
366/// # Examples
367///
368/// ```
369/// use u_analytics::spc::{RuleSet, RunRule, ChartPoint, ControlLimits, ViolationType};
370///
371/// // Only the 3-sigma test, and nothing else.
372/// let rules = RuleSet::from_iter([ViolationType::BeyondLimits]);
373///
374/// let limits = ControlLimits { ucl: 28.0, cl: 25.0, lcl: 22.0 };
375/// let points: Vec<ChartPoint> = (0..12)
376///     .map(|i| ChartPoint { value: 26.0, index: i, violations: vec![] })
377///     .collect();
378///
379/// // Twelve points above the centre line would trip Nelson rule 2, but that
380/// // test is not in this set.
381/// assert!(rules.check(&points, &limits).is_empty());
382/// assert!(RuleSet::nelson().check(&points, &limits).len() > 0);
383/// ```
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub struct RuleSet {
386    mask: u16,
387}
388
389fn bit_of(v: ViolationType) -> u16 {
390    let position = ViolationType::ALL
391        .iter()
392        .position(|&r| r == v)
393        .expect("ViolationType::ALL lists every variant");
394    1 << position
395}
396
397impl RuleSet {
398    /// The names [`RuleSet::named`] takes, in the order a refusal lists them.
399    pub const NAMES: [&'static str; 3] = ["shewhart", "western_electric", "nelson"];
400
401    /// The 3-sigma test alone -- Shewhart's criterion, and the default.
402    ///
403    /// In routine monitoring each added test raises the false alarm rate of an
404    /// in-control process (ISO 7870-2:2023, 8.2.7), so the supplementary tests
405    /// are chosen, not assumed.
406    pub fn shewhart() -> Self {
407        Self::from_iter([ViolationType::BeyondLimits])
408    }
409
410    /// Nelson's eight tests (1984).
411    pub fn nelson() -> Self {
412        Self::from_iter([
413            ViolationType::BeyondLimits,
414            ViolationType::NineOneSide,
415            ViolationType::SixTrend,
416            ViolationType::FourteenAlternating,
417            ViolationType::TwoOfThreeBeyond2Sigma,
418            ViolationType::FourOfFiveBeyond1Sigma,
419            ViolationType::FifteenWithin1Sigma,
420            ViolationType::EightBeyond1Sigma,
421        ])
422    }
423
424    /// The four Western Electric tests (1956): Nelson rules 1, 5 and 6, and
425    /// eight points in a row on one side of the centre line.
426    pub fn western_electric() -> Self {
427        Self::from_iter([
428            ViolationType::BeyondLimits,
429            ViolationType::TwoOfThreeBeyond2Sigma,
430            ViolationType::FourOfFiveBeyond1Sigma,
431            ViolationType::EightOneSide,
432        ])
433    }
434
435    /// The set a name in [`RuleSet::NAMES`] stands for.
436    pub fn named(name: &str) -> Option<Self> {
437        match name {
438            "shewhart" => Some(Self::shewhart()),
439            "western_electric" => Some(Self::western_electric()),
440            "nelson" => Some(Self::nelson()),
441            _ => None,
442        }
443    }
444
445    /// The empty set: no run test is applied.
446    ///
447    /// Useful as a base to add to, and as the honest way to say "limits only".
448    pub fn none() -> Self {
449        Self { mask: 0 }
450    }
451
452    /// Whether this set applies the test that reports `rule`.
453    pub fn contains(&self, rule: ViolationType) -> bool {
454        self.mask & bit_of(rule) != 0
455    }
456
457    /// This set with `rule` added.
458    #[must_use]
459    pub fn with(mut self, rule: ViolationType) -> Self {
460        self.mask |= bit_of(rule);
461        self
462    }
463
464    /// This set with `rule` removed.
465    #[must_use]
466    pub fn without(mut self, rule: ViolationType) -> Self {
467        self.mask &= !bit_of(rule);
468        self
469    }
470
471    /// The tests in this set, in the order of [`ViolationType::ALL`].
472    pub fn iter(&self) -> impl Iterator<Item = ViolationType> + '_ {
473        ViolationType::ALL.into_iter().filter(|&r| self.contains(r))
474    }
475
476    /// How many tests this set applies.
477    pub fn len(&self) -> usize {
478        self.mask.count_ones() as usize
479    }
480
481    /// Whether no test is applied.
482    pub fn is_empty(&self) -> bool {
483        self.mask == 0
484    }
485}
486
487impl Default for RuleSet {
488    /// [`RuleSet::shewhart`] -- the 3-sigma test alone.
489    fn default() -> Self {
490        Self::shewhart()
491    }
492}
493
494impl FromIterator<ViolationType> for RuleSet {
495    fn from_iter<I: IntoIterator<Item = ViolationType>>(iter: I) -> Self {
496        let mut mask = 0u16;
497        for v in iter {
498            mask |= bit_of(v);
499        }
500        Self { mask }
501    }
502}
503
504impl RunRule for RuleSet {
505    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
506        let mut results = Vec::new();
507        for rule in self.iter() {
508            results.extend(match rule {
509                ViolationType::BeyondLimits => check_rule1(points, limits),
510                ViolationType::NineOneSide => {
511                    check_one_side(points, limits, 9, ViolationType::NineOneSide)
512                }
513                ViolationType::EightOneSide => {
514                    check_one_side(points, limits, 8, ViolationType::EightOneSide)
515                }
516                ViolationType::SixTrend => check_rule3(points, limits),
517                ViolationType::FourteenAlternating => check_rule4(points, limits),
518                ViolationType::TwoOfThreeBeyond2Sigma => check_rule5(points, limits),
519                ViolationType::FourOfFiveBeyond1Sigma => check_rule6(points, limits),
520                ViolationType::FifteenWithin1Sigma => check_rule7(points, limits),
521                ViolationType::EightBeyond1Sigma => check_rule8(points, limits),
522            });
523        }
524        results.sort_by_key(|&(idx, _)| idx);
525        results
526    }
527}
528
529// ---------------------------------------------------------------------------
530// RunRule implementations
531// ---------------------------------------------------------------------------
532
533impl RunRule for WesternElectricRules {
534    /// Apply the 4 Western Electric run rules.
535    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
536        RuleSet::western_electric().check(points, limits)
537    }
538}
539
540impl RunRule for NelsonRules {
541    /// Apply all 8 Nelson run rules.
542    fn check(&self, points: &[ChartPoint], limits: &ControlLimits) -> Vec<(usize, ViolationType)> {
543        RuleSet::nelson().check(points, limits)
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550
551    // --- RuleSet ---
552
553    /// Twelve points above the centre line: rule 2 fires, rule 1 does not.
554    /// The distinguishing case for "did the selection actually take effect".
555    fn nine_on_one_side() -> (Vec<ChartPoint>, ControlLimits) {
556        let limits = ControlLimits {
557            ucl: 28.0,
558            cl: 25.0,
559            lcl: 22.0,
560        };
561        let points = (0..12)
562            .map(|i| ChartPoint {
563                value: 26.0,
564                index: i,
565                violations: vec![],
566            })
567            .collect();
568        (points, limits)
569    }
570
571    #[test]
572    fn ruleset_applies_only_the_tests_it_holds() {
573        let (points, limits) = nine_on_one_side();
574
575        // Nelson catches the run; a set without that test does not.
576        assert!(!RuleSet::nelson().check(&points, &limits).is_empty());
577        assert!(RuleSet::nelson()
578            .without(ViolationType::NineOneSide)
579            .check(&points, &limits)
580            .is_empty());
581
582        // And a set holding only that test still catches it -- so the empty
583        // result above is the selection working, not the data failing to trip.
584        assert!(!RuleSet::from_iter([ViolationType::NineOneSide])
585            .check(&points, &limits)
586            .is_empty());
587    }
588
589    #[test]
590    fn ruleset_empty_reports_nothing_even_beyond_limits() {
591        let limits = ControlLimits {
592            ucl: 28.0,
593            cl: 25.0,
594            lcl: 22.0,
595        };
596        let points = vec![ChartPoint {
597            value: 99.0,
598            index: 0,
599            violations: vec![],
600        }];
601        assert!(!RuleSet::nelson().check(&points, &limits).is_empty());
602        assert!(RuleSet::none().check(&points, &limits).is_empty());
603    }
604
605    #[test]
606    fn named_sets_match_their_definitions() {
607        assert_eq!(RuleSet::nelson().len(), 8);
608        assert_eq!(RuleSet::western_electric().len(), 4);
609        for r in [
610            ViolationType::BeyondLimits,
611            ViolationType::TwoOfThreeBeyond2Sigma,
612            ViolationType::FourOfFiveBeyond1Sigma,
613            ViolationType::EightOneSide,
614        ] {
615            assert!(RuleSet::western_electric().contains(r), "{r:?} missing");
616        }
617        for r in [
618            ViolationType::NineOneSide,
619            ViolationType::SixTrend,
620            ViolationType::FourteenAlternating,
621            ViolationType::FifteenWithin1Sigma,
622            ViolationType::EightBeyond1Sigma,
623        ] {
624            assert!(!RuleSet::western_electric().contains(r), "{r:?} present");
625        }
626        assert!(RuleSet::none().is_empty());
627    }
628
629    /// The named unit structs delegate to `RuleSet`, so they must still agree
630    /// with it -- otherwise the delegation quietly changed public behaviour.
631    #[test]
632    fn named_unit_structs_agree_with_their_rule_sets() {
633        let (points, limits) = nine_on_one_side();
634        assert_eq!(
635            NelsonRules.check(&points, &limits),
636            RuleSet::nelson().check(&points, &limits)
637        );
638        assert_eq!(
639            WesternElectricRules.check(&points, &limits),
640            RuleSet::western_electric().check(&points, &limits)
641        );
642    }
643
644    /// Helper: create chart points from a slice of values.
645    fn make_points(values: &[f64]) -> Vec<ChartPoint> {
646        values
647            .iter()
648            .enumerate()
649            .map(|(i, &v)| ChartPoint {
650                value: v,
651                index: i,
652                violations: Vec::new(),
653            })
654            .collect()
655    }
656
657    // --- Rule 1: Beyond limits ---
658
659    #[test]
660    fn test_rule1_point_above_ucl() {
661        let limits = ControlLimits {
662            ucl: 30.0,
663            cl: 25.0,
664            lcl: 20.0,
665        };
666        let points = make_points(&[25.0, 31.0, 25.0]);
667        let violations = check_rule1(&points, &limits);
668        assert_eq!(violations.len(), 1);
669        assert_eq!(violations[0].0, 1);
670        assert_eq!(violations[0].1, ViolationType::BeyondLimits);
671    }
672
673    #[test]
674    fn test_rule1_point_below_lcl() {
675        let limits = ControlLimits {
676            ucl: 30.0,
677            cl: 25.0,
678            lcl: 20.0,
679        };
680        let points = make_points(&[25.0, 19.0, 25.0]);
681        let violations = check_rule1(&points, &limits);
682        assert_eq!(violations.len(), 1);
683        assert_eq!(violations[0].0, 1);
684    }
685
686    #[test]
687    fn test_rule1_on_limit_is_not_violation() {
688        let limits = ControlLimits {
689            ucl: 30.0,
690            cl: 25.0,
691            lcl: 20.0,
692        };
693        let points = make_points(&[30.0, 20.0]);
694        let violations = check_rule1(&points, &limits);
695        assert!(violations.is_empty());
696    }
697
698    // --- Rule 2: 9 on same side ---
699
700    #[test]
701    fn test_rule2_nine_above() {
702        let limits = ControlLimits {
703            ucl: 30.0,
704            cl: 25.0,
705            lcl: 20.0,
706        };
707        // 9 points above center line
708        let values: Vec<f64> = (0..9).map(|_| 26.0).collect();
709        let points = make_points(&values);
710        let violations = check_one_side(&points, &limits, 9, ViolationType::NineOneSide);
711        assert_eq!(violations.len(), 1);
712        assert_eq!(violations[0].1, ViolationType::NineOneSide);
713    }
714
715    #[test]
716    fn test_rule2_eight_not_enough() {
717        let limits = ControlLimits {
718            ucl: 30.0,
719            cl: 25.0,
720            lcl: 20.0,
721        };
722        let values: Vec<f64> = (0..8).map(|_| 26.0).collect();
723        let points = make_points(&values);
724        let violations = check_one_side(&points, &limits, 9, ViolationType::NineOneSide);
725        assert!(violations.is_empty());
726    }
727
728    #[test]
729    fn test_rule2_nine_below() {
730        let limits = ControlLimits {
731            ucl: 30.0,
732            cl: 25.0,
733            lcl: 20.0,
734        };
735        let values: Vec<f64> = (0..9).map(|_| 24.0).collect();
736        let points = make_points(&values);
737        let violations = check_one_side(&points, &limits, 9, ViolationType::NineOneSide);
738        assert_eq!(violations.len(), 1);
739    }
740
741    // --- Western Electric run rule: 8 on one side ---
742
743    /// Eight points above the centre line: the Western Electric set signals at
744    /// the eighth point, Nelson's (nine) does not -- the one test where the two
745    /// sets disagree.
746    #[test]
747    fn western_electric_run_is_eight_points_nelson_is_nine() {
748        let limits = ControlLimits {
749            ucl: 30.0,
750            cl: 25.0,
751            lcl: 20.0,
752        };
753        let points = make_points(&[26.0; 8]);
754
755        let we = RuleSet::western_electric().check(&points, &limits);
756        assert_eq!(we, vec![(7, ViolationType::EightOneSide)]);
757        assert!(RuleSet::nelson().check(&points, &limits).is_empty());
758        assert_eq!(WesternElectricRules.check(&points, &limits), we);
759
760        // Seven is not a run for either.
761        let seven = make_points(&[26.0; 7]);
762        assert!(RuleSet::western_electric()
763            .check(&seven, &limits)
764            .is_empty());
765    }
766
767    /// The default set is the 3-sigma test alone: a run that both named sets
768    /// flag is not a signal until a set asks for it.
769    #[test]
770    fn default_set_is_the_three_sigma_test() {
771        let limits = ControlLimits {
772            ucl: 30.0,
773            cl: 25.0,
774            lcl: 20.0,
775        };
776        let mut values = vec![26.0; 12];
777        values.push(31.0);
778        let points = make_points(&values);
779
780        assert_eq!(RuleSet::default(), RuleSet::shewhart());
781        assert_eq!(
782            RuleSet::default().check(&points, &limits),
783            vec![(12, ViolationType::BeyondLimits)]
784        );
785        assert!(RuleSet::nelson().check(&points, &limits).len() > 1);
786    }
787
788    #[test]
789    fn named_sets_round_trip_and_names_round_trip() {
790        for name in RuleSet::NAMES {
791            assert!(RuleSet::named(name).is_some(), "{name}");
792        }
793        assert_eq!(RuleSet::named("weco"), None);
794        for v in ViolationType::ALL {
795            assert_eq!(ViolationType::from_name(v.name()), Some(v));
796        }
797        let all: RuleSet = ViolationType::ALL.into_iter().collect();
798        assert_eq!(all.len(), ViolationType::ALL.len());
799    }
800
801    // --- Rule 3: 6 trending ---
802
803    #[test]
804    fn test_rule3_six_increasing() {
805        let limits = ControlLimits {
806            ucl: 30.0,
807            cl: 25.0,
808            lcl: 20.0,
809        };
810        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0, 25.0]);
811        let violations = check_rule3(&points, &limits);
812        assert_eq!(violations.len(), 1);
813        assert_eq!(violations[0].1, ViolationType::SixTrend);
814    }
815
816    #[test]
817    fn test_rule3_six_decreasing() {
818        let limits = ControlLimits {
819            ucl: 30.0,
820            cl: 25.0,
821            lcl: 20.0,
822        };
823        let points = make_points(&[30.0, 29.0, 28.0, 27.0, 26.0, 25.0]);
824        let violations = check_rule3(&points, &limits);
825        assert_eq!(violations.len(), 1);
826    }
827
828    #[test]
829    fn test_rule3_five_not_enough() {
830        let limits = ControlLimits {
831            ucl: 30.0,
832            cl: 25.0,
833            lcl: 20.0,
834        };
835        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0]);
836        let violations = check_rule3(&points, &limits);
837        assert!(violations.is_empty());
838    }
839
840    // --- Rule 4: 14 alternating ---
841
842    #[test]
843    fn test_rule4_fourteen_alternating() {
844        let limits = ControlLimits {
845            ucl: 30.0,
846            cl: 25.0,
847            lcl: 20.0,
848        };
849        // Create alternating pattern: 24, 26, 24, 26, ...
850        let values: Vec<f64> = (0..14)
851            .map(|i| if i % 2 == 0 { 24.0 } else { 26.0 })
852            .collect();
853        let points = make_points(&values);
854        let violations = check_rule4(&points, &limits);
855        assert_eq!(violations.len(), 1);
856        assert_eq!(violations[0].1, ViolationType::FourteenAlternating);
857    }
858
859    #[test]
860    fn test_rule4_thirteen_not_enough() {
861        let limits = ControlLimits {
862            ucl: 30.0,
863            cl: 25.0,
864            lcl: 20.0,
865        };
866        let values: Vec<f64> = (0..13)
867            .map(|i| if i % 2 == 0 { 24.0 } else { 26.0 })
868            .collect();
869        let points = make_points(&values);
870        let violations = check_rule4(&points, &limits);
871        assert!(violations.is_empty());
872    }
873
874    // --- Rule 5: 2 of 3 beyond 2σ ---
875
876    #[test]
877    fn test_rule5_two_of_three_above() {
878        let limits = ControlLimits {
879            ucl: 28.0, // σ = (28-25)/3 ≈ 1.0, 2σ = 2.0
880            cl: 25.0,
881            lcl: 22.0,
882        };
883        // 2σ line upper = 25 + 2 = 27, lower = 25 - 2 = 23
884        // Two of three points above 27
885        let points = make_points(&[27.5, 25.0, 27.5]);
886        let violations = check_rule5(&points, &limits);
887        assert_eq!(violations.len(), 1);
888        assert_eq!(violations[0].1, ViolationType::TwoOfThreeBeyond2Sigma);
889    }
890
891    #[test]
892    fn test_rule5_two_of_three_below() {
893        let limits = ControlLimits {
894            ucl: 28.0,
895            cl: 25.0,
896            lcl: 22.0,
897        };
898        // Two of three below CL - 2σ = 23
899        let points = make_points(&[22.5, 25.0, 22.5]);
900        let violations = check_rule5(&points, &limits);
901        assert_eq!(violations.len(), 1);
902    }
903
904    // --- Rule 6: 4 of 5 beyond 1σ ---
905
906    #[test]
907    fn test_rule6_four_of_five_above() {
908        let limits = ControlLimits {
909            ucl: 28.0, // σ = 1.0
910            cl: 25.0,
911            lcl: 22.0,
912        };
913        // 1σ upper = 26. Four of five points above 26.
914        let points = make_points(&[26.5, 26.5, 25.0, 26.5, 26.5]);
915        let violations = check_rule6(&points, &limits);
916        assert_eq!(violations.len(), 1);
917        assert_eq!(violations[0].1, ViolationType::FourOfFiveBeyond1Sigma);
918    }
919
920    // --- Rule 7: 15 within 1σ ---
921
922    #[test]
923    fn test_rule7_fifteen_within() {
924        let limits = ControlLimits {
925            ucl: 28.0, // σ = 1.0
926            cl: 25.0,
927            lcl: 22.0,
928        };
929        // 15 points within CL ± σ = [24, 26]
930        let values: Vec<f64> = (0..15).map(|i| 24.5 + (i as f64 % 3.0) * 0.25).collect();
931        let points = make_points(&values);
932        let violations = check_rule7(&points, &limits);
933        assert_eq!(violations.len(), 1);
934        assert_eq!(violations[0].1, ViolationType::FifteenWithin1Sigma);
935    }
936
937    // --- Rule 8: 8 beyond 1σ on either side ---
938
939    #[test]
940    fn test_rule8_eight_beyond() {
941        let limits = ControlLimits {
942            ucl: 28.0, // σ = 1.0
943            cl: 25.0,
944            lcl: 22.0,
945        };
946        // 8 points beyond CL ± σ (alternating sides is fine)
947        let points = make_points(&[27.0, 23.0, 27.0, 23.0, 27.0, 23.0, 27.0, 23.0]);
948        let violations = check_rule8(&points, &limits);
949        assert_eq!(violations.len(), 1);
950        assert_eq!(violations[0].1, ViolationType::EightBeyond1Sigma);
951    }
952
953    #[test]
954    fn test_rule8_seven_not_enough() {
955        let limits = ControlLimits {
956            ucl: 28.0,
957            cl: 25.0,
958            lcl: 22.0,
959        };
960        let points = make_points(&[27.0, 23.0, 27.0, 23.0, 27.0, 23.0, 27.0]);
961        let violations = check_rule8(&points, &limits);
962        assert!(violations.is_empty());
963    }
964
965    // --- Western Electric Rules ---
966
967    #[test]
968    fn test_western_electric_combines_four_rules() {
969        let limits = ControlLimits {
970            ucl: 30.0,
971            cl: 25.0,
972            lcl: 20.0,
973        };
974        // A point beyond limits triggers WE rules
975        let points = make_points(&[25.0, 31.0, 25.0]);
976        let we = WesternElectricRules;
977        let violations = we.check(&points, &limits);
978        assert!(!violations.is_empty());
979        assert!(violations
980            .iter()
981            .any(|(_, v)| *v == ViolationType::BeyondLimits));
982    }
983
984    // --- Nelson Rules ---
985
986    #[test]
987    fn test_nelson_detects_trend() {
988        let limits = ControlLimits {
989            ucl: 30.0,
990            cl: 25.0,
991            lcl: 20.0,
992        };
993        let points = make_points(&[20.0, 21.0, 22.0, 23.0, 24.0, 25.0]);
994        let nelson = NelsonRules;
995        let violations = nelson.check(&points, &limits);
996        assert!(violations
997            .iter()
998            .any(|(_, v)| *v == ViolationType::SixTrend));
999    }
1000
1001    #[test]
1002    fn test_nelson_no_violations_in_random_data() {
1003        let limits = ControlLimits {
1004            ucl: 28.0,
1005            cl: 25.0,
1006            lcl: 22.0,
1007        };
1008        // A short random-looking sequence should not trigger
1009        let points = make_points(&[25.5, 24.8, 25.2, 24.9, 25.1]);
1010        let nelson = NelsonRules;
1011        let violations = nelson.check(&points, &limits);
1012        assert!(violations.is_empty());
1013    }
1014
1015    #[test]
1016    fn test_nelson_rule2_continuation() {
1017        // If 10 points on same side, rules fire at point 8 (index) and 9
1018        let limits = ControlLimits {
1019            ucl: 30.0,
1020            cl: 25.0,
1021            lcl: 20.0,
1022        };
1023        let values: Vec<f64> = (0..10).map(|_| 26.0).collect();
1024        let points = make_points(&values);
1025        let violations = check_one_side(&points, &limits, 9, ViolationType::NineOneSide);
1026        // Should fire at index 8 and 9
1027        assert_eq!(violations.len(), 2);
1028        assert_eq!(violations[0].0, 8);
1029        assert_eq!(violations[1].0, 9);
1030    }
1031
1032    #[test]
1033    fn test_rule5_not_triggered_mixed_sides() {
1034        let limits = ControlLimits {
1035            ucl: 28.0,
1036            cl: 25.0,
1037            lcl: 22.0,
1038        };
1039        // One point above 2σ upper (>27), one below 2σ lower (<23) — different sides
1040        // Rule 5 requires 2 of 3 on the SAME side, so this should not trigger
1041        let points = make_points(&[27.5, 25.0, 22.5]);
1042        let violations = check_rule5(&points, &limits);
1043        assert!(violations.is_empty());
1044    }
1045
1046    #[test]
1047    fn test_rule7_fourteen_not_enough() {
1048        let limits = ControlLimits {
1049            ucl: 28.0,
1050            cl: 25.0,
1051            lcl: 22.0,
1052        };
1053        let values: Vec<f64> = (0..14).map(|_| 25.5).collect();
1054        let points = make_points(&values);
1055        let violations = check_rule7(&points, &limits);
1056        assert!(violations.is_empty());
1057    }
1058}