diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 079e7544..65c6c787 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,6 +49,27 @@ jobs: - name: pytest run: pixi run test + r-bindings: + name: R bindings (Linux) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: r-lib/actions/setup-r@v2 + with: + use-public-rspm: true + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + - uses: Swatinem/rust-cache@v2 + with: + workspaces: withinr/src/rust + - name: cargo fmt (bridge crate) + run: cargo fmt --manifest-path withinr/src/rust/Cargo.toml -- --check + - name: Install package (dev mode, against the workspace crate) + run: NOT_CRAN=true R CMD INSTALL withinr + - name: Run R test suite + run: Rscript withinr/tests/run_tests.R + coverage: name: Coverage runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d4c6f008..e9e44e5b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,16 @@ target .hypothesis/ .pytest_cache/ .ruff_cache/ -*.proptest-regressions \ No newline at end of file +__pycache__/ +*.py[cod] +*.proptest-regressions + +# R build/check artifacts +*.Rcheck/ +*.tar.gz +.Rhistory +.RData +.Rproj.user/ + +# Serena MCP +.serena/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e7370c0..efacfb74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,13 @@ and this project follows [Semantic Versioning](https://semver.org/). - **Locality sort:** `Design` construction reorders observations by the highest-cardinality factor when unsorted, copying them once into an internal sorted store (the caller's store is never mutated; the `Store` trait stays read-only). Transparent — results return in caller row order. - Coalesced scatter for large sorted factors: one atomic add per equal-level run per chunk instead of one per row. +- `withinr` preconditioner handles now expose `variant` and `build_time_seconds` metadata for cache compatibility checks and diagnostics. +- `withinr` manual tests now cover diagonal/additive/off correctness parity, prebuilt preconditioner reuse, and sorted-vs-unsorted caller-order equivalence. ### Changed - Category views are borrowed only when the dominant factor is already sorted; otherwise the columns are copied once for the locality sort. The reorder changes summation order, so unsorted-input results match 0.2.0 within solver tolerance, not bitwise. +- `withinr` development builds patch `within` to the workspace crate, while offline builds patch it to the vendored local crate sources. ## [0.2.0] - 2026-06-04 diff --git a/README.md b/README.md index 0bfc9bcd..e11cf067 100644 --- a/README.md +++ b/README.md @@ -43,58 +43,78 @@ result = solve(fe, y, weights=np.ones(n)) result = solve(fe, y, preconditioner=PreconditionerConfig.Diagonal) ``` -### FWL regression example +## R quickstart -```python -beta_true = np.array([1.0, -2.0, 0.5]) -X = np.random.randn(n, 3) -y = X @ beta_true + np.random.randn(n) - -result = solve_batch(fe, np.column_stack([y, X])) -y_tilde, X_tilde = result.demeaned[:, 0], result.demeaned[:, 1:] -beta_hat = np.linalg.lstsq(X_tilde, y_tilde, rcond=None)[0] -print(np.round(beta_hat, 4)) # [ 0.9982 -2.006 0.5005] +Requires R and a Rust toolchain (`cargo` on `PATH`). + +From the repository root, use `devtools` to install R dependencies and build the +package: + +```r +install.packages("devtools") +Sys.setenv(NOT_CRAN = "true") +devtools::install_deps("withinr/", dependencies = TRUE) +devtools::load_all("withinr/") ``` -## Python API +Example (FWL with two-way fixed effects): -### High-level functions +```r +set.seed(42) +n <- 1000 +n_firms <- 50L +n_years <- 20L -| Function | Description | -|---|---| -| `solve(categories, y, options?, weights?, preconditioner?)` | Solve a single right-hand side. Returns `SolveResult`. | -| `solve_batch(categories, Y, options?, weights?, preconditioner?)` | Solve multiple RHS vectors in parallel. `Y` has shape `(n_obs, k)`. Returns `BatchSolveResult`. | +# 1-based fixed-effect ids in R +firm <- rep(seq_len(n_firms), each = n_years) +year <- rep(seq_len(n_years), times = n_firms) +categories <- cbind(firm, year) -`categories` is a 2-D `uint32` array of shape `(n_obs, n_factors)`. A `UserWarning` is emitted when a C-contiguous array is passed — use `np.asfortranarray(categories)` for best performance. +beta <- 1.5 +firm_fe <- rnorm(n_firms, sd = 3)[firm] +year_fe <- rnorm(n_years, sd = 1)[year] +x <- rnorm(n) + 0.3 * firm_fe +y <- beta * x + firm_fe + year_fe + rnorm(n, sd = 0.5) -### Persistent solver +res <- withinr::solve_batch(categories, cbind(y, x)) +y_tilde <- res$demeaned[, 1] +x_tilde <- res$demeaned[, 2] +beta_hat <- sum(x_tilde * y_tilde) / sum(x_tilde^2) -For repeated solves with the same design matrix, `Solver` builds the preconditioner once and reuses it. +print(beta_hat) +print(res$converged) +``` -```python -from within import Solver +| Function | Description | +|---|---| +| `solve(categories, y, options?, weights?, preconditioner?)` | Solve a single right-hand side. Returns a list shaped like `SolveResult`. | +| `solve_batch(categories, Y, options?, weights?, preconditioner?)` | Solve multiple RHS vectors in parallel. `Y` has shape `(n_obs, k)`. | + +For repeated solves with the same design matrix, `Solver` builds the preconditioner once and reuses it. In R, the solver is an environment with methods. -solver = Solver(fe) -r = solver.solve(y) # reuses preconditioner -r = solver.solve_batch(np.column_stack([y, X])) +```r +solver <- withinr::Solver(categories) +r <- solver$solve(y) +r <- solver$solve_batch(cbind(y, x)) -precond = solver.preconditioner # picklable property -solver2 = Solver(fe, preconditioner=precond) # skip re-factorization +precond <- solver$preconditioner() +payload <- precond$serialize() +solver2 <- withinr::Solver(categories, preconditioner = withinr::Preconditioner(payload)) ``` | Property / Method | Description | |---|---| | `Solver(categories, weights?, preconditioner?)` | Build solver. Factorizes the preconditioner at construction. | -| `.solve(y, options?)` | Solve a single RHS with the given LSMR tuning. Returns `SolveResult`. | -| `.solve_batch(Y, options?)` | Solve multiple RHS columns in parallel. Returns `BatchSolveResult`. | -| `.preconditioner` | Return the built `Preconditioner` (picklable), or `None`. Reuse via `Solver(fe, preconditioner=p)`. | +| `$solve(y, options?)` | Solve a single RHS with the given LSMR tuning. | +| `$solve_batch(Y, options?)` | Solve multiple RHS columns in parallel. | +| `$preconditioner()` | Return the built `Preconditioner`, or `NULL`. Reuse via `Solver(categories, preconditioner=p)`. | ### Solver configuration | Class | Description | |---|---| -| `LsmrOptions(tol=1e-8, maxiter=1000, local_size=None)` | Modified LSMR. `local_size` enables windowed reorthogonalization. | +| `LsmrOptions(tol=1e-8, maxiter=1000, local_size=None)` / `LsmrOptions(tol = 1e-8, maxiter = 1000L, local_size = NULL)` | Modified LSMR. `local_size` enables windowed reorthogonalization. | ### Preconditioner (5-form Union) @@ -102,10 +122,10 @@ The `preconditioner` argument accepts any of: | Form | Meaning | |---|---| -| `None` (default) | Library default — Additive Schwarz with sensible defaults. | -| `PreconditionerConfig.Off` | Explicit identity — solve unpreconditioned. | -| `PreconditionerConfig.Additive` | Additive Schwarz shortcut, equivalent to `None`. | -| `PreconditionerConfig.Diagonal` | Diagonal/Jacobi preconditioner using `diag(D^T W D)^{-1}`. | +| `None` / `NULL` (default) | Library default — Additive Schwarz with sensible defaults. | +| `PreconditionerConfig.Off` / `PreconditionerConfig$Off` | Explicit identity — solve unpreconditioned. | +| `PreconditionerConfig.Additive` / `PreconditionerConfig$Additive` | Additive Schwarz shortcut, equivalent to the default. | +| `PreconditionerConfig.Diagonal` / `PreconditionerConfig$Diagonal` | Diagonal/Jacobi preconditioner using `diag(D^T W D)^{-1}`. | | `AdditiveSchwarz(local_solver?, reduction?)` | Tuned Schwarz config — import from `within.config`. | | `Preconditioner` instance | Reuse a previously-built preconditioner across solvers. | @@ -114,7 +134,7 @@ The `preconditioner` argument accepts any of: | Class | Description | |---|---| | `LocalSolverConfig(approx_chol?, approx_schur?, dense_threshold=24)` | Schur reduction + approximate Cholesky. Omit `approx_schur` for the library-default approximate variant; pass `approx_schur=None` to request an exact Schur (slower, used for validation). | -| `ApproxCholConfig(seed=0, split=1)` | Approximate Cholesky parameters. | +| `ApproxCholConfig(seed=0, split_merge=None)` | Approximate Cholesky parameters. | | `ApproxSchurConfig(seed=0, split=1)` | Approximate Schur complement sampling parameters. | | `ReductionStrategy` enum | `Auto` (default), `AtomicScatter`, `ParallelReduction`. | @@ -194,7 +214,9 @@ crates/ schwarz-precond/ Generic domain decomposition library (traits, solvers, Schwarz preconditioners) within/ Core fixed-effects solver (observation stores, domains, operators, orchestration) within-py/ PyO3 bridge (cdylib → within._within) + within-r/ Workspace mirror for the extendr bridge python/within/ Python package re-exporting the Rust extension +withinr/ R package using the published within 0.2.0 crate benchmarks/ Python benchmark framework ``` diff --git a/withinr/.Rbuildignore b/withinr/.Rbuildignore new file mode 100644 index 00000000..11969560 --- /dev/null +++ b/withinr/.Rbuildignore @@ -0,0 +1,11 @@ +^.*\.Rproj$ +^\.Rproj\.user$ +^src/rust/target +^src/rust/vendor$ +^src/vendor$ +^src/\.cargo +^src/.*\.o$ +^src/.*\.so$ +^src/.*\.dll$ +^test_bindings\.R$ +^benchmarks$ diff --git a/withinr/.gitignore b/withinr/.gitignore new file mode 100644 index 00000000..36d87529 --- /dev/null +++ b/withinr/.gitignore @@ -0,0 +1,8 @@ +src/.cargo/ +src/rust/target/ +src/vendor/ +src/*.o +src/*.so +src/*.dll +src/withinr.lib +src/rust/vendor.tar.xz diff --git a/withinr/DESCRIPTION b/withinr/DESCRIPTION new file mode 100644 index 00000000..370ac068 --- /dev/null +++ b/withinr/DESCRIPTION @@ -0,0 +1,19 @@ +Package: withinr +Title: High-Performance Fixed Effects Solver +Version: 0.1.0 +Authors@R: c( + person("Alexander", "Fischer", role = c("aut", "cre"), + email = "alexander-fischer1801@t-online.de"), + person("Kristof", "Schroeder", role = "aut")) +Author: Alexander Fischer [aut, cre], Kristof Schroeder [aut] +Maintainer: Alexander Fischer +Description: Fast modified LSMR solvers with Schwarz and diagonal + preconditioners for absorbing high-dimensional fixed effects in panel + data regressions. The computational core is written in Rust via the + 'within' crate and accessed through 'extendr'. +License: MIT + file LICENSE +Encoding: UTF-8 +Roxygen: list(markdown = TRUE) +RoxygenNote: 7.3.3 +SystemRequirements: Rust tool chain w/ cargo, rustc +Config/rextendr/version: 0.5.0 diff --git a/withinr/LICENSE b/withinr/LICENSE new file mode 100644 index 00000000..7740f05d --- /dev/null +++ b/withinr/LICENSE @@ -0,0 +1,2 @@ +YEAR: 2025 +COPYRIGHT HOLDER: Alexander Fischer, Kristof Schröder diff --git a/withinr/NAMESPACE b/withinr/NAMESPACE new file mode 100644 index 00000000..69111921 --- /dev/null +++ b/withinr/NAMESPACE @@ -0,0 +1,16 @@ +# Generated by roxygen2: do not edit by hand + +S3method(print,within_preconditioner) +S3method(print,within_solver) +export(AdditiveSchwarz) +export(ApproxCholConfig) +export(ApproxSchurConfig) +export(LocalSolverConfig) +export(LsmrOptions) +export(Preconditioner) +export(PreconditionerConfig) +export(ReductionStrategy) +export(Solver) +export(within_solve) +export(within_solve_batch) +useDynLib(withinr, .registration = TRUE) diff --git a/withinr/R/extendr-wrappers.R b/withinr/R/extendr-wrappers.R new file mode 100644 index 00000000..7bc17cbd --- /dev/null +++ b/withinr/R/extendr-wrappers.R @@ -0,0 +1,38 @@ +# Generated by extendr: Do not edit by hand +# +# This file was created with the following call: +# .Call("wrap__make_withinr_wrappers", use_symbols = TRUE, package_name = "withinr") + +#' @usage NULL +#' @useDynLib withinr, .registration = TRUE +NULL + +solve_impl <- function(categories, y, options, weights, preconditioner) .Call(wrap__solve_impl, categories, y, options, weights, preconditioner) + +solve_batch_impl <- function(categories, y_matrix, options, weights, preconditioner) .Call(wrap__solve_batch_impl, categories, y_matrix, options, weights, preconditioner) + +solver_new_impl <- function(categories, weights, preconditioner) .Call(wrap__solver_new_impl, categories, weights, preconditioner) + +solver_solve_impl <- function(solver, y, options) .Call(wrap__solver_solve_impl, solver, y, options) + +solver_solve_batch_impl <- function(solver, y_matrix, options) .Call(wrap__solver_solve_batch_impl, solver, y_matrix, options) + +solver_preconditioner_impl <- function(solver) .Call(wrap__solver_preconditioner_impl, solver) + +solver_n_dofs_impl <- function(solver) .Call(wrap__solver_n_dofs_impl, solver) + +solver_n_obs_impl <- function(solver) .Call(wrap__solver_n_obs_impl, solver) + +preconditioner_apply_impl <- function(preconditioner, x) .Call(wrap__preconditioner_apply_impl, preconditioner, x) + +preconditioner_nrows_impl <- function(preconditioner) .Call(wrap__preconditioner_nrows_impl, preconditioner) + +preconditioner_ncols_impl <- function(preconditioner) .Call(wrap__preconditioner_ncols_impl, preconditioner) + +preconditioner_variant_impl <- function(preconditioner) .Call(wrap__preconditioner_variant_impl, preconditioner) + +preconditioner_serialize_impl <- function(preconditioner) .Call(wrap__preconditioner_serialize_impl, preconditioner) + +preconditioner_deserialize_impl <- function(data) .Call(wrap__preconditioner_deserialize_impl, data) + + diff --git a/withinr/R/solve.R b/withinr/R/solve.R new file mode 100644 index 00000000..8ac18625 --- /dev/null +++ b/withinr/R/solve.R @@ -0,0 +1,284 @@ +.default_approx_schur <- function() { + ApproxSchurConfig() +} + +validate_categories <- function(categories) { + if (!is.matrix(categories)) { + stop("`categories` must be a matrix", call. = FALSE) + } + if (!is.integer(categories)) { + categories <- matrix( + as.integer(categories), + nrow = nrow(categories), + ncol = ncol(categories) + ) + } + categories +} + +validate_weights <- function(weights) { + if (is.null(weights)) { + NULL + } else { + as.double(weights) + } +} + +#' Modified LSMR solver options +#' +#' Uses Modified Golub-Kahan bidiagonalization to solve the least-squares +#' problem directly. The preconditioner approximates `A^T A` and is applied as +#' one `M^{-1}` solve per iteration. +#' +#' Values are validated by the Rust bridge when the options are used. +#' +#' @param tol Positive finite convergence tolerance. Default `1e-8`. +#' @param maxiter Maximum number of LSMR iterations. Default `1000L`. +#' @param local_size Optional window size for modified Gram-Schmidt +#' reorthogonalization, or `NULL` to use the short recurrence. +#' @return A solver options object accepted by [within_solve()], +#' [within_solve_batch()], and persistent solver methods. +#' @export +LsmrOptions <- function(tol = 1e-8, maxiter = 1000L, local_size = NULL) { + structure( + list(tol = tol, maxiter = maxiter, local_size = local_size), + class = "within_lsmr_options" + ) +} + +#' Preconditioner shortcut values +#' +#' Use `PreconditionerConfig$Additive`, `PreconditionerConfig$Off`, or +#' `PreconditionerConfig$Diagonal` as the `preconditioner` argument. +#' `Additive` builds the default additive Schwarz preconditioner, `Off` +#' disables preconditioning, and `Diagonal` uses diagonal/Jacobi scaling. +#' +#' @export +PreconditionerConfig <- structure( + list(Additive = "additive", Off = "off", Diagonal = "diagonal"), + class = "within_preconditioner_config" +) + +#' Reduction strategy shortcut values +#' +#' Use these values inside [AdditiveSchwarz()]. +#' +#' @export +ReductionStrategy <- structure( + list( + Auto = "auto", + AtomicScatter = "atomic_scatter", + ParallelReduction = "parallel_reduction" + ), + class = "within_reduction_strategy" +) + +#' Approximate Cholesky configuration +#' +#' @param seed Non-negative integer random seed. +#' @param split_merge Optional positive integer split/merge parameter, or `NULL`. +#' @return A local-solver configuration object. +#' @export +ApproxCholConfig <- function(seed = 0, split_merge = NULL) { + structure( + list(seed = seed, split_merge = split_merge), + class = "within_approx_chol_config" + ) +} + +#' Approximate Schur complement configuration +#' +#' @param seed Non-negative integer random seed. +#' @param split Positive integer edge split factor. +#' @return A local-solver configuration object. +#' @export +ApproxSchurConfig <- function(seed = 0, split = 1L) { + structure( + list(seed = seed, split = split), + class = "within_approx_schur_config" + ) +} + +#' Local solver configuration for additive Schwarz subdomains +#' +#' @param approx_chol `NULL` for the library default, or an +#' [ApproxCholConfig()] object. +#' @param approx_schur Omitted for the library default approximate Schur, +#' `NULL` for exact Schur, or an [ApproxSchurConfig()] object. +#' @param dense_threshold Optional non-negative integer dense Schur threshold. +#' @return A local-solver configuration object accepted by [AdditiveSchwarz()]. +#' @export +LocalSolverConfig <- function(approx_chol = NULL, + approx_schur = .default_approx_schur(), + dense_threshold = NULL) { + structure( + list( + approx_chol = approx_chol, + approx_schur = approx_schur, + dense_threshold = dense_threshold + ), + class = "within_local_solver_config" + ) +} + +#' Additive Schwarz preconditioner configuration +#' +#' @param local_solver `NULL` for the library default, or a +#' [LocalSolverConfig()] object. +#' @param reduction One of `ReductionStrategy$Auto`, +#' `ReductionStrategy$AtomicScatter`, or `ReductionStrategy$ParallelReduction`. +#' @return A preconditioner configuration object. +#' @export +AdditiveSchwarz <- function(local_solver = NULL, + reduction = ReductionStrategy$Auto) { + structure( + list(local_solver = local_solver, reduction = reduction), + class = "within_additive_schwarz" + ) +} + +#' Solve fixed-effects normal equations +#' +#' Computes fixed-effect coefficients by solving the normal equations +#' \eqn{D^T W D x = D^T W y} where \eqn{D} is the dummy-variable design +#' matrix implied by `categories` and \eqn{W} is the diagonal weight matrix. +#' +#' @param categories Integer matrix of shape `(n_obs, n_factors)`. Each column +#' contains **1-based** factor level assignments. Values must be positive +#' integers with no `NA`s. +#' @param y Numeric vector of length `n_obs`. +#' @param options `NULL` for default [LsmrOptions()] or an options object. +#' @param weights Numeric vector of length `n_obs` or `NULL`. +#' @param preconditioner Controls preconditioning. Five input forms are +#' accepted: `NULL` builds the default additive Schwarz preconditioner, +#' `PreconditionerConfig$Off` disables preconditioning, +#' `PreconditionerConfig$Diagonal` uses diagonal/Jacobi scaling, +#' [AdditiveSchwarz()] overrides local-solver and reduction settings, and a +#' built [Preconditioner()] object reuses an existing factorization. +#' @return A named list with fields `x`, `demeaned`, `converged`, +#' `iterations`, `residual`, `time_total`, `time_setup`, and `time_solve`. +#' @export +within_solve <- function(categories, + y, + options = NULL, + weights = NULL, + preconditioner = NULL) { + categories <- validate_categories(categories) + y <- as.double(y) + weights <- validate_weights(weights) + + solve_impl(categories, y, options, weights, preconditioner) +} + +#' Solve fixed-effects normal equations for multiple response vectors +#' +#' Builds the operator and preconditioner once, then solves for each column +#' of `Y` in parallel. +#' +#' @inheritParams within_solve +#' @param Y Numeric matrix of shape `(n_obs, k)`. +#' @return A named list with matrix fields `x` and `demeaned`, plus +#' `converged`, `iterations`, `residual`, `time_solve`, and `time_total`. +#' @export +within_solve_batch <- function(categories, + Y, + options = NULL, + weights = NULL, + preconditioner = NULL) { + categories <- validate_categories(categories) + if (!is.matrix(Y)) { + stop("`Y` must be a matrix", call. = FALSE) + } + Y <- matrix(as.double(Y), nrow = nrow(Y), ncol = ncol(Y)) + weights <- validate_weights(weights) + + solve_batch_impl(categories, Y, options, weights, preconditioner) +} + +#' Persistent fixed-effects solver +#' +#' Builds the preconditioner once and reuses it for repeated solves with the +#' same design matrix. +#' +#' @inheritParams within_solve +#' @return A `within_solver` object with `$solve()`, `$solve_batch()`, and +#' `$preconditioner()` methods, plus `$n_dofs` and `$n_obs` fields. +#' @export +Solver <- function(categories, weights = NULL, preconditioner = NULL) { + categories <- validate_categories(categories) + weights <- validate_weights(weights) + ptr <- solver_new_impl(categories, weights, preconditioner) + + solver <- new.env(parent = emptyenv()) + solver$ptr <- ptr + solver$n_dofs <- solver_n_dofs_impl(ptr) + solver$n_obs <- solver_n_obs_impl(ptr) + solver$solve <- function(y, options = NULL) { + solver_solve_impl(ptr, as.double(y), options) + } + solver$solve_batch <- function(Y, options = NULL) { + if (!is.matrix(Y)) { + stop("`Y` must be a matrix", call. = FALSE) + } + Y <- matrix(as.double(Y), nrow = nrow(Y), ncol = ncol(Y)) + solver_solve_batch_impl(ptr, Y, options) + } + solver$preconditioner <- function() { + ptr <- solver_preconditioner_impl(solver$ptr) + if (is.null(ptr)) { + NULL + } else { + new_preconditioner(ptr) + } + } + class(solver) <- "within_solver" + solver +} + +new_preconditioner <- function(ptr) { + preconditioner <- new.env(parent = emptyenv()) + preconditioner$ptr <- ptr + preconditioner$nrows <- preconditioner_nrows_impl(ptr) + preconditioner$ncols <- preconditioner_ncols_impl(ptr) + preconditioner$apply <- function(x) { + preconditioner_apply_impl(ptr, as.double(x)) + } + preconditioner$serialize <- function() { + preconditioner_serialize_impl(ptr) + } + class(preconditioner) <- "within_preconditioner" + preconditioner +} + +#' Built preconditioner handle +#' +#' Deserializes a preconditioner from raw bytes produced by +#' `solver$preconditioner()$serialize()`. +#' +#' @param data Raw vector containing serialized preconditioner bytes. +#' @return A `within_preconditioner` object with `$apply()` and `$serialize()` +#' methods and `$nrows` and `$ncols` fields. +#' @export +Preconditioner <- function(data) { + if (!is.raw(data)) { + stop("`data` must be a raw vector", call. = FALSE) + } + new_preconditioner(preconditioner_deserialize_impl(data)) +} + +#' @export +print.within_solver <- function(x, ...) { + cat(sprintf("\n", x$n_obs, x$n_dofs)) + invisible(x) +} + +# Matches the Python __repr__: Preconditioner(, n=). +#' @export +print.within_preconditioner <- function(x, ...) { + cat(sprintf( + "Preconditioner(%s, n=%d)\n", + preconditioner_variant_impl(x$ptr), + x$nrows + )) + invisible(x) +} diff --git a/withinr/README.md b/withinr/README.md new file mode 100644 index 00000000..377b17ee --- /dev/null +++ b/withinr/README.md @@ -0,0 +1,127 @@ +# withinr + +R bindings for the [within](https://github.com/py-econometrics/within) fixed-effects solver. + +`withinr` exposes the Rust `within` crate through [extendr](https://extendr.github.io/extendr/extendr_api/). Development builds target the workspace `within` crate on `main`, including unreleased solver improvements; offline/CRAN-style builds use the vendored copy of those same local Rust sources. The solver surface is modified LSMR with additive Schwarz, diagonal, or identity preconditioning. + +## Quickstart (local development) + +Requires R and a Rust toolchain (`cargo` on `PATH`). + +```r +install.packages(c("rextendr", "devtools")) +Sys.setenv(NOT_CRAN = "true") # or WITHINR_DEV = "true" +devtools::load_all("withinr") +``` + +For command-line installation from the repository root: + +```bash +NOT_CRAN=true R CMD INSTALL withinr +``` + +## API + +R category matrices are 1-based. Each column is one fixed-effect factor, and `withinr` converts values to the 0-based indices used by Rust. + +```r +within_solve(categories, y, options = NULL, weights = NULL, preconditioner = NULL) +within_solve_batch(categories, Y, options = NULL, weights = NULL, preconditioner = NULL) +``` + +The one-shot entry points carry a `within_` prefix (unlike Python's +`within.solve`, which is namespaced) so that attaching the package does not +mask `base::solve()`. + +`options` is `NULL` or `LsmrOptions(tol = 1e-8, maxiter = 1000L, local_size = NULL)`. + +`preconditioner` accepts `NULL`, `PreconditionerConfig$Additive`, `PreconditionerConfig$Off`, `PreconditionerConfig$Diagonal`, `AdditiveSchwarz(...)`, or a built `Preconditioner` returned by a persistent solver. + +Both solve functions return lists with `x`, `demeaned`, `converged`, `iterations`, `residual`, and timing fields. In batch results, `x` and `demeaned` are matrices with one column per right-hand side. + +## Example + +```r +library(withinr) + +set.seed(42) +n <- 5000L +n_f1 <- 50L +n_f2 <- 30L +n_f3 <- 20L + +f1 <- sample.int(n_f1, n, replace = TRUE) +f2 <- sample.int(n_f2, n, replace = TRUE) +f3 <- sample.int(n_f3, n, replace = TRUE) +categories <- cbind(f1, f2, f3) + +alpha1 <- rnorm(n_f1) +alpha2 <- rnorm(n_f2) +alpha3 <- rnorm(n_f3) +beta <- c(1, 2, 3) +X <- matrix(rnorm(n * 3), ncol = 3) +y <- X %*% beta + alpha1[f1] + alpha2[f2] + alpha3[f3] + rnorm(n, sd = 0.5) + +res <- within_solve_batch(categories, cbind(y, X)) +y_tilde <- res$demeaned[, 1] +X_tilde <- res$demeaned[, 2:4] +qr.solve(X_tilde, y_tilde) +``` + +## Persistent Solver + +For repeated solves with the same design matrix, build a solver once and reuse its preconditioner. + +```r +solver <- Solver(categories) +r <- solver$solve(y) +b <- solver$solve_batch(cbind(y, X), options = LsmrOptions(tol = 1e-10)) + +precond <- solver$preconditioner() +print(precond) # Preconditioner(Additive, n=...) + +payload <- precond$serialize() +precond2 <- Preconditioner(payload) +solver2 <- Solver(categories, preconditioner = precond2) +``` + +## Advanced Configuration + +```r +schwarz <- AdditiveSchwarz( + local_solver = LocalSolverConfig( + approx_chol = ApproxCholConfig(seed = 1, split_merge = 2L), + approx_schur = ApproxSchurConfig(seed = 1, split = 1L), + dense_threshold = 24L + ), + reduction = ReductionStrategy$Auto +) + +res <- within_solve(categories, y, preconditioner = schwarz) +``` + +Passing `approx_schur = NULL` to `LocalSolverConfig()` requests exact Schur complements; omitting it uses the library-default approximate Schur configuration. + +## CRAN / Offline Packaging + +The package crate pins the released `within` version in `src/rust/Cargo.toml` as the compatibility requirement. Build wiring overrides that requirement: + +- `NOT_CRAN=true` or `WITHINR_DEV=true` builds patch `within` to `../../crates/within` from this workspace. +- Offline builds unpack `src/rust/vendor.tar.xz`; `src/rust/vendor-config.toml` patches `within` to `vendor/within`. + +The vendor archive is a build artifact and is not tracked in git — generate it at packaging time (and after Rust dependency or local crate changes): + +```r +rextendr::vendor_crates(path = "withinr") +``` + +Then replace `vendor/within` and `vendor/schwarz-precond` in the archive with the local workspace crates so offline builds match development builds. When the workspace `within` crate version or its dependencies change, also regenerate `src/rust/Cargo.lock` (build once in dev mode) so `--locked` builds keep resolving. + +## Tests + +```r +Sys.setenv(NOT_CRAN = "true") +devtools::load_all("withinr") +source("withinr/tests/run_tests.R") +withinr_run_tests() +``` diff --git a/withinr/benchmarks/benchmark_fixest_demean.R b/withinr/benchmarks/benchmark_fixest_demean.R new file mode 100644 index 00000000..7090430c --- /dev/null +++ b/withinr/benchmarks/benchmark_fixest_demean.R @@ -0,0 +1,169 @@ +# Benchmark withinr::within_solve_batch() vs fixest::demean() on the "difficult data" DGP. +# +# DGP provenance: +# - taken from fixest benchmarks +# - which are adapted from the authors of FixedEffectsModels.jl +# +# This script is fully in-memory (no disk writes). + +generate_difficult_data <- function(pows = 4:7, seed = 1L) { + set.seed(seed) + datasets <- vector("list", length(pows)) + names(datasets) <- paste0("n=", 10^pows) + + for (i in seq_along(pows)) { + pow <- pows[[i]] + n <- 10^pow + nb_indiv <- n / 20 + nb_firm <- round(n / 160) + nb_year <- round(n^0.3) + + id_indiv <- sample.int(nb_indiv, n, replace = TRUE) + id_firm <- pmin(sample.int(21, n, replace = TRUE) - 1L + pmax(1, id_indiv %/% 8 - 10), nb_firm) + id_year <- sample.int(nb_year, n, replace = TRUE) + + x1 <- 5 * cos(id_indiv) + 5 * sin(id_firm) + 5 * sin(id_year) + runif(n) + x2 <- cos(id_indiv) + sin(id_firm) + sin(id_year) + stats::rnorm(n) + y <- 3 * x1 + 5 * x2 + cos(id_indiv) + cos(id_firm)^2 + sin(id_year) + stats::rnorm(n) + + datasets[[i]] <- data.frame( + id_indiv = id_indiv, + id_firm = id_firm, + id_year = id_year, + x1 = x1, + x2 = x2, + y = y + ) + } + + datasets +} + +benchmark_withinr_vs_fixest_difficult <- function( + pows = 4:7, + reps = 3L, + seed = 1L, + options = withinr::LsmrOptions(), + preconditioner = NULL, + verbose = TRUE) { + if (!requireNamespace("fixest", quietly = TRUE)) { + stop("Package 'fixest' is required. Install with install.packages('fixest').", call. = FALSE) + } + if (!requireNamespace("withinr", quietly = TRUE)) { + stop("Package 'withinr' is required. Install/load withinr first.", call. = FALSE) + } + + if (length(pows) == 0L) { + stop("`pows` must be non-empty.", call. = FALSE) + } + if (reps < 1L) { + stop("`reps` must be >= 1.", call. = FALSE) + } + + datasets <- generate_difficult_data(pows = pows, seed = seed) + out <- vector("list", length(datasets)) + + for (i in seq_along(datasets)) { + df <- datasets[[i]] + n <- nrow(df) + cats <- as.matrix(df[, c("id_indiv", "id_firm", "id_year")]) + Y <- as.matrix(df[, c("y", "x1", "x2")]) + fes <- df[, c("id_indiv", "id_firm", "id_year")] + + if (verbose) { + message(sprintf("Benchmarking n=%s (replicates=%d) ...", format(n, big.mark = ","), reps)) + } + + t_within <- numeric(reps) + t_fixest <- numeric(reps) + demeaned_within <- NULL + demeaned_fixest <- NULL + + for (r in seq_len(reps)) { + t_within[r] <- system.time({ + wr <- withinr::within_solve_batch( + categories = cats, + Y = Y, + options = options, + preconditioner = preconditioner + ) + })[["elapsed"]] + demeaned_within <- wr$demeaned + + t_fixest[r] <- system.time({ + demeaned_fixest <- fixest::demean( + X = Y, + f = fes, + na.rm = TRUE, + as.matrix = TRUE + ) + })[["elapsed"]] + } + + beta_within <- qr.solve(demeaned_within[, 2:3, drop = FALSE], demeaned_within[, 1]) + beta_fixest <- qr.solve(demeaned_fixest[, 2:3, drop = FALSE], demeaned_fixest[, 1]) + + out[[i]] <- data.frame( + pow = pows[[i]], + n = n, + withinr_elapsed_mean = mean(t_within), + withinr_elapsed_median = stats::median(t_within), + fixest_elapsed_mean = mean(t_fixest), + fixest_elapsed_median = stats::median(t_fixest), + speedup_fixest_over_withinr = stats::median(t_fixest) / stats::median(t_within), + withinr_beta_x1 = unname(beta_within[1]), + withinr_beta_x2 = unname(beta_within[2]), + fixest_beta_x1 = unname(beta_fixest[1]), + fixest_beta_x2 = unname(beta_fixest[2]), + abs_beta_diff_x1 = abs(unname(beta_within[1]) - unname(beta_fixest[1])), + abs_beta_diff_x2 = abs(unname(beta_within[2]) - unname(beta_fixest[2])), + max_abs_demean_diff = max(abs(demeaned_within - demeaned_fixest)), + max_abs_beta_diff = max(abs(beta_within - beta_fixest)) + ) + } + + result <- do.call(rbind, out) + rownames(result) <- NULL + result +} + +format_benchmark_withinr_fixest <- function(bench, digits = 6L) { + if (!is.data.frame(bench)) { + stop("`bench` must be a data.frame returned by benchmark_withinr_vs_fixest_difficult().", + call. = FALSE) + } + + runtime <- data.frame( + n = bench$n, + withinr_sec = round(bench$withinr_elapsed_median, digits), + fixest_sec = round(bench$fixest_elapsed_median, digits) + ) + + estimates <- data.frame( + n = bench$n, + withinr_x1 = round(bench$withinr_beta_x1, digits), + fixest_x1 = round(bench$fixest_beta_x1, digits), + withinr_x2 = round(bench$withinr_beta_x2, digits), + fixest_x2 = round(bench$fixest_beta_x2, digits) + ) + + list(runtime = runtime, estimates = estimates, raw = bench) +} + +print_benchmark_withinr_fixest <- function(bench, digits = 6L) { + tabs <- format_benchmark_withinr_fixest(bench, digits = digits) + + cat("\n== Runtime (median seconds) ==\n") + print(tabs$runtime, row.names = FALSE) + + cat("\n== Point Estimates ==\n") + print(tabs$estimates, row.names = FALSE) + + invisible(tabs) +} + +# Example: +# devtools::load_all("withinr") +# source("withinr/benchmarks/benchmark_fixest_demean.R") +# bench <- benchmark_withinr_vs_fixest_difficult(pows = 4:7, reps = 3) +# print_benchmark_withinr_fixest(bench) diff --git a/withinr/examples/basic_withinr_usage.R b/withinr/examples/basic_withinr_usage.R new file mode 100644 index 00000000..779fccdd --- /dev/null +++ b/withinr/examples/basic_withinr_usage.R @@ -0,0 +1,94 @@ +# Basic withinr usage. +# +# Run from the repository root after installing the package: +# NOT_CRAN=true R CMD INSTALL withinr +# Rscript withinr/examples/basic_withinr_usage.R + +if (!requireNamespace("withinr", quietly = TRUE)) { + stop( + "Package 'withinr' is not installed. Run: NOT_CRAN=true R CMD INSTALL withinr", + call. = FALSE + ) +} + +set.seed(42) + +n <- 2000L +n_firm <- 120L +n_worker <- 300L +n_year <- 8L + +firm <- sample.int(n_firm, n, replace = TRUE) +worker <- sample.int(n_worker, n, replace = TRUE) +year <- sample.int(n_year, n, replace = TRUE) + +# withinr expects a 1-based integer matrix: rows are observations, columns are +# fixed-effect dimensions. +categories <- cbind(firm = firm, worker = worker, year = year) + +x1 <- rnorm(n) +x2 <- rnorm(n) +firm_fe <- rnorm(n_firm) +worker_fe <- rnorm(n_worker) +year_fe <- rnorm(n_year) + +y <- 1.5 * x1 - 0.75 * x2 + + firm_fe[firm] + worker_fe[worker] + year_fe[year] + + rnorm(n, sd = 0.2) + +X <- cbind(x1 = x1, x2 = x2) +weights <- runif(n, min = 0.5, max = 2.0) + +options <- withinr::LsmrOptions( + tol = 1e-10, + maxiter = 4000L, + local_size = 4L +) + +# 1. One-shot demeaning of y. +fit_y <- withinr::within_solve( + categories, + y, + options = options, + weights = weights, + preconditioner = withinr::PreconditionerConfig$Additive +) + +cat("Single RHS converged:", fit_y$converged, "\n") +cat("Iterations:", fit_y$iterations, "\n") +cat("Residual:", signif(fit_y$residual, 4), "\n\n") + +# 2. Batch demeaning: demean y and all regressors with one shared setup. +batch <- withinr::within_solve_batch( + categories, + cbind(y = y, X), + options = options, + weights = weights, + preconditioner = withinr::PreconditionerConfig$Additive +) + +y_tilde <- batch$demeaned[, 1] +X_tilde <- batch$demeaned[, 2:3] + +coef <- qr.solve(X_tilde, y_tilde) +cat("OLS after absorbing fixed effects:\n") +print(coef) +cat("\n") + +# 3. Persistent solver: build the design/preconditioner once and reuse it. +solver <- withinr::Solver(categories, weights = weights) +preconditioner <- solver$preconditioner() +print(preconditioner) + +again <- solver$solve(y, options = options) +stopifnot(isTRUE(all.equal(again$demeaned, fit_y$demeaned, tolerance = 1e-6))) + +# 4. Serialize and reuse a prebuilt preconditioner. +payload <- preconditioner$serialize() +preconditioner2 <- withinr::Preconditioner(payload) +solver2 <- withinr::Solver(categories, weights = weights, preconditioner = preconditioner2) +reused <- solver2$solve(y, options = options) +stopifnot(isTRUE(all.equal(reused$demeaned, fit_y$demeaned, tolerance = 1e-6))) + +cat("Serialized preconditioner bytes:", length(payload), "\n") +cat("Reuse check: OK\n") diff --git a/withinr/man/AdditiveSchwarz.Rd b/withinr/man/AdditiveSchwarz.Rd new file mode 100644 index 00000000..c0c90987 --- /dev/null +++ b/withinr/man/AdditiveSchwarz.Rd @@ -0,0 +1,21 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{AdditiveSchwarz} +\alias{AdditiveSchwarz} +\title{Additive Schwarz preconditioner configuration} +\usage{ +AdditiveSchwarz(local_solver = NULL, reduction = ReductionStrategy$Auto) +} +\arguments{ +\item{local_solver}{\code{NULL} for the library default, or a +\code{\link[=LocalSolverConfig]{LocalSolverConfig()}} object.} + +\item{reduction}{One of \code{ReductionStrategy$Auto}, +\code{ReductionStrategy$AtomicScatter}, or \code{ReductionStrategy$ParallelReduction}.} +} +\value{ +A preconditioner configuration object. +} +\description{ +Additive Schwarz preconditioner configuration +} diff --git a/withinr/man/ApproxCholConfig.Rd b/withinr/man/ApproxCholConfig.Rd new file mode 100644 index 00000000..79a4cb31 --- /dev/null +++ b/withinr/man/ApproxCholConfig.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{ApproxCholConfig} +\alias{ApproxCholConfig} +\title{Approximate Cholesky configuration} +\usage{ +ApproxCholConfig(seed = 0, split_merge = NULL) +} +\arguments{ +\item{seed}{Non-negative integer random seed.} + +\item{split_merge}{Optional positive integer split/merge parameter, or \code{NULL}.} +} +\value{ +A local-solver configuration object. +} +\description{ +Approximate Cholesky configuration +} diff --git a/withinr/man/ApproxSchurConfig.Rd b/withinr/man/ApproxSchurConfig.Rd new file mode 100644 index 00000000..b091a34c --- /dev/null +++ b/withinr/man/ApproxSchurConfig.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{ApproxSchurConfig} +\alias{ApproxSchurConfig} +\title{Approximate Schur complement configuration} +\usage{ +ApproxSchurConfig(seed = 0, split = 1L) +} +\arguments{ +\item{seed}{Non-negative integer random seed.} + +\item{split}{Positive integer edge split factor.} +} +\value{ +A local-solver configuration object. +} +\description{ +Approximate Schur complement configuration +} diff --git a/withinr/man/LocalSolverConfig.Rd b/withinr/man/LocalSolverConfig.Rd new file mode 100644 index 00000000..331c7566 --- /dev/null +++ b/withinr/man/LocalSolverConfig.Rd @@ -0,0 +1,27 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{LocalSolverConfig} +\alias{LocalSolverConfig} +\title{Local solver configuration for additive Schwarz subdomains} +\usage{ +LocalSolverConfig( + approx_chol = NULL, + approx_schur = .default_approx_schur(), + dense_threshold = NULL +) +} +\arguments{ +\item{approx_chol}{\code{NULL} for the library default, or an +\code{\link[=ApproxCholConfig]{ApproxCholConfig()}} object.} + +\item{approx_schur}{Omitted for the library default approximate Schur, +\code{NULL} for exact Schur, or an \code{\link[=ApproxSchurConfig]{ApproxSchurConfig()}} object.} + +\item{dense_threshold}{Optional non-negative integer dense Schur threshold.} +} +\value{ +A local-solver configuration object accepted by \code{\link[=AdditiveSchwarz]{AdditiveSchwarz()}}. +} +\description{ +Local solver configuration for additive Schwarz subdomains +} diff --git a/withinr/man/LsmrOptions.Rd b/withinr/man/LsmrOptions.Rd new file mode 100644 index 00000000..3359cb79 --- /dev/null +++ b/withinr/man/LsmrOptions.Rd @@ -0,0 +1,28 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{LsmrOptions} +\alias{LsmrOptions} +\title{Modified LSMR solver options} +\usage{ +LsmrOptions(tol = 1e-08, maxiter = 1000L, local_size = NULL) +} +\arguments{ +\item{tol}{Positive finite convergence tolerance. Default \code{1e-8}.} + +\item{maxiter}{Maximum number of LSMR iterations. Default \code{1000L}.} + +\item{local_size}{Optional window size for modified Gram-Schmidt +reorthogonalization, or \code{NULL} to use the short recurrence.} +} +\value{ +A solver options object accepted by \code{\link[=within_solve]{within_solve()}}, +\code{\link[=within_solve_batch]{within_solve_batch()}}, and persistent solver methods. +} +\description{ +Uses Modified Golub-Kahan bidiagonalization to solve the least-squares +problem directly. The preconditioner approximates \verb{A^T A} and is applied as +one \code{M^{-1}} solve per iteration. +} +\details{ +Values are validated by the Rust bridge when the options are used. +} diff --git a/withinr/man/Preconditioner.Rd b/withinr/man/Preconditioner.Rd new file mode 100644 index 00000000..cf99f565 --- /dev/null +++ b/withinr/man/Preconditioner.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{Preconditioner} +\alias{Preconditioner} +\title{Built preconditioner handle} +\usage{ +Preconditioner(data) +} +\arguments{ +\item{data}{Raw vector containing serialized preconditioner bytes.} +} +\value{ +A \code{within_preconditioner} object with \verb{$apply()} and \verb{$serialize()} +methods and \verb{$nrows} and \verb{$ncols} fields. +} +\description{ +Deserializes a preconditioner from raw bytes produced by +\code{solver$preconditioner()$serialize()}. +} diff --git a/withinr/man/PreconditionerConfig.Rd b/withinr/man/PreconditionerConfig.Rd new file mode 100644 index 00000000..adb94321 --- /dev/null +++ b/withinr/man/PreconditionerConfig.Rd @@ -0,0 +1,19 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\docType{data} +\name{PreconditionerConfig} +\alias{PreconditionerConfig} +\title{Preconditioner shortcut values} +\format{ +An object of class \code{within_preconditioner_config} of length 3. +} +\usage{ +PreconditionerConfig +} +\description{ +Use \code{PreconditionerConfig$Additive}, \code{PreconditionerConfig$Off}, or +\code{PreconditionerConfig$Diagonal} as the \code{preconditioner} argument. +\code{Additive} builds the default additive Schwarz preconditioner, \code{Off} +disables preconditioning, and \code{Diagonal} uses diagonal/Jacobi scaling. +} +\keyword{datasets} diff --git a/withinr/man/ReductionStrategy.Rd b/withinr/man/ReductionStrategy.Rd new file mode 100644 index 00000000..b13f611a --- /dev/null +++ b/withinr/man/ReductionStrategy.Rd @@ -0,0 +1,16 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\docType{data} +\name{ReductionStrategy} +\alias{ReductionStrategy} +\title{Reduction strategy shortcut values} +\format{ +An object of class \code{within_reduction_strategy} of length 3. +} +\usage{ +ReductionStrategy +} +\description{ +Use these values inside \code{\link[=AdditiveSchwarz]{AdditiveSchwarz()}}. +} +\keyword{datasets} diff --git a/withinr/man/Solver.Rd b/withinr/man/Solver.Rd new file mode 100644 index 00000000..fb021d26 --- /dev/null +++ b/withinr/man/Solver.Rd @@ -0,0 +1,30 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{Solver} +\alias{Solver} +\title{Persistent fixed-effects solver} +\usage{ +Solver(categories, weights = NULL, preconditioner = NULL) +} +\arguments{ +\item{categories}{Integer matrix of shape \verb{(n_obs, n_factors)}. Each column +contains \strong{1-based} factor level assignments. Values must be positive +integers with no \code{NA}s.} + +\item{weights}{Numeric vector of length \code{n_obs} or \code{NULL}.} + +\item{preconditioner}{Controls preconditioning. Five input forms are +accepted: \code{NULL} builds the default additive Schwarz preconditioner, +\code{PreconditionerConfig$Off} disables preconditioning, +\code{PreconditionerConfig$Diagonal} uses diagonal/Jacobi scaling, +\code{\link[=AdditiveSchwarz]{AdditiveSchwarz()}} overrides local-solver and reduction settings, and a +built \code{\link[=Preconditioner]{Preconditioner()}} object reuses an existing factorization.} +} +\value{ +A \code{within_solver} object with \verb{$solve()}, \verb{$solve_batch()}, and +\verb{$preconditioner()} methods, plus \verb{$n_dofs} and \verb{$n_obs} fields. +} +\description{ +Builds the preconditioner once and reuses it for repeated solves with the +same design matrix. +} diff --git a/withinr/man/within_solve.Rd b/withinr/man/within_solve.Rd new file mode 100644 index 00000000..c512d8ba --- /dev/null +++ b/withinr/man/within_solve.Rd @@ -0,0 +1,41 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{within_solve} +\alias{within_solve} +\title{Solve fixed-effects normal equations} +\usage{ +within_solve( + categories, + y, + options = NULL, + weights = NULL, + preconditioner = NULL +) +} +\arguments{ +\item{categories}{Integer matrix of shape \verb{(n_obs, n_factors)}. Each column +contains \strong{1-based} factor level assignments. Values must be positive +integers with no \code{NA}s.} + +\item{y}{Numeric vector of length \code{n_obs}.} + +\item{options}{\code{NULL} for default \code{\link[=LsmrOptions]{LsmrOptions()}} or an options object.} + +\item{weights}{Numeric vector of length \code{n_obs} or \code{NULL}.} + +\item{preconditioner}{Controls preconditioning. Five input forms are +accepted: \code{NULL} builds the default additive Schwarz preconditioner, +\code{PreconditionerConfig$Off} disables preconditioning, +\code{PreconditionerConfig$Diagonal} uses diagonal/Jacobi scaling, +\code{\link[=AdditiveSchwarz]{AdditiveSchwarz()}} overrides local-solver and reduction settings, and a +built \code{\link[=Preconditioner]{Preconditioner()}} object reuses an existing factorization.} +} +\value{ +A named list with fields \code{x}, \code{demeaned}, \code{converged}, +\code{iterations}, \code{residual}, \code{time_total}, \code{time_setup}, and \code{time_solve}. +} +\description{ +Computes fixed-effect coefficients by solving the normal equations +\eqn{D^T W D x = D^T W y} where \eqn{D} is the dummy-variable design +matrix implied by \code{categories} and \eqn{W} is the diagonal weight matrix. +} diff --git a/withinr/man/within_solve_batch.Rd b/withinr/man/within_solve_batch.Rd new file mode 100644 index 00000000..2adb419b --- /dev/null +++ b/withinr/man/within_solve_batch.Rd @@ -0,0 +1,40 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/solve.R +\name{within_solve_batch} +\alias{within_solve_batch} +\title{Solve fixed-effects normal equations for multiple response vectors} +\usage{ +within_solve_batch( + categories, + Y, + options = NULL, + weights = NULL, + preconditioner = NULL +) +} +\arguments{ +\item{categories}{Integer matrix of shape \verb{(n_obs, n_factors)}. Each column +contains \strong{1-based} factor level assignments. Values must be positive +integers with no \code{NA}s.} + +\item{Y}{Numeric matrix of shape \verb{(n_obs, k)}.} + +\item{options}{\code{NULL} for default \code{\link[=LsmrOptions]{LsmrOptions()}} or an options object.} + +\item{weights}{Numeric vector of length \code{n_obs} or \code{NULL}.} + +\item{preconditioner}{Controls preconditioning. Five input forms are +accepted: \code{NULL} builds the default additive Schwarz preconditioner, +\code{PreconditionerConfig$Off} disables preconditioning, +\code{PreconditionerConfig$Diagonal} uses diagonal/Jacobi scaling, +\code{\link[=AdditiveSchwarz]{AdditiveSchwarz()}} overrides local-solver and reduction settings, and a +built \code{\link[=Preconditioner]{Preconditioner()}} object reuses an existing factorization.} +} +\value{ +A named list with matrix fields \code{x} and \code{demeaned}, plus +\code{converged}, \code{iterations}, \code{residual}, \code{time_solve}, and \code{time_total}. +} +\description{ +Builds the operator and preconditioner once, then solves for each column +of \code{Y} in parallel. +} diff --git a/withinr/src/Makevars b/withinr/src/Makevars new file mode 100644 index 00000000..bd50c687 --- /dev/null +++ b/withinr/src/Makevars @@ -0,0 +1,44 @@ +TARGET_DIR = ./rust/target +LIBDIR = $(TARGET_DIR)/release +STATLIB = $(LIBDIR)/libwithinr.a +PKG_LIBS = -L$(LIBDIR) -lwithinr +CARGO_DEV_PATCH = --config 'patch.crates-io.within.path="../../crates/within"' + +all: C_clean + +$(SHLIB): $(STATLIB) + +CARGOTMP = $(CURDIR)/.cargo + +$(STATLIB): + if [ "$(NOT_CRAN)" = "true" ] || [ "$(WITHINR_DEV)" = "true" ]; then \ + export PATH="$(PATH):$(HOME)/.cargo/bin" && \ + cargo build --locked --lib --release --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR) $(CARGO_DEV_PATCH); \ + elif [ -d ./rust/vendor ]; then \ + mkdir -p $(CARGOTMP) && \ + cp rust/vendor-config.toml $(CARGOTMP)/config.toml && \ + export CARGO_HOME=$(CARGOTMP) && \ + export PATH="$(PATH):$(HOME)/.cargo/bin" && \ + cargo build --offline --locked --lib --release --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR); \ + elif [ -f ./rust/vendor.tar.xz ]; then \ + tar xf rust/vendor.tar.xz && \ + mkdir -p $(CARGOTMP) && \ + cp rust/vendor-config.toml $(CARGOTMP)/config.toml && \ + export CARGO_HOME=$(CARGOTMP) && \ + export PATH="$(PATH):$(HOME)/.cargo/bin" && \ + cargo build --offline --locked --lib --release --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR); \ + else \ + echo "ERROR: missing rust/vendor.tar.xz (or rust/vendor/)."; \ + echo "Run rextendr::vendor_crates(path = 'withinr') before install/check."; \ + exit 1; \ + fi + +# Also drop the staticlib so cargo always runs (its own cache keeps this +# cheap); otherwise a stale rust/target/release/libwithinr.a from an earlier +# build gets linked silently and Rust changes never reach the package. +C_clean: + rm -Rf $(SHLIB) $(OBJECTS) $(STATLIB) + +clean: + rm -Rf $(SHLIB) $(OBJECTS) $(STATLIB) \ + $(CARGOTMP) $(TARGET_DIR) diff --git a/withinr/src/Makevars.win b/withinr/src/Makevars.win new file mode 100644 index 00000000..4fcd243e --- /dev/null +++ b/withinr/src/Makevars.win @@ -0,0 +1,45 @@ +TARGET = $(subst 64,x86_64,$(subst 32,i686,$(WIN)))-pc-windows-gnu +TARGET_DIR = ./rust/target +LIBDIR = $(TARGET_DIR)/$(TARGET)/release +STATLIB = $(LIBDIR)/libwithinr.a +PKG_LIBS = -L$(LIBDIR) -lwithinr -lws2_32 -ladvapi32 -luserenv -lbcrypt -lntdll +CARGO_DEV_PATCH = --config 'patch.crates-io.within.path="../../crates/within"' + +all: C_clean + +$(SHLIB): $(STATLIB) + +CARGOTMP = $(CURDIR)/.cargo + +$(STATLIB): + if [ "$(NOT_CRAN)" = "true" ] || [ "$(WITHINR_DEV)" = "true" ]; then \ + export PATH="$(PATH):$(HOME)/.cargo/bin" && \ + cargo build --locked --lib --release --target=$(TARGET) --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR) $(CARGO_DEV_PATCH); \ + elif [ -d ./rust/vendor ]; then \ + mkdir -p $(CARGOTMP) && \ + cp rust/vendor-config.toml $(CARGOTMP)/config.toml && \ + export CARGO_HOME=$(CARGOTMP) && \ + export PATH="$(PATH):$(HOME)/.cargo/bin" && \ + cargo build --offline --locked --lib --release --target=$(TARGET) --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR); \ + elif [ -f ./rust/vendor.tar.xz ]; then \ + tar xf rust/vendor.tar.xz && \ + mkdir -p $(CARGOTMP) && \ + cp rust/vendor-config.toml $(CARGOTMP)/config.toml && \ + export CARGO_HOME=$(CARGOTMP) && \ + export PATH="$(PATH):$(HOME)/.cargo/bin" && \ + cargo build --offline --locked --lib --release --target=$(TARGET) --manifest-path=./rust/Cargo.toml --target-dir $(TARGET_DIR); \ + else \ + echo "ERROR: missing rust/vendor.tar.xz (or rust/vendor/)."; \ + echo "Run rextendr::vendor_crates(path = 'withinr') before install/check."; \ + exit 1; \ + fi + +# Also drop the staticlib so cargo always runs (its own cache keeps this +# cheap); otherwise a stale rust/target/release/libwithinr.a from an earlier +# build gets linked silently and Rust changes never reach the package. +C_clean: + rm -Rf $(SHLIB) $(OBJECTS) $(STATLIB) + +clean: + rm -Rf $(SHLIB) $(OBJECTS) $(STATLIB) \ + $(CARGOTMP) $(TARGET_DIR) diff --git a/withinr/src/entrypoint.c b/withinr/src/entrypoint.c new file mode 100644 index 00000000..23a81c1f --- /dev/null +++ b/withinr/src/entrypoint.c @@ -0,0 +1,9 @@ +// Forward routine registration from C to the Rust static library. +// R's dynamic loader calls R_init_withinr(); extendr generates +// R_init_withinr_extendr() inside the Rust code. + +void R_init_withinr_extendr(void *dll); + +void R_init_withinr(void *dll) { + R_init_withinr_extendr(dll); +} diff --git a/withinr/src/rust/Cargo.lock b/withinr/src/rust/Cargo.lock new file mode 100644 index 00000000..75b81a0a --- /dev/null +++ b/withinr/src/rust/Cargo.lock @@ -0,0 +1,1900 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "approx-chol" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fc1b78930a040edb9d77183530c9ebe65cffb929469f044f5bba8dd4fbb8f7" +dependencies = [ + "num-traits", + "rand 0.10.1", + "serde", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "atomic-wait" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55b94919229f2c42292fd71ffa4b75e83193bffdd77b1e858cd55fd2d0b0ea8" +dependencies = [ + "libc", + "windows-sys 0.42.0", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "defer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "930c7171c8df9fb1782bdf9b918ed9ed2d33d1d22300abb754f9085bc48bf8e8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyn-stack" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c4713e43e2886ba72b8271aa66c93d722116acf7a75555cce11dcde84388fe8" +dependencies = [ + "bytemuck", + "dyn-stack-macros", +] + +[[package]] +name = "dyn-stack-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d926b4d407d372f141f93bb444696142c29d32962ccbd3531117cf3aa0bfa9" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "enum-as-inner" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equator" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c35da53b5a021d2484a7cc49b2ac7f2d840f8236a286f84202369bd338d761ea" +dependencies = [ + "equator-macro 0.2.1", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro 0.4.2", +] + +[[package]] +name = "equator" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02da895aab06bbebefb6b2595f6d637b18c9ff629b4cd840965bb3164e4194b0" +dependencies = [ + "equator-macro 0.6.0", +] + +[[package]] +name = "equator-macro" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bf679796c0322556351f287a51b49e48f7c4986e727b5dd78c972d30e2e16cc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equator-macro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b14b339eb76d07f052cdbad76ca7c1310e56173a138095d3bf42a23c06ef5d8" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "extendr-api" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea54977c6e37236839ffcbc20b5dcea58aa32ae43fbef54a81e1011dc6b19061" +dependencies = [ + "extendr-ffi", + "extendr-macros", + "once_cell", + "paste", +] + +[[package]] +name = "extendr-ffi" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76777174a82bdb3e66872f580687d3d0143eed1df9b9cd72b321b9596a23ca7" + +[[package]] +name = "extendr-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "661cc4ae29de9c4dafe16cfcbda1dbb9f31bd2568f96ebad232cc1f9bcc8b04d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "faer" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02d2ecfb80b6f8b0c569e36988a052e64b14d8def9d372390b014e8bf79f299a" +dependencies = [ + "bytemuck", + "dyn-stack", + "equator 0.6.0", + "faer-traits", + "gemm", + "generativity", + "libm", + "nano-gemm", + "npyz", + "num-complex", + "num-traits", + "private-gemm-x86", + "pulp", + "rand 0.9.4", + "rand_distr", + "rayon", + "reborrow", + "spindle", +] + +[[package]] +name = "faer-traits" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b87d23ed7ab1f26c0cba0e5b9e061a796fbb7dc170fa8bee6970055a1308bb0f" +dependencies = [ + "bytemuck", + "dyn-stack", + "generativity", + "libm", + "num-complex", + "num-traits", + "pulp", + "qd", + "reborrow", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "gemm" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa0673db364b12263d103b68337a68fbecc541d6f6b61ba72fe438654709eacb" +dependencies = [ + "dyn-stack", + "gemm-c32", + "gemm-c64", + "gemm-common", + "gemm-f16", + "gemm-f32", + "gemm-f64", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "086936dbdcb99e37aad81d320f98f670e53c1e55a98bee70573e83f95beb128c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-c64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8aeeeec425959bda4d9827664029ba1501a90a0d1e6228e48bef741db3a3f" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-common" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88027625910cc9b1085aaaa1c4bc46bb3a36aad323452b33c25b5e4e7c8e2a3e" +dependencies = [ + "bytemuck", + "dyn-stack", + "half", + "libm", + "num-complex", + "num-traits", + "once_cell", + "paste", + "pulp", + "raw-cpuid", + "rayon", + "seq-macro", + "sysctl", +] + +[[package]] +name = "gemm-f16" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3df7a55202e6cd6739d82ae3399c8e0c7e1402859b30e4cb780e61525d9486e" +dependencies = [ + "dyn-stack", + "gemm-common", + "gemm-f32", + "half", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "rayon", + "seq-macro", +] + +[[package]] +name = "gemm-f32" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02e0b8c9da1fbec6e3e3ab2ce6bc259ef18eb5f6f0d3e4edf54b75f9fd41a81c" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "gemm-f64" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "056131e8f2a521bfab322f804ccd652520c79700d81209e9d9275bbdecaadc6a" +dependencies = [ + "dyn-stack", + "gemm-common", + "num-complex", + "num-traits", + "paste", + "raw-cpuid", + "seq-macro", +] + +[[package]] +name = "generativity" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5881e4c3c2433fe4905bb19cfd2b5d49d4248274862b68c27c33d9ba4e13f9ec" + +[[package]] +name = "generator" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "bytemuck", + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "interpol" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb58032ba748f4010d15912a1855a8a0b1ba9eaad3395b0c171c09b3b356ae50" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "nano-gemm" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e04345dc84b498ff89fe0d38543d1f170da9e43a2c2bcee73a0f9069f72d081" +dependencies = [ + "equator 0.2.2", + "nano-gemm-c32", + "nano-gemm-c64", + "nano-gemm-codegen", + "nano-gemm-core", + "nano-gemm-f32", + "nano-gemm-f64", + "num-complex", +] + +[[package]] +name = "nano-gemm-c32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0775b1e2520e64deee8fc78b7732e3091fb7585017c0b0f9f4b451757bbbc562" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", +] + +[[package]] +name = "nano-gemm-c64" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9af49a20d58816e6b5ee65f64142e50edb5eba152678d4bb7377fcbf63f8437a" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", + "num-complex", +] + +[[package]] +name = "nano-gemm-codegen" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cc8d495c791627779477a2cf5df60049f5b165342610eb0d76bee5ff5c5d74c" + +[[package]] +name = "nano-gemm-core" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d998dfa644de87a0f8660e5ea511d7cb5c33b5a2d9847b7af57a2565105089f0" + +[[package]] +name = "nano-gemm-f32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879d962e79bc8952e4ad21ca4845a21132540ed3f5e01184b2ff7f720e666523" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", +] + +[[package]] +name = "nano-gemm-f64" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9a513473dce7dc00c7e7c318481ca4494034e76997218d8dad51bd9f007a815" +dependencies = [ + "nano-gemm-codegen", + "nano-gemm-core", +] + +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "npyz" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f0e759e014e630f90af745101b614f761306ddc541681e546649068e25ec1b9" +dependencies = [ + "byteorder", + "num-bigint", + "py_literal", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", + "rand 0.8.6", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "private-gemm-x86" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0af8c3e5087969c323f667ccb4b789fa0954f5aa650550e38e81cf9108be21b5" +dependencies = [ + "crossbeam", + "defer", + "interpol", + "num_cpus", + "raw-cpuid", + "rayon", + "spindle", + "sysctl", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulp" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e205bb30d5b916c55e584c22201771bcf2bad9aabd5d4127f38387140c38632" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e24eee682d89fb193496edf918a7f407d30175b2e785fe057e4392dfd182e0" + +[[package]] +name = "py_literal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "102df7a3d46db9d3891f178dcc826dc270a6746277a9ae6436f8d29fd490a8e1" +dependencies = [ + "num-bigint", + "num-complex", + "num-traits", + "pest", + "pest_derive", +] + +[[package]] +name = "qd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f1304a5aecdcfe9ee72fbba90aa37b3aa067a69d14cb7f3d9deada0be7c07c" +dependencies = [ + "bytemuck", + "libm", + "num-traits", + "pulp", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_distr" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +dependencies = [ + "num-traits", + "rand 0.9.4", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schwarz-precond" +version = "0.2.0" +dependencies = [ + "faer", + "rayon", + "serde", + "thiserror 2.0.18", + "thread_local", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "seq-macro" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spindle" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aaca3d8aa5387a6eba861fbf984af5348d9df5d940c25c6366b19556fdf64" +dependencies = [ + "atomic-wait", + "crossbeam", + "equator 0.4.2", + "loom", + "rayon", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sysctl" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" +dependencies = [ + "bitflags", + "byteorder", + "enum-as-inner", + "libc", + "thiserror 1.0.69", + "walkdir", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.42.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a3e1820f08b8513f676f7ab6c1f99ff312fb97b553d30ff4dd86f9f15728aa7" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "within" +version = "0.2.0" +dependencies = [ + "approx-chol", + "faer", + "ndarray", + "portable-atomic", + "postcard", + "rayon", + "schwarz-precond", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "withinr" +version = "0.1.0" +dependencies = [ + "extendr-api", + "ndarray", + "postcard", + "within", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/withinr/src/rust/Cargo.toml b/withinr/src/rust/Cargo.toml new file mode 100644 index 00000000..20c598df --- /dev/null +++ b/withinr/src/rust/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "withinr" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +crate-type = ["staticlib"] + +# Detach from the repo root workspace to avoid nested-workspace Cargo errors. +[workspace] + +[dependencies] +extendr-api = "0.8" +ndarray = "0.16" +postcard = { version = "1.1", features = ["use-std"] } + +within = "0.2.0" diff --git a/withinr/src/rust/src/api.rs b/withinr/src/rust/src/api.rs new file mode 100644 index 00000000..52bfc722 --- /dev/null +++ b/withinr/src/rust/src/api.rs @@ -0,0 +1,216 @@ +//! Solve entry points and the persistent solver handle. +//! +//! Mirrors `crates/within-py/src/api.rs`: the one-shot `solve`/`solve_batch` +//! bindings plus the persistent `Solver` surface. + +use extendr_api::prelude::*; + +use within::observation::FactorMajorStore; +use within::{ + solve as solve_native, solve_batch as solve_batch_native, Design, Preconditioner, + Solver as NativeSolver, +}; + +use crate::config::{parse_lsmr_options, parse_preconditioner, PreconditionerArg}; +use crate::convert::{ + cast_categories, categories_view, err, extract_weights, factor_major_store, or_throw, + usize_to_i32, weights_slice, +}; +use crate::results::{batch_result_to_list, result_to_list}; + +/// Owned-store solver held behind the R external pointer. +pub(crate) type SolverHandle = NativeSolver; + +/// Borrow the columns of an R numeric matrix as slices. +/// +/// R matrices are column-major, so every RHS column is already contiguous — +/// no copy needed (the Python glue needs Cow columns for strided input; R +/// input never is). +fn matrix_columns(matrix: &RMatrix) -> Vec<&[f64]> { + let data = matrix.data(); + let nrow = matrix.nrows(); + (0..matrix.ncols()) + .map(|j| &data[j * nrow..(j + 1) * nrow]) + .collect() +} + +// --------------------------------------------------------------------------- +// One-shot solve API +// --------------------------------------------------------------------------- + +// Solve fixed-effects normal equations for a single response vector. +#[extendr] +fn solve_impl( + categories: RMatrix, + y: &[f64], + options: Robj, + weights: Robj, + preconditioner: Robj, +) -> List { + or_throw((|| -> Result { + let cats_u32 = cast_categories(categories.data())?; + let cats = categories_view(&categories, &cats_u32)?; + let lsmr = parse_lsmr_options(&options)?; + let weights = weights_slice(&weights)?; + + match parse_preconditioner(preconditioner)? { + PreconditionerArg::Config(config) => { + solve_native(cats, y, weights, &lsmr, config.as_ref()) + .map_err(|e| err(e.to_string())) + .and_then(result_to_list) + } + PreconditionerArg::Built(built) => solve_native(cats, y, weights, &lsmr, built) + .map_err(|e| err(e.to_string())) + .and_then(result_to_list), + } + })()) +} + +// Solve fixed-effects normal equations for multiple response vectors. +#[extendr] +fn solve_batch_impl( + categories: RMatrix, + y_matrix: RMatrix, + options: Robj, + weights: Robj, + preconditioner: Robj, +) -> List { + or_throw((|| -> Result { + if y_matrix.nrows() != categories.nrows() { + return Err(err(format!( + "Y has {} rows but categories has {} observations", + y_matrix.nrows(), + categories.nrows() + ))); + } + + let cats_u32 = cast_categories(categories.data())?; + let cats = categories_view(&categories, &cats_u32)?; + let lsmr = parse_lsmr_options(&options)?; + let weights = weights_slice(&weights)?; + let column_refs = matrix_columns(&y_matrix); + + match parse_preconditioner(preconditioner)? { + PreconditionerArg::Config(config) => { + solve_batch_native(cats, &column_refs, weights, &lsmr, config.as_ref()) + .map_err(|e| err(e.to_string())) + .and_then(batch_result_to_list) + } + PreconditionerArg::Built(built) => { + solve_batch_native(cats, &column_refs, weights, &lsmr, built) + .map_err(|e| err(e.to_string())) + .and_then(batch_result_to_list) + } + } + })()) +} + +// --------------------------------------------------------------------------- +// Persistent solver API +// --------------------------------------------------------------------------- + +// Build a persistent solver that can be reused across multiple solves. +#[extendr] +fn solver_new_impl( + categories: RMatrix, + weights: Robj, + preconditioner: Robj, +) -> ExternalPtr { + or_throw((|| -> Result> { + let weights = extract_weights(weights)?; + let store = factor_major_store(&categories)?; + let design = Design::from_store(store).map_err(|e| err(e.to_string()))?; + + let solver = match parse_preconditioner(preconditioner)? { + PreconditionerArg::Config(config) => { + NativeSolver::new(design, weights, config.as_ref()) + } + PreconditionerArg::Built(built) => NativeSolver::new(design, weights, built), + } + .map_err(|e| err(e.to_string()))?; + + Ok(ExternalPtr::new(solver)) + })()) +} + +// Solve one response vector with a persistent solver. +#[extendr] +fn solver_solve_impl(solver: ExternalPtr, y: &[f64], options: Robj) -> List { + or_throw((|| -> Result { + let lsmr = parse_lsmr_options(&options)?; + solver + .try_addr()? + .solve(y, &lsmr) + .map_err(|e| err(e.to_string())) + .and_then(result_to_list) + })()) +} + +// Solve multiple response vectors with a persistent solver. +#[extendr] +fn solver_solve_batch_impl( + solver: ExternalPtr, + y_matrix: RMatrix, + options: Robj, +) -> List { + or_throw((|| -> Result { + let lsmr = parse_lsmr_options(&options)?; + let handle = solver.try_addr()?; + + if y_matrix.nrows() != handle.n_obs() { + return Err(err(format!( + "Y has {} rows but solver has {} observations", + y_matrix.nrows(), + handle.n_obs() + ))); + } + + let column_refs = matrix_columns(&y_matrix); + + handle + .solve_batch(&column_refs, &lsmr) + .map_err(|e| err(e.to_string())) + .and_then(batch_result_to_list) + })()) +} + +// Return the built preconditioner from a persistent solver, or NULL. +#[extendr] +fn solver_preconditioner_impl( + solver: ExternalPtr, +) -> Option> { + or_throw((|| -> Result>> { + Ok(solver + .try_addr()? + .preconditioner() + .map(|preconditioner| ExternalPtr::new(preconditioner.clone()))) + })()) +} + +// Number of DOFs (coefficients) in the persistent solver. +#[extendr] +fn solver_n_dofs_impl(solver: ExternalPtr) -> i32 { + or_throw((|| -> Result { + usize_to_i32(solver.try_addr()?.n_dofs(), "n_dofs") + })()) +} + +// Number of observations in the persistent solver. +#[extendr] +fn solver_n_obs_impl(solver: ExternalPtr) -> i32 { + or_throw((|| -> Result { + usize_to_i32(solver.try_addr()?.n_obs(), "n_obs") + })()) +} + +extendr_module! { + mod api; + fn solve_impl; + fn solve_batch_impl; + fn solver_new_impl; + fn solver_solve_impl; + fn solver_solve_batch_impl; + fn solver_preconditioner_impl; + fn solver_n_dofs_impl; + fn solver_n_obs_impl; +} diff --git a/withinr/src/rust/src/config.rs b/withinr/src/rust/src/config.rs new file mode 100644 index 00000000..d00898f5 --- /dev/null +++ b/withinr/src/rust/src/config.rs @@ -0,0 +1,294 @@ +//! R config objects → native [`within::config`] conversions, plus the +//! built-preconditioner handle exposed to R. +//! +//! Mirrors `crates/within-py/src/config.rs`: this is where R-facing +//! configuration values (classed lists, shortcut strings) are parsed into +//! native `within` config types. + +use extendr_api::prelude::*; + +use within::config::{ + ApproxCholConfig, ApproxSchurConfig, LocalSolverConfig, LsmrOptions, PreconditionerConfig, + ReductionStrategy, +}; +use within::Preconditioner; + +use crate::convert::{ + err, get_field_or_null, or_throw, parse_nonnegative_integer, parse_optional_u32, + parse_positive_f64, parse_positive_u32, usize_to_i32, +}; + +/// Native interpretation of the R `preconditioner` argument. +/// +/// A pre-built [`Preconditioner`] takes the reuse path; everything else is a +/// [`PreconditionerConfig`] (or `None` for the library default) to build from. +/// Mirrors `PrecondInput` on the Python side. +pub(crate) enum PreconditionerArg { + Config(Option), + Built(Preconditioner), +} + +// --------------------------------------------------------------------------- +// LSMR options +// --------------------------------------------------------------------------- + +/// Resolve the R `options` argument into native [`LsmrOptions`]. +/// +/// `NULL` means library defaults; anything else must be created by +/// `LsmrOptions(...)`. Mirrors `resolve_lsmr_config` on the Python side; +/// value validation lives here, not in the R constructor. +pub(crate) fn parse_lsmr_options(options: &Robj) -> Result { + if options.is_null() { + return Ok(LsmrOptions::default()); + } + if !options.inherits("within_lsmr_options") { + return Err(err("options must be created by LsmrOptions(...) or NULL")); + } + + let tol = parse_positive_f64(&get_field_or_null(options, "tol"), "tol")?; + + let maxiter = parse_nonnegative_integer(&get_field_or_null(options, "maxiter"), "maxiter")?; + if maxiter < 1 { + return Err(err("maxiter must be >= 1")); + } + + let local_size_obj = get_field_or_null(options, "local_size"); + let local_size = if local_size_obj.is_null() { + None + } else { + let value = parse_nonnegative_integer(&local_size_obj, "local_size")?; + if value < 1 { + return Err(err("local_size must be NULL or >= 1")); + } + Some(value as usize) + }; + + Ok(LsmrOptions { + tol, + maxiter: maxiter as usize, + local_size, + }) +} + +// --------------------------------------------------------------------------- +// Preconditioner config conversion +// --------------------------------------------------------------------------- + +fn parse_approx_chol_config(obj: &Robj) -> Result { + if obj.is_null() { + return Ok(LocalSolverConfig::default().approx_chol); + } + if !obj.inherits("within_approx_chol_config") { + return Err(err( + "approx_chol must be an object created by ApproxCholConfig(...) or NULL", + )); + } + + let seed_obj = get_field_or_null(obj, "seed"); + let split_merge_obj = get_field_or_null(obj, "split_merge"); + + let seed = parse_nonnegative_integer(&seed_obj, "approx_chol$seed")?; + + Ok(ApproxCholConfig { + seed: seed as u64, + split_merge: parse_optional_u32(&split_merge_obj, "approx_chol$split_merge")?, + }) +} + +fn parse_approx_schur_config(obj: &Robj) -> Result> { + if obj.is_null() { + return Ok(None); + } + if !obj.inherits("within_approx_schur_config") { + return Err(err( + "approx_schur must be an object created by ApproxSchurConfig(...) or NULL", + )); + } + + let seed_obj = get_field_or_null(obj, "seed"); + let split_obj = get_field_or_null(obj, "split"); + + let seed = parse_nonnegative_integer(&seed_obj, "approx_schur$seed")?; + + Ok(Some(ApproxSchurConfig { + seed: seed as u64, + split: parse_positive_u32(&split_obj, "approx_schur$split")?, + })) +} + +fn parse_local_solver_config(obj: &Robj) -> Result { + if obj.is_null() { + return Ok(LocalSolverConfig::default()); + } + if !obj.inherits("within_local_solver_config") { + return Err(err( + "local_solver must be an object created by LocalSolverConfig(...) or NULL", + )); + } + + let approx_chol_obj = get_field_or_null(obj, "approx_chol"); + let approx_schur_obj = get_field_or_null(obj, "approx_schur"); + let dense_threshold_obj = get_field_or_null(obj, "dense_threshold"); + + let dense_threshold = if dense_threshold_obj.is_null() { + LocalSolverConfig::default().dense_threshold + } else { + let value = + parse_nonnegative_integer(&dense_threshold_obj, "local_solver$dense_threshold")?; + usize::try_from(value).map_err(|_| err("local_solver$dense_threshold is too large"))? + }; + + Ok(LocalSolverConfig { + approx_chol: parse_approx_chol_config(&approx_chol_obj)?, + approx_schur: parse_approx_schur_config(&approx_schur_obj)?, + dense_threshold, + }) +} + +fn parse_reduction_strategy(obj: &Robj) -> Result { + if obj.is_null() { + return Ok(ReductionStrategy::default()); + } + let Some(name) = obj.as_str() else { + return Err(err("reduction must be a character scalar")); + }; + match name { + "auto" => Ok(ReductionStrategy::Auto), + "atomic_scatter" => Ok(ReductionStrategy::AtomicScatter), + "parallel_reduction" => Ok(ReductionStrategy::ParallelReduction), + other => Err(err(format!( + "unknown reduction strategy '{other}'; use 'auto', 'atomic_scatter', or 'parallel_reduction'" + ))), + } +} + +fn parse_preconditioner_string(name: &str) -> Result { + match name { + "additive" => Ok(PreconditionerArg::Config(Some( + PreconditionerConfig::default(), + ))), + "off" => Ok(PreconditionerArg::Config(Some(PreconditionerConfig::Off))), + "diagonal" => Ok(PreconditionerArg::Config(Some( + PreconditionerConfig::Diagonal, + ))), + other => Err(err(format!( + "unknown preconditioner '{other}'; use PreconditionerConfig$Additive, \ + PreconditionerConfig$Off, PreconditionerConfig$Diagonal, AdditiveSchwarz(...), \ + a Preconditioner, or NULL" + ))), + } +} + +pub(crate) fn parse_preconditioner(preconditioner: Robj) -> Result { + if preconditioner.is_null() { + return Ok(PreconditionerArg::Config(None)); + } + + // A `within_preconditioner` R object is an environment holding the + // external pointer in `$ptr`; a bare external pointer is accepted too. + let ptr_obj = if preconditioner.inherits("within_preconditioner") { + get_field_or_null(&preconditioner, "ptr") + } else { + preconditioner.clone() + }; + if let Ok(ptr) = ExternalPtr::::try_from(ptr_obj) { + return Ok(PreconditionerArg::Built(ptr.try_addr()?.clone())); + } + + if let Some(name) = preconditioner.as_str() { + return parse_preconditioner_string(name); + } + + if preconditioner.inherits("within_additive_schwarz") { + let local_solver = + parse_local_solver_config(&get_field_or_null(&preconditioner, "local_solver"))?; + let reduction = parse_reduction_strategy(&get_field_or_null(&preconditioner, "reduction"))?; + return Ok(PreconditionerArg::Config(Some( + PreconditionerConfig::Additive { + local_solver, + reduction, + }, + ))); + } + + Err(err( + "preconditioner must be NULL, 'additive', 'off', 'diagonal', AdditiveSchwarz(...), or a Preconditioner", + )) +} + +// --------------------------------------------------------------------------- +// Preconditioner handle API +// --------------------------------------------------------------------------- + +// Apply a built preconditioner: y = M^{-1} x. +#[extendr] +fn preconditioner_apply_impl(preconditioner: ExternalPtr, x: &[f64]) -> Vec { + or_throw((|| -> Result> { + let inner = preconditioner.try_addr()?; + if x.len() != inner.ncols() { + return Err(err(format!( + "x has length {} but preconditioner expects {}", + x.len(), + inner.ncols() + ))); + } + let mut y = vec![0.0; inner.nrows()]; + inner.apply(x, &mut y).map_err(|e| err(e.to_string()))?; + Ok(y) + })()) +} + +// Number of rows in a built preconditioner. +#[extendr] +fn preconditioner_nrows_impl(preconditioner: ExternalPtr) -> i32 { + or_throw((|| -> Result { + usize_to_i32(preconditioner.try_addr()?.nrows(), "nrows") + })()) +} + +// Number of columns in a built preconditioner. +#[extendr] +fn preconditioner_ncols_impl(preconditioner: ExternalPtr) -> i32 { + or_throw((|| -> Result { + usize_to_i32(preconditioner.try_addr()?.ncols(), "ncols") + })()) +} + +// Concrete preconditioner variant name (used by the R print method, matching +// the Python __repr__). +#[extendr] +fn preconditioner_variant_impl(preconditioner: ExternalPtr) -> String { + or_throw((|| -> Result { + Ok(preconditioner.try_addr()?.variant_name().to_string()) + })()) +} + +// Serialize a built preconditioner into raw bytes. +#[extendr] +fn preconditioner_serialize_impl(preconditioner: ExternalPtr) -> Raw { + or_throw((|| -> Result { + let bytes = + postcard::to_stdvec(preconditioner.try_addr()?).map_err(|e| err(e.to_string()))?; + Ok(Raw::from_bytes(&bytes)) + })()) +} + +// Deserialize a built preconditioner from raw bytes. +#[extendr] +fn preconditioner_deserialize_impl(data: Raw) -> ExternalPtr { + or_throw((|| -> Result> { + let preconditioner: Preconditioner = + postcard::from_bytes(data.as_slice()).map_err(|e| err(e.to_string()))?; + Ok(ExternalPtr::new(preconditioner)) + })()) +} + +extendr_module! { + mod config; + fn preconditioner_apply_impl; + fn preconditioner_nrows_impl; + fn preconditioner_ncols_impl; + fn preconditioner_variant_impl; + fn preconditioner_serialize_impl; + fn preconditioner_deserialize_impl; +} diff --git a/withinr/src/rust/src/convert.rs b/withinr/src/rust/src/convert.rs new file mode 100644 index 00000000..4c6b43b8 --- /dev/null +++ b/withinr/src/rust/src/convert.rs @@ -0,0 +1,143 @@ +//! Shared R ↔ Rust coercion helpers and error plumbing for the bridge. +//! +//! R users pass **1-based** integer category matrices. This module validates +//! that all entries are >= 1 and contain no `NA`, then subtracts 1 to produce +//! the **0-based** `u32` indices expected by the Rust solver. + +use extendr_api::prelude::*; +use ndarray::{ArrayView2, ShapeBuilder}; + +use within::observation::FactorMajorStore; + +pub(crate) fn err(message: impl Into) -> Error { + Error::Other(message.into()) +} + +/// Raise `Err` as an R error, preserving its message. +/// +/// extendr's default handling of `Result` return values unwraps them (a +/// panic), which surfaces in R as a generic "User function panicked" message. +/// Every `#[extendr]` entry point therefore wraps its fallible body in a +/// `?`-friendly closure and funnels the outcome through this helper instead. +pub(crate) fn or_throw(result: Result) -> T { + match result { + Ok(value) => value, + Err(error) => throw_r_error(error.to_string()), + } +} + +pub(crate) fn usize_to_i32(value: usize, name: &str) -> Result { + i32::try_from(value).map_err(|_| err(format!("{name} exceeds i32 range"))) +} + +pub(crate) fn parse_positive_f64(field: &Robj, name: &str) -> Result { + let value = if let Some(value) = field.as_real() { + value + } else if let Some(value) = field.as_integer() { + value as f64 + } else { + return Err(err(format!("{name} must be a positive finite number"))); + }; + + if !value.is_finite() || value <= 0.0 { + return Err(err(format!("{name} must be a positive finite number"))); + } + Ok(value) +} + +pub(crate) fn parse_nonnegative_integer(field: &Robj, name: &str) -> Result { + let value = if let Some(value) = field.as_integer() { + value as f64 + } else if let Some(value) = field.as_real() { + value + } else { + return Err(err(format!("{name} must be numeric"))); + }; + + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 { + return Err(err(format!("{name} must be a non-negative integer"))); + } + Ok(value as i64) +} + +pub(crate) fn parse_positive_u32(field: &Robj, name: &str) -> Result { + let value = parse_nonnegative_integer(field, name)?; + if value < 1 || value > u32::MAX as i64 { + return Err(err(format!("{name} must be in 1..={}", u32::MAX))); + } + Ok(value as u32) +} + +pub(crate) fn parse_optional_u32(field: &Robj, name: &str) -> Result> { + if field.is_null() { + return Ok(None); + } + Ok(Some(parse_positive_u32(field, name)?)) +} + +pub(crate) fn get_field_or_null(obj: &Robj, field: &str) -> Robj { + obj.dollar(field).unwrap_or_else(|_| nil_value()) +} + +/// Owned weights for the persistent solver (which stores them across solves). +pub(crate) fn extract_weights(weights: Robj) -> Result>> { + if weights.is_null() { + return Ok(None); + } + weights + .as_real_vector() + .map(Some) + .ok_or_else(|| err("weights must be a numeric vector or NULL")) +} + +/// Borrowed weights for the one-shot paths (`NULL` → `None`), avoiding a copy. +pub(crate) fn weights_slice(weights: &Robj) -> Result> { + if weights.is_null() { + return Ok(None); + } + weights + .as_real_slice() + .map(Some) + .ok_or_else(|| err("weights must be a numeric vector or NULL")) +} + +/// Convert an R integer matrix (1-based, column-major) to a 0-based `u32` +/// buffer preserving R's column-major layout. +pub(crate) fn cast_categories(data: &[i32]) -> Result> { + let mut out = Vec::with_capacity(data.len()); + for (pos, &value) in data.iter().enumerate() { + if value == i32::MIN { + return Err(err(format!( + "categories must not contain NA values (found at position {})", + pos + 1 + ))); + } + if value < 1 { + return Err(err(format!( + "categories must be >= 1 (1-based); found {value} at position {}", + pos + 1 + ))); + } + out.push((value - 1) as u32); + } + Ok(out) +} + +pub(crate) fn categories_view<'a>( + categories: &RMatrix, + cats_u32: &'a [u32], +) -> Result> { + ArrayView2::from_shape((categories.nrows(), categories.ncols()).f(), cats_u32) + .map_err(|e| err(e.to_string())) +} + +pub(crate) fn factor_major_store(categories: &RMatrix) -> Result { + let n_obs = categories.nrows(); + let n_factors = categories.ncols(); + let cats_u32 = cast_categories(categories.data())?; + let cats = categories_view(categories, &cats_u32)?; + let factor_levels = (0..n_factors) + .map(|factor| cats.column(factor).iter().copied().collect()) + .collect(); + FactorMajorStore::new(factor_levels, n_obs).map_err(|e| err(e.to_string())) +} diff --git a/withinr/src/rust/src/lib.rs b/withinr/src/rust/src/lib.rs new file mode 100644 index 00000000..dbe67977 --- /dev/null +++ b/withinr/src/rust/src/lib.rs @@ -0,0 +1,23 @@ +// Extendr bridge exposing the `within` Rust crate to R as the `withinr` package. +// +// The bridge mirrors the Python binding (`crates/within-py`) in both shape and +// module layout, so core API changes port mechanically between the two: +// +// - `api` — solve entry points and the persistent solver handle +// - `config` — R config objects → native `within` config conversions, plus +// the built-preconditioner handle +// - `convert` — shared R ↔ Rust coercion helpers and error plumbing +// - `results` — native result → R list conversions + +use extendr_api::prelude::*; + +mod api; +mod config; +mod convert; +mod results; + +extendr_module! { + mod withinr; + use api; + use config; +} diff --git a/withinr/src/rust/src/results.rs b/withinr/src/rust/src/results.rs new file mode 100644 index 00000000..0569f66c --- /dev/null +++ b/withinr/src/rust/src/results.rs @@ -0,0 +1,46 @@ +//! Native result → R list conversions. + +use extendr_api::prelude::*; + +use within::{BatchSolveResult, SolveResult}; + +use crate::convert::usize_to_i32; + +pub(crate) fn result_to_list(result: SolveResult) -> Result { + Ok(list!( + x = result.x, + demeaned = result.demeaned, + converged = result.converged, + iterations = usize_to_i32(result.iterations, "iterations")?, + residual = result.residual, + time_total = result.time_total, + time_setup = result.time_setup, + time_solve = result.time_solve + )) +} + +pub(crate) fn batch_result_to_list(result: BatchSolveResult) -> Result { + let n_rhs = result.converged.len(); + + let mut x = RMatrix::new(result.n_dofs, n_rhs); + x.data_mut().copy_from_slice(&result.x); + + let mut demeaned = RMatrix::new(result.n_obs, n_rhs); + demeaned.data_mut().copy_from_slice(&result.demeaned); + + let iterations = result + .iterations + .iter() + .map(|&value| usize_to_i32(value, "iterations")) + .collect::>>()?; + + Ok(list!( + x = x, + demeaned = demeaned, + converged = result.converged, + iterations = iterations, + residual = result.residual, + time_solve = result.time_solve, + time_total = result.time_total + )) +} diff --git a/withinr/src/rust/vendor-config.toml b/withinr/src/rust/vendor-config.toml new file mode 100644 index 00000000..95f0729d --- /dev/null +++ b/withinr/src/rust/vendor-config.toml @@ -0,0 +1,8 @@ +[source.crates-io] +replace-with = "vendored-sources" + +[source.vendored-sources] +directory = "vendor" + +[patch.crates-io] +within = { path = "vendor/within" } diff --git a/withinr/tests/run_tests.R b/withinr/tests/run_tests.R new file mode 100644 index 00000000..fc214087 --- /dev/null +++ b/withinr/tests/run_tests.R @@ -0,0 +1,249 @@ +assert_true <- function(cond, msg) { + if (!isTRUE(cond)) stop(msg, call. = FALSE) +} + +assert_equal <- function(a, b, tol = 0, msg = "Values are not equal.") { + ok <- isTRUE(all.equal(a, b, tolerance = tol, check.attributes = FALSE)) + if (!ok) stop(msg, call. = FALSE) +} + +assert_error <- function(expr, contains = NULL, msg = "Expected an error, but none occurred.") { + got <- tryCatch( + { + force(expr) + NULL + }, + error = function(e) e + ) + if (is.null(got)) stop(msg, call. = FALSE) + if (!is.null(contains) && !grepl(contains, conditionMessage(got), fixed = TRUE)) { + stop( + sprintf("Expected error containing '%s', got: %s", contains, conditionMessage(got)), + call. = FALSE + ) + } +} + +assert_all_converged <- function(result, msg) { + assert_true(all(result$converged), msg) +} + +withinr_run_tests <- function(verbose = TRUE) { + if (!requireNamespace("withinr", quietly = TRUE)) { + stop("Package 'withinr' must be installed or loaded first.", call. = FALSE) + } + + cats_2x2 <- matrix(c(1L, 1L, 2L, 2L, + 1L, 2L, 1L, 2L), ncol = 2) + y_simple <- c(1.0, 2.0, 3.0, 4.0) + + # solve() smoke + r <- withinr::within_solve(cats_2x2, y_simple) + assert_true(is.list(r), "solve() did not return a list.") + assert_equal( + names(r), + c("x", "demeaned", "converged", "iterations", "residual", "time_total", "time_setup", "time_solve"), + msg = "solve() returned unexpected list fields." + ) + assert_true(isTRUE(r$converged), "solve() did not converge on smoke case.") + assert_true(is.double(r$x), "solve() x is not double.") + assert_true(is.double(r$demeaned), "solve() demeaned output is not double.") + assert_true(length(r$demeaned) == length(y_simple), "solve() demeaned length mismatch.") + assert_true(r$iterations >= 0L, "solve() iterations is negative.") + assert_true(r$residual >= 0, "solve() residual is negative.") + assert_true(r$time_total >= 0, "solve() time_total is negative.") + + opts <- withinr::LsmrOptions(tol = 1e-10, maxiter = 2000L, local_size = 2L) + assert_true(withinr::within_solve(cats_2x2, y_simple, options = opts)$converged, "Custom options failed.") + assert_true( + withinr::within_solve(cats_2x2, y_simple, preconditioner = withinr::PreconditionerConfig$Off)$converged, + "Unpreconditioned solve failed." + ) + assert_true( + withinr::within_solve(cats_2x2, y_simple, preconditioner = withinr::PreconditionerConfig$Diagonal)$converged, + "Diagonal preconditioner solve failed." + ) + schwarz <- withinr::AdditiveSchwarz( + local_solver = withinr::LocalSolverConfig( + approx_chol = withinr::ApproxCholConfig(split_merge = 2L), + approx_schur = withinr::ApproxSchurConfig(split = 1L) + ), + reduction = withinr::ReductionStrategy$Auto + ) + assert_true(withinr::within_solve(cats_2x2, y_simple, preconditioner = schwarz)$converged, "AdditiveSchwarz config failed.") + + w <- c(1.0, 2.0, 1.0, 2.0) + assert_true(withinr::within_solve(cats_2x2, y_simple, weights = w)$converged, "Weighted solve failed.") + + # solve() correctness checks + d <- withinr::within_solve(cats_2x2, y_simple)$demeaned + assert_equal(d[1] + d[2], 0, tol = 1e-6, msg = "Factor-1 group mean not centered (group 1).") + assert_equal(d[3] + d[4], 0, tol = 1e-6, msg = "Factor-1 group mean not centered (group 2).") + assert_equal(d[1] + d[3], 0, tol = 1e-6, msg = "Factor-2 group mean not centered (group 1).") + assert_equal(d[2] + d[4], 0, tol = 1e-6, msg = "Factor-2 group mean not centered (group 2).") + + r2 <- withinr::within_solve(cats_2x2, y_simple) + n1 <- 2L + y_hat <- r2$x[cats_2x2[, 1]] + r2$x[n1 + cats_2x2[, 2]] + assert_equal(y_hat + r2$demeaned, y_simple, tol = 1e-6, msg = "Reconstruction y != y_hat + demeaned.") + + # solve_batch() smoke + Y <- cbind(y_simple, rev(y_simple)) + b <- withinr::within_solve_batch(cats_2x2, Y) + assert_true(is.matrix(b$x), "solve_batch() x is not matrix.") + assert_true(is.matrix(b$demeaned), "solve_batch() demeaned are not matrix.") + assert_true(ncol(b$x) == 2L, "solve_batch() x column count mismatch.") + assert_true(ncol(b$demeaned) == 2L, "solve_batch() demeaned column count mismatch.") + assert_true(nrow(b$demeaned) == 4L, "solve_batch() demeaned row count mismatch.") + assert_true(length(b$converged) == 2L, "solve_batch() converged vector length mismatch.") + assert_true(all(b$converged), "solve_batch() did not converge for all RHS.") + + s1 <- withinr::within_solve(cats_2x2, Y[, 1]) + s2 <- withinr::within_solve(cats_2x2, Y[, 2]) + assert_equal(b$x[, 1], s1$x, tol = 1e-6, msg = "Batch x RHS1 mismatch.") + assert_equal(b$x[, 2], s2$x, tol = 1e-6, msg = "Batch x RHS2 mismatch.") + assert_equal(b$demeaned[, 1], s1$demeaned, tol = 1e-6, msg = "Batch demeaned RHS1 mismatch.") + assert_equal(b$demeaned[, 2], s2$demeaned, tol = 1e-6, msg = "Batch demeaned RHS2 mismatch.") + + # Persistent Solver and Preconditioner reuse + solver <- withinr::Solver(cats_2x2) + assert_true(inherits(solver, "within_solver"), "Solver() did not return a solver object.") + assert_equal(solver$n_obs, 4L, msg = "solver$n_obs mismatch.") + assert_true(solver$n_dofs >= 4L, "solver$n_dofs is too small.") + p <- solver$preconditioner() + assert_true(inherits(p, "within_preconditioner"), "solver$preconditioner() did not return a preconditioner.") + assert_true( + grepl("Preconditioner(Additive", paste(capture.output(print(p)), collapse = ""), fixed = TRUE), + "preconditioner print does not report the Additive variant." + ) + assert_equal(length(p$apply(rep(1, p$ncols))), p$nrows, msg = "preconditioner apply length mismatch.") + + solver_diag <- withinr::Solver(cats_2x2, preconditioner = withinr::PreconditionerConfig$Diagonal) + p_diag <- solver_diag$preconditioner() + assert_true( + grepl("Preconditioner(Diagonal", paste(capture.output(print(p_diag)), collapse = ""), fixed = TRUE), + "diagonal preconditioner print does not report the Diagonal variant." + ) + + r_persistent <- solver$solve(y_simple) + assert_equal(r_persistent$demeaned, s1$demeaned, tol = 1e-6, msg = "Persistent solve mismatch.") + b_persistent <- solver$solve_batch(Y) + assert_equal(b_persistent$demeaned, b$demeaned, tol = 1e-6, msg = "Persistent batch mismatch.") + + bytes <- p$serialize() + p2 <- withinr::Preconditioner(bytes) + assert_true( + grepl("Preconditioner(Additive", paste(capture.output(print(p2)), collapse = ""), fixed = TRUE), + "deserialized preconditioner print does not report the Additive variant." + ) + solver2 <- withinr::Solver(cats_2x2, preconditioner = p2) + assert_equal(solver2$solve(y_simple)$demeaned, s1$demeaned, tol = 1e-6, msg = "Preconditioner reuse mismatch.") + + # validation/error behavior + assert_error(withinr::within_solve(c(1L, 2L), y_simple), contains = "must be a matrix") + assert_error(withinr::within_solve_batch(cats_2x2, y_simple), contains = "must be a matrix") + + bad <- cats_2x2 + bad[1, 1] <- NA_integer_ + assert_error(withinr::within_solve(bad, y_simple), contains = "must not contain NA") + + bad0 <- cats_2x2 - 1L + assert_error(withinr::within_solve(bad0, y_simple), contains = "1-based") + + cats_dbl <- matrix(c(1, 1, 2, 2, 1, 2, 1, 2), ncol = 2) + assert_true(withinr::within_solve(cats_dbl, y_simple)$converged, "Numeric categories coercion case failed.") + + # Larger deterministic design: preconditioner correctness and caller-order invariance + set.seed(20260704) + n <- 180L + cats <- cbind( + sample.int(29L, n, replace = TRUE), + sample.int(17L, n, replace = TRUE), + sample.int(11L, n, replace = TRUE) + ) + y <- rnorm(n) + y_alt <- 0.25 * y + rnorm(n) + Y_big <- cbind(y, y_alt) + weights <- runif(n, min = 0.5, max = 2.0) + tight <- withinr::LsmrOptions(tol = 1e-10, maxiter = 4000L, local_size = 4L) + + add_big <- withinr::within_solve(cats, y, options = tight, weights = weights) + diag_big <- withinr::within_solve( + cats, + y, + options = tight, + weights = weights, + preconditioner = withinr::PreconditionerConfig$Diagonal + ) + off_big <- withinr::within_solve( + cats, + y, + options = tight, + weights = weights, + preconditioner = withinr::PreconditionerConfig$Off + ) + assert_all_converged(add_big, "Additive preconditioner failed on larger design.") + assert_all_converged(diag_big, "Diagonal preconditioner failed on larger design.") + assert_all_converged(off_big, "Unpreconditioned solve failed on larger design.") + assert_equal( + diag_big$demeaned, + add_big$demeaned, + tol = 1e-6, + msg = "Diagonal and additive demeaned outputs disagree." + ) + assert_equal( + off_big$demeaned, + add_big$demeaned, + tol = 1e-6, + msg = "Unpreconditioned and additive demeaned outputs disagree." + ) + + solver_big <- withinr::Solver(cats, weights = weights) + pre_big <- solver_big$preconditioner() + reuse_big <- withinr::within_solve(cats, y, options = tight, weights = weights, preconditioner = pre_big) + assert_equal(reuse_big$demeaned, add_big$demeaned, tol = 1e-6, msg = "One-shot preconditioner reuse mismatch.") + + batch_reuse <- withinr::within_solve_batch(cats, Y_big, options = tight, weights = weights, preconditioner = pre_big) + batch_fresh <- withinr::within_solve_batch(cats, Y_big, options = tight, weights = weights) + assert_all_converged(batch_reuse, "Prebuilt batch solve failed on larger design.") + assert_equal( + batch_reuse$demeaned, + batch_fresh$demeaned, + tol = 1e-6, + msg = "Batch preconditioner reuse mismatch." + ) + + order_dominant <- order(cats[, 1], cats[, 2], cats[, 3]) + sorted <- withinr::within_solve( + cats[order_dominant, , drop = FALSE], + y[order_dominant], + options = tight, + weights = weights[order_dominant] + ) + assert_equal( + add_big$demeaned[order_dominant], + sorted$demeaned, + tol = 1e-6, + msg = "Unsorted input did not return demeaned values in caller order." + ) + + sorted_batch <- withinr::within_solve_batch( + cats[order_dominant, , drop = FALSE], + Y_big[order_dominant, , drop = FALSE], + options = tight, + weights = weights[order_dominant] + ) + assert_equal( + batch_fresh$demeaned[order_dominant, , drop = FALSE], + sorted_batch$demeaned, + tol = 1e-6, + msg = "Unsorted batch input did not return demeaned values in caller order." + ) + + if (verbose) message("withinr manual tests: OK") + invisible(TRUE) +} + +if (identical(environment(), globalenv())) { + withinr_run_tests(verbose = TRUE) +}