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
//! The single authoritative permutation-test scaffold (CONS-02, Plan A).
//!
//! Consolidates the per-permutation `reseed → shuffle → recompute → count →
//! (1+n_ge)/(1+n_perm)` pattern into ONE `pub(crate)` helper,
//! [`permutation_pvalue`]. The scaffold is **feature-gated parallel** (threshold
//! owned by the caller) and **bit-identical** across thread counts and feature
//! configs because every permutation reseeds its own RNG via
//! [`crate::helpers::seed_for_thread`] — so the parallel `.sum()` reduction is
//! order-independent and equals the sequential one.
//!
//! # Scope
//!
//! This module is deliberately named `permutation_test` to avoid colliding with
//! the existing [`crate::inference::permutation`] submodule. It is the CONS-02
//! consolidation of Plan A: only `frechet_anova`'s primary loop migrates onto it,
//! because that is the single site whose loop already matches the contract
//! (per-permutation reseeded `StdRng`, threshold-gated parallel,
//! `(1+n_ge)/(1+n_perm)`). The advancing-single-RNG sites (`t_perm_test`,
//! `f_perm_test`, `explain/importance`, `famm`) and the fixed-42 LCG site
//! (`function_on_scalar::fanova`) are documented-and-excluded — migrating them
//! WOULD change their p-values.
//!
//! # Draw-application contract
//!
//! The helper draws the permutation as a shuffled `Vec<usize>` of `0..n` and
//! hands the closure `&perm_idx` (a permutation *of positions*). The caller
//! applies that permutation itself — typically by GATHERING its own per-position
//! data through `perm_idx`. Because the helper applies exactly ONE
//! `SliceRandom::shuffle` (Fisher–Yates) to a length-`n` slice under the same
//! per-permutation seed a hand-rolled loop would use, `perm_idx` is the same
//! position-permutation that loop's in-place `shuffle` produced — so gathering
//! through it reproduces the old shuffled vector bit-for-bit.
use crateseed_for_thread;
use crateiter_maybe_parallel;
use SliceRandom;
use ParallelIterator;
/// Compute a seeded permutation p-value `(1 + n_ge) / (1 + n_perm)`.
///
/// For each `perm` in `0..n_perm`, reseeds an `StdRng` at `seed + perm` (via
/// [`seed_for_thread`]), shuffles a fresh `Vec<usize>` of `0..n` in place, and
/// passes the resulting position-permutation `&perm_idx` to `stat`. Counts how
/// many permutation statistics are `>= observed`, then returns
/// `(n_ge + 1) / (n_perm + 1)` as an `f64`.
///
/// Dispatch is parallel via `iter_maybe_parallel!` when
/// `n_perm >= threshold` (the caller owns the payback point), else sequential.
/// The reduction is a plain `.sum()`, which is order-independent here because
/// each permutation reseeds — so the parallel and sequential branches, and the
/// `parallel`-on / `parallel`-off feature configs, are all BIT-IDENTICAL.
///
/// A degenerate permutation (e.g. a compute error the caller wants to skip
/// conservatively) is expressed by having `stat` return [`f64::NEG_INFINITY`],
/// so the `>= observed` comparison yields `false` (counts 0).
pub