2cf4574e33
Cycle-close audit (architect: drift_found, design core clean). What holds, architect-confirmed: C18 bit-identity for RunMetrics (prose derived byte-identically, rng order preserved, registry suite unchanged); the C10 wall (monomorphic R-gates, r_based in the R vocabulary, cross-vocabulary refusal tested both ways — no leak); C28 direction (trait in aura-analysis, vocabulary supplied from the outer rungs, zero Cargo edge changes); C1 determinism pinned through the generic path. Drift items, all resolved as fixes in this commit: - design ledger: the C28 #147 disposition now records item 2 SHIPPED (the A1 cut) with A2 still deliberately deferred; the #136 one-implementor clause carries a supersession note (the IC is the second implementor). - seven stale doc comments describing the pre-#147 or mid-cycle state (analysis trait + estimator docs, registry check_r_metric C9 claim, engine re-export note, campaign PER_MEMBER_METRICS roster note, member-seam guard comment, research vocabulary note) updated to the shipped state. - R_BASED_METRICS is now oracle-pinned against RunMetricKey::r_based() in the vocabulary test (it feeds the NonRMetric refusal prose; a divergence would have misreported the R-gate silently). Noted, not amended (history stays): commit 6744f67's body says 'three syntax-only edits' where the test module actually took five. No regression scripts are configured (the bench is report-only); the architect review is the gate. Verification: extended vocabulary test, campaign suites, analysis/research suites green; clippy --workspace -D warnings clean. refs #147
407 lines
18 KiB
Rust
407 lines
18 KiB
Rust
//! Domain-free post-run statistics and selection provenance (C1/C12): the
|
||
//! multiple-comparison hurdle math (`inv_norm_cdf`, `expected_max_of_normals`)
|
||
//! and the selection-provenance record a sweep winner carries
|
||
//! (`FamilySelection` / `SelectionMode`, embedded by the engine's
|
||
//! `RunManifest.selection`). No trading vocabulary lives here: the backtest
|
||
//! reductions (R-metrics, the position-event table) moved to
|
||
//! `aura-backtest::metrics` (issue #291, C28 phase 5). Foundation-grade — any
|
||
//! ladder rung may depend on this crate without violating the C28 import
|
||
//! direction. Every type's serde shape is byte-pinned by C18 goldens; bodies
|
||
//! moved verbatim.
|
||
|
||
/// Which selection objective produced the record (additive provenance, C23).
|
||
/// `Argmax` is the bare-best pick (cycle 0076), deflated for the number of trials.
|
||
/// `PlateauMean` / `PlateauWorst` (cycle 0077) argmax the neighbourhood-smoothed
|
||
/// surface instead, so peak-vs-plateau runs stay distinguishable on this field.
|
||
#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub enum SelectionMode {
|
||
Argmax,
|
||
PlateauMean,
|
||
PlateauWorst,
|
||
}
|
||
|
||
/// Selection-provenance for a sweep winner. The selection RULE (`mode`) and its
|
||
/// ANNOTATION are orthogonal: `Argmax` carries the trials-deflation annotation
|
||
/// (`deflated_score` / `overfit_probability` / `n_resamples` / `block_len` /
|
||
/// `seed`); `Plateau*` carries the smoothing annotation (`neighbourhood_score` /
|
||
/// `n_neighbours`). Each annotation is `Option`, present iff its rule produced the
|
||
/// record; all are additive — recorded, never re-ranking (C23). A legacy line
|
||
/// (pre-0077) carries the deflation scalars as bare values that deserialize to
|
||
/// `Some` (serde default), so it still loads (C14/C18).
|
||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct FamilySelection {
|
||
pub selection_metric: String,
|
||
pub n_trials: usize,
|
||
pub raw_winner_metric: f64,
|
||
pub mode: SelectionMode,
|
||
// deflation annotation (present iff mode == Argmax with a deflation run)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub deflated_score: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub overfit_probability: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub n_resamples: Option<usize>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub block_len: Option<usize>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub seed: Option<u64>,
|
||
// plateau annotation (present iff mode is Plateau*)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub neighbourhood_score: Option<f64>,
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub n_neighbours: Option<usize>,
|
||
}
|
||
|
||
/// Inverse standard-normal CDF (quantile function), Acklam's rational
|
||
/// approximation — absolute error < ~1.2e-9 over `p ∈ (0,1)`. Pure (C1).
|
||
/// Callers pass strictly-interior `p` (`k >= 2` keeps the argument off the
|
||
/// boundaries); the boundary branches return ±inf as a defined limit.
|
||
// Acklam's published coefficients verbatim; some carry more decimals than an
|
||
// f64 holds (clippy::excessive_precision) — kept literal as reference constants.
|
||
#[allow(clippy::excessive_precision)]
|
||
pub fn inv_norm_cdf(p: f64) -> f64 {
|
||
const A: [f64; 6] = [-3.969683028665376e+01, 2.209460984245205e+02, -2.759285104469687e+02,
|
||
1.383577518672690e+02, -3.066479806614716e+01, 2.506628277459239e+00];
|
||
const B: [f64; 5] = [-5.447609879822406e+01, 1.615858368580409e+02, -1.556989798598866e+02,
|
||
6.680131188771972e+01, -1.328068155288572e+01];
|
||
const C: [f64; 6] = [-7.784894002430293e-03, -3.223964580411365e-01, -2.400758277161838e+00,
|
||
-2.549732539343734e+00, 4.374664141464968e+00, 2.938163982698783e+00];
|
||
const D: [f64; 4] = [7.784695709041462e-03, 3.224671290700398e-01, 2.445134137142996e+00,
|
||
3.754408661907416e+00];
|
||
const P_LOW: f64 = 0.02425;
|
||
if p <= 0.0 { return f64::NEG_INFINITY; }
|
||
if p >= 1.0 { return f64::INFINITY; }
|
||
if p < P_LOW {
|
||
let q = (-2.0 * p.ln()).sqrt();
|
||
(((((C[0]*q + C[1])*q + C[2])*q + C[3])*q + C[4])*q + C[5])
|
||
/ ((((D[0]*q + D[1])*q + D[2])*q + D[3])*q + 1.0)
|
||
} else if p <= 1.0 - P_LOW {
|
||
let q = p - 0.5;
|
||
let r = q * q;
|
||
(((((A[0]*r + A[1])*r + A[2])*r + A[3])*r + A[4])*r + A[5]) * q
|
||
/ (((((B[0]*r + B[1])*r + B[2])*r + B[3])*r + B[4])*r + 1.0)
|
||
} else {
|
||
let q = (-2.0 * (1.0 - p).ln()).sqrt();
|
||
-(((((C[0]*q + C[1])*q + C[2])*q + C[3])*q + C[4])*q + C[5])
|
||
/ ((((D[0]*q + D[1])*q + D[2])*q + D[3])*q + 1.0)
|
||
}
|
||
}
|
||
|
||
/// Expected maximum of `k` i.i.d. standard normals — the hurdle a search of `k`
|
||
/// configurations clears by chance alone. `k <= 1` -> `0.0` (a single trial has no
|
||
/// multiple-comparison inflation, and guards the `Φ⁻¹(1 - 1/k) -> Φ⁻¹(0)`
|
||
/// divergence). For `k >= 2`:
|
||
/// `(1 - γ)·Φ⁻¹(1 - 1/k) + γ·Φ⁻¹(1 - 1/(k·e))`, `γ = 0.5772156649…`. Pure (C1).
|
||
pub fn expected_max_of_normals(k: usize) -> f64 {
|
||
if k <= 1 {
|
||
return 0.0;
|
||
}
|
||
const GAMMA: f64 = 0.577_215_664_901_532_9;
|
||
let kf = k as f64;
|
||
(1.0 - GAMMA) * inv_norm_cdf(1.0 - 1.0 / kf)
|
||
+ GAMMA * inv_norm_cdf(1.0 - 1.0 / (kf * std::f64::consts::E))
|
||
}
|
||
|
||
/// A tiny, fully-deterministic, dependency-free PRNG (SplitMix64). The seed
|
||
/// completely determines the sequence; no external entropy, no global state.
|
||
/// Bit-stable across toolchains and crate versions — the property C1 needs for
|
||
/// seed-as-input reproducibility (C12).
|
||
pub struct SplitMix64 {
|
||
state: u64,
|
||
}
|
||
|
||
impl SplitMix64 {
|
||
pub fn new(seed: u64) -> Self {
|
||
Self { state: seed }
|
||
}
|
||
pub fn next_u64(&mut self) -> u64 {
|
||
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
|
||
let mut z = self.state;
|
||
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
|
||
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
|
||
z ^ (z >> 31)
|
||
}
|
||
/// A uniform `f64` in `[0, 1)` from the top 53 bits.
|
||
pub fn next_f64(&mut self) -> f64 {
|
||
(self.next_u64() >> 11) as f64 / ((1u64 << 53) as f64)
|
||
}
|
||
}
|
||
|
||
/// Mean + a fixed quantile set of one metric across the realizations. `p50` is
|
||
/// the median (the two coincide by definition), so "mean/median/quantiles" is
|
||
/// `mean` + `p50` + the surrounding quantiles, with no redundant field.
|
||
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||
pub struct MetricStats {
|
||
pub mean: f64,
|
||
pub p5: f64,
|
||
pub p25: f64,
|
||
pub p50: f64, // == median
|
||
pub p75: f64,
|
||
pub p95: f64,
|
||
}
|
||
|
||
impl MetricStats {
|
||
/// Mean + the fixed type-7 quantile set over a value set. Sorts a copy;
|
||
/// `values` must be finite and non-empty. The shared reduction behind both the
|
||
/// MC aggregate (a metric across draws) and the walk-forward param-stability
|
||
/// summary (a param across windows; `aura-engine`'s `param_stability`).
|
||
pub fn from_values(values: &[f64]) -> MetricStats {
|
||
let mut xs = values.to_vec();
|
||
xs.sort_by(|a, b| a.partial_cmp(b).expect("values are finite"));
|
||
MetricStats {
|
||
mean: xs.iter().sum::<f64>() / xs.len() as f64,
|
||
p5: quantile(&xs, 0.05),
|
||
p25: quantile(&xs, 0.25),
|
||
p50: quantile(&xs, 0.50),
|
||
p75: quantile(&xs, 0.75),
|
||
p95: quantile(&xs, 0.95),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Linear-interpolation quantile (the numpy/Excel "type 7" default) over a
|
||
/// pre-sorted, non-empty slice. `p` in `[0, 1]`. `rank = p * (n - 1)`;
|
||
/// interpolate between the two bracketing order statistics. `n == 1` returns the
|
||
/// sole value.
|
||
pub fn quantile(sorted: &[f64], p: f64) -> f64 {
|
||
let n = sorted.len();
|
||
if n == 1 {
|
||
return sorted[0];
|
||
}
|
||
let rank = p * (n - 1) as f64;
|
||
let lo = rank.floor() as usize;
|
||
let frac = rank - lo as f64;
|
||
if lo + 1 < n {
|
||
sorted[lo] + frac * (sorted[lo + 1] - sorted[lo])
|
||
} else {
|
||
sorted[n - 1]
|
||
}
|
||
}
|
||
|
||
/// One moving-block resample of `rs` to length `rs.len()` (non-circular; the final
|
||
/// block is truncated so the resample has exactly `n` values). `block_len` must be
|
||
/// pre-clamped to `[1, rs.len()]` by the caller. Pure given the `rng` state — the
|
||
/// shared kernel behind `r_bootstrap` and the trials-deflation reality-check (C1).
|
||
pub fn resample_block(rs: &[f64], block_len: usize, rng: &mut SplitMix64) -> Vec<f64> {
|
||
let n = rs.len();
|
||
debug_assert!(
|
||
(1..=n).contains(&block_len),
|
||
"resample_block requires block_len in [1, rs.len()] (caller pre-clamps); \
|
||
got block_len={block_len}, rs.len()={n}",
|
||
);
|
||
let mut sample: Vec<f64> = Vec::with_capacity(n);
|
||
while sample.len() < n {
|
||
let start = (rng.next_u64() % (n - block_len + 1) as u64) as usize;
|
||
let take = block_len.min(n - sample.len());
|
||
sample.extend_from_slice(&rs[start..start + take]);
|
||
}
|
||
sample
|
||
}
|
||
|
||
/// Pearson product-moment correlation of two equal-length finite series.
|
||
/// `n < 2`, unequal lengths, or zero variance on either side → `0.0`: no linear
|
||
/// relationship is defined, and `0.0` is the honest "no correlation" value (mirrors
|
||
/// how the R bootstrap floors a degenerate series rather than emitting `NaN`).
|
||
pub fn pearson_corr(xs: &[f64], ys: &[f64]) -> f64 {
|
||
let n = xs.len();
|
||
if n < 2 || ys.len() != n {
|
||
return 0.0;
|
||
}
|
||
let nf = n as f64;
|
||
let mx = xs.iter().sum::<f64>() / nf;
|
||
let my = ys.iter().sum::<f64>() / nf;
|
||
let (mut sxy, mut sxx, mut syy) = (0.0, 0.0, 0.0);
|
||
for i in 0..n {
|
||
let (dx, dy) = (xs[i] - mx, ys[i] - my);
|
||
sxy += dx * dy;
|
||
sxx += dx * dx;
|
||
syy += dy * dy;
|
||
}
|
||
if sxx <= 0.0 || syy <= 0.0 {
|
||
return 0.0;
|
||
}
|
||
sxy / (sxx.sqrt() * syy.sqrt())
|
||
}
|
||
|
||
/// In-place Fisher-Yates shuffle driven by `SplitMix64` — a uniform permutation,
|
||
/// sampling WITHOUT replacement (the permutation null). Distinct from
|
||
/// `resample_block`, which resamples contiguous blocks WITH replacement. Pure given
|
||
/// the `rng` state (C1).
|
||
pub fn permute<T>(xs: &mut [T], rng: &mut SplitMix64) {
|
||
for i in (1..xs.len()).rev() {
|
||
let j = (rng.next_u64() % (i as u64 + 1)) as usize;
|
||
xs.swap(i, j);
|
||
}
|
||
}
|
||
|
||
/// Per-metric vocabulary a report payload supplies to the registry's
|
||
/// ranking/deflation machinery (#147). Narrow by design (#136): exactly the
|
||
/// surface the two production implementors — the backtest metrics
|
||
/// (`RunMetrics`, centred moving-block null) and the measurement IC
|
||
/// (within-run permutation null) — demonstrably share. The registry owns the
|
||
/// family fold — best-of-K, p95, Laplace, dispersion floor — the payload
|
||
/// owns what each metric IS: its name, direction, value read, and what one
|
||
/// draw of its null statistic means.
|
||
pub trait MetricVocabulary: Sized {
|
||
/// Resolved metric identity — cheap to copy, resolved once per call.
|
||
type Key: Copy;
|
||
/// Name → key; `None` for a name outside this vocabulary.
|
||
fn resolve(name: &str) -> Option<Self::Key>;
|
||
/// The canonical accepted-name roster, in refusal-prose order.
|
||
fn known() -> &'static [&'static str];
|
||
/// The key's optimisation direction: `true` when larger is better.
|
||
fn higher_is_better(key: Self::Key) -> bool;
|
||
/// The member's scalar under the key. A missing optional block reads
|
||
/// `f64::NEG_INFINITY` (the worst rank for higher-is-better keys).
|
||
fn value(&self, key: Self::Key) -> f64;
|
||
/// Whether the key deflates via a resampled null (the best-of-K arm) or
|
||
/// the dispersion floor.
|
||
fn has_resampling_null(key: Self::Key) -> bool;
|
||
/// ONE draw of this member's null statistic under H0, advancing `rng`.
|
||
/// `None` means no usable null input for this member, and consumes no
|
||
/// rng. `block_len` parameterises resampling vocabularies; permutation
|
||
/// vocabularies ignore it.
|
||
fn null_draw(&self, key: Self::Key, block_len: usize, rng: &mut SplitMix64) -> Option<f64>;
|
||
}
|
||
|
||
/// One-sided Laplace-smoothed tail estimate `(ge + 1) / (n + 1)`: the
|
||
/// probability mass at-or-above an observed statistic given that `ge` of `n`
|
||
/// null draws reached it. The single source of the formula: the registry's
|
||
/// R deflation arm and the CLI's IC reduction both call it.
|
||
pub fn one_sided_p_laplace(ge: usize, n: usize) -> f64 {
|
||
(ge + 1) as f64 / (n + 1) as f64
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn pearson_corr_known_values() {
|
||
// identical series → +1; reversed monotone → −1; constant side → 0 (zero variance)
|
||
assert!((pearson_corr(&[1.0, 2.0, 3.0, 4.0], &[1.0, 2.0, 3.0, 4.0]) - 1.0).abs() < 1e-12);
|
||
assert!((pearson_corr(&[1.0, 2.0, 3.0, 4.0], &[4.0, 3.0, 2.0, 1.0]) + 1.0).abs() < 1e-12);
|
||
assert_eq!(pearson_corr(&[1.0, 2.0, 3.0, 4.0], &[7.0, 7.0, 7.0, 7.0]), 0.0);
|
||
// degenerate: fewer than two points, or unequal lengths → 0.0
|
||
assert_eq!(pearson_corr(&[1.0], &[1.0]), 0.0);
|
||
assert_eq!(pearson_corr(&[1.0, 2.0], &[1.0]), 0.0);
|
||
}
|
||
|
||
#[test]
|
||
fn permute_is_a_permutation_and_deterministic() {
|
||
let mut a = [1u32, 2, 3, 4, 5, 6, 7, 8];
|
||
let mut b = a;
|
||
let mut ra = SplitMix64::new(42);
|
||
let mut rb = SplitMix64::new(42);
|
||
permute(&mut a, &mut ra);
|
||
permute(&mut b, &mut rb);
|
||
assert_eq!(a, b, "same seed → same permutation (C1)");
|
||
let mut sorted = a;
|
||
sorted.sort_unstable();
|
||
assert_eq!(sorted, [1, 2, 3, 4, 5, 6, 7, 8], "a permutation preserves the multiset");
|
||
// a different seed generally yields a different order (not a hard guarantee, but true here)
|
||
let mut c = [1u32, 2, 3, 4, 5, 6, 7, 8];
|
||
let mut rc = SplitMix64::new(43);
|
||
permute(&mut c, &mut rc);
|
||
assert_ne!(a, c);
|
||
}
|
||
|
||
#[test]
|
||
fn inv_norm_cdf_matches_known_quantiles() {
|
||
assert!((inv_norm_cdf(0.975) - 1.959964).abs() < 1e-3);
|
||
assert!(inv_norm_cdf(0.5).abs() < 1e-9);
|
||
assert!((inv_norm_cdf(0.1) + inv_norm_cdf(0.9)).abs() < 1e-9); // symmetry
|
||
}
|
||
|
||
#[test]
|
||
fn expected_max_of_normals_is_zero_at_one_and_monotone() {
|
||
assert_eq!(expected_max_of_normals(1), 0.0);
|
||
assert_eq!(expected_max_of_normals(0), 0.0);
|
||
let (e2, e4, e10, e100) = (
|
||
expected_max_of_normals(2), expected_max_of_normals(4),
|
||
expected_max_of_normals(10), expected_max_of_normals(100),
|
||
);
|
||
assert!(e2 < e4 && e4 < e10 && e10 < e100); // strictly increasing
|
||
assert!((1.0..1.1).contains(&e4)); // ~1.05 (not the √(2 ln 4)=1.66 asymptote)
|
||
assert!((2.4..2.7).contains(&e100)); // ~2.53
|
||
}
|
||
|
||
#[test]
|
||
fn quantile_endpoints_and_singleton() {
|
||
// singleton returns the sole value; p=0 -> min, p=1 -> max.
|
||
assert_eq!(quantile(&[42.0], 0.0), 42.0);
|
||
assert_eq!(quantile(&[42.0], 0.5), 42.0);
|
||
assert_eq!(quantile(&[42.0], 1.0), 42.0);
|
||
let xs = [1.0, 2.0, 3.0, 4.0, 5.0];
|
||
assert_eq!(quantile(&xs, 0.0), 1.0);
|
||
assert_eq!(quantile(&xs, 1.0), 5.0);
|
||
}
|
||
|
||
#[test]
|
||
fn metric_stats_from_values_matches_known_fixture() {
|
||
// type-7 quantile + mean over [0,1,2,3,4], directly on the
|
||
// extracted reduction: mean=2.0, p50=2.0, p5≈0.2, p95≈3.8 (same numbers the
|
||
// aggregate fixture pins, now on from_values).
|
||
let s = MetricStats::from_values(&[0.0, 1.0, 2.0, 3.0, 4.0]);
|
||
assert_eq!(s.mean, 2.0);
|
||
assert_eq!(s.p50, 2.0);
|
||
assert!((s.p5 - 0.2).abs() < 1e-9, "p5 = {}", s.p5);
|
||
assert!((s.p95 - 3.8).abs() < 1e-9, "p95 = {}", s.p95);
|
||
}
|
||
|
||
#[test]
|
||
fn metric_stats_serde_round_trips() {
|
||
// MetricStats gains serde (consistent with the
|
||
// report types) so the CLI summary renders it and #70 lineage can persist.
|
||
let s = MetricStats::from_values(&[1.0, 2.0, 3.0]);
|
||
let json = serde_json::to_string(&s).expect("serialize MetricStats");
|
||
let back: MetricStats = serde_json::from_str(&json).expect("deserialize MetricStats");
|
||
assert_eq!(back, s);
|
||
}
|
||
|
||
#[test]
|
||
fn resample_block_returns_n_values_from_contiguous_runs() {
|
||
// The extracted kernel's standalone contract (now a public entry point):
|
||
// given a pre-clamped block_len in [1, n], it yields exactly `n` values, each
|
||
// appearing in `rs`, arranged as contiguous runs of `rs` (the final run
|
||
// truncated to reach exactly n). Distinct powers of ten make membership a sum
|
||
// check. n=5, block_len=2 -> runs of length 2, 2, 1.
|
||
let rs = [1.0, 10.0, 100.0, 1000.0, 10000.0];
|
||
let n = rs.len();
|
||
let block_len = 2;
|
||
let mut rng = SplitMix64::new(7);
|
||
let sample = resample_block(&rs, block_len, &mut rng);
|
||
assert_eq!(sample.len(), n, "resample has exactly n values");
|
||
assert!(
|
||
sample.iter().all(|v| rs.contains(v)),
|
||
"every resampled value is drawn from rs",
|
||
);
|
||
// Reconstruct the contiguous-run structure: walk the sample in blocks of
|
||
// `block_len` (last truncated) and assert each block is a contiguous slice of rs.
|
||
let mut filled = 0usize;
|
||
while filled < n {
|
||
let take = block_len.min(n - filled);
|
||
let run = &sample[filled..filled + take];
|
||
let start = rs
|
||
.iter()
|
||
.position(|v| v == &run[0])
|
||
.expect("run head is in rs");
|
||
assert_eq!(
|
||
run,
|
||
&rs[start..start + take],
|
||
"each run is a contiguous slice of rs",
|
||
);
|
||
filled += take;
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn one_sided_p_laplace_known_values() {
|
||
assert_eq!(one_sided_p_laplace(0, 3), 0.25);
|
||
assert_eq!(one_sided_p_laplace(3, 3), 1.0);
|
||
assert_eq!(one_sided_p_laplace(1, 999), 2.0 / 1000.0);
|
||
// the registry's R-arm shape: count-of-ge over usable draws
|
||
assert_eq!(one_sided_p_laplace(0, 999), 1.0 / 1000.0);
|
||
}
|
||
}
|