6 Coding conventions
This chapter is the reference for how bmm code is written. It exists because your pull request will be judged against these conventions, and until now they lived mainly in the repository’s AGENTS.md, which an outside contributor has no particular reason to open. Everything here is checked against bmm 1.3.2.
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 |
if (cond) warning2(...) |
warnif(cond, ...) |
Same |
sprintf() |
glue::glue() |
Named interpolation, no positional arguments to miscount |
paste(x, collapse = ", ") |
collapse_comma(x) |
Also quotes each element |
In 1.3.2 the counts are stopif 173, stop2 38, warning2 27, warnif 22, message2 11. The base equivalents appear four times, and every one of them is either inside the definition of stop2()/warning2() themselves (R/utils.R:169, :174) or 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.
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 (R/utils.R:177-184). 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() (R/utils.R:189-199). 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:
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.
DESCRIPTION Imports at 1.3.2 are bayesplot, brms, crayon, fs, glue, matrixStats, methods, parallel, rtdists, rlang, stats and withr. dplyr, tidyr, ggplot2 and stringr are Suggests, not Imports — 1.3.0 removed the dplyr/tidyr/magrittr dependencies. 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:
as at R/helpers-data.R:174-178. Note the same function opens with
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. The 74 return() calls in 1.3.2 are almost all guard clauses, and that is the endorsed use:
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. In 1.3.2 set.seed() appears six times and all six are 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; if you need it in a test, set the seed in the test.
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 supported_models() lists every object matching ^\.model_ as a model (R/helpers-model.R:251).
Comments say why, not what. # construct the family above a brms::custom_family() call adds nothing. The comment bmm’s own source carries at R/model_sdm.R:124 — # 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. In 1.3.2 there are 11 check_data methods and 10 NextMethod("check_data") calls; the one without it is check_data.default, which is the end of the chain and returns the data. Forget the NextMethod() and your model silently skips every generic check, including whether its response column exists.
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 and posterior_predict
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():
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
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.
loop in custom_family() is about the generated Stan code, not about this R function. With loop = FALSE brms emits a vectorized likelihood call, and with loop = TRUE it emits one call per observation. The consequence you will hit concerns vars, the custom_family() argument that names extra Stan variables your likelihood needs beyond the parameters (aggregate counts, trial numbers). With loop = FALSE you may not index those with [n], because there is no per-observation loop to index inside, and brms stops with “Invalid use of index ‘[n]’ in an unlooped custom likelihood”. SDM uses loop = FALSE and no vars; EZDM uses loop = TRUE with vars = c("vreal1[n]", "vint1[n]", "trials[n]").
6.7 Naming traps
Each of these cost somebody real debugging time, and none of them produce an error message that points at the cause.
A custom family’s dpars may not contain _ or ., and may not end with a digit. Both are checked in brms::custom_family() (brms 2.23.0): “Dots or underscores are not allowed in ‘dpars’” and “‘dpars’ should not end with a number”. So neither delta_1 nor delta1 is a legal dpar name. The digits you see on IMM parameters (kappa1, theta3, mu2) are generated by brms::mixture() itself, not declared by bmm, and non-linear parameters introduced through nlf() are not subject to this check.
Reusing a brms dpar name silently inherits its default prior. brms:::def_dpar_prior() attaches a default prior by dpar name (the three colons reach an unexported function, which is why you will not find it in brms’ documentation), and the names it recognizes are mu, sigma, shape, nu, phi, kappa, beta, zi, hu, zoi, coi, bs, ndt, bias, quantile, xi, alpha, disc and theta. Worse, the lookup goes through brms:::dpar_class(), which strips trailing digits, so kappa1 is looked up as kappa. bmm reuses several of these names deliberately and sets its own defaults over the top, which is fine — the trap is a parameter you reuse and then forget to give a default prior, which will quietly get brms’ prior for an unrelated quantity.
A dpar must not be uniquely prefixed by thres. brms:::stan_log_lik_custom() assigns p$thres only for ordinal families but reads p$thres unconditionally when it builds the likelihood call. p is a list keyed by dpar name, and $ partial-matches on lists, so a dpar named threshold is found by p$thres and passed where the ordinal thresholds belong. The result is Stan code that does not parse, from a name that looks perfectly reasonable.
Stan probability-function suffixes require the bar syntax at every call site. stanc, the Stan compiler, says it plainly (version 2.39):
Probability functions with suffixes
_lpdf,_lupdf,_lpmf,_lupmf,_cdf,_lcdfand_lccdf, require a vertical bar (|) between the first two arguments.
A helper you name sdt_cdf therefore cannot be called as sdt_cdf(x, mu); it has to be sdt_cdf(x | mu). If the function is not really a distribution function, give it a different name — sdt_cumprob — and the problem disappears. bmm’s own distribution functions take the other route and use the bar: swald_lpdf(rt | drift, bound, ndt, s) and swald_lccdf(rt | -drift, bound_lower, ndt, s) in inst/stan_chunks/cswald_*.stan.
An nlf() right-hand side is pasted together as text, so operator precedence is yours to manage. The IMM builds its theta expressions with glue and parenthesizes every sub-expression, including the constant: "{lure_idx} * log(exp(c-expS*{nt_distances}) + exp(a)) + (1 - {lure_idx}) * (-100)" (R/model_imm.R:328). When you interpolate an expression into a larger one, wrap it.
6.8 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:56 has has_keyword("bmmodel"). So a new model’s user-facing alias needs #' @keywords bmmodel, which all eight released model aliases carry and which use_model_template() does not generate. Add it yourself. An exported function that is not a model gets @keywords internal or a place in _pkgdown.yml.
A NEWS entry for every user-visible change, under the right heading in NEWS.md, with the issue or PR number in parentheses. Read the 1.3.2 section for the expected level of detail: entries say what changed, what the old behaviour was, and why it mattered, not just “fixed a bug”.
6.9 Tests
Test files mirror source file names — R/model_ddm.R is tested by tests/testthat/test-model_ddm.R. This is a convention rather than a rule: at 1.3.2 nine files under R/ have no matching test file and six test files have no matching source file, usually because they test a theme (test-default-priors.R, test-pkgdown.R) rather than a file. A new model gets a matching test file.
Use brms’ mock backend for pipeline tests (Chapter 5 explains what it is and the two ways to get the call wrong) and testthat edition 3 (Config/testthat/edition: 3 in DESCRIPTION). Set withr::local_options("bmm.silent" = 2) so message2() output does not clutter the run.
6.9.1 Parameter recovery, and the circularity trap
A new model needs a parameter recovery study. It is not part of the package sources, so it is not run by R CMD check and does not need to be fast. Report it in the pull request: the script, what generated the data, and the values you recovered. Where you keep the script is up to you — bmm’s .gitignore excludes local/ for exactly this kind of scratch work (Chapter 1) — but a study the reviewer cannot see does not count as one.
Do not simulate and fit with the same distribution function. If you generate data with your own rmymodel() and then fit a likelihood built from your own dmymodel(), the study will report clean recovery whether or not either function is correct — any error shared by both cancels. Use an independent generator: rtdists::rdiffusion() for diffusion-type models (rtdists is already an Imports), a published reference implementation, or a direct simulation of the process the model describes. A recovery study that cannot fail is not evidence.
Recover both the hyper-parameters (means and standard deviations) and the subject-level parameters, and report how much data was needed, because that is the question a user will ask.