1#![cfg_attr(not(feature = "std"), no_std)]
48#![deny(unsafe_code)]
51#![cfg_attr(not(test), deny(clippy::unwrap_used))]
52#![cfg_attr(not(test), deny(clippy::expect_used))]
53#![cfg_attr(not(test), deny(clippy::panic))]
54#![warn(missing_docs)]
55#[cfg(not(feature = "std"))]
56extern crate alloc;
57
58#[cfg(not(feature = "std"))]
59use alloc::string::String;
60
61use core::fmt;
62use core::str::FromStr;
63
64#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
75pub struct NexId([u8; 16]);
76
77#[cfg(feature = "serde")]
82impl serde::Serialize for NexId {
83 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
84 where
85 S: serde::Serializer,
86 {
87 serializer.serialize_str(&self.to_string_hyphenated())
88 }
89}
90
91#[cfg(feature = "serde")]
92impl<'de> serde::Deserialize<'de> for NexId {
93 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
94 where
95 D: serde::Deserializer<'de>,
96 {
97 struct NexIdVisitor;
98
99 impl serde::de::Visitor<'_> for NexIdVisitor {
100 type Value = NexId;
101
102 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103 write!(f, "a UUID string (hyphenated or simple)")
104 }
105
106 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
107 where
108 E: serde::de::Error,
109 {
110 v.parse().map_err(serde::de::Error::custom)
111 }
112 }
113
114 deserializer.deserialize_str(NexIdVisitor)
115 }
116}
117
118#[non_exhaustive]
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub enum ParseError {
122 InvalidLength,
124 InvalidCharacter,
126 InvalidFormat,
128}
129
130impl fmt::Display for ParseError {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 match self {
133 Self::InvalidLength => write!(f, "invalid UUID length"),
134 Self::InvalidCharacter => write!(f, "invalid character in UUID"),
135 Self::InvalidFormat => write!(f, "invalid UUID format"),
136 }
137 }
138}
139
140#[cfg(feature = "std")]
141impl std::error::Error for ParseError {}
142
143impl NexId {
144 pub const NIL: Self = Self([0; 16]);
146
147 pub const MAX: Self = Self([0xff; 16]);
149
150 #[must_use]
152 pub const fn from_bytes(bytes: [u8; 16]) -> Self {
153 Self(bytes)
154 }
155
156 #[must_use]
158 pub const fn as_bytes(&self) -> &[u8; 16] {
159 &self.0
160 }
161
162 #[must_use]
164 pub const fn version(&self) -> u8 {
165 (self.0[6] >> 4) & 0x0f
166 }
167
168 #[must_use]
170 pub const fn variant(&self) -> u8 {
171 (self.0[8] >> 6) & 0x03
172 }
173
174 #[must_use]
176 pub const fn is_nil(&self) -> bool {
177 self.0[0] == 0
180 && self.0[1] == 0
181 && self.0[2] == 0
182 && self.0[3] == 0
183 && self.0[4] == 0
184 && self.0[5] == 0
185 && self.0[6] == 0
186 && self.0[7] == 0
187 && self.0[8] == 0
188 && self.0[9] == 0
189 && self.0[10] == 0
190 && self.0[11] == 0
191 && self.0[12] == 0
192 && self.0[13] == 0
193 && self.0[14] == 0
194 && self.0[15] == 0
195 }
196
197 #[cfg(feature = "std")]
213 #[must_use]
214 pub fn v4() -> Self {
215 let mut bytes = [0u8; 16];
216 fill_random(&mut bytes);
217
218 bytes[6] = (bytes[6] & 0x0f) | 0x40;
220 bytes[8] = (bytes[8] & 0x3f) | 0x80;
222
223 Self(bytes)
224 }
225
226 #[cfg(feature = "std")]
245 #[must_use]
246 pub fn v7() -> Self {
247 let mut bytes = [0u8; 16];
248
249 let ts_millis: u128 = std::time::SystemTime::now()
254 .duration_since(std::time::UNIX_EPOCH)
255 .map(|d| d.as_millis())
256 .unwrap_or(0);
257 #[allow(
259 clippy::as_conversions,
260 reason = "ts_millis fits in u64 for any realistic timestamp: u64::MAX ms ~= 584 million years; saturating lossy cast is intentional"
261 )]
262 let ts = ts_millis as u64;
263
264 #[allow(
267 clippy::as_conversions,
268 reason = "byte extraction: right-shift isolates the target octet, as u8 discards upper bits intentionally"
269 )]
270 {
271 bytes[0] = (ts >> 40) as u8;
272 bytes[1] = (ts >> 32) as u8;
273 bytes[2] = (ts >> 24) as u8;
274 bytes[3] = (ts >> 16) as u8;
275 bytes[4] = (ts >> 8) as u8;
276 bytes[5] = ts as u8;
277 }
278
279 let mut rand_bytes = [0u8; 10];
281 fill_random(&mut rand_bytes);
282 bytes[6..16].copy_from_slice(&rand_bytes);
283
284 bytes[6] = (bytes[6] & 0x0f) | 0x70;
286 bytes[8] = (bytes[8] & 0x3f) | 0x80;
288
289 Self(bytes)
290 }
291
292 #[must_use]
294 pub const fn from_u128(value: u128) -> Self {
295 Self(value.to_be_bytes())
296 }
297
298 #[must_use]
300 pub const fn to_u128(&self) -> u128 {
301 u128::from_be_bytes(self.0)
302 }
303
304 #[must_use]
306 pub fn to_string_hyphenated(&self) -> String {
307 let mut s = String::with_capacity(36);
308 for (i, byte) in self.0.iter().enumerate() {
309 if i == 4 || i == 6 || i == 8 || i == 10 {
310 s.push('-');
311 }
312 let hi = usize::from(byte >> 4);
314 let lo = usize::from(byte & 0x0f);
315 s.push(HEX_CHARS.get(hi).copied().unwrap_or('?'));
316 s.push(HEX_CHARS.get(lo).copied().unwrap_or('?'));
317 }
318 s
319 }
320
321 #[must_use]
323 pub fn to_string_simple(&self) -> String {
324 let mut s = String::with_capacity(32);
325 for byte in &self.0 {
326 let hi = usize::from(byte >> 4);
327 let lo = usize::from(byte & 0x0f);
328 s.push(HEX_CHARS.get(hi).copied().unwrap_or('?'));
329 s.push(HEX_CHARS.get(lo).copied().unwrap_or('?'));
330 }
331 s
332 }
333}
334
335const HEX_CHARS: [char; 16] = [
336 '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f',
337];
338
339impl fmt::Display for NexId {
340 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
341 write!(f, "{}", self.to_string_hyphenated())
342 }
343}
344
345impl fmt::Debug for NexId {
346 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
347 write!(f, "NexId({})", self.to_string_hyphenated())
348 }
349}
350
351impl FromStr for NexId {
352 type Err = ParseError;
353
354 fn from_str(s: &str) -> Result<Self, Self::Err> {
355 let s = s.trim();
356
357 match s.len() {
359 36 => parse_hyphenated(s),
360 32 => parse_simple(s),
361 _ => Err(ParseError::InvalidLength),
362 }
363 }
364}
365
366fn parse_hyphenated(s: &str) -> Result<NexId, ParseError> {
367 let bytes = s.as_bytes();
368
369 let b8 = bytes.get(8).copied().ok_or(ParseError::InvalidFormat)?;
372 let b13 = bytes.get(13).copied().ok_or(ParseError::InvalidFormat)?;
373 let b18 = bytes.get(18).copied().ok_or(ParseError::InvalidFormat)?;
374 let b23 = bytes.get(23).copied().ok_or(ParseError::InvalidFormat)?;
375 if b8 != b'-' || b13 != b'-' || b18 != b'-' || b23 != b'-' {
376 return Err(ParseError::InvalidFormat);
377 }
378
379 let mut result = [0u8; 16];
380 let mut byte_idx: usize = 0;
381
382 for (i, chunk) in s.split('-').enumerate() {
383 let expected_len = match i {
384 0 => 8,
385 1..=3 => 4,
386 4 => 12,
387 _ => return Err(ParseError::InvalidFormat),
388 };
389
390 if chunk.len() != expected_len {
391 return Err(ParseError::InvalidFormat);
392 }
393
394 for pair in chunk.as_bytes().chunks(2) {
395 let high = hex_digit(pair.first().copied().ok_or(ParseError::InvalidFormat)?)?;
396 let low = hex_digit(pair.get(1).copied().ok_or(ParseError::InvalidFormat)?)?;
397 let slot = result.get_mut(byte_idx).ok_or(ParseError::InvalidFormat)?;
398 *slot = (high << 4) | low;
399 byte_idx = byte_idx.saturating_add(1);
400 }
401 }
402
403 Ok(NexId(result))
404}
405
406fn parse_simple(s: &str) -> Result<NexId, ParseError> {
407 let mut result = [0u8; 16];
408
409 for (i, pair) in s.as_bytes().chunks(2).enumerate() {
410 if pair.len() != 2 {
411 return Err(ParseError::InvalidLength);
412 }
413 let high = hex_digit(pair.first().copied().ok_or(ParseError::InvalidFormat)?)?;
414 let low = hex_digit(pair.get(1).copied().ok_or(ParseError::InvalidFormat)?)?;
415 let slot = result.get_mut(i).ok_or(ParseError::InvalidFormat)?;
416 *slot = (high << 4) | low;
417 }
418
419 Ok(NexId(result))
420}
421
422const fn hex_digit(c: u8) -> Result<u8, ParseError> {
423 match c {
424 #[allow(
427 clippy::arithmetic_side_effects,
428 reason = "match arm guarantees c >= b'0' and c <= b'9', so subtraction cannot underflow"
429 )]
430 b'0'..=b'9' => Ok(c - b'0'),
431 #[allow(
433 clippy::arithmetic_side_effects,
434 reason = "match arm guarantees c in b'a'..=b'f', so c - b'a' is 0..=5 and adding 10 gives 10..=15, no overflow"
435 )]
436 b'a'..=b'f' => Ok(c - b'a' + 10),
437 #[allow(
439 clippy::arithmetic_side_effects,
440 reason = "match arm guarantees c in b'A'..=b'F', so c - b'A' is 0..=5 and adding 10 gives 10..=15, no overflow"
441 )]
442 b'A'..=b'F' => Ok(c - b'A' + 10),
443 _ => Err(ParseError::InvalidCharacter),
444 }
445}
446
447#[cfg(all(feature = "std", unix))]
449static URANDOM: std::sync::OnceLock<std::sync::Mutex<std::fs::File>> = std::sync::OnceLock::new();
450
451#[cfg(all(feature = "std", unix))]
453fn init_urandom() -> std::sync::Mutex<std::fs::File> {
454 use std::sync::Mutex;
455 let file = std::fs::File::open("/dev/urandom").unwrap_or_else(|_| {
456 std::fs::File::open("/dev/null").unwrap_or_else(|_| std::process::abort())
457 });
458 Mutex::new(file)
459}
460
461#[cfg(feature = "std")]
468fn fill_random(buf: &mut [u8]) {
469 #[cfg(unix)]
470 {
471 fill_random_unix(buf);
472 }
473
474 #[cfg(windows)]
475 {
476 fill_random_windows(buf);
477 }
478
479 #[cfg(not(any(unix, windows)))]
480 {
481 fallback_random(buf);
482 }
483}
484
485#[cfg(all(feature = "std", windows))]
504fn fill_random_windows(buf: &mut [u8]) {
505 #[link(name = "bcrypt")]
507 extern "system" {
508 fn BCryptGenRandom(
515 h_algorithm: *mut core::ffi::c_void,
516 pb_buffer: *mut u8,
517 cb_buffer: u32,
518 dw_flags: u32,
519 ) -> i32;
520 }
521
522 const BCRYPT_USE_SYSTEM_PREFERRED_RNG: u32 = 0x0000_0002;
525
526 const STATUS_SUCCESS: i32 = 0;
528
529 #[allow(
532 clippy::as_conversions,
533 reason = "buf is always 16 or 10 bytes (UUID-sized), well within u32::MAX; truncation is impossible"
534 )]
535 let buf_len = buf.len() as u32;
536
537 #[allow(unsafe_code)] let status = unsafe {
542 BCryptGenRandom(
543 core::ptr::null_mut(),
544 buf.as_mut_ptr(),
545 buf_len,
546 BCRYPT_USE_SYSTEM_PREFERRED_RNG,
547 )
548 };
549
550 if status != STATUS_SUCCESS {
551 #[cfg(debug_assertions)]
554 eprintln!(
555 "WARNING: BCryptGenRandom failed with status 0x{:08X}, using weak fallback",
556 status
557 );
558 fallback_random(buf);
559 }
560}
561
562#[cfg(all(feature = "std", unix))]
564fn fill_random_unix(buf: &mut [u8]) {
565 use std::io::Read;
566 let mutex = URANDOM.get_or_init(init_urandom);
567 let result = mutex.lock().map(|mut g| g.read_exact(buf));
568 if result.is_err() || result.is_ok_and(|r| r.is_err()) {
569 fallback_random(buf);
570 }
571}
572
573#[cfg(feature = "std")]
575#[allow(
576 clippy::cast_possible_truncation,
577 reason = "intentional: as_nanos() truncates u128 to u64 for xorshift seed (nanosecond precision, upper bits discarded); byte extraction via >> 56 then as u8 isolates the top byte deliberately"
578)]
579fn fallback_random(buf: &mut [u8]) {
580 use std::time::{SystemTime, UNIX_EPOCH};
581
582 #[allow(
585 clippy::as_conversions,
586 reason = "truncating u128 nanoseconds to u64 for xorshift PRNG seed; upper bits are discarded intentionally as the lower 64 bits provide sufficient entropy variation"
587 )]
588 let seed = SystemTime::now()
589 .duration_since(UNIX_EPOCH)
590 .map(|d| d.as_nanos() as u64)
591 .unwrap_or(0);
592
593 let mut state = seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
595
596 for byte in buf.iter_mut() {
597 state ^= state >> 12;
598 state ^= state << 25;
599 state ^= state >> 27;
600 #[allow(
602 clippy::as_conversions,
603 reason = "state >> 56 produces a value in 0..=255 (top byte of u64), so as u8 is a lossless truncation"
604 )]
605 {
606 *byte = (state.wrapping_mul(0x2545_f491_4f6c_dd1d) >> 56) as u8;
607 }
608 }
609}
610
611impl Default for NexId {
612 fn default() -> Self {
613 Self::NIL
614 }
615}
616
617impl From<[u8; 16]> for NexId {
618 fn from(bytes: [u8; 16]) -> Self {
619 Self::from_bytes(bytes)
620 }
621}
622
623impl From<NexId> for [u8; 16] {
624 fn from(id: NexId) -> Self {
625 id.0
626 }
627}
628
629impl From<u128> for NexId {
630 fn from(value: u128) -> Self {
631 Self::from_u128(value)
632 }
633}
634
635impl From<NexId> for u128 {
636 fn from(id: NexId) -> Self {
637 id.to_u128()
638 }
639}
640
641#[cfg(test)]
642mod tests {
643 use super::*;
644
645 #[test]
646 fn test_nil() {
647 assert!(NexId::NIL.is_nil());
648 assert!(!NexId::MAX.is_nil());
649 }
650
651 #[test]
652 fn test_v4_version() {
653 let id = NexId::v4();
654 assert_eq!(id.version(), 4);
655 assert_eq!(id.variant(), 2); }
657
658 #[test]
659 fn test_v7_version() {
660 let id = NexId::v7();
661 assert_eq!(id.version(), 7);
662 assert_eq!(id.variant(), 2); }
664
665 #[test]
666 fn test_v7_ordering() {
667 let id1 = NexId::v7();
668 std::thread::sleep(std::time::Duration::from_millis(2));
669 let id2 = NexId::v7();
670 assert!(id1 < id2, "v7 UUIDs should be time-ordered");
671 }
672
673 #[test]
674 fn test_parse_hyphenated() {
675 let s = "550e8400-e29b-41d4-a716-446655440000";
676 let id: NexId = s.parse().unwrap();
677 assert_eq!(id.to_string(), s);
678 }
679
680 #[test]
681 fn test_parse_simple() {
682 let s = "550e8400e29b41d4a716446655440000";
683 let id: NexId = s.parse().unwrap();
684 assert_eq!(id.to_string_simple(), s);
685 }
686
687 #[test]
688 fn test_roundtrip() {
689 let original = NexId::v4();
690 let s = original.to_string();
691 let parsed: NexId = s.parse().unwrap();
692 assert_eq!(original, parsed);
693 }
694
695 #[test]
696 fn test_u128_conversion() {
697 let value: u128 = 0x550e8400_e29b_41d4_a716_446655440000;
698 let id = NexId::from_u128(value);
699 assert_eq!(id.to_u128(), value);
700 }
701
702 #[test]
703 fn test_uniqueness() {
704 let ids: Vec<NexId> = (0..1000).map(|_| NexId::v4()).collect();
705 let mut sorted = ids.clone();
706 sorted.sort();
707 sorted.dedup();
708 assert_eq!(ids.len(), sorted.len(), "All v4 IDs should be unique");
709 }
710}
711
712#[cfg(all(test, feature = "serde"))]
714mod serde_tests {
715 use super::*;
716
717 #[test]
718 fn test_serialize_json() {
719 let id: NexId = "550e8400-e29b-41d4-a716-446655440000".parse().unwrap();
720 let json = serde_json::to_string(&id).unwrap();
721 assert_eq!(json, "\"550e8400-e29b-41d4-a716-446655440000\"");
722 }
723
724 #[test]
725 fn test_deserialize_json_hyphenated() {
726 let json = "\"550e8400-e29b-41d4-a716-446655440000\"";
727 let id: NexId = serde_json::from_str(json).unwrap();
728 assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
729 }
730
731 #[test]
732 fn test_deserialize_json_simple() {
733 let json = "\"550e8400e29b41d4a716446655440000\"";
734 let id: NexId = serde_json::from_str(json).unwrap();
735 assert_eq!(id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
736 }
737
738 #[test]
739 fn test_serde_roundtrip() {
740 let original = NexId::v4();
741 let json = serde_json::to_string(&original).unwrap();
742 let restored: NexId = serde_json::from_str(&json).unwrap();
743 assert_eq!(original, restored);
744 }
745
746 #[test]
747 fn test_deserialize_invalid() {
748 let result: Result<NexId, _> = serde_json::from_str("\"not-a-uuid\"");
749 assert!(result.is_err());
750 }
751}
752
753#[cfg(test)]
755mod nist_math {
756 fn erfc_poly(t: f64) -> f64 {
758 let c = [
759 0.170_872_77,
760 -0.822_152_23,
761 1.488_515_87,
762 -1.135_203_98,
763 0.278_868_07,
764 -0.186_288_06,
765 0.096_784_18,
766 0.374_091_96,
767 1.000_023_68,
768 -1.265_512_23,
769 ];
770 let mut result = c[0];
771 for &coef in &c[1..] {
772 result = result * t + coef;
773 }
774 result
775 }
776
777 pub fn erfc(x: f64) -> f64 {
779 let t = 1.0 / (1.0 + 0.5 * x.abs());
780 let tau = t * (-x * x + erfc_poly(t)).exp();
781 if x >= 0.0 { tau } else { 2.0 - tau }
782 }
783
784 pub fn ln_gamma(x: f64) -> f64 {
786 let c = [
787 76.180_091_729_471_46,
788 -86.505_320_329_416_77,
789 24.014_098_240_830_91,
790 -1.231_739_572_450_155,
791 0.001_208_650_973_866_179,
792 -0.000_005_395_239_384_953,
793 ];
794 let y = x - 1.0;
795 let mut sum = 1.000_000_000_190_015;
796 for (i, &coef) in c.iter().enumerate() {
797 #[allow(
799 clippy::as_conversions,
800 reason = "i is 0..=5 from a fixed-length array iteration; f64 represents all integers up to 2^53 exactly"
801 )]
802 let i_f64 = i as f64;
803 sum += coef / (y + i_f64 + 1.0);
804 }
805 let t = y + 5.5;
806 0.5 * (2.0 * core::f64::consts::PI).ln() + (y + 0.5) * t.ln() - t + sum.ln()
807 }
808
809 pub fn igamc(a: f64, x: f64) -> f64 {
811 if x < 0.0 || a <= 0.0 {
812 return 1.0;
813 }
814 if x < a + 1.0 {
815 1.0 - igam_series(a, x)
816 } else {
817 igam_cf(a, x)
818 }
819 }
820
821 fn igam_series(a: f64, x: f64) -> f64 {
822 if x == 0.0 {
823 return 0.0;
824 }
825 let mut sum = 1.0 / a;
826 let mut term = sum;
827 for n in 1..200 {
828 #[allow(
830 clippy::as_conversions,
831 reason = "n is 1..200, well within f64's exact integer range of 2^53"
832 )]
833 let n_f64 = n as f64;
834 term *= x / (a + n_f64);
835 sum += term;
836 if term.abs() < sum.abs() * 1e-14 {
837 break;
838 }
839 }
840 sum * (-x + a * x.ln() - ln_gamma(a)).exp()
841 }
842
843 fn igam_cf(a: f64, x: f64) -> f64 {
844 let mut f = 1e-30_f64;
845 let mut c = 1e-30_f64;
846 for n in 1..200 {
847 let an = compute_an(n, a);
848 #[allow(
850 clippy::as_conversions,
851 reason = "n is 1..200, well within f64's exact integer range of 2^53"
852 )]
853 let n_f64 = n as f64;
854 let bn = x + n_f64 - a;
855 let d = clamp_small(1.0 / clamp_small(bn + an / f));
856 c = clamp_small(bn + an / c);
857 let delta = c * d;
858 f *= delta;
859 if (delta - 1.0).abs() < 1e-14 {
860 break;
861 }
862 }
863 (-x + a * x.ln() - ln_gamma(a)).exp() / f
864 }
865
866 fn compute_an(n: i32, a: f64) -> f64 {
867 #[allow(
869 clippy::as_conversions,
870 reason = "n is 1..200 (i32), converting to f64 is exact; all values <= 2^53"
871 )]
872 if n % 2 == 1 {
873 (n as f64 + 1.0) / 2.0
874 } else {
875 -(n as f64 / 2.0 - a)
876 }
877 }
878
879 fn clamp_small(x: f64) -> f64 {
880 if x.abs() < 1e-30 { 1e-30 } else { x }
881 }
882}
883
884#[cfg(test)]
886mod nist_sp800_22 {
887 use super::*;
888 use nist_math::{erfc, igamc};
889
890 const SAMPLE_BITS: usize = 100_000;
891 const ALPHA: f64 = 0.01;
892
893 fn collect_random_bits(n_bits: usize) -> Vec<u8> {
894 let mut bits = Vec::with_capacity(n_bits);
895 while bits.len() < n_bits {
896 let id = NexId::v4();
897 append_uuid_bits(id.as_bytes(), &mut bits, n_bits);
898 }
899 bits
900 }
901
902 fn append_uuid_bits(bytes: &[u8; 16], bits: &mut Vec<u8>, limit: usize) {
903 for (i, &byte) in bytes.iter().enumerate() {
904 append_byte_bits(byte, i, bits, limit);
905 }
906 }
907
908 fn append_byte_bits(byte: u8, byte_idx: usize, bits: &mut Vec<u8>, limit: usize) {
909 for bit_idx in 0..8 {
910 if bits.len() >= limit {
911 return;
912 }
913 let is_version = byte_idx == 6 && bit_idx >= 4;
914 let is_variant = byte_idx == 8 && bit_idx >= 6;
915 if !is_version && !is_variant {
916 bits.push((byte >> bit_idx) & 1);
917 }
918 }
919 }
920
921 #[test]
922 fn test_frequency_monobit() {
923 let bits = collect_random_bits(SAMPLE_BITS);
924 let p = frequency_monobit_pvalue(&bits);
925 assert!(p >= ALPHA, "Frequency test FAILED: p={p:.6}");
926 }
927
928 fn frequency_monobit_pvalue(bits: &[u8]) -> f64 {
929 #[allow(
931 clippy::as_conversions,
932 reason = "bits.len() <= 100_000 which is exactly representable in f64 (< 2^53)"
933 )]
934 let n = bits.len() as f64;
935 let s_n: i64 = bits
936 .iter()
937 .map(|&b| if b == 1 { 1i64 } else { -1i64 })
938 .sum();
939 #[allow(
941 clippy::as_conversions,
942 reason = "s_n is bounded by bits.len() <= 100_000, well within f64's exact integer range"
943 )]
944 let s_obs = (s_n as f64).abs() / n.sqrt();
945 erfc(s_obs / core::f64::consts::SQRT_2)
946 }
947
948 #[test]
949 fn test_frequency_block() {
950 let bits = collect_random_bits(SAMPLE_BITS);
951 let p = block_frequency_pvalue(&bits, 100);
952 assert!(p >= ALPHA, "Block frequency FAILED: p={p:.6}");
953 }
954
955 fn block_frequency_pvalue(bits: &[u8], block_size: usize) -> f64 {
956 let n_blocks = bits.len() / block_size;
957 let chi_sq = block_chi_squared(bits, block_size, n_blocks);
958 #[allow(
960 clippy::as_conversions,
961 reason = "n_blocks <= 1_000, well within f64's exact integer range of 2^53"
962 )]
963 let n_blocks_f64 = n_blocks as f64;
964 igamc(n_blocks_f64 / 2.0, chi_sq / 2.0)
965 }
966
967 fn block_chi_squared(bits: &[u8], m: usize, n: usize) -> f64 {
968 let mut chi_sq = 0.0;
969 for i in 0..n {
970 let ones: usize = bits
971 .get(i.saturating_mul(m)..i.saturating_mul(m).saturating_add(m))
972 .map(|sl| sl.iter().map(|&b| usize::from(b)).sum())
973 .unwrap_or(0);
974 #[allow(
977 clippy::as_conversions,
978 reason = "ones and m are both bounded by SAMPLE_BITS = 100_000, well within f64's exact integer range"
979 )]
980 let pi = ones as f64 / m as f64;
981 chi_sq += (pi - 0.5).powi(2);
982 }
983 #[allow(
985 clippy::as_conversions,
986 reason = "m <= SAMPLE_BITS = 100_000, well within f64's exact integer range of 2^53"
987 )]
988 {
989 chi_sq * 4.0 * m as f64
990 }
991 }
992
993 #[test]
994 fn test_runs() {
995 let bits = collect_random_bits(SAMPLE_BITS);
996 let p = runs_pvalue(&bits);
997 if let Some(pval) = p {
998 assert!(pval >= ALPHA, "Runs test FAILED: p={pval:.6}");
999 }
1000 }
1001
1002 fn runs_pvalue(bits: &[u8]) -> Option<f64> {
1003 #[allow(
1005 clippy::as_conversions,
1006 reason = "bits.len() <= 100_000, well within f64's exact integer range of 2^53"
1007 )]
1008 let n = bits.len() as f64;
1009 let ones: usize = bits.iter().map(|&b| usize::from(b)).sum();
1010 #[allow(
1012 clippy::as_conversions,
1013 reason = "ones <= bits.len() <= 100_000, well within f64's exact integer range"
1014 )]
1015 let pi = ones as f64 / n;
1016 let tau = 2.0 / n.sqrt();
1017 if (pi - 0.5).abs() >= tau {
1018 return None;
1019 }
1020 let v_obs = count_transitions(bits).saturating_add(1);
1021 let expected = 2.0 * n * pi * (1.0 - pi) + 1.0;
1022 let variance = 2.0 * n * pi * (1.0 - pi);
1023 #[allow(
1025 clippy::as_conversions,
1026 reason = "v_obs <= SAMPLE_BITS + 1 = 100_001, well within f64's exact integer range of 2^53"
1027 )]
1028 let z = (v_obs as f64 - expected).abs() / (2.0 * variance).sqrt();
1029 Some(erfc(z / core::f64::consts::SQRT_2))
1030 }
1031
1032 fn count_transitions(bits: &[u8]) -> u64 {
1033 #[allow(
1035 clippy::as_conversions,
1036 reason = "transition count is bounded by bits.len() <= 100_000, well within u64::MAX"
1037 )]
1038 {
1039 bits.windows(2).filter(|w| w[0] != w[1]).count() as u64
1040 }
1041 }
1042
1043 #[test]
1044 fn test_bit_independence() {
1045 let bits = collect_random_bits(SAMPLE_BITS);
1046 let p = independence_pvalue(&bits);
1047 assert!(p >= ALPHA, "Independence FAILED: p={p:.6}");
1048 }
1049
1050 fn independence_pvalue(bits: &[u8]) -> f64 {
1051 let counts = count_bit_pairs(bits);
1052 #[allow(
1054 clippy::as_conversions,
1055 reason = "bits.len() <= 100_000, so bits.len() - 1 <= 99_999, well within f64's exact integer range"
1056 )]
1057 let total = (bits.len() - 1) as f64;
1058 let expected = total / 4.0;
1059 let chi_sq = chi_squared_from_counts(&counts, expected);
1060 igamc(1.5, chi_sq / 2.0)
1061 }
1062
1063 fn count_bit_pairs(bits: &[u8]) -> [u64; 4] {
1064 let mut c = [0u64; 4];
1065 for w in bits.windows(2) {
1066 let idx = usize::from(w[0]) * 2 + usize::from(w[1]);
1069 if let Some(slot) = c.get_mut(idx) {
1070 *slot = slot.saturating_add(1);
1071 }
1072 }
1073 c
1074 }
1075
1076 fn chi_squared_from_counts(counts: &[u64; 4], expected: f64) -> f64 {
1077 counts
1078 .iter()
1079 .map(|&c| {
1081 #[allow(clippy::as_conversions, reason = "c is a window count bounded by SAMPLE_BITS = 100_000, well within f64's exact integer range of 2^53")]
1082 let c_f64 = c as f64;
1083 (c_f64 - expected).powi(2) / expected
1084 })
1085 .sum()
1086 }
1087}