6  Coding conventions

This chapter is the reference for how bmm code is written. Your pull request will be judged against these conventions. The Coding style section of bmm’s CONTRIBUTING.md and its AGENTS.md state them briefly; this chapter gives the reasons.

Read it as conventions, not as a style war. Most of the entries have a reason attached, and the reason is usually that the alternative broke something.

6.1 Errors, warnings and messages

Use the package’s own helpers rather than the base functions:

Instead of Use Why
stop() stop2() Interpolates with glue, and suppresses the “Error in f(x):” call prefix so the user reads the message, not our internals
warning() warning2() Same
message() message2() Same, and respects options(bmm.silent)
if (cond) stop2(...) stopif(cond, ...) One line, and reads as the condition it guards
stopifnot() stopif(cond, ...) stopifnot() messages name the failed expression, not the user’s argument
if (cond) warning2(...) warnif(cond, ...) Same
sprintf() in a message glue::glue() Named interpolation, no positional arguments to miscount
paste(x, collapse = ", ") collapse_comma(x) Also quotes each element

The base functions survive almost only inside the definitions of the helpers themselves and in R/brms-misc.R, which is vendored code: a copy of brms internals kept inside bmm rather than called through brms::, and marked read-only. sprintf() is still the right tool for formatting numbers ("%.2f"); the rule is about building messages.

Two of the reasons deserve spelling out, because they are not stylistic.

message2() checks getOption("bmm.silent", 1) and prints nothing at silence level 2 or above. Tests set withr::local_options("bmm.silent" = 2) to keep output clean. A base message() call ignores that and will make somebody’s test noisy.

stopif() and warnif() pass env.frame = -2 down to stop2(). That number counts frames back up the call stack: -1 would interpolate in stopif()’s own frame, where your variables do not exist, so -2 reaches past it into the function that called stopif(). This is what lets the message interpolate variables from your function. So this works and needs no glue() call of its own:

stopif(
  !version %in% c("custom", "cs", "ss"),
  'Unknown version: {version}. It should be one of "ss", "cs" or "custom"'
)

Write messages as one glue string. stop2() handles multi-line strings with trailing \\ continuations, so a long message does not need paste0().

6.2 Dependencies

Package code is base R plus the native pipe |>. bmm requires R >= 4.1.0, so |> is always available and %>% is not used in package code.

The Imports in DESCRIPTION are a short list, and adding to it needs a reason the reviewer will accept. dplyr, tidyr, ggplot2 and stringr are Suggests, not Imports — dplyr and tidyr became optional in 1.2.0, and magrittr was removed in 1.3.0. If you have seen bmm guidance that prescribes dplyr idiom for package code, it is out of date. Tidyverse packages belong in articles and tests, where they are already available.

A function from a Suggests package must be guarded, or the package breaks for anyone who installed it without that suggestion. The pattern is:

stopif(
  !requireNamespace("tidyr", quietly = TRUE),
  'The "tidyr" package is required for this functionality'
)
data <- tidyr::gather(data, non_target_name, non_target_value, eval(nt_features))

as in R/helpers-data.R. Note the same function opens with

y <- y_nt <- non_target_name <- non_target_value <- NULL

which exists to silence R CMD check’s “no visible binding for global variable” notes from the non-standard evaluation in the gather() call. If you write code with bare column names, you need this line.

Always namespace explicitly: brms::stanvar(), stats::complete.cases(), methods::formalArgs(). bmm imports few functions directly, and an unqualified call to an Imports package is the kind of thing that works on your machine and fails in R CMD check.

6.3 Code shape

Return the last expression; use return() only to leave early. Guard clauses are the endorsed use:

x <- try_read_bmmfit(file, file_refit)
if (!is.null(x)) {
  return(x)
}

Do not write return(out) as the last line of a function.

No set.seed() inside a function, and no seed argument. A function that sets the seed silently rewrites the caller’s random state. set.seed() appears in bmm only in @examples blocks in the roxygen comments — the #' comments above each function from which devtools::document() generates the help pages — where it is the right thing. If you need reproducibility inside a function, take the draws you were given; in a test, use withr::local_seed().

snake_case for functions and variables. Private helpers are prefixed with a dot (.base_imm_formula, .imm_mixture_family). One reservation: a private helper must not be named .model_*, because model_names() lists every object matching ^\.model_ as a model.

Comments say why, not what. # construct the family above a brms::custom_family() call adds nothing. The comment in configure_model.sdm() —# note - c has a log link, but I've coded it manually for computational efficiency — is worth its line, and so is every comment that records a constraint somebody would otherwise remove.

6.4 S3 conventions

Dispatch runs general to specific. A model’s class vector starts with "bmmodel" (the class chains are listed in Section 3.4), so check_data.bmmodel runs before check_data.sdm, not after. This is the opposite of the intuition most people bring, and it has one sharp corollary: a fall-through fallback belongs on .default, never on .bmmodel. A method defined on .bmmodel runs first and shadows nothing, but a fallback placed there would be reached before the model-specific method had a chance to run. bmf2bf is the worked example — bmf2bf.default returns NULL and bmf2bf.bmmodel supplies the fallback after NextMethod() has come back empty (see Chapter 4).

Every check_data method ends with NextMethod("check_data"). Each method does its own model-specific validation and then hands on down the chain. The only method without it is check_data.default, which is the end of the chain and returns the data. Forget it in a method on a shared class (circular, non_targets, sdt) and every more specific method below it never runs, so a model’s own checks are silently skipped. In a model’s own method the omission does no harm today, but the next shared class added above it would break.

Match the generic’s signature exactly, including ... where the generic has one and the defaults on check_model(model, data = NULL, formula = NULL). See the generics table in Chapter 3.

6.5 Where to validate arguments

Validate in the exported constructor only — the user-facing alias. .model_*(), configure_model.*() and private helpers should not, because their error messages name arguments the user never typed. The alias is the one place where the argument the message refers to is the argument the user actually passed:

R/model_m3.R
m3 <- function(resp_cats, num_options, choice_rule = "softmax", version = "custom", ...) {
  call <- match.call()
  stop_missing_args() # errors naming any argument with no default that the user omitted
  stopif(
    length(num_options) != length(resp_cats),
    "The option variables should have the same length as the response variables."
  )
  .model_m3(...)
}

check_data.*() is the exception, and not really an exception: it validates properties of the data, which the constructor cannot see. “Your nt_distances columns contain negative values” is a data message and belongs there.

6.6 log_lik, posterior_predict and posterior_epred

These are plain functions, not S3 methods. Do not name them log_lik.mymodel — that would register them against brms’ log_lik generic, which is not what you want. Name them log_lik_<family_name> and pass them to brms::custom_family():

sdm_simple <- brms::custom_family(
  name = "sdm_simple",
  dpars = c("mu", "c", "kappa"),   # distributional parameters
  ...
  log_lik = log_lik_sdm_simple,
  posterior_predict = posterior_predict_sdm_simple,
  posterior_epred = posterior_epred_sdm_simple
)

brms checks the first arguments when you build the family: log_lik must start with (i, prep) and posterior_predict with (i, prep, ...).

Both take i, the index of one observation, and prep, an object brms assembles from the fitted model and hands to you. prep holds the posterior draws and the data, and you pull values out of it rather than computing them: brms::get_dpar() for a parameter, prep$data$Y for the response.

The contract is easy to get wrong in one specific way. brms::get_dpar(prep, "par", i = i) returns a vector with one value per posterior draw, not a scalar, and your function must return a vector of that same length. The response is a scalar, prep$data$Y[i]:

R/model_sdm.R
log_lik_sdm_simple <- function(i, prep) {
  mu <- brms::get_dpar(prep, "mu", i = i)
  c <- brms::get_dpar(prep, "c", i = i)
  kappa <- brms::get_dpar(prep, "kappa", i = i)
  y <- prep$data$Y[i]
  dsdm(y, mu, c, kappa, log = T)
}

A density function that collapses its arguments (an if on a vector, a sum()) will return a scalar here, and brms will recycle it across all draws without complaint. The posterior predictive checks then look suspiciously clean. Check length() of what you return.

posterior_epred_<family_name> has a different contract: it takes only prep, is called once for all observations, and returns a draws × observations matrix of expected responses. prep$dpars then holds one draws × observations matrix per parameter, and data columns have to be lined up with them. Two helpers in R/helpers-postprocess.R do the bookkeeping: .epred_data() repeats a data column per draw, and .epred_matrix() shapes the result:

R/model_sdt_yn.R
posterior_epred_sdt_yn <- function(prep) {
  stimulus <- .epred_data(prep$data$vint1, prep)
  dist <- .sdt_dist_names[prep$data$vint2[1]]
  criterion <- .sdt_criterion_dpar(prep, prep$dpars$d, prep$dpars$sdratio)
  p_old <- with(prep$dpars, exp(.sdt_log_p_old(.sdt_eta(d, criterion, stimulus, sdratio), dist)))
  .epred_matrix(.epred_data(prep$data$trials, prep) * p_old, prep)
}

Pass it to custom_family() like the other two. A custom family without one fails in posterior_epred() with “object ‘posterior_epred_’ not found”, because brms looks for the function in the global environment. If the expected response of your model is not meaningful, pass posterior_epred_undefined("<model>"), which fails with a message pointing to native_parameters().

For a custom family on aggregated data, loop and vars decide what the Stan likelihood can index; see Section A.4. The names you give a family’s parameters have traps of their own (Section A.8).

6.7 Documentation and the checks that enforce it

NAMESPACE and everything in man/ are generated. Run devtools::document(); never hand-edit them.

Exported functions need @returns and @examples. Two further requirements are enforced by tests rather than by review:

@keywords. The package website is built by pkgdown, which needs every public help topic to appear somewhere in its reference index, and _pkgdown.yml is where that index is declared. tests/testthat/test-pkgdown.R reads every man/*.Rd file, drops the ones with \keyword{internal}, and fails if any remaining topic is not covered by the reference section of _pkgdown.yml. Coverage can be by name or through a has_keyword() selector, and _pkgdown.yml has has_keyword("bmmodel"). So a new model’s user-facing alias needs #' @keywords bmmodel, which every model alias carries and which use_model_template() generates. Keep it when you edit the roxygen block. An exported function that is not a model gets @keywords internal or a place in _pkgdown.yml.

A NEWS bullet for every user-visible change, under the development heading at the top of NEWS.md (on a dev-x.y.0 branch, under that release’s heading), with the issue or pull request number in parentheses. Keep it short: what changes for the user and what they should do, not how the fix works.

6.8 Tests

Test files mirror source file names: R/model_ddm.R is tested by tests/testthat/test-model_ddm.R. Some files cover a theme instead (test-default-priors.R, test-pkgdown.R). Tests use testthat edition 3, the mock backend instead of sampling, and withr::local_options("bmm.silent" = 2) so that message2() output does not clutter the run. The rules that apply to every test are numbered R1 to R10 in AGENTS.md; Chapter 5 lists the ones a new model meets.

6.9 Parameter recovery: the circularity trap

What a recovery study for a new model has to show is set out in CONTRIBUTING.md: true against recovered values, a hierarchical design with several subjects, and data from an independent generator. The last point is the one that is easy to get wrong.

Do not simulate and fit with the same distribution function. If you generate data with your own rmymodel() and fit a likelihood built from your own dmymodel(), the study reports clean recovery whether or not either function is correct, because any error shared by both cancels. Use an independent generator: rtdists::rdiffusion() for diffusion-type models (rtdists is already in Imports), a published reference implementation, or a direct simulation of the process the model describes. A recovery study that cannot fail is not evidence.