wasm4pm 26.7.1

High-performance process mining algorithms in WebAssembly for JavaScript/TypeScript
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
//! Category H -- Mutation Adequacy Validation Tests
//!
//! Validates that the existing test suite is ADEQUATE by simulating specific
//! known bugs (mutations) and proving they would be caught. Each test:
//! 1. Documents the specific mutation (what code change represents the bug)
//! 2. Exercises the function with inputs that expose the mutation
//! 3. Asserts the function produces the CORRECT value (proving the mutation
//!    would cause a detectable failure if introduced)
//!
//! Since we cannot actually modify source code in a test, we prove adequacy
//! by demonstrating that the correct behavior is observably different from
//! what the mutation would produce. If the mutation existed, the assertion
//! would fail -- therefore the test suite WOULD catch it.
//!
//! Mutations validated:
//! 1. Same-state Bellman (update uses state instead of next_state)
//! 2. SPC one-shot vs historical (SPC only checks current snapshot)
//! 3. String::len() instead of event count (feature extraction uses string length)
//! 4. Zero step counter in circuit breaker (step counter never advances)

use wasm4pm::reinforcement::QLearning;
use wasm4pm::self_healing::{
    advance_clock, reset_clock, CircuitBreaker, CircuitBreakerConfig, CircuitState, CLOCK_LOCK,
};
use wasm4pm::spc::{check_western_electric_rules, ChartData, SpecialCause, TrendDirection};
use wasm4pm::RlAction;
use wasm4pm::RlState;

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/// Create an RlState with a specific health_level and all other fields zeroed.
fn health_state(health_level: u8) -> RlState {
    wasm4pm::create_rl_state(health_level, 0, 0, 0, 0, 0, 0, 0)
}

/// Create a QLearning agent with exploration_rate=0.0 (purely greedy).
fn greedy_q_agent() -> QLearning<RlState, RlAction> {
    QLearning::with_hyperparams(0.1, 0.99, 0.0)
}

/// Build a ChartData point with a given value, using standard control limits.
fn spc_point(timestamp: &str, value: f64, cl: f64, sigma: f64) -> ChartData {
    ChartData {
        timestamp: timestamp.to_string(),
        value,
        ucl: cl + 3.0 * sigma,
        cl,
        lcl: f64::max(cl - 3.0 * sigma, 0.0),
        subgroup_data: None,
    }
}

// ===========================================================================
// Mutation 1: Same-state Bellman (the original bug)
// ===========================================================================
//
// The bug: `update(state, action, reward, state, done)` instead of
//           `update(state, action, reward, next_state, done)`
//
// If this mutation existed, the Bellman update would look up Q-values for
// the CURRENT state instead of the NEXT state. Since Q(state, .) = 0.0
// for an unvisited state, the bootstrap term would be zero, and the update
// would produce a delta of exactly alpha * (reward - 0.0), ignoring the
// value of the destination state entirely.
//
// Detection: Pre-populate next_state with high Q-values via repeated
// positive updates. Then update the current state with reward=0.0 and
// the valuable next_state. If the mutation existed, Q(state, action)
// would remain at 0.0 (target = 0.0 + gamma * max Q(state, .) = 0.0).
// With correct code, Q(state, action) becomes positive because it
// bootstraps from next_state's high Q-values.

#[test]
fn test_mutation_1_same_state_bellman_detected() {
    // This test validates that our Bellman tests would catch the same-state bug.
    //
    // Mutation: if update() used state instead of next_state, then:
    // - Q(state, action) after update with (state, action, r, state, false)
    //   would equal Q(state, action) before update (target = current Q, delta = 0)
    // - Q(state, action) after update with (state, action, r, next_state, false)
    //   where next_state has high Q-values, would be > Q(state, action) before
    //
    // Detection strategy:
    // 1. Create QLearning agent with exploration_rate=0.0
    // 2. Pre-populate next_state with high Q-values via repeated updates
    // 3. Record Q(state, action) -- should be 0.0 (unvisited)
    // 4. Update with (state, action, r=0.0, next_state, done=false)
    // 5. Assert: Q(state, action) increased (proves next_state was used)
    //
    // If mutation existed: Q would not change, and this assertion would FAIL.
    // Therefore: our test suite WOULD catch this mutation.

    let agent = greedy_q_agent();
    let state = health_state(2); // Degraded (unvisited)
    let next_state = health_state(0); // Normal (will be seeded with high Q)
    let action = RlAction::Continue;

    // Step 2: Pre-populate next_state with high Q-values via repeated updates
    // Each update with r=1.0, gamma=0.99, alpha=0.1 accumulates value.
    // After 10 updates: Q(next_state, Scale) converges toward r/(1-gamma) = 100.0
    for _ in 0..10 {
        agent.update(&next_state, &RlAction::Scale, 1.0, &next_state, false);
    }

    // Verify next_state now has high Q-values (sanity check)
    let q_next = agent.get_q_value(&next_state, &RlAction::Scale);
    assert!(
        q_next > 0.5,
        "Sanity: Q(next_state, Scale) should be high after 10 positive updates, got {}",
        q_next
    );

    // Step 3: Record Q(state, action) -- should be 0.0 since state is unvisited
    let q_before = agent.get_q_value(&state, &action);
    assert_eq!(
        q_before, 0.0,
        "Sanity: Q(state, action) should be 0.0 for unvisited state"
    );

    // Step 4: Update with r=0.0, next_state has high Q-values, non-terminal
    // CORRECT: target = 0.0 + 0.99 * max Q(next_state, .) > 0
    //   -> Q(state, action) INCREASES
    // MUTATION (same-state): target = 0.0 + 0.99 * max Q(state, .) = 0.0
    //   -> Q(state, action) stays at 0.0 (delta = 0)
    agent.update(&state, &action, 0.0, &next_state, false);

    // Step 5: Assert Q increased -- proves next_state was used
    let q_after = agent.get_q_value(&state, &action);
    assert!(
        q_after > q_before,
        "MUTATION 1 DETECTION: Q(state, action) must increase after zero-reward update \
         with valuable next_state. Before={:.6}, After={:.6}. \
         If Q did not change, the Bellman update is using state instead of next_state \
         (the same-state bug). The existing test_bellman_uses_next_state_not_current_state \
         and test_different_next_states_produce_different_q_updates in \
         bellman_correctness_tests.rs would catch this.",
        q_before,
        q_after
    );
}

// ===========================================================================
// Mutation 2: SPC one-shot vs historical
// ===========================================================================
//
// The bug: SPC only checks the current snapshot against control limits
// (one-shot evaluation), ignoring historical trend data.
//
// If this mutation existed, gradual drift where every individual point
// stays within control limits would NEVER be detected, because the
// one-shot check only tests the latest point against UCL/LCL.
//
// Detection: Create data where all points are within UCL/LCL (one-shot
// passes) but 6 consecutive points are strictly increasing (trend rule
// fires). If the mutation existed, no Trend alert would be emitted
// because only Rule 1 (out-of-control) would be evaluated.
//
// This test proves that `check_western_electric_rules` evaluates
// Rule 3 (trend) in addition to Rule 1 (out-of-control).

#[test]
fn test_mutation_2_spc_historical_vs_oneshot_detected() {
    // Validates that our drift tests distinguish historical from one-shot SPC.
    //
    // Mutation: if SPC only checked current value vs limits (one-shot),
    // then gradual drift within limits would never be detected.
    //
    // Detection strategy:
    // 1. Create data points with gradual increase (all within UCL)
    // 2. Each individual point is within control limits (one-shot would pass)
    // 3. But the TREND rule (6 consecutive increasing) should fire
    // 4. Assert: check_western_electric_rules returns a Trend alert
    //
    // If mutation existed: no Trend alert, assertion would FAIL.
    // Therefore: our test suite WOULD catch this mutation.

    let cl = 5.0;
    let sigma = 0.5;
    let _ucl = cl + 3.0 * sigma; // 6.5 (not directly used, but documents the control limit)

    // Build 20 points with a gradual upward trend of +0.05 per step.
    // Values: 5.0, 5.05, 5.10, ..., 5.95 -- all well below UCL=6.5.
    // One-shot check: every value is within [3.5, 6.5] -- all pass.
    // Trend check: 6 consecutive increasing values exist (e.g., t3..t8).
    let data: Vec<ChartData> = (0..20)
        .map(|i| {
            let value = 5.0 + (i as f64) * 0.05; // 5.0 to 5.95
            spc_point(&format!("t{}", i), value, cl, sigma)
        })
        .collect();

    // Verify all points are within control limits (one-shot would pass all)
    for point in &data {
        assert!(
            point.value >= point.lcl && point.value <= point.ucl,
            "Sanity: all values must be within control limits for one-shot to pass. \
             Got value={} (lcl={}, ucl={})",
            point.value,
            point.lcl,
            point.ucl
        );
    }

    // Verify Rule 1 (out-of-control) does NOT fire (all within limits)
    let alerts = check_western_electric_rules(&data);
    assert!(
        !alerts
            .iter()
            .any(|a| matches!(a, SpecialCause::OutOfControl { .. })),
        "Sanity: Rule 1 must NOT fire when all values are within control limits"
    );

    // THE KEY ASSERTION: Rule 3 (Trend) MUST fire despite all values being in-control.
    // This proves SPC evaluates historical patterns, not just one-shot thresholds.
    let trend_alert = alerts
        .iter()
        .find(|a| matches!(a, SpecialCause::Trend { .. }));
    assert!(
        trend_alert.is_some(),
        "MUTATION 2 DETECTION: Trend rule must fire on gradual drift within control limits. \
         All 20 values are within [3.5, 6.5] but 6+ are consecutively increasing. \
         If no Trend alert fires, SPC is doing one-shot evaluation only (the bug). \
         The existing test_gradual_drift_detected_by_trend_rule in \
         behavioral_drift_tests.rs would catch this. Got alerts: {:?}",
        alerts
    );

    // Verify trend direction is Increasing
    if let Some(SpecialCause::Trend { direction, .. }) = trend_alert {
        assert_eq!(
            *direction,
            TrendDirection::Increasing,
            "Trend direction must be Increasing for upward drift"
        );
    }
}

// ===========================================================================
// Mutation 3: String::len() instead of event count
// ===========================================================================
//
// The bug: event_rate_q was computed from the trace name's string length
// instead of the actual event count from the XES log.
//
// If this mutation existed, two traces with the same name length but
// different event counts would produce the same event_rate_q, which is
// incorrect. The quantization should depend on the normalized event rate
// (features[0] = event_count / 10000), not on any string property.
//
// Detection: Create two RlState instances via from_features with
// different features[0] values (representing different event rates).
// If the mutation existed, changing features[0] would have no effect
// on event_rate_q because the code would use string length instead.
//
// We prove this by showing that different features[0] values produce
// different event_rate_q values (monotonicity), which proves the
// quantization depends on the feature value, not a fixed string length.

#[test]
fn test_mutation_3_event_count_not_string_length_detected() {
    // Validates that feature extraction uses actual event counts, not string lengths.
    //
    // Mutation: if event_rate_q was computed from string length instead of
    // event count, then changing features[0] would not affect event_rate_q.
    //
    // Detection strategy:
    // 1. Create state_a with features[0] = 0.05 (500 events, low rate)
    // 2. Create state_b with features[0] = 0.80 (8000 events, high rate)
    // 3. All other features identical
    // 4. Assert: state_a.event_rate_q != state_b.event_rate_q
    //
    // If mutation existed: both would have the same event_rate_q (computed
    // from a fixed string length), and the assertion would FAIL.
    // Therefore: our test suite WOULD catch this mutation.

    // features[0] = event_count / 10000
    // 0.05 -> 500 events -> quantize_event_rate: 0..=500 => bucket 0
    // 0.80 -> 8000 events -> quantize_event_rate: 5001..=7500 => bucket 6
    let features_low = [0.05f32, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
    let features_high = [0.80f32, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];

    let state_low = RlState::from_features(&features_low, 0, 0.0);
    let state_high = RlState::from_features(&features_high, 0, 0.0);

    // Assert that event_rate_q differs between the two states
    assert_ne!(
        state_low.event_rate_q, state_high.event_rate_q,
        "MUTATION 3 DETECTION: event_rate_q must differ when features[0] differs. \
         features[0]=0.05 -> event_rate_q={}, features[0]=0.80 -> event_rate_q={}. \
         If both are the same, the quantization is not using the feature value \
         (possibly using string length instead). The existing \
         test_quantization_event_rate_is_monotonic in \
         feature_normalization_tests.rs would catch this.",
        state_low.event_rate_q, state_high.event_rate_q
    );

    // Assert the ordering is correct: higher event rate -> higher quantized value
    assert!(
        state_high.event_rate_q > state_low.event_rate_q,
        "MUTATION 3 DETECTION: Higher event rate should produce higher event_rate_q. \
         features[0]=0.80 -> {}, features[0]=0.05 -> {}",
        state_high.event_rate_q,
        state_low.event_rate_q
    );

    // Verify the exact quantization buckets match the documented ranges
    // features[0]=0.05 -> (0.05 * 10000) = 500 -> bucket 0 (0..=500)
    assert_eq!(
        state_low.event_rate_q, 0,
        "500 events (features[0]=0.05) should quantize to bucket 0 (0..=500)"
    );
    // features[0]=0.80 -> (0.80 * 10000) = 8000 -> bucket 7 (7501+)
    assert_eq!(
        state_high.event_rate_q, 7,
        "8000 events (features[0]=0.80) should quantize to bucket 7 (7501+)"
    );

    // Additional proof: intermediate values land in intermediate buckets
    // features[0]=0.15 -> 1500 events -> bucket 2 (1001..=2000)
    let features_mid = [0.15f32, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5, 0.5];
    let state_mid = RlState::from_features(&features_mid, 0, 0.0);
    assert_eq!(
        state_mid.event_rate_q, 2,
        "1500 events (features[0]=0.15) should quantize to bucket 2 (1001..=2000)"
    );
    assert!(
        state_low.event_rate_q < state_mid.event_rate_q
            && state_mid.event_rate_q < state_high.event_rate_q,
        "MUTATION 3 DETECTION: Monotonicity must hold: low({}) < mid({}) < high({})",
        state_low.event_rate_q,
        state_mid.event_rate_q,
        state_high.event_rate_q
    );
}

// ===========================================================================
// Mutation 4: Zero step counter in circuit breaker
// ===========================================================================
//
// The bug: The step counter (now_ms()) always returns 0, so the timeout
// calculation `current_ms - last_state_change_ms` always equals 0.
// The Open state would never transition to HalfOpen regardless of
// configured timeout, because the elapsed time is always 0 < timeout.
//
// Detection: Create a circuit breaker, drive it to Open state, advance
// the clock past the configured timeout, then call allow_request() and
// verify the state transitions to HalfOpen.
//
// If the mutation existed (step counter always 0), then:
// - last_state_change_ms = 0 (set at transition to Open)
// - current_ms = 0 (mutation: step counter never advances)
// - elapsed = 0 - 0 = 0 < open_timeout_ms
// - allow_request() returns false, state stays Open
//
// With correct code (advance_clock works):
// - last_state_change_ms = 0
// - current_ms = open_timeout_ms (after advance_clock)
// - elapsed = open_timeout_ms >= open_timeout_ms
// - allow_request() returns true, state transitions to HalfOpen

#[test]
fn test_mutation_4_circuit_breaker_step_counter_detected() {
    // Validates that circuit breaker timeout actually depends on step counter.
    //
    // Mutation: if step counter was always 0, then:
    // - Open state would never transition to HalfOpen regardless of time elapsed
    //
    // Detection strategy:
    // 1. Reset clock to known state
    // 2. Create circuit breaker with known timeout (1ms for fast test)
    // 3. Drive it to Open via failures
    // 4. Advance clock past timeout
    // 5. Call allow_request() or check state()
    // 6. Assert: state == HalfOpen (timeout was honored)
    //
    // If mutation existed: state would still be Open, assertion would FAIL.
    // Therefore: our test suite WOULD catch this mutation.

    // Step 1: Reset clock — guard against parallel sibling tests racing
    // for the global `TIME_OFFSET_MS` atomic.
    let _clock_guard = CLOCK_LOCK
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    reset_clock();

    // Step 2: Create breaker with very short timeout (1ms) for deterministic test
    let open_timeout_ms = 1u64;
    let config = CircuitBreakerConfig {
        failure_threshold: 2,
        success_threshold: 2,
        open_timeout_ms,
        half_open_timeout_ms: 30_000,
    };
    let mut breaker = CircuitBreaker::with_config(config).unwrap();

    // Step 3: Drive to Open
    breaker.record_failure();
    assert_eq!(
        breaker.state(),
        CircuitState::Closed,
        "Sanity: should be Closed after 1 failure"
    );
    breaker.record_failure();
    assert_eq!(
        breaker.state(),
        CircuitState::Open,
        "Sanity: should be Open after 2 failures (threshold=2)"
    );

    // Verify allow_request returns false immediately (timeout not elapsed)
    assert!(
        !breaker.allow_request(),
        "Sanity: allow_request should return false immediately after Open"
    );
    // allow_request may have triggered a state check -- verify still Open
    assert_eq!(
        breaker.state(),
        CircuitState::Open,
        "Sanity: state should still be Open when timeout not elapsed"
    );

    // Step 4: Advance clock past timeout
    advance_clock(open_timeout_ms + 1);

    // Step 5-6: Call allow_request and assert transition to HalfOpen
    // This is the critical assertion:
    // If the step counter mutation existed (now_ms() always returns 0),
    // then elapsed = 0 - last_state_change_ms would be negative/zero,
    // and allow_request would return false with state still Open.
    assert!(
        breaker.allow_request(),
        "MUTATION 4 DETECTION: allow_request must return true after timeout elapsed. \
         If this returns false, the step counter is not advancing (always 0), \
         and the circuit breaker can never recover from Open state. \
         The existing test_open_to_half_open_after_timeout and \
         test_recovery_timeout_is_configurable in \
         circuit_breaker_state_machine_tests.rs would catch this."
    );
    assert_eq!(
        breaker.state(),
        CircuitState::HalfOpen,
        "MUTATION 4 DETECTION: State must be HalfOpen after timeout. \
         If still Open, the step counter is not advancing. Got: {:?}",
        breaker.state()
    );
}

// ===========================================================================
// Mutation H1: +→- operator in compute_reward health improvement bonus
// ===========================================================================
//
// The mutation: `reward += 1.0` (health improvement) becomes `reward -= 1.0`.
// With the mutation, a health improvement would DECREASE reward instead of
// increasing it, making the agent learn to avoid health recovery.
//
// Detection: Verify that health improvement (prev > curr) gives a POSITIVE
// contribution to reward, not negative. The correct behavior is reward_improvement
// > reward_stable > reward_degradation.

use wasm4pm::rl_orchestrator::compute_reward;

#[test]
fn test_h1_plus_to_minus_operator_mutation_detected() {
    // If the +→- mutation existed for the health improvement bonus:
    //   compute_reward(3, 1, ...) would be LESS than compute_reward(1, 1, ...)
    //   because improvement would subtract 1.0 instead of adding 1.0.
    //
    // CORRECT behavior: improvement adds +1.0.
    //   reward_improve = 1.0 (health) + 0.0 (spc) + 0.1 (guard+circ) = 1.1
    //   reward_stable  = 0.2 (health) + 0.0 (spc) + 0.1 (guard+circ) = 0.3
    // MUTATION: improvement subtracts 1.0.
    //   reward_improve_mutated = -1.0 + 0.1 = -0.9 (WRONG)
    //
    // If mutation existed: reward_improve < reward_stable, assertion would FAIL.

    let reward_improve = compute_reward(3, 1, 0, true, true, false, 0); // health 3→1 (improvement)
    let reward_stable = compute_reward(2, 2, 0, true, true, false, 0); // health 2→2 (stable)

    assert!(
        reward_improve > reward_stable,
        "H1 MUTATION DETECTION: Health improvement reward ({:.4}) must be > stability reward ({:.4}). \
         If improvement reward is negative, the + operator was mutated to -. \
         Expected: reward_improve > reward_stable (improvement bonus +1.0 > stability +0.2).",
        reward_improve,
        reward_stable
    );

    // Exact value: reward_improve = +1.0 + 0.1 (guard+circuit) = +1.1
    assert!(
        (reward_improve - 1.1).abs() < 1e-5,
        "H1 MUTATION DETECTION: Health improvement reward should be exactly +1.1 \
         (+1.0 improvement + 0.1 guard/circuit bonus). Got {:.6}",
        reward_improve
    );
}

// ===========================================================================
// Mutation H2: >→>= boundary in SPC Rule 2 (9 consecutive points same side)
// ===========================================================================
//
// The mutation: `recent.len() >= 9` in Rule 2 check becomes `recent.len() >= 8`
// (or the above_cl check uses `>` instead of checking all 9 points).
//
// The SPC implementation checks Rule 2 using `recent = &data[data.len()-9..]`
// followed by `recent.len() >= 9`. With the mutation (len >= 8), Rule 2 would
// fire with only 8 points instead of requiring the full 9.
//
// Detection: Build exactly 9 points all above CL. Rule 2 MUST fire at 9.
// Then build exactly 8 points all above CL. Rule 2 must NOT fire at 8.

#[test]
fn test_h2_spc_rule2_fires_at_9_not_8_detected() {
    use wasm4pm::spc::{check_western_electric_rules, ChartData, SpecialCause};

    // Helper to create a chart data point above CL (within UCL)
    let make_above = |t: &str| -> ChartData {
        ChartData {
            timestamp: t.to_string(),
            value: 5.5, // above CL=5.0
            ucl: 8.0,
            cl: 5.0,
            lcl: 2.0,
            subgroup_data: None,
        }
    };

    // 8 points all above CL — Rule 2 should NOT fire (requires 9)
    let data_8: Vec<ChartData> = (0..8).map(|i| make_above(&format!("t{}", i))).collect();
    let alerts_8 = check_western_electric_rules(&data_8);

    // Note: check_western_electric_rules returns early if data.len() < 9,
    // so with 8 points, no Rule 2 alert should fire.
    let has_shift_8 = alerts_8
        .iter()
        .any(|a| matches!(a, SpecialCause::Shift { .. }));
    assert!(
        !has_shift_8,
        "H2 MUTATION DETECTION: Rule 2 (Shift) must NOT fire with only 8 points above CL. \
         If it fires, the boundary check uses >= 8 instead of >= 9. Got alerts: {:?}",
        alerts_8
    );

    // 9 points all above CL — Rule 2 MUST fire
    let data_9: Vec<ChartData> = (0..9).map(|i| make_above(&format!("t{}", i))).collect();
    let alerts_9 = check_western_electric_rules(&data_9);

    let has_shift_9 = alerts_9
        .iter()
        .any(|a| matches!(a, SpecialCause::Shift { .. }));
    assert!(
        has_shift_9,
        "H2 MUTATION DETECTION: Rule 2 (Shift) MUST fire with exactly 9 consecutive \
         points above CL. If it does not fire, the boundary is wrong. Got alerts: {:?}",
        alerts_9
    );
}

// ===========================================================================
// Mutation H3: Conditional negation in guard+circuit bonus
// ===========================================================================
//
// The mutation: `if guard_pass && circuit_allowed { reward += 0.1 }`
// becomes `if !guard_pass || !circuit_allowed { reward += 0.1 }` (negated).
// With the mutation, the bonus would apply when EITHER guard fails OR circuit
// is blocked, and the penalty (-0.5) would apply when BOTH are true.
//
// Detection: Verify that guard_pass=true AND circuit_allowed=true gives +0.1
// bonus (not -0.5 penalty), and guard_pass=false OR circuit_allowed=false
// gives -0.5 penalty (not +0.1 bonus).

#[test]
fn test_h3_conditional_negation_guard_circuit_bonus_detected() {
    // Case 1: guard=true, circuit=true → should give +0.1 bonus
    // reward = +0.2 (stable) + 0.1 (guard+circ) = +0.3
    let reward_both_pass = compute_reward(2, 2, 0, true, true, false, 0);

    // Case 2: guard=true, circuit=false → should give -0.5 penalty
    // reward = +0.2 (stable) - 0.5 (guard+circ penalty) = -0.3
    let reward_circ_fail = compute_reward(2, 2, 0, true, false, false, 0);

    // Case 3: guard=false, circuit=true → should give -0.5 penalty
    // reward = +0.2 (stable) - 0.5 (guard+circ penalty) = -0.3
    let reward_guard_fail = compute_reward(2, 2, 0, false, true, false, 0);

    // H3 mutation detection: both_pass must give the HIGHER reward (bonus),
    // not the lower reward (penalty). If mutation existed, both_pass would give
    // -0.5 and circ_fail/guard_fail would give +0.1.
    assert!(
        reward_both_pass > reward_circ_fail,
        "H3 MUTATION DETECTION: guard=true+circuit=true ({:.4}) must give higher reward \
         than guard=true+circuit=false ({:.4}). If inverted, the condition was negated.",
        reward_both_pass,
        reward_circ_fail
    );
    assert!(
        reward_both_pass > reward_guard_fail,
        "H3 MUTATION DETECTION: guard=true+circuit=true ({:.4}) must give higher reward \
         than guard=false+circuit=true ({:.4}). If inverted, the condition was negated.",
        reward_both_pass,
        reward_guard_fail
    );

    // Exact values
    assert!(
        (reward_both_pass - 0.3).abs() < 1e-5,
        "H3 MUTATION DETECTION: guard=true+circuit=true reward should be exactly +0.3 \
         (+0.2 stable + 0.1 bonus). Got {:.6}",
        reward_both_pass
    );
    assert!(
        (reward_circ_fail - (-0.3)).abs() < 1e-5,
        "H3 MUTATION DETECTION: guard=true+circuit=false reward should be exactly -0.3 \
         (+0.2 stable - 0.5 penalty). Got {:.6}",
        reward_circ_fail
    );
}

// ===========================================================================
// Mutation adequacy documentation (not a test)
// ===========================================================================
//
// Mutation adequacy: 7/7 mutations have detection tests.
//
// Mutations validated:
// 1. Same-state Bellman -> detected by test_mutation_1_same_state_bellman_detected
// 2. SPC one-shot vs historical -> detected by test_mutation_2_spc_historical_vs_oneshot_detected
// 3. String::len() instead of event count -> detected by test_mutation_3_event_count_not_string_length_detected
// 4. Zero step counter in circuit breaker -> detected by test_mutation_4_circuit_breaker_step_counter_detected
// 5. Health improvement +→- -> detected by test_h1_plus_to_minus_operator_mutation_detected
// 6. SPC Rule 2 boundary >→>= -> detected by test_h2_spc_rule2_fires_at_9_not_8_detected
// 7. Guard+circuit conditional negation -> detected by test_h3_conditional_negation_guard_circuit_bonus_detected