diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9c5f254..f09e7d4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,11 +27,11 @@ jobs: run: | python -m pip install --upgrade pip pip install pytest pytest-cov - pip install -e ./Python + pip install -e "./Python[experiment]" - name: Run Python tests run: | - pytest test_sampling_methods.py test_md5_usage.py test_dl_seeding.py -v --cov=seedhash --cov-report=xml + pytest test_sampling_methods.py test_md5_usage.py test_dl_seeding.py test_correctness.py test_distributed_seeding.py -v --cov=seedhash --cov-report=xml - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 @@ -41,6 +41,30 @@ jobs: flags: python name: codecov-python + test-python-torch: + # Runs the multi-process seeding tests (torchrun, several simulated nodes, + # mp.spawn), which are skipped in the matrix above because it has no PyTorch + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install torch --index-url https://download.pytorch.org/whl/cpu + pip install -e "./Python[experiment]" + + - name: Run PyTorch seeding tests + run: | + pytest test_distributed_seeding.py test_dl_seeding.py -v + test-r: runs-on: ${{ matrix.os }} strategy: @@ -62,9 +86,15 @@ jobs: install.packages(c("R6", "digest", "testthat")) shell: Rscript {0} + - name: Install package + run: | + install.packages("R", repos = NULL, type = "source") + shell: Rscript {0} + - name: Run R tests run: | - source("R/tests/test_seedhash.R") + testthat::test_dir("R/tests/testthat", package = "seedhash", + load_package = "installed", stop_on_failure = TRUE) shell: Rscript {0} lint-python: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8881568..83ec528 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,14 +105,16 @@ We actively welcome pull requests! 3. **Install development dependencies:** ```bash - pip install -e . + pip install -e ".[experiment]" pip install pytest pytest-cov ``` -4. **Run tests:** +4. **Run tests** (the test files are in the repository root): ```bash - pytest tests/ -v + cd .. + pytest test_*.py -v ``` + The multi-process tests in `test_distributed_seeding.py` need PyTorch (`pip install torch`) and are skipped without it. ### R Development @@ -192,30 +194,31 @@ All contributions must include appropriate tests. ### Python Testing - Use `pytest` for testing -- Place tests in `Python/tests/` +- Place tests in `test_*.py` files in the repository root - Aim for high code coverage - Test edge cases and error conditions ```bash -# Run tests -pytest tests/ -v +# Run tests (from the repository root) +pytest test_*.py -v # Run with coverage -pytest tests/ --cov=seedhash --cov-report=html +pytest test_*.py --cov=seedhash --cov-report=html ``` ### R Testing - Use `testthat` for testing -- Place tests in `R/tests/` +- Place tests in `R/tests/testthat/` - Test core functionality and edge cases +- Seed values shared with Python must match the Python tests (see `test_correctness.py`) ```r -# Run tests -devtools::test() +# Run tests (from the repository root) +devtools::test("R") # Check package -devtools::check() +devtools::check("R") ``` ## Documentation diff --git a/GITHUB_INSTALL.md b/GITHUB_INSTALL.md index aa63f6f..934a503 100644 --- a/GITHUB_INSTALL.md +++ b/GITHUB_INSTALL.md @@ -164,16 +164,17 @@ library(seedhash) ### ⚠️ Important: R Integer Limitations -R uses 32-bit signed integers with these limits: -- **Minimum**: `-2,147,483,648` +R uses 32-bit signed integers, so `min_value` and `max_value` must be whole numbers between: +- **Minimum**: `-2,147,483,647` (`-2^31` itself cannot be stored as an R integer) - **Maximum**: `2,147,483,647` -- **Recommended range**: `-1e9` to `1e9` (default) +- **Default range**: `0` to `2^31 - 1`, the same as Python ```r -# ✅ This works -gen <- SeedHashGenerator$new("test", -1e9, 1e9) +# ✅ These work (the full range too) +gen <- SeedHashGenerator$new("test") +gen <- SeedHashGenerator$new("test", -(2^31 - 1), 2^31 - 1) -# ❌ This fails (values too large) +# ❌ This fails (values outside R's integer range) gen <- SeedHashGenerator$new("test", -10^33, 10^33) ``` @@ -190,7 +191,7 @@ See [R/INSTALL.md](https://github.com/melhzy/seedhash/tree/main/R/INSTALL.md) fo | Feature | Python | R | |---------|--------|---| | **Integer Range** | Unlimited ✅ | ±2.1 billion ⚠️ | -| **Default Range** | -10^9 to 10^9 | -1e9 to 1e9 | +| **Default Range** | 0 to 2^31 - 1 | 0 to 2^31 - 1 | | **Dependencies** | None (stdlib only) | R6, digest | | **Installation** | pip | pak (recommended) | | **Class System** | Native classes | R6 classes | @@ -244,10 +245,10 @@ pip install git+https://github.com/melhzy/seedhash.git#subdirectory=Python pak::pkg_install("github::melhzy/seedhash/R") ``` -### R: "Range is too large" +### R: "outside R's integer range" ```r -# Use smaller range (within ±2.1 billion) -gen <- SeedHashGenerator$new("test", -1e9, 1e9) +# Keep both bounds within ±2,147,483,647 +gen <- SeedHashGenerator$new("test", -(2^31 - 1), 2^31 - 1) ``` ### R: Package installation corrupted diff --git a/JOSS_CHECKLIST.md b/JOSS_CHECKLIST.md index b3dd3d4..29c1512 100644 --- a/JOSS_CHECKLIST.md +++ b/JOSS_CHECKLIST.md @@ -36,7 +36,7 @@ The paper.md includes all required sections: ### Testing ⚠️ -- [x] Test files exist (`test_*.py`, `test_seedhash.R`) +- [x] Test files exist (`test_*.py`, `tests/testthat/test-seedhash.R`) - [x] CI workflow created (`.github/workflows/tests.yml`) - [ ] **TODO**: Verify CI runs successfully on GitHub - [ ] **TODO**: Add test coverage badge to README diff --git a/JOSS_PREPARATION_SUMMARY.md b/JOSS_PREPARATION_SUMMARY.md index fcb8101..4039a95 100644 --- a/JOSS_PREPARATION_SUMMARY.md +++ b/JOSS_PREPARATION_SUMMARY.md @@ -110,10 +110,11 @@ I've prepared SeedHash for submission to the Journal of Open Source Software (JO 6. **Verify All Tests Run** ```bash # Python - pytest test_sampling_methods.py test_md5_usage.py test_dl_seeding.py -v + pytest test_sampling_methods.py test_md5_usage.py test_dl_seeding.py test_correctness.py test_distributed_seeding.py -v # R - Rscript R/tests/test_seedhash.R + R CMD INSTALL R + Rscript -e 'testthat::test_dir("R/tests/testthat", package = "seedhash", load_package = "installed")' ``` 7. **Add Test Coverage Badge** (optional but recommended) diff --git a/PYTHON_TO_R_GUIDE.md b/PYTHON_TO_R_GUIDE.md index e01eca4..51c23aa 100644 --- a/PYTHON_TO_R_GUIDE.md +++ b/PYTHON_TO_R_GUIDE.md @@ -1,7 +1,7 @@ # Python to R Conversion Guide for seedhash # ============================================ # Reference document for converting Python seedhash features to R -# Last Updated: October 31, 2025 +# Last Updated: September 30, 2026 ## Table of Contents 1. [Language Differences Overview](#language-differences) @@ -39,7 +39,7 @@ **Python:** ```python # Unlimited precision - any integer size works -hash_int = int(hash_hex[:8], 16) # Can be any size +hash_int = int(hash_hex, 16) % 2**32 # 0 to 2^32 - 1 min_value = -10**33 # Works fine max_value = 10**33 # Works fine range_size = max_value - min_value # No problem @@ -47,16 +47,18 @@ range_size = max_value - min_value # No problem **R - CRITICAL LIMITATIONS:** ```r -# 32-bit signed integer limit: -2,147,483,648 to 2,147,483,647 -hash_int <- strtoi(substr(hash_hex, 1, 7), base = 16) # Use 7 chars, not 8! -min_value <- -1e9 # Safe: -1,000,000,000 -max_value <- 1e9 # Safe: 1,000,000,000 -range_size <- max_value - min_value + 1 # Must be <= 2,147,483,647 +# 32-bit signed integer limit: -2,147,483,647 to 2,147,483,647 (-2^31 is NA) +# The last 8 hex digits are the digest mod 2^32; build them as a double +hash_int <- strtoi(substr(hash_hex, 25, 28), 16L) * 65536 + + strtoi(substr(hash_hex, 29, 32), 16L) +min_value <- 0 # Same defaults as Python +max_value <- 2^31 - 1 +range_size <- as.numeric(max_value) - min_value + 1 # Compute in doubles ``` **Key Conversion Rules:** -1. **Hex to Integer**: Use max 7 hex characters (28 bits) in R, not 8 -2. **Default Ranges**: Use -1e9 to 1e9 in R (not 0 to 2^31-1) +1. **Hex to Integer**: Match Python's `% 2**32` by reading the last 8 hex digits as a double; `strtoi()` on all 8 at once returns NA above 2^31 - 1 +2. **Default Ranges**: Keep Python's 0 to 2^31-1; `sample.int()` accepts ranges wider than `.Machine$integer.max` 3. **Range Validation**: Check BOTH values AND total range span 4. **Coercion**: Always validate BEFORE `as.integer()` conversion @@ -86,21 +88,22 @@ seeds = [random.randint(min_value, max_value) for _ in range(count)] **R:** ```r -set.seed(seed_number) -range_size <- as.numeric(max_value) - as.numeric(min_value) + 1 - -# CRITICAL: Validate range_size -if (range_size > .Machine$integer.max) { - stop("Range is too large. Maximum range size is ", .Machine$integer.max) -} - -random_numbers <- sample.int( - n = as.integer(range_size), - size = count, - replace = TRUE -) + min_value - 1 +# set.seed() only accepts R integers, but seed_number can be up to 2^32 - 1: +# reinterpret it as a signed 32-bit value +rng_seed <- if (seed_number >= 2^31) seed_number - 2^32 else seed_number +set.seed(rng_seed, kind = "Mersenne-Twister", normal.kind = "Inversion", + sample.kind = "Rejection") # same stream whatever RNGkind() is + +# Work in doubles: sample.int() accepts ranges of up to 2^32 - 1 values, +# but min_value + draw can overflow R's integer type +range_size <- as.numeric(max_value) - min_value + 1 +random_numbers <- as.integer( + sample.int(range_size, size = count, replace = TRUE) - 1 + min_value +) ``` +The package also restores the caller's `.Random.seed` afterwards, so `generate_seeds()` does not change the user's random stream. + --- ## 3. Class Structure Conversion @@ -141,144 +144,53 @@ class SeedHashGenerator: ### R Class Structure (R6) -**R (R/seedhash.R):** +The full implementation is in [R/R/seedhash.R](R/R/seedhash.R). Its structure: + ```r #' @export SeedHashGenerator <- R6::R6Class( "SeedHashGenerator", - - # Public fields (like Python attributes) + public = list( - #' @field input_string The input string - input_string = NULL, - - #' @field min_value Minimum value - min_value = NULL, - - #' @field max_value Maximum value - max_value = NULL, - - #' @field seed_number The integer seed - seed_number = NULL, - - #' @description Initialize (Python's __init__) - #' @param input_string The string to hash - #' @param min_value Minimum value (default: -1e9) - #' @param max_value Maximum value (default: 1e9) - initialize = function(input_string, min_value = -1e9, max_value = 1e9) { - # Validate string - if (!is.character(input_string) || length(input_string) != 1) { - stop("input_string must be a single character string") - } - - if (nchar(input_string) == 0) { - stop("input_string cannot be empty") - } - - self$input_string <- input_string - - # Validate range BEFORE converting to integer - if (!is.numeric(min_value) || !is.numeric(max_value)) { - stop("min_value and max_value must be numeric") - } - - if (min_value >= max_value) { - stop(sprintf("min_value (%.0f) must be less than max_value (%.0f)", - min_value, max_value)) - } - - # Check R's integer range limits - max_int <- 2^31 - 1 - min_int <- -2^31 - - if (min_value < min_int || min_value > max_int) { - stop(sprintf("min_value (%.0f) is outside R's integer range [%d, %d]", - min_value, min_int, max_int)) - } - - if (max_value < min_int || max_value > max_int) { - stop(sprintf("max_value (%.0f) is outside R's integer range [%d, %d]", - max_value, min_int, max_int)) - } - - # Now safe to convert - self$min_value <- as.integer(min_value) - self$max_value <- as.integer(max_value) - - # Generate seed - self$seed_number <- private$generate_seed() + # Python's __init__, with the same defaults as Python + initialize = function(input_string, min_value = 0, max_value = 2^31 - 1) { + # Validate everything BEFORE as.integer(): a single string, whole + # numbers, no NA, bounds within +/-(2^31 - 1) (as.integer(-2^31) is + # NA), min_value < max_value + ... + private$.hash <- digest::digest(enc2utf8(input_string), algo = "md5", + serialize = FALSE) + private$.seed_number <- private$hash_to_seed(private$.hash) }, - - #' @description Generate random seeds - #' @param count Number of seeds to generate - #' @return Vector of random integers - generate_seeds = function(count) { - if (!is.numeric(count) || length(count) != 1) { - stop("count must be a single numeric value") - } - - count <- as.integer(count) - - if (count <= 0) { - stop("count must be a positive integer") - } - - # Set seed - set.seed(self$seed_number) - - # Calculate range size - handle potential overflow - range_size <- as.numeric(self$max_value) - as.numeric(self$min_value) + 1 - - # Validate range size - if (range_size > .Machine$integer.max) { - stop("Range is too large. Maximum range size is ", .Machine$integer.max) - } - - # Generate random numbers - random_numbers <- sample.int( - n = as.integer(range_size), - size = count, - replace = TRUE - ) + self$min_value - 1 - - return(random_numbers) - }, - - #' @description Get MD5 hash - #' @return MD5 hash as hexadecimal string - get_hash = function() { - return(digest::digest(self$input_string, algo = "md5", serialize = FALSE)) - }, - - #' @description Print method (Python's __str__) - #' @param ... Additional arguments (unused) - print = function(...) { - cat(sprintf("SeedHashGenerator:\n")) - cat(sprintf(" Input String: '%s'\n", self$input_string)) - cat(sprintf(" Range: [%d, %d]\n", self$min_value, self$max_value)) - cat(sprintf(" Seed Number: %d\n", self$seed_number)) - cat(sprintf(" MD5 Hash: %s\n", self$get_hash())) - invisible(self) + + generate_seeds = function(count) ..., # see Random Number Generation + set_seed = function() ..., # Python's set_seed("python") + get_hash = function() private$.hash, + print = function(...) ... # format seed_number with %.0f + ), + + # Read-only fields, like Python attributes. Plain public fields could be + # reassigned without validation. Document them with @field. + active = list( + input_string = function(value) { + if (!missing(value)) stop("input_string is read-only", call. = FALSE) + private$.input_string } + # min_value, max_value and seed_number follow the same pattern ), - - # Private methods (Python's _method) + private = list( - generate_seed = function() { - # Get MD5 hash - hash_value <- digest::digest(self$input_string, algo = "md5", serialize = FALSE) - - # Convert hex to integer - # CRITICAL: Use 7 chars (28 bits) not 8 (32 bits) to avoid overflow - seed <- strtoi(substr(hash_value, 1, 7), base = 16) - - # Validate result - if (is.na(seed)) { - stop("Failed to generate valid seed from input string") - } - - return(seed) + .input_string = NULL, .min_value = NULL, .max_value = NULL, + .hash = NULL, .seed_number = NULL, + + # Python: int(hash, 16) % 2**32. The result can exceed R's integer + # range, so build it as a double from the last 8 hex digits + hash_to_seed = function(hash) { + strtoi(substr(hash, 25, 28), 16L) * 65536 + + strtoi(substr(hash, 29, 32), 16L) } + # rng_seed() maps seed_number into set.seed()'s integer range, and + # with_seed() restores the caller's RNG state after generate_seeds() ) ) ``` @@ -293,7 +205,7 @@ SeedHashGenerator <- R6::R6Class( |-----------|--------|---| | Import | `import hashlib` | `library(digest)` | | Hash string | `hashlib.md5(s.encode()).hexdigest()` | `digest::digest(s, algo="md5", serialize=FALSE)` | -| Hex to int | `int(hex_str[:8], 16)` | `strtoi(substr(hex_str, 1, 7), base=16)` ⚠️ Use 7 not 8! | +| Hash to seed | `int(hex_str, 16) % 2**32` | `strtoi(substr(hex_str, 25, 28), 16) * 65536 + strtoi(substr(hex_str, 29, 32), 16)` | ### Random Generation @@ -539,20 +451,20 @@ install.packages("seedhash") 1. **Integer Limits** ⚠️ CRITICAL - Python: Unlimited ✅ - R: ±2.1 billion ⚠️ - - Solution: Validate BEFORE conversion, use smaller defaults (-1e9 to 1e9) - - **Real Issue**: Used full range 0 to 2^31-1, caused overflow in generate_seeds() + - Solution: Validate BEFORE conversion, and do range arithmetic in doubles + - **Real Issue**: `sample.int(...) + min_value` in integers overflowed to NA at the top of the range 2. **Hex Conversion** ⚠️ CRITICAL FIX - - Python: `int(hash[:8], 16)` works - - R: `strtoi(substr(hash, 1, 7), 16)` - use 7 not 8! - - Reason: 8 hex chars = 32 bits can overflow signed int - - **Real Issue**: 8 chars caused NA values, set.seed() failed with "not a valid integer" + - Python: `int(hash, 16) % 2**32` + - R: last 8 hex digits built as a double, then mapped into signed-int range for `set.seed()` + - Reason: `strtoi()` on 8 hex digits returns NA above 2^31 - 1 + - **Real Issue**: an earlier workaround used the first 7 hex digits, which gave different seeds from Python 3. **Default Ranges** ⚠️ CRITICAL FIX - Python: Can use any range - - R: Use -1e9 to 1e9 (not 0 to 2^31-1) - - Reason: Range SPAN must also fit in integer - - **Real Issue**: Default 0 to 2^31-1 worked for initialization but failed in generate_seeds() + - R: Use the same 0 to 2^31-1 default; `sample.int()` handles spans above `.Machine$integer.max` + - Reason: Different defaults make the two entry points and the two languages disagree + - **Real Issue**: A "range too large" check made the 0 to 2^31-1 default fail in generate_seeds() 4. **Package Installation** ⚠️ IMPORTANT - Python: pip handles everything reliably @@ -610,9 +522,9 @@ When adding new Python features to R: - [ ] Test with edge cases: -2^31, 2^31-1, ranges > 2.1B **Code Implementation:** -- [ ] Use 7 hex characters (not 8) for hash-to-int conversion -- [ ] Set safe defaults: -1e9 to 1e9 (not 0 to 2^31-1) -- [ ] Add range size validation before sample.int() +- [ ] Derive the seed exactly as Python does (digest mod 2^32) and test against Python's values +- [ ] Use the same defaults as Python (0 to 2^31-1) +- [ ] Do range arithmetic in doubles before sample.int() - [ ] Provide clear error messages with sprintf() - [ ] Use R6 class structure properly (self$field, not self.field) @@ -692,10 +604,10 @@ When adding new Python features to R: ### Key Takeaways from This Project 1. **Always use pak for GitHub R installations** - saves hours of debugging -2. **7 hex characters, not 8** - prevents integer overflow in R -3. **Default to -1e9 to 1e9** - safe range that works for most use cases +2. **Match Python's seed exactly** - digest mod 2^32, built as a double, tested against Python's values +3. **Keep Python's defaults (0 to 2^31-1)** - compute in doubles instead of shrinking the range 4. **Validate before conversion** - catch errors before they become NAs -5. **Test the range span** - not just individual min/max values +5. **Do range arithmetic in doubles** - `sample.int()` accepts large ranges, integer addition overflows 6. **Separate Python/ and R/** - keeps dual-language projects organized 7. **Clear error messages** - saves user frustration 8. **Document limitations** - R users need to know about integer constraints @@ -704,12 +616,12 @@ When adding new Python features to R: | Issue | Symptom | Solution | |-------|---------|----------| -| 8 hex chars | "not a valid integer" | Use 7 chars: `substr(hash, 1, 7)` | -| Large default range | "Range is too large" | Use -1e9 to 1e9, not 0 to 2^31-1 | +| 8 hex chars via `strtoi()` | NA, "not a valid integer" | Build the last 8 hex digits as a double; map into signed range for `set.seed()` | +| 7 hex chars | Seeds differ from Python | Use the digest mod 2^32, as Python does | +| Integer overflow near 2^31-1 | NA seeds | Compute `min_value + draw` in doubles | | devtools SSL error | "cannot open URL" | Use pak instead of devtools | | Silent NA conversion | Cryptic errors later | Validate BEFORE as.integer() | -| Range overflow | sample.int() fails | Check range SPAN, not just values | -| Duplicate @field | roxygen2 warnings | Use @description for R6 fields | +| R6 fields documented with @description | Field docs attached to the next method | Use @field, also for active bindings | | Corrupted lazy-load | Package won't load | Remove pkg dir, clean install | --- @@ -823,9 +735,18 @@ if (min_value < -2^31 || min_value > 2^31 - 1) { self$min_value <- as.integer(min_value) ``` +## Fixes Applied September 30, 2026 + +These supersede Fixes #1 to #3 above: + +- **Seed matches Python**: the first 7 hex digits gave different seeds from Python. The seed is now the digest mod 2^32 (the last 8 hex digits, built as a double), so `seed_number` is identical in both languages. +- **Default range back to 0 to 2^31-1**: `sample.int()` accepts ranges wider than `.Machine$integer.max`, so the -1e9 to 1e9 workaround and the "Range is too large" check were removed. Draws are computed in doubles to avoid overflow. +- **`set_seed()`**: `seed_number` can exceed R's integer range, so it is mapped into signed-integer range before `set.seed()`. +- **RNG state**: `generate_seeds()` pins the RNG kind and restores the caller's `.Random.seed`. + --- -**Last Updated**: October 31, 2025 (Final) +**Last Updated**: September 30, 2026 **Version**: 2.0 **Status**: Production-ready with all fixes applied **Repository**: https://github.com/melhzy/seedhash diff --git a/Python/README.md b/Python/README.md index 34c4729..0c1e5ad 100644 --- a/Python/README.md +++ b/Python/README.md @@ -127,6 +127,32 @@ print(status) # Shows which frameworks were seeded status = gen.set_seed("all", deterministic=True) ``` +#### Multi-GPU and Multi-Node Training (PyTorch) + +Call `set_seed()` in every process. It seeds the CPU and every visible GPU of the process, and the code is the same for all three setups: + +| Setup | Launch | +|-------|--------| +| One GPU on one node | `python train.py` | +| Several GPUs on one node | `torchrun --standalone --nproc-per-node=4 train.py` | +| GPUs on several nodes | `torchrun --nnodes=2 --nproc-per-node=4 --node-rank= --master-addr= --master-port=29500 train.py` on each node (also under SLURM or MPI) | + +By default every process uses `seed_number`. Pass `per_rank=True` to give each process its own seed, `(seed_number + global_rank) mod 2^32`, so that ranks draw different augmentations and dropout masks: + +```python +gen = SeedHashGenerator("resnet50_run1") +gen.set_seed("torch", per_rank=True) # rank 0 still uses gen.seed_number +print(gen.rank_seed()) # this process's seed +``` + +- The global rank comes from `torch.distributed` once the process group is initialized, and otherwise from the launcher's environment: `RANK` (torchrun, DeepSpeed, Accelerate), `OMPI_COMM_WORLD_RANK` (Open MPI), `PMI_RANK` (MPICH, Intel MPI) or `SLURM_PROCID`. `LOCAL_RANK` is never used, because it repeats on every node. +- Seeds depend only on the global rank, so 8 processes give the same results on one node with 8 GPUs as on two nodes with 4 GPUs each. +- `torch.multiprocessing.spawn` sets none of these variables, so pass the rank: `gen.set_seed("torch", per_rank=True, rank=rank)`. +- `DistributedSampler` must shuffle the same way on every rank, so give it the shared seed: `DistributedSampler(dataset, seed=gen.seed_number)`. +- DistributedDataParallel copies rank 0's initial weights to every rank, so per-rank seeds do not change model initialization. With FSDP, set `sync_module_states=True` for the same effect. + +See [examples/distributed_seeding.py](examples/distributed_seeding.py) for a runnable script. + ## Features - **Unlimited integer range** (Python's arbitrary precision integers) @@ -138,6 +164,7 @@ status = gen.set_seed("all", deterministic=True) - NumPy seeding - Deterministic mode for maximum reproducibility - Seed all frameworks with one command + - Single-GPU, multi-GPU and multi-node PyTorch jobs (torchrun, SLURM, MPI) - **Fast** MD5-based hashing - **Simple API** with clear error messages @@ -152,7 +179,7 @@ status = gen.set_seed("all", deterministic=True) ## API Reference -### `set_seed(framework="torch", deterministic=True)` +### `set_seed(framework="torch", deterministic=True, per_rank=False, rank=None)` Seed the specified framework(s). @@ -164,6 +191,8 @@ Seed the specified framework(s). - `"python"` - Python's random module only - `"all"` - All available frameworks - `deterministic` (bool): Enable deterministic algorithms (default: True) +- `per_rank` (bool): Use `rank_seed(rank)` instead of `seed_number`, so each process of a distributed job gets its own seed (default: False) +- `rank` (int): Global rank for `per_rank=True`; detected automatically if omitted **Returns:** Dictionary with seeding status for each framework @@ -173,14 +202,24 @@ gen.set_seed() # PyTorch (default) gen.set_seed("tensorflow") # TensorFlow gen.set_seed("all") # All frameworks gen.set_seed("torch", deterministic=False) # PyTorch, non-deterministic +gen.set_seed("torch", per_rank=True) # One seed per distributed process ``` -### `seed_all(deterministic=True)` +### `rank_seed(rank=None)` + +Return the seed a distributed process uses with `per_rank=True`: `(seed_number + rank) mod 2^32`. The global rank is detected with `get_global_rank()` if omitted. + +### `get_global_rank()` + +Module-level function (`from seedhash import get_global_rank`) that returns this process's global rank: from `torch.distributed` if a process group is initialized, otherwise from `RANK`, `OMPI_COMM_WORLD_RANK`, `PMI_RANK` or `SLURM_PROCID`, and 0 outside a distributed job. + +### `seed_all(deterministic=True, per_rank=False, rank=None)` Convenience method to seed all available frameworks. **Parameters:** - `deterministic` (bool): Enable deterministic algorithms (default: True) +- `per_rank`, `rank`: As for `set_seed()` **Returns:** Dictionary with seeding status @@ -199,6 +238,8 @@ See [examples/deep_learning_seeding.py](examples/deep_learning_seeding.py) for c - Multi-framework experiments - Deterministic mode usage +See [examples/distributed_seeding.py](examples/distributed_seeding.py) for multi-GPU and multi-node seeding with `torchrun`. + Run examples: ```bash cd Python @@ -676,11 +717,15 @@ python examples/advanced_ml_paradigms.py ## Testing +The tests live in the repository root: + ```bash -cd Python -pytest tests/ +pip install -e "Python[experiment]" pytest +pytest test_*.py -v ``` +The multi-process tests in `test_distributed_seeding.py` need PyTorch and are skipped without it. + ## Building ```bash @@ -698,4 +743,4 @@ For converting Python features to R, see [PYTHON_TO_R_GUIDE.md](../PYTHON_TO_R_G **Version**: 0.3.0 **License**: MIT -**Python**: >=3.7 +**Python**: >=3.8 diff --git a/Python/examples/distributed_seeding.py b/Python/examples/distributed_seeding.py new file mode 100644 index 0000000..7a915a3 --- /dev/null +++ b/Python/examples/distributed_seeding.py @@ -0,0 +1,72 @@ +""" +Seed PyTorch on one GPU, several GPUs on one node, or GPUs on several nodes. + +The seeding code is the same in all three cases; only the launch changes. +Without GPUs the script runs on the CPU with the gloo backend. + + # One GPU (or the CPU) on one node + python distributed_seeding.py + + # Four GPUs on one node + torchrun --standalone --nproc-per-node=4 distributed_seeding.py + + # Two nodes with four GPUs each: run this on every node with its own + # --node-rank (0 and 1), pointing at node 0's address + torchrun --nnodes=2 --nproc-per-node=4 --node-rank=0 \\ + --master-addr= --master-port=29500 distributed_seeding.py + + # SLURM: start one torchrun per node, as above, with srun +""" + +import os + +import torch +import torch.distributed as dist +from torch.utils.data import DataLoader, TensorDataset +from torch.utils.data.distributed import DistributedSampler + +from seedhash import SeedHashGenerator, get_global_rank + + +def main(): + distributed = "WORLD_SIZE" in os.environ # set by torchrun + if torch.cuda.is_available(): + device = torch.device("cuda", int(os.environ.get("LOCAL_RANK", 0))) + torch.cuda.set_device(device) + else: + device = torch.device("cpu") + if distributed: + dist.init_process_group("nccl" if device.type == "cuda" else "gloo") + + gen = SeedHashGenerator("resnet50_imagenet_run1") + rank = get_global_rank() + + # Same seed in every process: every rank draws the same numbers + gen.set_seed("torch") + shared = torch.rand(2, device=device) + + # One seed per process, so ranks get different augmentations and + # dropout masks. Rank 0 keeps gen.seed_number. + gen.set_seed("torch", per_rank=True) + own = torch.rand(2, device=device) + + # DistributedSampler must shuffle the same way on every rank so that the + # ranks split the data without overlap: give it the shared seed + dataset = TensorDataset(torch.arange(16)) + sampler = DistributedSampler(dataset, seed=gen.seed_number) if distributed else None + loader = DataLoader(dataset, batch_size=4, sampler=sampler, shuffle=sampler is None) + first_batch = next(iter(loader))[0].tolist() + + print( + f"rank {rank} on {device}: " + f"shared seed {gen.seed_number} -> {shared.tolist()}, " + f"rank seed {gen.rank_seed()} -> {own.tolist()}, " + f"first batch {first_batch}" + ) + + if distributed: + dist.destroy_process_group() + + +if __name__ == "__main__": + main() diff --git a/Python/pyproject.toml b/Python/pyproject.toml index 654b1a9..573e4b5 100644 --- a/Python/pyproject.toml +++ b/Python/pyproject.toml @@ -17,18 +17,38 @@ classifiers = [ "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Scientific/Engineering", ] keywords = ["seed", "random", "hash", "reproducible", "deterministic"] -requires-python = ">=3.7" +requires-python = ">=3.8" dependencies = [] +[project.optional-dependencies] +# torch.use_deterministic_algorithms(..., warn_only=True) needs torch 1.11 +torch = ["torch>=1.11"] +tensorflow = ["tensorflow>=2.0.0"] +numpy = ["numpy>=1.19.0"] +pandas = ["pandas>=1.3.0"] +experiment = ["pandas>=1.3.0", "numpy>=1.19.0"] +all = [ + "torch>=1.11", + "tensorflow>=2.0.0", + "numpy>=1.19.0", + "pandas>=1.3.0", +] +dev = [ + "pytest>=6.0", + "pytest-cov>=2.12", + "black>=21.0", + "flake8>=3.9", +] + [project.urls] Homepage = "https://github.com/melhzy/seedhash" Repository = "https://github.com/melhzy/seedhash" diff --git a/Python/requirements.txt b/Python/requirements.txt index 5ae07c2..fd57b6b 100644 --- a/Python/requirements.txt +++ b/Python/requirements.txt @@ -5,7 +5,7 @@ pandas>=1.3.0 # For DataFrame-based experiment tracking and result export # Optional dependencies for deep learning framework support # Install with: pip install seedhash[torch] or pip install seedhash[all] -torch>=1.7.0 # Optional: For PyTorch seeding +torch>=1.11 # Optional: For PyTorch seeding tensorflow>=2.0.0 # Optional: For TensorFlow seeding numpy>=1.19.0 # Optional: For NumPy seeding and ML metrics diff --git a/Python/seedhash/__init__.py b/Python/seedhash/__init__.py index 1452dd2..29ffe49 100644 --- a/Python/seedhash/__init__.py +++ b/Python/seedhash/__init__.py @@ -6,12 +6,13 @@ Features: - Generate deterministic seeds from string inputs - Deep learning framework support (PyTorch, TensorFlow, NumPy) +- Seeding for single-GPU, multi-GPU and multi-node PyTorch jobs - Hierarchical seed management for systematic experiments - Multiple sampling methods (simple, stratified, cluster, systematic) - ML experiment tracking with pandas DataFrame output """ -from .core import SeedHashGenerator +from .core import SeedHashGenerator, get_global_rank from .experiment import ( SeedExperimentManager, SeedSampler, @@ -23,6 +24,7 @@ __author__ = "melhzy" __all__ = [ "SeedHashGenerator", + "get_global_rank", "SeedExperimentManager", "SeedSampler", "MLMetrics", diff --git a/Python/seedhash/core.py b/Python/seedhash/core.py index e899326..a6031fa 100644 --- a/Python/seedhash/core.py +++ b/Python/seedhash/core.py @@ -7,17 +7,19 @@ from typing import List, Optional, Literal -# Optional deep learning framework imports +# Optional deep learning framework imports. A broken install can raise more +# than ImportError (e.g. protobuf or NumPy ABI mismatches in TensorFlow), and +# that must not make seedhash itself unimportable. try: import torch TORCH_AVAILABLE = True -except ImportError: +except Exception: TORCH_AVAILABLE = False try: import tensorflow as tf TF_AVAILABLE = True -except ImportError: +except Exception: TF_AVAILABLE = False try: @@ -29,6 +31,46 @@ FrameworkType = Literal["torch", "tensorflow", "numpy", "all", "python"] +# Environment variables holding a process's global rank, checked in order: +# torchrun and torch.distributed.launch (also set by DeepSpeed and +# Accelerate), Open MPI, MPICH/Intel MPI, and SLURM. torchrun sets RANK when +# it runs under SLURM, so RANK must be checked before SLURM_PROCID. +RANK_ENV_VARS = ("RANK", "OMPI_COMM_WORLD_RANK", "PMI_RANK", "SLURM_PROCID") + + +def get_global_rank() -> int: + """Return this process's global rank in a distributed job, or 0 outside one. + + The global rank is unique across all nodes, unlike LOCAL_RANK, which + repeats on every node. It is read from torch.distributed when a process + group is initialized, otherwise from the first variable in RANK_ENV_VARS + that is set. + + Returns: + The global rank, or 0 if no distributed job is detected. + + Raises: + ValueError: If a rank environment variable is not a non-negative integer. + """ + dist = getattr(torch, "distributed", None) if TORCH_AVAILABLE else None + if dist is not None and dist.is_available() and dist.is_initialized(): + return dist.get_rank() + + for name in RANK_ENV_VARS: + value = os.environ.get(name, "").strip() + if value: + try: + rank = int(value) + except ValueError: + rank = -1 + if rank < 0: + raise ValueError( + f"Environment variable {name}={value!r} is not a valid rank" + ) + return rank + + return 0 + class SeedHashGenerator: """Generate deterministic random seeds from string input using MD5 hashing. @@ -105,13 +147,46 @@ def _generate_seed(self) -> int: # Use modulo 2^32 to ensure compatibility with all frameworks return int(hashed_value, 16) % (2**32) + def rank_seed(self, rank: Optional[int] = None) -> int: + """Return the seed for one process of a distributed job. + + The seed is (seed_number + rank) mod 2^32, so rank 0, like any + non-distributed run, uses seed_number itself, and every process of a + job with fewer than 2^32 processes gets a different seed. + + Args: + rank: Global rank of the process (not LOCAL_RANK, which repeats + on every node). Detected with get_global_rank() if None. + + Returns: + The seed for that rank, in [0, 2^32 - 1]. + + Raises: + TypeError: If rank is not an integer. + ValueError: If rank is negative. + """ + if rank is None: + rank = get_global_rank() + elif isinstance(rank, bool) or not isinstance(rank, int): + raise TypeError("rank must be an integer") + elif rank < 0: + raise ValueError(f"rank must be non-negative, got {rank}") + + return (self.seed_number + rank) % (2**32) + def set_seed( self, framework: FrameworkType = "torch", - deterministic: bool = True + deterministic: bool = True, + per_rank: bool = False, + rank: Optional[int] = None ) -> dict: """Set random seeds for specified deep learning framework(s). + Works the same on one GPU, several GPUs on one node and GPUs across + several nodes: call it in every process. All visible GPUs of a process + are seeded, so it does not matter which device is current. + Args: framework: Which framework to seed. Options: - "torch": Seed PyTorch (default) @@ -122,18 +197,30 @@ def set_seed( deterministic: If True, enable deterministic algorithms (PyTorch only). For PyTorch, sets torch.use_deterministic_algorithms(True) and CUBLAS environment variables for reproducibility. + per_rank: If False (default), every process of a distributed job + uses seed_number. If True, each process uses rank_seed(rank), so + ranks draw different random numbers, e.g. for data augmentation + and dropout. DistributedDataParallel copies rank 0's initial + weights to all ranks either way; FSDP needs + sync_module_states=True for that. + rank: Global rank to use with per_rank=True. Detected from + torch.distributed or the launcher's environment (torchrun, + SLURM, MPI) if None. Pass it when those are not set, e.g. in a + function started by torch.multiprocessing.spawn. Returns: A dictionary with the status of seeding for each framework. Raises: ImportError: If the specified framework is not installed. - ValueError: If an invalid framework name is provided. + ValueError: If an invalid framework name is provided, or rank is + given without per_rank=True. Example: >>> gen = SeedHashGenerator("experiment_1") >>> gen.set_seed("torch") # Seed PyTorch >>> gen.set_seed("all") # Seed all available frameworks + >>> gen.set_seed("torch", per_rank=True) # One seed per process """ valid_frameworks = ["torch", "tensorflow", "numpy", "all", "python"] if framework not in valid_frameworks: @@ -142,6 +229,11 @@ def set_seed( f"Must be one of: {valid_frameworks}" ) + if rank is not None and not per_rank: + raise ValueError("rank is only used with per_rank=True") + + seed = self.rank_seed(rank) if per_rank else self.seed_number + status = {} frameworks_to_seed = [] @@ -155,13 +247,13 @@ def set_seed( # Seed Python's random module (always) if "python" in frameworks_to_seed: - random.seed(self.seed_number) + random.seed(seed) status["python"] = "seeded" # Seed NumPy if "numpy" in frameworks_to_seed: if NUMPY_AVAILABLE: - np.random.seed(self.seed_number) + np.random.seed(seed) status["numpy"] = "seeded" elif framework == "numpy": raise ImportError( @@ -173,12 +265,12 @@ def set_seed( # Seed PyTorch if "torch" in frameworks_to_seed: if TORCH_AVAILABLE: - torch.manual_seed(self.seed_number) + torch.manual_seed(seed) - # Seed CUDA if available + # Seed every visible GPU, not just the current device, so + # the result does not depend on torch.cuda.set_device() if torch.cuda.is_available(): - torch.cuda.manual_seed(self.seed_number) - torch.cuda.manual_seed_all(self.seed_number) + torch.cuda.manual_seed_all(seed) # Enable deterministic mode if requested if deterministic: @@ -189,8 +281,9 @@ def set_seed( torch.backends.cudnn.deterministic = True torch.backends.cudnn.benchmark = False - # Set environment variables for CUBLAS - os.environ['CUBLAS_WORKSPACE_CONFIG'] = ':4096:8' + # Set environment variables for CUBLAS, keeping any + # workspace config the user already chose + os.environ.setdefault('CUBLAS_WORKSPACE_CONFIG', ':4096:8') status["torch"] = "seeded" + ("_deterministic" if deterministic else "") elif framework == "torch": @@ -203,12 +296,16 @@ def set_seed( # Seed TensorFlow if "tensorflow" in frameworks_to_seed: if TF_AVAILABLE: - tf.random.set_seed(self.seed_number) + tf.random.set_seed(seed) - # Set deterministic operations if requested + # Set deterministic operations if requested. TF 2.8+ ignores + # TF_DETERMINISTIC_OPS in favour of enable_op_determinism(). if deterministic: - os.environ['TF_DETERMINISTIC_OPS'] = '1' - os.environ['TF_CUDNN_DETERMINISTIC'] = '1' + if hasattr(tf.config.experimental, "enable_op_determinism"): + tf.config.experimental.enable_op_determinism() + else: + os.environ['TF_DETERMINISTIC_OPS'] = '1' + os.environ['TF_CUDNN_DETERMINISTIC'] = '1' status["tensorflow"] = "seeded" + ("_deterministic" if deterministic else "") elif framework == "tensorflow": @@ -220,11 +317,19 @@ def set_seed( return status - def seed_all(self, deterministic: bool = True) -> dict: + def seed_all( + self, + deterministic: bool = True, + per_rank: bool = False, + rank: Optional[int] = None + ) -> dict: """Convenience method to seed all available frameworks. Args: deterministic: If True, enable deterministic algorithms where applicable. + per_rank: If True, use a different seed in each process of a + distributed job (see set_seed). + rank: Global rank to use with per_rank=True (see set_seed). Returns: A dictionary with the status of seeding for each framework. @@ -234,7 +339,9 @@ def seed_all(self, deterministic: bool = True) -> dict: >>> status = gen.seed_all() >>> print(status) """ - return self.set_seed("all", deterministic=deterministic) + return self.set_seed( + "all", deterministic=deterministic, per_rank=per_rank, rank=rank + ) def generate_seeds(self, count: int) -> List[int]: """Generate a list of random seed numbers. @@ -255,12 +362,13 @@ def generate_seeds(self, count: int) -> List[int]: if count <= 0: raise ValueError("count must be a positive integer") - # Set the random seed - random.seed(self.seed_number) + # Use a private generator so the caller's global random state is + # untouched; it yields the same numbers random.seed() + randint() did + rng = random.Random(self.seed_number) # Generate random numbers random_numbers = [ - random.randint(self.min_value, self.max_value) + rng.randint(self.min_value, self.max_value) for _ in range(count) ] diff --git a/Python/seedhash/experiment.py b/Python/seedhash/experiment.py index 1ed4469..001c482 100644 --- a/Python/seedhash/experiment.py +++ b/Python/seedhash/experiment.py @@ -8,15 +8,13 @@ from datetime import datetime import warnings +# pandas is only needed for DataFrame output; methods that need it warn or +# raise when called, so a core-only install imports without warnings try: import pandas as pd PANDAS_AVAILABLE = True except ImportError: PANDAS_AVAILABLE = False - warnings.warn( - "pandas is not installed. Install with: pip install pandas\n" - "Experiment tracking requires pandas for DataFrame output." - ) try: import numpy as np @@ -119,11 +117,23 @@ def stratified_random_sampling( n_strata: Number of strata (divisions) in the seed space. Returns: - List of stratified seeds with balanced coverage. + List of stratified seeds with balanced coverage. When n_samples is + not a multiple of n_strata, the extra samples go to the lowest strata. + Strata are (max - min) // n_strata wide, so the few values left over + at the top of the range are never drawn; changing that would change + the seeds this method has always produced. + + Raises: + ValueError: If max - min of seed_range is less than n_strata. """ self.rng.seed(self.master_seed) min_seed, max_seed = seed_range + if max_seed - min_seed < n_strata: + raise ValueError( + f"seed_range {seed_range} is too narrow for n_strata={n_strata}; " + f"max - min must be at least n_strata" + ) stratum_size = (max_seed - min_seed) // n_strata samples_per_stratum = n_samples // n_strata remainder = n_samples % n_strata @@ -212,25 +222,26 @@ def systematic_random_sampling( Returns: List of systematically sampled seeds at regular intervals. + + Raises: + ValueError: If n_samples is less than 1 or greater than max - min + of seed_range (the interval would be 0, repeating one seed). """ self.rng.seed(self.master_seed) min_seed, max_seed = seed_range + if not 1 <= n_samples <= max_seed - min_seed: + raise ValueError( + f"n_samples must be between 1 and max - min of seed_range " + f"({max_seed - min_seed}), got {n_samples}" + ) interval = (max_seed - min_seed) // n_samples - # Random starting point within first interval + # Random starting point within first interval. The last seed is at + # most min_seed + n_samples * interval <= max_seed, so none wrap. start = self.rng.randint(min_seed, min(min_seed + interval, max_seed)) - seeds = [] - for i in range(n_samples): - seed = start + (i * interval) - if seed <= max_seed: - seeds.append(seed) - else: - # Wrap around if needed - seeds.append(min_seed + (seed - max_seed)) - - return seeds + return [start + (i * interval) for i in range(n_samples)] class SeedExperimentManager: @@ -313,18 +324,19 @@ def generate_seed_hierarchy( child_seeds = sampling_func_local(n_samples, seed_range) current_level_seeds.extend(child_seeds) - # Track hierarchy - if parent_seed not in self._seed_hierarchy: - self._seed_hierarchy[parent_seed] = {'children': [], 'level': depth - 1} - - # Ensure 'children' key exists - if 'children' not in self._seed_hierarchy[parent_seed]: - self._seed_hierarchy[parent_seed]['children'] = [] - - self._seed_hierarchy[parent_seed]['children'].extend(child_seeds) + # Track hierarchy. Nodes are keyed by seed value, so a seed that + # occurs more than once keeps the record from its first + # occurrence; overwriting it would rewrite its parent and could + # make a seed its own ancestor. + parent_node = self._seed_hierarchy.setdefault( + parent_seed, {'children': [], 'level': depth - 1} + ) + parent_node['children'].extend(child_seeds) for child in child_seeds: - self._seed_hierarchy[child] = {'parent': parent_seed, 'level': depth} + self._seed_hierarchy.setdefault( + child, {'parent': parent_seed, 'children': [], 'level': depth} + ) hierarchy[depth] = current_level_seeds @@ -347,11 +359,16 @@ def add_experiment_result( sampling_method: Sampling method used to generate this seed. metadata: Optional additional information. """ - # Reconstruct seed hierarchy + # Reconstruct seed hierarchy, stopping if a seed repeats so that a + # cyclic parent chain cannot loop forever hierarchy = [seed] + visited = {seed} current = seed while current in self._seed_hierarchy and 'parent' in self._seed_hierarchy[current]: parent = self._seed_hierarchy[current]['parent'] + if parent in visited: + break + visited.add(parent) hierarchy.insert(0, parent) current = parent @@ -368,8 +385,9 @@ def add_experiment_result( seed_level=seed_level, sampling_method=sampling_method, ml_task=ml_task, - metrics=metrics, - metadata=metadata or {} + # Copy so later changes to the caller's dicts don't alter this result + metrics=dict(metrics), + metadata=dict(metadata or {}) ) self.results.append(result) @@ -461,6 +479,22 @@ def export_results(self, filepath: str, format: str = 'csv') -> None: raise ValueError(f"Unsupported format: {format}") +def _paired_arrays(y_true, y_pred): + """Flatten y_true and y_pred, raising if they hold different numbers of values. + + Without this, shapes such as (n,) and (n, 1) broadcast to an (n, n) array + and every metric is silently computed over all cross pairs. + """ + y_true = np.ravel(np.asarray(y_true)) + y_pred = np.ravel(np.asarray(y_pred)) + if y_true.shape != y_pred.shape: + raise ValueError( + f"y_true and y_pred must have the same number of values, " + f"got {y_true.size} and {y_pred.size}" + ) + return y_true, y_pred + + class MLMetrics: """Common ML evaluation metrics for different task types.""" @@ -478,8 +512,7 @@ def regression_metrics(y_true, y_pred) -> Dict[str, float]: if not NUMPY_AVAILABLE: raise ImportError("numpy is required for metric calculation") - y_true = np.array(y_true) - y_pred = np.array(y_pred) + y_true, y_pred = _paired_arrays(y_true, y_pred) mse = np.mean((y_true - y_pred) ** 2) rmse = np.sqrt(mse) @@ -501,39 +534,44 @@ def regression_metrics(y_true, y_pred) -> Dict[str, float]: } @staticmethod - def classification_metrics(y_true, y_pred, y_prob=None) -> Dict[str, float]: + def classification_metrics(y_true, y_pred, y_prob=None, pos_label=None) -> Dict[str, float]: """Calculate classification metrics. Args: y_true: True labels. y_pred: Predicted labels. y_prob: Predicted probabilities (optional, for AUC). + pos_label: Positive class for binary tasks. Defaults to 1 when it is + one of the two labels, otherwise to the larger label. Returns: - Dictionary with accuracy, precision, recall, F1. + Dictionary with accuracy, precision, recall, F1. Multi-class tasks + use macro averages (F1 is the mean of the per-class F1 scores). """ if not NUMPY_AVAILABLE: raise ImportError("numpy is required for metric calculation") - y_true = np.array(y_true) - y_pred = np.array(y_pred) + y_true, y_pred = _paired_arrays(y_true, y_pred) # Accuracy accuracy = np.mean(y_true == y_pred) + classes = np.unique(y_true) + # For binary classification - if len(np.unique(y_true)) == 2: - tp = np.sum((y_true == 1) & (y_pred == 1)) - fp = np.sum((y_true == 0) & (y_pred == 1)) - fn = np.sum((y_true == 1) & (y_pred == 0)) + if len(classes) == 2: + if pos_label is None: + pos_label = 1 if 1 in classes.tolist() else classes[-1] + tp = np.sum((y_true == pos_label) & (y_pred == pos_label)) + fp = np.sum((y_true != pos_label) & (y_pred == pos_label)) + fn = np.sum((y_true == pos_label) & (y_pred != pos_label)) precision = tp / (tp + fp) if (tp + fp) > 0 else 0 recall = tp / (tp + fn) if (tp + fn) > 0 else 0 f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0 else: # Multi-class: use macro average - classes = np.unique(y_true) - precisions, recalls = [], [] + precisions, recalls, f1s = [], [], [] for cls in classes: tp = np.sum((y_true == cls) & (y_pred == cls)) @@ -545,10 +583,11 @@ def classification_metrics(y_true, y_pred, y_prob=None) -> Dict[str, float]: precisions.append(prec) recalls.append(rec) + f1s.append(2 * prec * rec / (prec + rec) if (prec + rec) > 0 else 0) precision = np.mean(precisions) recall = np.mean(recalls) - f1 = 2 * (precision * recall) / (precision + recall) if (precision + recall) > 0 else 0 + f1 = np.mean(f1s) return { 'accuracy': float(accuracy), @@ -566,51 +605,52 @@ def clustering_metrics(X, labels) -> Dict[str, float]: labels: Cluster labels. Returns: - Dictionary with silhouette score and Davies-Bouldin index. + Dictionary with silhouette score, number of clusters and number of + samples. The silhouette is 0.0 unless there are between 2 and + n_samples - 1 clusters. """ if not NUMPY_AVAILABLE: raise ImportError("numpy is required for metric calculation") - X = np.array(X) - labels = np.array(labels) + X = np.asarray(X, dtype=float) + if X.ndim == 1: + X = X.reshape(-1, 1) + labels = np.ravel(np.asarray(labels)) + if len(labels) != len(X): + raise ValueError( + f"X and labels must have the same number of samples, " + f"got {len(X)} and {len(labels)}" + ) - # Simplified silhouette score calculation - n_clusters = len(np.unique(labels)) + clusters = np.unique(labels) + n_clusters = len(clusters) + metrics = { + 'silhouette': 0.0, + 'n_clusters': int(n_clusters), + 'n_samples': int(len(X)) + } if n_clusters < 2 or n_clusters >= len(X): - return {'silhouette': 0.0, 'davies_bouldin': float('inf')} - - # Calculate intra-cluster distances (a) - a_values = [] - for i in range(len(X)): - same_cluster = X[labels == labels[i]] - if len(same_cluster) > 1: - a = np.mean([np.linalg.norm(X[i] - x) for x in same_cluster if not np.array_equal(X[i], x)]) - else: - a = 0 - a_values.append(a) + return metrics - # Calculate nearest-cluster distances (b) - b_values = [] + scores = [] for i in range(len(X)): - min_b = float('inf') - for cluster in np.unique(labels): - if cluster != labels[i]: - other_cluster = X[labels == cluster] - b = np.mean([np.linalg.norm(X[i] - x) for x in other_cluster]) - min_b = min(min_b, b) - b_values.append(min_b) - - # Silhouette score - silhouette = np.mean([ - (b - a) / max(a, b) if max(a, b) > 0 else 0 - for a, b in zip(a_values, b_values) - ]) + distances = np.linalg.norm(X - X[i], axis=1) + + # Exclude the point itself by index, not by value, so identical + # points in the same cluster still count + same_cluster = labels == labels[i] + same_cluster[i] = False + if not same_cluster.any(): + # A point alone in its cluster scores 0 (as in sklearn) + scores.append(0.0) + continue + + a = distances[same_cluster].mean() + b = min(distances[labels == c].mean() for c in clusters if c != labels[i]) + scores.append((b - a) / max(a, b) if max(a, b) > 0 else 0.0) - return { - 'silhouette': float(silhouette), - 'n_clusters': int(n_clusters), - 'n_samples': int(len(X)) - } + metrics['silhouette'] = float(np.mean(scores)) + return metrics @staticmethod def semi_supervised_metrics( @@ -718,13 +758,16 @@ def reinforcement_learning_metrics( # Convergence indicators if len(episode_rewards) >= 10: - # Recent performance (last 20%) - recent_window = max(10, len(episode_rewards) // 5) + # Recent performance (last 20%, at least 10 episodes), capped at + # half the episodes so the early and recent windows never overlap + recent_window = min(max(10, len(episode_rewards) // 5), len(episode_rewards) // 2) recent_rewards = episode_rewards[-recent_window:] + early_mean = np.mean(episode_rewards[:recent_window]) metrics['recent_mean_reward'] = float(np.mean(recent_rewards)) + # Divide by the magnitude so that rising negative rewards + # (e.g. -100 to -50) count as improvement metrics['improvement_rate'] = float( - (np.mean(recent_rewards) - np.mean(episode_rewards[:recent_window])) / - (np.mean(episode_rewards[:recent_window]) + 1e-8) + (np.mean(recent_rewards) - early_mean) / (abs(early_mean) + 1e-8) ) return metrics diff --git a/Python/setup.py b/Python/setup.py index a59b262..a8471d7 100644 --- a/Python/setup.py +++ b/Python/setup.py @@ -23,26 +23,26 @@ "Intended Audience :: Science/Research", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Libraries :: Python Modules", "Topic :: Scientific/Engineering", ], - python_requires=">=3.7", + python_requires=">=3.8", install_requires=[ # No required dependencies - uses only Python standard library ], extras_require={ - "torch": ["torch>=1.7.0"], + "torch": ["torch>=1.11"], "tensorflow": ["tensorflow>=2.0.0"], "numpy": ["numpy>=1.19.0"], "pandas": ["pandas>=1.3.0"], "experiment": ["pandas>=1.3.0", "numpy>=1.19.0"], # For experiment management "all": [ - "torch>=1.7.0", + "torch>=1.11", "tensorflow>=2.0.0", "numpy>=1.19.0", "pandas>=1.3.0", diff --git a/R/DESCRIPTION b/R/DESCRIPTION index bd1f37d..6cbfa22 100644 --- a/R/DESCRIPTION +++ b/R/DESCRIPTION @@ -13,10 +13,13 @@ Description: Provides a simple way to generate reproducible random seeds from License: MIT + file LICENSE Encoding: UTF-8 LazyData: true -Depends: R (>= 3.5.0) +Depends: R (>= 3.6.0) Imports: digest, R6 -RoxygenNote: 7.3.3 +Suggests: + testthat (>= 3.0.0) +Config/testthat/edition: 3 URL: https://github.com/melhzy/seedhash BugReports: https://github.com/melhzy/seedhash/issues +Config/roxygen2/version: 8.1.0 diff --git a/R/INSTALL.md b/R/INSTALL.md index fa3ae74..7094161 100644 --- a/R/INSTALL.md +++ b/R/INSTALL.md @@ -4,7 +4,7 @@ Before installing the seedhash R package, ensure you have: -1. **R** (version 3.5.0 or higher) +1. **R** (version 3.6.0 or higher) 2. **Required packages**: `R6`, `digest` (installed automatically) ## Method 1: Install from GitHub using pak ✅ RECOMMENDED @@ -133,10 +133,8 @@ print(seeds) To run the test suite: ```r -# If using source files directly -source("d:/Github/seedhash/R/tests/test_seedhash.R") - -# Tests will run automatically and show results +# From the repository root, after installing the package +testthat::test_dir("R/tests/testthat", package = "seedhash", load_package = "installed") ``` ## Running Examples @@ -211,7 +209,7 @@ After successful installation: 1. Read the [README.md](README.md) for usage examples 2. Check out [example_usage.R](examples/example_usage.R) for comprehensive examples -3. Run the test suite in [test_seedhash.R](tests/test_seedhash.R) +3. Run the test suite in [test-seedhash.R](tests/testthat/test-seedhash.R) 4. Read the documentation: `?SeedHashGenerator` ## Support diff --git a/R/INTEGER_RANGE_SOLUTION.md b/R/INTEGER_RANGE_SOLUTION.md index 1244bbe..d2cf95c 100644 --- a/R/INTEGER_RANGE_SOLUTION.md +++ b/R/INTEGER_RANGE_SOLUTION.md @@ -10,7 +10,7 @@ generator <- SeedHashGenerator$new( ) ``` -**Error**: `min_value (10^33) is outside R's integer range` +**Error**: `min_value (-999999999999999945575230987042816) is outside R's integer range [-2147483647, 2147483647]` --- @@ -33,7 +33,7 @@ seeds <- generator$generate_seeds(5) print(seeds) ``` -**Output**: `-978425718, 888036529, 835804612, -141761863, 515662747` +**Output**: `291580102, 166267505, 964507646, -491586732, 526158187` --- @@ -41,9 +41,9 @@ print(seeds) ### The Problem R uses **32-bit integers** with these limits: -- **Minimum**: `-2,147,483,648` (-2^31) +- **Minimum**: `-2,147,483,647` (-(2^31 - 1); -2^31 is NA in R) - **Maximum**: `2,147,483,647` (2^31 - 1) -- **Maximum range span**: `2,147,483,647` (can't span full min to max) +- **Maximum range span**: the full range, `-(2^31 - 1)` to `2^31 - 1` Your value `10^33` = **1,000,000,000,000,000,000,000,000,000,000,000** is way too large! @@ -58,6 +58,7 @@ Your value `10^33` = **1,000,000,000,000,000,000,000,000,000,000,000** is way to | **Large range** | -1e9 | 1e9 | ✅ Works | | **Scientific range** | -1e7 | 1e7 | ✅ Works | | **Default range** | 0 | 2^31-1 | ✅ Works | +| **Full range** | -(2^31-1) | 2^31-1 | ✅ Works | --- @@ -111,7 +112,7 @@ seeds <- generator$generate_seeds(5) ## 🔍 Understanding the Limits -### Why Can't We Use -2^31 to 2^31-1? +### Why Can't We Use -2^31? ```r # This fails: @@ -120,19 +121,13 @@ generator <- SeedHashGenerator$new( min_value = -2^31, # -2,147,483,648 max_value = 2^31 - 1 # 2,147,483,647 ) -# Error: Range is too large. Maximum range size is 2147483647 -``` - -**Reason**: The **span** (-2^31 to 2^31-1) is approximately 4.3 billion, but R's `sample.int()` can only handle ranges up to 2.1 billion. +# Error: min_value (-2147483648) is outside R's integer range [-2147483647, 2147483647] -### Maximum Safe Range Span - -The span (max - min + 1) must be ≤ 2,147,483,647 +# This works: the full range R can store +generator <- SeedHashGenerator$new("test", -(2^31 - 1), 2^31 - 1) +``` -**Examples**: -- -1e9 to 1e9: span = 2,000,000,001 ✅ -- 0 to 2^31-1: span = 2,147,483,647 ✅ -- -2^31 to 2^31-1: span ≈ 4.3e9 ❌ +**Reason**: R reserves the bit pattern of -2^31 for `NA_integer_`, so `as.integer(-2^31)` is `NA`. Any range inside ±(2^31 - 1) works, including spans of about 4.3 billion values. --- @@ -183,9 +178,9 @@ min_value = -10^33 # Way too large! max_value = 10^33 # Won't work in R # 📊 LIMITS -# Min: -2,147,483,648 +# Min: -2,147,483,647 # Max: 2,147,483,647 -# Span: ≤ 2,147,483,647 +# Span: up to the full range ``` --- diff --git a/R/NAMESPACE b/R/NAMESPACE index 9451dee..9b01857 100644 --- a/R/NAMESPACE +++ b/R/NAMESPACE @@ -2,3 +2,5 @@ export(SeedHashGenerator) export(create_seedhash) +importFrom(R6,R6Class) +importFrom(digest,digest) diff --git a/R/NEWS.md b/R/NEWS.md index f1c36e2..bae9839 100644 --- a/R/NEWS.md +++ b/R/NEWS.md @@ -1,3 +1,34 @@ +# seedhash (development version) + +## Breaking changes + +* `seed_number` is now the MD5 digest of the UTF-8 encoded input modulo 2^32, + the same value the Python package produces. It is stored as a double because + it can exceed R's integer range. Seeds from `generate_seeds()` therefore + differ from version 0.1.0. +* `SeedHashGenerator$new()` now defaults to the range 0 to 2^31 - 1, like + `create_seedhash()` and the Python package (it was -1e9 to 1e9). +* `input_string`, `min_value`, `max_value` and `seed_number` are read-only. +* Requires R >= 3.6.0. + +## New features + +* `set_seed()` seeds R's random number generator from the input string, like + Python's `set_seed("python")`. Use it instead of `set.seed(gen$seed_number)`, + which fails when `seed_number` exceeds R's integer range. + +## Bug fixes + +* `create_seedhash()` with its default range no longer fails in + `generate_seeds()`; ranges up to the full integer range now work. +* `generate_seeds()` no longer returns NA from integer overflow near + 2^31 - 1, and returns an integer vector. +* `generate_seeds()` no longer depends on `RNGkind()` and restores the caller's + random number generator state. +* Non-ASCII input is hashed as UTF-8 on every platform. +* Non-whole, NA, length > 1 and -2^31 bounds and counts are rejected with + clear errors. + # seedhash 0.1.0 ## Initial Release diff --git a/R/PYTHON_VS_R.md b/R/PYTHON_VS_R.md index 4cdf806..46a1441 100644 --- a/R/PYTHON_VS_R.md +++ b/R/PYTHON_VS_R.md @@ -240,15 +240,15 @@ tryCatch({ **Python:** - Uses unlimited precision integers -- Can handle the full MD5 hash (128 bits) +- Seed number is the MD5 digest modulo 2^32 - Seed range up to 2^31 - 1 by default **R:** - Uses 32-bit integers (limited to ~2.1 billion) -- Uses first 8 hex characters of MD5 hash +- Seed number is the MD5 digest modulo 2^32, stored as a double - Same default seed range (2^31 - 1) -**Impact:** Seeds will differ between Python and R versions because they use different portions of the MD5 hash. +**Impact:** `seed_number` is the same in both languages. Because it can exceed R's integer range, seed R's generator with `gen$set_seed()` rather than `set.seed(gen$seed_number)`. ### 2. Random Number Generation @@ -260,7 +260,7 @@ random_numbers = [random.randint(min_val, max_val) for _ in range(count)] **R:** ```r -set.seed(seed_number) +gen$set_seed() # set.seed() with seed_number mapped into R's integer range random_numbers <- sample.int(n = max_val - min_val + 1, size = count, replace = TRUE) + min_val - 1 ``` @@ -370,33 +370,27 @@ if (!is.character(input_string) || length(input_string) != 1) { ## Reproducibility Across Languages -**Important Note:** While each implementation is reproducible within its own language, the actual random numbers generated will differ between Python and R even with the same input string. This is due to: +Both implementations derive `seed_number` the same way: the MD5 digest of the UTF-8 encoded input string, modulo 2^32. The same string therefore has the same `seed_number` in Python and R. -1. Different hash-to-seed conversion (full hash vs. 8 chars) -2. Different random number generation algorithms -3. Different seed handling mechanisms +The seeds returned by `generate_seeds()` still differ between the languages, because each one draws them from its own random number generator (Python's `random.Random` and R's `sample.int()`). Each implementation is reproducible within its own language. If you need identical seed lists in both languages, export the generated seeds rather than sharing only the input string. -If you need identical results across both languages, you would need to export and share the actual generated seeds rather than just the input string. - -## Example: Same Input, Different Results +## Example: Same Seed Number, Different Seed Lists **Python:** ```python gen = SeedHashGenerator("test") -print(gen.seed_number) # Example: 1234567890123456789... -seeds = gen.generate_seeds(3) -print(seeds) # Example: [1234567, 9876543, 456789] +print(gen.seed_number) # 640136438 +seeds = gen.generate_seeds(3) # Python's own sequence ``` **R:** ```r gen <- SeedHashGenerator$new("test") -print(gen$seed_number) # Example: 12345678 (different!) -seeds <- gen$generate_seeds(3) -print(seeds) # Example: [234567, 876543, 56789] (different!) +print(gen$seed_number) # 640136438 (same as Python) +seeds <- gen$generate_seeds(3) # R's own sequence, differs from Python ``` -Both are reproducible within their language, but produce different results from each other. +R stores `seed_number` as a double because it can exceed R's integer range; it is converted to a valid `set.seed()` value internally. ## Conclusion diff --git a/R/R/seedhash.R b/R/R/seedhash.R index b922129..fc39684 100644 --- a/R/R/seedhash.R +++ b/R/R/seedhash.R @@ -4,169 +4,222 @@ #' This class allows users to generate reproducible sequences of random numbers #' by hashing an input string and using it as a seed for R's random number generator. #' +#' The seed number is the MD5 digest of the UTF-8 encoded input string modulo +#' 2^32, the same algorithm the Python package uses, so a given string has the +#' same \code{seed_number} in both languages. The seeds returned by +#' \code{generate_seeds()} come from R's own random number generator and +#' therefore differ from the ones the Python package returns. +#' +#' \code{generate_seeds()} always uses the Mersenne-Twister generator with the +#' "Rejection" sampler, whatever \code{RNGkind()} is set to, and restores the +#' caller's random number generator state before returning. +#' #' @examples -#' \donttest{ #' # Create a generator with default range #' gen <- SeedHashGenerator$new("Shalini") -#' +#' #' # Generate 10 random seeds #' seeds <- gen$generate_seeds(10) #' print(seeds) -#' +#' #' # Create a generator with custom range #' gen_custom <- SeedHashGenerator$new("MyProject", min_value = 1, max_value = 100) #' seeds_custom <- gen_custom$generate_seeds(5) #' print(seeds_custom) -#' +#' #' # Get the MD5 hash #' hash_value <- gen$get_hash() #' print(hash_value) -#' } #' +#' @importFrom R6 R6Class +#' @importFrom digest digest #' @export SeedHashGenerator <- R6::R6Class( "SeedHashGenerator", - + public = list( - #' @description The input string for seed generation - input_string = NULL, - - #' @description Minimum value for random number range - min_value = NULL, - - #' @description Maximum value for random number range - max_value = NULL, - - #' @description The integer seed derived from hashing - seed_number = NULL, - #' @description #' Initialize the SeedHashGenerator #' @param input_string The string to hash for seed generation - #' @param min_value Minimum value for random number range (default: -1e9) - #' @param max_value Maximum value for random number range (default: 1e9) + #' @param min_value Minimum value for random number range, inclusive (default: 0) + #' @param max_value Maximum value for random number range, inclusive (default: 2^31 - 1) #' @return A new SeedHashGenerator object - initialize = function(input_string, - min_value = -1e9, - max_value = 1e9) { + initialize = function(input_string, + min_value = 0, + max_value = 2^31 - 1) { # Validate input_string - if (!is.character(input_string) || length(input_string) != 1) { + if (!is.character(input_string) || length(input_string) != 1 || + is.na(input_string)) { stop("input_string must be a single character string") } - + if (nchar(input_string) == 0) { stop("input_string cannot be empty") } - - self$input_string <- input_string - + # Validate range BEFORE converting to integer - if (!is.numeric(min_value) || !is.numeric(max_value)) { - stop("min_value and max_value must be numeric") - } - + private$check_bound(min_value, "min_value") + private$check_bound(max_value, "max_value") + if (min_value >= max_value) { stop(sprintf("min_value (%.0f) must be less than max_value (%.0f)", - min_value, max_value)) - } - - # Check if values are within R's integer range - max_int <- 2^31 - 1 - min_int <- -2^31 - - if (min_value < min_int || min_value > max_int) { - stop(sprintf("min_value (%.0f) is outside R's integer range [%d, %d]. Use values between -2,147,483,648 and 2,147,483,647", - min_value, min_int, max_int)) - } - - if (max_value < min_int || max_value > max_int) { - stop(sprintf("max_value (%.0f) is outside R's integer range [%d, %d]. Use values between -2,147,483,648 and 2,147,483,647", - max_value, min_int, max_int)) + min_value, max_value)) } - - # Now safe to convert to integer - self$min_value <- as.integer(min_value) - self$max_value <- as.integer(max_value) - - # Generate the seed from the input string - self$seed_number <- private$generate_seed() + + private$.input_string <- input_string + private$.min_value <- as.integer(min_value) + private$.max_value <- as.integer(max_value) + + # Hash the UTF-8 bytes, as Python's input_string.encode('utf-8') does + private$.hash <- digest::digest(enc2utf8(input_string), algo = "md5", + serialize = FALSE) + private$.seed_number <- private$hash_to_seed(private$.hash) }, - + #' @description #' Generate a list of random seed numbers #' @param count The number of random seeds to generate - #' @return A vector of random integers within the specified range + #' @return An integer vector of random seeds within the specified range generate_seeds = function(count) { - if (!is.numeric(count) || length(count) != 1) { - stop("count must be a single numeric value") + if (!is.numeric(count) || length(count) != 1 || !is.finite(count) || + count != round(count) || count <= 0) { + stop("count must be a single positive integer") } - - count <- as.integer(count) - - if (count <= 0) { - stop("count must be a positive integer") - } - - # Set the random seed - set.seed(self$seed_number) - - # Generate random numbers - # Calculate range size - handle potential integer overflow - range_size <- as.numeric(self$max_value) - as.numeric(self$min_value) + 1 - - # Check if range is too large for sample.int - if (range_size > .Machine$integer.max) { - stop("Range is too large. Maximum range size is ", .Machine$integer.max) + + if (count > .Machine$integer.max) { + stop("count must be at most ", .Machine$integer.max) } - - random_numbers <- sample.int( - n = as.integer(range_size), - size = count, - replace = TRUE - ) + self$min_value - 1 - - return(random_numbers) + + private$with_seed({ + # Work in doubles: the range can hold up to 2^32 - 1 values, and + # min_value + draw can overflow R's integer type + range_size <- as.numeric(private$.max_value) - private$.min_value + 1 + draws <- sample.int(range_size, size = count, replace = TRUE) + as.integer(draws - 1 + private$.min_value) + }) }, - + + #' @description + #' Seed R's random number generator from the input string, the R + #' counterpart of Python's \code{set_seed("python")}. Use this instead of + #' \code{set.seed(seed_number)}: \code{seed_number} can exceed R's integer + #' range, which \code{set.seed()} rejects, so it is first reinterpreted as + #' a signed 32-bit integer. The current \code{RNGkind()} is kept. + #' @return The generator, invisibly + set_seed = function() { + set.seed(private$rng_seed()) + invisible(self) + }, + #' @description #' Get the MD5 hash of the input string #' @return The MD5 hash as a hexadecimal string get_hash = function() { - return(digest::digest(self$input_string, algo = "md5", serialize = FALSE)) + return(private$.hash) }, - + #' @description #' Print method for SeedHashGenerator #' @param ... Additional arguments (unused) print = function(...) { - cat(sprintf("SeedHashGenerator:\n")) - cat(sprintf(" Input String: '%s'\n", self$input_string)) - cat(sprintf(" Range: [%d, %d]\n", self$min_value, self$max_value)) - cat(sprintf(" Seed Number: %d\n", self$seed_number)) - cat(sprintf(" MD5 Hash: %s\n", self$get_hash())) + cat("SeedHashGenerator:\n") + cat(sprintf(" Input String: '%s'\n", private$.input_string)) + cat(sprintf(" Range: [%d, %d]\n", private$.min_value, private$.max_value)) + cat(sprintf(" Seed Number: %.0f\n", private$.seed_number)) + cat(sprintf(" MD5 Hash: %s\n", private$.hash)) invisible(self) } ), - + + active = list( + #' @field input_string The input string for seed generation (read-only) + input_string = function(value) { + if (!missing(value)) stop("input_string is read-only", call. = FALSE) + private$.input_string + }, + + #' @field min_value Minimum value for random number range, inclusive (read-only) + min_value = function(value) { + if (!missing(value)) stop("min_value is read-only", call. = FALSE) + private$.min_value + }, + + #' @field max_value Maximum value for random number range, inclusive (read-only) + max_value = function(value) { + if (!missing(value)) stop("max_value is read-only", call. = FALSE) + private$.max_value + }, + + #' @field seed_number The seed derived from hashing, a whole number in + #' [0, 2^32 - 1] stored as a double because it can exceed R's integer + #' range (read-only) + seed_number = function(value) { + if (!missing(value)) stop("seed_number is read-only", call. = FALSE) + private$.seed_number + } + ), + private = list( - # Generate an integer seed from the input string using MD5 hashing - # Returns an integer representation of the MD5 hash - generate_seed = function() { - # Get MD5 hash - hash_value <- digest::digest(self$input_string, algo = "md5", serialize = FALSE) - - # Convert hexadecimal to integer - # R's integer range is smaller than Python's, so we'll use modulo to fit - # Convert first 7 hex characters to safely stay within integer range - # (7 hex digits = 28 bits, well within 32-bit signed integer range) - seed <- strtoi(substr(hash_value, 1, 7), base = 16) - - # Ensure it's a valid integer (not NA) - if (is.na(seed)) { - stop("Failed to generate valid seed from input string") + .input_string = NULL, + .min_value = NULL, + .max_value = NULL, + .hash = NULL, + .seed_number = NULL, + + # Stop unless value is a single whole number that R can store as an + # integer. -2^31 is excluded because as.integer(-2^31) is NA in R. + check_bound = function(value, name) { + if (!is.numeric(value) || length(value) != 1 || !is.finite(value) || + value != round(value)) { + stop(sprintf("%s must be a single whole number", name)) } - - return(seed) + + if (abs(value) > .Machine$integer.max) { + stop(sprintf("%s (%.0f) is outside R's integer range [%d, %d]", + name, value, -.Machine$integer.max, .Machine$integer.max)) + } + }, + + # The last 8 hex digits of the digest are the digest modulo 2^32, which is + # how the Python package derives its seed. The value can exceed R's integer + # range, so it is assembled from two 16-bit halves as a double. + hash_to_seed = function(hash) { + high <- strtoi(substr(hash, 25, 28), base = 16L) + low <- strtoi(substr(hash, 29, 32), base = 16L) + high * 65536 + low + }, + + # set.seed() needs a value in R's integer range, so reinterpret the + # unsigned 32-bit seed as a signed one. The single seed 2^31 would become + # -2^31, which R cannot represent as an integer, so it is mapped to 0. + rng_seed = function() { + seed <- private$.seed_number + if (seed >= 2^31) seed <- seed - 2^32 + if (seed == -2^31) seed <- 0 + as.integer(seed) + }, + + # Evaluate code with the RNG seeded from the hash, then restore the + # caller's RNG kind and state so their own random stream is unaffected + with_seed = function(code) { + env <- globalenv() + old_kind <- RNGkind() + old_seed <- if (exists(".Random.seed", envir = env, inherits = FALSE)) { + get(".Random.seed", envir = env, inherits = FALSE) + } + on.exit({ + # Restoring the "Rounding" sampler kind warns; that is the caller's choice + suppressWarnings(RNGkind(old_kind[1], old_kind[2], old_kind[3])) + if (is.null(old_seed)) { + rm(".Random.seed", envir = env) + } else { + assign(".Random.seed", old_seed, envir = env) + } + }) + + set.seed(private$rng_seed(), kind = "Mersenne-Twister", + normal.kind = "Inversion", sample.kind = "Rejection") + code } ) ) @@ -176,16 +229,14 @@ SeedHashGenerator <- R6::R6Class( #' This is a convenience function to create a SeedHashGenerator object. #' #' @param input_string The string to hash for seed generation -#' @param min_value Minimum value for random number range (default: 0) -#' @param max_value Maximum value for random number range (default: 2^31 - 1) +#' @param min_value Minimum value for random number range, inclusive (default: 0) +#' @param max_value Maximum value for random number range, inclusive (default: 2^31 - 1) #' @return A new SeedHashGenerator object -#' +#' #' @examples -#' \dontrun{ #' gen <- create_seedhash("Shalini") #' seeds <- gen$generate_seeds(10) -#' } -#' +#' #' @export create_seedhash <- function(input_string, min_value = 0, max_value = 2^31 - 1) { SeedHashGenerator$new(input_string, min_value, max_value) diff --git a/R/README.md b/R/README.md index 6b2b277..290f89a 100644 --- a/R/README.md +++ b/R/README.md @@ -27,7 +27,7 @@ devtools::install_github("melhzy/seedhash", subdir = "R") ### Dependencies The package requires: -- R (>= 3.5.0) +- R (>= 3.6.0) - digest - R6 @@ -89,7 +89,8 @@ seeds <- gen$generate_seeds(10) The main class that handles seed generation: - **`new(input_string, min_value = 0, max_value = 2^31 - 1)`**: Initialize a new generator -- **`generate_seeds(count)`**: Generate a specified number of random seeds +- **`generate_seeds(count)`**: Generate a specified number of random seeds (leaves R's global random state unchanged) +- **`set_seed()`**: Seed R's random number generator from the input string; use this instead of `set.seed(gen$seed_number)`, since `seed_number` can exceed R's integer range - **`get_hash()`**: Get the MD5 hash of the input string - **`print()`**: Display generator information @@ -182,14 +183,14 @@ This R package mirrors the functionality of the Python `seedhash` library: ### Key Differences -1. **Seed Size**: R uses 32-bit integers for seeds, while Python can handle larger integers. The R version uses the first 8 hex characters of the MD5 hash. +1. **Seed Number**: Both versions take the MD5 digest of the UTF-8 encoded string modulo 2^32, so `seed_number` is the same in both languages. R stores it as a double because it can exceed R's integer range. The seeds from `generate_seeds()` differ between the languages because each uses its own random number generator. 2. **Syntax**: R uses `$` for method calls (e.g., `gen$generate_seeds()`), while Python uses `.` (e.g., `gen.generate_seeds()`). 3. **Random Number Generation**: R uses `sample.int()` while Python uses `random.randint()`. ## How It Works 1. **Hash Generation**: The input string is hashed using MD5 to create a deterministic hexadecimal value -2. **Seed Conversion**: The hash is converted to an integer seed +2. **Seed Conversion**: The hash modulo 2^32 becomes the seed number, the same value the Python package produces 3. **Random Generation**: R's random number generator is seeded with this value 4. **Number Generation**: Random numbers are generated within the specified range diff --git a/R/README_CRAN.md b/R/README_CRAN.md index d65d236..5319474 100644 --- a/R/README_CRAN.md +++ b/R/README_CRAN.md @@ -112,7 +112,9 @@ R/ ├── examples/ │ └── example_usage.R # Examples ✅ └── tests/ - └── test_seedhash.R # Tests ✅ + ├── testthat.R + └── testthat/ + └── test-seedhash.R # Tests ✅ ``` ## 🔍 Quick Reference diff --git a/R/README_PACKAGE.md b/R/README_PACKAGE.md index 055d7f4..91efc58 100644 --- a/R/README_PACKAGE.md +++ b/R/README_PACKAGE.md @@ -97,21 +97,23 @@ print(gen) - **R6 class** for object-oriented design - **Reproducible** seeds based on string input - **Safe integer handling** with clear error messages -- **Default range**: -1 billion to +1 billion +- **Default range**: 0 to 2^31 - 1 (same as Python) - **Dependencies**: R6, digest ## Important: R Integer Limitations -⚠️ R uses 32-bit signed integers: **-2,147,483,648 to 2,147,483,647** +⚠️ R uses 32-bit signed integers, so bounds must lie in **-2,147,483,647 to 2,147,483,647** (`-2^31` is NA in R) ```r -# ✅ This works -gen <- SeedHashGenerator$new("test", -1e9, 1e9) +# ✅ This works (up to the full range) +gen <- SeedHashGenerator$new("test", -(2^31 - 1), 2^31 - 1) -# ❌ This fails (range too large) +# ❌ This fails (values outside R's integer range) gen <- SeedHashGenerator$new("test", -10^33, 10^33) ``` +`seed_number` can be larger than 2^31 - 1, so seed R's own generator with `gen$set_seed()` rather than `set.seed(gen$seed_number)`. + See [INTEGER_RANGE_SOLUTION.md](INTEGER_RANGE_SOLUTION.md) for details. ## Testing @@ -141,8 +143,8 @@ devtools::check() When developing R features based on Python: - Validate integers BEFORE `as.integer()` conversion -- Use 7 hex characters (not 8) for hash-to-int -- Default range: -1e9 to 1e9 (not full int range) +- Derive the seed exactly as Python does (MD5 digest mod 2^32) and test against Python's values +- Use Python's default range (0 to 2^31 - 1) and do range arithmetic in doubles - Test edge cases around ±2^31 See [PYTHON_TO_R_GUIDE.md](../PYTHON_TO_R_GUIDE.md) for complete conversion guide. @@ -162,5 +164,5 @@ See [SUBMISSION_STATUS.md](SUBMISSION_STATUS.md) for current status. **Version**: 0.1.0 **License**: MIT -**R**: >= 3.5.0 +**R**: >= 3.6.0 **Status**: Ready for CRAN submission diff --git a/R/SUBMIT_NOW.md b/R/SUBMIT_NOW.md index c0878ef..fa381c2 100644 --- a/R/SUBMIT_NOW.md +++ b/R/SUBMIT_NOW.md @@ -125,7 +125,7 @@ devtools::build() - **Version**: 0.1.0 - **Maintainer**: melhzy - **License**: MIT -- **Dependencies**: R (>= 3.5.0), digest, R6 +- **Dependencies**: R (>= 3.6.0), digest, R6 - **Description**: Deterministic seed generation from strings using MD5 ## ✅ Pre-Submission Checklist diff --git a/R/examples/example_usage.R b/R/examples/example_usage.R index 02d9b41..f99fe7a 100644 --- a/R/examples/example_usage.R +++ b/R/examples/example_usage.R @@ -99,7 +99,7 @@ cat("=== Example 9: Comparing Different Strings ===\n") strings <- c("Alice", "Bob", "Charlie") for (str in strings) { gen <- SeedHashGenerator$new(str) - cat(sprintf("'%s' -> Hash: %s, Seed: %d\n", + cat(sprintf("'%s' -> Hash: %s, Seed: %.0f\n", str, gen$get_hash(), gen$seed_number)) } cat("\n") diff --git a/R/man/SeedHashGenerator.Rd b/R/man/SeedHashGenerator.Rd index 5bcb11d..eb2e08c 100644 --- a/R/man/SeedHashGenerator.Rd +++ b/R/man/SeedHashGenerator.Rd @@ -4,17 +4,21 @@ \alias{SeedHashGenerator} \title{SeedHash Generator} \description{ -SeedHash Generator - -SeedHash Generator -} -\details{ Generate deterministic random seeds from string input using MD5 hashing. This class allows users to generate reproducible sequences of random numbers by hashing an input string and using it as a seed for R's random number generator. + +The seed number is the MD5 digest of the UTF-8 encoded input string modulo +2^32, the same algorithm the Python package uses, so a given string has the +same \code{seed_number} in both languages. The seeds returned by +\code{generate_seeds()} come from R's own random number generator and +therefore differ from the ones the Python package returns. + +\code{generate_seeds()} always uses the Mersenne-Twister generator with the +"Rejection" sampler, whatever \code{RNGkind()} is set to, and restores the +caller's random number generator state before returning. } \examples{ -\donttest{ # Create a generator with default range gen <- SeedHashGenerator$new("Shalini") @@ -30,120 +34,150 @@ print(seeds_custom) # Get the MD5 hash hash_value <- gen$get_hash() print(hash_value) + } +\section{Active bindings}{ + \if{html}{\out{
}} + \describe{ + \item{\code{input_string}}{The input string for seed generation (read-only)} + \item{\code{min_value}}{Minimum value for random number range, inclusive (read-only)} + + \item{\code{max_value}}{Maximum value for random number range, inclusive (read-only)} + + \item{\code{seed_number}}{The seed derived from hashing, a whole number in +[0, 2^32 - 1] stored as a double because it can exceed R's integer +range (read-only)} + } + \if{html}{\out{
}} } \section{Methods}{ \subsection{Public methods}{ -\itemize{ -\item \href{#method-SeedHashGenerator-new}{\code{SeedHashGenerator$new()}} -\item \href{#method-SeedHashGenerator-generate_seeds}{\code{SeedHashGenerator$generate_seeds()}} -\item \href{#method-SeedHashGenerator-get_hash}{\code{SeedHashGenerator$get_hash()}} -\item \href{#method-SeedHashGenerator-print}{\code{SeedHashGenerator$print()}} -\item \href{#method-SeedHashGenerator-clone}{\code{SeedHashGenerator$clone()}} -} + \itemize{ + \item \href{#method-SeedHashGenerator-initialize}{\code{SeedHashGenerator$new()}} + \item \href{#method-SeedHashGenerator-generate_seeds}{\code{SeedHashGenerator$generate_seeds()}} + \item \href{#method-SeedHashGenerator-set_seed}{\code{SeedHashGenerator$set_seed()}} + \item \href{#method-SeedHashGenerator-get_hash}{\code{SeedHashGenerator$get_hash()}} + \item \href{#method-SeedHashGenerator-print}{\code{SeedHashGenerator$print()}} + \item \href{#method-SeedHashGenerator-clone}{\code{SeedHashGenerator$clone()}} + } } \if{html}{\out{
}} -\if{html}{\out{}} -\if{latex}{\out{\hypertarget{method-SeedHashGenerator-new}{}}} -\subsection{Method \code{new()}}{ -The input string for seed generation - - -Minimum value for random number range - - -Maximum value for random number range - - -The integer seed derived from hashing - - -Initialize the SeedHashGenerator -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{SeedHashGenerator$new(input_string, min_value = 0, max_value = 2^31 - 1)}\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-SeedHashGenerator-initialize}{}}} +\subsection{\code{SeedHashGenerator$new()}}{ + Initialize the SeedHashGenerator + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{SeedHashGenerator$new(input_string, min_value = 0, max_value = 2^31 - 1)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{input_string}}{The string to hash for seed generation} + \item{\code{min_value}}{Minimum value for random number range, inclusive (default: 0)} + \item{\code{max_value}}{Maximum value for random number range, inclusive (default: 2^31 - 1)} + } + \if{html}{\out{
}} + } + \subsection{Returns}{ + A new SeedHashGenerator object + } } -\subsection{Arguments}{ -\if{html}{\out{
}} -\describe{ -\item{\code{input_string}}{The string to hash for seed generation} - -\item{\code{min_value}}{Minimum value for random number range (default: 0)} - -\item{\code{max_value}}{Maximum value for random number range (default: 2^31 - 1)} -} -\if{html}{\out{
}} -} -\subsection{Returns}{ -A new SeedHashGenerator object -} -} \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-SeedHashGenerator-generate_seeds}{}}} -\subsection{Method \code{generate_seeds()}}{ -Generate a list of random seed numbers -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{SeedHashGenerator$generate_seeds(count)}\if{html}{\out{
}} +\subsection{\code{SeedHashGenerator$generate_seeds()}}{ + Generate a list of random seed numbers + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{SeedHashGenerator$generate_seeds(count)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{count}}{The number of random seeds to generate} + } + \if{html}{\out{
}} + } + \subsection{Returns}{ + An integer vector of random seeds within the specified range + } } -\subsection{Arguments}{ -\if{html}{\out{
}} -\describe{ -\item{\code{count}}{The number of random seeds to generate} -} -\if{html}{\out{
}} -} -\subsection{Returns}{ -A vector of random integers within the specified range -} +\if{html}{\out{
}} +\if{html}{\out{}} +\if{latex}{\out{\hypertarget{method-SeedHashGenerator-set_seed}{}}} +\subsection{\code{SeedHashGenerator$set_seed()}}{ + Seed R's random number generator from the input string, the R +counterpart of Python's \code{set_seed("python")}. Use this instead of +\code{set.seed(seed_number)}: \code{seed_number} can exceed R's integer +range, which \code{set.seed()} rejects, so it is first reinterpreted as +a signed 32-bit integer. The current \code{RNGkind()} is kept. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{SeedHashGenerator$set_seed()} + \if{html}{\out{
}} + } + \subsection{Returns}{ + The generator, invisibly + } } + \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-SeedHashGenerator-get_hash}{}}} -\subsection{Method \code{get_hash()}}{ -Get the MD5 hash of the input string -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{SeedHashGenerator$get_hash()}\if{html}{\out{
}} +\subsection{\code{SeedHashGenerator$get_hash()}}{ + Get the MD5 hash of the input string + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{SeedHashGenerator$get_hash()} + \if{html}{\out{
}} + } + \subsection{Returns}{ + The MD5 hash as a hexadecimal string + } } -\subsection{Returns}{ -The MD5 hash as a hexadecimal string -} -} \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-SeedHashGenerator-print}{}}} -\subsection{Method \code{print()}}{ -Print method for SeedHashGenerator -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{SeedHashGenerator$print(...)}\if{html}{\out{
}} +\subsection{\code{SeedHashGenerator$print()}}{ + Print method for SeedHashGenerator + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{SeedHashGenerator$print(...)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{...}}{Additional arguments (unused)} + } + \if{html}{\out{
}} + } } -\subsection{Arguments}{ -\if{html}{\out{
}} -\describe{ -\item{\code{...}}{Additional arguments (unused)} -} -\if{html}{\out{
}} -} -} \if{html}{\out{
}} \if{html}{\out{}} \if{latex}{\out{\hypertarget{method-SeedHashGenerator-clone}{}}} -\subsection{Method \code{clone()}}{ -The objects of this class are cloneable with this method. -\subsection{Usage}{ -\if{html}{\out{
}}\preformatted{SeedHashGenerator$clone(deep = FALSE)}\if{html}{\out{
}} +\subsection{\code{SeedHashGenerator$clone()}}{ + The objects of this class are cloneable with this method. + \subsection{Usage}{ + \if{html}{\out{
}} + \preformatted{SeedHashGenerator$clone(deep = FALSE)} + \if{html}{\out{
}} + } + \subsection{Arguments}{ + \if{html}{\out{
}} + \describe{ + \item{\code{deep}}{Whether to make a deep clone.} + } + \if{html}{\out{
}} + } } -\subsection{Arguments}{ -\if{html}{\out{
}} -\describe{ -\item{\code{deep}}{Whether to make a deep clone.} -} -\if{html}{\out{
}} -} -} } diff --git a/R/man/create_seedhash.Rd b/R/man/create_seedhash.Rd index 8bbd13d..dfc99e6 100644 --- a/R/man/create_seedhash.Rd +++ b/R/man/create_seedhash.Rd @@ -9,9 +9,9 @@ create_seedhash(input_string, min_value = 0, max_value = 2^31 - 1) \arguments{ \item{input_string}{The string to hash for seed generation} -\item{min_value}{Minimum value for random number range (default: 0)} +\item{min_value}{Minimum value for random number range, inclusive (default: 0)} -\item{max_value}{Maximum value for random number range (default: 2^31 - 1)} +\item{max_value}{Maximum value for random number range, inclusive (default: 2^31 - 1)} } \value{ A new SeedHashGenerator object @@ -20,9 +20,7 @@ A new SeedHashGenerator object This is a convenience function to create a SeedHashGenerator object. } \examples{ -\dontrun{ gen <- create_seedhash("Shalini") seeds <- gen$generate_seeds(10) -} } diff --git a/R/tests/test_seedhash.R b/R/tests/test_seedhash.R deleted file mode 100644 index df36bdd..0000000 --- a/R/tests/test_seedhash.R +++ /dev/null @@ -1,155 +0,0 @@ -# Test script for seedhash R package -# Run this script to test the functionality - -library(R6) -library(digest) - -# Source the main file -source("R/R/seedhash.R") - -cat("=== Running Tests for seedhash R Package ===\n\n") - -# Test 1: Basic initialization -cat("Test 1: Basic initialization... ") -tryCatch({ - gen <- SeedHashGenerator$new("test") - if (!is.null(gen$input_string) && - !is.null(gen$seed_number) && - gen$min_value == 0 && - gen$max_value == 2^31 - 1) { - cat("PASSED\n") - } else { - cat("FAILED\n") - } -}, error = function(e) { - cat("FAILED:", e$message, "\n") -}) - -# Test 2: Custom range -cat("Test 2: Custom range... ") -tryCatch({ - gen <- SeedHashGenerator$new("test", min_value = 10, max_value = 100) - if (gen$min_value == 10 && gen$max_value == 100) { - cat("PASSED\n") - } else { - cat("FAILED\n") - } -}, error = function(e) { - cat("FAILED:", e$message, "\n") -}) - -# Test 3: Empty string error -cat("Test 3: Empty string error... ") -tryCatch({ - gen <- SeedHashGenerator$new("") - cat("FAILED (should have thrown error)\n") -}, error = function(e) { - if (grepl("cannot be empty", e$message)) { - cat("PASSED\n") - } else { - cat("FAILED (wrong error)\n") - } -}) - -# Test 4: Invalid range error -cat("Test 4: Invalid range error... ") -tryCatch({ - gen <- SeedHashGenerator$new("test", min_value = 100, max_value = 10) - cat("FAILED (should have thrown error)\n") -}, error = function(e) { - if (grepl("must be less than", e$message)) { - cat("PASSED\n") - } else { - cat("FAILED (wrong error)\n") - } -}) - -# Test 5: Generate seeds -cat("Test 5: Generate seeds... ") -tryCatch({ - gen <- SeedHashGenerator$new("test") - seeds <- gen$generate_seeds(10) - if (length(seeds) == 10 && is.numeric(seeds)) { - cat("PASSED\n") - } else { - cat("FAILED\n") - } -}, error = function(e) { - cat("FAILED:", e$message, "\n") -}) - -# Test 6: Reproducibility -cat("Test 6: Reproducibility... ") -tryCatch({ - gen1 <- SeedHashGenerator$new("reproducibility_test") - seeds1 <- gen1$generate_seeds(5) - - gen2 <- SeedHashGenerator$new("reproducibility_test") - seeds2 <- gen2$generate_seeds(5) - - if (identical(seeds1, seeds2)) { - cat("PASSED\n") - } else { - cat("FAILED (seeds not identical)\n") - } -}, error = function(e) { - cat("FAILED:", e$message, "\n") -}) - -# Test 7: Get hash -cat("Test 7: Get hash... ") -tryCatch({ - gen <- SeedHashGenerator$new("test") - hash <- gen$get_hash() - if (is.character(hash) && nchar(hash) == 32) { - cat("PASSED\n") - } else { - cat("FAILED\n") - } -}, error = function(e) { - cat("FAILED:", e$message, "\n") -}) - -# Test 8: Invalid count -cat("Test 8: Invalid count error... ") -tryCatch({ - gen <- SeedHashGenerator$new("test") - seeds <- gen$generate_seeds(-5) - cat("FAILED (should have thrown error)\n") -}, error = function(e) { - if (grepl("positive integer", e$message)) { - cat("PASSED\n") - } else { - cat("FAILED (wrong error)\n") - } -}) - -# Test 9: Convenience function -cat("Test 9: Convenience function... ") -tryCatch({ - gen <- create_seedhash("convenience_test") - seeds <- gen$generate_seeds(5) - if (length(seeds) == 5) { - cat("PASSED\n") - } else { - cat("FAILED\n") - } -}, error = function(e) { - cat("FAILED:", e$message, "\n") -}) - -# Test 10: Seeds within range -cat("Test 10: Seeds within range... ") -tryCatch({ - gen <- SeedHashGenerator$new("range_test", min_value = 10, max_value = 20) - seeds <- gen$generate_seeds(100) - if (all(seeds >= 10 & seeds <= 20)) { - cat("PASSED\n") - } else { - cat("FAILED (seeds out of range)\n") - } -}, error = function(e) { - cat("FAILED:", e$message, "\n") -}) - -cat("\n=== All Tests Completed ===\n") diff --git a/R/tests/testthat.R b/R/tests/testthat.R new file mode 100644 index 0000000..ebe9a3f --- /dev/null +++ b/R/tests/testthat.R @@ -0,0 +1,4 @@ +library(testthat) +library(seedhash) + +test_check("seedhash") diff --git a/R/tests/testthat/test-seedhash.R b/R/tests/testthat/test-seedhash.R new file mode 100644 index 0000000..c42465d --- /dev/null +++ b/R/tests/testthat/test-seedhash.R @@ -0,0 +1,178 @@ +test_that("defaults match create_seedhash() and the Python package", { + gen <- SeedHashGenerator$new("test") + expect_identical(gen$min_value, 0L) + expect_identical(gen$max_value, .Machine$integer.max) + + conv <- create_seedhash("test") + expect_identical(conv$min_value, gen$min_value) + expect_identical(conv$max_value, gen$max_value) +}) + +test_that("custom range is stored as integers", { + gen <- SeedHashGenerator$new("test", min_value = 10, max_value = 100) + expect_identical(gen$min_value, 10L) + expect_identical(gen$max_value, 100L) +}) + +test_that("input_string is validated", { + expect_error(SeedHashGenerator$new(""), "cannot be empty") + expect_error(SeedHashGenerator$new(NA_character_), "single character string") + expect_error(SeedHashGenerator$new(c("a", "b")), "single character string") + expect_error(SeedHashGenerator$new(123), "single character string") +}) + +test_that("range bounds are validated", { + expect_error(SeedHashGenerator$new("test", 100, 10), "must be less than") + expect_error(SeedHashGenerator$new("test", 5, 5), "must be less than") + expect_error(SeedHashGenerator$new("test", 0.2, 0.8), "whole number") + expect_error(SeedHashGenerator$new("test", NA_real_, 5), "whole number") + expect_error(SeedHashGenerator$new("test", c(1, 2), 10), "whole number") + expect_error(SeedHashGenerator$new("test", "0", 10), "whole number") + expect_error(SeedHashGenerator$new("test", 0, 2^31), "outside R's integer range") + # as.integer(-2^31) is NA in R, so -2^31 must be rejected + expect_error(SeedHashGenerator$new("test", -2^31, 0), "outside R's integer range") +}) + +test_that("seed_number matches the Python package", { + # Values from Python: int(hashlib.md5(s.encode('utf-8')).hexdigest(), 16) % 2**32 + expected <- c( + experiment_1 = 1603554058, + test = 640136438, + Shalini = 52639232, + "café" = 1930968482 + ) + for (s in names(expected)) { + expect_identical(SeedHashGenerator$new(s)$seed_number, expected[[s]], label = s) + } +}) + +test_that("non-ASCII input hashes its UTF-8 bytes whatever the declared encoding", { + utf8 <- "café" + latin1 <- iconv(utf8, "UTF-8", "latin1") + expect_identical(Encoding(latin1), "latin1") + expect_identical( + SeedHashGenerator$new(latin1)$get_hash(), + SeedHashGenerator$new(utf8)$get_hash() + ) +}) + +test_that("get_hash() returns the MD5 of the input string", { + gen <- SeedHashGenerator$new("test") + expect_identical(gen$get_hash(), "098f6bcd4621d373cade4e832627b4f6") +}) + +test_that("generate_seeds() returns reproducible integers in range", { + gen <- SeedHashGenerator$new("range_test", min_value = 10, max_value = 20) + seeds <- gen$generate_seeds(100) + expect_type(seeds, "integer") + expect_length(seeds, 100) + expect_true(all(seeds >= 10 & seeds <= 20)) + expect_identical( + SeedHashGenerator$new("range_test", 10, 20)$generate_seeds(100), + seeds + ) +}) + +test_that("generate_seeds() output is pinned", { + # Changing these values breaks reproducibility for existing users + expect_identical( + SeedHashGenerator$new("test", 1, 100)$generate_seeds(5), + c(2L, 13L, 54L, 4L, 13L) + ) + expect_identical( + create_seedhash("experiment_1")$generate_seeds(3), + c(860817984L, 1641195952L, 1260949474L) + ) +}) + +test_that("the default and full integer ranges can generate seeds", { + expect_length(create_seedhash("convenience_test")$generate_seeds(5), 5) + + full <- SeedHashGenerator$new("test", -.Machine$integer.max, .Machine$integer.max) + seeds <- full$generate_seeds(1000) + expect_type(seeds, "integer") + expect_false(anyNA(seeds)) +}) + +test_that("generate_seeds() does not overflow at the top of the integer range", { + gen <- SeedHashGenerator$new("x", 2147483600, .Machine$integer.max) + seeds <- expect_silent(gen$generate_seeds(1000)) + expect_false(anyNA(seeds)) + expect_true(all(seeds >= 2147483600)) +}) + +test_that("count is validated", { + gen <- SeedHashGenerator$new("test") + expect_error(gen$generate_seeds(-5), "positive integer") + expect_error(gen$generate_seeds(0), "positive integer") + expect_error(gen$generate_seeds(2.9), "positive integer") + expect_error(gen$generate_seeds(NA_real_), "positive integer") + expect_error(gen$generate_seeds(c(1, 2)), "positive integer") + expect_error(gen$generate_seeds("5"), "positive integer") + expect_error(gen$generate_seeds(3e9), "at most") +}) + +test_that("generate_seeds() leaves the caller's RNG state alone", { + gen <- SeedHashGenerator$new("test") + + set.seed(42) + expected <- runif(3) + set.seed(42) + first <- runif(1) + gen$generate_seeds(3) + expect_identical(c(first, runif(2)), expected) + + old_kind <- RNGkind() + on.exit(suppressWarnings(RNGkind(old_kind[1], old_kind[2], old_kind[3]))) + seeds <- gen$generate_seeds(5) + RNGkind("L'Ecuyer-CMRG") + expect_identical(gen$generate_seeds(5), seeds) + expect_identical(RNGkind()[1], "L'Ecuyer-CMRG") +}) + +test_that("generate_seeds() does not create .Random.seed", { + env <- globalenv() + if (exists(".Random.seed", envir = env, inherits = FALSE)) { + saved <- get(".Random.seed", envir = env, inherits = FALSE) + on.exit(assign(".Random.seed", saved, envir = env)) + rm(".Random.seed", envir = env) + } + SeedHashGenerator$new("test")$generate_seeds(1) + expect_false(exists(".Random.seed", envir = env, inherits = FALSE)) +}) + +test_that("seeds above R's integer range map to valid set.seed() values", { + gen <- SeedHashGenerator$new("test") + private <- gen$.__enclos_env__$private + private$.seed_number <- 2^32 - 1 + expect_identical(private$rng_seed(), -1L) + private$.seed_number <- 2^31 + expect_identical(private$rng_seed(), 0L) + private$.seed_number <- 2^31 - 1 + expect_identical(private$rng_seed(), .Machine$integer.max) +}) + +test_that("set_seed() seeds R's generator even when seed_number exceeds integer range", { + gen <- SeedHashGenerator$new("Bob") + expect_identical(gen$seed_number, 3214761019) + + gen$set_seed() + first <- runif(3) + gen$set_seed() + expect_identical(runif(3), first) + + set.seed(gen$seed_number - 2^32) + expect_identical(runif(3), first) +}) + +test_that("fields are read-only", { + gen <- SeedHashGenerator$new("test") + expect_error(gen$input_string <- "other", "read-only") + expect_error(gen$min_value <- 5, "read-only") + expect_error(gen$max_value <- 5, "read-only") + expect_error(gen$seed_number <- 5, "read-only") +}) + +test_that("print() shows seeds beyond R's integer range", { + expect_output(print(SeedHashGenerator$new("experiment_1")), "Seed Number: 1603554058") +}) diff --git a/README.md b/README.md index 2093bb5..9558e17 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # SeedHash: MD5-Based Deterministic Random Seed Generator -[![Python Version](https://img.shields.io/badge/python-3.7+-blue.svg)](https://www.python.org/downloads/) +[![Python Version](https://img.shields.io/badge/python-3.8+-blue.svg)](https://www.python.org/downloads/) [![R Version](https://img.shields.io/badge/R-3.5+-blue.svg)](https://www.r-project.org/) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Tests](https://github.com/melhzy/seedhash/workflows/Tests/badge.svg)](https://github.com/melhzy/seedhash/actions) @@ -36,6 +36,7 @@ seedhash/ - 🔄 **Cross-Language**: Available in both Python and R - 🎲 **4 Sampling Methods**: Simple, Stratified, Cluster, and Systematic random sampling - 🧪 **ML Integration**: Experiment tracking with hierarchical seed management +- 🖥️ **Multi-GPU Ready**: Seeds PyTorch the same way on one GPU, several GPUs, or several nodes (torchrun, SLURM, MPI) ## Installation @@ -351,6 +352,7 @@ Python/ │ ├── demo.py # Usage examples │ ├── hierarchical_sampling.py │ ├── deep_learning_seeding.py +│ ├── distributed_seeding.py # Multi-GPU / multi-node │ └── advanced_ml_paradigms.py ├── setup.py # Setup configuration ├── pyproject.toml # Modern Python packaging @@ -362,7 +364,9 @@ R/ │ └── seedhash.R # R package implementation ├── man/ # R documentation ├── tests/ -│ └── test_seedhash.R # R tests +│ ├── testthat.R # testthat entry point +│ └── testthat/ +│ └── test-seedhash.R # R tests ├── examples/ │ └── example_usage.R ├── DESCRIPTION # R package metadata @@ -382,11 +386,13 @@ Root files: ### Running Tests ```bash -# Install in development mode -pip install -e . +# Python: install in development mode, then run the tests from the repository root +pip install -e "Python[experiment]" pytest +pytest test_*.py -v -# Run the example script -python examples/demo.py +# R: install the package, then run its testthat suite +R CMD INSTALL R +Rscript -e 'testthat::test_dir("R/tests/testthat", package = "seedhash", load_package = "installed")' ``` ### Building the Package diff --git a/paper.md b/paper.md index 47ebb6c..ec62c87 100644 --- a/paper.md +++ b/paper.md @@ -63,7 +63,7 @@ These methods are inspired by survey sampling theory [@cochran1977sampling] and **3. Hierarchical experiment management (`SeedExperimentManager`)**: Supports nested experimental designs where experiments have sub-experiments (e.g., models with hyperparameter searches, each with k-fold CV). This addresses the common pattern in ML research of three-level hierarchies: project → model variants → cross-validation folds. -**Cross-language consistency**: Python and R implementations use identical algorithms, ensuring that `SeedHashGenerator("experiment_1")` produces the same seed value in both languages. This enables workflows that span both environments (e.g., data preprocessing in Python, statistical modeling in R) while maintaining reproducibility. +**Cross-language consistency**: Python and R derive the seed with the same algorithm (the MD5 digest of the UTF-8 encoded string, modulo 2^32), so `SeedHashGenerator("experiment_1")` has the same seed number, 1603554058, in both languages; both test suites check this against shared reference values. The seed lists returned by `generate_seeds()` come from each language's native random number generator and therefore differ between Python and R. This enables workflows that span both environments (e.g., data preprocessing in Python, statistical modeling in R) while keeping every seed traceable to the same string identifier. **Design trade-offs**: We chose MD5 over cryptographic hashes (SHA-256) for speed, since seed generation is not security-sensitive. We use object-oriented patterns for extensibility, though this adds slight verbosity compared to functional interfaces. The hierarchical manager uses pandas [@pandas] and R data frames for compatibility with common data science workflows rather than custom formats. diff --git a/test_correctness.py b/test_correctness.py new file mode 100644 index 0000000..ade5351 --- /dev/null +++ b/test_correctness.py @@ -0,0 +1,227 @@ +""" +Regression tests for correctness fixes in the Python package. + +Covers cross-language seed values, reproducibility of existing outputs, +side effects on global state, hierarchy tracking and the ML metrics. +""" + +import os +import random +import subprocess +import sys +import textwrap + +import numpy as np +import pytest + +sys.path.insert(0, 'Python') + +from seedhash import MLMetrics, SeedExperimentManager, SeedHashGenerator, SeedSampler + + +# --- Seeds and reproducibility --------------------------------------------- + +def test_seed_number_matches_r_package(): + """The R package's tests pin the same values.""" + expected = { + "experiment_1": 1603554058, + "test": 640136438, + "Shalini": 52639232, + "café": 1930968482, + } + for text, seed in expected.items(): + assert SeedHashGenerator(text).seed_number == seed, text + + +def test_generate_seeds_output_is_pinned(): + assert SeedHashGenerator("experiment_1").generate_seeds(5) == [ + 179576649, 1611871093, 1351360959, 1045713345, 1687315778 + ] + assert SeedHashGenerator("test", 1, 100).generate_seeds(5) == [75, 27, 2, 28, 34] + + +def test_sampling_output_is_pinned(): + def sampler(): + return SeedSampler(12345) + + assert sampler().simple_random_sampling(3, (0, 1000)) == [426, 750, 10] + assert sampler().stratified_random_sampling(10, (0, 1000)) == [ + 106, 187, 2, 459, 461, 455, 576, 718, 844, 988 + ] + assert sampler().cluster_random_sampling(10, (0, 1000)) == [ + 426, 445, 898, 887, 312, 303, 188, 172, 922, 911 + ] + assert sampler().systematic_random_sampling(10, (0, 1000)) == [ + 53, 153, 253, 353, 453, 553, 653, 753, 853, 953 + ] + + +def test_generate_seeds_leaves_global_random_alone(): + random.seed(42) + expected = [random.random() for _ in range(3)] + + random.seed(42) + first = random.random() + SeedHashGenerator("test").generate_seeds(3) + assert [first, random.random(), random.random()] == expected + + +# --- Imports ----------------------------------------------------------------- + +def _run_python(code, extra_path=None): + env = dict(os.environ) + if extra_path: + env["PYTHONPATH"] = os.pathsep.join([extra_path, env.get("PYTHONPATH", "")]) + return subprocess.run( + [sys.executable, "-c", textwrap.dedent(code)], + capture_output=True, text=True, env=env, + ) + + +def test_import_without_pandas_does_not_warn(): + result = _run_python(""" + import sys, warnings + sys.path.insert(0, 'Python') + sys.modules['pandas'] = None # makes `import pandas` raise ImportError + warnings.simplefilter('error') + from seedhash import SeedHashGenerator + """) + assert result.returncode == 0, result.stderr + + +def test_broken_tensorflow_does_not_break_import(tmp_path): + fake = tmp_path / "tensorflow" + fake.mkdir() + (fake / "__init__.py").write_text( + "raise TypeError('Descriptors cannot be created directly')\n" + ) + result = _run_python(""" + import sys + sys.path.insert(0, 'Python') + from seedhash import core + assert not core.TF_AVAILABLE + """, extra_path=str(tmp_path)) + assert result.returncode == 0, result.stderr + + +# --- Sampling input validation --------------------------------------------- + +def test_stratified_rejects_range_narrower_than_strata(): + with pytest.raises(ValueError, match="too narrow"): + SeedSampler(0).stratified_random_sampling(4, (0, 2), n_strata=4) + with pytest.raises(ValueError, match="too narrow"): + SeedSampler(0).stratified_random_sampling(4, (0, 3), n_strata=4) + assert len(SeedSampler(0).stratified_random_sampling(4, (0, 4), n_strata=4)) == 4 + + +def test_systematic_rejects_invalid_sample_counts(): + with pytest.raises(ValueError, match="n_samples"): + SeedSampler(0).systematic_random_sampling(0, (0, 100)) + with pytest.raises(ValueError, match="n_samples"): + SeedSampler(0).systematic_random_sampling(5, (0, 3)) + + seeds = SeedSampler(0).systematic_random_sampling(10, (0, 10)) + assert len(set(seeds)) == 10 + assert all(0 <= s <= 10 for s in seeds) + + +# --- Experiment manager ---------------------------------------------------- + +def _run_with_timeout(func, seconds=10): + """Fail instead of hanging forever if func loops.""" + import threading + + error = [] + + def target(): + try: + func() + except Exception as exc: # pragma: no cover - reported below + error.append(exc) + + thread = threading.Thread(target=target, daemon=True) + thread.start() + thread.join(seconds) + assert not thread.is_alive(), "call did not finish (infinite loop?)" + if error: + raise error[0] + + +def test_seed_collisions_do_not_hang_or_rewrite_lineage(): + manager = SeedExperimentManager("x", master_seed=0) + hierarchy = manager.generate_seed_hierarchy( + n_seeds=10, n_sub_seeds=5, seed_range=(0, 100) + ) + + for seed in hierarchy[1] + hierarchy[2]: + _run_with_timeout(lambda: manager.add_experiment_result( + seed, "classification", {"acc": 1.0}, "simple" + )) + + # Level-1 seeds keep the master as their parent even when they + # reappear as sub-seeds further down the hierarchy + for seed in hierarchy[1]: + assert manager._seed_hierarchy[seed]["parent"] == 0 + + first = manager.results[0] + assert first.seed_hierarchy == [0, hierarchy[1][0]] + assert first.seed_level == 1 + + +def test_results_do_not_share_callers_dicts(): + manager = SeedExperimentManager("x") + metrics = {} + for accuracy in (0.1, 0.9): + metrics["accuracy"] = accuracy + manager.add_experiment_result(1, "classification", metrics, "simple") + + assert [r.metrics["accuracy"] for r in manager.results] == [0.1, 0.9] + + +# --- Metrics ----------------------------------------------------------------- + +def test_binary_metrics_with_non_01_labels(): + metrics = MLMetrics.classification_metrics([-1, 1, -1, 1], [1, 1, 1, -1]) + assert metrics["accuracy"] == 0.25 + assert metrics["precision"] == pytest.approx(1 / 3) + assert metrics["recall"] == pytest.approx(0.5) + assert metrics["f1"] == pytest.approx(0.4) + + metrics = MLMetrics.classification_metrics(["cat", "dog", "dog"], ["cat", "dog", "dog"]) + assert metrics["precision"] == metrics["recall"] == metrics["f1"] == 1.0 + + +def test_multiclass_f1_is_mean_of_per_class_f1(): + # Per-class F1: class 0 = 0.8, class 1 = 0.5, class 2 = 0.0. The F1 of + # the macro precision and recall would be about 0.494 instead. + metrics = MLMetrics.classification_metrics([0, 0, 0, 1, 2], [0, 0, 1, 1, 1]) + assert metrics["f1"] == pytest.approx((0.8 + 0.5 + 0.0) / 3) + + +def test_metrics_reject_or_flatten_mismatched_shapes(): + y = np.arange(100.0) + assert MLMetrics.regression_metrics(y, y.reshape(-1, 1))["rmse"] == 0.0 + assert MLMetrics.classification_metrics(y, y.reshape(-1, 1))["accuracy"] == 1.0 + + with pytest.raises(ValueError, match="same number of values"): + MLMetrics.regression_metrics([1, 2, 3], [1, 2]) + + +def test_silhouette_handles_singletons_and_identical_points(): + # Value from sklearn.metrics.silhouette_score for the same input + metrics = MLMetrics.clustering_metrics([[0], [0], [10], [11], [30]], [0, 0, 1, 1, 2]) + assert metrics["silhouette"] == pytest.approx(0.7618181818181818) + assert metrics["n_clusters"] == 3 + + degenerate = MLMetrics.clustering_metrics([[0], [1]], [0, 0]) + assert degenerate == {"silhouette": 0.0, "n_clusters": 1, "n_samples": 2} + + +def test_improvement_rate_sign_with_negative_rewards(): + rewards = list(np.linspace(-100, -50, 50)) + metrics = MLMetrics.reinforcement_learning_metrics(rewards, [1] * 50) + assert metrics["improvement_rate"] > 0 + + # With 10 episodes the early and recent windows must not be the same data + metrics = MLMetrics.reinforcement_learning_metrics(list(range(1, 11)), [1] * 10) + assert metrics["improvement_rate"] == pytest.approx((8 - 3) / 3) diff --git a/test_distributed_seeding.py b/test_distributed_seeding.py new file mode 100644 index 0000000..921656e --- /dev/null +++ b/test_distributed_seeding.py @@ -0,0 +1,298 @@ +""" +Tests for seeding PyTorch on one GPU, several GPUs on one node, and GPUs +across several nodes. + +The unit tests need no PyTorch. The launcher tests start real multi-process +jobs with torchrun and torch.multiprocessing.spawn on the CPU (gloo backend) +and are skipped when PyTorch is not installed. +""" + +import json +import os +import random +import socket +import subprocess +import sys +import textwrap +from pathlib import Path +from types import SimpleNamespace + +import pytest + +PYTHON_DIR = Path(__file__).resolve().parent / "Python" +sys.path.insert(0, str(PYTHON_DIR)) + +import seedhash.core as core +from seedhash import SeedHashGenerator, get_global_rank + +try: + import torch + import torch.distributed as dist + HAVE_GLOO = dist.is_available() and dist.is_gloo_available() +except Exception: + HAVE_GLOO = False + +requires_gloo = pytest.mark.skipif(not HAVE_GLOO, reason="needs PyTorch with the gloo backend") + +LAUNCHER_ENV_VARS = core.RANK_ENV_VARS + ("LOCAL_RANK", "WORLD_SIZE", "LOCAL_WORLD_SIZE") + + +@pytest.fixture(autouse=True) +def no_launcher_env(monkeypatch): + """Start every test outside any distributed job.""" + for name in LAUNCHER_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +# --- Rank detection --------------------------------------------------------- + +def test_rank_is_zero_outside_a_distributed_job(): + assert get_global_rank() == 0 + + +@pytest.mark.parametrize("name", core.RANK_ENV_VARS) +def test_rank_is_read_from_each_launcher(monkeypatch, name): + monkeypatch.setenv(name, "5") + assert get_global_rank() == 5 + + +def test_torchrun_rank_wins_over_slurm(monkeypatch): + # torchrun under SLURM: SLURM_PROCID numbers the node's task, RANK the process + monkeypatch.setenv("SLURM_PROCID", "1") + monkeypatch.setenv("RANK", "6") + assert get_global_rank() == 6 + + +def test_local_rank_alone_is_not_used(monkeypatch): + # LOCAL_RANK repeats on every node, so it cannot give unique seeds + monkeypatch.setenv("LOCAL_RANK", "3") + assert get_global_rank() == 0 + + +@pytest.mark.parametrize("value", ["-1", "abc", "1.5"]) +def test_invalid_rank_variable_raises(monkeypatch, value): + monkeypatch.setenv("RANK", value) + with pytest.raises(ValueError, match="RANK"): + get_global_rank() + + +def _fake_torch(calls, initialized=False, rank=0): + """Just enough of torch for set_seed() and get_global_rank().""" + return SimpleNamespace( + manual_seed=lambda seed: calls.append(("manual_seed", seed)), + cuda=SimpleNamespace( + is_available=lambda: True, + manual_seed_all=lambda seed: calls.append(("cuda.manual_seed_all", seed)), + ), + distributed=SimpleNamespace( + is_available=lambda: True, + is_initialized=lambda: initialized, + get_rank=lambda: rank, + ), + ) + + +def test_initialized_process_group_wins_over_environment(monkeypatch): + monkeypatch.setattr(core, "torch", _fake_torch([], initialized=True, rank=7), raising=False) + monkeypatch.setattr(core, "TORCH_AVAILABLE", True) + monkeypatch.setenv("RANK", "2") + assert get_global_rank() == 7 + + +# --- Seeds ------------------------------------------------------------------ + +def test_rank_seed_offsets_seed_number_by_rank(): + gen = SeedHashGenerator("distributed_test") + assert gen.rank_seed(0) == gen.seed_number + assert gen.rank_seed(3) == (gen.seed_number + 3) % 2**32 + assert len({gen.rank_seed(r) for r in range(64)}) == 64 + + +def test_rank_seed_wraps_to_32_bits(): + gen = SeedHashGenerator("Bob") # seed_number 3214761019 + assert gen.rank_seed(2**32 - gen.seed_number) == 0 + + +def test_rank_seed_detects_rank(monkeypatch): + gen = SeedHashGenerator("distributed_test") + monkeypatch.setenv("RANK", "4") + assert gen.rank_seed() == gen.rank_seed(4) + + +@pytest.mark.parametrize("rank, error", [(-1, ValueError), (1.0, TypeError), (True, TypeError)]) +def test_rank_seed_validates_rank(rank, error): + with pytest.raises(error): + SeedHashGenerator("x").rank_seed(rank) + + +def test_set_seed_uses_seed_number_on_every_rank_by_default(monkeypatch): + gen = SeedHashGenerator("distributed_test") + monkeypatch.setenv("RANK", "3") + gen.set_seed("python") + assert random.random() == random.Random(gen.seed_number).random() + + +def test_set_seed_per_rank_uses_rank_seed(monkeypatch): + gen = SeedHashGenerator("distributed_test") + monkeypatch.setenv("RANK", "3") + gen.set_seed("python", per_rank=True) + assert random.random() == random.Random(gen.rank_seed(3)).random() + + gen.set_seed("python", per_rank=True, rank=5) + assert random.random() == random.Random(gen.rank_seed(5)).random() + + +def test_rank_without_per_rank_raises(): + with pytest.raises(ValueError, match="per_rank"): + SeedHashGenerator("x").set_seed("python", rank=1) + + +def test_set_seed_seeds_cpu_and_all_gpus_with_rank_seed(monkeypatch): + calls = [] + monkeypatch.setattr(core, "torch", _fake_torch(calls), raising=False) + monkeypatch.setattr(core, "TORCH_AVAILABLE", True) + gen = SeedHashGenerator("distributed_test") + + gen.set_seed("torch", deterministic=False, per_rank=True, rank=2) + assert calls == [ + ("manual_seed", gen.rank_seed(2)), + ("cuda.manual_seed_all", gen.rank_seed(2)), + ] + + calls.clear() + gen.seed_all(deterministic=False, per_rank=True, rank=2) + assert ("manual_seed", gen.rank_seed(2)) in calls + + +# --- Real multi-process jobs ------------------------------------------------- + +WORKER = textwrap.dedent(""" + import json, os, sys + import torch + import torch.distributed as dist + import torch.multiprocessing as mp + + sys.path.insert(0, sys.argv[1]) + from seedhash import SeedHashGenerator, get_global_rank + + + def run(rank, world_size, init_method, out_path): + gen = SeedHashGenerator("distributed_test") + rank_before_init = get_global_rank() + dist.init_process_group("gloo", init_method=init_method, + rank=rank, world_size=world_size) + record = { + "rank": dist.get_rank(), + "local_rank": int(os.environ.get("LOCAL_RANK", rank)), + "rank_before_init": rank_before_init, + "rank_after_init": get_global_rank(), + } + gen.set_seed("torch") + record["shared"] = torch.rand(3).tolist() + if init_method == "env://": + gen.set_seed("torch", per_rank=True) + else: + gen.set_seed("torch", per_rank=True, rank=rank) + record["per_rank_seed"] = gen.rank_seed(dist.get_rank()) + record["per_rank"] = torch.rand(3).tolist() + + records = [None] * dist.get_world_size() + dist.all_gather_object(records, record) + if dist.get_rank() == 0: + with open(out_path, "w") as f: + json.dump(records, f) + dist.destroy_process_group() + + + if __name__ == "__main__": + out_path = sys.argv[2] + if sys.argv[3] == "spawn": + port = sys.argv[4] + mp.spawn(run, nprocs=2, args=(2, f"tcp://127.0.0.1:{port}", out_path)) + else: + run(int(os.environ["RANK"]), int(os.environ["WORLD_SIZE"]), "env://", out_path) +""") + + +def _free_port(): + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _launcher_env(): + env = {k: v for k, v in os.environ.items() if k not in LAUNCHER_ENV_VARS} + env["OMP_NUM_THREADS"] = "1" + return env + + +def _torchrun(tmp_path, *args): + return [sys.executable, "-m", "torch.distributed.run", *args, + str(tmp_path / "worker.py"), str(PYTHON_DIR), str(tmp_path / "out.json"), "torchrun"] + + +def _check_records(tmp_path, world_size, launched_by_env): + records = sorted(json.loads((tmp_path / "out.json").read_text()), key=lambda r: r["rank"]) + gen = SeedHashGenerator("distributed_test") + + assert [r["rank"] for r in records] == list(range(world_size)) + for r in records: + assert r["rank_after_init"] == r["rank"] + # Without launcher variables (mp.spawn) the rank must be passed explicitly + assert r["rank_before_init"] == (r["rank"] if launched_by_env else 0) + + # Default: every rank draws the same numbers + torch.manual_seed(gen.seed_number) + expected_shared = torch.rand(3).tolist() + assert all(r["shared"] == expected_shared for r in records) + + # per_rank: each rank gets its own seed and reproducible numbers + assert len({r["per_rank_seed"] for r in records}) == world_size + for r in records: + assert r["per_rank_seed"] == gen.rank_seed(r["rank"]) + torch.manual_seed(r["per_rank_seed"]) + assert r["per_rank"] == torch.rand(3).tolist() + return records + + +@requires_gloo +def test_torchrun_several_processes_on_one_node(tmp_path): + (tmp_path / "worker.py").write_text(WORKER) + subprocess.run( + _torchrun(tmp_path, "--standalone", "--nproc-per-node=4"), + env=_launcher_env(), check=True, timeout=120, capture_output=True, + ) + _check_records(tmp_path, world_size=4, launched_by_env=True) + + +@requires_gloo +def test_torchrun_several_nodes(tmp_path): + """Two nodes with two processes each, simulated on localhost.""" + (tmp_path / "worker.py").write_text(WORKER) + port = _free_port() + nodes = [ + subprocess.Popen( + _torchrun(tmp_path, "--nnodes=2", "--nproc-per-node=2", f"--node-rank={node}", + "--master-addr=127.0.0.1", f"--master-port={port}"), + env=_launcher_env(), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + ) + for node in (0, 1) + ] + for proc in nodes: + output, _ = proc.communicate(timeout=120) + assert proc.returncode == 0, output.decode() + + records = _check_records(tmp_path, world_size=4, launched_by_env=True) + # Local ranks repeat on each node, but the seeds are still all different + assert [r["local_rank"] for r in records] == [0, 1, 0, 1] + + +@requires_gloo +def test_multiprocessing_spawn_with_explicit_rank(tmp_path): + (tmp_path / "worker.py").write_text(WORKER) + subprocess.run( + [sys.executable, str(tmp_path / "worker.py"), str(PYTHON_DIR), + str(tmp_path / "out.json"), "spawn", str(_free_port())], + env=_launcher_env(), check=True, timeout=120, capture_output=True, + ) + _check_records(tmp_path, world_size=2, launched_by_env=False)