tests/testthat/test-model_mpt.R

test_that("mpt_tree constructs and prints a tree", {
  tree <- mpt_tree("old", list(
    old = "D + (1 - D) * g",
    new = "(1 - D) * (1 - g)"
  ))
  expect_s3_class(tree, "mpt_tree")
  expect_equal(tree$name, "old")
  expect_equal(names(tree$branches), c("old", "new"))
  expect_output(print(tree), "P\\(old\\) = D")
})

test_that("mpt_tree validates its inputs", {
  expect_error(mpt_tree("t", list("D + g")), "named after its response category")
  expect_error(
    mpt_tree("t", list(a = "D + g", a = "1 - D - g")),
    "unique within a tree"
  )
  expect_error(mpt_tree("t", list(a = "D + * g", b = "x")), "Cannot parse")
  expect_error(mpt_tree("t", list(a = 0.5, b = "x")), "character strings")
})

test_that("mpt_tree takes formulas and strings interchangeably", {
  mpt_formula_tree_pairs <- list(
    twohtm_old = list(
      formulas = mpt_tree("old",
        old ~ D + (1 - D) * g,
        new ~ (1 - D) * (1 - g)
      ),
      strings = mpt_tree("old", list(
        old = "D + (1 - D) * g",
        new = "(1 - D) * (1 - g)"
      ))
    ),
    pcm = list(
      formulas = mpt_tree("study",
        C ~ cp + (1 - cp) * rp * rp,
        E ~ 2 * (1 - cp) * rp * (1 - rp),
        U ~ (1 - cp) * (1 - rp) * (1 - rp)
      ),
      strings = mpt_tree("study", list(
        C = "cp + (1 - cp) * rp * rp",
        E = "2 * (1 - cp) * rp * (1 - rp)",
        U = "(1 - cp) * (1 - rp) * (1 - rp)"
      ))
    ),
    source_monitoring = list(
      formulas = mpt_tree("sourceA",
        A ~ dA + (1 - dA) * gA,
        B ~ (1 - dA) * gB,
        New ~ (1 - dA) * gNew
      ),
      strings = mpt_tree("sourceA", list(
        A = "dA + (1 - dA) * gA",
        B = "(1 - dA) * gB",
        New = "(1 - dA) * gNew"
      ))
    ),
    folded_fractions = list(
      formulas = mpt_tree("nodist",
        corr ~ Pm * Pb + (1 - Pm) * (1/5),
        npl ~ Pm * (1 - Pb) + (1 - Pm) * (4/5),
        impossible = "dist"
      ),
      strings = mpt_tree("nodist", list(
        corr = "Pm * Pb + (1 - Pm) * (1/5)",
        npl = "Pm * (1 - Pb) + (1 - Pm) * (4/5)"
      ), impossible = "dist")
    )
  )
  for (pair in mpt_formula_tree_pairs) {
    expect_identical(pair$formulas, pair$strings)
  }
  expect_equal(mpt_formula_tree_pairs$folded_fractions$formulas$branches$corr[[3]][[3]], 0.2)

  formula_model <- mpt(
    list(
      mpt_tree("old", old ~ D + (1 - D) * g, new ~ (1 - D) * (1 - g)),
      mpt_tree("new", old ~ (1 - D) * g, new ~ D + (1 - D) * (1 - g))
    ),
    tree_id = "item_type"
  )
  string_model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  expect_equal(formula_model, string_model, ignore_attr = "call")
  dat <- mpt_2htm_data()
  expect_identical(
    stancode(bmf(D ~ 1, g ~ 1), data = dat, model = formula_model),
    stancode(bmf(D ~ 1, g ~ 1), data = dat, model = string_model)
  )
})

test_that("mpt_tree validates formula input", {
  expect_error(mpt_tree("t", ~ D), "two-sided")
  expect_error(mpt_tree("t", a ~ D, ~ 1 - D), "two-sided")
  expect_error(mpt_tree("t", "a" ~ D, b ~ 1 - D), "single response category name")
  expect_error(mpt_tree("t", a ~ D, c(a, b) ~ 1 - D), "single response category name")
  expect_error(mpt_tree("t", a ~ D, a ~ 1 - D), "unique within a tree")
  expect_error(mpt_tree("t", a ~ D, "1 - D"), "mix")
  expect_error(mpt_tree("t", a ~ D, 0.5), "mix")
  expect_error(mpt_tree("t", list(a = "D", b = "1 - D"), c ~ 1), "Cannot mix")
  expect_error(mpt_tree("t", list(a = "D", b = "1 - D"), "a"), "impossible argument has to be named")
  expect_error(
    mpt_tree("t", a ~ D, b ~ 1 - D, "c"),
    "Cannot mix formulas.*impossible argument has to be named, impossible = \"c\""
  )
  expect_error(mpt_tree("t", list(a ~ D, b ~ 1 - D)), "separate arguments")
  expect_error(mpt_tree("t", a ~ D, b ~ 1 - D, impossible = "a"), "both impossible")
  expect_error(mpt_tree("t", a ~ 1e-5 * D, b ~ 1 - D), "scientific notation")
})

test_that("mpt_tree gives the same tree for a formula as for the same string", {
  expressions <- c(
    "D", "-D", "-(1 - D)", "(D)", "((D))", "D^2", "D * 2^-1", "2 * (1 - D) * g",
    "pnorm(-x) + exp(-(y + 1))", "`my par` * (1 - `my par`)", "`a.b` + 0.5",
    "1/3 + D * (1/3)", "(1 - D) * (4/5)", "-1 + 2 * D", "D / (1 + D)"
  )
  for (expression in expressions) {
    from_string <- mpt_tree("t", list(a = expression, b = "1"))
    from_formula <- mpt_tree("t", as.formula(paste("a ~", expression)), b ~ 1)
    expect_identical(from_formula, from_string, label = expression)
  }
})

test_that("mpt_tree stores nothing from the environment of a formula", {
  formula_with_large_environment <- function() {
    large_object <- numeric(1e5)
    a ~ D
  }
  tree <- mpt_tree("t", formula_with_large_environment(), b ~ 1 - D)
  string_tree <- mpt_tree("t", list(a = "D", b = "1 - D"))
  expect_identical(length(serialize(tree, NULL)), length(serialize(string_tree, NULL)))
})

test_that("mpt_tree does not let a partially matched name reorder or drop formulas", {
  expect_error(mpt_tree("t", a ~ D, b = b ~ 1 - D), "in full")
  expect_error(mpt_tree("t", a ~ D, foo = b ~ 1 - D), "in full")
  expect_error(mpt_tree("t", list(a = "D"), imp = "b"), "in full")
  expect_identical(
    mpt_tree("t", br = list(a = "D", b = "1 - D")),
    mpt_tree("t", list(a = "D", b = "1 - D"))
  )
  expect_identical(
    names(mpt_tree(name = "t", a ~ D, b ~ 1 - D)$branches),
    c("a", "b")
  )
})

test_that("mpt_tree rejects branch values that are not expressions", {
  expect_error(mpt_tree("t", a ~ D, b ~ NULL), "must be an expression")
  expect_error(mpt_tree("t", a ~ D, b ~ "1 - D"), "must be an expression")
  expect_error(mpt_tree("t", a ~ D, eval(bquote(b ~ .(c(0.2, 0.3))))), "must be an expression")
  expect_error(mpt_tree("t", list(a = "D", b = "NULL")), "must be an expression")
  expect_error(mpt_tree("t", list(a = "D", b = "'1 - D'")), "must be an expression")
  expect_s3_class(mpt_tree("t", a ~ 0.5, b ~ 0.5), "mpt_tree")
})

test_that("mpt_tree names a trailing comma instead of reporting a missing argument", {
  expect_error(mpt_tree("t", a ~ D, b ~ 1 - D, ), "trailing comma")
  expect_error(mpt_tree("t", a ~ D, , b ~ 1 - D), "trailing comma")
  expect_error(mpt_tree("t", list(a = "D", b = "1 - D"), ), "trailing comma")
})

test_that("mpt stores its derived state once", {
  model <- mpt(mpt_impossible_trees(), tree_id = "tree")
  expect_equal(model$other_vars$link, "logit")
  expect_equal(model$other_vars$indicators$tree, c(withdist = "Idx_withdist", nodist = "Idx_nodist"))
  expect_equal(model$other_vars$indicators$possible, c(dist = "Poss_dist"))
  expect_null(model$other_vars$simplex_raw)
  expect_setequal(names(model$parameters), c("Pm", "Pb"))
  expect_equal(model$links$Pm, "logit")
  expect_equal(model$default_priors$Pm$main, "logistic(0, 1)")
  expect_equal(model$default_priors$Pm$effects, "logistic(0, 1)")

  single <- mpt(mpt_tree("t", list(A = "gA", B = "gB", C = "gC")),
    simplex = c("gA", "gB", "gC"), links = "probit"
  )
  expect_null(single$other_vars$indicators$tree)
  expect_null(single$other_vars$indicators$possible)
  expect_equal(single$other_vars$simplex_raw, c(gA = "gAraw", gB = "gBraw"))
  expect_equal(single$links$gA, "identity")
  expect_equal(single$default_priors$gAraw$main, "probitbeta(1, 2)")
  expect_equal(single$default_priors$gAraw$effects, "normal(0, 1)")
  expect_equal(single$default_priors$gBraw, .mpt_latent_prior("probit"))

  covariate_tree <- mpt_tree("main", list(
    correct = "Pb + (1 - Pb) * Pi * GcorrPi",
    other = "(1 - Pb) * Pi * (1 - GcorrPi) + (1 - Pb) * (1 - Pi)"
  ))
  with_covariate <- mpt(covariate_tree, covariates = "GcorrPi")
  expect_setequal(names(with_covariate$parameters), c("Pb", "Pi"))
  expect_equal(with_covariate$other_vars$covariates, "GcorrPi")
})

test_that("importers record their own call", {
  model <- mpt_from_string(
    "D + (1 - D) * g # old\n(1 - D) * (1 - g) # new", tree_names = "old"
  )
  expect_equal(deparse(attr(model, "call")[[1]]), "mpt_from_string")
  expect_output(print(model), "mpt_from_string")

  eqn_file <- tempfile(fileext = ".eqn")
  writeLines(c("t  a  g", "t  b  1 - g"), eqn_file)
  from_eqn <- mpt_from_eqn(eqn_file)
  expect_equal(deparse(attr(from_eqn, "call")[[1]]), "mpt_from_eqn")
  expect_output(print(from_eqn), "mpt_from_eqn")
})

test_that("an empty formula fits every parameter with an intercept", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  fit <- suppressMessages(bmm(
    bmf(), mpt_2htm_data(), model, backend = "mock", mock_fit = 1, rename = FALSE
  ))
  expect_s3_class(fit, "bmmfit")
  expect_setequal(names(fit$bmm$model$parameters), c("D", "g"))
})

test_that("update() re-runs the mpt data preparation for new data", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat10 <- mpt_2htm_data(n_id = 10)
  dat20 <- mpt_2htm_data(n_id = 20)

  # a mock stanfit with just enough structure for brms::update.brmsfit to
  # reach its data-revalidation path without any compilation or sampling
  methods::setClass("bmm_mock_stanfit", representation(sim = "list"))
  mockfit <- methods::new("bmm_mock_stanfit", sim = list(
    warmup = 1000, iter = 2000, chains = 1, thin = 1,
    samples = list(structure(list(), args = list(control = list())))
  ))
  fit <- suppressMessages(bmm(
    bmf(D ~ 1, g ~ 1), dat10, model,
    backend = "mock", mock_fit = mockfit, rename = FALSE
  ))
  expect_equal(nrow(fit$data), nrow(dat10))
  expect_true(all(c("Idx_old", "Idx_new") %in% names(fit$data)))

  fit2 <- suppressMessages(
    update(fit, newdata = dat20, testmode = TRUE, recompile = FALSE)
  )
  expect_equal(nrow(fit2$data), nrow(dat20))
  expect_equal(sum(fit2$data$Idx_old), nrow(dat20) / 2)
})

test_that("mpt errors on invalid parameter and category names", {
  bad_par <- mpt_tree("t", list(a = "d_A + g", b = "1 - d_A - g"))
  expect_error(mpt(bad_par), "underscores or dots")
  bad_cat <- mpt_tree("t", list(cat_a = "D", cat_b = "1 - D"))
  expect_error(mpt(bad_cat), "underscores or dots")
})

test_that("mpt errors on inconsistent trees and a missing tree_id", {
  trees <- list(
    mpt_tree("t1", list(a = "p", b = "1 - p")),
    mpt_tree("t2", list(a = "p", c = "1 - p"))
  )
  expect_error(mpt(trees, tree_id = "cond"), "same response categories")
  expect_error(mpt(mpt_2htm_trees()), "require the tree_id argument")
  dup_trees <- list(
    mpt_tree("t1", list(a = "p", b = "1 - p")),
    mpt_tree("t1", list(a = "p", b = "1 - p"))
  )
  expect_error(mpt(dup_trees, tree_id = "cond"), "unique")
})

test_that("mpt refuses a zero branch and points to impossible categories", {
  expect_error(
    mpt(mpt_tree("a", list(y = "D", z = "0 * D", w = "1 - D"))),
    "is 0 at the test values.*mpt_tree\\(impossible = \\)"
  )
  # the hint reads as one sentence, not with the source's line breaks
  expect_error(
    mpt(mpt_tree("a", list(y = "D", z = "0 * D", w = "1 - D"))),
    "undefined. Declare a response category that a tree cannot produce with mpt_tree(impossible = ) instead",
    fixed = TRUE
  )
  zero_padded <- list(
    mpt_tree("a", list(x = "D", y = "1 - D", z = "0")),
    mpt_tree("b", list(x = "1 - 1", y = "g", z = "1 - g"))
  )
  expect_error(mpt(zero_padded, tree_id = "t"), "constant 0")
  expect_error(mpt(zero_padded, tree_id = "t"), "'z' in tree 'a', 'x' in tree 'b'")
  expect_error(mpt(zero_padded, tree_id = "t"), "mpt_tree(impossible = )", fixed = TRUE)
  expect_error(mpt(mpt_tree("c", list(x = "(0)", y = "D + (1 - D)"))), "constant 0")
  # not the literal 0 this guard reads, but 0 at every interior test point,
  # which the range check refuses
  zero_product <- mpt_tree("d", list(x = "0 * D", y = "1 - 0 * D"))
  expect_error(mpt(zero_product), "category 'x' in tree 'd' is 0 .*outside \\(0, 1\\]")

  declared <- list(
    mpt_tree("a", list(x = "D", y = "1 - D"), impossible = "z"),
    mpt_tree("b", list(y = "g", z = "1 - g"), impossible = "x")
  )
  model <- mpt(declared, tree_id = "t")
  expect_equal(model$other_vars$indicators$possible, c(z = "Poss_z", x = "Poss_x"))
})

test_that("mpt errors on name collisions and reserved names", {
  tree_collision <- mpt_tree("t", list(D = "D + g", other = "1 - D - g"))
  expect_error(mpt(tree_collision), "both a parameter and a response category")
  tree_reserved <- mpt_tree("t", list(Y = "p", other = "1 - p"))
  expect_error(mpt(tree_reserved), "reserved")
})

test_that("mpt validates simplex groups", {
  trees <- mpt_2htm_trees()
  expect_error(
    mpt(trees, tree_id = "item_type", simplex = c("g", "x")),
    "Unknown"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", simplex = "g"),
    "at least two parameters"
  )
  three <- mpt_tree("t", list(A = "a", B = "b", C = "c"))
  expect_error(
    mpt(three, simplex = c("a", "b", "c"), restrictions = "a = b"),
    "'a' are also restricted, and the restriction removed them"
  )
  expect_error(mpt(three), "belong in the simplex argument")
})

test_that("the links of simplex parameters cannot be changed", {
  tree <- mpt_tree("src", list(
    a = "gA", b = "gB", n = "gN"
  ))
  model <- mpt(tree, simplex = c("gA", "gB", "gN"))
  dat <- data.frame(a = 10, b = 5, n = 5)
  model$links$gA <- "logit"
  expect_error(
    suppressMessages(check_model(model, dat, bmf(gA ~ 1))),
    "cannot be changed"
  )
})

test_that("check_formula generates linked category formulas", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  formula <- bmf(D ~ 1 + (1 | id), g ~ 1)
  model_checked <- check_model(model, dat, formula)
  dat_checked <- check_data(model_checked, dat, formula)
  formula_checked <- check_formula(model_checked, dat_checked, formula)

  expect_setequal(names(formula_checked), c("old", "new", "D", "g"))
  old_rhs <- paste(deparse(formula_checked$old[[3]]), collapse = " ")
  expect_true(grepl("Idx_old", old_rhs, fixed = TRUE))
  expect_true(grepl("inv_logit(D)", old_rhs, fixed = TRUE))
  expect_true(all(is_nl(formula_checked)[c("old", "new")]))
})

test_that("check_formula uses Phi for the probit link", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type", links = "probit")
  dat <- mpt_2htm_data()
  formula <- bmf(D ~ 1, g ~ 1)
  model_checked <- check_model(model, dat, formula)
  dat_checked <- check_data(model_checked, dat, formula)
  formula_checked <- check_formula(model_checked, dat_checked, formula)
  old_rhs <- paste(deparse(formula_checked$old[[3]]), collapse = " ")
  expect_true(grepl("Phi(D)", old_rhs, fixed = TRUE))
  expect_false(grepl("pnorm", old_rhs, fixed = TRUE))
})

test_that("links = list() sets one parameter's link and matched prior at construction", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type", links = list(D = "probit"))
  expect_equal(model$links, list(D = "probit", g = "logit"))
  expect_equal(model$default_priors$D, bmm:::.mpt_latent_prior("probit"))
  expect_equal(model$default_priors$g, bmm:::.mpt_latent_prior("logit"))
  expect_output(print(model), "Links:\\s+D = probit; g = logit")

  pr <- suppressMessages(default_prior(bmf(D ~ 1, g ~ 1), mpt_2htm_data(), model))
  intercepts <- pr$class == "b" & pr$coef == "Intercept"
  expect_equal(pr$prior[intercepts & pr$nlpar == "D"], "normal(0, 1)")
  expect_equal(pr$prior[intercepts & pr$nlpar == "g"], "logistic(0, 1)")

  # the single string remains the shorthand for every latent parameter
  all_probit <- mpt(mpt_2htm_trees(), tree_id = "item_type", links = "probit")
  expect_equal(all_probit$links, list(D = "probit", g = "probit"))
  expect_equal(all_probit$default_priors$g, bmm:::.mpt_latent_prior("probit"))
  expect_equal(all_probit$other_vars$link, "probit")
})

test_that("the links argument of mpt() refuses what set_links() refuses", {
  trees <- mpt_2htm_trees()
  expect_warning(
    model <- mpt(trees, tree_id = "item_type", links = list(gg = "probit")),
    "'gg' read as 'g'"
  )
  expect_equal(model$links$g, "probit")
  expect_error(
    mpt(trees, tree_id = "item_type", links = list(zzz = "probit")),
    "Unrecognized link target\\(s\\): 'zzz'"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", links = list(D = "cauchit")),
    "Unknown link function\\(s\\): 'cauchit'\\. mpt\\(\\) takes 'logit', 'probit'"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", links = "cauchit"),
    "Unknown link function\\(s\\): 'cauchit'\\. mpt\\(\\) takes 'logit', 'probit'"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", links = c("logit", "probit")),
    "one link function for the whole model"
  )
  expect_error(mpt(trees, tree_id = "item_type", links = 1), "named list")
  # NULL takes the default link, as in the other model constructors
  expect_equal(
    mpt(trees, tree_id = "item_type", links = NULL)$links,
    list(D = "logit", g = "logit")
  )

  # simplex members and their stick-breaking components carry identity links
  tree <- mpt_tree("t", list(A = "gA", B = "gB", C = "gC"))
  expect_error(
    mpt(tree, simplex = c("gA", "gB", "gC"), links = list(gA = "probit")),
    "The link of 'gA' cannot be changed in mpt\\(\\)"
  )
  expect_error(
    mpt(tree, simplex = c("gA", "gB", "gC"), links = list(gAraw = "probit")),
    "The link of 'gAraw' cannot be changed in mpt\\(\\)"
  )
})

test_that("mpt() refuses a link for a parameter the restrictions removed", {
  trees <- list(
    mpt_tree("old", list(old = "Do + (1 - Do) * g", new = "(1 - Do) * (1 - g)")),
    mpt_tree("new", list(old = "(1 - Dn) * g", new = "Dn + (1 - Dn) * (1 - g)"))
  )
  expect_error(
    mpt(trees, tree_id = "item_type", restrictions = "Dn = Do", links = list(Dn = "probit")),
    "'Dn' .*removed by the restrictions"
  )
  # 'g' is one edit from 'D', so the link matcher used to read it as D
  expect_error(
    mpt(mpt_2htm_trees(), tree_id = "item_type", restrictions = "g = 0.5",
        links = list(g = "probit")),
    "'g' .*removed by the restrictions"
  )
  model <- mpt(trees, tree_id = "item_type", restrictions = "Dn = Do", links = list(Do = "probit"))
  expect_equal(model$links, list(Do = "probit", g = "logit"))
})

test_that("formulas for response categories are rejected", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  formula <- bmf(old ~ 1, D ~ 1, g ~ 1)
  expect_error(
    bmm(formula, dat, model, backend = "mock", mock_fit = 1, rename = FALSE),
    "cannot be predicted directly"
  )
})

test_that("response categories are refused as predictors before brms sees them", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  expect_error(
    bmm(bmf(D ~ old, g ~ 1), dat, model, backend = "mock", mock_fit = 1, rename = FALSE),
    "response counts 'old' .* cannot be predictors.*formula\\(s\\) for: 'D'"
  )
  expect_error(
    check_model(model, dat, bmf(D ~ inv_logit(k * new), k ~ 1, g ~ 1)),
    "response counts 'new' .* cannot be predictors.*formula\\(s\\) for: 'D'"
  )
})

test_that("mpt compiles for a multi-tree binary-category model", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  formula <- bmf(D ~ 1 + (1 | id), g ~ 1)
  expect_silent(bmm(
    formula = formula, data = dat, model = model,
    backend = "mock", mock_fit = 1, rename = FALSE
  ))
})

test_that("mpt compiles for a single-tree multinomial model", {
  pcm_tree <- mpt_tree("study", list(
    C = "cp + (1 - cp) * rp * rp",
    E = "2 * (1 - cp) * rp * (1 - rp)",
    U = "(1 - cp) * (1 - rp) * (1 - rp)"
  ))
  model <- mpt(pcm_tree)
  dat <- data.frame(id = factor(1:10))
  counts <- t(rmultinom(10, 40, c(0.5, 0.3, 0.2)))
  colnames(counts) <- c("C", "E", "U")
  dat <- cbind(dat, counts)
  formula <- bmf(cp ~ 1 + (1 | id), rp ~ 1 + (1 | id))
  expect_silent(bmm(
    formula = formula, data = dat, model = model,
    backend = "mock", mock_fit = 1, rename = FALSE
  ))
})

test_that("mpt compiles with design-fixed covariates", {
  tree <- mpt_tree("main", list(
    correct = "D + (1 - D) * Gcorr",
    incorrect = "(1 - D) * (1 - Gcorr)"
  ))
  model <- mpt(tree, covariates = "Gcorr")
  dat <- data.frame(
    id = factor(1:10), Gcorr = 0.25,
    correct = rbinom(10, 40, 0.7), incorrect = 0
  )
  dat$incorrect <- 40 - dat$correct
  expect_silent(bmm(
    bmf(D ~ 1 + (1 | id)), dat, model,
    backend = "mock", mock_fit = 1, rename = FALSE
  ))
})

test_that("generated indicator and covariate columns do not trigger the clash warning", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  expect_no_warning(bmm(
    bmf(D ~ 1 + (1 | id), g ~ 1), dat, model,
    backend = "mock", mock_fit = 1, rename = FALSE
  ))

  cov_model <- mpt(
    mpt_tree("main", list(
      correct = "D + (1 - D) * Gcorr",
      incorrect = "(1 - D) * (1 - Gcorr)"
    )),
    covariates = "Gcorr"
  )
  cov_dat <- data.frame(
    id = factor(1:10), Gcorr = 0.25, correct = 28L, incorrect = 12L
  )
  expect_no_warning(bmm(
    bmf(D ~ 1 + (1 | id)), cov_dat, cov_model,
    backend = "mock", mock_fit = 1, rename = FALSE
  ))
})

test_that("a fixed non-linear sub-parameter reaches the constant prior", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  dat$ptime <- rep(c(1, 2), length.out = nrow(dat))
  formula <- bmf(
    D ~ inv_logit(Dmax) * (1 - exp(-exp(rate) * ptime)),
    Dmax = 1, rate ~ 1, g ~ 1
  )
  prior <- suppressWarnings(suppressMessages(default_prior(formula, dat, model)))
  constant_row <- prior[grepl("constant", prior$prior), ]
  expect_equal(constant_row$nlpar, "Dmax")
  expect_equal(constant_row$prior, "constant(1)")
})

test_that("non-linear parameter formulas bypass the link transformation", {
  tree <- mpt_tree("main", list(
    correct = "D + (1 - D) * 0.25",
    incorrect = "(1 - D) * 0.75"
  ))
  model <- mpt(tree)
  dat <- data.frame(
    id = factor(1:10), ptime = rep(c(0.5, 1), 5),
    correct = rbinom(10, 40, 0.6)
  )
  dat$incorrect <- 40 - dat$correct
  formula <- bmf(
    D ~ inv_logit(Dmax) * (1 - exp(-exp(rate) * ptime)),
    Dmax ~ 1,
    rate ~ 1
  )
  model_checked <- suppressMessages(check_model(model, dat, formula))
  expect_equal(model_checked$links$D, "identity")
  expect_setequal(
    names(model_checked$parameters), c("D", "Dmax", "rate")
  )
  expect_equal(model_checked$links$Dmax, "identity")
  expect_equal(model_checked$default_priors$Dmax$main, "normal(0, 1)")
  expect_null(model_checked$default_priors[["D"]])

  dat_checked <- check_data(model_checked, dat, formula)
  formula_checked <- suppressMessages(
    check_formula(model_checked, dat_checked, formula)
  )
  correct_rhs <- paste(deparse(formula_checked$correct[[3]]), collapse = " ")
  expect_false(grepl("inv_logit(D)", correct_rhs, fixed = TRUE))
})

test_that("the message on non-linear sub-parameters names the sd prior they get", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  dat$x <- rep(c(0, 1), length.out = nrow(dat))
  formula <- bmf(D ~ inv_logit(a + b * x), a ~ 1 + (1 | id), b ~ 1, g ~ 1)
  priors <- suppressWarnings(suppressMessages(default_prior(formula, dat, model)))
  sd_prior <- priors$prior[priors$class == "sd" & priors$nlpar == "a" & priors$group == ""]
  expect_length(sd_prior, 1)
  expect_message(check_model(model, dat, formula), sd_prior, fixed = TRUE)
  # the rank covers the tree parameters only, so u + v would pass unnoticed
  expect_message(
    check_model(model, dat, formula),
    "'a', 'b' .*Whether the data identify these parameters is not checked"
  )
})

test_that("a symbol of a non-linear formula that is neither column nor parameter errors", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data(n_id = 4)
  formula <- bmf(D ~ inv_logit(a + b * x), a ~ 1, b ~ 1, g ~ 1)
  expect_error(
    bmm(formula, dat, model, backend = "mock", mock_fit = 1, rename = FALSE),
    "'x' .* neither a data column nor a model parameter"
  )
  # the implicit intercept of a sub-parameter without a formula is gone too
  expect_error(
    check_model(model, dat, bmf(D ~ inv_logit(a + c), a ~ 1, g ~ 1)),
    "neither a data column"
  )
  dat$x <- rep(c(0, 1), length.out = nrow(dat))
  fit <- suppressMessages(
    bmm(formula, dat, model, backend = "mock", mock_fit = 1, rename = FALSE)
  )
  expect_setequal(names(fit$bmm$model$parameters), c("D", "g", "a", "b"))
})

test_that("links set after construction are checked like any other model's", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  formula <- bmf(D ~ 1, g ~ 1)

  model$links$D <- "cloglog"
  expect_error(check_model(model, dat, formula), "Unknown link function")
  model$links$D <- "log"
  expect_error(check_model(model, dat, formula), "Unknown link function")
})

test_that("model_docs() names no scale that contradicts a switched link", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  model$links$D <- "probit"
  expect_no_match(paste(model_docs(model), collapse = "\n"), "logit scale")
})

test_that("a per-parameter probit link gets the probit default prior", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  model$links$D <- "probit"
  dat <- mpt_2htm_data()
  prior <- suppressMessages(default_prior(bmf(D ~ 1, g ~ 1), dat, model))
  intercept <- prior[prior$coef == "Intercept", ]
  expect_equal(intercept$prior[intercept$nlpar == "D"], "normal(0, 1)")
  expect_equal(intercept$prior[intercept$nlpar == "g"], "logistic(0, 1)")
})

test_that("random-effects SDs get a default prior on the scale of the link", {
  sd_prior <- function(model, par) {
    pr <- suppressMessages(default_prior(
      bmf(D ~ 1 + (1 | id), g ~ 1 + (1 | id)), mpt_2htm_data(), model
    ))
    pr$prior[pr$class == "sd" & pr$coef == "" & pr$group == "" & pr$nlpar == par]
  }
  logit <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  probit <- mpt(mpt_2htm_trees(), tree_id = "item_type", links = "probit")
  for (par in c("D", "g")) {
    expect_equal(sd_prior(logit, par), "exponential(1)")
    expect_equal(sd_prior(probit, par), "exponential(2)")
  }
})

test_that("condition effects under reference coding get the intercept's prior scale", {
  dat <- mpt_2htm_data()
  dat$cond <- rep(c("a", "b"), length.out = nrow(dat))
  effect_prior <- function(model) {
    pr <- suppressMessages(default_prior(bmf(D ~ 1 + cond, g ~ 1), dat, model))
    pr$prior[pr$class == "b" & pr$coef == "" & pr$nlpar == "D"]
  }
  expect_equal(effect_prior(mpt(mpt_2htm_trees(), tree_id = "item_type")), "logistic(0, 1)")
  expect_equal(
    effect_prior(mpt(mpt_2htm_trees(), tree_id = "item_type", links = "probit")),
    "normal(0, 1)"
  )
})

test_that("the links a non-linear formula switches off survive a second check", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  dat$ptime <- rep(c(1, 2), length.out = nrow(dat))
  formula <- bmf(
    D ~ inv_logit(Dmax) * (1 - exp(-exp(rate) * ptime)),
    Dmax ~ 1, rate ~ 1, g ~ 1
  )
  checked <- suppressMessages(check_model(model, dat, formula))
  rechecked <- suppressMessages(check_model(checked, dat, formula))
  expect_equal(rechecked$links, checked$links)
})

test_that("a parameter made linear again in a re-check gets back its link and prior", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  dat$x <- rep(c(0, 1), length.out = nrow(dat))
  dat$cond <- rep(c("a", "b"), each = 2, length.out = nrow(dat))
  nl_formula <- bmf(D ~ inv_logit(a + b * x), a ~ 1, b ~ 1, g ~ 1)
  linear_formula <- bmf(D ~ 1 + cond, g ~ 1)

  checked <- suppressMessages(check_model(model, dat, nl_formula))
  rechecked <- check_model(checked, dat, linear_formula)
  expect_setequal(names(rechecked$parameters), c("D", "g"))
  expect_equal(rechecked$links$D, "logit")
  expect_identical(rechecked$default_priors$D, bmm:::.mpt_latent_prior("logit"))

  model$links$D <- "probit"
  checked <- suppressMessages(check_model(model, dat, nl_formula))
  rechecked <- check_model(checked, dat, linear_formula)
  expect_equal(rechecked$links$D, "probit")
  expect_identical(rechecked$default_priors$D, bmm:::.mpt_latent_prior("probit"))
})

test_that("a stick-breaking component made linear again in a re-check gets back its prior", {
  tree <- mpt_tree("t", list(A = "D * gA", B = "D * gB", C = "D * gC", N = "1 - D"))
  model <- mpt(tree, simplex = c("gA", "gB", "gC"))
  dat <- data.frame(id = factor(1:8), x = rep(0:1, 4), A = 10L, B = 10L, C = 10L, N = 10L)

  checked <- suppressMessages(check_model(model, dat, bmf(gAraw ~ a + b * x, a ~ 1, b ~ 1)))
  rechecked <- check_model(checked, dat, bmf(gAraw ~ 1 + x))
  expect_equal(rechecked$links$gAraw, "identity")
  expect_equal(rechecked$default_priors$gAraw$main, "logitbeta(1, 2)")
  expect_equal(rechecked$default_priors$gAraw$effects, "logistic(0, 1)")
})

test_that("update() to a linear formula restores the link and prior of a non-linear parameter", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  dat$x <- rep(c(0, 1), length.out = nrow(dat))
  dat$cond <- rep(c("a", "b"), each = 2, length.out = nrow(dat))
  methods::setClass("bmm_mock_stanfit", representation(sim = "list"))
  mockfit <- methods::new("bmm_mock_stanfit", sim = list(
    warmup = 1000, iter = 2000, chains = 1, thin = 1,
    samples = list(structure(list(), args = list(control = list())))
  ))
  fit <- suppressWarnings(suppressMessages(bmm(
    bmf(D ~ inv_logit(a + b * x), a ~ 1, b ~ 1, g ~ 1), dat, model,
    backend = "mock", mock_fit = mockfit, rename = FALSE
  )))

  up <- suppressMessages(update(
    fit, formula. = bmf(D ~ 1 + cond, g ~ 1), newdata = dat,
    testmode = TRUE, recompile = FALSE
  ))
  expect_setequal(names(up$bmm$model$parameters), c("D", "g"))
  expect_equal(up$bmm$model$links$D, "logit")
  intercept <- up$prior$nlpar == "D" & up$prior$coef == "Intercept"
  expect_equal(up$prior$prior[intercept], bmm:::.mpt_latent_prior("logit")$main)
})

test_that("printing an mpt model lists trees, restrictions and the identifiability bound", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  expect_output(print(model), "MPT tree 'old':")
  expect_output(print(model), "P\\(new\\) = \\(1 - D\\) \\* \\(1 - g\\)")
  expect_output(
    print(model),
    "2 free parameter\\(s\\), 2 degrees of freedom"
  )
  expect_no_match(capture.output(print(model)), "not identified")

  trees <- list(
    mpt_tree("old", list(yes = "Do + (1 - Do) * g", no = "(1 - Do) * (1 - g)")),
    mpt_tree("new", list(yes = "(1 - Dn) * g", no = "Dn + (1 - Dn) * (1 - g)"))
  )
  unidentified <- mpt(trees, tree_id = "item_type")
  expect_output(print(unidentified), "3 free parameter\\(s\\), 2 degrees of freedom")
  expect_output(print(unidentified), "not identified")

  dat <- mpt_2htm_data()
  dat$x <- rep(0:1, length.out = nrow(dat))
  nonlinear <- suppressMessages(check_model(
    model, dat, bmf(D ~ inv_logit(a + b * x), a ~ 1, b ~ 1, g ~ 1)
  ))
  expect_output(print(nonlinear), "2 free parameter\\(s\\)")
  expect_no_match(capture.output(print(nonlinear)), "not identified")

  fixed <- suppressMessages(check_model(model, dat, bmf(D ~ 1, g = 0.5)))
  expect_output(print(fixed), "1 free parameter\\(s\\)")

  restricted <- mpt(trees, tree_id = "item_type", restrictions = c("Dn = Do", "g = 0.5"))
  expect_output(print(restricted), "Restrictions: Dn = Do, g = 0.5")

  simplex <- mpt(
    mpt_tree("t", list(A = "gA", B = "gB", C = "gC")),
    simplex = c("gA", "gB", "gC")
  )
  expect_output(print(simplex), "2 free parameter\\(s\\), 2 degrees of freedom")
  expect_output(
    print(simplex),
    "pass through inv_logit\\(\\) in the generated formulas \\(hence the identity links above\\)"
  )
  expect_output(print(simplex), "symmetric Dirichlet\\(1\\), prior mean 1/3 = 0.333")
  expect_match(
    parameter_info(simplex)$description[4],
    "transformed by the inverse logit into the stick proportion"
  )
})

test_that("restrictions are substituted into the trees before parameters are identified", {
  trees <- list(
    mpt_tree("old", list(yes = "Do + (1 - Do) * g", no = "(1 - Do) * (1 - g)")),
    mpt_tree("new", list(yes = "(1 - Dn) * g", no = "Dn + (1 - Dn) * (1 - g)"))
  )
  equated <- mpt(trees, tree_id = "item_type", restrictions = "Dn = Do")
  expect_setequal(names(equated$parameters), c("Do", "g"))
  expect_equal(equated$other_vars$trees$new$branches$yes, quote((1 - Do) * g))
  expect_equal(equated$other_vars$restrictions, list(Dn = quote(Do)))

  fixed <- mpt(trees, tree_id = "item_type", restrictions = c("g = 0.5", "Dn = Do"))
  expect_equal(names(fixed$parameters), "Do")
  expect_equal(
    dmpt(c(yes = 6, no = 4), pars = c(Do = 0.2), mpt_model = fixed, tree = "old", log = FALSE),
    dmultinom(c(6, 4), prob = c(0.2 + 0.8 * 0.5, 0.8 * 0.5))
  )
  expect_equal(
    mpt(trees, tree_id = "item_type", restrictions = list(g = 0.5, Dn = "Do"))$other_vars$trees,
    fixed$other_vars$trees
  )
  for (restricted in list(equated, fixed)) {
    branch_errors <- bmm:::.mpt_tree_branch_errors(
      restricted$other_vars$trees, names(restricted$parameters), list()
    )
    expect_true(all(is.na(branch_errors)))
  }
  one_symbol <- mpt_tree("t", list(A = "a", B = "(1 - a) * b", C = "(1 - a) * (1 - b)"))
  expect_error(mpt(one_symbol, restrictions = "a = 0"), "strictly between 0 and 1")
  for (r in c("g = 1.5", "g = -0.2", "g = 1")) {
    expect_error(mpt(trees, tree_id = "item_type", restrictions = r), "strictly between 0 and 1")
  }
  expect_error(
    mpt(trees, tree_id = "item_type", restrictions = list(g = 1)),
    "strictly between 0 and 1"
  )
  expect_equal(
    mpt(trees, tree_id = "item_type", restrictions = list("Dn = Do", "g = 0.5"))$other_vars$trees,
    mpt(trees, tree_id = "item_type", restrictions = c("Dn = Do", "g = 0.5"))$other_vars$trees
  )
  expect_error(
    mpt(mpt_tree("t", list(A = "a", B = "1 - a")), restrictions = "a = 0.3"),
    "no latent parameters"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", restrictions = "Do = Dn = FE"),
    "person effects.*random effects"
  )

  expect_error(
    mpt(trees, tree_id = "item_type", restrictions = "Do > Dn"),
    "Order constraints"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", restrictions = "Dn = Dold"),
    "do not appear"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", restrictions = c("Dn = Do", "Do = Dn")),
    "circular"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", restrictions = c("g = 0.5", "g = 0.4")),
    "more than once"
  )
  expect_error(
    mpt(trees, tree_id = "item_type", restrictions = "g = 0.00001"),
    "restriction\\(s\\) g = 1e-05 in scientific notation"
  )

  cov_tree <- mpt_tree("t", list(a = "D + (1 - D) * gc", b = "(1 - D) * (1 - gc)"))
  expect_error(
    mpt(cov_tree, covariates = "gc", restrictions = "gc = 0.5"),
    "Covariates"
  )
})

mpt_sep_trees <- function() {
  list(
    mpt_tree("old", list(yes = "Do + (1 - Do) * g", no = "(1 - Do) * (1 - g)")),
    mpt_tree("new", list(yes = "(1 - Dn) * g", no = "Dn + (1 - Dn) * (1 - g)"))
  )
}

mpt_printed <- function(model) {
  gsub("\\s+", " ", paste(capture.output(print(model)), collapse = " "))
}

test_that("print() names parameters that only enter as a product although the count passes", {
  # D and r enter only as D * r, so the four parameters fill four degrees of
  # freedom but leave one combination open
  model <- mpt(list(
    mpt_tree("a", list(x = "D * r", y = "(1 - D * r) * g", z = "(1 - D * r) * (1 - g)")),
    mpt_tree("b", list(x = "g", y = "(1 - g) * h", z = "(1 - g) * (1 - h)"))
  ), tree_id = "tree")
  printed <- mpt_printed(model)
  expect_match(printed, "4 free parameter\\(s\\), 4 degrees of freedom")
  expect_match(
    printed,
    "Jacobian rank 3 of 4 at interior test values: the combination of 'D', 'r' is not identified by the branch expressions"
  )
})

test_that("print() reports full Jacobian rank for identified models", {
  expect_match(
    mpt_printed(mpt(mpt_2htm_trees(), tree_id = "item_type")),
    "Jacobian rank 2 of 2 at interior test values: locally identified"
  )

  # Do and Dn are identified once the guessing rate varies across two bias trees
  bias_trees <- lapply(1:2, function(i) {
    list(
      mpt_tree(paste0("old", i), list(
        yes = glue("Do + (1 - Do) * g{i}"), no = glue("(1 - Do) * (1 - g{i})")
      )),
      mpt_tree(paste0("new", i), list(
        yes = glue("(1 - Dn) * g{i}"), no = glue("Dn + (1 - Dn) * (1 - g{i})")
      ))
    )
  })
  bias <- mpt(unlist(bias_trees, recursive = FALSE), tree_id = "tree")
  expect_match(mpt_printed(bias), "Jacobian rank 4 of 4 at interior test values")

  # pair-clustering model, pairs tree
  pair_clustering <- mpt(mpt_tree("pairs", list(
    adjacent = "c * r",
    apart = "(1 - c) * u * u",
    one = "2 * (1 - c) * u * (1 - u)",
    none = "c * (1 - r) + (1 - c) * (1 - u) * (1 - u)"
  )))
  printed <- mpt_printed(pair_clustering)
  expect_match(printed, "Jacobian rank 3 of 3 at interior test values")
  expect_no_match(printed, "not identified")
})

test_that("the identifiability check counts a parameter fixed in the formula as known", {
  model <- mpt(mpt_sep_trees(), tree_id = "item_type")
  printed <- mpt_printed(model)
  expect_match(printed, "More free parameters than degrees of freedom")
  expect_match(
    printed,
    "Jacobian rank 2 of 3 at interior test values: the combination of all free parameters is not identified by the branch expressions"
  )

  fixed <- expect_no_warning(
    check_model(model, mpt_2htm_data(), bmf(Do ~ 1, Dn ~ 1, g = 0.5))
  )
  expect_match(mpt_printed(fixed), "Jacobian rank 2 of 2 at interior test values")
})

test_that("bmm() warns about a rank deficit when every formula is intercept-only", {
  model <- mpt(mpt_sep_trees(), tree_id = "item_type")
  expect_warning(
    check_model(model, mpt_2htm_data(), bmf(Do ~ 1 + (1 | id), Dn ~ 1, g ~ 1)),
    "Jacobian rank 2 of 3.*the posterior follows the prior"
  )
  expect_silent(
    check_model(model, mpt_2htm_data(), bmf(Do ~ 1, Dn = 0.6, g ~ 1))
  )
})

test_that("bmm() only announces a rank deficit when a formula may identify it", {
  model <- mpt(mpt_sep_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  dat$bias <- rep(c("low", "high"), length.out = nrow(dat))
  fixed_text <- paste(
    "The branch expressions alone do not identify all free parameters\\.",
    "Predictors or non-linear formulas may identify them; this was not checked\\."
  )
  announced <- function(formula) {
    expect_no_warning(expect_message(check_model(model, dat, formula), fixed_text))
  }

  announced(bmf(Do ~ 1, Dn ~ 1, g ~ 0 + bias))
  announced(bmf(Do ~ inv_logit(phi), phi ~ 1, Dn ~ 1, g ~ 1))
  announced(bmf(Do ~ 1, Dn ~ Do, g ~ 0 + bias))
})

test_that("a formula outside the deficit still gives the message, not the warning", {
  model <- mpt(list(
    mpt_tree("a", list(x = "D * r", y = "(1 - D * r) * g", z = "(1 - D * r) * (1 - g)")),
    mpt_tree("b", list(x = "g", y = "(1 - g) * h", z = "(1 - g) * (1 - h)"))
  ), tree_id = "tree")
  dat <- data.frame(tree = rep(c("a", "b"), each = 3), x = 5L, y = 5L, z = 5L, k = 1:6)
  fixed_text <- "do not identify 'D', 'r'\\. Predictors or non-linear formulas may"

  expect_warning(
    check_model(model, dat, bmf(D ~ 1, r ~ 1, g ~ 1, h ~ 1)),
    "Jacobian rank 3 of 4.*the combination of 'D', 'r'"
  )
  # a predictor on h, a non-linear formula on h, and a formula terms() cannot read
  for (formula in list(
    bmf(D ~ 1, r ~ 1, g ~ 1, h ~ k),
    bmf(D ~ 1, r ~ 1, g ~ 1, h ~ inv_logit(phi), phi ~ 1),
    bmf(D ~ 1, r ~ 1, g ~ 1, h ~ .)
  )) {
    expect_no_warning(expect_message(
      check_model(model, dat, formula), fixed_text
    ))
  }
})

test_that("a formula that ties parameters together is not reported as a rank deficit", {
  model <- mpt(mpt_sep_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()

  expect_no_warning(expect_message(
    tied <- check_model(model, dat, bmf(Do ~ 1, Dn ~ Do, g ~ 1)),
    "Predictors or non-linear formulas may identify them"
  ))
  expect_match(
    mpt_printed(tied),
    "Jacobian rank 2 of 3 at interior test values: the combination of all free parameters is not identified"
  )

  single <- mpt(mpt_tree("x", list(A = "a", B = "1 - a")))
  sub_pars <- suppressMessages(check_model(
    single, data.frame(A = 5L, B = 5L), bmf(a ~ inv_logit(u + v), u ~ 1, v ~ 1)
  ))
  printed <- mpt_printed(sub_pars)
  expect_match(
    printed,
    "Jacobian rank 1 of 1 .*locally identified; non-linear formula\\(s\\) for 'a' not analysed"
  )
  expect_match(
    mpt_printed(tied),
    "not identified by the branch expressions; non-linear formula\\(s\\) for 'Dn' not analysed"
  )
  expect_no_match(mpt_printed(single), "not analysed")

  # the deficit text names a simplex group by its members, not its sticks
  guessing <- mpt(mpt_tree("t", list(
    A = "D + (1 - D) * gA", B = "(1 - D) * gB", C = "(1 - D) * gC"
  )), simplex = list(c("gA", "gB", "gC")))
  tied_simplex <- suppressMessages(check_model(
    guessing, data.frame(A = 5L, B = 3L, C = 2L),
    bmf(D ~ inv_logit(phi), phi ~ 1, gA ~ 1, gB ~ 1)
  ))
  expect_match(
    mpt_printed(tied_simplex),
    "the combination of .*the simplex group 'gA', 'gB', 'gC'.* not identified.*non-linear formula\\(s\\) for 'D' not analysed"
  )
})

test_that("deep chains of identified parameters keep full rank", {
  # category i is reached after i - 1 failures, so the columns of the last
  # parameters are products of many probabilities but stay identified
  chain_model <- function(k) {
    reach <- function(i) if (i > 1) paste0("(1 - a", seq_len(i - 1), ")")
    branches <- lapply(seq_len(k), function(i) {
      paste(c(reach(i), paste0("a", i)), collapse = " * ")
    })
    branches[[k + 1]] <- paste(reach(k + 1), collapse = " * ")
    suppressMessages(mpt(mpt_tree("t", setNames(branches, paste0("c", seq_len(k + 1))))))
  }
  for (k in c(20, 30)) {
    printed <- mpt_printed(chain_model(k))
    expect_match(printed, glue("Jacobian rank {k} of {k} at interior test values"))
    expect_no_match(printed, "zero up to rounding")
  }

  # each p_i moves 1 - p1 * ... * p30 by 1e-10 only, yet it does move it
  path <- paste(paste0("p", 1:30), collapse = " * ")
  product <- suppressMessages(mpt(mpt_tree("t", list(
    x = glue("{path} * q"), y = glue("{path} * (1 - q)"), z = glue("1 - {path}")
  ))))
  printed <- mpt_printed(product)
  expect_match(printed, "Jacobian rank 2 of 31 at interior test values")
  expect_no_match(printed, "zero up to rounding")
})

test_that("a parameter that cancels with a rounding residue is not counted as identified", {
  # D(x, "q") is a * b * c - c * b * a, a residue of 1e-17 at some test points
  model <- mpt(mpt_tree("t", list(
    x = "a * b * c * q + c * b * a * (1 - q)",
    y = "a * (1 - b)", z = "(1 - a) * c",
    w = "1 - a * b * c - a * (1 - b) - (1 - a) * c"
  )))
  printed <- mpt_printed(model)
  expect_match(printed, "Jacobian rank 3 of 4 at interior test values: 'q' is not identified")
  expect_warning(
    check_model(model, data.frame(x = 5, y = 5, z = 5, w = 5), bmf(a ~ 1, b ~ 1, c ~ 1, q ~ 1)),
    "'q' is not identified by the branch expressions"
  )
})

test_that("a residue column stays a zero column when its partners are fixed", {
  # with a, b and c fixed, q is the only free column and a pure residue: at
  # these values a * b * c and c * b * a differ in the last bit
  model <- mpt(mpt_tree("t", list(
    x = "a * b * c * q + c * b * a * (1 - q)", y = "1 - a * b * c"
  )))
  formula <- bmf(q ~ 1, a = 0.11, b = 0.13, c = 0.69)
  dat <- data.frame(x = 5, y = 5)
  expect_warning(
    checked <- check_model(model, dat, formula),
    "'q' is not identified"
  )
  expect_match(mpt_printed(checked), "Jacobian rank 0 of 1 at interior test values")
})

test_that("a model whose every free column is exactly zero reports rank 0", {
  model <- mpt(mpt_tree("t", list(x = "q * 0.25 + (1 - q) * 0.25", y = "0.75")))
  printed <- mpt_printed(model)
  expect_match(printed, "Jacobian rank 0 of 1 at interior test values: 'q' is not identified by the branch expressions")
})

test_that("the rank check takes the maximum over the parameter test points", {
  # the derivative of (a - k)^2 vanishes only at the first test point, where a
  # equals k; a decimal spelling of k need not parse back to k, a hex one does
  k <- bmm:::.mpt_test_points("a", list())[[1]][["a"]]
  spelling <- sprintf("%a", k)
  expect_identical(str2lang(spelling), k)
  model <- mpt(mpt_tree("t", list(
    yes = glue("0.5 + 0.5 * (a - {spelling})^2"),
    no = glue("0.5 - 0.5 * (a - {spelling})^2")
  )))
  expect_match(
    mpt_printed(model),
    "Jacobian rank 1 of 1 at interior test values: locally identified"
  )
  expect_no_warning(check_model(model, data.frame(yes = 5, no = 5), bmf(a ~ 1)))
})

test_that("a long list of entangled parameters is printed as its complement", {
  model <- mpt(list(
    mpt_tree("a", list(x = "D * r * s", y = "(1 - D * r * s) * g", z = "(1 - D * r * s) * (1 - g)")),
    mpt_tree("b", list(x = "g", y = "(1 - g) * h", z = "(1 - g) * (1 - h)"))
  ), tree_id = "tree")
  expect_match(
    mpt_printed(model),
    "Jacobian rank .* the combination of all free parameters except 'g', 'h' is not identified"
  )
})

test_that("the rank check works in the stick-breaking components of a simplex group", {
  guessing <- mpt(list(
    mpt_tree("srcA", list(A = "D + (1 - D) * gA", B = "(1 - D) * gB", N = "(1 - D) * gN")),
    mpt_tree("new", list(A = "gA", B = "gB", N = "gN"))
  ), tree_id = "tree", simplex = c("gA", "gB", "gN"))
  printed <- mpt_printed(guessing)
  expect_match(printed, "3 free parameter\\(s\\), 4 degrees of freedom")
  expect_match(printed, "Jacobian rank 3 of 3 at interior test values")
  dat <- data.frame(tree = c("srcA", "new"), A = c(5, 3), B = c(2, 3), N = c(3, 4))
  expect_no_warning(expect_no_message(
    check_model(guessing, dat, bmf(D ~ 1, gA ~ 1, gB ~ 1))
  ))

  # the members only enter through their sum, which is 1 whatever the sticks
  sum_only <- mpt(mpt_tree("t", list(
    A = "D * gA + D * gB + D * gN", B = "(1 - D) * h", N = "(1 - D) * (1 - h)"
  )), simplex = c("gA", "gB", "gN"))
  printed <- mpt_printed(sum_only)
  expect_match(
    printed,
    "Jacobian rank 2 of 4 .*the combination of .*the simplex group 'gA', 'gB', 'gN'.* not identified"
  )
  expect_no_match(sub(".*not identified", "", printed), "raw")
})

test_that("the rank check stacks rows over covariate values and never frees a covariate", {
  scaled <- mpt(list(
    mpt_tree("old", list(yes = "D + (1 - D) * g * x", no = "(1 - D) * (1 - g * x)")),
    mpt_tree("new", list(yes = "(1 - D) * g * x", no = "D + (1 - D) * (1 - g * x)"))
  ), tree_id = "item_type", covariates = "x")
  expect_match(
    mpt_printed(scaled),
    "Jacobian rank 2 of 2 at interior test values and 5 test values of the covariates"
  )
  dat <- data.frame(item_type = c("old", "new"), x = c(0.5, 1), yes = 5, no = 5)
  expect_no_warning(expect_no_message(check_model(scaled, dat, bmf(D ~ 1, g ~ 1))))

  # one tree with a 0/1 covariate: a and b are identified only across rows
  switch_tree <- mpt(
    mpt_tree("t", list(yes = "x * a + (1 - x) * b", no = "1 - x * a - (1 - x) * b")),
    covariates = "x"
  )
  printed <- mpt_printed(switch_tree)
  expect_match(printed, "in one design cell; covariate values that differ")
  expect_match(printed, "Jacobian rank 2 of 2")
  both <- data.frame(x = c(0, 1, 1), yes = 5, no = 5)
  expect_no_warning(expect_no_message(check_model(switch_tree, both, bmf(a ~ 1, b ~ 1))))
  # bmm() does not look at the data: a covariate with one distinct value
  # leaves b open without a warning
  only_a <- data.frame(x = c(1, 1), yes = 5, no = 5)
  expect_no_warning(expect_no_message(check_model(switch_tree, only_a, bmf(a ~ 1, b ~ 1))))

  # a covariate is never listed among the parameters
  product <- mpt(
    mpt_tree("t", list(yes = "x * a * b", no = "1 - x * a * b")),
    covariates = "x"
  )
  printed <- mpt_printed(product)
  expect_match(printed, "Jacobian rank 1 of 2")
  expect_no_match(printed, "'x'")

  # a residue that cancels stays a zero column when the rows are stacked
  residue <- mpt(mpt_tree("t", list(
    yes = "x * (a * b * c * q + c * b * a * (1 - q))",
    no = "1 - x * (a * b * c * q + c * b * a * (1 - q))"
  )), covariates = "x")
  expect_match(mpt_printed(residue), "Jacobian rank 1 of 4 .*the combination of all free parameters is not identified")
})

test_that("the rank check counts only the categories a tree can produce", {
  model <- mpt(list(
    mpt_tree("lure", list(
      target = "D + (1 - D) * g",
      lure = "(1 - D) * (1 - g) * l",
      other = "(1 - D) * (1 - g) * (1 - l)"
    )),
    mpt_tree("new", list(
      target = "(1 - D) * g",
      other = "D + (1 - D) * (1 - g)"
    ), impossible = "lure")
  ), tree_id = "tree")
  printed <- mpt_printed(model)
  expect_match(printed, "3 free parameter\\(s\\), 3 degrees of freedom")
  expect_match(printed, "Jacobian rank 3 of 3 at interior test values")
})

test_that("five test values of the covariates separate the Bernstein coefficients", {
  # only the single row at x = 0.5 separates b from the other two, so a rank
  # taken over too few covariate settings loses it
  bernstein <- mpt(
    mpt_tree("t", list(
      yes = "z * (a * (1 - x)^2 + b * 2 * x * (1 - x) + c * x^2) + (1 - z) * 0.5",
      no = "1 - z * (a * (1 - x)^2 + b * 2 * x * (1 - x) + c * x^2) - (1 - z) * 0.5"
    )),
    covariates = c("x", "z")
  )
  expect_match(
    mpt_printed(bernstein),
    "Jacobian rank 3 of 3 at interior test values and 5 test values of the covariates: locally identified"
  )
  dat <- data.frame(x = c(0, 0.5, 1), z = 0.5, yes = 5, no = 5)
  expect_no_warning(expect_no_message(
    check_model(bernstein, dat, bmf(a ~ 1, b ~ 1, c ~ 1))
  ))
})

test_that("the rank check gives the same result when the settings come in chunks", {
  rank_of <- function(model, chunk_size) {
    jacobian_rank <- bmm:::.mpt_jacobian_rank
    local_mocked_bindings(.mpt_jacobian_rank = function(...) {
      jacobian_rank(..., chunk_size = chunk_size)
    })
    bmm:::.mpt_identifiability(model)[c("rank", "involved", "absent")]
  }
  expect_chunks_agree <- function(model, rank) {
    whole <- rank_of(model, 1e6)
    expect_equal(whole$rank, rank)
    expect_identical(rank_of(model, 2L), whole)
  }

  # the five test values of the covariates enter two at a time
  bernstein <- mpt(
    mpt_tree("t", list(
      yes = "z * (a * (1 - x)^2 + b * 2 * x * (1 - x) + c * x^2) + (1 - z) * 0.5",
      no = "1 - z * (a * (1 - x)^2 + b * 2 * x * (1 - x) + c * x^2) - (1 - z) * 0.5"
    )),
    covariates = c("x", "z")
  )
  expect_chunks_agree(bernstein, 3L)

  residue <- mpt(mpt_tree("t", list(
    yes = "x * (a * b * c * q + c * b * a * (1 - q))",
    no = "1 - x * (a * b * c * q + c * b * a * (1 - q))"
  )), covariates = "x")
  expect_chunks_agree(residue, 1L)
  expect_equal(rank_of(residue, 2L)$absent, "q")

  product <- mpt(
    mpt_tree("t", list(yes = "x * a * b", no = "1 - x * a * b")),
    covariates = "x"
  )
  expect_chunks_agree(product, 1L)
  expect_equal(rank_of(product, 2L)$involved, c("a", "b"))

  guessing <- mpt(mpt_tree("t", list(
    A = "D * x + (1 - D * x) * gA", B = "(1 - D * x) * gB", N = "(1 - D * x) * gN"
  )), simplex = c("gA", "gB", "gN"), covariates = "x")
  expect_chunks_agree(guessing, 3L)
})

test_that("bmm() does not look at the data for identifiability", {
  two_htm <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  expect_match(
    mpt_printed(two_htm),
    "Jacobian rank 2 of 2 at interior test values: locally identified"
  )
  # a tree without rows leaves D and g open in the data; only the model rank counts
  only_old <- data.frame(item_type = "old", yes = 5, no = 5)
  expect_no_warning(expect_no_message(check_model(two_htm, only_old, bmf(D ~ 1, g ~ 1))))
})

test_that("the Jacobian rank is reported as not computed when D() cannot differentiate", {
  model <- mpt(mpt_tree("t", list(x = "plogis(a)", y = "1 - plogis(a)")))
  printed <- mpt_printed(model)
  expect_match(printed, "Jacobian rank not computed: .*'plogis'")
  expect_silent(check_model(model, data.frame(x = 1, y = 1), bmf(a ~ 1)))
})

test_that("without a rank, check_model() uses the count of free parameters", {
  scaled <- "a * b * (0.5 + 0.2 * abs(c - 0.5))"
  model <- mpt(mpt_tree("t", list(y = scaled, n = glue("1 - {scaled}"))))
  expect_warning(
    check_model(model, data.frame(y = 3, n = 3), bmf(a ~ 1, b ~ 1, c ~ 1)),
    paste0(
      "^The model is not identified\\. 3 free parameters for 1 degrees of ",
      "freedom .*Jacobian rank not computed"
    )
  )
  fixed_text <- "Predictors or non-linear formulas may identify them; this was not checked"
  expect_no_warning(expect_message(
    check_model(model, data.frame(cond = c("p", "q"), y = 3, n = 3), bmf(a ~ 1, b ~ 0 + cond, c ~ 1)),
    fixed_text
  ))
  expect_no_warning(expect_message(
    check_model(model, data.frame(y = 3, n = 3), bmf(a ~ 1, b ~ 1, c ~ a)),
    fixed_text
  ))

  # covariates are not predictors: the rank is taken at their test values
  with_x <- mpt(mpt_tree("t", list(y = glue("{scaled} * x"), n = glue("1 - {scaled} * x"))), covariates = "x")
  expect_warning(
    check_model(with_x, data.frame(x = c(0.2, 0.6), y = 3, n = 3), bmf(a ~ 1, b ~ 1, c ~ 1)),
    "3 free parameters for 1 degrees of freedom"
  )
})

test_that("a test value with a non-finite derivative is left out of the rank", {
  # the derivative of ((c - k)^2)^0.25 is Inf * 0 at c = k
  k <- vapply(bmm:::.mpt_test_points(c("a", "b", "c"), list()), `[[`, numeric(1), "c")[1]
  cusp_tree <- function(inner) {
    mpt_tree("t2", list(
      y = glue("0.3 + 0.2 * (({inner})^2)^0.25"), n = glue("0.7 - 0.2 * (({inner})^2)^0.25")
    ))
  }
  left_out <- "At 1 of the 5 interior test values a derivative .*not finite, so the rank is taken at the other 4"
  product <- mpt(list(
    mpt_tree("t1", list(y = "a * b", n = "1 - a * b")), cusp_tree(glue("c - {sprintf('%a', k)}"))
  ), tree_id = "tr")
  expect_match(mpt_printed(product), glue("Jacobian rank 2 of 3 at interior test values: .*{left_out}"))
  expect_warning(
    check_model(product, data.frame(tr = c("t1", "t2"), y = 3, n = 3), bmf(a ~ 1, b ~ 1, c ~ 1)),
    glue("Jacobian rank 2 of 3.*{left_out}")
  )
  # the test points depend on the symbols, so c takes other values here
  k_ac <- vapply(bmm:::.mpt_test_points(c("a", "c"), list()), `[[`, numeric(1), "c")[1]
  identified <- mpt(list(
    mpt_tree("t1", list(y = "a", n = "1 - a")), cusp_tree(glue("c - {sprintf('%a', k_ac)}"))
  ), tree_id = "tr")
  expect_match(mpt_printed(identified), glue("Jacobian rank 2 of 2 at interior test values: locally identified\\. {left_out}"))
})

test_that("a derivative not finite at every test value leaves the rank uncomputed and check_data() names the row", {
  model <- mpt(list(
    mpt_tree("plain", list(y = "a", n = "1 - a")),
    mpt_tree("cov", list(y = "b * 0.5 / x", n = "1 - b * 0.5 / x"))
  ), tree_id = "tr", covariates = "x")
  dat <- data.frame(tr = c("plain", "cov"), x = c(1, 0), y = 3, n = 3)
  expect_silent(check_model(model, dat, bmf(a ~ 1, b ~ 1)))
  expect_error(
    bmm(bmf(a ~ 1, b ~ 1), dat, model, backend = "mock", mock_fit = 1, rename = FALSE),
    "branch probabilities of tree 'cov' do not sum to 1 for 1 row\\(s\\) \\(first: row 2, sum = NaN\\)"
  )
})

test_that("a rank that only one finite test value would give is not computed", {
  # cusps at four test values of c and a double root at the fifth: the one
  # point left is singular, so ranking it alone would call c unidentified
  k <- vapply(bmm:::.mpt_test_points(c("a", "c"), list()), `[[`, numeric(1), "c")
  cusps <- paste(glue("((c - {sprintf('%a', k[1:4])})^2)^0.25"), collapse = " + ")
  shape <- glue("(c - {sprintf('%a', k[5])})^2 * (1 + 0.05 * ({cusps}))")
  model <- mpt(list(
    mpt_tree("t1", list(y = "a", n = "1 - a")),
    mpt_tree("t2", list(y = glue("0.3 + 0.2 * {shape}"), n = glue("0.7 - 0.2 * {shape}")))
  ), tree_id = "tr")
  expect_match(
    mpt_printed(model),
    "Jacobian rank not computed: at 4 of the 5 interior test values some derivative"
  )
  expect_no_warning(check_model(model, data.frame(tr = c("t1", "t2"), y = 3, n = 3), bmf(a ~ 1, c ~ 1)))
})

test_that("fixed parameter values stay probabilities and reach the prior on the latent scale", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  formula <- bmf(D ~ 1, g = 0.7)
  model_checked <- check_model(model, dat, formula)
  expect_equal(model_checked$fixed_parameters$g, 0.7)
  expect_equal(check_model(model_checked, dat, formula), model_checked)

  prior <- default_prior(formula, dat, model)
  constant_row <- prior[grepl("constant", prior$prior), ]
  expect_equal(constant_row$nlpar, "g")
  expect_equal(constant_row$prior, glue("constant({qlogis(0.7)})"))

  model_probit <- mpt(mpt_2htm_trees(), tree_id = "item_type", links = "probit")
  prior_probit <- default_prior(formula, dat, model_probit)
  constant_row <- prior_probit[grepl("constant", prior_probit$prior), ]
  expect_equal(constant_row$prior, glue("constant({qnorm(0.7)})"))

  expect_error(check_model(model, dat, bmf(D ~ 1, g = 1.5)), "strictly")
})

test_that("simplex parameters cannot be fixed to constants", {
  trees <- list(
    mpt_tree("A", list(a = "gA", b = "gB", c = "gC")),
    mpt_tree("B", list(a = "gB", b = "gC", c = "gA"))
  )
  model <- mpt(trees, tree_id = "tree", simplex = c("gA", "gB", "gC"))
  expect_error(
    check_model(model, formula = bmf(gA = 0.3)),
    "Fixing simplex parameters"
  )
  expect_error(
    check_model(model, formula = bmf(gAraw = 0.3)),
    "Fixing simplex parameters.*gAraw.*Fix parameters outside the group instead"
  )
})

test_that("mpt compiles with a simplex group via stick-breaking", {
  trees <- list(
    mpt_tree("sourceA", list(
      A = "dA + (1 - dA) * gA",
      B = "(1 - dA) * gB",
      New = "(1 - dA) * gNew"
    )),
    mpt_tree("sourceB", list(
      A = "(1 - dB) * gA",
      B = "dB + (1 - dB) * gB",
      New = "(1 - dB) * gNew"
    )),
    mpt_tree("new", list(A = "gA", B = "gB", New = "gNew"))
  )
  model <- mpt(trees, tree_id = "source", simplex = c("gA", "gB", "gNew"))
  expect_setequal(
    names(model$parameters),
    c("dA", "dB", "gA", "gB", "gNew", "gAraw", "gBraw")
  )
  expect_equal(model$links$gA, "identity")
  expect_equal(model$default_priors$gAraw$main, "logitbeta(1, 2)")
  expect_equal(model$default_priors$gBraw$main, "logistic(0, 1)")
  expect_null(model$default_priors[["gA"]])

  dat <- expand.grid(
    id = factor(1:10), source = c("sourceA", "sourceB", "new"),
    stringsAsFactors = FALSE
  )
  counts <- t(rmultinom(nrow(dat), 30, c(0.4, 0.3, 0.3)))
  colnames(counts) <- c("A", "B", "New")
  dat <- cbind(dat, counts)
  formula <- bmf(dA ~ 1, dB ~ 1, gA ~ 1 + (1 | id), gB ~ 1)

  model_checked <- check_model(model, dat, formula)
  dat_checked <- check_data(model_checked, dat, formula)
  formula_checked <- suppressMessages(
    check_formula(model_checked, dat_checked, formula)
  )
  gA_rhs <- paste(deparse(formula_checked$gA[[3]]), collapse = " ")
  gNew_rhs <- paste(deparse(formula_checked$gNew[[3]]), collapse = " ")
  expect_true(grepl("inv_logit(gAraw)", gA_rhs, fixed = TRUE))
  expect_equal(gNew_rhs, "1 - (gA + gB)")
  gAraw_rhs <- paste(deparse(formula_checked$gAraw[[3]]), collapse = " ")
  expect_true(grepl("(1 | id)", gAraw_rhs, fixed = TRUE))

  expect_no_warning(
    suppressMessages(bmm(
      formula, dat, model,
      backend = "mock", mock_fit = 1, rename = FALSE
    )),
    message = "Non-linear"
  )
})

test_that("stick-breaking members form a simplex for any raw values", {
  tree <- mpt_tree("t", list(A = "gA", B = "gB", C = "gC", D = "gD"))
  model <- mpt(tree, simplex = c("gA", "gB", "gC", "gD"))
  dat <- data.frame(A = 5, B = 5, C = 5, D = 5)
  formula <- suppressMessages(check_formula(model, dat, bmf(gA ~ 1, gB ~ 1, gC ~ 1)))
  raw <- list(gAraw = 1.3, gBraw = -0.4, gCraw = 0.7)
  env <- list2env(c(raw, list(inv_logit = stats::plogis)))
  for (par in c("gA", "gB", "gC", "gD")) assign(par, eval(formula[[par]][[3]], env), env)
  s <- unname(stats::plogis(unlist(raw)))
  expect_equal(
    unlist(mget(c("gA", "gB", "gC", "gD"), env), use.names = FALSE),
    c(s[1], (1 - s[1]) * s[2], (1 - s[1]) * (1 - s[2]) * s[3], prod(1 - s))
  )
})

test_that("generated simplex formulas are neither announced as missing nor listed in the summary", {
  skip_on_cran() # over 0.4 s in CRAN mode (ADR 0004, R10)
  tree <- mpt_tree("t", list(A = "gA", B = "gB", New = "gNew"))
  model <- mpt(tree, simplex = c("gA", "gB", "gNew"))
  dat <- data.frame(A = 10L, B = 10L, New = 10L)
  expect_no_message(
    fit <- bmm(
      bmf(gA ~ 1, gB ~ 1), dat, model, backend = "mock", mock_fit = 1, rename = FALSE
    ),
    message = "No formula"
  )
  expect_equal(summarise_formula(fit$bmm$user_formula, fit$bmm$model), c("gA ~ 1", "gB ~ 1"))
  # the header block print(summary(fit)) uses; a mock fit has no draws to summarise
  withr::local_options(bmm.color_summary = FALSE)
  expect_output(
    bmm:::print_model_header(fit$bmm$model, fit$bmm$user_formula, "dat", 1L),
    "Formula: gA ~ 1\\s+gB ~ 1\\s+Data:"
  )

  # a parameter the user leaves out of a plain model is still announced, and
  # the summary still lists the constants the user fixed
  plain <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  expect_message(
    bmm(bmf(D ~ 1), mpt_2htm_data(), plain, backend = "mock", mock_fit = 1, rename = FALSE),
    "No formula for parameter g"
  )
  fixed <- suppressMessages(bmm(
    bmf(D ~ 1, g = 0.5), mpt_2htm_data(), plain, backend = "mock", mock_fit = 1, rename = FALSE
  ))
  expect_equal(summarise_formula(fixed$bmm$user_formula, fixed$bmm$model), c("D ~ 1", "g = 0.5"))
})

test_that("predictors on the derived simplex parameter are rejected", {
  trees <- list(mpt_tree("t", list(A = "gA", B = "gB", New = "gNew")))
  model <- mpt(trees, simplex = c("gA", "gB", "gNew"))
  dat <- data.frame(id = factor(1:5), A = 10, B = 10, New = 10)
  formula <- bmf(gA ~ 1, gB ~ 1, gNew ~ 1 + (1 | id))
  expect_error(
    suppressMessages(bmm(
      formula, dat, model,
      backend = "mock", mock_fit = 1, rename = FALSE
    )),
    "derived"
  )
})

test_that("a non-linear formula reading a simplex member announces the sticks' deficit", {
  trees <- list(mpt_tree("t", list(A = "(a + b) * u", B = "c * u", C = "1 - u")))
  model <- mpt(trees, simplex = c("a", "b", "c"))
  dat <- data.frame(A = 10, B = 10, C = 10)
  expect_warning(
    check_model(model, dat, bmf(u ~ 1)),
    "The model is not identified"
  )
  expect_no_warning(expect_message(
    check_model(model, dat, bmf(u ~ a, a ~ 1)),
    "do not identify .*Predictors or non-linear formulas may identify them"
  ))
})

test_that("check_data errors are informative", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()

  dat_missing <- dat[setdiff(names(dat), "new")]
  expect_error(
    check_data(model, dat_missing, bmf(D ~ 1, g ~ 1)),
    "Missing columns: 'new'"
  )

  dat_bad_cond <- dat
  dat_bad_cond$item_type[1] <- "unknown"
  expect_error(
    check_data(model, dat_bad_cond, bmf(D ~ 1, g ~ 1)),
    "Unmatched values: 'unknown'"
  )

  tree <- mpt_tree("main", list(
    correct = "D + (1 - D) * Gcorr",
    incorrect = "(1 - D) * (1 - Gcorr)"
  ))
  model_cov <- mpt(tree, covariates = "Gcorr")
  dat_cov <- data.frame(correct = 10, incorrect = 10)
  expect_error(
    check_data(model_cov, dat_cov, bmf(D ~ 1)),
    "covariates 'Gcorr' are missing"
  )
})

test_that("check_data requires a column for every declared covariate, used or not", {
  unused <- mpt(
    mpt_tree("main", list(correct = "D", incorrect = "1 - D")),
    covariates = "z"
  )
  expect_error(
    check_data(unused, data.frame(correct = 10, incorrect = 10), bmf(D ~ 1)),
    "covariates 'z' are missing"
  )
  expect_no_error(check_data(
    unused, data.frame(correct = 10, incorrect = 10, z = 1), bmf(D ~ 1)
  ))
})

test_that("check_data warns on missing counts and refuses the columns it builds", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()

  dat_na <- dat
  dat_na$old[c(1, 3)] <- NA
  expect_warning(
    checked <- check_data(model, dat_na, bmf(D ~ 1, g ~ 1)),
    "2 missing value\\(s\\), which are counted as 0"
  )
  expect_equal(unname(checked$Y[1, ]), c(0, dat$new[1]))

  dat_reserved <- dat
  dat_reserved$nTrials <- 50
  expect_error(
    check_data(model, dat_reserved, bmf(D ~ 1, g ~ 1)),
    "'nTrials' would be overwritten"
  )
  dat_reserved$Y <- 1
  expect_error(
    check_data(model, dat_reserved, bmf(D ~ 1, g ~ 1)),
    "'Y', 'nTrials'"
  )
})

test_that("mpt category probabilities match the production m3 likelihood", {
  # for the simple choice rule with fixed b, list size NL and response-set
  # size N, the simple-span M3 is an MPT with design-fixed guessing rates;
  # the two probability vectors must be identical through the analytic
  # bijection a = 2*b*Pm*(1-Pb)/(1-Pm), c = N*b*Pm*Pb/(1-Pm)
  b <- 0.1
  NL <- 4
  N <- 8

  mpt_model <- mpt(mpt_tree("main", list(
    correct = "Pm*Pb + Pm*(1 - Pb)*0.25 + (1 - Pm)*0.125",
    other = "Pm*(1 - Pb)*0.75 + (1 - Pm)*0.375",
    npl = "(1 - Pm)*0.5"
  )))

  m3_model <- m3(
    resp_cats = c("correct", "other", "npl"),
    num_options = c(1, NL - 1, N - NL),
    choice_rule = "simple",
    version = "custom"
  )
  act_funs <- bmf(correct ~ b + a + c, other ~ b + a, npl ~ b)

  grid <- expand.grid(Pm = seq(0.15, 0.9, 0.15), Pb = seq(0.15, 0.9, 0.15))
  max_diff <- max(vapply(seq_len(nrow(grid)), function(i) {
    Pm <- grid$Pm[i]
    Pb <- grid$Pb[i]
    p_mpt <- bmm:::.mpt_probability_vector(
      pars = c(Pm = Pm, Pb = Pb), mpt_model = mpt_model
    )
    a <- 2 * b * Pm * (1 - Pb) / (1 - Pm)
    c_par <- N * b * Pm * Pb / (1 - Pm)
    p_m3 <- .compute_m3_probability_vector(
      pars = c(a = a, c = c_par, b = b), m3_model = m3_model,
      act_funs = act_funs
    )
    max(abs(p_mpt - p_m3))
  }, numeric(1)))

  expect_lt(max_diff, 1e-10)
})

test_that("simplex predictors may be given on the parameter or its raw component", {
  trees <- list(mpt_tree("t", list(A = "gA", B = "gB", C = "gC")))
  model <- mpt(trees, simplex = c("gA", "gB", "gC"))
  dat <- data.frame(id = factor(1:5), A = 10, B = 10, C = 10)

  # an explicit gA ~ 1 does not override predictors on gAraw
  on_raw <- suppressMessages(check_formula(
    model, dat, bmf(gA ~ 1, gAraw ~ 1 + (1 | id), gB ~ 1, gBraw ~ 1)
  ))
  expect_equal(deparse1(on_raw$gAraw[[3]]), "1 + (1 | id)")
  expect_equal(deparse1(on_raw$gA[[3]]), "inv_logit(gAraw)")

  expect_error(
    suppressMessages(check_formula(
      model, dat, bmf(gA ~ 0 + id, gAraw ~ 1 + (1 | id), gB ~ 1, gBraw ~ 1)
    )),
    "Conflicting"
  )
  expect_no_error(suppressMessages(check_formula(
    model, dat, bmf(gA ~ 0 + id, gAraw ~ 0 + id, gB ~ 1, gBraw ~ 1)
  )))

  dat$gAraw <- 1
  expect_error(check_data(model, dat, bmf(gA ~ 1, gB ~ 1)), "stick-breaking")
})

test_that("mpt supports multiple simplex groups", {
  tree <- mpt_tree("t", list(
    A = "m * gA + (1 - m) * hA",
    B = "m * gB + (1 - m) * hB",
    C = "m * gC + (1 - m) * hC"
  ))
  model <- mpt(
    tree,
    simplex = list(c("gA", "gB", "gC"), c("hA", "hB", "hC"))
  )
  expect_setequal(
    names(model$parameters),
    c("m", "gA", "gB", "gC", "hA", "hB", "hC", "gAraw", "gBraw", "hAraw", "hBraw")
  )

  dat <- data.frame(id = factor(1:8), A = 10, B = 10, C = 10)
  formula <- bmf(m ~ 1, gA ~ 1, gB ~ 1, hA ~ 1, hB ~ 1)
  # one tree has two degrees of freedom for m and four sticks
  expect_warning(
    expect_no_warning(
      suppressMessages(bmm(
        formula, dat, model,
        backend = "mock", mock_fit = 1, rename = FALSE
      )),
      message = "Non-linear"
    ),
    "Jacobian rank 2 of 5"
  )
})

test_that("factor tree identifier columns are matched to tree names", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  dat$item_type <- factor(dat$item_type, levels = c("old", "new"))
  checked <- check_data(model, dat, bmf(D ~ 1, g ~ 1))
  expect_equal(checked$Idx_old, as.integer(dat$item_type == "old"))
  expect_equal(checked$Idx_new, as.integer(dat$item_type == "new"))
})

test_that("mpt_tree validates impossible response categories", {
  branches <- list(a = "D", b = "1 - D")
  expect_error(
    mpt_tree("t", branches, impossible = "a"),
    "cannot be both impossible and have a branch"
  )
  expect_error(
    mpt_tree("t", branches, impossible = c("c", "c")),
    "must be unique"
  )
  expect_error(mpt_tree("t", branches, impossible = 1), "character vector")

  tree <- mpt_tree("t", branches, impossible = "c")
  expect_equal(tree$impossible, "c")
  expect_output(print(tree), "P\\(c\\) = 0 \\(structurally impossible\\)")
})

test_that("mpt requires impossible categories to exist in some other tree", {
  trees <- list(
    mpt_tree("one", list(a = "D", b = "1 - D"), impossible = "c"),
    mpt_tree("two", list(a = "1 - D", b = "D"), impossible = "c")
  )
  expect_error(
    mpt(trees, tree_id = "cond"),
    "impossible in every tree"
  )
  expect_error(
    mpt(mpt_tree("one", list(a = "D", b = "1 - D"), impossible = "c")),
    "impossible in every tree"
  )
})

test_that("mpt counts impossible categories when comparing trees", {
  model <- mpt(mpt_impossible_trees(), tree_id = "tree")
  expect_equal(model$resp_vars$resp_cats, c("corr", "dist", "npl"))

  mismatched <- list(
    mpt_tree("one", list(a = "D", b = "1 - D"), impossible = "c"),
    mpt_tree("two", list(a = "D", b = "1 - D", d = "0"))
  )
  expect_error(mpt(mismatched, tree_id = "cond"), "same response categories")
})

test_that("check_data builds possibility indicators for impossible categories", {
  model <- mpt(mpt_impossible_trees(), tree_id = "tree")
  dat <- mpt_impossible_data()
  checked <- check_data(model, dat, bmf(Pm ~ 1, Pb ~ 1))
  expect_equal(checked$Poss_dist, as.integer(dat$cond == "withdist"))

  dat_collide <- dat
  dat_collide$Poss_dist <- 1
  expect_error(check_data(model, dat_collide, bmf(Pm ~ 1, Pb ~ 1)), "reserved")

  dat_observed <- dat
  dat_observed$dist[dat_observed$cond == "same"][1] <- 3L
  expect_error(
    check_data(model, dat_observed, bmf(Pm ~ 1, Pb ~ 1)),
    "declared impossible for 1 row\\(s\\)"
  )
})

test_that("check_data warns about a tree without rows in units of rows", {
  model <- mpt(mpt_2htm_trees(), tree_id = "item_type")
  dat <- mpt_2htm_data()
  expect_warning(
    check_data(model, dat[dat$item_type == "old", ], bmf(D ~ 1, g ~ 1)),
    "no rows for tree\\(s\\): 'new'"
  )
})

test_that("missing counts of an impossible category do not warn", {
  model <- mpt(mpt_impossible_trees(), tree_id = "tree")
  dat <- mpt_impossible_data()
  dat$dist[dat$tree == "nodist"] <- NA
  expect_silent(checked <- check_data(model, dat, bmf(Pm ~ 1, Pb ~ 1)))
  expect_true(all(checked$Y[dat$tree == "nodist", "dist"] == 0))

  dat$dist[dat$tree == "withdist"][1] <- NA
  dat$corr[2] <- NA
  expect_warning(
    check_data(model, dat, bmf(Pm ~ 1, Pb ~ 1)),
    "2 missing value\\(s\\)"
  )
})

test_that("impossible categories are switched off in the linear predictor", {
  model <- mpt(mpt_impossible_trees(), tree_id = "tree")
  dat <- mpt_impossible_data()
  formula <- bmf(Pm ~ 1, Pb ~ 1)
  checked_data <- check_data(model, dat, formula)
  checked_formula <- check_formula(model, checked_data, formula)

  # the tree that cannot produce the category contributes a placeholder, so
  # log() stays defined for its rows
  expect_match(deparse1(checked_formula$dist[[3]]), "Idx_nodist * (1)", fixed = TRUE)

  brms_formula <- configure_model(model, checked_data, checked_formula)$formula
  expect_match(
    deparse1(brms_formula$pforms$mudist[[3]]),
    "Poss_dist * log(dist) + (1 - Poss_dist) * (-100)",
    fixed = TRUE
  )
  expect_match(deparse1(brms_formula$formula[[3]]), "log(corr)", fixed = TRUE)
})

test_that("the brms formula reproduces the tree probabilities with impossible categories", {
  model <- mpt(mpt_impossible_trees(), tree_id = "tree")
  dat <- mpt_impossible_data()
  formula <- bmf(Pm ~ 1, Pb ~ 1)
  checked_data <- check_data(model, dat, formula)
  checked_formula <- check_formula(model, checked_data, formula)
  brms_formula <- configure_model(model, checked_data, checked_formula)$formula
  resp_cats <- model$resp_vars$resp_cats

  # intercept-only parameters on the logit scale, evaluated as brms does:
  # category probabilities first, then the linear predictor of each category
  pars <- c(Pm = 0.4, Pb = 1.1)
  env <- c(as.list(checked_data), as.list(pars), list(inv_logit = stats::plogis))
  probs <- lapply(resp_cats, function(cat) eval(checked_formula[[cat]][[3]], env))
  names(probs) <- resp_cats
  env <- c(env, probs)
  eta <- vapply(resp_cats, function(cat) {
    rhs <- if (cat == resp_cats[1]) {
      brms_formula$formula[[3]]
    } else {
      brms_formula$pforms[[paste0("mu", cat)]][[3]]
    }
    eval(rhs, env)
  }, numeric(nrow(dat)))
  softmax <- exp(eta) / rowSums(exp(eta))

  expected <- t(vapply(seq_len(nrow(dat)), function(i) {
    bmm:::.mpt_probability_vector(plogis(pars), model, tree = dat$tree[i])[resp_cats]
  }, numeric(length(resp_cats))))
  is_impossible <- dat$tree == "nodist"
  expect_true(all(softmax[is_impossible, "dist"] < 1e-40))
  expect_equal(
    unname(softmax[, c("corr", "npl")]), unname(expected[, c("corr", "npl")]),
    tolerance = 1e-12
  )
  expect_equal(unname(softmax[!is_impossible, ]), unname(expected[!is_impossible, ]),
    tolerance = 1e-12
  )
})

test_that("mpt compiles with structurally impossible categories", {
  expect_silent(bmm(
    bmf(Pm ~ 1 + (1 | id), Pb ~ 1),
    mpt_impossible_data(), mpt(mpt_impossible_trees(), tree_id = "tree"),
    backend = "mock", mock_fit = 1, rename = FALSE
  ))
})

test_that("several levels of a factor can share one tree", {
  model <- mpt(mpt_impossible_trees(), tree_id = "tree")
  dat <- mpt_impossible_data()
  checked <- check_data(model, dat, bmf(Pm ~ 0 + cond, Pb ~ 1))
  expect_equal(checked$Idx_withdist, as.integer(dat$cond == "withdist"))
  expect_equal(checked$Idx_nodist, as.integer(dat$cond %in% c("reord", "same")))

  # the experimental factor survives untouched for the parameter formulas
  expect_equal(checked$cond, dat$cond)
})

test_that("tree identifier values must match tree names", {
  model <- mpt(mpt_impossible_trees(), tree_id = "tree")
  dat <- mpt_impossible_data()
  dat$tree[dat$cond == "reord"] <- "reord"
  expect_error(
    check_data(model, dat, bmf(Pm ~ 1, Pb ~ 1)),
    "Unmatched values: 'reord'"
  )

  missing_col <- mpt_impossible_data()
  missing_col$tree <- NULL
  expect_error(
    check_data(model, missing_col, bmf(Pm ~ 1, Pb ~ 1)),
    "not present in the data"
  )
})

test_that("check_data validates branch sums with observed covariate values", {
  # the tree sums to 1 only when Gcorr + Gother = 1, which synthetic test
  # values at construction cannot verify (mpt() stays silent) but the observed
  # covariate columns can (check_data() errors there)
  tree <- mpt_tree("main", list(
    correct = "D + (1 - D) * Gcorr",
    incorrect = "(1 - D) * Gother"
  ))
  model <- expect_silent(mpt(tree, covariates = c("Gcorr", "Gother")))
  dat <- data.frame(
    id = factor(1:6), Gcorr = 0.25, Gother = 0.75,
    correct = 10, incorrect = 30
  )
  expect_silent(check_data(model, dat, bmf(D ~ 1)))

  dat_bad <- dat
  dat_bad$Gother[3] <- 0.9
  expect_error(
    check_data(model, dat_bad, bmf(D ~ 1)),
    "do not sum to 1 for 1 row"
  )

  dat_na <- dat
  dat_na$Gcorr[c(2, 5)] <- NA
  expect_error(
    check_data(model, dat_na, bmf(D ~ 1)),
    "'Gcorr' is missing in row 2 of tree 'main'.*cannot be computed"
  )

  # swapped complements agree at the symmetric point D = 0.5, so the branch
  # expressions are checked at several parameter values
  swapped <- expect_silent(mpt(mpt_tree("main", list(
    correct = "D + (1 - D) * Gcorr", incorrect = "D * (1 - Gcorr)"
  )), covariates = "Gcorr"))
  dat_swap <- data.frame(Gcorr = 0.25, correct = 10, incorrect = 30)
  expect_error(check_data(swapped, dat_swap, bmf(D ~ 1)), "do not sum to 1")

  stray <- expect_silent(mpt(mpt_tree("main", list(
    correct = "D + (1 - D) * Gcorr", incorrect = "2 * D * (1 - D) * (1 - Gcorr)"
  )), covariates = "Gcorr"))
  expect_error(check_data(stray, dat_swap, bmf(D ~ 1)), "do not sum to 1")
})

test_that("check_data rejects covariate values that push a branch outside (0, 1]", {
  model <- mpt(mpt_tree("main", list(
    correct = "D + (1 - D) * G", incorrect = "(1 - D) * (1 - G)"
  )), covariates = "G")
  dat <- data.frame(G = c(0.25, 0.5, 0.75), correct = 10, incorrect = 30)
  expect_silent(check_data(model, dat, bmf(D ~ 1)))

  expect_silent(check_data(model, transform(dat, G = 0), bmf(D ~ 1)))

  dat_over <- dat
  dat_over$G[2] <- 1.2
  expect_error(
    check_data(model, dat_over, bmf(D ~ 1)),
    "'correct' in tree 'main' is [0-9.]+ in row 2, outside \\(0, 1\\]"
  )
  # no trailing blank when there is no hint about impossible categories
  expect_error(check_data(model, dat_over, bmf(D ~ 1)), "column\\(s\\): 'G'\\.$")

  # the branch D + (1 - D) * G is negative only for small D, below every
  # interior test point, so a slightly negative covariate needs the
  # near-boundary points
  for (g in c(-0.05, -0.1)) {
    dat_neg <- dat
    dat_neg$G[1] <- g
    expect_error(
      check_data(model, dat_neg, bmf(D ~ 1)),
      "'correct' in tree 'main' is -[0-9.]+ in row 1, outside \\(0, 1\\]"
    )
  }

  # a branch of exactly 0 is a structurally impossible category
  dat_one <- dat
  dat_one$G[3] <- 1
  expect_error(
    check_data(model, dat_one, bmf(D ~ 1)),
    "is 0 in row 3.*mpt_tree\\(impossible = \\)"
  )

  zero_int <- mpt(mpt_tree("main", list(
    hit = "D * G", miss = "1 - D * G"
  )), covariates = "G")
  dat_int <- data.frame(G = c(1L, 0L), hit = 10, miss = 30)
  expect_error(
    check_data(zero_int, dat_int, bmf(D ~ 1)),
    "'hit' in tree 'main' is 0 in row 2"
  )
})

test_that("the near-boundary points accept valid simplex trees with covariates", {
  tree <- mpt_tree("main", list(
    hit = "G * D + (1 - G * D) * gA",
    other = "(1 - G * D) * gB",
    miss = "(1 - G * D) * gC"
  ))
  model <- mpt(
    tree, covariates = "G", simplex = c("gA", "gB", "gC")
  )
  dat <- data.frame(G = c(0.2, 0.5, 0.9), hit = 10, other = 10, miss = 10)
  expect_silent(check_data(model, dat, bmf(D ~ 1, gA ~ 1, gB ~ 1)))
  expect_error(
    check_data(model, transform(dat, G = c(0.2, -0.1, 0.9)), bmf(D ~ 1, gA ~ 1, gB ~ 1)),
    "in row 2, outside \\(0, 1\\]"
  )
})

test_that("check_data refuses a branch that mixes a parameter with another's complement (#528)", {
  mixed <- mpt(mpt_tree("t", list(
    yes = "a * (1 - b) * G", no = "1 - a * (1 - b) * G"
  )), covariates = "G")
  formula <- bmf(a ~ 1, b ~ 1)
  expect_silent(check_data(mixed, data.frame(G = 0.9, yes = 6, no = 4), formula))
  expect_error(
    check_data(mixed, data.frame(G = 1.04, yes = 6, no = 4), formula),
    "category 'yes' in tree 't' is 1.04 in row 1, outside \\(0, 1\\] at the test parameter values a = 1, b = 0"
  )

  product <- mpt(mpt_tree("t", list(
    yes = "a * b * G", no = "1 - a * b * G"
  )), covariates = "G")
  expect_error(
    check_data(product, data.frame(G = 1.04, yes = 6, no = 4), formula),
    "category 'yes' in tree 't' is [0-9.]+ in row 1, outside \\(0, 1\\]"
  )

  # the row that leaves the range is the one reported, not the first row
  expect_error(
    check_data(mixed, data.frame(G = c(0.5, 0.7, 1.04), yes = 6, no = 4), formula),
    "in row 3, outside"
  )
})

test_that("the vertex check catches a branch that exceeds 1 by less than 0.1%", {
  # a * (1 - b) * G peaks at G as a -> 1 and b -> 0; a grid at 0.001 and 0.999
  # sees only 0.998 * G
  mixed <- mpt(mpt_tree("t", list(
    yes = "a * (1 - b) * G", no = "1 - a * (1 - b) * G"
  )), covariates = "G")
  expect_error(
    check_data(mixed, data.frame(G = 1.0015, yes = 6, no = 4), bmf(a ~ 1, b ~ 1)),
    "is 1.0015 in row 1, outside \\(0, 1\\] at the test parameter values a = 1, b = 0"
  )
  expect_error(
    mpt(mpt_tree("t", list(yes = "1.0015 * a * (1 - b)", no = "1 - 1.0015 * a * (1 - b)"))),
    "is 1.0015 at the test values a = 1, b = 0, outside \\(0, 1\\]"
  )
})

test_that("check_data checks a parameter that enters a branch twice on a grid", {
  # u * (1 - u) peaks at u = 0.5, between the vertices and away from the
  # interior test points; G = 1.01 puts only |u - 0.5| < 0.05 above 1
  pair <- function(...) mpt(mpt_tree("t", list(...)), covariates = "G")
  model <- pair(yes = "4 * u * (1 - u) * G", no = "1 - 4 * u * (1 - u) * G")
  formula <- bmf(u ~ 1)
  expect_error(
    check_data(model, data.frame(G = 1.1, yes = 6, no = 4), formula),
    "category 'yes' in tree 't' is [0-9.]+ in row 1, outside \\(0, 1\\]"
  )
  expect_error(
    check_data(model, data.frame(G = 1.01, yes = 6, no = 4), formula),
    "category 'yes' in tree 't' is 1.01 in row 1, outside \\(0, 1\\] at the test parameter values u = 0.5"
  )
  expect_silent(check_data(model, data.frame(G = 0.9, yes = 6, no = 4), formula))
})

test_that("check_data accepts the design-fixed guessing tree of the article over its covariate range", {
  model <- mpt(
    mpt_tree("ss", list(
      correct = "Pb + (1 - Pb) * Pi * GcorrPi + (1 - Pb) * (1 - Pi) * GcorrNoPi",
      other = "(1 - Pb) * Pi * (1 - GcorrPi) +
               (1 - Pb) * (1 - Pi) * (1 - GcorrNoPi) * GotherNoPi",
      npl = "(1 - Pb) * (1 - Pi) * (1 - GcorrNoPi) * (1 - GotherNoPi)"
    )),
    covariates = c("GcorrPi", "GcorrNoPi", "GotherNoPi")
  )
  set_size <- c(2, 4, 6, 8)
  dat <- data.frame(
    GcorrPi = 1 / set_size, GcorrNoPi = 1 / (set_size + 2),
    GotherNoPi = 0.5, correct = 10, other = 10, npl = 10
  )
  expect_silent(check_data(model, dat, bmf(Pb ~ 1, Pi ~ 1)))

  dat$GcorrNoPi[3] <- 1.3
  expect_error(
    check_data(model, dat, bmf(Pb ~ 1, Pi ~ 1)),
    "in row 3, outside \\(0, 1\\]"
  )
})

test_that("check_data names a branch whose range is only checked approximately", {
  parameters <- paste0("p", 1:11)
  build <- function(n_parameters) {
    product <- paste(parameters[seq_len(n_parameters)], collapse = " * ")
    mpt(mpt_tree("t", list(
      yes = glue("{product} * G"), no = glue("1 - {product} * G")
    )), covariates = "G")
  }
  dat <- data.frame(G = 0.5, yes = 6, no = 4)
  formula <- do.call(bmf, lapply(parameters, function(par) stats::as.formula(paste(par, "~ 1"))))

  expect_silent(check_data(build(10), dat, formula))
  # one message names every branch that was only sampled
  msgs <- testthat::capture_messages(check_data(build(11), dat, formula))
  expect_length(msgs, 1L)
  expect_match(msgs, "category 'yes' in tree 't', category 'no' in tree 't'.*not every vertex")
  # the fallback still runs the range check at the corners
  expect_error(
    suppressMessages(check_data(build(11), transform(dat, G = 1.5), formula)),
    "outside \\(0, 1\\]"
  )
})

test_that("check_data checks the vertices of a simplex group as one dimension", {
  # yes reaches 1.04 * 0.999 * 0.9995 only with D small and gB big, a vertex
  # that is neither all-small nor all-large
  model <- mpt(
    mpt_tree("t", list(
      yes = "G * (1 - D) * gB",
      mid = "0.5 * G * (1 - D) * gA",
      no = "1 - G * (1 - D) * gB - 0.5 * G * (1 - D) * gA"
    )),
    covariates = "G", simplex = c("gA", "gB")
  )
  formula <- bmf(D ~ 1, gA ~ 1)
  dat <- data.frame(G = 0.9, yes = 3, mid = 3, no = 4)
  expect_silent(check_data(model, dat, formula))
  expect_error(
    check_data(model, transform(dat, G = 1.04), formula),
    "category 'yes' in tree 't' is 1.04 in row 1, outside \\(0, 1\\] at the test parameter values D = 0, gB = 1"
  )
})

test_that("an NA covariate in a tree that does not use it is an error, not a dropped row", {
  trees <- list(
    mpt_tree("withdist", list(
      corr = "Pm * Pb + (1 - Pm) * (1 - Gd)",
      dist = "(1 - Pm) * Gd",
      npl = "Pm * (1 - Pb)"
    )),
    mpt_tree("nodist", list(
      corr = "Pm * Pb + (1 - Pm) * 0.2",
      npl = "Pm * (1 - Pb) + (1 - Pm) * 0.8"
    ), impossible = "dist")
  )
  model <- mpt(trees, tree_id = "tree", covariates = "Gd")
  dat <- mpt_impossible_data()
  dat$Gd <- ifelse(dat$tree == "withdist", 0.3, 0)
  expect_silent(check_data(model, dat, bmf(Pm ~ 1, Pb ~ 1)))

  dat_na <- dat
  dat_na$Gd[dat_na$tree == "nodist"] <- NA
  first_row <- which(dat_na$tree == "nodist")[1]
  expect_error(
    check_data(model, dat_na, bmf(Pm ~ 1, Pb ~ 1)),
    glue::glue("'Gd' is missing in row {first_row} of tree 'nodist'.*does not use 'Gd'")
  )

  # in a tree that uses the covariate, the branches cannot be computed
  dat_used <- dat
  dat_used$Gd[dat_used$tree == "withdist"][1] <- NA
  first_used <- which(dat_used$tree == "withdist")[1]
  expect_error(
    check_data(model, dat_used, bmf(Pm ~ 1, Pb ~ 1)),
    glue::glue("'Gd' is missing in row {first_used} of tree 'withdist'.*cannot be computed")
  )
})

test_that("the NA advice for an unused covariate does not promise that 0 is safe", {
  model <- mpt(list(
    mpt_tree("sstree", list(hit = "D + (1 - D) / ss", miss = "(1 - D) * (1 - 1 / ss)")),
    mpt_tree("plain", list(hit = "D + (1 - D) * g", miss = "(1 - D) * (1 - g)"))
  ), tree_id = "tree", covariates = "ss")
  dat <- data.frame(
    tree = rep(c("sstree", "plain"), each = 2), ss = c(4, 4, NA, NA),
    hit = 5, miss = 5
  )
  msg <- tryCatch(check_data(model, dat, bmf(D ~ 1, g ~ 1)), error = conditionMessage)
  expect_match(msg, "does not use 'ss'")
  expect_match(msg, "0 works unless one of them divides by 'ss'", fixed = TRUE)
  expect_no_match(msg, "cannot be computed")
})

test_that("a branch that is undefined on the rows of another tree is an error", {
  # brms evaluates every tree's branches on every row, so a 0 filled in for a
  # tree that does not use the covariate breaks a tree that divides by it
  model <- mpt(list(
    mpt_tree("sstree", list(hit = "D + (1 - D) / ss", miss = "(1 - D) * (1 - 1 / ss)")),
    mpt_tree("plain", list(hit = "D + (1 - D) * g", miss = "(1 - D) * (1 - g)"))
  ), tree_id = "tree", covariates = "ss")
  formula <- bmf(D ~ 1, g ~ 1)
  dat <- data.frame(
    tree = rep(c("sstree", "plain"), each = 2), ss = c(4, 4, 0, 0),
    hit = 5, miss = 5
  )
  expect_error(
    check_data(model, dat, formula),
    "'hit' in tree 'sstree' is not finite in row 3.*belongs to tree 'plain'"
  )
  dat$ss[3:4] <- 1
  expect_silent(check_data(model, dat, formula))
})

test_that("a branch that divides by a covariate equal to 0 in its own tree gets the package message", {
  model <- mpt(mpt_tree("main", list(
    hit = "D + (1 - D) / ss", miss = "(1 - D) * (1 - 1 / ss)"
  )), covariates = "ss")
  dat <- data.frame(ss = c(4, 0), hit = 5, miss = 5)
  expect_error(
    check_data(model, dat, bmf(D ~ 1)),
    "do not sum to 1.*first: row 2, sum = NaN"
  )
})

test_that("a valid branch that underflows to 0 near the boundary is accepted", {
  model <- mpt(mpt_tree("main", list(
    hit = "1 - (1 - D)^n", miss = "(1 - D)^n"
  )), covariates = "n")
  expect_silent(check_data(model, data.frame(hit = 5L, miss = 5L, n = 200), bmf(D ~ 1)))
  expect_silent(check_data(model, data.frame(hit = 5L, miss = 5L, n = 2), bmf(D ~ 1)))

  neg <- mpt(mpt_tree("main", list(
    correct = "D + (1 - D) * G", incorrect = "(1 - D) * (1 - G)"
  )), covariates = "G")
  expect_error(
    check_data(neg, data.frame(G = -0.05, correct = 10, incorrect = 30), bmf(D ~ 1)),
    "-[0-9.]+ in row 1"
  )
})

test_that("the sum and range messages name only the covariates the tree uses", {
  sums <- mpt(mpt_tree("main", list(
    a = "D", b = "(1 - D) * H"
  )), covariates = c("G", "H"))
  msg <- tryCatch(
    check_data(sums, data.frame(G = 0.5, H = 0.5, a = 5, b = 5), bmf(D ~ 1)),
    error = conditionMessage
  )
  expect_match(msg, "do not sum to 1 for 1 row.*at the test parameter values")
  expect_match(msg, "column(s): 'H'", fixed = TRUE)
  expect_no_match(msg, "'G'")

  # a tree without covariates beside a covariate tree is refused at mpt(),
  # before any covariate value exists to blame
  trees <- list(
    mpt_tree("t1", list(x = "2 * a", y = "1 - 2 * a")),
    mpt_tree("t2", list(x = "D + (1 - D) * G", y = "(1 - D) * (1 - G)"))
  )
  msg <- tryCatch(mpt(trees, tree_id = "tree", covariates = "G"), error = conditionMessage)
  expect_match(msg, "category 'x' in tree 't1' is [0-9.]+ at the test values a = .*outside \\(0, 1\\]")
  expect_no_match(msg, "'G'")
})

test_that("mpt() refuses a tree that leaves (0, 1] only at a boundary corner, with or without covariates", {
  tree <- mpt_tree("t", list(yes = "1.2 * a - 0.2", no = "1.2 - 1.2 * a"))
  corner_msg <- "category 'yes' in tree 't' is -0.1988 at the test values a = 0.001, outside \\(0, 1\\]"
  expect_error(mpt(tree), corner_msg)
  expect_error(mpt(tree, covariates = "G"), corner_msg)

  # 0.001^200 underflows to exactly 0 at the corner D = 0.999
  expect_silent(mpt(mpt_tree("u", list(hit = "1 - (1 - D)^200", miss = "(1 - D)^200"))))
})

test_that("mpt() refuses a tree without covariates that leaves (0, 1] at a mixed vertex (#528)", {
  mixed <- function(scale) {
    mpt_tree("t", list(
      yes = glue("{scale} * a * (1 - b)"), no = glue("1 - {scale} * a * (1 - b)")
    ))
  }
  expect_silent(mpt(mixed(1)))
  expect_error(
    mpt(mixed(2)),
    "category 'yes' in tree 't' is 2 at the test values a = 1, b = 0, outside \\(0, 1\\]"
  )
  expect_error(mpt(mixed(1.04)), "category 'yes' in tree 't' is 1.04 at the test values a = 1, b = 0")

  # a parameter of degree 2 peaks between the vertices, away from the interior points
  pair <- function(scale) {
    mpt_tree("t", list(
      yes = glue("{scale} * u * (1 - u)"), no = glue("1 - {scale} * u * (1 - u)")
    ))
  }
  expect_silent(mpt(pair(4)))
  expect_error(mpt(pair(4.04)), "category 'yes' in tree 't' is 1.01 at the test values u = 0.5")

  # a tree that uses a covariate is decided by check_data(), not here
  expect_silent(mpt(mixed("2 * G"), covariates = "G"))
})

test_that("mpt() names the branches of a tree without covariates whose vertices it only samples", {
  parameters <- paste0("p", 1:11)
  product <- paste(parameters, collapse = " * ")
  tree <- mpt_tree("t", list(yes = product, no = glue("1 - {product}")))
  msgs <- testthat::capture_messages(mpt(tree))
  expect_length(msgs, 1L)
  expect_match(msgs, "category 'yes' in tree 't', category 'no' in tree 't'.*not every vertex")

  ten <- paste(parameters[1:10], collapse = " * ")
  expect_silent(mpt(mpt_tree("t", list(yes = ten, no = glue("1 - {ten}")))))
})

test_that("a branch that cancels to just below 0 at a corner is accepted", {
  # 1 minus the other branches is -8.49e-20 at the corner 0.999 for seven
  # stages, and the expanded (1 - a)^7 is -1.33e-15 there
  stages <- paste0("p", 1:7)
  reached <- c("", vapply(1:6, function(i) {
    paste0(paste0("(1 - ", stages[seq_len(i)], ")", collapse = " * "), " * ")
  }, character(1)))
  branches <- paste0(reached, stages)
  chain <- as.list(setNames(
    c(branches, paste0("1 - ", paste(branches, collapse = " - "))), paste0("c", 1:8)
  ))
  expect_silent(mpt(mpt_tree("t", chain)))

  expanded <- "1 - 7*a + 21*a^2 - 35*a^3 + 35*a^4 - 21*a^5 + 7*a^6 - a^7"
  expect_silent(mpt(mpt_tree("t", list(y = glue("1 - ({expanded})"), n = expanded))))

  # the same rule holds where the covariate values of the data are checked
  scaled <- mpt(
    mpt_tree("t", list(y = glue("1 - x * ({expanded})"), n = glue("x * ({expanded})"))),
    covariates = "x"
  )
  expect_silent(check_data(scaled, data.frame(x = 1, y = 5, n = 5), bmf(a ~ 1)))
  shifted <- mpt(
    mpt_tree("t", list(yes = "x * (1.2 * a - 0.2)", no = "1 - x * (1.2 * a - 0.2)")),
    covariates = "x"
  )
  expect_error(
    check_data(shifted, data.frame(x = 1, yes = 5, no = 5), bmf(a ~ 1)),
    "category 'yes' in tree 't' is -0.1988 in row 1, outside \\(0, 1\\]"
  )
})

test_that("mpt() refuses a branch that is not a number at a test value", {
  # 0.001^200 underflows to 0, so the ratio is 0 / 0 at the corner
  expect_error(
    mpt(mpt_tree("t", list(y = "a^200 / (a^200 + b^200)", n = "b^200 / (a^200 + b^200)"))),
    "category 'y' in tree 't' is not a number at the test values a = 0.001, b = 0.001"
  )
})

test_that("mpt() alone decides a tree without covariates in a model with covariates and a simplex", {
  covariate_tree <- mpt_tree("t1", list(
    A = "gA * D + (1 - D) * x", B = "gB * D", C = "gC * D + (1 - D) * (1 - x)"
  ))
  build <- function(free_tree) {
    mpt(
      list(covariate_tree, free_tree),
      tree_id = "tree", covariates = "x", simplex = c("gA", "gB", "gC")
    )
  }
  expect_error(
    build(mpt_tree("t2", list(
      A = "1.25 * D", B = "(1 - 1.25 * D) * 0.5", C = "(1 - 1.25 * D) * 0.5"
    ))),
    "category 'A' in tree 't2' is 1.24875 at the test values D = 0.999"
  )

  # the branches of t2 sum to 1 at every value of D that mpt() tries, and to
  # something else at the values a data check in another symbol order would try
  # (t2 adds no symbol, so t1 alone sets the order). The bump pins the 7 points
  # mpt() tries today (5 interior, 2 corners); if mpt() gains test points, t2
  # is refused at construction and this test must change with them
  construction <- bmm:::.mpt_tree_parameters(list(covariate_tree), "x")
  checked_d <- c(
    vapply(bmm:::.mpt_test_points(construction, list(c("gA", "gB", "gC"))), `[[`, numeric(1), "D"),
    0.001, 0.999
  )
  bump <- paste0("(D - ", sprintf("%.10f", checked_d), ")", collapse = " * ")
  model <- build(mpt_tree("t2", list(A = "D", B = glue("1 - D + {bump}")), impossible = "C"))
  dat <- data.frame(tree = c("t1", "t2"), x = 0.5, A = 3, B = 3, C = c(3, 0))
  expect_silent(check_data(model, dat, bmf(D ~ 1)))
})

test_that("the data check catches a tree that is right at the first interior and boundary points", {
  at_first <- sprintf("%.8f", bmm:::.mpt_test_points("D", list())[[1]][["D"]])
  model <- mpt(mpt_tree("main", list(
    a = "D",
    b = glue("1 - D + (D - {at_first}) * (D - 0.001) * (D - 0.999) * G")
  )), covariates = "G")
  expect_error(
    check_data(model, data.frame(G = 0.5, a = 5, b = 5), bmf(D ~ 1)),
    "do not sum to 1"
  )
})

test_that("an NA in a declared covariate that no branch uses keeps every row", {
  model <- mpt(mpt_tree("main", list(
    correct = "D + (1 - D) * G", incorrect = "(1 - D) * (1 - G)"
  )), covariates = c("G", "H"))
  dat <- data.frame(G = 0.25, H = c(NA, 1, 2, NA), correct = 10, incorrect = 30)
  expect_silent(checked <- check_data(model, dat, bmf(D ~ 1)))
  expect_equal(nrow(checked), nrow(dat))
})

test_that("check_data catches a typo between parameters four places apart", {
  branches <- list(
    r1 = "A * B", r2 = "A * (1 - B) * C", r3 = "A * (1 - B) * (1 - C) * E",
    r4 = "A * (1 - B) * (1 - C) * (1 - E) * G",
    r5 = "A * (1 - B) * (1 - C) * (1 - E) * (1 - G) + (1 - A) * F"
  )
  dat <- data.frame(G = c(0.2, 0.6), r1 = 5, r2 = 5, r3 = 5, r4 = 5, r5 = 5, r6 = 5)
  formula <- bmf(A ~ 1, B ~ 1, C ~ 1, E ~ 1, F ~ 1)

  good <- mpt(
    mpt_tree("main", c(branches, r6 = "(1 - A) * (1 - F)")), covariates = "G"
  )
  expect_silent(check_data(good, dat, formula))

  typo <- mpt(
    mpt_tree("main", c(branches, r6 = "(1 - A) * (1 - A)")), covariates = "G"
  )
  expect_error(check_data(typo, dat, formula), "do not sum to 1")
})

test_that("covariate sum check respects tree membership", {
  trees <- list(
    mpt_tree("cued", list(
      correct = "D + (1 - D) * Gcorr",
      incorrect = "(1 - D) * Gother"
    )),
    mpt_tree("free", list(
      correct = "D",
      incorrect = "1 - D"
    ))
  )
  model <- mpt(trees, tree_id = "cond", covariates = c("Gcorr", "Gother"))
  dat <- data.frame(
    cond = rep(c("cued", "free"), each = 3),
    Gcorr = c(0.25, 0.25, 0.25, 99, 99, 99),
    Gother = c(0.75, 0.75, 0.75, 99, 99, 99),
    correct = 10, incorrect = 30
  )
  # the invalid covariate values sit in rows of the tree that does not use
  # the covariates, so no warning should be raised
  expect_silent(check_data(model, dat, bmf(D ~ 1)))
})

test_that("a tree without rows is still checked on the rows of the other trees", {
  trees <- list(
    mpt_tree("sstree", list(hit = "D + (1 - D) / ss", miss = "(1 - D) * (1 - 1 / ss)")),
    mpt_tree("plain", list(hit = "D + (1 - D) * g", miss = "(1 - D) * (1 - g)"))
  )
  model <- mpt(trees, tree_id = "tt", covariates = "ss")
  # brms evaluates the sstree branches on the plain rows, where 1 / 0 is Inf
  dat <- data.frame(tt = "plain", ss = 0, hit = c(60, 70, 65), miss = c(40, 30, 35))
  expect_error(
    suppressWarnings(check_data(model, dat, bmf(D ~ 1, g ~ 1))),
    "category 'hit' in tree 'sstree' is not finite in row 1"
  )
})

test_that("the item-memory-first MPT matches the simple-rule m3 with a distractor category", {
  # bijection for act_funs corr ~ b+a+c, other ~ b+a, dist ~ b+d, npl ~ b
  # with candidate counts (1, 4, 5, 5) and S = 15b + 5a + c + 5d:
  #   Pi = (5a + c + 5d)/S, Pb = c/(5a + c + 5d), Pd = d/(a + d)
  b <- 0.1
  mpt_dist <- mpt(mpt_tree("newdist", list(
    corr = "Pi * Pb + Pi * (1 - Pb) * (1 - Pd) * (1/5) + (1 - Pi) * (1/15)",
    other = "Pi * (1 - Pb) * (1 - Pd) * (4/5) + (1 - Pi) * (4/15)",
    dist = "Pi * (1 - Pb) * Pd + (1 - Pi) * (5/15)",
    npl = "(1 - Pi) * (5/15)"
  )))
  m3_dist <- m3(
    resp_cats = c("corr", "other", "dist", "npl"),
    num_options = c(1, 4, 5, 5), choice_rule = "simple", version = "custom"
  )
  acts_dist <- bmf(corr ~ b + a + c, other ~ b + a, dist ~ b + d, npl ~ b)

  grid <- expand.grid(a = c(0.2, 1, 3), c = c(0.5, 2, 6), d = c(0.1, 0.8, 2))
  max_diff <- max(vapply(seq_len(nrow(grid)), function(i) {
    a <- grid$a[i]
    c_par <- grid$c[i]
    d <- grid$d[i]
    denom <- 5 * a + c_par + 5 * d
    p_mpt <- bmm:::.mpt_probability_vector(
      pars = c(
        Pi = denom / (15 * b + denom),
        Pb = c_par / denom,
        Pd = d / (a + d)
      ),
      mpt_model = mpt_dist
    )
    p_m3 <- .compute_m3_probability_vector(
      pars = c(a = a, c = c_par, d = d, b = b),
      m3_model = m3_dist, act_funs = acts_dist
    )
    max(abs(p_mpt - p_m3))
  }, numeric(1)))
  expect_lt(max_diff, 1e-10)

  # no-distractor condition: counts (1, 4, 10), S = 15b + 5a + c
  mpt_nodist <- mpt(mpt_tree("nodist", list(
    corr = "Pi * Pb + Pi * (1 - Pb) * (1/5) + (1 - Pi) * (1/15)",
    other = "Pi * (1 - Pb) * (4/5) + (1 - Pi) * (4/15)",
    npl = "(1 - Pi) * (10/15)"
  )))
  m3_nodist <- m3(
    resp_cats = c("corr", "other", "npl"),
    num_options = c(1, 4, 10), choice_rule = "simple", version = "custom"
  )
  acts_nodist <- bmf(corr ~ b + a + c, other ~ b + a, npl ~ b)
  max_diff_nodist <- max(vapply(seq_len(nrow(grid)), function(i) {
    a <- grid$a[i]
    c_par <- grid$c[i]
    p_mpt <- bmm:::.mpt_probability_vector(
      pars = c(
        Pi = (5 * a + c_par) / (15 * b + 5 * a + c_par),
        Pb = c_par / (5 * a + c_par)
      ),
      mpt_model = mpt_nodist
    )
    p_m3 <- .compute_m3_probability_vector(
      pars = c(a = a, c = c_par, b = b),
      m3_model = m3_nodist, act_funs = acts_nodist
    )
    max(abs(p_mpt - p_m3))
  }, numeric(1)))
  expect_lt(max_diff_nodist, 1e-10)
})

test_that("bmm() stores the name of the data the user passed to an mpt fit", {
  withr::local_options("bmm.silent" = 2)
  my_counts <- mpt_2htm_data()
  fit <- bmm(bmf(D ~ 1, g ~ 1), my_counts, mpt(mpt_2htm_trees(), "item_type"),
    backend = "mock", mock_fit = 1, rename = FALSE
  )
  expect_equal(attr(fit$data, "data_name"), "my_counts")
})

test_that("bmm_data_check() reads the correlation ID that ?mpt recommends as a label", {
  dat <- mpt_2htm_data()
  dat$id <- rep(1:2, length.out = nrow(dat))
  res <- bmm_data_check(
    bmf(D ~ 1 + (1 |p| id), g ~ 1 + (1 |p| id)), dat,
    mpt(mpt_2htm_trees(), "item_type")
  )
  expect_setequal(res$predictors$coding$variable, "id")
  expect_false("p" %in% unlist(res$predictors$pred_map))
})

test_that("mpt keeps the trees as given before the restrictions", {
  trees <- setNames(mpt_2htm_trees(), c("old", "new"))
  restricted <- mpt(trees, tree_id = "item_type", restrictions = "g = 0.5")
  expect_equal(restricted$other_vars$unrestricted_trees, trees)
  expect_equal(deparse(restricted$other_vars$trees$old$branches$old), "D + (1 - D) * 0.5")
  expect_equal(deparse(restricted$other_vars$trees$new$branches$new), "D + (1 - D) * 0.5")
  expect_null(mpt(trees, tree_id = "item_type")$other_vars$unrestricted_trees)
})

Try the bmm package in your browser

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

bmm documentation built on Oct. 16, 2026, 9:08 a.m.