R/hobbs.R

Defines functions print.hobbs_run example_batch_code example_scalar_code format_num `%||%` validate_log_cache compile_c_model encode_c_include prepare_model_translation_unit find_top_level_colon translate_r_like_for_loop_line vectorize_simple_target_reductions translate_r_like_for_loops parse_lhs_range translate_distribution_statement_line translate_distribution_statements translate_simple_model_signature translate_mat_declarations translate_vec_declarations translate_block_signatures translate_user_model_c generate_param_macros find_log_posterior_param_name strip_param_declarations resolve_param_extent resolve_integer_extent parse_param_declarations parse_param_declaration_options resolve_block_runtime parse_block_declarations add_implicit_block_target expand_multi_block_declarations generate_scalar_kernels_c generate_cache_c cache_block_update_is_reversible cache_multi_index_declarations invert_additive_cache_update_body cache_update_is_reversible cache_reference_pattern cache_direct_additive_write_pattern translate_current_proposal_refs generate_param_access_macros cache_index_expr cache_dims_to_c_len extract_attached_cache_declarations cache_body_lines collect_braced_declaration normalize_attached_cache_syntax split_top_level_commas count_char generate_data_bridge_c sanitize_c_identifier materialize_rows_by_data add_implicit_data_dimensions materialize_data resolve_parameter_output_plan resolve_dim inline_zero_arg_func_calls extract_func_body_lines extract_zero_arg_funcs inline_func_declarations looks_like_model_source materialize_model copy_dir ensure_rust_target hobbs_sampler_executable hobbs_rust_target exe_name hobbs_cache_dir run_in_dir hobbs_example_model read_hobbs_mean is_hobbs_binary_file read_hobbs format_hobbs_seed hobbs hobbs_check_sampler hobbs_require_installed_sampler hobbs_installed_sampler hobbs_check_build_tools hobbs_build_sampler

Documented in hobbs hobbs_build_sampler hobbs_check_sampler hobbs_example_model print.hobbs_run read_hobbs read_hobbs_mean

#' Build the bundled hobbs Rust sampler
#'
#' Returns the sampler built during package installation, or explicitly builds
#' the bundled Rust sampler in the user cache when needed. Rebuilding requires
#' Cargo and Rust. Normal `hobbs()` calls use the installed sampler directly.
#'
#' @param rebuild Logical. If TRUE, force a fresh cargo build.
#' @param quiet Logical. If TRUE, suppress build output.
#' @return Path to the hobbs executable.
#' @examples
#' library(hobbs)
#' hobbs_build_sampler()
#' @export
hobbs_build_sampler <- function(rebuild = FALSE, quiet = TRUE) {
  # Normal package installation builds the bundled Rust sampler through
  # src/Makevars and installs it under inst/hobbs/bin. Prefer that CRAN-style
  # build. A requested rebuild retains the existing user-cache build path.
  if (!rebuild) {
    installed_exe <- hobbs_installed_sampler()
    if (file.exists(installed_exe)) {
      return(normalizePath(installed_exe, mustWork = TRUE))
    }
  }

  src <- system.file("hobbs", package = "hobbs", mustWork = TRUE)
  cache_root <- hobbs_cache_dir()
  dst <- file.path(cache_root, "hobbs")
  rust_target <- hobbs_rust_target()
  exe <- hobbs_sampler_executable(dst, rust_target)

  # The Rust sampler is cached outside the installed package. Invalidate that
  # cache when the R package version changes, otherwise an older cached binary
  # can silently lack newer CLI features such as --data.
  stamp <- file.path(dst, ".hobbs_package_version")
  target_stamp <- file.path(dst, ".hobbs_rust_target")
  pkg_version <- as.character(utils::packageVersion("hobbs"))
  target_id <- if (is.null(rust_target)) "host" else rust_target
  cached_version <- if (file.exists(stamp)) readLines(stamp, warn = FALSE, n = 1L) else NA_character_
  cached_target <- if (file.exists(target_stamp)) readLines(target_stamp, warn = FALSE, n = 1L) else NA_character_
  if (dir.exists(dst) && (
    !identical(cached_version, pkg_version) ||
    !identical(cached_target, target_id)
  )) {
    unlink(dst, recursive = TRUE, force = TRUE)
  }

  if (rebuild && dir.exists(dst)) unlink(dst, recursive = TRUE, force = TRUE)
  if (!dir.exists(dst)) {
    dir.create(cache_root, recursive = TRUE, showWarnings = FALSE)
    copy_dir(src, dst)
    writeLines(pkg_version, stamp)
    writeLines(target_id, target_stamp)
  }

  if (!file.exists(exe) || rebuild) {
    hobbs_check_build_tools(quiet = quiet, stop_on_error = TRUE)
    cargo <- Sys.which("cargo")
    if (!nzchar(cargo)) stop("Could not find `cargo` on PATH. Install Rust from https://rustup.rs/ and try again.", call. = FALSE)
    ensure_rust_target(rust_target, quiet = quiet)
    out <- if (quiet) FALSE else ""
    err <- if (quiet) FALSE else ""
    cargo_args <- c("build", "--release", "--locked")
    if (!is.null(rust_target)) cargo_args <- c(cargo_args, "--target", rust_target)

    # On Windows, build the standalone sampler with a statically linked C
    # runtime.  This avoids depending on whichever MSVC/MinGW runtime happens
    # to be visible on PATH when the package is checked or run.  CRAN's
    # Windows builders can otherwise compile the executable successfully but
    # fail to load it before main() with STATUS_ENTRYPOINT_NOT_FOUND (0x139).
    old_rustflags <- Sys.getenv("RUSTFLAGS", unset = NA_character_)
    if (.Platform$OS.type == "windows") {
      base_flags <- if (is.na(old_rustflags)) "" else old_rustflags
      Sys.setenv(RUSTFLAGS = trimws(paste(
        base_flags,
        "-C target-feature=+crt-static -C target-cpu=x86-64"
      )))
      on.exit({
        if (is.na(old_rustflags)) {
          Sys.unsetenv("RUSTFLAGS")
        } else {
          Sys.setenv(RUSTFLAGS = old_rustflags)
        }
      }, add = TRUE)
    }

    cargo_home <- tempfile("hobbs-cargo-")
    dir.create(cargo_home, recursive = TRUE, showWarnings = FALSE)
    on.exit(unlink(cargo_home, recursive = TRUE, force = TRUE), add = TRUE)

    old_cargo_home <- Sys.getenv("CARGO_HOME", unset = NA_character_)
    Sys.setenv(CARGO_HOME = cargo_home)
    on.exit({
      if (is.na(old_cargo_home)) {
        Sys.unsetenv("CARGO_HOME")
      } else {
        Sys.setenv(CARGO_HOME = old_cargo_home)
      }
    }, add = TRUE)

    status <- run_in_dir(dst, cargo, cargo_args, stdout = out, stderr = err)
    if (!identical(status, 0L)) stop("cargo build --release --locked failed", call. = FALSE)
  }

  normalizePath(exe, mustWork = TRUE)
}

# #' Check the hobbs compilation toolchain
# #'
# #' Deprecated. Use [hobbs_check_sampler()] to check the installed sampler.
# #' Rust and Cargo are needed only for source installation or explicit builds.
# #' This compatibility function reports build tools and the model C compiler.
# #'
# #' @param quiet Logical. If `TRUE`, suppress status messages.
# #' @param stop_on_error Logical. If `TRUE`, stop when any required tool is
# #'   unavailable.
# #'
# #' @return Invisibly returns a named character vector containing the paths to
# #'   `cargo`, `rustc`, and the detected C compiler.
# #' @export
# hobbs_check_toolchain <- function(quiet = FALSE, stop_on_error = FALSE) {
#     .Deprecated("hobbs_check_sampler")
#     hobbs_check_build_tools(quiet = quiet, stop_on_error = stop_on_error)
# }

hobbs_check_build_tools <- function(quiet = FALSE, stop_on_error = FALSE) {
    if (!is.logical(quiet) || length(quiet) != 1L || is.na(quiet)) {
        stop("`quiet` must be TRUE or FALSE.", call. = FALSE)
    }
    
    if (
        !is.logical(stop_on_error) ||
        length(stop_on_error) != 1L ||
        is.na(stop_on_error)
    ) {
        stop("`stop_on_error` must be TRUE or FALSE.", call. = FALSE)
    }
    
    find_first <- function(commands) {
        paths <- Sys.which(commands)
        paths <- unname(paths[nzchar(paths)])
        
        if (length(paths)) {
            normalizePath(paths[[1L]], mustWork = FALSE)
        } else {
            ""
        }
    }
    
    cargo <- find_first("cargo")
    rustc <- find_first("rustc")
    
    c_candidates <- if (.Platform$OS.type == "windows") {
        c("gcc", "clang", "cc")
    } else if (identical(Sys.info()[["sysname"]], "Darwin")) {
        c("clang", "cc", "gcc")
    } else {
        c("cc", "gcc", "clang")
    }
    
    tools <- c(
        cargo = cargo,
        rustc = rustc,
        c_compiler = find_first(c_candidates)
    )
    
    available <- stats::setNames(
        nzchar(unname(tools)),
        names(tools)
    )
    
    labels <- c(
        cargo = "Cargo",
        rustc = "Rust compiler",
        c_compiler = "C compiler"
    )
    
    if (!quiet) {
        for (name in names(tools)) {
            if (isTRUE(available[[name]])) {
                message(
                    sprintf(
                        "%-14s [OK] %s",
                        labels[[name]],
                        tools[[name]]
                    )
                )
            } else {
                message(
                    sprintf(
                        "%-14s [NOT FOUND]",
                        labels[[name]]
                    )
                )
            }
        }
    }
    
    if (!all(available)) {
        missing <- names(available)[!available]
        instructions <- character()
        
        if (any(c("cargo", "rustc") %in% missing)) {
            instructions <- c(
                instructions,
                "Install Rust and Cargo from https://rustup.rs/."
            )
        }
        
        if ("c_compiler" %in% missing) {
            c_instruction <- if (.Platform$OS.type == "windows") {
                paste0(
                    "Install the version of Rtools appropriate for your version of R ",
                    "from https://cran.r-project.org/bin/windows/Rtools/."
                )
            } else if (identical(Sys.info()[["sysname"]], "Darwin")) {
                paste0(
                    "Install the Xcode Command Line Tools by running ",
                    "`xcode-select --install` in Terminal."
                )
            } else {
                paste0(
                    "Install a C compiler, such as GCC or Clang, using your system ",
                    "package manager."
                )
            }
            
            instructions <- c(instructions, c_instruction)
        }
        
        msg <- paste(
            c(
                paste0(
                    "hobbs could not find: ",
                    paste(labels[missing], collapse = ", "),
                    "."
                ),
                instructions
            ),
            collapse = "\n"
        )
        
        if (stop_on_error) {
            stop(msg, call. = FALSE)
        }
        
        if (!quiet) {
            warning(msg, call. = FALSE)
        }
    }
    
    invisible(tools)
}


# #' Install the hobbs sampler (deprecated)
# #'
# #' Deprecated. Package installation builds the sampler. This compatibility
# #' function returns that executable without requiring Rust, or delegates an
# #' explicit rebuild to [hobbs_build_sampler()].
# #'
# #' @param rebuild Logical. If `TRUE`, rebuild the sampler in the user cache.
# #' @param quiet Logical. If `TRUE`, suppress status messages.
# #' @return Invisibly returns the path to the sampler.
# #' @export
# hobbs_install_sampler <- function(rebuild = FALSE, quiet = FALSE) {
#     .Deprecated("hobbs_build_sampler")
#     if (!is.logical(rebuild) || length(rebuild) != 1L || is.na(rebuild)) {
#         stop("`rebuild` must be TRUE or FALSE.", call. = FALSE)
#     }
#     if (!is.logical(quiet) || length(quiet) != 1L || is.na(quiet)) {
#         stop("`quiet` must be TRUE or FALSE.", call. = FALSE)
#     }
#     sampler <- if (rebuild) {
#         hobbs_build_sampler(rebuild = TRUE, quiet = quiet)
#     } else {
#         hobbs_require_installed_sampler()
#     }
#     if (!quiet) message("hobbs sampler: ", sampler)
#     invisible(sampler)
# }

# Resolve only the executable installed with the package; never build or inspect
# a user cache during ordinary sampling or diagnostics.
hobbs_installed_sampler <- function() {
    bin_dir <- system.file("hobbs", "bin", package = "hobbs")
    if (!nzchar(bin_dir)) return("")
    file.path(bin_dir, exe_name("hobbs"))
}

hobbs_require_installed_sampler <- function() {
    sampler <- hobbs_installed_sampler()
    if (!nzchar(sampler) || !file.exists(sampler)) {
        stop(
            "The installed hobbs sampler is missing. Reinstall the hobbs package. ",
            "Source installation requires Cargo and Rust.",
            call. = FALSE
        )
    }
    normalizePath(sampler, mustWork = TRUE)
}

#' Check the installed hobbs sampler
#'
#' Checks whether the sampler installed with the package exists and can be
#' started with `--help`. Does not require Rust, build a sampler, or modify the
#' user cache. A C compiler is checked separately when compiling a model.
#'
#' @param quiet Logical. If `TRUE`, suppress status messages.
#' @return Invisibly returns `TRUE` when the installed sampler starts
#'   successfully, and `FALSE` otherwise.
#' @examples
#' library(hobbs)
#' hobbs_check_sampler()
#' @export
hobbs_check_sampler <- function(quiet = FALSE) {
    if (!is.logical(quiet) || length(quiet) != 1L || is.na(quiet)) {
        stop("`quiet` must be TRUE or FALSE.", call. = FALSE)
    }
    sampler <- hobbs_installed_sampler()
    if (!nzchar(sampler) || !file.exists(sampler)) {
        if (!quiet) message("The installed hobbs sampler is missing. Reinstall the hobbs package.")
        return(invisible(FALSE))
    }
    sampler <- normalizePath(sampler, mustWork = TRUE)
    output <- tryCatch(
        suppressWarnings(system2(sampler, "--help", stdout = TRUE, stderr = TRUE)),
        error = identity
    )
    if (inherits(output, "error")) {
        if (!quiet) message("hobbs sampler: ", sampler,
                            "\nCould not start: ", conditionMessage(output))
        return(invisible(FALSE))
    }
    status <- attr(output, "status")
    if (!is.null(status) && !identical(as.integer(status), 0L)) {
        if (!quiet) {
            message("hobbs sampler: ", sampler, "\nStartup failed with status ", status, ".")
            if (length(output)) message(paste(output, collapse = "\n"))
        }
        return(invisible(FALSE))
    }
    if (!quiet) message("hobbs sampler is installed and working: ", sampler)
    invisible(TRUE)
}

#' Compile and sample a hobbs Bayesian model
#'
#' @description
#' `hobbs()` is the main model-fitting interface for **hobbs** (High dimensiOnal
#' Bayesian omniBus Sampler), a probabilistic programming system designed for
#' high-dimensional Bayesian models with sparse or structured computational
#' dependencies. Models are written in a compact C-like language using
#' parameter declarations, reusable code chunks, probability statements, and
#' parameter-local sampling blocks.
#'
#' The R front end translates the model and R data into optimized C code and
#' compiles it as a shared library. A reusable Rust runtime then performs the
#' MCMC sweeps, sampler adaptation, output, and diagnostics. In block mode,
#' model-specific posterior calculations and deterministic cache updates remain
#' in generated C so that each scalar update can perform only the work that its
#' dependency structure requires.
#'
#' @details
#' The preferred interface declares model parameters directly in the model
#' source with `param` for continuous parameters and `dparam` for bounded
#' discrete parameters. Parameter and data accessors use one-based indexing,
#' matching R. For example, `param beta(p);` creates `beta(1)`, ..., `beta(p)`,
#' and a matrix supplied as `X` in `data` can be read as `X(i, j)`. Continuous
#' declarations use `sampler=rwmh` by default and can instead request scalar
#' slice sampling with `sampler=slice`, e.g. `param u(n, p) sampler=slice;`.
#' The sampler modifier is declaration-local, so RWMH and slice parameters can
#' be mixed in the same model.
#'
#' Probability statements have the form
#' `value ~ distribution(arguments);` and add the corresponding log density or
#' log probability to the current block target. Repeated calculations can be
#' placed in `func name() { ... }` declarations. These are reusable code chunks
#' that are expanded at their call sites before C compilation, so they can use
#' indices and local declarations from the surrounding block. Ordinary C
#' expressions, scalar declarations, loops, conditionals, transformations, and
#' direct additions to `target` can also be used. Temporary `vec` and `mat`
#' declarations are available for vector and matrix calculations used by
#' multivariate distributions.
#'
#' @section Toolchain:
#' hobbs models are compiled at run time, so ordinary use requires a working C
#' compiler and the sampler built during package installation. Rust and Cargo
#' are required only for source installation or an explicit sampler rebuild.
#' Use [hobbs_check_sampler()] to verify the installed executable, and
#' [hobbs_build_sampler()] for an explicit build or rebuild in the user cache.
#' `hobbs()` uses the installed sampler and never builds it automatically.
#'
#' @section Parameter-local blocks:
#' A declaration such as
#'
#' ```
#' block beta(j) {
#'   beta(j) ~ dnorm(0, 10);
#'   llk();
#' }
#' ```
#'
#' creates one scalar update for each coordinate of `beta`. The block need not
#' evaluate the complete log posterior. It should evaluate exactly the prior,
#' likelihood, and other posterior terms whose values can change when that
#' coordinate changes. Terms that do not depend on the active coordinate may be
#' omitted because they are constant in that coordinate's conditional target.
#'
#' This is an exact computation, not an approximation, provided that every
#' posterior contribution affected by the update is included. A `block` is
#' therefore both a sampling declaration and an explicit dependency contract.
#' This lets grouped, sparse, latent-variable, and variable-selection models
#' restrict work to the observations or terms actually affected by an update.
#'
#' Continuous coordinates use sequential scalar Gaussian random-walk Metropolis
#' updates by default. Adding `sampler=slice` to a continuous parameter
#' declaration selects a stepping-out/shrinkage slice update for every scalar
#' coordinate in that declaration. Bounded discrete coordinates declared with,
#' for example, `dparam z(n, 0, 1);` are updated by evaluating their local block
#' target over every value in the declared support and drawing from the
#' resulting finite-state conditional distribution.
#'
#' @section Persistent deterministic caches:
#' A block can maintain exact deterministic state with attached `cache` and
#' `update` declarations. For example, a cached linear predictor can be
#' initialized once and updated after a proposal to `beta(j)` using
#'
#' ```
#' mu(i) += (proposal(beta(j)) - current(beta(j))) * X(i, j);
#' ```
#'
#' `proposal(...)` is the proposed scalar value and `current(...)` is the
#' currently accepted value. Cache updates are transactional: the proposed
#' parameter and updated cache are used together to evaluate the block target;
#' on rejection, hobbs restores the previous cache automatically. A cache can
#' be maintained by multiple blocks. Correctness requires that its initializer
#' and every attached update keep the cached quantity algebraically consistent
#' with the current parameter state.
#'
#' Persistent deterministic caches are exact and are distinct from the optional
#' lookup-table approximation controlled by `log_cache`.
#'
#' @section Sampling and adaptation:
#' Each continuous coordinate is updated once per sweep using the sampler named
#' in its parameter declaration. `sampler=rwmh` uses the existing adaptive
#' Gaussian random-walk Metropolis kernel with a coordinate-specific
#' Robbins-Monro scale and target acceptance probability `0.44`.
#' `sampler=slice` uses univariate slice sampling with randomized stepping out
#' and interval shrinkage. Slice widths start at `1` and adapt from observed
#' scalar jump distances during burn-in only; the widths are frozen for retained
#' sampling. Bounded discrete parameters continue to use exact finite-state
#' Gibbs updates. All three update types can coexist in one model.
#'
#' @section Output and high-dimensional storage:
#' By default, retained draws are written to a binary chain and can be read with
#' [read_hobbs()]. A parameter declaration can include `save=mean`, for example
#' `param u(m, 2) save=mean;`. The parameter remains part of the Markov state and
#' is sampled normally, but only its post-burn-in posterior mean is retained.
#' This separates the dimension of the sampled state from the dimension of the
#' stored chain and can greatly reduce storage for large nuisance parameter
#' arrays. Mean-only parameters do not retain information needed for posterior
#' quantiles or convergence diagnostics; use [read_hobbs_mean()] to read their
#' saved means when mixed with full-chain parameters.
#'
#' The returned `hobbs_run` object records the generated source and shared
#' library, output paths, model dimensions and parameter names, block metadata,
#' adaptation diagnostics, and process status. It can be passed directly to
#' [read_hobbs()].
#'
#' @section Built-in probability statements:
#' Sampling statements currently include scalar continuous distributions
#' `dnorm`, `normal01`, `normal_sd1`, `dunif`, `dexp`, `dgamma`, `dinvgamma`,
#' `dbeta`, `dcauchy`, `dt`, `dchisq`, `dlnorm`, `dlogis`, `dlaplace`,
#' `dweibull`, `dpareto`, `dhalfnorm`, and `dhalfcauchy`; discrete and linked
#' distributions `dbern`, `bernoulli_logit`, `bernoulli_probit`,
#' `bernoulli_cloglog`, `dbinom`, `binomial_logit`, `dpois`, `poisson_log`,
#' `dnbinom`, and `dnbinom_log`; and multivariate/matrix distributions `dbvn`,
#' `dmvn`, `dwish`, `dinvwish`, and `dlkjcorr2`.
#'
#' Built-in distributions do not limit the models that can be expressed.
#' Model-specific log-density calculations may be placed in a `func` chunk and
#' added directly to `target` using ordinary C expressions.
#'
#' @section Optional distribution lookup cache:
#' Setting `log_cache = TRUE` enables an optional lookup-table approximation for
#' selected repeated elementary calculations. Unlike persistent deterministic
#' caches, this can perturb the numerical log target slightly and is therefore
#' disabled by default. `log_cache_bits` controls table resolution and the
#' associated memory/speed tradeoff. Use this option only when its approximation
#' is acceptable for the application.
#'
#' @param model A character string containing hobbs model source or a path to a
#'   `.c` model file. Model source can contain `param` and `dparam`
#'   declarations, `func` chunks, `block` declarations, probability statements,
#'   and attached `cache`/`update` declarations. A continuous declaration may
#'   include `sampler=rwmh` (the default) or `sampler=slice`, independently of
#'   the optional `save=mean` output modifier. For example,
#'   `param u(n, p) save=mean sampler=slice;` uses slice sampling while retaining
#'   only the posterior mean. One-based ascending loops written as
#'   `for (i in 1:N)` or `for (i = 1:N)` are translated to C
#'   loops before compilation.
#' @param data Optional named list containing model data. Numeric, integer, and
#'   logical scalars, vectors, and matrices are supported. Scalars are exposed by
#'   name; vectors and matrices receive one-based function-like accessors such
#'   as `y(i)` and `X(i, j)`. Vector lengths are exposed as `<name>_len`; matrix
#'   dimensions are exposed as `<name>_len`, `<name>_nrow`, and `<name>_ncol`.
#' @param samples Number of retained post-burn-in samples to save. Default
#'   `1000`.
#' @param burnin Number of discarded burn-in iterations. RWMH proposal scales
#'   and slice widths are adapted throughout burn-in, then frozen. Default `500`.
#' @param out Output path for the retained binary chain. Defaults to
#'   `chain.bin` in `workdir`.
#' @param workdir Working directory used for generated model source, data
#'   bindings, compiled libraries, and other temporary build products. Defaults
#'   to [tempdir()].
#' @param seed RNG seed. A numeric seed may be any exactly representable whole
#'   number from 0 through `2^53 - 1`; a decimal character string may be used
#'   for the full unsigned 64-bit seed range.
#' @param log_cache Logical. If `TRUE`, enable the optional lookup-table
#'   approximation for selected repeated log/distribution calculations. Default
#'   `FALSE` because this can slightly perturb the log target.
#' @param log_cache_bits Integer table-resolution setting used when
#'   `log_cache = TRUE`. The default `8` allocates `2^8` mantissa lookup values
#'   (about 2 KB for the log table). Larger values increase resolution and cache
#'   footprint.
#'
#' @return Invisibly returns an object of class `hobbs_run`. It can be passed to
#'   [read_hobbs()] to read retained draws and to [read_hobbs_mean()] when the
#'   model contains parameters declared with `save=mean`.
#'
#' @seealso [read_hobbs()], [read_hobbs_mean()], [hobbs_build_sampler()],
#'   [hobbs_check_sampler()]
#'
#' @examples
#' library(hobbs)
#'
#' set.seed(1)
#' n <- 200L
#' p <- 4L
#' X <- cbind(1, matrix(rnorm(n * (p - 1L)), nrow = n))
#' beta_true <- c(0.5, 1, -0.75, 0.25)
#' sigma_true <- 0.75
#' y <- as.numeric(X %*% beta_true + rnorm(n, sd = sigma_true))
#'
#' dat <- list(n = n, p = p, X = X, y = y)
#'
#' model <- '
#' param beta(p);
#' param logsigma(1);
#'
#' func llk() {
#'   double sigma = exp(logsigma(1));
#'   for (i = 1:n) {
#'     y(i) ~ dnorm(mu(i), sigma);
#'   }
#' }
#'
#' block beta(j) {
#'   beta(j) ~ dnorm(0, 10);
#'   llk();
#' } cache mu(n) {
#'   for (i = 1:n) {
#'     for (k = 1:p) {
#'       mu(i) += beta(k) * X(i, k);
#'     }
#'   }
#' } update mu(n) {
#'   for (i = 1:n) {
#'     mu(i) += (proposal(beta(j)) - current(beta(j))) * X(i, j);
#'   }
#' }
#'
#' block logsigma(1) {
#'   logsigma(1) ~ dnorm(0, 2);
#'   llk();
#' }
#' '
#' 
#' out <- tempfile("regression", fileext = ".bin")
#'
#' fit <- hobbs(
#'   model = model,
#'   data = dat,
#'   samples = 2000,
#'   burnin = 1000,
#'   seed = 123,
#'   out = out
#' )
#'
#' draws <- read_hobbs(fit)
#' colMeans(draws[paste0("beta[", seq_len(p), "]")])
#' @export

hobbs <- function(model,
                  data = NULL,
                  samples = 1000L,
                  burnin = 500L,
                  out = file.path(workdir, "chain.bin"),
                  workdir = tempdir(),
                  seed = 123456789,
                  log_cache = FALSE,
                  log_cache_bits = 8L) {
    count_arg <- function(value, name, minimum) {
        if (!is.numeric(value) || length(value) != 1L || !is.finite(value) ||
            value < minimum || value != floor(value) || value > .Machine$integer.max) {
            stop(name, " must be a whole number between ", minimum,
                 " and ", .Machine$integer.max, call. = FALSE)
        }
        as.integer(value)
    }
    
    samples <- count_arg(samples, "samples", 1L)
    burnin <- count_arg(burnin, "burnin", 0L)
    seed_arg <- format_hobbs_seed(seed)
    log_cache_config <- validate_log_cache(log_cache, log_cache_bits)
    
    # The public interface intentionally exposes only the settings above.
    # Current sampler behavior is fixed to block updates, binary chain output,
    # one retained draw per sweep, adaptation through burn-in, and the package
    # defaults for RWMH proposal scale/target acceptance and slice width.
    adapt_until <- burnin
    eval <- "auto"
    format <- "bin"
    save <- "chain"
    update <- "block"
    step <- 0.25
    adapt_every <- 25L
    target_accept <- 0.44
    thin <- 1L
    quiet <- FALSE
    
    dir.create(workdir, recursive = TRUE, showWarnings = FALSE)
    workdir <- normalizePath(workdir, mustWork = TRUE)
    
    binary <- hobbs_require_installed_sampler()
    binary <- normalizePath(binary, mustWork = TRUE)
    
    model_user_c <- materialize_model(model, workdir)
    model_user_c <- inline_func_declarations(model_user_c, workdir)
    data <- add_implicit_data_dimensions(data)
    param_info <- parse_param_declarations(model_user_c, data = data)
    model_runtime_c <- model_user_c
    block_info <- parse_block_declarations(model_runtime_c)
    dim <- resolve_dim(NULL, data, param_info = param_info)
    output_plan <- resolve_parameter_output_plan(param_info, dim, global_save = save)
    data_info <- materialize_data(data, workdir)
    block_runtime <- resolve_block_runtime(update, param_info, block_info)
    
    model_c <- prepare_model_translation_unit(
        model_runtime_c,
        workdir = workdir,
        log_cache = log_cache_config$enabled,
        data_spec = data_info$spec,
        param_info = param_info,
        block_info = block_info,
        allow_block_only = TRUE
    )
    
    lib <- compile_c_model(
        model_c,
        workdir = workdir,
        quiet = quiet,
        log_cache = log_cache_config
    )
    
    block_cli_args <- unlist(lapply(block_runtime, function(b) {
        value_type <- b$value_type %||% "continuous"
        if (identical(value_type, "discrete")) {
            spec <- paste(
                b$name, b$offset, b$len, b$type, value_type, b$lower, b$upper,
                sep = ":"
            )
        } else {
            sampler <- b$sampler %||% "rwmh"
            spec <- paste(b$name, b$offset, b$len, b$type, sampler, sep = ":")
        }
        c("--block", spec)
    }), use.names = FALSE)
    
    mean_range_cli_args <- if (output_plan$declaration_means) {
        unlist(lapply(output_plan$mean_ranges, function(item) {
            c("--mean-range", paste(item$offset, item$len, sep = ":"))
        }), use.names = FALSE)
    } else {
        character()
    }
    
    args <- c(
        "--lib", lib,
        if (!is.null(data_info$path)) c("--data", data_info$path),
        "--dim", as.integer(dim),
        "--samples", samples,
        "--burnin", burnin,
        "--adapt-until", adapt_until,
        "--eval", eval,
        "--format", format,
        "--save", save,
        "--update", update,
        block_cli_args,
        mean_range_cli_args,
        "--step", format_num(step),
        "--adapt-every", adapt_every,
        "--target-accept", format_num(target_accept),
        "--seed", seed_arg,
        "--thin", thin
    )
    
    out_path <- normalizePath(dirname(out), mustWork = TRUE)
    out_path <- file.path(out_path, basename(out))
    args <- c(args, "--out", out_path)
    
    adaptation_path <- paste0(out_path, ".adaptation.csv")
    args <- c(args, "--adapt-diagnostics-out", adaptation_path)
    
    declaration_means <- isTRUE(output_plan$declaration_means)
    mean_output_path <- NA_character_
    chain_output_path <- NA_character_
    
    if (declaration_means) {
        mean_output_path <- if (output_plan$chain_dim == 0L) {
            out_path
        } else {
            paste0(tools::file_path_sans_ext(out_path), ".mean.bin")
        }
        args <- c(args, "--mean-out", mean_output_path)
        if (output_plan$chain_dim > 0L) {
            chain_output_path <- out_path
        }
    } else {
        chain_output_path <- out_path
    }
    
    # system2() on Unix can still split unquoted arguments that contain spaces
    # in some environments, so quote command arguments defensively. Capture
    # output so platform-specific loader/runtime failures are not reduced to an
    # opaque numeric exit status during automated checks.
    sampler_stdout <- tempfile("hobbs-sampler-stdout-", tmpdir = workdir, fileext = ".log")
    sampler_stderr <- tempfile("hobbs-sampler-stderr-", tmpdir = workdir, fileext = ".log")
    on.exit(unlink(c(sampler_stdout, sampler_stderr), force = TRUE), add = TRUE)

    res <- system2(
        binary,
        shQuote(args),
        stdout = "",
        stderr = sampler_stderr
    )
    if (!identical(res, 0L)) {
        diagnostic <- c(
            if (file.exists(sampler_stderr)) readLines(sampler_stderr, warn = FALSE),
            if (file.exists(sampler_stdout)) readLines(sampler_stdout, warn = FALSE)
        )
        diagnostic <- diagnostic[nzchar(trimws(diagnostic))]
        detail <- if (length(diagnostic)) {
            paste0("\n", paste(diagnostic, collapse = "\n"))
        } else {
            ""
        }
        stop(
            "hobbs sampler failed with status ", res, detail,
            call. = FALSE
        )
    }

    if (file.exists(sampler_stderr)) {
        sampler_messages <- readLines(sampler_stderr, warn = FALSE)
        if (length(sampler_messages)) {
            message(paste(sampler_messages, collapse = "\n"))
        }
    }
    
    if (!file.exists(out_path)) {
        stop(
            "hobbs sampler reported success but did not create output file: ",
            out_path,
            ". Check hobbs_check_sampler() and reinstall the package if necessary.",
            call. = FALSE
        )
    }
    
    if (declaration_means && !file.exists(mean_output_path)) {
        stop(
            "hobbs sampler did not create declaration-level posterior means: ",
            mean_output_path,
            call. = FALSE
        )
    }
    
    if (!file.exists(adaptation_path)) {
        stop(
            "hobbs sampler did not create scalar adaptation diagnostics: ",
            adaptation_path,
            call. = FALSE
        )
    }
    
    parameter_save <- if (length(param_info$spec)) {
        modes <- vapply(
            param_info$spec,
            function(item) item$save %||% "chain",
            character(1)
        )
        setNames(modes, vapply(param_info$spec, `[[`, character(1), "name"))
    } else {
        character()
    }
    parameter_sampler <- if (length(param_info$spec)) {
        modes <- vapply(
            param_info$spec,
            function(item) item$sampler %||% (if (identical(item$value_type, "discrete")) "gibbs" else "rwmh"),
            character(1)
        )
        setNames(modes, vapply(param_info$spec, `[[`, character(1), "name"))
    } else {
        character()
    }
    
    if (declaration_means && output_plan$chain_dim == 0L) {
        primary_dim <- output_plan$mean_dim
        primary_names <- output_plan$mean_names
        primary_save <- "mean"
    } else {
        primary_dim <- output_plan$chain_dim
        primary_names <- output_plan$chain_names
        primary_save <- "chain"
    }
    
    if (length(primary_names)) {
        saveRDS(primary_names, paste0(out_path, ".param_names.rds"))
    }
    
    metadata <- list(
        dim = as.integer(primary_dim),
        model_dim = as.integer(dim),
        param_names = primary_names,
        all_param_names = param_info$names,
        chain_param_names = output_plan$chain_names,
        mean_param_names = output_plan$mean_names,
        chain_output = chain_output_path,
        mean_output = mean_output_path,
        parameter_save = parameter_save,
        parameter_sampler = parameter_sampler,
        samples = as.integer(samples),
        burnin = as.integer(burnin),
        adaptation = adaptation_path,
        target_accept = target_accept,
        step = step,
        seed = seed_arg,
        format = "bin",
        save = primary_save,
        has_binary_header = TRUE,
        record_size = as.integer(24L + 8L * as.integer(primary_dim))
    )
    saveRDS(metadata, paste0(out_path, ".metadata.rds"))
    
    if (declaration_means && output_plan$chain_dim > 0L) {
        if (length(output_plan$mean_names)) {
            saveRDS(
                output_plan$mean_names,
                paste0(mean_output_path, ".param_names.rds")
            )
        }
        
        mean_metadata <- list(
            dim = output_plan$mean_dim,
            model_dim = as.integer(dim),
            param_names = output_plan$mean_names,
            all_param_names = param_info$names,
            source_output = out_path,
            retained_samples = as.integer(samples),
            format = "bin",
            save = "mean",
            has_binary_header = TRUE,
            record_size = as.integer(24L + 8L * output_plan$mean_dim)
        )
        saveRDS(
            mean_metadata,
            paste0(mean_output_path, ".metadata.rds")
        )
    }
    
    ans <- list(
        output = out_path,
        chain_output = chain_output_path,
        mean_output = mean_output_path,
        model_c = model_c,
        user_model_c = model_user_c,
        runtime_model_c = model_runtime_c,
        model_lib = lib,
        data = data_info$path,
        data_spec = data_info$spec,
        binary = binary,
        dim = as.integer(dim),
        chain_dim = output_plan$chain_dim,
        mean_dim = output_plan$mean_dim,
        param_names = param_info$names,
        chain_param_names = output_plan$chain_names,
        mean_param_names = output_plan$mean_names,
        parameter_save = parameter_save,
        parameter_sampler = parameter_sampler,
        param_spec = param_info$spec,
        samples = as.integer(samples),
        burnin = as.integer(burnin),
        adaptation = adaptation_path,
        seed = seed_arg,
        blocks = block_runtime,
        log_cache = log_cache_config,
        status = res
    )
    
    class(ans) <- "hobbs_run"
    invisible(ans)
}



format_hobbs_seed <- function(seed) {
  if (is.character(seed)) {
    if (length(seed) != 1L || is.na(seed) || !grepl("^[0-9]+$", seed, perl = TRUE)) {
      stop("seed must be a non-negative whole number", call. = FALSE)
    }
    seed <- sub("^0+(?=[0-9])", "", seed, perl = TRUE)
    # u64::MAX = 18446744073709551615. Compare decimal strings without losing
    # precision in R's double representation.
    max_u64 <- "18446744073709551615"
    if (nchar(seed) > nchar(max_u64) ||
        (nchar(seed) == nchar(max_u64) && seed > max_u64)) {
      stop("seed must fit in an unsigned 64-bit integer", call. = FALSE)
    }
    return(seed)
  }

  # Numeric R values are exact only through 2^53 - 1. Character seeds provide
  # access to the complete unsigned 64-bit range.
  max_exact <- 9007199254740991
  if (!is.numeric(seed) || length(seed) != 1L || !is.finite(seed) || seed < 0 ||
      seed != floor(seed) || seed > max_exact) {
    stop(
      "numeric seed must be a whole number from 0 through 2^53 - 1; use a decimal character string for larger unsigned 64-bit seeds",
      call. = FALSE
    )
  }
  sprintf("%.0f", seed)
}

#' Read hobbs binary output
#'
#' Reads the default binary chain. When an `hobbs_run` uses declaration-level
#' `save=mean`, this returns the full draws for the unmarked parameters. Use
#' [read_hobbs_mean()] for the one-row binary of mean-only parameters.
#'
#' @param file Path to binary chain output.
#' @param dim Number of parameters in the chain. If omitted, it is read from
#'   the binary header written by recent hobbs versions.
#' @param max_records Optional maximum number of records to read.
#' @param param_names Optional names for theta columns. Usually supplied automatically when reading a `hobbs_run` object returned by `hobbs()`.
#' @return A data frame with columns `iter`, `accepted`, `logp`, and the saved parameter columns.
#' @examples
#' library(hobbs)
#' 
#' set.seed(1)
#' n <- 200L
#' p <- 4L
#' X <- cbind(1, matrix(rnorm(n * (p - 1L)), nrow = n))
#' beta_true <- c(0.5, 1, -0.75, 0.25)
#' sigma_true <- 0.75
#' y <- as.numeric(X %*% beta_true + rnorm(n, sd = sigma_true))
#' 
#' dat <- list(n = n, p = p, X = X, y = y)
#' 
#' model <- '
#' param beta(p);
#' param logsigma(1);
#' 
#' func llk() {
#'   double sigma = exp(logsigma(1));
#'   for (i = 1:n) {
#'     y(i) ~ dnorm(mu(i), sigma);
#'   }
#' }
#' 
#' block beta(j) {
#'   beta(j) ~ dnorm(0, 10);
#'   llk();
#' } cache mu(n) {
#'   for (i = 1:n) {
#'     for (k = 1:p) {
#'       mu(i) += beta(k) * X(i, k);
#'     }
#'   }
#' } update mu(n) {
#'   for (i = 1:n) {
#'     mu(i) += (proposal(beta(j)) - current(beta(j))) * X(i, j);
#'   }
#' }
#' 
#' block logsigma(1) {
#'   logsigma(1) ~ dnorm(0, 2);
#'   llk();
#' }
#' '
#' 
#' out <- tempfile("regression", fileext = ".bin")
#' 
#' fit <- hobbs(
#'     model = model,
#'     data = dat,
#'     samples = 2000,
#'     burnin = 1000,
#'     seed = 123,
#'     out = out
#' )
#' 
#' draws <- read_hobbs(fit)
#' colMeans(draws[paste0("beta[", seq_len(p), "]")])
#' 
#' @export
read_hobbs <- function(file, dim = NULL, max_records = NULL, param_names = NULL) {
  if (inherits(file, "hobbs_run")) {
    has_chain_output <- !is.null(file$chain_output) && length(file$chain_output) == 1L &&
      !is.na(file$chain_output) && nzchar(file$chain_output)
    has_mean_output <- !is.null(file$mean_output) && length(file$mean_output) == 1L &&
      !is.na(file$mean_output) && nzchar(file$mean_output)

    if (identical(file$save, "mean") || (!has_chain_output && has_mean_output)) {
      return(read_hobbs_mean(file, param_names = param_names))
    }
    if (is.null(param_names)) {
      param_names <- file$chain_param_names %||% file$param_names
    }
    if (is.null(dim)) dim <- file$chain_dim %||% file$dim
    file <- if (has_chain_output) file$chain_output else file$output
  }
  file <- normalizePath(file, mustWork = TRUE)

  metadata_path <- paste0(file, ".metadata.rds")
  if (file.exists(metadata_path)) {
    metadata <- readRDS(metadata_path)
    if (is.null(dim) && !is.null(metadata$dim)) dim <- metadata$dim
    if (is.null(param_names) && !is.null(metadata$param_names)) param_names <- metadata$param_names
  }

  if (is.null(param_names)) {
    sidecar <- paste0(file, ".param_names.rds")
    if (file.exists(sidecar)) param_names <- readRDS(sidecar)
  }
  if (is.null(dim) && !is.null(param_names)) {
    dim <- length(param_names)
  }

  if (is.null(max_records)) max_records <- -1L
  dim_arg <- if (is.null(dim)) -1L else as.integer(dim)
  out <- .Call("hobbs_read_bin", file, dim_arg, as.integer(max_records), PACKAGE = "hobbs")
  dim_out <- length(out) - 3L
  if (!is.null(param_names)) {
    if (length(param_names) != dim_out) {
      stop("`param_names` length must match the number of theta columns in the chain.", call. = FALSE)
    }
    theta_names <- param_names
  } else {
    theta_names <- paste0("theta", seq_len(dim_out))
  }
  names(out) <- c("iter", "accepted", "logp", theta_names)
  class(out) <- "data.frame"
  attr(out, "row.names") <- .set_row_names(length(out[[1L]]))
  out
}


is_hobbs_binary_file <- function(path) {
  con <- file(path, open = "rb")
  on.exit(close(con), add = TRUE)
  magic <- readBin(con, what = "raw", n = 12L)
  length(magic) == 12L && identical(rawToChar(magic), "hobbs_BIN_V1")
}


#' Read hobbs posterior-mean output
#'
#' For declaration-level `save=mean`, reads the one-record standard hobbs
#' binary written beside the ordinary chain. The `saved` field is the number of
#' retained draws used in each posterior mean. The older global
#' `hobbs(save = "mean")` CSV format remains readable for compatibility.
#'
#' @param file An `hobbs_run` object or path to a mean output file.
#' @param param_names Optional parameter names. Usually supplied automatically.
#' @return A one-row data frame with `saved`, `logp`, and posterior mean columns.
#' @examples
#' library(hobbs)
#' 
#' set.seed(1)
#' n <- 200L
#' p <- 4L
#' X <- cbind(1, matrix(rnorm(n * (p - 1L)), nrow = n))
#' beta_true <- c(0.5, 1, -0.75, 0.25)
#' sigma_true <- 0.75
#' y <- as.numeric(X %*% beta_true + rnorm(n, sd = sigma_true))
#' 
#' dat <- list(n = n, p = p, X = X, y = y)
#' 
#' model <- '
#' param beta(p) save=mean;
#' param logsigma(1);
#' 
#' func llk() {
#'   double sigma = exp(logsigma(1));
#'   for (i = 1:n) {
#'     y(i) ~ dnorm(mu(i), sigma);
#'   }
#' }
#' 
#' block beta(j) {
#'   beta(j) ~ dnorm(0, 10);
#'   llk();
#' } cache mu(n) {
#'   for (i = 1:n) {
#'     for (k = 1:p) {
#'       mu(i) += beta(k) * X(i, k);
#'     }
#'   }
#' } update mu(n) {
#'   for (i = 1:n) {
#'     mu(i) += (proposal(beta(j)) - current(beta(j))) * X(i, j);
#'   }
#' }
#' 
#' block logsigma(1) {
#'   logsigma(1) ~ dnorm(0, 2);
#'   llk();
#' }
#' '
#' 
#' out <- tempfile("regression", fileext = ".bin")
#' 
#' fit <- hobbs(
#'     model = model,
#'     data = dat,
#'     samples = 2000,
#'     burnin = 1000,
#'     seed = 123,
#'     out = out
#' )
#' 
#' draws <- read_hobbs(fit)
#' draws_mean <- read_hobbs_mean(fit)
#' 
#' @export
read_hobbs_mean <- function(file, param_names = NULL) {
  dim <- NULL
  if (inherits(file, "hobbs_run")) {
    has_mean_output <- !is.null(file$mean_output) && length(file$mean_output) == 1L &&
      !is.na(file$mean_output) && nzchar(file$mean_output)
    if (is.null(param_names)) {
      param_names <- if (length(file$mean_param_names %||% character())) {
        file$mean_param_names
      } else {
        file$param_names
      }
    }
    dim <- if (has_mean_output && !is.null(file$mean_dim) && file$mean_dim > 0L) {
      file$mean_dim
    } else {
      file$dim
    }
    file <- if (has_mean_output) file$mean_output else file$output
  }
  file <- normalizePath(file, mustWork = TRUE)
  if (is.null(param_names)) {
    sidecar <- paste0(file, ".param_names.rds")
    if (file.exists(sidecar)) param_names <- readRDS(sidecar)
  }

  if (is_hobbs_binary_file(file)) {
    if (is.null(dim) && !is.null(param_names)) dim <- length(param_names)
    out <- read_hobbs(
      file,
      dim = dim,
      param_names = param_names
    )
    if (nrow(out) != 1L) {
      stop("Posterior-mean binary must contain exactly one record.", call. = FALSE)
    }
    out$accepted <- NULL
    names(out)[names(out) == "iter"] <- "saved"
    return(out)
  }

  out <- utils::read.csv(file, check.names = FALSE)
  theta_cols <- grep("^theta[0-9]+$", names(out), value = TRUE)
  if (!is.null(param_names) && length(param_names)) {
    if (length(param_names) != length(theta_cols)) {
      stop("`param_names` length must match the number of theta columns in the mean output.", call. = FALSE)
    }
    names(out)[match(theta_cols, names(out))] <- param_names
  }
  out
}


#' Write an example C posterior model
#'
#' @param path Destination path.
#' @param batch Logical. If TRUE, write a batch-capable model.
#' @return Path to the written C file.
#' @examples
#' library(hobbs)
#' hobbs_example_model()
#' @export
hobbs_example_model <- function(path = tempfile(fileext = ".c"), batch = TRUE) {
  code <- if (isTRUE(batch)) example_batch_code() else example_scalar_code()
  writeLines(code, path)
  normalizePath(path, mustWork = TRUE)
}

run_in_dir <- function(dir, command, args = character(), stdout = "", stderr = "") {
  old <- getwd()
  on.exit(setwd(old), add = TRUE)
  setwd(dir)
  system2(command, args, stdout = stdout, stderr = stderr)
}

hobbs_cache_dir <- function() {
  if (utils::packageVersion("utils") >= "3.6.0") {
    tools::R_user_dir("hobbs", which = "cache")
  } else {
    file.path(path.expand("~"), ".cache", "hobbs")
  }
}

exe_name <- function(x) {
  if (.Platform$OS.type == "windows") paste0(x, ".exe") else x
}

hobbs_rust_target <- function() {
  # Build the standalone Rust sampler for Cargo's native host target.
  #
  # In particular, do not infer a Rust target from the compiler used to build
  # R on Windows. The sampler and the model DLL are not linked together at
  # build time: the sampler loads the DLL dynamically through LoadLibraryW and
  # calls a plain C ABI. Forcing x86_64-pc-windows-gnu/gnullvm to mirror R's
  # toolchain can therefore make an otherwise valid Rust executable depend on
  # a Windows runtime that is not compatible with the machine running it.
  # Cargo's host target is the appropriate and most portable choice here.
  NULL
}

hobbs_sampler_executable <- function(root, rust_target = hobbs_rust_target()) {
  release_dir <- if (is.null(rust_target)) {
    file.path(root, "target", "release")
  } else {
    file.path(root, "target", rust_target, "release")
  }
  file.path(release_dir, exe_name("hobbs"))
}

ensure_rust_target <- function(target, quiet = TRUE) {
  if (is.null(target)) return(invisible(TRUE))

  rustup <- Sys.which("rustup")
  if (!nzchar(rustup)) return(invisible(FALSE))

  installed <- suppressWarnings(
    system2(rustup, c("target", "list", "--installed"), stdout = TRUE, stderr = TRUE)
  )
  status <- attr(installed, "status")
  if (!is.null(status) && !identical(as.integer(status), 0L)) return(invisible(FALSE))
  if (target %in% trimws(installed)) return(invisible(TRUE))

  out <- if (quiet) FALSE else ""
  err <- if (quiet) FALSE else ""
  status <- system2(rustup, c("target", "add", target), stdout = out, stderr = err)
  if (!identical(status, 0L)) {
    stop("Could not install the required Rust target `", target, "`.", call. = FALSE)
  }

  invisible(TRUE)
}

copy_dir <- function(from, to) {
  dir.create(to, recursive = TRUE, showWarnings = FALSE)
  ok <- file.copy(list.files(from, all.files = TRUE, no.. = TRUE, full.names = TRUE), to, recursive = TRUE)
  if (!all(ok)) stop("Failed to copy bundled hobbs source into cache", call. = FALSE)
}

materialize_model <- function(model, workdir) {
  if (!is.character(model) || length(model) != 1L || is.na(model)) {
    stop("`model` must be a single path or a single C/hobbs source string", call. = FALSE)
  }

  # Existing paths keep the traditional file-based workflow.  Everything else
  # is interpreted as inline model source only if it looks like C/hobbs code.
  if (file.exists(model)) return(normalizePath(model, mustWork = TRUE))

  if (!looks_like_model_source(model)) {
    stop("`model` is neither an existing file nor inline hobbs model source. ",
         "Pass a file path, or a string containing declarations such as `param`, `block`, ",
         "or a model body with `{ ... }`.",
         call. = FALSE)
  }

  path <- file.path(workdir, paste0("hobbs_inline_model_",
                                    format(Sys.time(), "%Y%m%d%H%M%OS3"),
                                    "_", sprintf("%06d", sample.int(999999L, 1L)),
                                    ".c"))
  writeLines(model, path, useBytes = TRUE)
  normalizePath(path, mustWork = TRUE)
}

looks_like_model_source <- function(x) {
  if (!is.character(x) || length(x) != 1L || is.na(x)) return(FALSE)
  if (grepl("\n|\r", x)) return(TRUE)
  if (grepl("[{};]", x)) return(TRUE)
  if (grepl("\b(param|dparam|block|func|for|return|double|int|posterior_logp|log_posterior)\b", x, perl = TRUE)) return(TRUE)
  FALSE
}


inline_func_declarations <- function(model_c, workdir) {
  src <- readLines(model_c, warn = FALSE)
  parsed <- extract_zero_arg_funcs(src)
  if (!length(parsed$funcs)) return(model_c)
  expanded <- inline_zero_arg_func_calls(parsed$src, parsed$funcs)
  out <- file.path(workdir, "hobbs_model_func_inlined.c")
  writeLines(expanded, out)
  normalizePath(out, mustWork = TRUE)
}

extract_zero_arg_funcs <- function(src) {
  funcs <- list()
  keep <- character()
  i <- 1L
  pat <- "^([[:space:]]*)func[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*\\)[[:space:]]*\\{(.*)$"
  while (i <= length(src)) {
    line <- src[[i]]
    m <- regexec(pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 4L) {
      nm <- hit[[3L]]
      if (nm %in% names(funcs)) stop("Duplicate func definition: ", nm, call. = FALSE)
      block_lines <- line
      bal <- count_char(line, "{") - count_char(line, "}")
      j <- i
      while (bal > 0L && j < length(src)) {
        j <- j + 1L
        block_lines <- c(block_lines, src[[j]])
        bal <- bal + count_char(src[[j]], "{") - count_char(src[[j]], "}")
      }
      if (bal != 0L) stop("Unbalanced braces in func definition `", nm, "`.", call. = FALSE)
      funcs[[nm]] <- extract_func_body_lines(block_lines)
      i <- j + 1L
      next
    }
    if (grepl("^[[:space:]]*func[[:space:]]+", line, perl = TRUE)) {
      stop("Invalid func definition. Use zero-argument syntax like `func name() { ... }`.", call. = FALSE)
    }
    keep <- c(keep, line)
    i <- i + 1L
  }
  list(src = keep, funcs = funcs)
}

extract_func_body_lines <- function(block_lines) {
  if (!length(block_lines)) return(character())
  first <- block_lines[[1L]]
  last <- block_lines[[length(block_lines)]]
  first_after <- sub("^[^{]*\\{", "", first, perl = TRUE)
  if (length(block_lines) == 1L) {
    inner <- sub("}[^}]*$", "", first_after, perl = TRUE)
    return(if (nzchar(trimws(inner))) inner else character())
  }
  body <- character()
  if (nzchar(trimws(first_after))) body <- c(body, first_after)
  if (length(block_lines) > 2L) body <- c(body, block_lines[2L:(length(block_lines) - 1L)])
  last_before <- sub("}[[:space:]]*(//.*)?$", "", last, perl = TRUE)
  if (nzchar(trimws(last_before))) body <- c(body, last_before)
  body
}

inline_zero_arg_func_calls <- function(src, funcs) {
  out <- character()
  call_names <- names(funcs)
  if (!length(call_names)) return(src)
  call_pat <- paste0("^([[:space:]]*)(", paste(call_names, collapse = "|"), ")[[:space:]]*\\([[:space:]]*\\)[[:space:]]*;[[:space:]]*(?://.*)?$")
  for (line in src) {
    m <- regexec(call_pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 3L) {
      indent <- hit[[2L]]
      nm <- hit[[3L]]
      body <- funcs[[nm]]
      # `func` declarations are raw code chunks.  Inline the body exactly at
      # the call site, preserving the caller's scope rather than adding an
      # extra C block that would hide declarations made by the chunk.
      if (length(body)) out <- c(out, paste0(indent, body))
    } else {
      out <- c(out, line)
    }
  }
  if (any(grepl(call_pat, out, perl = TRUE))) {
    out2 <- inline_zero_arg_func_calls(out, funcs)
    if (!identical(out2, out)) return(out2)
  }
  out
}



resolve_dim <- function(dim, data = NULL, param_info = NULL) {
  if (!is.null(dim)) {
    if (length(dim) != 1L || is.na(dim) || !is.finite(dim) || dim <= 0) {
      stop("`dim` must be a single positive integer.", call. = FALSE)
    }
    dim <- as.integer(dim)
    if (!is.null(param_info) && !is.null(param_info$dim) && param_info$dim > 0L && dim != as.integer(param_info$dim)) {
      stop("`dim` does not match the total size implied by `param ...` declarations: ",
           dim, " vs ", as.integer(param_info$dim), ".", call. = FALSE)
    }
    return(dim)
  }

  if (!is.null(param_info) && !is.null(param_info$dim) && is.finite(param_info$dim) && param_info$dim > 0) {
    return(as.integer(param_info$dim))
  }

  if (is.list(data) && !is.null(names(data))) {
    candidates <- c("dim", "theta_dim", "npar", "n_params", "param_dim")
    for (nm in candidates) {
      if (nm %in% names(data)) {
        x <- data[[nm]]
        if ((is.numeric(x) || is.integer(x)) && length(x) == 1L && is.finite(x) && x > 0) {
          return(as.integer(x))
        }
      }
    }
  }

  stop("`dim` was not supplied. Either pass `dim = ...`, add `param name(k)` declarations to the C model, or include an explicit scalar such as `theta_dim = ...` in `data`.",
       call. = FALSE)
}

resolve_parameter_output_plan <- function(param_info, dim, global_save = "chain") {
  all_names <- param_info$names %||% character()
  if (identical(global_save, "mean")) {
    return(list(
      chain_dim = 0L, mean_dim = as.integer(dim),
      chain_names = character(), mean_names = all_names,
      mean_ranges = list(), declaration_means = FALSE
    ))
  }

  ranges <- param_info$mean_ranges %||% list()
  mean_dim <- if (length(ranges)) {
    sum(vapply(ranges, function(item) as.integer(item$len), integer(1)))
  } else {
    0L
  }
  if (mean_dim < 0L || mean_dim > dim) {
    stop("Internal parameter-output plan exceeds model dimension.", call. = FALSE)
  }
  list(
    chain_dim = as.integer(dim - mean_dim),
    mean_dim = as.integer(mean_dim),
    chain_names = param_info$chain_names %||% all_names,
    mean_names = param_info$mean_names %||% character(),
    mean_ranges = ranges,
    declaration_means = length(ranges) > 0L
  )
}

materialize_data <- function(data, workdir, row_map_spec = NULL) {
  if (is.null(data)) return(list(path = NULL, spec = NULL))
  if (is.character(data) && length(data) == 1L) {
    if (!file.exists(data)) stop("data file does not exist: ", data, call. = FALSE)
    return(list(path = normalizePath(data, mustWork = TRUE), spec = NULL))
  }
  if (!is.list(data) || is.null(names(data)) || any(!nzchar(names(data)))) {
    stop("`data` must be NULL, a data file path, or a named list of numeric/integer/logical scalars, vectors, and matrices.", call. = FALSE)
  }

  data <- add_implicit_data_dimensions(data)

  if (anyDuplicated(names(data))) stop("`data` names must be unique.", call. = FALSE)
  c_names <- vapply(names(data), sanitize_c_identifier, character(1))
  if (anyDuplicated(c_names)) stop("`data` names must be unique after conversion to C identifiers.", call. = FALSE)

  specs <- vector("list", length(data))
  payload_path <- file.path(workdir, "hobbs_data.bin")
  con <- file(payload_path, open = "wb")
  on.exit(close(con), add = TRUE)
  writeBin(as.integer(length(data)), con, size = 4L, endian = "little")

  for (i in seq_along(data)) {
    x <- data[[i]]
    nm <- names(data)[[i]]
    cn <- c_names[[i]]
    dims <- dim(x)
    is_matrix <- length(dims) == 2L
    is_scalar <- is.null(dims) && length(x) == 1L
    if (is.null(dims)) dims <- c(length(x), 1L)
    if (!length(x)) stop("data item `", nm, "` has length zero", call. = FALSE)
    if (!(is.numeric(x) || is.integer(x) || is.logical(x))) {
      stop("data item `", nm, "` must be numeric, integer, or logical", call. = FALSE)
    }
    type <- if (is.double(x)) "double" else "int"
    rank <- if (is_scalar) 0L else if (is_matrix) 2L else 1L
    nrow <- as.integer(dims[[1L]])
    ncol <- as.integer(dims[[2L]])
    storage <- if (is_matrix) as.vector(x) else as.vector(x)
    # Keep R's native column-major matrix layout. The generated matrix macro
    # uses the automatically exposed <name>_nrow scalar to index it naturally.
    writeBin(as.integer(if (identical(type, "double")) 1L else 2L), con, size = 4L, endian = "little")
    writeBin(as.integer(rank), con, size = 4L, endian = "little")
    writeBin(nrow, con, size = 4L, endian = "little")
    writeBin(ncol, con, size = 4L, endian = "little")
    if (identical(type, "double")) {
      writeBin(as.double(storage), con, size = 8L, endian = "little")
    } else {
      writeBin(as.integer(storage), con, size = 4L, endian = "little")
    }
    specs[[i]] <- list(name = nm, c_name = cn, type = type, rank = rank,
                       is_scalar = is_scalar, is_matrix = is_matrix,
                       nrow = nrow, ncol = ncol, len = as.integer(length(x)))
  }
  attr(specs, "hobbs_row_map_spec") <- row_map_spec
  list(path = normalizePath(payload_path, mustWork = TRUE), spec = specs, row_map_spec = row_map_spec)
}

add_implicit_data_dimensions <- function(data) {
  if (is.null(data) || !is.list(data) || is.null(names(data))) return(data)

  row_map_spec <- attr(data, "hobbs_row_map_spec", exact = TRUE)
  existing_c_names <- vapply(names(data), sanitize_c_identifier, character(1))
  extra <- list()
  extra_c_names <- character()

  add_dim <- function(name, value) {
    cn <- sanitize_c_identifier(name)
    if (cn %in% existing_c_names || cn %in% extra_c_names) return()
    extra[[name]] <<- as.integer(value)
    extra_c_names <<- c(extra_c_names, cn)
  }

  for (i in seq_along(data)) {
    x <- data[[i]]
    nm <- names(data)[[i]]
    dims <- dim(x)
    is_scalar <- is.null(dims) && length(x) == 1L
    is_matrix <- length(dims) == 2L
    if (is_scalar) next

    base <- sanitize_c_identifier(nm)
    add_dim(paste0(base, "_len"), length(x))
    if (is_matrix) {
      add_dim(paste0(base, "_nrow"), dims[[1L]])
      add_dim(paste0(base, "_ncol"), dims[[2L]])
    }
  }

  if (length(extra)) data <- c(data, extra)
  attr(data, "hobbs_row_map_spec") <- row_map_spec
  data
}


materialize_rows_by_data <- function(data, rows_by = NULL, param_info = NULL) {
  if (is.null(rows_by)) return(data)
  if (!is.list(rows_by) || is.null(names(rows_by)) || any(!nzchar(names(rows_by)))) {
    stop("`rows_by` must be a named list, e.g. `rows_by = list(ability = ability_idx)`.", call. = FALSE)
  }
  if (is.null(param_info) || !length(param_info$spec)) {
    stop("`rows_by` requires `param ...` declarations in the C model.", call. = FALSE)
  }
  if (is.null(data)) data <- list()
  if (!is.list(data) || is.null(names(data))) {
    stop("`rows_by` requires `data` to be a named list.", call. = FALSE)
  }
  pmap <- setNames(param_info$spec, vapply(param_info$spec, `[[`, character(1), "name"))
  row_specs <- list()
  extra <- list()
  for (nm in names(rows_by)) {
    cn <- sanitize_c_identifier(nm)
    if (!(cn %in% names(pmap))) {
      stop("`rows_by` name `", nm, "` does not match a `param` block name.", call. = FALSE)
    }
    idx <- rows_by[[nm]]
    if (!(is.numeric(idx) || is.integer(idx)) || !length(idx)) {
      stop("`rows_by$", nm, "` must be a non-empty integer/numeric vector of 1-based block indices.", call. = FALSE)
    }
    idx <- as.integer(idx)
    n_block <- as.integer(pmap[[cn]]$len)
    if (any(is.na(idx)) || any(idx < 1L) || any(idx > n_block)) {
      stop("`rows_by$", nm, "` must contain 1-based indices from 1 to ", n_block, ".", call. = FALSE)
    }
    starts <- integer(n_block + 1L)
    rows <- integer(length(idx))
    pos <- 1L
    for (b in seq_len(n_block)) {
      starts[[b]] <- pos - 1L
      hits <- which(idx == b)
      if (length(hits)) {
        rows[pos:(pos + length(hits) - 1L)] <- as.integer(hits)
        pos <- pos + length(hits)
      }
    }
    starts[[n_block + 1L]] <- pos - 1L
    start_name <- paste0("hobbs_", cn, "_row_start")
    index_name <- paste0("hobbs_", cn, "_row_index")
    extra[[start_name]] <- as.integer(starts)
    extra[[index_name]] <- as.integer(rows)
    row_specs[[length(row_specs) + 1L]] <- list(name = cn, start = start_name, index = index_name, n_block = n_block)
  }
  dup <- intersect(names(extra), names(data))
  if (length(dup)) stop("Internal `rows_by` data names collide with user data: ", paste(dup, collapse = ", "), call. = FALSE)
  data <- c(data, extra)
  attr(data, "hobbs_row_map_spec") <- row_specs
  data
}

sanitize_c_identifier <- function(x) {
  y <- gsub("[^A-Za-z0-9_]", "_", x)
  y <- gsub("_+", "_", y)
  if (!grepl("^[A-Za-z_]", y)) y <- paste0("data_", y)
  y
}

generate_data_bridge_c <- function(spec, row_map_spec = NULL) {
  decl <- c(
    "#include <stdint.h>",
    "#include <stdio.h>",
    "#include <stdlib.h>",
    ""
  )

  for (item in spec) {
    cn <- item$c_name
    ctype <- if (identical(item$type, "double")) "double" else "int"
    decl <- c(decl, sprintf("/* explicit data item `%s` from hobbs(data = list(...)) */", cn))
    if (isTRUE(item$is_scalar)) {
      decl <- c(decl, sprintf("%s %s = 0;", ctype, cn))
    } else {
      decl <- c(decl, sprintf("%s *%s = NULL;", ctype, cn))
      if (isTRUE(item$is_matrix)) {
        # Matrix dimensions are exposed automatically as <name>_nrow,
        # <name>_ncol, and <name>_len. Matrix indexing is 1-based
        # to look like R code and uses R's
        # column-major memory layout: X(i,j) == R's X[i, j].
        decl <- c(decl,
          sprintf("#define %s(row, col) %s[((int)(row) - 1) + ((int)(col) - 1) * %s_nrow]", cn, cn, cn)
        )
      } else {
        # Vector indexing is also 1-based: y(i) == R's y[i].
        decl <- c(decl,
          sprintf("#define %s(i) %s[((int)(i) - 1)]", cn, cn)
        )
      }
    }
    decl <- c(decl, "")
  }

  if (!is.null(row_map_spec) && length(row_map_spec)) {
    decl <- c(decl, "/* generated indexed block row helpers */")
    for (rm in row_map_spec) {
      nm <- rm$name
      start <- paste0("hobbs_", nm, "_row_start")
      index <- paste0("hobbs_", nm, "_row_index")
      decl <- c(decl,
        sprintf("#define %s_nrows(block_index) (%s[(int)(block_index)] - %s[(int)(block_index) - 1])", nm, start, start),
        sprintf("#define %s_row(block_index, ii) %s[%s[(int)(block_index) - 1] + ((int)(ii) - 1)]", nm, index, start),
        ""
      )
    }
  }

  init <- c(
    "int posterior_init(const char *data_path) {",
    "    FILE *fp = fopen(data_path, \"rb\");",
    "    if (!fp) return 1;",
    "    int32_t n_items = 0;",
    "    if (fread(&n_items, sizeof(int32_t), 1, fp) != 1) { fclose(fp); return 2; }",
    sprintf("    if (n_items != %d) { fclose(fp); return 3; }", length(spec))
  )

  code <- 10L
  for (idx in seq_along(spec)) {
    item <- spec[[idx]]
    cn <- item$c_name
    ctype <- if (identical(item$type, "double")) "double" else "int"
    type_id <- if (identical(item$type, "double")) 1L else 2L
    rank <- item$rank
    nrow <- item$nrow
    ncol <- item$ncol
    len <- item$len
    init <- c(init,
      "    {",
      "        int32_t type_id = 0, rank = 0, nr = 0, nc = 0;",
      sprintf("        if (fread(&type_id, sizeof(int32_t), 1, fp) != 1) { fclose(fp); return %d; }", code),
      sprintf("        if (fread(&rank, sizeof(int32_t), 1, fp) != 1) { fclose(fp); return %d; }", code + 1L),
      sprintf("        if (fread(&nr, sizeof(int32_t), 1, fp) != 1) { fclose(fp); return %d; }", code + 2L),
      sprintf("        if (fread(&nc, sizeof(int32_t), 1, fp) != 1) { fclose(fp); return %d; }", code + 3L),
      sprintf("        if (type_id != %d || rank != %d || nr != %d || nc != %d) { fclose(fp); return %d; }", type_id, rank, nrow, ncol, code + 4L)
    )
    if (isTRUE(item$is_scalar)) {
      init <- c(init,
        sprintf("        if (fread(&%s, sizeof(%s), 1, fp) != 1) { fclose(fp); return %d; }", cn, ctype, code + 6L)
      )
    } else {
      init <- c(init,
        sprintf("        %s = (%s *)hobbs_aligned_malloc((size_t)%d * sizeof(%s));", cn, ctype, len, ctype),
        sprintf("        if (!%s) { fclose(fp); return %d; }", cn, code + 5L),
        sprintf("        if (fread(%s, sizeof(%s), (size_t)%d, fp) != (size_t)%d) { fclose(fp); return %d; }", cn, ctype, len, len, code + 6L)
      )
    }
    init <- c(init, "    }")
    code <- code + 10L
  }
  init <- c(init, "    fclose(fp);", "    return 0;", "}", "")

  free <- c("void posterior_free(void) {")
  for (item in spec) {
    if (!isTRUE(item$is_scalar)) {
      cn <- item$c_name
      free <- c(free,
        sprintf("    hobbs_aligned_free(%s);", cn),
        sprintf("    %s = NULL;", cn)
      )
    }
  }
  free <- c(free, "}")
  c(decl, init, free)
}



count_char <- function(x, ch) {
  if (!nzchar(x)) return(0L)
  chars <- strsplit(x, "", fixed = TRUE)[[1L]]
  sum(chars == ch)
}

split_top_level_commas <- function(x) {
  chars <- strsplit(x, "", fixed = TRUE)[[1L]]
  if (!length(chars)) return(character())
  depth <- 0L
  start <- 1L
  out <- character()
  for (k in seq_along(chars)) {
    ch <- chars[[k]]
    if (identical(ch, "(")) depth <- depth + 1L
    else if (identical(ch, ")")) depth <- depth - 1L
    else if (identical(ch, ",") && depth == 0L) {
      out <- c(out, trimws(paste(chars[start:(k - 1L)], collapse = "")))
      start <- k + 1L
    }
  }
  out <- c(out, trimws(paste(chars[start:length(chars)], collapse = "")))
  out[nzchar(out)]
}


normalize_attached_cache_syntax <- function(src) {
  # The public deterministic-cache language is deliberately small: a cache
  # initializer followed by one or more update declarations attached to a
  # scalar block. Acceptance probabilities always come from ordinary old-state
  # and proposed-state block evaluation.
  removed_ratio_pat <- "(^|})[[:space:]]*(?:ratio|logratio)[[:space:]]*\\{"
  if (any(grepl(removed_ratio_pat, src, perl = TRUE))) {
    stop(
      "`ratio { ... }` and `logratio { ... }` are not supported. ",
      "Define the changing log-posterior terms in the block and maintain deterministic state with `cache`/`update`.",
      call. = FALSE
    )
  }

  # Let users write attached cache/update declarations on the same line as the
  # preceding block. Splitting only these hobbs keywords keeps ordinary C
  # untouched and lets the line-oriented parser continue to work.
  out <- character()
  for (line in src) {
    line <- gsub("}[[:space:]]+cache[[:space:]]+", "}\ncache ", line, perl = TRUE)
    line <- gsub("}[[:space:]]+update[[:space:]]+", "}\nupdate ", line, perl = TRUE)
    out <- c(out, strsplit(line, "\n", fixed = TRUE)[[1L]])
  }
  out
}


collect_braced_declaration <- function(src, i, keyword) {
  line <- src[[i]]
  bal <- count_char(line, "{") - count_char(line, "}")
  lines <- line
  j <- i
  while (bal > 0L && j < length(src)) {
    j <- j + 1L
    lines <- c(lines, src[[j]])
    bal <- bal + count_char(src[[j]], "{") - count_char(src[[j]], "}")
  }
  if (bal != 0L) stop("Unbalanced braces in ", keyword, " declaration.", call. = FALSE)
  list(lines = lines, next_i = j + 1L)
}

cache_body_lines <- function(lines) {
  extract_func_body_lines(lines)
}

extract_attached_cache_declarations <- function(src) {
  src <- normalize_attached_cache_syntax(src)
  out <- character()
  caches <- list()
  i <- 1L
  block_pat <- "^([[:space:]]*)block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)(?:[[:space:]]*\\([[:space:]]*([^)]*)[[:space:]]*\\))?[[:space:]]*\\{"
  cache_pat <- "^[[:space:]]*cache[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([^)]*)[[:space:]]*\\)[[:space:]]*\\{"
  update_pat <- "^[[:space:]]*update[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([^)]*)[[:space:]]*\\)[[:space:]]*\\{"
  while (i <= length(src)) {
    line <- src[[i]]
    m <- regexec(block_pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) >= 3L) {
      block_name <- hit[[3L]]
      block_index_text <- if (length(hit) >= 4L && !is.na(hit[[4L]])) trimws(hit[[4L]]) else ""
      block_index_vars <- character()
      if (nzchar(block_index_text) && block_index_text != "1") {
        block_index_vars <- split_top_level_commas(block_index_text)
      }
      block_index <- if (length(block_index_vars) == 1L) block_index_vars[[1L]] else "hobbs_block_index"
      blk <- collect_braced_declaration(src, i, "block")
      out <- c(out, blk$lines)
      i <- blk$next_i
      repeat {
        if (i > length(src)) break
        next_line <- src[[i]]
        mc <- regexec(cache_pat, next_line, perl = TRUE)
        hc <- regmatches(next_line, mc)[[1L]]
        if (length(hc) == 3L) {
          cc <- collect_braced_declaration(src, i, "cache")
          caches[[length(caches) + 1L]] <- list(
            kind = "cache",
            block = block_name,
            block_index = block_index,
            block_index_vars = block_index_vars,
            name = hc[[2L]],
            dims = trimws(hc[[3L]]),
            body = cache_body_lines(cc$lines)
          )
          i <- cc$next_i
          next
        }
        mu <- regexec(update_pat, next_line, perl = TRUE)
        hu <- regmatches(next_line, mu)[[1L]]
        if (length(hu) == 3L) {
          uu <- collect_braced_declaration(src, i, "update")
          caches[[length(caches) + 1L]] <- list(
            kind = "update",
            block = block_name,
            block_index = block_index,
            block_index_vars = block_index_vars,
            name = hu[[2L]],
            dims = trimws(hu[[3L]]),
            body = cache_body_lines(uu$lines)
          )
          i <- uu$next_i
          next
        }
        break
      }
      next
    }
    # Standalone declarations are kept as ordinary source so C compilation
    # fails clearly; cache/update declarations must be attached to a block.
    out <- c(out, line)
    i <- i + 1L
  }
  list(src = out, caches = caches)
}


cache_dims_to_c_len <- function(dims) {
  parts <- split_top_level_commas(dims)
  if (!length(parts)) stop("cache declarations need an extent, e.g. cache mu(n)", call. = FALSE)
  paste(sprintf("((size_t)(%s))", parts), collapse = " * ")
}

cache_index_expr <- function(dims) {
  parts <- split_top_level_commas(dims)
  if (length(parts) == 1L) return("((int)(i) - 1)")
  if (length(parts) == 2L) return(sprintf("((int)(i) - 1) + ((int)(j) - 1) * (%s)", parts[[1L]]))
  stop("cache declarations currently support one- or two-dimensional caches.", call. = FALSE)
}

generate_param_access_macros <- function(param_info, theta_name, prefix = "") {
  if (is.null(param_info) || !length(param_info$spec)) return(character())
  out <- character()
  for (item in param_info$spec) {
    nm <- item$name
    macro_nm <- paste0(prefix, nm)
    off <- as.integer(item$offset)
    nr <- as.integer(item$nrow)
    nc <- as.integer(item$ncol)
    is_discrete <- identical(item$value_type %||% "continuous", "discrete")
    cast0 <- if (is_discrete) "((int)" else ""
    cast1 <- if (is_discrete) ")" else ""
    if (nc == 1L) {
      out <- c(out, sprintf("#define %s(i) %s%s[%d + ((int)(i) - 1)]%s", macro_nm, cast0, theta_name, off, cast1))
    } else {
      out <- c(out, sprintf("#define %s(i, j) %s%s[%d + ((int)(i) - 1) + ((int)(j) - 1) * %d]%s", macro_nm, cast0, theta_name, off, nr, cast1))
    }
  }
  out
}

translate_current_proposal_refs <- function(lines, param_info, block_name = NULL, block_index = "hobbs_block_index", active_args = NULL) {
  if (is.null(param_info) || !length(param_info$spec) || !length(lines)) return(lines)
  if (any(grepl("\\bdelta[[:space:]]*\\(", lines, perl = TRUE))) {
    stop(
      "`delta(...)` is not part of the cache/update language. Write `proposal(x) - current(x)` explicitly.",
      call. = FALSE
    )
  }
  if (!is.null(block_name) && nzchar(block_name)) {
    # The theta pointer already contains the scalar proposal. The old accepted
    # value for the active component is passed separately, so current(beta(j))
    # is O(1) and no parameter-vector copy is required.
    if (is.null(active_args) || !length(active_args)) {
      pmap <- setNames(param_info$spec, vapply(param_info$spec, `[[`, character(1), "name"))
      active_info <- pmap[[block_name]]
      # `block sigma(1)` has no named index variable, but current/proposal
      # references still need to recognize sigma(1) as the active scalar.
      active_args <- if (!is.null(active_info) && as.integer(active_info$len) == 1L) "1" else block_index
    }
    active_args <- trimws(active_args)
    active_call <- paste(active_args, collapse = "[[:space:]]*,[[:space:]]*")
    active <- sprintf("%s[[:space:]]*\\([[:space:]]*%s[[:space:]]*\\)", block_name, active_call)
    replacement_call <- sprintf("%s(%s)", block_name, paste(active_args, collapse = ","))
    lines <- gsub(sprintf("proposal[[:space:]]*\\([[:space:]]*%s[[:space:]]*\\)", active), replacement_call, lines, perl = TRUE)
    lines <- gsub(sprintf("current[[:space:]]*\\([[:space:]]*%s[[:space:]]*\\)", active), "hobbs_current_value", lines, perl = TRUE)
  }
  for (item in param_info$spec) {
    nm <- item$name
    # Other parameters are unchanged during this scalar update, so current(x)
    # and proposal(x) both reduce to the ordinary accessor.
    pat_prop <- sprintf("proposal[[:space:]]*\\([[:space:]]*%s[[:space:]]*\\(([^()]*)\\)[[:space:]]*\\)", nm)
    lines <- gsub(pat_prop, sprintf("%s(\\1)", nm), lines, perl = TRUE)
    pat_cur <- sprintf("current[[:space:]]*\\([[:space:]]*%s[[:space:]]*\\(([^()]*)\\)[[:space:]]*\\)", nm)
    lines <- gsub(pat_cur, sprintf("%s(\\1)", nm), lines, perl = TRUE)
  }
  lines
}


cache_direct_additive_write_pattern <- function(cache_name) {
  # Deliberately line-oriented and anchored.  Bodies that use compound
  # statements, multiline assignments, pointer aliases, or helper calls fall
  # back to the ordinary backup-and-restore path rather than being guessed reversible.
  paste0(
    "^[[:space:]]*", cache_name,
    "[[:space:]]*\\(.*\\)[[:space:]]*(\\+=|-=)[[:space:]]*(.*;[[:space:]]*)$"
  )
}

cache_reference_pattern <- function(cache_name) {
  # Match the generated cache accessor and its private backing pointer. Update
  # bodies that reach storage through an alias are conservatively treated as
  # non-reversible and use an internal backup on rejection.
  paste0(
    "(?:\\b", cache_name, "[[:space:]]*\\(",
    "|\\bhobbs_cache_", cache_name, "\\b)"
  )
}


cache_update_is_reversible <- function(update_item) {
  # A cache update is algebraically reversible only when every occurrence of
  # the attached cache is the left-hand side of a direct += or -= statement,
  # and the right-hand side does not read that cache.  Rejection then executes
  # the same body with each additive operator inverted while theta still holds
  # the proposal.  This preserves branch decisions and right-hand sides exactly.
  nm <- update_item$name
  lines <- trimws(update_item$body)
  lines <- lines[nzchar(lines)]
  if (!length(lines)) return(FALSE)
  write_pattern <- cache_direct_additive_write_pattern(nm)
  reference_pattern <- cache_reference_pattern(nm)
  saw_write <- FALSE

  for (ln in lines) {
    match <- regexec(write_pattern, ln, perl = TRUE)
    hit <- regmatches(ln, match)[[1L]]
    if (length(hit) == 3L) {
      saw_write <- TRUE
      rhs <- hit[[3L]]
      if (grepl(reference_pattern, rhs, perl = TRUE)) return(FALSE)
    } else if (grepl(reference_pattern, ln, perl = TRUE)) {
      return(FALSE)
    }
  }
  saw_write
}

invert_additive_cache_update_body <- function(lines, cache_name) {
  write_pattern <- cache_direct_additive_write_pattern(cache_name)
  vapply(lines, function(line) {
    match <- regexec(write_pattern, line, perl = TRUE)
    hit <- regmatches(line, match)[[1L]]
    if (length(hit) != 3L) return(line)
    operator <- hit[[2L]]
    replacement <- if (identical(operator, "+=")) "-=" else "+="
    # Only the first compound operator can be the anchored cache write.  Any
    # further operators belong to the right-hand side and must remain intact.
    sub(if (identical(operator, "+=")) "\\+=" else "-=", replacement, line, perl = TRUE)
  }, character(1), USE.NAMES = FALSE)
}


cache_multi_index_declarations <- function(block_name, block_index_vars, param_info) {
  if (length(block_index_vars) <= 1L) return(character())
  if (length(block_index_vars) != 2L || any(!grepl("^[A-Za-z_][A-Za-z0-9_]*$", block_index_vars, perl = TRUE))) {
    stop("Multi-index cache update syntax currently supports exactly two index variables, e.g. `block u(j, l) { ... }`.", call. = FALSE)
  }
  pmap <- setNames(param_info$spec, vapply(param_info$spec, `[[`, character(1), "name"))
  info <- pmap[[block_name]]
  if (is.null(info) || as.integer(info$ncol) <= 1L) {
    stop("`block ", block_name, "(", paste(block_index_vars, collapse = ", "), ")` requires a two-dimensional `param ", block_name, "(nrow, ncol)` declaration.", call. = FALSE)
  }
  nr <- as.integer(info$nrow)
  c(
    sprintf("  int %s = ((hobbs_block_index - 1) %% %d) + 1;", block_index_vars[[1L]], nr),
    sprintf("  int %s = ((hobbs_block_index - 1) / %d) + 1;", block_index_vars[[2L]], nr)
  )
}

cache_block_update_is_reversible <- function(update_items, all_cache_names) {
  if (!length(update_items) || !all(vapply(update_items, cache_update_is_reversible, logical(1)))) {
    return(FALSE)
  }
  # No right-hand side or control expression may read any mutable cache.  Such
  # dependencies can make an operator-wise inverse order-dependent.  Strip the
  # recognized direct LHS before checking, and use the general backup path for
  # every ambiguous body.
  for (item in update_items) {
    own_pattern <- cache_direct_additive_write_pattern(item$name)
    residual <- vapply(item$body, function(line) {
      match <- regexec(own_pattern, trimws(line), perl = TRUE)
      hit <- regmatches(trimws(line), match)[[1L]]
      if (length(hit) == 3L) hit[[3L]] else line
    }, character(1), USE.NAMES = FALSE)
    body <- paste(residual, collapse = "\n")
    for (nm in all_cache_names) {
      if (grepl(cache_reference_pattern(nm), body, perl = TRUE)) {
        return(FALSE)
      }
    }
  }
  TRUE
}


generate_cache_c <- function(cache_decls, param_info, theta_name) {
  if (!length(cache_decls)) return(character())
  cache_items <- cache_decls[vapply(cache_decls, function(x) identical(x$kind, "cache"), logical(1))]
  update_items <- cache_decls[vapply(cache_decls, function(x) identical(x$kind, "update"), logical(1))]
  if (!length(cache_items)) {
    stop("cache update syntax requires at least one attached cache initializer.", call. = FALSE)
  }
  cache_names <- vapply(cache_items, `[[`, character(1), "name")
  if (anyDuplicated(cache_names)) {
    stop(
      "Each cache may be declared once; duplicate cache declaration(s): ",
      paste(unique(cache_names[duplicated(cache_names)]), collapse = ", "),
      call. = FALSE
    )
  }
  cache_map <- setNames(cache_items, cache_names)
  for (item in update_items) {
    if (!(item$name %in% names(cache_map))) {
      stop(
        "update for unknown cache `", item$name, "`. Attach a cache ", item$name,
        "(...) initializer first.",
        call. = FALSE
      )
    }
    if (!identical(trimws(item$dims), trimws(cache_map[[item$name]]$dims))) {
      stop(
        "update dimension for cache `", item$name,
        "` must match its cache declaration: update ", item$name, "(", item$dims,
        ") vs cache ", item$name, "(", cache_map[[item$name]]$dims, ").",
        call. = FALSE
      )
    }
  }

  out <- c(
    "",
    "/* generated by hobbs from attached cache/update declarations */",
    "#include <stdlib.h>",
    "#include <string.h>",
    ""
  )
  for (cache in cache_items) {
    nm <- cache$name
    index_expr <- cache_index_expr(cache$dims)
    out <- c(
      out,
      sprintf("static double *hobbs_cache_%s = NULL;", nm),
      sprintf("static double *hobbs_cache_%s_saved = NULL;", nm),
      sprintf("static size_t hobbs_cache_%s_len = 0;", nm),
      if (length(split_top_level_commas(cache$dims)) == 1L) {
        sprintf("#define %s(i) hobbs_cache_%s[%s]", nm, nm, index_expr)
      } else {
        sprintf("#define %s(i, j) hobbs_cache_%s[%s]", nm, nm, index_expr)
      },
      ""
    )
  }

  # Backup storage is allocated only if a non-reversible update or a legacy
  # compatibility path needs it. Ordinary additive cache updates keep only the
  # live aligned array and are reversed algebraically after rejection.
  for (cache in cache_items) {
    nm <- cache$name
    out <- c(
      out,
      sprintf("static inline int hobbs_cache_ensure_saved_%s(void) {", nm),
      sprintf("  if (hobbs_cache_%s_saved != NULL) return 1;", nm),
      sprintf("  hobbs_cache_%s_saved = (double*)hobbs_aligned_malloc(hobbs_cache_%s_len * sizeof(double));", nm, nm),
      sprintf("  return hobbs_cache_%s_saved != NULL;", nm),
      "}",
      ""
    )
  }

  out <- c(
    out,
    "void hobbs_cache_free(void);",
    "",
    sprintf("int hobbs_cache_init(const double *%s, int dim) {", theta_name),
    "  (void)dim;"
  )
  for (cache in cache_items) {
    nm <- cache$name
    out <- c(
      out,
      sprintf("  hobbs_cache_%s_len = %s;", nm, cache_dims_to_c_len(cache$dims)),
      # Return directly on allocation failure rather than jumping to a label
      # after the user cache body. A user cache initializer may declare a VLA
      # (for example, `int active[p];`), and C forbids a goto from entering the
      # scope of a variably modified object. Direct cleanup-and-return is valid
      # regardless of declarations in the translated user body.
      sprintf("  if (hobbs_cache_%s_len == 0) { hobbs_cache_free(); return 1; }", nm),
      sprintf("  hobbs_cache_%s = (double*)hobbs_aligned_malloc(hobbs_cache_%s_len * sizeof(double));", nm, nm),
      sprintf("  if (hobbs_cache_%s == NULL) { hobbs_cache_free(); return 1; }", nm),
      sprintf("  memset(hobbs_cache_%s, 0, hobbs_cache_%s_len * sizeof(double));", nm, nm)
    )
  }
  for (cache in cache_items) {
    body <- translate_r_like_for_loops(cache$body)
    out <- c(out, paste0("  ", body))
  }
  out <- c(
    out,
    "  return 0;",
    "}",
    "",
    "void hobbs_cache_free(void) {"
  )
  for (cache in cache_items) {
    nm <- cache$name
    out <- c(
      out,
      sprintf("  hobbs_aligned_free(hobbs_cache_%s); hobbs_cache_%s = NULL;", nm, nm),
      sprintf("  hobbs_aligned_free(hobbs_cache_%s_saved); hobbs_cache_%s_saved = NULL;", nm, nm),
      sprintf("  hobbs_cache_%s_len = 0;", nm)
    )
  }
  out <- c(out, "}", "")

  # Compatibility snapshot ABI for hand-written libraries and fallback sampler
  # paths. Generated scalar kernels below save only the caches touched by the
  # active block.
  out <- c(out, "void hobbs_cache_snapshot(void) {")
  for (cache in cache_items) {
    nm <- cache$name
    out <- c(
      out,
      sprintf("  if (!hobbs_cache_ensure_saved_%s()) abort();", nm),
      sprintf("  memcpy(hobbs_cache_%s_saved, hobbs_cache_%s, hobbs_cache_%s_len * sizeof(double));", nm, nm, nm)
    )
  }
  out <- c(out, "}", "", "void hobbs_cache_restore(void) {")
  for (cache in cache_items) {
    nm <- cache$name
    out <- c(
      out,
      sprintf("  if (hobbs_cache_%s_saved != NULL) memcpy(hobbs_cache_%s, hobbs_cache_%s_saved, hobbs_cache_%s_len * sizeof(double));", nm, nm, nm, nm)
    )
  }
  out <- c(out, "}", "")

  blocks <- unique(vapply(update_items, `[[`, character(1), "block"))
  for (block_name in blocks) {
    updates <- update_items[vapply(update_items, function(x) identical(x$block, block_name), logical(1))]
    index_vars <- updates[[1L]]$block_index_vars %||% character()
    index_name <- if (length(index_vars) == 1L) {
      updates[[1L]]$block_index %||% index_vars[[1L]]
    } else {
      "hobbs_block_index"
    }
    touched <- unique(vapply(updates, `[[`, character(1), "name"))
    reversible <- cache_block_update_is_reversible(updates, cache_names)

    translated_updates <- vector("list", length(updates))
    out <- c(
      out,
      sprintf("void hobbs_cache_update_%s(const double *%s, int %s, double hobbs_current_value) {", block_name, theta_name, index_name),
      sprintf("  (void)%s; (void)%s; (void)hobbs_current_value;", theta_name, index_name)
    )
    out <- c(out, cache_multi_index_declarations(block_name, index_vars, param_info))
    for (update_index in seq_along(updates)) {
      item <- updates[[update_index]]
      body <- translate_current_proposal_refs(
        item$body,
        param_info,
        block_name = block_name,
        block_index = index_name,
        active_args = index_vars
      )
      body <- translate_r_like_for_loops(body)
      translated_updates[[update_index]] <- list(name = item$name, body = body)
      out <- c(out, paste0("  ", body))
    }
    out <- c(
      out,
      "}",
      "",
      sprintf("int hobbs_cache_update_reversible_%s(void) { return %d; }", block_name, as.integer(reversible)),
      ""
    )

    if (isTRUE(reversible)) {
      out <- c(
        out,
        sprintf("hobbs_EXPORT void hobbs_cache_undo_%s(const double *%s, int %s, double hobbs_current_value) {", block_name, theta_name, index_name),
        sprintf("  (void)%s; (void)%s; (void)hobbs_current_value;", theta_name, index_name),
        cache_multi_index_declarations(block_name, index_vars, param_info)
      )
      for (translated in translated_updates) {
        inverse_body <- invert_additive_cache_update_body(translated$body, translated$name)
        out <- c(out, paste0("  ", inverse_body))
      }
      out <- c(out, "}", "")
    }

    if (isTRUE(reversible)) {
      out <- c(
        out,
        sprintf("static inline void hobbs_cache_begin_%s(const double *theta, int index, double current_value) {", block_name),
        sprintf("  hobbs_cache_update_%s(theta, index, current_value);", block_name),
        "}",
        sprintf("static inline void hobbs_cache_commit_%s(void) { }", block_name),
        sprintf("static inline void hobbs_cache_abort_%s(const double *theta, int index, double current_value) {", block_name),
        sprintf("  hobbs_cache_undo_%s(theta, index, current_value);", block_name),
        "}",
        ""
      )
    } else {
      out <- c(out, sprintf("static inline void hobbs_cache_begin_%s(const double *theta, int index, double current_value) {", block_name))
      for (nm in touched) {
        out <- c(
          out,
          sprintf("  if (!hobbs_cache_ensure_saved_%s()) abort();", nm),
          sprintf("  memcpy(hobbs_cache_%s_saved, hobbs_cache_%s, hobbs_cache_%s_len * sizeof(double));", nm, nm, nm)
        )
      }
      out <- c(
        out,
        sprintf("  hobbs_cache_update_%s(theta, index, current_value);", block_name),
        "}",
        sprintf("static inline void hobbs_cache_commit_%s(void) { }", block_name),
        sprintf("static inline void hobbs_cache_abort_%s(const double *theta, int index, double current_value) {", block_name),
        "  (void)theta; (void)index; (void)current_value;"
      )
      for (nm in touched) {
        out <- c(
          out,
          sprintf("  memcpy(hobbs_cache_%s, hobbs_cache_%s_saved, hobbs_cache_%s_len * sizeof(double));", nm, nm, nm)
        )
      }
      out <- c(out, "}", "")
    }
  }
  out
}


generate_scalar_kernels_c <- function(param_info, block_info, cache_decls) {
  if (is.null(param_info) || !length(param_info$spec) || !length(block_info)) return(character())
  block_names <- vapply(block_info, `[[`, character(1), "name")
  block_map <- setNames(block_info, block_names)
  cache_updates <- cache_decls[vapply(cache_decls, function(x) identical(x$kind, "update"), logical(1))]
  cache_blocks <- unique(vapply(cache_updates, `[[`, character(1), "block"))
  out <- c("", "/* generated scalar transaction and whole-sweep kernels */")

  for (parameter in param_info$spec) {
    name <- parameter$name
    if (is.null(block_map[[name]])) next
    offset <- as.integer(parameter$offset)
    len <- as.integer(parameter$len)
    value_type <- parameter$value_type %||% "continuous"
    sampler <- parameter$sampler %||% (if (identical(value_type, "discrete")) "gibbs" else "rwmh")
    has_cache <- name %in% cache_blocks

    out <- c(
      out,
      sprintf("static inline double hobbs_candidate_impl_%s(double *theta, int index, int position, double current_value, double proposed_value) {", name),
      "  (void)current_value;",
      "  theta[position] = proposed_value;"
    )
    if (has_cache) {
      out <- c(out, sprintf("  hobbs_cache_begin_%s(theta, index, current_value);", name))
    }
    out <- c(
      out,
      sprintf("  return hobbs_block_%s(theta, index);", name),
      "}",
      "",
      sprintf("hobbs_EXPORT double hobbs_scalar_candidate_%s(double *theta, int index, int position, double current_value, double proposed_value) {", name),
      sprintf("  return hobbs_candidate_impl_%s(theta, index, position, current_value, proposed_value);", name),
      "}"
    )

    if (identical(value_type, "continuous") && identical(sampler, "slice")) {
      out <- c(
        out,
        sprintf("hobbs_EXPORT double hobbs_scalar_probe_%s(double *theta, int index, int position, double current_value, double proposed_value) {", name),
        sprintf("  const double proposed_local = hobbs_candidate_impl_%s(theta, index, position, current_value, proposed_value);", name)
      )
      if (has_cache) out <- c(out, sprintf("  hobbs_cache_abort_%s(theta, index, current_value);", name))
      out <- c(
        out,
        "  theta[position] = current_value;",
        "  return proposed_local;",
        "}",
        sprintf("hobbs_EXPORT double hobbs_scalar_slice_try_%s(double *theta, int index, int position, double current_value, double proposed_value, double log_slice, int *accepted_out) {", name),
        sprintf("  const double proposed_local = hobbs_candidate_impl_%s(theta, index, position, current_value, proposed_value);", name),
        "  if (isfinite(proposed_local) && proposed_local >= log_slice) {"
      )
      if (has_cache) out <- c(out, sprintf("    hobbs_cache_commit_%s();", name))
      out <- c(
        out,
        "    *accepted_out = 1;",
        "    return proposed_local;",
        "  }"
      )
      if (has_cache) out <- c(out, sprintf("  hobbs_cache_abort_%s(theta, index, current_value);", name))
      out <- c(
        out,
        "  theta[position] = current_value;",
        "  *accepted_out = 0;",
        "  return proposed_local;",
        "}"
      )
    }

    out <- c(out, sprintf("hobbs_EXPORT void hobbs_scalar_accept_%s(void) {", name))
    if (has_cache) out <- c(out, sprintf("  hobbs_cache_commit_%s();", name))
    out <- c(
      out,
      "}",
      sprintf("hobbs_EXPORT void hobbs_scalar_reject_%s(double *theta, int index, int position, double current_value, double proposed_value) {", name),
      "  (void)index; (void)proposed_value;"
    )
    if (has_cache) out <- c(out, sprintf("  hobbs_cache_abort_%s(theta, index, current_value);", name))
    out <- c(out, "  theta[position] = current_value;", "}", "")

    if (!identical(value_type, "continuous") || !identical(sampler, "rwmh")) next

    # One scalar transition helper is shared by the compatibility sweep and
    # the fused adaptation sweep. Every coordinate still receives an ordinary
    # old-state/proposed-state Metropolis decision in sequence.
    out <- c(
      out,
      sprintf("static hobbs_ALWAYS_INLINE double hobbs_scalar_step_%s(", name),
      "    double * hobbs_RESTRICT theta,",
      "    int index,",
      "    int position,",
      "    double scale,",
      "    double normal,",
      "    double uniform,",
      "    unsigned char *accepted_out,",
      "    int *bad_index) {",
      "  const double current_value = theta[position];",
      "  const double proposed_value = current_value + scale * normal;",
      sprintf("  const double current_local = hobbs_block_%s(theta, index);", name),
      "  if (!isfinite(current_local)) {",
      "    if (bad_index != NULL) *bad_index = index;",
      "    *accepted_out = 0u;",
      "    return NAN;",
      "  }",
      sprintf("  const double proposed_local = hobbs_candidate_impl_%s(theta, index, position, current_value, proposed_value);", name),
      "  const double log_alpha = proposed_local - current_local;",
      "  const int accepted = isfinite(proposed_local) && (log_alpha >= 0.0 || uniform < exp(log_alpha));",
      "  *accepted_out = (unsigned char)accepted;",
      "  if (accepted) {"
    )
    if (has_cache) out <- c(out, sprintf("    hobbs_cache_commit_%s();", name))
    out <- c(
      out,
      "    return log_alpha;",
      "  }"
    )
    if (has_cache) out <- c(out, sprintf("  hobbs_cache_abort_%s(theta, index, current_value);", name))
    out <- c(
      out,
      "  theta[position] = current_value;",
      "  return 0.0;",
      "}",
      ""
    )

    # Compatibility ABI retained for hand-written callers and regression
    # tests. Package-generated Rust samplers use the fused sweeps below.
    out <- c(
      out,
      sprintf("hobbs_EXPORT double hobbs_sweep_%s(", name),
      "    double * hobbs_RESTRICT theta,",
      "    const double * hobbs_RESTRICT scales,",
      "    const double * hobbs_RESTRICT normals,",
      "    const double * hobbs_RESTRICT uniforms,",
      "    unsigned char * hobbs_RESTRICT accepted_flags,",
      "    int *bad_index) {",
      "  double delta_sum = 0.0;",
      "  if (bad_index != NULL) *bad_index = 0;",
      sprintf("  for (int local = 0; local < %d; ++local) {", len),
      "    const int index = local + 1;",
      sprintf("    const int position = %d + local;", offset),
      sprintf("    const double delta = hobbs_scalar_step_%s(theta, index, position, scales[local], normals[local], uniforms[local], &accepted_flags[local], bad_index);", name),
      "    if (isnan(delta)) return NAN;",
      "    delta_sum += delta;",
      "  }",
      "  return delta_sum;",
      "}",
      ""
    )

    # Frozen-scaling production sweep. Once scalar adaptation is over, the C ABI
    # no longer needs tuning factors, warmup counters, or adaptation bounds.
    # the C ABI. Acceptance accounting stays in C so Rust does not need a
    # second per-coordinate pass over flags.
    out <- c(
      out,
      sprintf("hobbs_EXPORT double hobbs_sweep_sample_%s(", name),
      "    double * hobbs_RESTRICT theta,",
      "    const double * hobbs_RESTRICT scales,",
      "    const double * hobbs_RESTRICT normals,",
      "    const double * hobbs_RESTRICT uniforms,",
      "    uint64_t * hobbs_RESTRICT accept_counts,",
      "    uint64_t *accepted_count,",
      "    int *bad_index) {",
      "  double delta_sum = 0.0;",
      "  uint64_t accepted_total = 0u;",
      "  if (accepted_count != NULL) *accepted_count = 0u;",
      "  if (bad_index != NULL) *bad_index = 0;",
      sprintf("  for (int local = 0; local < %d; ++local) {", len),
      "    const int index = local + 1;",
      sprintf("    const int position = %d + local;", offset),
      "    unsigned char accepted = 0u;",
      sprintf("    const double delta = hobbs_scalar_step_%s(theta, index, position, scales[local], normals[local], uniforms[local], &accepted, bad_index);", name),
      "    if (isnan(delta)) return NAN;",
      "    delta_sum += delta;",
      "    if (accepted != 0u) {",
      "      accept_counts[local] += 1u;",
      "      accepted_total += 1u;",
      "    }",
      "  }",
      "  if (accepted_count != NULL) *accepted_count = accepted_total;",
      "  return delta_sum;",
      "}",
      ""
    )

    out <- c(
      out,
      sprintf("hobbs_EXPORT double hobbs_sweep_adapt_%s(", name),
      "    double * hobbs_RESTRICT theta,",
      "    double * hobbs_RESTRICT scales,",
      "    double * hobbs_RESTRICT tuning_factors,",
      "    const double * hobbs_RESTRICT normals,",
      "    const double * hobbs_RESTRICT uniforms,",
      "    uint64_t * hobbs_RESTRICT accept_counts,",
      "    uint64_t * hobbs_RESTRICT warmup_accept_counts,",
      "    int adapting,",
      "    double initial_step,",
      "    double scale_up,",
      "    double scale_down,",
      "    double min_tuning,",
      "    double max_tuning,",
      "    double min_scale,",
      "    double max_scale,",
      "    uint64_t *accepted_count,",
      "    int *bad_index) {",
      "  double delta_sum = 0.0;",
      "  uint64_t accepted_total = 0u;",
      "  if (accepted_count != NULL) *accepted_count = 0u;",
      "  if (bad_index != NULL) *bad_index = 0;",
      sprintf("  for (int local = 0; local < %d; ++local) {", len),
      "    const int index = local + 1;",
      sprintf("    const int position = %d + local;", offset),
      "    unsigned char accepted = 0u;",
      sprintf("    const double delta = hobbs_scalar_step_%s(theta, index, position, scales[local], normals[local], uniforms[local], &accepted, bad_index);", name),
      "    if (isnan(delta)) return NAN;",
      "    delta_sum += delta;",
      "    if (accepted != 0u) {",
      "      accept_counts[local] += 1u;",
      "      accepted_total += 1u;",
      "      if (adapting) warmup_accept_counts[local] += 1u;",
      "    }",
      "    if (adapting) {",
      "      double tuning = tuning_factors[local] * (accepted != 0u ? scale_up : scale_down);",
      "      if (tuning < min_tuning) tuning = min_tuning;",
      "      else if (tuning > max_tuning) tuning = max_tuning;",
      "      tuning_factors[local] = tuning;",
      "      double proposal_scale = initial_step * tuning;",
      "      if (proposal_scale < min_scale) proposal_scale = min_scale;",
      "      else if (proposal_scale > max_scale) proposal_scale = max_scale;",
      "      scales[local] = proposal_scale;",
      "    }",
      "  }",
      "  if (accepted_count != NULL) *accepted_count = accepted_total;",
      "  return delta_sum;",
      "}",
      ""
    )
  }
  out
}


expand_multi_block_declarations <- function(src) {
  src <- normalize_attached_cache_syntax(src)
  out <- character()
  i <- 1L
  pat <- "^([[:space:]]*)block[[:space:]]+(.+?)[[:space:]]*\\{(.*)$"
  while (i <= length(src)) {
    line <- src[[i]]
    m <- regexec(pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 4L) {
      indent <- hit[[2L]]
      head <- trimws(hit[[3L]])
      specs <- split_top_level_commas(head)
      if (length(specs) > 1L) {
        block_lines <- line
        bal <- count_char(line, "{") - count_char(line, "}")
        j <- i
        while (bal > 0L && j < length(src)) {
          j <- j + 1L
          block_lines <- c(block_lines, src[[j]])
          bal <- bal + count_char(src[[j]], "{") - count_char(src[[j]], "}")
        }
        for (spec in specs) {
          copy <- block_lines
          copy[[1L]] <- sub(pat, paste0(indent, "block ", spec, " {\\3"), copy[[1L]], perl = TRUE)
          out <- c(out, copy)
        }
        i <- j + 1L
        next
      }
    }
    out <- c(out, line)
    i <- i + 1L
  }
  out
}

add_implicit_block_target <- function(src) {
  out <- character()
  i <- 1L
  pat <- "^([[:space:]]*)block[[:space:]]+[A-Za-z_][A-Za-z0-9_]*(?:[[:space:]]*\\([[:space:]]*(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+)(?:[[:space:]]*,[[:space:]]*(?:[A-Za-z_][A-Za-z0-9_]*|[0-9]+))*[[:space:]]*\\))?[[:space:]]*\\{(.*)$"
  while (i <= length(src)) {
    line <- src[[i]]
    m <- regexec(pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 3L) {
      block_lines <- line
      bal <- count_char(line, "{") - count_char(line, "}")
      j <- i
      while (bal > 0L && j < length(src)) {
        j <- j + 1L
        block_lines <- c(block_lines, src[[j]])
        bal <- bal + count_char(src[[j]], "{") - count_char(src[[j]], "}")
      }
      # Normalize a simple one-line declaration such as
      #   block beta(j) { beta(j) ~ dnorm(0, 1); }
      # before inserting the implicit target accumulator.  The previous code
      # treated the entire line as a header, which placed `target` and `return`
      # outside the generated C function.
      if (length(block_lines) == 1L && bal == 0L) {
        open_at <- regexpr("{", block_lines[[1L]], fixed = TRUE)[[1L]]
        close_at <- gregexpr("}", block_lines[[1L]], fixed = TRUE)[[1L]]
        close_at <- close_at[close_at > open_at]
        if (open_at > 0L && length(close_at)) {
          close_at <- close_at[[length(close_at)]]
          original <- block_lines[[1L]]
          header <- substr(original, 1L, open_at)
          body <- trimws(substr(original, open_at + 1L, close_at - 1L))
          suffix <- substr(original, close_at + 1L, nchar(original))
          header_indent <- sub("^([[:space:]]*).*", "\\1", original, perl = TRUE)
          block_lines <- c(
            header,
            if (nzchar(body)) paste0(header_indent, "  ", body),
            paste0(header_indent, "}", suffix)
          )
        }
      }
      body_txt <- paste(block_lines[-1L], collapse = "\n")
      returns <- gregexpr("\\breturn[[:space:]]+([^;]+);", body_txt, perl = TRUE)[[1L]]
      has_non_boundary_return <- FALSE
      if (!identical(returns[[1L]], -1L)) {
        ret_txt <- regmatches(body_txt, gregexpr("\\breturn[[:space:]]+([^;]+);", body_txt, perl = TRUE))[[1L]]
        exprs <- trimws(sub("^return[[:space:]]+", "", sub(";$", "", ret_txt)))
        # Treat common impossible-state guards as boundary returns; still add final return target.
        boundary <- exprs %in% c("-INFINITY", "-Inf", "-HUGE_VAL")
        has_non_boundary_return <- any(!boundary)
      }
      if (!has_non_boundary_return) {
        has_target_decl <- grepl("\\bdouble[[:space:]]+target[[:space:]]*=", paste(block_lines, collapse = "\n"), perl = TRUE)
        header_indent <- sub("^([[:space:]]*).*", "\\1", block_lines[[1L]], perl = TRUE)
        body_indent <- paste0(header_indent, "  ")
        # Insert target declaration immediately after the block header unless user already declared it.
        if (!has_target_decl) {
          block_lines <- append(block_lines, paste0(body_indent, "double target = 0.0;"), after = 1L)
        }
        # Insert final return immediately before the closing brace line.
        if (length(block_lines) >= 2L) {
          block_lines <- append(block_lines, paste0(body_indent, "return target;"), after = length(block_lines) - 1L)
        }
      }
      out <- c(out, block_lines)
      i <- j + 1L
      next
    }
    out <- c(out, line)
    i <- i + 1L
  }
  out
}


parse_block_declarations <- function(model_c) {
  src <- readLines(model_c, warn = FALSE)
  src <- normalize_attached_cache_syntax(src)
  src <- expand_multi_block_declarations(src)
  # Supports scalar/indexed blocks.  Multi-index syntax still denotes one
  # scalar parameter coordinate after flattening; it is not a multivariate
  # proposal:
  #   block u0(p) { ... }
  # scalar one-parameter blocks:
  #   block sigma(1) { ... }
  # Bare `block beta { ... }` is parsed only so resolve_block_runtime() can
  # reject it with a focused scalar-only diagnostic.
  pat_multi <- "^[[:space:]]*block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([^)]*,[^)]*)[[:space:]]*\\)[[:space:]]*\\{"
  pat_index <- "^[[:space:]]*block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\)[[:space:]]*\\{"
  pat_one <- "^[[:space:]]*block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*1[[:space:]]*\\)[[:space:]]*\\{"
  pat_group <- "^[[:space:]]*block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\{"
  out <- list()
  for (line in src) {
    m <- regexec(pat_multi, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 3L) {
      idx <- split_top_level_commas(hit[[3L]])
      out[[length(out) + 1L]] <- list(name = hit[[2L]], index = paste(idx, collapse = ","), type = "indexed", scalar_one_syntax = FALSE, n_index = length(idx))
      next
    }
    m <- regexec(pat_index, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 3L) {
      out[[length(out) + 1L]] <- list(name = hit[[2L]], index = hit[[3L]], type = "indexed", scalar_one_syntax = FALSE)
      next
    }
    m <- regexec(pat_one, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 2L) {
      out[[length(out) + 1L]] <- list(name = hit[[2L]], index = "1", type = "indexed", scalar_one_syntax = TRUE)
      next
    }
    m <- regexec(pat_group, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 2L) {
      out[[length(out) + 1L]] <- list(name = hit[[2L]], index = NA_character_, type = "group", scalar_one_syntax = FALSE)
    }
  }
  if (length(out)) {
    nms <- vapply(out, `[[`, character(1), "name")
    if (anyDuplicated(nms)) stop("Block function names must be unique; use exactly one scalar/indexed declaration such as `block name(i)` or `block name(1)` for each parameter.", call. = FALSE)
  }
  out
}


resolve_block_runtime <- function(update, param_info, block_info) {
  if (!identical(update, "block")) return(list())
  if (is.null(param_info) || !length(param_info$spec)) {
    stop("`update = \"block\"` requires `param ...` or `dparam ...` declarations.", call. = FALSE)
  }
  if (is.null(block_info) || !length(block_info)) {
    stop("`update = \"block\"` requires at least one scalar block such as `block name(i) { ... }`.", call. = FALSE)
  }
  block_names <- vapply(block_info, `[[`, character(1), "name")
  specs <- param_info$spec
  param_names <- vapply(specs, `[[`, character(1), "name")
  extra <- setdiff(block_names, param_names)
  if (length(extra)) {
    stop("Block function(s) do not match any declared parameter: ", paste(extra, collapse = ", "), call. = FALSE)
  }
  missing <- setdiff(param_names, block_names)
  if (length(missing)) {
    stop("`update = \"block\"` needs a block function for each parameter block. Missing: ", paste(missing, collapse = ", "), call. = FALSE)
  }
  block_type <- setNames(vapply(block_info, function(x) x$type %||% "indexed", character(1)), block_names)
  grouped <- param_names[block_type[param_names] != "indexed"]
  if (length(grouped)) {
    stop(
      "hobbs block mode uses scalar coordinate updates only. Replace bare grouped declaration(s) ",
      paste(sprintf("`block %s`", grouped), collapse = ", "),
      " with indexed scalar declaration(s), e.g. ",
      paste(sprintf("`block %s(j)`", grouped), collapse = ", "),
      ". Every coordinate is then visited once per sweep; no multivariate block update is made.",
      call. = FALSE
    )
  }

  block_map <- setNames(block_info, block_names)
  for (parameter in specs) {
    declaration <- block_map[[parameter$name]]
    nrow <- as.integer(parameter$nrow)
    ncol <- as.integer(parameter$ncol)
    len <- as.integer(parameter$len)
    n_index <- as.integer(declaration$n_index %||% 1L)

    if (isTRUE(declaration$scalar_one_syntax) && len != 1L) {
      stop(
        "`block ", parameter$name, "(1)` is only valid for a singleton declaration. ",
        "Use `block ", parameter$name, "(j)` so all ", len,
        " scalar coordinates are evaluated with their active index.",
        call. = FALSE
      )
    }
    if (ncol > 1L && n_index != 2L) {
      stop(
        "Two-dimensional parameter `", parameter$name, "(", nrow, ", ", ncol,
        ")` requires a two-index scalar declaration such as `block ",
        parameter$name, "(j, l)`. hobbs flattens it internally but still updates ",
        "exactly one matrix element at a time.",
        call. = FALSE
      )
    }
    if (ncol == 1L && n_index != 1L) {
      stop(
        "Vector parameter `", parameter$name, "(", nrow,
        ")` requires one scalar index, e.g. `block ", parameter$name, "(j)`.",
        call. = FALSE
      )
    }
  }

  out <- lapply(specs, function(x) list(
    name = x$name,
    offset = as.integer(x$offset),
    len = as.integer(x$len),
    type = "indexed",
    value_type = x$value_type %||% "continuous",
    sampler = x$sampler %||% (if (identical(x$value_type, "discrete")) "gibbs" else "rwmh"),
    lower = as.integer(x$lower %||% 0L),
    upper = as.integer(x$upper %||% 0L)
  ))
  # Discrete blocks are safe and often necessary to initialize first.  For
  # example, a ZIP model starts z at 0 but y>0 requires z=1 before continuous
  # parameter blocks have finite likelihood.
  ord <- order(vapply(out, function(x) if (identical(x$value_type, "discrete")) 0L else 1L, integer(1)))
  out[ord]
}


parse_param_declaration_options <- function(suffix, allow_sampler = TRUE, line = "") {
  text <- sub("//.*$", "", suffix, perl = TRUE)
  text <- trimws(text)
  text <- sub(";[[:space:]]*$", "", text, perl = TRUE)
  text <- trimws(text)

  save_mode <- "chain"
  sampler_mode <- if (isTRUE(allow_sampler)) "rwmh" else "gibbs"
  if (!nzchar(text)) {
    return(list(save = save_mode, sampler = sampler_mode))
  }

  text <- gsub("[[:space:]]*=[[:space:]]*", "=", text, perl = TRUE)
  tokens <- strsplit(text, "[[:space:]]+", perl = TRUE)[[1L]]
  seen <- character()

  for (token in tokens) {
    if (token %in% c("save=mean", "save=chain")) {
      if ("save" %in% seen) {
        stop("Duplicate `save=` option in parameter declaration: ", trimws(line), call. = FALSE)
      }
      save_mode <- sub("^save=", "", token)
      seen <- c(seen, "save")
      next
    }

    if (grepl("^sampler=", token, perl = TRUE)) {
      if (!isTRUE(allow_sampler)) {
        stop(
          "`sampler=` is only valid for continuous `param` declarations; bounded `dparam` declarations use exact finite-state Gibbs updates: ",
          trimws(line), call. = FALSE
        )
      }
      if ("sampler" %in% seen) {
        stop("Duplicate `sampler=` option in parameter declaration: ", trimws(line), call. = FALSE)
      }
      sampler_mode <- sub("^sampler=", "", token)
      if (!(sampler_mode %in% c("rwmh", "slice"))) {
        stop(
          "Unknown parameter sampler `", sampler_mode,
          "` in: ", trimws(line),
          ". Supported continuous samplers are `rwmh` and `slice`.",
          call. = FALSE
        )
      }
      seen <- c(seen, "sampler")
      next
    }

    stop(
      "Invalid parameter declaration option `", token, "` in: ", trimws(line),
      ". Supported options are `save=mean` and `sampler=rwmh|slice`.",
      call. = FALSE
    )
  }

  list(save = save_mode, sampler = sampler_mode)
}


parse_param_declarations <- function(model_c, data = NULL) {
  src <- readLines(model_c, warn = FALSE)
  specs <- list()
  offset <- 0L
  names_out <- character()

  # Continuous parameters:
  #   param beta(2)
  #   param theta(n_row, n_col) save=mean sampler=rwmh
  #   param u(n_row, n_col) sampler=slice
  # Discrete integer parameters:
  #   dparam z(n, 0, 1)
  #   dparam z(n, 0, 1) save=mean
  # where the final two entries are inclusive integer bounds. Continuous
  # parameters default to adaptive scalar random-walk Metropolis; sampler=slice
  # selects scalar stepping-out/shrinkage slice sampling for that declaration.
  # Bounded discrete parameters always use exact finite-state Gibbs updates.
  pat_param <- paste0(
    "^[[:space:]]*param[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\(",
    "[[:space:]]*([A-Za-z_][A-Za-z0-9_]*|[0-9]+)[[:space:]]*",
    "(?:,[[:space:]]*([A-Za-z_][A-Za-z0-9_]*|[0-9]+)[[:space:]]*)?\\)",
    "(.*)$"
  )
  pat_dparam <- paste0(
    "^[[:space:]]*dparam[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\(",
    "[[:space:]]*([A-Za-z_][A-Za-z0-9_]*|[0-9]+)[[:space:]]*,",
    "[[:space:]]*([-]?[A-Za-z_][A-Za-z0-9_]*|[-]?[0-9]+)[[:space:]]*,",
    "[[:space:]]*([-]?[A-Za-z_][A-Za-z0-9_]*|[-]?[0-9]+)[[:space:]]*\\)",
    "(.*)$"
  )

  add_spec <- function(nm, nr, nc, value_type = "continuous", lower = NA_integer_,
                       upper = NA_integer_, save_mode = "chain", sampler_mode = "rwmh",
                       line = "") {
    if (is.na(nr) || is.na(nc) || nr <= 0L || nc <= 0L) {
      stop("Invalid parameter declaration: ", trimws(line), call. = FALSE)
    }
    if (nm %in% c("dim", "for", "if", "while", "switch", "return", "sizeof", "param", "dparam", "block")) {
      stop("Invalid parameter block name `", nm, "` in parameter declaration.", call. = FALSE)
    }
    len_dbl <- as.numeric(nr) * as.numeric(nc)
    if (!is.finite(len_dbl) || len_dbl > .Machine$integer.max) {
      stop("Parameter declaration is too large: ", nm, call. = FALSE)
    }
    len <- as.integer(len_dbl)
    start <- offset
    item_names <- if (nc == 1L) {
      sprintf("%s[%d]", nm, seq_len(nr))
    } else {
      unlist(lapply(seq_len(nc), function(j) {
        sprintf("%s[%d,%d]", nm, seq_len(nr), j)
      }), use.names = FALSE)
    }
    specs[[length(specs) + 1L]] <<- list(
      name = nm, nrow = as.integer(nr), ncol = as.integer(nc),
      len = len, offset = start, value_type = value_type,
      lower = if (is.na(lower)) NA_integer_ else as.integer(lower),
      upper = if (is.na(upper)) NA_integer_ else as.integer(upper),
      save = save_mode, sampler = sampler_mode, names = item_names
    )
    names_out <<- c(names_out, item_names)
    offset <<- offset + len
  }

  for (line in src) {
    md <- regexec(pat_dparam, line, perl = TRUE)
    hd <- regmatches(line, md)[[1L]]
    if (length(hd) == 6L) {
      nm <- hd[[2L]]
      nr <- resolve_param_extent(hd[[3L]], data = data, context = trimws(line))
      lo <- resolve_integer_extent(hd[[4L]], data = data, context = trimws(line))
      hi <- resolve_integer_extent(hd[[5L]], data = data, context = trimws(line))
      options <- parse_param_declaration_options(hd[[6L]], allow_sampler = FALSE, line = line)
      if (hi < lo) stop("dparam upper bound must be >= lower bound in: ", trimws(line), call. = FALSE)
      add_spec(nm, nr, 1L, value_type = "discrete", lower = lo, upper = hi,
               save_mode = options$save, sampler_mode = "gibbs", line = line)
      next
    }

    m <- regexec(pat_param, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 5L) {
      nm <- hit[[2L]]
      nr_expr <- hit[[3L]]
      nc_expr <- hit[[4L]]
      options <- parse_param_declaration_options(hit[[5L]], allow_sampler = TRUE, line = line)
      nr <- resolve_param_extent(nr_expr, data = data, context = trimws(line))
      nc <- if (nzchar(nc_expr)) resolve_param_extent(nc_expr, data = data, context = trimws(line)) else 1L
      add_spec(nm, nr, nc, value_type = "continuous", save_mode = options$save,
               sampler_mode = options$sampler, line = line)
      next
    }

    if (grepl("^[[:space:]]*(?:param|dparam)\\b", line, perl = TRUE)) {
      stop(
        "Invalid parameter declaration: ", trimws(line),
        ". Continuous declarations support optional `save=mean` and `sampler=rwmh|slice` modifiers, e.g. ",
        "`param u(m, 2) save=mean sampler=slice;`.",
        call. = FALSE
      )
    }
  }

  if (length(specs)) {
    raw_names <- vapply(specs, `[[`, character(1), "name")
    if (anyDuplicated(raw_names)) stop("Parameter block names in `param ...` / `dparam ...` declarations must be unique.", call. = FALSE)
  }
  is_mean <- vapply(specs, function(item) identical(item$save, "mean"), logical(1))
  list(
    spec = specs,
    dim = offset,
    names = names_out,
    chain_names = unlist(lapply(specs[!is_mean], `[[`, "names"), use.names = FALSE),
    mean_names = unlist(lapply(specs[is_mean], `[[`, "names"), use.names = FALSE),
    mean_ranges = lapply(specs[is_mean], function(item) {
      list(offset = as.integer(item$offset), len = as.integer(item$len), name = item$name)
    })
  )
}

resolve_integer_extent <- function(x, data = NULL, context = "dparam declaration") {
  x <- trimws(x)
  if (grepl("^-?[0-9]+$", x, perl = TRUE)) return(as.integer(x))
  if (!grepl("^[A-Za-z_][A-Za-z0-9_]*$", x, perl = TRUE)) {
    stop("Invalid integer extent `", x, "` in ", context,
         ". Use an integer or the name of a scalar in `data`.", call. = FALSE)
  }
  if (!is.list(data) || is.null(names(data)) || !(x %in% names(data))) {
    stop("Integer extent `", x, "` in ", context,
         " must be supplied as a scalar in `data = list(...)`.", call. = FALSE)
  }
  val <- data[[x]]
  if (!(is.numeric(val) || is.integer(val)) || length(val) != 1L || is.na(val) || !is.finite(val)) {
    stop("Integer extent `", x, "` must be a single finite numeric/integer scalar.", call. = FALSE)
  }
  if (abs(val - round(val)) > .Machine$double.eps^0.5) {
    stop("Integer extent `", x, "` must be integer-like.", call. = FALSE)
  }
  as.integer(round(val))
}


resolve_param_extent <- function(x, data = NULL, context = "param declaration") {
  x <- trimws(x)
  if (grepl("^[0-9]+$", x, perl = TRUE)) return(as.integer(x))
  if (!grepl("^[A-Za-z_][A-Za-z0-9_]*$", x, perl = TRUE)) {
    stop("Invalid parameter extent `", x, "` in ", context,
         ". Use a positive integer or the name of a scalar in `data`.", call. = FALSE)
  }
  if (!is.list(data) || is.null(names(data)) || !(x %in% names(data))) {
    stop("Parameter extent `", x, "` in ", context,
         " must be supplied as a scalar in `data = list(...)`.", call. = FALSE)
  }
  val <- data[[x]]
  if (!(is.numeric(val) || is.integer(val)) || length(val) != 1L || is.na(val) || !is.finite(val)) {
    stop("Parameter extent `", x, "` must be a single finite numeric/integer scalar.", call. = FALSE)
  }
  if (val <= 0 || abs(val - round(val)) > .Machine$double.eps^0.5) {
    stop("Parameter extent `", x, "` must be a positive integer-like scalar.", call. = FALSE)
  }
  as.integer(round(val))
}

strip_param_declarations <- function(src) {
  pat <- paste0(
    "^[[:space:]]*(?:param|dparam)[[:space:]]+[A-Za-z_][A-Za-z0-9_]*",
    "[[:space:]]*\\([^)]+\\)",
    "(?:[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]*=[[:space:]]*[A-Za-z_][A-Za-z0-9_]*)*",
    "[[:space:]]*;?[[:space:]]*(?://.*)?$"
  )
  src[!grepl(pat, src, perl = TRUE)]
}


find_log_posterior_param_name <- function(src) {
  txt <- paste(src, collapse = "\n")
  pats <- c(
    "double[[:space:]]+log_posterior[[:space:]]*\\([[:space:]]*const[[:space:]]+double[[:space:]]*\\*[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*,[[:space:]]*int[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\\)",
    "double[[:space:]]+log_posterior[[:space:]]*\\([[:space:]]*double[[:space:]]+const[[:space:]]*\\*[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*,[[:space:]]*int[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\\)",
    "double[[:space:]]+posterior_logp[[:space:]]*\\([[:space:]]*const[[:space:]]+double[[:space:]]*\\*[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*,[[:space:]]*int[[:space:]]+[A-Za-z_][A-Za-z0-9_]*[[:space:]]*\\)"
  )
  for (pat in pats) {
    m <- regexec(pat, txt, perl = TRUE)
    hit <- regmatches(txt, m)[[1L]]
    if (length(hit) == 2L) return(hit[[2L]])
  }
  NULL
}

generate_param_macros <- function(param_info, theta_name) {
  if (is.null(param_info) || !length(param_info$spec)) return(character())
  if (is.null(theta_name) || !nzchar(theta_name)) theta_name <- "theta"
  out <- c("", "/* generated by hobbs from param declarations */")
  for (item in param_info$spec) {
    nm <- item$name
    off <- as.integer(item$offset)
    nr <- as.integer(item$nrow)
    nc <- as.integer(item$ncol)
    is_discrete <- identical(item$value_type %||% "continuous", "discrete")
    if (nc == 1L) {
      if (is_discrete) {
        # One-based integer vector access for dparam: z(i) == (int)theta[offset + i - 1]
        out <- c(out, sprintf("#define %s(i) ((int)%s[%d + ((int)(i) - 1)])", nm, theta_name, off))
      } else {
        # One-based vector access: beta(i) == theta[offset + i - 1]
        out <- c(out, sprintf("#define %s(i) %s[%d + ((int)(i) - 1)]", nm, theta_name, off))
      }
    } else {
      if (is_discrete) {
        out <- c(out, sprintf("#define %s(i, j) ((int)%s[%d + ((int)(i) - 1) + ((int)(j) - 1) * %d])", nm, theta_name, off, nr))
      } else {
        # One-based, R-style column-major matrix access.
        out <- c(out, sprintf("#define %s(i, j) %s[%d + ((int)(i) - 1) + ((int)(j) - 1) * %d]", nm, theta_name, off, nr))
      }
    }
  }
  c(out, "")
}

translate_user_model_c <- function(model_c, workdir, param_info = NULL, block_info = NULL) {
  src <- readLines(model_c, warn = FALSE)
  src <- normalize_attached_cache_syntax(src)
  src <- expand_multi_block_declarations(src)
  extracted_cache <- extract_attached_cache_declarations(src)
  src <- extracted_cache$src
  src <- add_implicit_block_target(src)
  src <- strip_param_declarations(src)
  translated <- translate_simple_model_signature(src)
  theta_name <- attr(translated, "theta_name", exact = TRUE) %||% find_log_posterior_param_name(translated)
  if (is.null(theta_name) || !nzchar(theta_name)) theta_name <- "theta"
  macros <- generate_param_macros(param_info, theta_name)
  cache_c <- generate_cache_c(extracted_cache$caches, param_info, theta_name)
  translated <- c(macros, cache_c, translated)
  translated <- translate_block_signatures(translated, theta_name, param_info = param_info)
  translated <- translate_distribution_statements(translated)
  translated <- translate_vec_declarations(translated)
  translated <- translate_mat_declarations(translated)
  translated <- translate_r_like_for_loops(translated)
  translated <- vectorize_simple_target_reductions(translated)

  # These plain-C wrappers are intentionally emitted last. The compiler can
  # inline each model block and its deterministic cache update into one native
  # scalar sweep, leaving only one Rust/C boundary crossing per declared
  # parameter vector per MCMC sweep.
  runtime_c <- generate_scalar_kernels_c(param_info, block_info, extracted_cache$caches)
  translated <- c(translated, runtime_c)
  out <- file.path(workdir, "posterior_model_user_translated.c")
  writeLines(translated, out)
  normalizePath(out, mustWork = TRUE)
}


translate_block_signatures <- function(src, theta_name = "par", param_info = NULL) {
  pmap <- list()
  if (!is.null(param_info) && length(param_info$spec)) {
    pmap <- setNames(param_info$spec, vapply(param_info$spec, `[[`, character(1), "name"))
  }
  vapply(src, function(line) {
    pat_multi <- "^([[:space:]]*)block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([^)]*,[^)]*)[[:space:]]*\\)[[:space:]]*\\{(.*)$"
    m <- regexec(pat_multi, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 5L) {
      indent <- hit[[2L]]
      block_name <- hit[[3L]]
      idx_names <- split_top_level_commas(hit[[4L]])
      if (length(idx_names) != 2L || any(!grepl("^[A-Za-z_][A-Za-z0-9_]*$", idx_names, perl = TRUE))) {
        stop("Multi-index block syntax currently supports exactly two index variables, e.g. `block u(j, l) { ... }`.", call. = FALSE)
      }
      info <- pmap[[block_name]]
      if (is.null(info) || as.integer(info$ncol) <= 1L) {
        stop("`block ", block_name, "(", paste(idx_names, collapse = ", "), ")` requires a two-dimensional `param ", block_name, "(nrow, ncol)` declaration.", call. = FALSE)
      }
      nr <- as.integer(info$nrow)
      rest <- hit[[5L]]
      paste0(indent, "double hobbs_block_", block_name, "(const double *", theta_name, ", int hobbs_block_index) {",
             " (void)", theta_name, "; (void)hobbs_block_index;",
             " int ", idx_names[[1L]], " = ((hobbs_block_index - 1) % ", nr, ") + 1;",
             " int ", idx_names[[2L]], " = ((hobbs_block_index - 1) / ", nr, ") + 1;",
             " (void)", idx_names[[1L]], "; (void)", idx_names[[2L]], ";", rest)
    } else {
      pat_index <- "^([[:space:]]*)block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\)[[:space:]]*\\{(.*)$"
      m <- regexec(pat_index, line, perl = TRUE)
      hit <- regmatches(line, m)[[1L]]
      if (length(hit) == 5L) {
        return(paste0(hit[[2L]], "double hobbs_block_", hit[[3L]], "(const double *", theta_name, ", int ", hit[[4L]], ") { (void)", theta_name, "; (void)", hit[[4L]], ";", hit[[5L]]))
      }

      pat_one <- "^([[:space:]]*)block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*1[[:space:]]*\\)[[:space:]]*\\{(.*)$"
      m <- regexec(pat_one, line, perl = TRUE)
      hit <- regmatches(line, m)[[1L]]
      if (length(hit) == 4L) {
        return(paste0(hit[[2L]], "double hobbs_block_", hit[[3L]], "(const double *", theta_name, ", int __hobbs_block_index_unused) { (void)", theta_name, "; (void)__hobbs_block_index_unused;", hit[[4L]]))
      }

      pat_group <- "^([[:space:]]*)block[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\{(.*)$"
      m <- regexec(pat_group, line, perl = TRUE)
      hit <- regmatches(line, m)[[1L]]
      if (length(hit) == 4L) {
        return(paste0(hit[[2L]], "double hobbs_block_", hit[[3L]], "(const double *", theta_name, ", int __hobbs_block_index_unused) { (void)", theta_name, "; (void)__hobbs_block_index_unused;", hit[[4L]]))
      }
      line
    }
  }, character(1), USE.NAMES = FALSE)
}

translate_vec_declarations <- function(src) {
  # Local convenience vectors in user model/block code:
  #   vec x(2);
  # becomes a stack array initialized to zero, and x(i) on ordinary lines is
  # rewritten to one-based array indexing.  Distribution range LHS rewriting is
  # run before this function, so x(1:2) ~ dmvn(...) is already pointer-based.
  decl_pat <- "^([[:space:]]*)vec[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([^)]+?)[[:space:]]*\\)[[:space:]]*;[[:space:]]*(?://.*)?$"
  vec_names <- character()
  for (line in src) {
    m <- regexec(decl_pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 4L) vec_names <- c(vec_names, hit[[3L]])
  }
  out <- character()
  for (line in src) {
    m <- regexec(decl_pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 4L) {
      indent <- hit[[2L]]
      nm <- hit[[3L]]
      n <- trimws(hit[[4L]])
      init_i <- paste0("__hobbs_vec_i_", nm)
      out <- c(out,
        paste0(indent, "double ", nm, "[(int)(", n, ")];"),
        paste0(indent, "for (int ", init_i, " = 0; ", init_i, " < (int)(", n, "); ++", init_i, ") ", nm, "[", init_i, "] = 0.0;")
      )
    } else {
      if (length(vec_names)) {
        for (nm in vec_names) {
          pat <- paste0("\\b", nm, "[[:space:]]*\\([[:space:]]*([^():]+?)[[:space:]]*\\)")
          line <- gsub(pat, paste0(nm, "[((int)(\\1) - 1)]"), line, perl = TRUE)
        }
      }
      out <- c(out, line)
    }
  }
  out
}


translate_mat_declarations <- function(src) {
  # Local convenience matrices in user model/block code:
  #   mat A(2, 2);
  # becomes a stack array initialized to zero, and A(i, j) on ordinary lines is
  # rewritten to one-based, R-style column-major array indexing.  Distribution
  # range LHS rewriting is run before this function, so A(1:2, 1:2) patterns
  # are not handled here.
  decl_pat <- "^([[:space:]]*)mat[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([^,]+?)[[:space:]]*,[[:space:]]*([^)]+?)[[:space:]]*\\)[[:space:]]*;[[:space:]]*(?://.*)?$"
  mats <- list()
  for (line in src) {
    m <- regexec(decl_pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 5L) {
      mats[[hit[[3L]]]] <- list(nrow = trimws(hit[[4L]]), ncol = trimws(hit[[5L]]))
    }
  }
  out <- character()
  for (line in src) {
    m <- regexec(decl_pat, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 5L) {
      indent <- hit[[2L]]
      nm <- hit[[3L]]
      nr <- trimws(hit[[4L]])
      nc <- trimws(hit[[5L]])
      init_i <- paste0("__hobbs_mat_i_", nm)
      out <- c(out,
        paste0(indent, "double ", nm, "[(int)(", nr, ") * (int)(", nc, ")];"),
        paste0(indent, "for (int ", init_i, " = 0; ", init_i, " < (int)(", nr, ") * (int)(", nc, "); ++", init_i, ") ", nm, "[", init_i, "] = 0.0;")
      )
    } else {
      if (length(mats)) {
        for (nm in names(mats)) {
          nr <- mats[[nm]]$nrow
          pat <- paste0("\\b", nm, "[[:space:]]*\\([[:space:]]*([^(),:]+?)[[:space:]]*,[[:space:]]*([^(),:]+?)[[:space:]]*\\)")
          repl <- paste0(nm, "[((int)(\\1) - 1) + ((int)(\\2) - 1) * (int)(", nr, ")]")
          line <- gsub(pat, repl, line, perl = TRUE)
        }
      }
      out <- c(out, line)
    }
  }
  out
}


translate_simple_model_signature <- function(src) {
  # Translate the preferred hobbs model syntax:
  #   my_model {
  #       ...
  #   }
  # to:
  #   double log_posterior(const double *theta, int dim) {
  #       ...
  #   }
  # Also keeps supporting:
  #   my_model(theta) { ... }
  # where the user chooses the parameter-vector name.  Helper functions should
  # use ordinary C signatures.  If no model function is present, block-only
  # models are allowed later when update = "block".
  control_words <- c("if", "for", "while", "switch", "return", "sizeof", "block", "param")
  pat_arg <- "^([[:space:]]*)([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\)[[:space:]]*\\{(.*)$"
  pat_noarg <- "^([[:space:]]*)([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\{(.*)$"
  done <- FALSE
  out <- src
  for (k in seq_along(out)) {
    if (done) break
    line <- out[[k]]

    m <- regexec(pat_arg, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 5L) {
      indent <- hit[[2L]]
      model_name <- hit[[3L]]
      param_name <- hit[[4L]]
      rest <- hit[[5L]]
      if (!(model_name %in% control_words)) {
        if (identical(param_name, "dim")) {
          stop("The parameter-vector name in `model_name(param_name) { ... }` cannot be `dim`, because hobbs supplies `dim` separately.", call. = FALSE)
        }
        out[[k]] <- paste0(indent, "double log_posterior(const double *", param_name, ", int dim) { (void)dim;", rest)
        attr(out, "theta_name") <- param_name
        done <- TRUE
      }
      next
    }

    m <- regexec(pat_noarg, line, perl = TRUE)
    hit <- regmatches(line, m)[[1L]]
    if (length(hit) == 4L) {
      indent <- hit[[2L]]
      model_name <- hit[[3L]]
      rest <- hit[[4L]]
      if (!(model_name %in% control_words)) {
        out[[k]] <- paste0(indent, "double log_posterior(const double *theta, int dim) { (void)dim;", rest)
        attr(out, "theta_name") <- "theta"
        done <- TRUE
      }
    }
  }
  out
}


translate_distribution_statements <- function(src) {
  vapply(src, translate_distribution_statement_line, character(1), USE.NAMES = FALSE)
}

translate_distribution_statement_line <- function(line) {
  # Convenience BUGS/JAGS-like density statements.  These are pure text
  # rewrites to the same cached helpers users can call manually, so there is no
  # runtime penalty versus `target += ..._lpdf(...)`.
  if (!grepl("~", line, fixed = TRUE)) return(line)

  # Preserve a compact one-line loop such as
  #   for (j = 1:p) beta(j) ~ dnorm(0, 1);
  # by translating only its density statement. The loop itself is converted by
  # translate_r_like_for_loops() later in the pipeline.
  control_pat <- "^([[:space:]]*for[[:space:]]*\\([^)]*\\)[[:space:]]*)(.+~.+;[[:space:]]*(?://.*)?)$"
  control_match <- regexec(control_pat, line, perl = TRUE)
  control_hit <- regmatches(line, control_match)[[1L]]
  if (length(control_hit) == 3L) {
    translated_body <- translate_distribution_statement_line(control_hit[[3L]])
    return(paste0(control_hit[[2L]], trimws(translated_body)))
  }

  pat <- "^([[:space:]]*)([^~;]+?)[[:space:]]*~[[:space:]]*([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\((.*)\\)[[:space:]]*;[[:space:]]*(?://.*)?$"
  m <- regexec(pat, line, perl = TRUE)
  hit <- regmatches(line, m)[[1L]]
  if (length(hit) != 5L) return(line)
  indent <- hit[[2L]]
  lhs <- trimws(hit[[3L]])
  dist <- hit[[4L]]
  args <- trimws(hit[[5L]])

  lhs_range <- parse_lhs_range(lhs)
  if (dist %in% c("dmvn", "mvn", "multi_normal", "multivariate_normal")) {
    if (is.null(lhs_range)) return(line)
    return(paste0(indent, "target += dmvn_lpdf(", lhs_range$ptr, ", ", args, ", ", lhs_range$dim, ");"))
  }
  if (dist %in% c("dbvn", "bvn", "bivariate_normal")) {
    if (!is.null(lhs_range)) {
      return(paste0(indent, "target += dbvn_cov_lpdf(", lhs_range$ptr, ", ", args, ");"))
    }
    return(paste0(indent, "target += dbvn_lpdf(", lhs, if (nzchar(args)) ", " else "", args, ");"))
  }
  if (dist %in% c("dwish", "wishart", "dinvwish", "invwishart", "inv_wishart", "dlkjcorr2", "lkjcorr2")) {
    if (is.null(lhs_range)) return(line)
    fn_mat <- switch(dist,
      dwish = "dwish_lpdf",
      wishart = "dwish_lpdf",
      dinvwish = "dinvwish_lpdf",
      invwishart = "dinvwish_lpdf",
      inv_wishart = "dinvwish_lpdf",
      dlkjcorr2 = "dlkjcorr2_lpdf",
      lkjcorr2 = "dlkjcorr2_lpdf",
      NA_character_
    )
    return(paste0(indent, "target += ", fn_mat, "(", lhs_range$ptr, if (nzchar(args)) ", " else "", args, ");"))
  }

  fn <- switch(dist,
    dbern = "bernoulli_lpdf",
    bernoulli = "bernoulli_lpdf",
    bernoulli_logit = "bernoulli_logit_lpdf",
    bernoulli_probit = "bernoulli_probit_lpdf",
    bernoulli_cloglog = "bernoulli_cloglog_lpdf",
    dpois = "poisson_lpdf",
    poisson = "poisson_lpdf",
    poisson_log = "poisson_log_lpdf",
    dnorm = "normal_lpdf",
    normal = "normal_lpdf",
    normal01 = "normal01_lpdf",
    normal_sd1 = "normal_sd1_lpdf",
    dbinom = "binomial_lpdf",
    binomial = "binomial_lpdf",
    binomial_logit = "binomial_logit_lpdf",
    dnbinom = "negbinomial_lpdf",
    negbinomial = "negbinomial_lpdf",
    negbinomial_log = "negbinomial_log_lpdf",
    dnbinom_log = "negbinomial_log_lpdf",
    dunif = "uniform_lpdf",
    uniform = "uniform_lpdf",
    dexp = "exponential_lpdf",
    exponential = "exponential_lpdf",
    dgamma = "gamma_lpdf",
    gamma = "gamma_lpdf",
    dinvgamma = "invgamma_lpdf",
    invgamma = "invgamma_lpdf",
    dbeta = "beta_lpdf",
    beta = "beta_lpdf",
    dcauchy = "cauchy_lpdf",
    cauchy = "cauchy_lpdf",
    dt = "student_t_lpdf",
    student_t = "student_t_lpdf",
    dchisq = "chisq_lpdf",
    chisq = "chisq_lpdf",
    dlnorm = "lognormal_lpdf",
    lognormal = "lognormal_lpdf",
    dlogis = "logistic_lpdf",
    logistic = "logistic_lpdf",
    dlaplace = "laplace_lpdf",
    laplace = "laplace_lpdf",
    dweibull = "weibull_lpdf",
    weibull = "weibull_lpdf",
    dpareto = "pareto_lpdf",
    pareto = "pareto_lpdf",
    dhalfnorm = "halfnorm_lpdf",
    halfnorm = "halfnorm_lpdf",
    dhalfcauchy = "halfcauchy_lpdf",
    halfcauchy = "halfcauchy_lpdf",
    NA_character_
  )
  if (is.na(fn)) return(line)
  paste0(indent, "target += ", fn, "(", lhs, if (nzchar(args)) ", " else "", args, ");")
}

parse_lhs_range <- function(lhs) {
  # Range sampling syntax for contiguous local vectors/arrays, e.g.
  #   utem(1:2) ~ dmvn(mu, Sigma);
  pat <- "^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([A-Za-z_][A-Za-z0-9_]*|[0-9]+)[[:space:]]*:[[:space:]]*([A-Za-z_][A-Za-z0-9_]*|[0-9]+)[[:space:]]*\\)$"
  m <- regexec(pat, lhs, perl = TRUE)
  hit <- regmatches(lhs, m)[[1L]]
  if (length(hit) == 4L) {
    nm <- hit[[2L]]
    lo <- hit[[3L]]
    hi <- hit[[4L]]
    dim <- if (grepl("^[0-9]+$", lo) && grepl("^[0-9]+$", hi)) {
      as.character(as.integer(hi) - as.integer(lo) + 1L)
    } else {
      paste0("((int)(", hi, ") - (int)(", lo, ") + 1)")
    }
    return(list(ptr = paste0(nm, " + ((int)(", lo, ") - 1)"), dim = dim))
  }

  # Matrix row/column slices with fixed integer ranges, e.g.
  #   u(j, 1:2) ~ dmvn(mu, Sigma);
  # dmvn_lpdf expects contiguous input, while a matrix row is strided in the
  # generated column-major layout.  Use a C99 compound literal so users do not
  # have to create an explicit temporary vector in the model code.
  pat2 <- "^([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*\\([[:space:]]*([^,]+?)[[:space:]]*,[[:space:]]*([^,]+?)[[:space:]]*\\)$"
  m <- regexec(pat2, lhs, perl = TRUE)
  hit <- regmatches(lhs, m)[[1L]]
  if (length(hit) != 4L) return(NULL)

  nm <- hit[[2L]]
  a <- trimws(hit[[3L]])
  b <- trimws(hit[[4L]])
  a_colon <- find_top_level_colon(a)
  b_colon <- find_top_level_colon(b)

  if (!is.na(a_colon) && is.na(b_colon)) {
    lo <- trimws(substring(a, 1L, a_colon - 1L))
    hi <- trimws(substring(a, a_colon + 1L))
    if (!grepl("^[0-9]+$", lo) || !grepl("^[0-9]+$", hi)) return(NULL)
    idx <- seq.int(as.integer(lo), as.integer(hi))
    vals <- paste0(nm, "(", idx, ",", b, ")")
    return(list(ptr = paste0("((double[]){", paste(vals, collapse = ", "), "})"), dim = as.character(length(idx))))
  }

  if (is.na(a_colon) && !is.na(b_colon)) {
    lo <- trimws(substring(b, 1L, b_colon - 1L))
    hi <- trimws(substring(b, b_colon + 1L))
    if (!grepl("^[0-9]+$", lo) || !grepl("^[0-9]+$", hi)) return(NULL)
    idx <- seq.int(as.integer(lo), as.integer(hi))
    vals <- paste0(nm, "(", a, ",", idx, ")")
    return(list(ptr = paste0("((double[]){", paste(vals, collapse = ", "), "})"), dim = as.character(length(idx))))
  }

  NULL
}

translate_r_like_for_loops <- function(src) {
  vapply(src, translate_r_like_for_loop_line, character(1), USE.NAMES = FALSE)
}


vectorize_simple_target_reductions <- function(src) {
  # Break the long floating-point dependency chain in the common generated
  # pattern
  #   for (int i = 1; i <= n; ++i) {
  #     target += expression(i);
  #   }
  # using eight independent accumulators. This is deliberately conservative:
  # only an otherwise empty three-line loop is transformed.
  out <- character()
  i <- 1L
  serial <- 0L
  loop_pat <- "^([[:space:]]*)for[[:space:]]*\\([[:space:]]*int[[:space:]]+([A-Za-z_][A-Za-z0-9_]*)[[:space:]]*=[[:space:]]*1[[:space:]]*;[[:space:]]*\\2[[:space:]]*<=[[:space:]]*(.+?)[[:space:]]*;[[:space:]]*\\+\\+\\2[[:space:]]*\\)[[:space:]]*\\{[[:space:]]*$"
  add_pat <- "^[[:space:]]*target[[:space:]]*\\+=[[:space:]]*(.+);[[:space:]]*$"
  close_pat <- "^[[:space:]]*}[[:space:]]*$"
  while (i <= length(src)) {
    if (i + 2L <= length(src)) {
      m <- regexec(loop_pat, src[[i]], perl = TRUE)
      loop_hit <- regmatches(src[[i]], m)[[1L]]
      a <- regexec(add_pat, src[[i + 1L]], perl = TRUE)
      add_hit <- regmatches(src[[i + 1L]], a)[[1L]]
      if (length(loop_hit) == 4L && length(add_hit) == 2L && grepl(close_pat, src[[i + 2L]], perl = TRUE)) {
        indent <- loop_hit[[2L]]
        var <- loop_hit[[3L]]
        upper <- trimws(loop_hit[[4L]])
        expr <- trimws(add_hit[[2L]])
        # Avoid transforming expressions with obvious side effects.
        if (!grepl("(\\+\\+|--|[^=!<>]=[^=])", expr, perl = TRUE)) {
          serial <- serial + 1L
          tag <- paste0("hobbs_red_", serial)
          replace_var <- function(offset) {
            repl <- if (offset == 0L) var else sprintf("(%s + %d)", var, offset)
            gsub(paste0("\\b", var, "\\b"), repl, expr, perl = TRUE)
          }
          lines <- c(
            paste0(indent, "{"),
            sprintf("%s  const int64_t %s_end = (int64_t)(%s);", indent, tag, upper),
            sprintf("%s  int64_t %s = 1;", indent, var),
            sprintf("%s  double %s_0 = 0.0, %s_1 = 0.0, %s_2 = 0.0, %s_3 = 0.0;", indent, tag, tag, tag, tag),
            sprintf("%s  double %s_4 = 0.0, %s_5 = 0.0, %s_6 = 0.0, %s_7 = 0.0;", indent, tag, tag, tag, tag),
            sprintf("%s  const int64_t %s_vec_end = %s_end >= 8 ? %s_end - 7 : 0;", indent, tag, tag, tag),
            sprintf("%s  for (; %s <= %s_vec_end; %s += 8) {", indent, var, tag, var)
          )
          for (k in 0:7) {
            lines <- c(lines, sprintf("%s    %s_%d += %s;", indent, tag, k, replace_var(k)))
          }
          lines <- c(
            lines,
            paste0(indent, "  }"),
            sprintf("%s  for (; %s < %s_end; ++%s) {", indent, var, tag, var),
            sprintf("%s    %s_0 += %s;", indent, tag, replace_var(0L)),
            paste0(indent, "  }"),
            sprintf("%s  if (%s == %s_end) %s_0 += %s;", indent, var, tag, tag, replace_var(0L)),
            sprintf("%s  target += ((%s_0 + %s_1) + (%s_2 + %s_3)) + ((%s_4 + %s_5) + (%s_6 + %s_7));", indent, tag, tag, tag, tag, tag, tag, tag, tag),
            paste0(indent, "}")
          )
          out <- c(out, lines)
          i <- i + 3L
          next
        }
      }
    }
    out <- c(out, src[[i]])
    i <- i + 1L
  }
  out
}

translate_r_like_for_loop_line <- function(line) {
  # Translate the hobbs model convenience syntax:
  #   for (i in 1:N) { ... }
  #   for (i = 1:N)  { ... }
  # into ordinary C:
  #   for (int i = 1; i <= N; ++i) { ... }
  # This is intentionally conservative.  Normal C for-loops contain semicolons
  # and are left untouched.
  loc <- regexpr("for\\s*\\(", line, perl = TRUE)
  if (loc[[1L]] < 0L) return(line)

  open <- regexpr("\\(", substring(line, loc[[1L]]), perl = TRUE)[[1L]] + loc[[1L]] - 1L
  chars <- strsplit(line, "", fixed = TRUE)[[1L]]
  depth <- 0L
  close <- NA_integer_
  for (k in seq.int(open, length(chars))) {
    if (identical(chars[[k]], "(")) depth <- depth + 1L
    if (identical(chars[[k]], ")")) {
      depth <- depth - 1L
      if (depth == 0L) {
        close <- k
        break
      }
    }
  }
  if (is.na(close)) return(line)

  inner <- substring(line, open + 1L, close - 1L)
  if (grepl(";", inner, fixed = TRUE)) return(line)

  m <- regexec("^\\s*(?:int\\s+)?([A-Za-z_][A-Za-z0-9_]*)\\s*(?:in|=)\\s*(.+)\\s*$", inner, perl = TRUE)
  hit <- regmatches(inner, m)[[1L]]
  if (length(hit) != 3L) return(line)

  var <- trimws(hit[[2L]])
  range <- trimws(hit[[3L]])
  colon <- find_top_level_colon(range)
  if (is.na(colon)) return(line)

  lower <- trimws(substring(range, 1L, colon - 1L))
  upper <- trimws(substring(range, colon + 1L))
  if (!nzchar(lower) || !nzchar(upper)) return(line)

  repl <- sprintf("for (int %s = %s; %s <= %s; ++%s)", var, lower, var, upper, var)
  paste0(substring(line, 1L, loc[[1L]] - 1L), repl, substring(line, close + 1L))
}

find_top_level_colon <- function(x) {
  chars <- strsplit(x, "", fixed = TRUE)[[1L]]
  depth <- 0L
  for (k in seq_along(chars)) {
    ch <- chars[[k]]
    if (identical(ch, "(")) depth <- depth + 1L
    else if (identical(ch, ")")) depth <- depth - 1L
    else if (identical(ch, ":") && depth == 0L) return(k)
  }
  NA_integer_
}

prepare_model_translation_unit <- function(model_c, workdir, log_cache = FALSE, data_spec = NULL, param_info = NULL, block_info = NULL, allow_block_only = FALSE) {
  model_c <- translate_user_model_c(model_c, workdir, param_info = param_info, block_info = block_info)
  src <- readLines(model_c, warn = FALSE)
  has_posterior_logp <- any(grepl("\\bposterior_logp\\s*\\(", src))
  has_log_posterior <- any(grepl("\\blog_posterior\\s*\\(", src))
  has_posterior_init <- any(grepl("\\bposterior_init\\s*\\(", src))
  has_posterior_free <- any(grepl("\\bposterior_free\\s*\\(", src))
  if (!is.null(data_spec) && (has_posterior_init || has_posterior_free)) {
    stop("When `data` is an R object, the C model should not define `posterior_init` or `posterior_free`; hobbs generates those automatically.", call. = FALSE)
  }

  include_dir <- system.file("include", package = "hobbs", mustWork = FALSE)
  if (!nzchar(include_dir) || !dir.exists(include_dir)) {
    include_dir <- normalizePath(file.path(dirname(dirname(model_c)), "inst", "include"), mustWork = FALSE)
  }
  header <- file.path(include_dir, "hobbs_model.h")

  wrapper <- file.path(workdir, "posterior_model_wrapped.c")
  data_bridge <- if (!is.null(data_spec)) c("", generate_data_bridge_c(data_spec, row_map_spec = attr(data_spec, "hobbs_row_map_spec", exact = TRUE)), "") else character()
  lines <- c(
    "/* generated by hobbs: helper API + optional ABI wrapper */",
    sprintf("#include %s", encode_c_include(header)),
    data_bridge,
    sprintf("#include %s", encode_c_include(model_c))
  )

  if (!has_posterior_logp) {
    if (!has_log_posterior) {
      if (!isTRUE(allow_block_only)) {
        stop("C model must define preferred syntax `model_name { ... }` or `model_name(theta) { ... }`, ",
             "legacy `double log_posterior(const double *theta, int dim)`, ",
             "or advanced ABI function `double posterior_logp(const double *theta, int dim)`.",
             call. = FALSE)
      }
    } else {
      lines <- c(lines,
        "",
        "double posterior_logp(const double *theta, int dim) {",
        "    return log_posterior(theta, dim);",
        "}")
    }
  }

  writeLines(lines, wrapper)
  normalizePath(wrapper, mustWork = TRUE)
}

encode_c_include <- function(path) {
  path <- normalizePath(path, mustWork = TRUE)
  paste0('"', gsub("\\\\", "/", path), '"')
}

compile_c_model <- function(model_c, workdir, compiler = NULL, cflags = NULL, quiet = FALSE, log_cache = list(enabled = FALSE)) {
  sys <- Sys.info()[["sysname"]]
  if (is.null(compiler)) {
    # Prefer the compiler configured for this R installation.  In particular,
    # do not blindly take the first gcc/clang on PATH on Windows: that compiler
    # may belong to a different Rtools/MSYS installation and produce a DLL with
    # incompatible runtime dependencies.
    r_bin <- file.path(R.home("bin"), if (.Platform$OS.type == "windows") "R.exe" else "R")
    r_cc <- tryCatch(
      system2(r_bin, c("CMD", "config", "CC"), stdout = TRUE, stderr = FALSE),
      error = function(e) character()
    )
    r_cc <- if (length(r_cc)) trimws(r_cc[[1L]]) else ""
    r_cc_cmd <- if (nzchar(r_cc)) {
      tryCatch(scan(text = r_cc, what = character(), quiet = TRUE)[1L], error = function(e) "")
    } else {
      ""
    }

    fallback <- if (identical(sys, "Darwin")) {
      c("clang", "cc", "gcc")
    } else if (.Platform$OS.type == "windows") {
      c("gcc", "clang", "cc")
    } else {
      c("cc", "gcc", "clang")
    }
    compiler <- unique(c(r_cc_cmd, fallback))
    compiler <- compiler[nzchar(compiler)]
  }
  compiler <- unname(Sys.which(compiler))
  compiler <- compiler[nzchar(compiler)]
  if (!length(compiler)) stop("Could not find requested C compiler", call. = FALSE)
  compiler <- compiler[[1L]]

  ext <- if (identical(sys, "Darwin")) ".dylib" else if (.Platform$OS.type == "windows") ".dll" else ".so"
  lib <- file.path(workdir, paste0("posterior", ext))

  if (is.null(cflags)) {
    opt_flags <- c("-std=gnu11", "-O3", "-DNDEBUG", "-march=native", "-fno-math-errno", "-ffp-contract=fast", "-funroll-loops")
    cflags <- if (identical(sys, "Darwin")) {
      c(opt_flags, "-dynamiclib")
    } else if (.Platform$OS.type == "windows") {
      c(opt_flags, "-shared", "-static-libgcc", "-Wl,--export-all-symbols")
    } else {
      c(opt_flags, "-fno-semantic-interposition", "-fPIC", "-shared", "-Wl,-O2")
    }
  }

  include_dir <- system.file("include", package = "hobbs", mustWork = FALSE)
  if (!nzchar(include_dir) || !dir.exists(include_dir)) {
    include_dir <- normalizePath(file.path(dirname(dirname(model_c)), "inst", "include"), mustWork = FALSE)
  }
  cache_flags <- character()
  if (isTRUE(log_cache$enabled)) {
    cache_flags <- c(
      "-Dhobbs_ENABLE_LOG_CACHE",
      paste0("-Dhobbs_LOG_CACHE_BITS=", as.integer(log_cache$bits)),
      paste0("-Dhobbs_LOGIT_CACHE_BITS=", as.integer(log_cache$bits))
    )
  }
  ldflags <- if (!identical(sys, "Darwin") && !identical(.Platform$OS.type, "windows")) "-lm" else character()
  args <- c(cflags, cache_flags, paste0("-I", shQuote(include_dir)), shQuote(model_c), "-o", shQuote(lib), ldflags)
  status <- system2(compiler, args, stdout = if (quiet) FALSE else "", stderr = if (quiet) FALSE else "")
  if (!identical(status, 0L)) stop("C model compilation failed", call. = FALSE)
  normalizePath(lib, mustWork = TRUE)
}


validate_log_cache <- function(log_cache, log_cache_bits) {
  enabled <- isTRUE(log_cache)
  if (is.list(log_cache)) {
    enabled <- isTRUE(log_cache$enabled %||% TRUE)
    log_cache_bits <- log_cache$bits %||% log_cache_bits
  }

  bits <- as.integer(log_cache_bits)
  if (length(bits) != 1L || is.na(bits) || bits < 8L || bits > 28L) {
    stop("`log_cache_bits` must be a single integer from 8 to 28.", call. = FALSE)
  }
  list(
    enabled = enabled,
    bits = bits,
    entries = 2^bits,
    approximate = enabled
  )
}

`%||%` <- function(x, y) if (is.null(x)) y else x

format_num <- function(x) format(x, scientific = FALSE, trim = TRUE)

example_scalar_code <- function() {
'double log_posterior(const double* theta, int dim) {
    if (dim < 2) return -INFINITY;
    const double x = theta[0];
    const double y = theta[1];
    const double lp_x = -0.5 * (x * x) / 100.0;
    const double mean_y = 0.03 * (x * x - 100.0);
    const double r = y - mean_y;
    double target = lp_x -0.5 * (r * r) / 4.0;
    for (int j = 2; j < dim; ++j) target += -0.5 * theta[j] * theta[j];
    return target;
}
'
}

example_batch_code <- function() {
'static inline double one_logp(const double* theta, int dim) {
    if (dim < 2) return -INFINITY;
    const double x = theta[0];
    const double y = theta[1];
    const double lp_x = -0.5 * (x * x) / 100.0;
    const double mean_y = 0.03 * (x * x - 100.0);
    const double r = y - mean_y;
    double target = lp_x -0.5 * (r * r) / 4.0;
    for (int j = 2; j < dim; ++j) target += -0.5 * theta[j] * theta[j];
    return target;
}

double log_posterior(const double* theta, int dim) {
    return one_logp(theta, dim);
}

void posterior_logp_batch(const double* theta, int dim, int n_batch, double* out) {
    for (int i = 0; i < n_batch; ++i) out[i] = one_logp(theta + ((long long)i * dim), dim);
}
'
}

#' Print a hobbs run
#'
#' @description
#' Prints a concise summary of a completed \code{hobbs} sampling run, including
#' output locations, model dimension, retained samples, warmup settings, and
#' adaptation diagnostics when available.
#'
#' @param x An object of class \code{hobbs_run}, typically returned by
#'   [hobbs()].
#' @param ... Additional arguments passed to the print method. Currently unused.
#'
#' @return Invisibly returns \code{x}.
#'
#' @method print hobbs_run
#' @export
print.hobbs_run <- function(x, ...) {
  cat("hobbs run\n")
  has_chain_output <- !is.null(x$chain_output) && length(x$chain_output) == 1L &&
    !is.na(x$chain_output) && nzchar(x$chain_output)
  has_mean_output <- !is.null(x$mean_output) && length(x$mean_output) == 1L &&
    !is.na(x$mean_output) && nzchar(x$mean_output)
  if (has_chain_output) cat("  chain:       ", x$chain_output, "\n", sep = "")
  if (has_mean_output) cat("  mean:        ", x$mean_output, "\n", sep = "")
  if (!has_chain_output && !has_mean_output) cat("  output:      ", x$output, "\n", sep = "")
  if (!is.null(x$data)) cat("  data:        ", x$data, "\n", sep = "")
  cat("  dim:         ", x$dim, "\n", sep = "")
  if (!is.null(x$chain_dim) && !is.null(x$mean_dim) && x$mean_dim > 0L) {
    cat("  retained:    ", x$chain_dim, " full-chain; ", x$mean_dim, " mean-only\n", sep = "")
  }
  cat("  samples:     ", x$samples, "\n", sep = "")
  cat("  warmups:     ", x$warmups %||% x$burnin, "\n", sep = "")
  cat("  adapt_until: ", x$adapt_until, "\n", sep = "")
  cat("  eval:        ", x$eval, "\n", sep = "")
  cat("  save:        ", x$save %||% "chain", "\n", sep = "")
  if (!is.null(x$adaptation) && !is.na(x$adaptation)) {
    cat("  adaptation:  ", x$adaptation, "\n", sep = "")
  }
  invisible(x)
}

Try the hobbs package in your browser

Any scripts or data that you put into this service are public.

hobbs documentation built on Oct. 6, 2026, 1:07 a.m.