)}. This helper builds
+the object BREAD needs, so a matrix workflow does not stall at the first
+step. \code{\link[=fit_bread]{fit_bread()}} calls it for you; use it directly when you want to
+coerce once and reuse the result.
+}
+\section{Where coordinates come from}{
+
+A bare matrix has no coordinates, so you must supply them one of two ways:
+pass \code{rowRanges} (a \link[GenomicRanges:GRanges-class]{GenomicRanges::GRanges}, ideally named by probe ID),
+or pass \code{platform} to look the manifest up through \code{sesameData}.
+
+\strong{The platform is never guessed.} \code{cg########} identifiers are shared
+across HM450, EPIC and MM285, so inferring the array from probe names would
+silently return the wrong coordinates for a substantial fraction of probes,
+assign them to the wrong regions, and produce confident, wrong biology with
+no error anywhere. One word from you removes that entire failure mode.
+}
+
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+# Take a packaged SE apart, then put it back together the matrix way
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+mat <- assay(se, "betas")
+cd <- as.data.frame(colData(se))
+gr <- rowRanges(se)
+
+se2 <- bread_se(mat, colData = cd, rowRanges = gr)
+se2
+}
+\seealso{
+\code{\link[=fit_bread]{fit_bread()}}
+}
diff --git a/man/classifications.Rd b/man/classifications.Rd
index 94668cf..c974627 100644
--- a/man/classifications.Rd
+++ b/man/classifications.Rd
@@ -22,3 +22,16 @@ A named character vector of classifications per region
\description{
Extract region classifications from a \link{BreadFit}
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+fit <- fit_bread(se_ctrl, reg, ~ passage)
+
+cls <- classifications(fit)
+head(cls)
+table(cls)
+}
diff --git a/man/classify_regions.Rd b/man/classify_regions.Rd
index bcfd9b5..41214c9 100644
--- a/man/classify_regions.Rd
+++ b/man/classify_regions.Rd
@@ -2,9 +2,14 @@
% Please edit documentation in R/classify.R
\name{classify_regions}
\alias{classify_regions}
-\title{Classify regions as hyper / hypo / inconclusive}
+\title{Classify regions as hyper / hypo / unchanged / inconclusive}
\usage{
-classify_regions(post, delta = 0.1, prob_cutoff = 0.95)
+classify_regions(
+ post,
+ delta = 0.1,
+ prob_cutoff = 0.95,
+ rope_cutoff = prob_cutoff
+)
}
\arguments{
\item{post}{Output of \code{\link[=posterior_summary]{posterior_summary()}}.}
@@ -13,23 +18,69 @@ classify_regions(post, delta = 0.1, prob_cutoff = 0.95)
as an attribute; does not re-evaluate the posterior probabilities (those
must have been computed at this same \code{delta} upstream).}
-\item{prob_cutoff}{Posterior probability cutoff. Default \code{0.95}.}
+\item{prob_cutoff}{Posterior probability cutoff for a \emph{directional} call.
+Default \code{0.95}.}
+
+\item{rope_cutoff}{Posterior probability cutoff for an \emph{equivalence} call.
+Defaults to \code{prob_cutoff}. Worth setting independently: concluding
+equivalence requires the posterior to fit entirely inside
+\eqn{[-\delta, +\delta]}, a far stricter demand than a directional call,
+and at small n almost nothing reaches 0.95. Loosening it should not
+require loosening the discovery threshold too.}
}
\value{
The input \code{data.frame} with an added \code{classification} factor column
-(levels: \code{hypermethylated}, \code{hypomethylated}, \code{inconclusive}). Attributes
-\code{delta} and \code{prob_cutoff} are updated.
+(levels: \code{hypermethylated}, \code{hypomethylated}, \code{unchanged},
+\code{inconclusive}). Attributes \code{delta}, \code{prob_cutoff} and \code{rope_cutoff} are
+updated. If \code{post} has no \code{prob_rope} column it is derived as
+\code{1 - prob_hyper - prob_hypo}.
}
\description{
Applies the BREAD decision rule to the output of \code{\link[=posterior_summary]{posterior_summary()}}:
\itemize{
-\item \code{hypermethylated} if \code{p_gt_delta >= prob_cutoff}
-\item \code{hypomethylated} if \code{p_lt_neg_delta >= prob_cutoff}
+\item \code{hypermethylated} if \code{prob_hyper >= prob_cutoff}
+\item \code{hypomethylated} if \code{prob_hypo >= prob_cutoff}
+\item \code{unchanged} if \code{prob_rope >= rope_cutoff}
\item \code{inconclusive} otherwise
}
}
-\details{
-In the rare case that both probabilities exceed the cutoff (only possible
-for very low \code{prob_cutoff}), the region is assigned to whichever side has
-the larger posterior probability.
+\section{Why \code{unchanged} is a separate class}{
+
+\code{inconclusive} used to absorb two entirely different situations: a region
+whose posterior sits tightly inside the region of practical equivalence
+(strong evidence of \emph{no} change) and a region whose posterior is so diffuse
+that nothing can be said. Collapsing them discards the one claim a p-value
+structurally cannot make — that a region is \emph{demonstrably} unmoved at the
+stated \code{delta}. \code{unchanged} means "practically unchanged at this \code{delta}",
+not "identical"; \code{inconclusive} now means only what its name says.
+}
+
+\section{Mutual exclusivity}{
+
+\code{prob_hyper}, \code{prob_hypo} and \code{prob_rope} partition the posterior, so they
+sum to 1. Two of them can therefore clear their thresholds simultaneously
+only if the two thresholds sum to no more than 1 — impossible at any
+sensible setting (0.95 + 0.95 > 1). Should you set thresholds that low, the
+largest of the qualifying probabilities wins, with ties resolved
+hyper > hypo > unchanged.
+}
+
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+fit <- fit_bread(se_ctrl, reg, ~ passage)
+post <- posterior_summary(fit)
+
+cl <- classify_regions(post)
+table(cl$classification)
+
+# A stricter cutoff moves borderline regions into `inconclusive`
+table(classify_regions(post, prob_cutoff = 0.99)$classification)
+
+# Relax only the equivalence bar, leaving discovery untouched
+table(classify_regions(post, rope_cutoff = 0.80)$classification)
}
diff --git a/man/dot-beta_to_m.Rd b/man/dot-beta_to_m.Rd
index aec9e74..ed2911a 100644
--- a/man/dot-beta_to_m.Rd
+++ b/man/dot-beta_to_m.Rd
@@ -6,6 +6,14 @@
\usage{
.beta_to_m(beta, eps = 1e-06)
}
+\arguments{
+\item{beta}{Numeric vector of beta values.}
+
+\item{eps}{Clamping tolerance keeping values off the 0/1 asymptotes.}
+}
+\value{
+Numeric vector of M-values.
+}
\description{
Beta -> M transform, clamped away from 0/1.
}
diff --git a/man/dot-m_to_beta.Rd b/man/dot-m_to_beta.Rd
index d6b503c..8553722 100644
--- a/man/dot-m_to_beta.Rd
+++ b/man/dot-m_to_beta.Rd
@@ -6,6 +6,12 @@
\usage{
.m_to_beta(m)
}
+\arguments{
+\item{m}{Numeric vector of M-values.}
+}
+\value{
+Numeric vector of beta values in (0, 1).
+}
\description{
M -> Beta transform.
}
diff --git a/man/figures/logo.png b/man/figures/logo.png
new file mode 100644
index 0000000..087f2c0
Binary files /dev/null and b/man/figures/logo.png differ
diff --git a/man/fit_bread.Rd b/man/fit_bread.Rd
index b22b43b..45cbd86 100644
--- a/man/fit_bread.Rd
+++ b/man/fit_bread.Rd
@@ -5,12 +5,18 @@
\title{Fit a Bayesian region-specific methylation model}
\usage{
fit_bread(
- se,
+ x,
features,
design,
contrast = NULL,
+ colData = NULL,
+ rowRanges = NULL,
+ platform = NULL,
delta = 0.1,
prob_cutoff = 0.95,
+ rope_cutoff = prob_cutoff,
+ ci = 0.95,
+ ref_beta = NULL,
min_probes = 3L,
feature_class_col = NULL,
summary_fun = c("mean", "median", "weighted_mean", "pc1"),
@@ -18,22 +24,46 @@ fit_bread(
input_scale = NULL,
backend = c("conjugate", "brms"),
prior = NULL,
+ df_mode = c("conjugate", "residual"),
...
)
}
\arguments{
-\item{se}{A \link[SummarizedExperiment:SummarizedExperiment-class]{SummarizedExperiment::SummarizedExperiment} with a methylation assay.}
+\item{x}{A \link[SummarizedExperiment:SummarizedExperiment-class]{SummarizedExperiment::SummarizedExperiment} with a methylation
+assay, or a probe-by-sample \code{matrix} (with \code{colData} and either
+\code{rowRanges} or \code{platform}), or a \code{list(betas =, sampleInfo =)} as
+returned by \code{sesameData}. See \code{\link[=bread_se]{bread_se()}}.}
-\item{features}{A \link[GenomicRanges:GRanges-class]{GenomicRanges::GRanges} of user-defined regions.}
+\item{features}{A \link[GenomicRanges:GRanges-class]{GenomicRanges::GRanges} of user-defined regions. Several
+ranges may share a name to define one region as an exact probe set.}
\item{design}{A one-sided formula giving the model design, e.g. \code{~ group + sex}.}
\item{contrast}{Character coefficient name of interest. If \code{NULL} (default),
the first non-intercept coefficient is used and a message is emitted.}
-\item{delta}{Effect-size threshold on the M-value scale. Default \code{0.10}.}
+\item{colData, rowRanges, platform}{Only for matrix input: sample metadata,
+probe coordinates, and the array platform for a \code{sesameData} manifest
+lookup. Passing any of them alongside a \code{SummarizedExperiment} is an
+error. See \code{\link[=bread_se]{bread_se()}}.}
-\item{prob_cutoff}{Posterior probability cutoff for classification. Default \code{0.95}.}
+\item{delta}{Effect-size threshold on the M-value scale. Default \code{0.10}
+(a beta change of roughly 0.017 at mid-methylation, less toward the
+extremes -- see \code{\link[=bread_delta_beta]{bread_delta_beta()}}).}
+
+\item{prob_cutoff}{Posterior probability cutoff for a directional
+(hyper/hypo) call. Default \code{0.95}.}
+
+\item{rope_cutoff}{Posterior probability cutoff for an \code{unchanged}
+(equivalence) call. Defaults to \code{prob_cutoff}; see \code{\link[=classify_regions]{classify_regions()}}
+for why it is worth setting independently.}
+
+\item{ci}{Credible-interval mass reported in \code{ci_lo}/\code{ci_hi}. Default
+\code{0.95}. Independent of \code{prob_cutoff}.}
+
+\item{ref_beta}{Reference methylation level(s) anchoring the beta-scale
+columns. \code{NULL} (default) uses each region's own mean. See
+\code{\link[=posterior_summary]{posterior_summary()}}.}
\item{min_probes}{Minimum probes per region. Default \code{3}.}
@@ -51,6 +81,15 @@ the first non-intercept coefficient is used and a message is emitted.}
\item{prior}{Optional \code{\link[=bread_prior]{bread_prior()}} object (conjugate backend only).}
+\item{df_mode}{Degrees-of-freedom convention for the conjugate backend:
+\code{"conjugate"} (default, \eqn{a_n = a_0 + n/2}) or \code{"residual"}
+(\eqn{a_n = a_0 + (n-p)/2}), which reproduces the classical
+\eqn{t_{n-p}} marginal and matches \code{lm()} intervals under a weak prior.
+The default overstates precision by a factor \eqn{\sqrt{n/(n-p)}} on the
+posterior scale — negligible when \eqn{p \ll n}, material for interaction
+designs at small \eqn{n}. Ignored by \code{backend = "brms"}, which samples
+\eqn{\sigma^2} directly. See \code{\link[=fit_bread_summary]{fit_bread_summary()}}.}
+
\item{...}{Additional arguments forwarded to the backend. For
\code{backend = "brms"}, this accepts \code{iter}, \code{chains}, \code{cores}, \code{seed}, etc.}
}
@@ -64,7 +103,8 @@ with a methylation assay and a \link[GenomicRanges:GRanges-class]{GenomicRanges:
regions, BREAD maps probes to regions, summarizes them per sample, and
fits Bayesian region-level models to produce posterior probabilities of
directional methylation change under the contrast of interest. Regions
-are classified as hypermethylated, hypomethylated, or inconclusive.
+are classified as hypermethylated, hypomethylated, unchanged (posterior
+concentrated inside the region of practical equivalence) or inconclusive.
}
\section{Minimal call}{
@@ -91,6 +131,22 @@ updates); MCMC controls \code{iter}, \code{chains}, \code{cores}, \code{seed} ca
through \code{...} to \code{fit_bread_brms()}.
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+# Which of the 500 predefined regions change methylation with passage
+# in the control (untreated) fibroblasts?
+fit <- fit_bread(se_ctrl, reg, ~ passage,
+ feature_class_col = "feature_class")
+fit
+
+head(results(fit))
+table(results(fit)$classification)
+}
\seealso{
\code{\link[=validate_bread_input]{validate_bread_input()}}, \code{\link[=map_probes_to_features]{map_probes_to_features()}},
\code{\link[=summarize_features]{summarize_features()}}, \code{\link[=posterior_summary]{posterior_summary()}}, \code{\link[=classify_regions]{classify_regions()}}
diff --git a/man/fit_bread_hierarchical.Rd b/man/fit_bread_hierarchical.Rd
index 0648e26..14c816b 100644
--- a/man/fit_bread_hierarchical.Rd
+++ b/man/fit_bread_hierarchical.Rd
@@ -6,6 +6,10 @@
\usage{
fit_bread_hierarchical(...)
}
+\value{
+Currently signals an error; planned to return a list with the
+same shape as \code{\link[=fit_bread_summary]{fit_bread_summary()}}.
+}
\description{
Planned for v2. Models CpGs nested within regions with CpG-specific
offsets and partial pooling of region-level effects.
diff --git a/man/fit_bread_summary.Rd b/man/fit_bread_summary.Rd
index 6c1c7fa..805bc1f 100644
--- a/man/fit_bread_summary.Rd
+++ b/man/fit_bread_summary.Rd
@@ -4,7 +4,14 @@
\alias{fit_bread_summary}
\title{Fit BREAD summary-mode Bayesian model}
\usage{
-fit_bread_summary(region_mat, coldata, design, contrast, prior = NULL)
+fit_bread_summary(
+ region_mat,
+ coldata,
+ design,
+ contrast,
+ prior = NULL,
+ df_mode = c("conjugate", "residual")
+)
}
\arguments{
\item{region_mat}{Region-by-sample numeric matrix (from \code{\link[=summarize_features]{summarize_features()}}).}
@@ -16,6 +23,9 @@ fit_bread_summary(region_mat, coldata, design, contrast, prior = NULL)
\item{contrast}{Character coefficient name of interest.}
\item{prior}{A \code{\link[=bread_prior]{bread_prior()}} object (or \code{NULL} for defaults).}
+
+\item{df_mode}{\code{"conjugate"} (default) or \code{"residual"}. See the
+\emph{Degrees of freedom} section.}
}
\value{
A list with:
@@ -26,6 +36,7 @@ A list with:
\item \code{contrast}, \code{contrast_idx}: contrast name and its column index in \code{X}
\item \code{region_ids}: rownames of \code{region_mat}
\item \code{prior}: the prior applied (with \code{mu0}/\code{Lambda0} filled in)
+\item \code{df_mode}: the degrees-of-freedom convention used
}
}
\description{
@@ -45,4 +56,31 @@ Posterior:
\deqn{a_n = a_0 + n/2,\quad b_n = b_0 + \tfrac{1}{2}(y^\top y + \mu_0^\top \Lambda_0 \mu_0 - \mu_n^\top \Lambda_n \mu_n).}
}
+\section{Degrees of freedom (\code{df_mode})}{
+
+The marginal posterior of a coefficient is a Student-t with
+\eqn{\nu = 2 a_n} degrees of freedom. Under the textbook conjugate update
+\eqn{a_n = a_0 + n/2}, so \eqn{\nu} depends on the sample size \strong{only} and
+never on the number of coefficients \eqn{p}. With the weak default prior
+(\eqn{\Lambda_0 = 0.01 I}) that overstates precision: the reference-prior
+answer, and the one \code{lm()} gives, is \eqn{n - p}. The discrepancy is exactly
+a factor \eqn{\sqrt{n/(n-p)}} on the posterior scale, so it grows with
+\eqn{p/n} and bites hardest on interaction designs at small \eqn{n}.
+\itemize{
+\item \code{"conjugate"} (default): \eqn{a_n = a_0 + n/2}. The literal conjugate
+result; correct given the stated prior, but optimistic when that prior was
+only ever meant to be uninformative.
+\item \code{"residual"}: \eqn{a_n = a_0 + (n - p)/2}. Reproduces the classical
+\eqn{t_{n-p}} marginal, matching \code{lm()} confidence intervals as
+\eqn{\Lambda_0 \to 0}. Recommended whenever the prior is weak and
+\eqn{p > 1}.
+}
+
+Regions with \code{n <= p} carry no residual information about
+\eqn{\sigma^2}: the residuals are identically zero, \code{b_n} collapses to
+\code{b0}, and the posterior scale collapses with it. Such regions are dropped
+(\code{error = "n <= number of coefficients"}) under \strong{both} modes rather than
+returned with a spuriously tight interval.
+}
+
\keyword{internal}
diff --git a/man/map_probes_to_features.Rd b/man/map_probes_to_features.Rd
index 41dc1fa..7a6e44e 100644
--- a/man/map_probes_to_features.Rd
+++ b/man/map_probes_to_features.Rd
@@ -11,7 +11,12 @@ map_probes_to_features(se, features, min_probes = 3L)
non-empty \code{rowRanges()}.}
\item{features}{A \link[GenomicRanges:GRanges-class]{GenomicRanges::GRanges} of regions. If \code{names(features)}
-is \code{NULL} or empty, IDs \verb{region_1, region_2, ...} are generated.}
+is \code{NULL} or empty, IDs \verb{region_1, region_2, ...} are generated.
+Several ranges may share one name: this is the only way to define a
+region as an exact set of probes, since a single bounding interval would
+sweep in neighbours. Such ranges are collapsed into one region, so
+\code{length(features)} counts \emph{ranges} while the region counts below count
+distinct IDs.}
\item{min_probes}{Integer. Regions with fewer overlapping probes are
dropped. Default \code{3L}.}
@@ -23,8 +28,8 @@ A \code{data.frame} with (at minimum) columns
\itemize{
\item \code{dropped_regions} : character vector of region IDs excluded.
\item \code{min_probes} : the threshold applied.
-\item \code{n_features_in} : regions supplied.
-\item \code{n_features_out} : regions retained.
+\item \code{n_features_in} : distinct region IDs supplied (not ranges).
+\item \code{n_features_out} : distinct region IDs retained.
}
}
\description{
@@ -37,3 +42,18 @@ format). Probes with no region are silently excluded from the returned
mapping. Regions excluded by \code{min_probes} (including those with zero
overlaps) are recorded on \code{attr(mapping, "dropped_regions")}.
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+mapping <- map_probes_to_features(se, reg)
+head(mapping)
+nrow(mapping)
+
+# Regions carrying fewer than `min_probes` probes are dropped and
+# recorded in an attribute rather than silently disappearing.
+attr(mapping, "dropped_regions")
+}
diff --git a/man/plot_feature_set.Rd b/man/plot_feature_set.Rd
index a1a3976..efcb312 100644
--- a/man/plot_feature_set.Rd
+++ b/man/plot_feature_set.Rd
@@ -19,3 +19,17 @@ Bar chart of classification counts across all fitted regions. When
\code{feature_class_col} is supplied, bars are stacked by feature class so users
can see, e.g., how PRC / CGI / LAD subsets partition into hyper vs. hypo.
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+# `feature_class_col` names a column of the fit's probe-to-region
+# mapping, so it has to be passed to fit_bread() first.
+fit <- fit_bread(se_ctrl, reg, ~ passage,
+ feature_class_col = "feature_class")
+
+plot_feature_set(fit, feature_class_col = "feature_class")
+}
diff --git a/man/plot_region_data.Rd b/man/plot_region_data.Rd
index c465848..18b8021 100644
--- a/man/plot_region_data.Rd
+++ b/man/plot_region_data.Rd
@@ -18,3 +18,19 @@ A \link[ggplot2:ggplot]{ggplot2::ggplot} object.
Boxplot + jitter of the summarized region values for a single region,
grouped by the first variable in the design formula.
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+fit <- fit_bread(se_ctrl, reg, ~ passage)
+
+res <- results(fit)
+rid <- res$region_id[which(res$classification == "hypermethylated")[1]]
+if (is.na(rid)) rid <- res$region_id[1]
+
+# `region_id` must be a single region.
+plot_region_data(fit, rid)
+}
diff --git a/man/plot_region_posterior.Rd b/man/plot_region_posterior.Rd
index 5d6088a..5123495 100644
--- a/man/plot_region_posterior.Rd
+++ b/man/plot_region_posterior.Rd
@@ -25,3 +25,21 @@ for one or more regions, with vertical guides at \code{0} and \verb{+/- delta} a
color by final classification. When multiple regions are supplied the plot
facets by region.
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+fit <- fit_bread(se_ctrl, reg, ~ passage,
+ feature_class_col = "feature_class")
+
+# Pick a region that was actually called hypermethylated; passing
+# `region_id = NULL` would facet every region in the fit.
+res <- results(fit)
+rid <- res$region_id[which(res$classification == "hypermethylated")[1]]
+if (is.na(rid)) rid <- res$region_id[1]
+
+plot_region_posterior(fit, region_id = rid)
+}
diff --git a/man/posterior_draws.Rd b/man/posterior_draws.Rd
index d825fd3..18cbb82 100644
--- a/man/posterior_draws.Rd
+++ b/man/posterior_draws.Rd
@@ -27,3 +27,19 @@ A long \code{data.frame} with columns \code{region_id}, \code{draw}, \code{value
Samples are drawn from the marginal scaled Student-t posterior of the
contrast coefficient, \verb{beta ~ mu_n + scale * t_\{2 a_n\}}.
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+fit <- fit_bread(se_ctrl, reg, ~ passage)
+
+# Always name the region(s) you want -- the default (NULL) draws from
+# every region, which is `n` x n_regions rows.
+rid <- results(fit)$region_id[1]
+d <- posterior_draws(fit, region_id = rid, n = 500L, seed = 1L)
+head(d)
+quantile(d$value, c(0.025, 0.5, 0.975))
+}
diff --git a/man/posterior_summary.Rd b/man/posterior_summary.Rd
index e52a5ef..2ab8327 100644
--- a/man/posterior_summary.Rd
+++ b/man/posterior_summary.Rd
@@ -4,20 +4,33 @@
\alias{posterior_summary}
\title{Extract per-region posterior summaries}
\usage{
-posterior_summary(fit, delta = 0.1, ci = 0.95)
+posterior_summary(fit, delta = 0.1, ci = 0.95, ref_beta = NULL)
}
\arguments{
-\item{fit}{Output of \code{\link[=fit_bread_summary]{fit_bread_summary()}} or \code{\link[=fit_bread_brms]{fit_bread_brms()}}.}
+\item{fit}{A \link{BreadFit} (as returned by \code{\link[=fit_bread]{fit_bread()}}), or the internal
+model list from \code{\link[=fit_bread_summary]{fit_bread_summary()}} / \code{\link[=fit_bread_brms]{fit_bread_brms()}}.}
\item{delta}{Effect-size threshold on the M-value scale. Default \code{0.10}.}
\item{ci}{Credible-interval mass. Default \code{0.95}.}
+
+\item{ref_beta}{Reference methylation level for the beta-scale columns.
+\code{NULL} (default) derives it per region from the fitted region matrix.
+Otherwise a single value applied to every region, or a numeric vector
+named by \code{region_id}. Values must lie in (0, 1).}
}
\value{
A \code{data.frame} with one row per region and columns:
\code{region_id}, \code{n}, \code{mean_effect}, \code{median_effect}, \code{ci_lo}, \code{ci_hi},
-\code{df}, \code{scale}, \code{p_pos}, \code{p_neg}, \code{p_gt_delta}, \code{p_lt_neg_delta}, \code{error}.
-\code{df} is \code{NA_real_} for the empirical path.
+\code{df}, \code{scale}, \code{prob_pos}, \code{prob_neg}, \code{prob_hyper}, \code{prob_hypo},
+\code{prob_rope}, \code{ref_beta}, \code{mean_dbeta}, \code{dbeta_lo}, \code{dbeta_hi},
+\code{delta_beta}, \code{error}.
+\code{n} is the number of \strong{samples} contributing to the region's fit
+after dropping NAs -- not the number of probes, which is carried
+per region in the \code{n_probes} column of the fit's \code{mapping}.
+\code{df} is \code{NA_real_} for the empirical path. The beta-scale columns are
+\code{NA_real_} when no region matrix is available, or when
+\code{summary_fun = "pc1"} (PC1 scores are not M-values).
}
\description{
Given the output of \code{\link[=fit_bread_summary]{fit_bread_summary()}} (conjugate backend) or
@@ -39,3 +52,56 @@ computed from the MCMC draws directly.
Columns in the returned data frame are the same in both cases.
}
+\section{Equivalence (\code{prob_rope})}{
+
+\code{prob_hyper}, \code{prob_hypo} and \code{prob_rope} are mutually exclusive and
+exhaustive: they are the posterior mass above \code{+delta}, below \code{-delta}, and
+inside the region of practical equivalence \eqn{[-\delta, +\delta]}, and
+they sum to 1. \code{prob_rope} is what lets BREAD state that a region is
+\emph{confidently unchanged} rather than merely undetected — a claim no p-value
+can make. See \code{\link[=classify_regions]{classify_regions()}}.
+}
+
+\section{Beta-scale columns}{
+
+BREAD models M-values, but reports a beta-scale translation of the effect
+and the ROPE half-width via the local linearisation
+\eqn{d\beta \approx dM \cdot \beta(1-\beta)\ln 2}, anchored per region at
+\code{ref_beta}. By default \code{ref_beta} is the region's own mean methylation,
+back-transformed from the mean M-value — well defined for every design and
+contrast type, unlike the reference level of a factor. The same multiplier
+is applied to the effect, both interval bounds and \code{delta}, so the
+beta-scale comparison can never contradict the M-scale classification
+beside it. See \code{\link[=bread_delta_beta]{bread_delta_beta()}}.
+
+Being a first-order expansion, this is exact only in the limit of small
+effects, and it overstates \verb{|mean_dbeta|} for large ones. Measured on
+the packaged vitamin C example (493 regions), the deviation from an
+exact back-transform of the same posterior mean has median 0.0005 and
+99th percentile 0.021 in beta units; relative error is ~2\% for
+\verb{|mean_effect| < 0.25} but ~10\% above 0.5. Regions whose effects are
+that large are unambiguous on the M scale anyway, so the approximation
+does not affect any call -- but do not quote \code{mean_dbeta} to three
+decimal places for a strongly changing region. Back-transform the
+endpoints yourself when the exact beta magnitude is the claim.
+}
+
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+fit <- fit_bread(se_ctrl, reg, ~ passage)
+
+# A BreadFit is accepted directly; the internal model list also works.
+post <- posterior_summary(fit)
+head(post)
+
+# A wider credible interval
+head(posterior_summary(fit, ci = 0.99))
+
+# Posterior mass inside the region of practical equivalence
+summary(posterior_summary(fit)$prob_rope)
+}
diff --git a/man/refit_bread.Rd b/man/refit_bread.Rd
new file mode 100644
index 0000000..de208a5
--- /dev/null
+++ b/man/refit_bread.Rd
@@ -0,0 +1,103 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/refit.R
+\name{refit_bread}
+\alias{refit_bread}
+\title{Re-fit or re-threshold an existing BreadFit}
+\usage{
+refit_bread(
+ fit,
+ colData = NULL,
+ design = NULL,
+ contrast = NULL,
+ delta = NULL,
+ prob_cutoff = NULL,
+ rope_cutoff = NULL,
+ ci = NULL,
+ ref_beta = NULL,
+ prior = NULL,
+ backend = NULL,
+ ...
+)
+}
+\arguments{
+\item{fit}{A \link{BreadFit} from \code{\link[=fit_bread]{fit_bread()}}.}
+
+\item{colData}{Replacement sample metadata, with one row per column of the
+region matrix. If it has rownames they are matched and reordered against
+the matrix columns.}
+
+\item{design}{Replacement one-sided design formula.}
+
+\item{contrast}{Replacement coefficient name.}
+
+\item{delta, prob_cutoff, rope_cutoff, ci, ref_beta}{Replacement posterior and
+classification settings. See \code{\link[=fit_bread]{fit_bread()}}.}
+
+\item{prior}{Replacement \code{\link[=bread_prior]{bread_prior()}} (conjugate backend only).}
+
+\item{backend}{Replacement backend. Note that a \code{"brms"} refit recompiles
+the Stan model; use \code{"conjugate"} for permutation work.}
+
+\item{...}{Passed to the brms backend when \code{backend = "brms"}.}
+}
+\value{
+A new \link{BreadFit}. The \code{mapping}, \code{features}, \code{mode}, \code{assay_name}
+and \code{input_scale} slots are carried over unchanged; \code{diagnostics} gains a
+\code{refit_of} timestamp naming the parent fit.
+}
+\description{
+Repeats the modelling step of \code{\link[=fit_bread]{fit_bread()}} on a fit you already have,
+reusing its region-by-sample matrix. Probe-to-region mapping and region
+summarization — by far the expensive parts — are never repeated.
+}
+\details{
+Every argument defaults to \code{NULL}, meaning "keep what the original fit
+used". Supply only what changes.
+}
+\section{Why this exists}{
+
+Label-permutation calibration is the natural way to check a posterior at
+small n: shuffle the group labels a few hundred times and see where the
+observed effect falls in the resulting null. That needs the region matrix
+computed once and only the fit repeated. Without a public entry point the
+only route was \code{BREAD:::fit_bread_summary()}, which is exactly the sort of
+thing users should not have to reach for.
+
+\if{html}{\out{}}\preformatted{nulls <- vapply(permutations, function(g) \{
+ cd <- coldata; cd$genotype <- g
+ results(refit_bread(fit, colData = cd))$prob_hyper[i]
+\}, numeric(1))
+}\if{html}{\out{
}}
+}
+
+\section{Re-thresholding is free}{
+
+When only \code{delta}, \code{prob_cutoff}, \code{rope_cutoff}, \code{ci} or \code{ref_beta} change,
+the model is not re-fitted at all — the stored posterior is re-summarized
+and re-classified. So sweeping a delta x cutoff grid costs essentially
+nothing.
+}
+
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+fit <- fit_bread(se_ctrl, reg, ~ passage)
+
+# Re-threshold without re-fitting anything
+table(results(refit_bread(fit, delta = 0.25))$classification)
+
+# Relax only the equivalence bar
+table(results(refit_bread(fit, rope_cutoff = 0.80))$classification)
+
+# Re-fit against shuffled labels (one draw from a permutation null)
+cd <- as.data.frame(colData(se_ctrl))
+cd$passage <- sample(cd$passage)
+head(results(refit_bread(fit, colData = cd))$prob_hyper)
+}
+\seealso{
+\code{\link[=fit_bread]{fit_bread()}}, \code{\link[=posterior_summary]{posterior_summary()}}, \code{\link[=classify_regions]{classify_regions()}}
+}
diff --git a/man/report_feature_set.Rd b/man/report_feature_set.Rd
deleted file mode 100644
index a93b356..0000000
--- a/man/report_feature_set.Rd
+++ /dev/null
@@ -1,21 +0,0 @@
-% Generated by roxygen2: do not edit by hand
-% Please edit documentation in R/report.R
-\name{report_feature_set}
-\alias{report_feature_set}
-\title{Feature-set level summary report}
-\usage{
-report_feature_set(fit, feature_class_col = NULL)
-}
-\arguments{
-\item{fit}{A \link{BreadFit}.}
-
-\item{feature_class_col}{Column in \code{mcols(features)} defining feature class.}
-}
-\value{
-A data frame with one row per feature class.
-}
-\description{
-Aggregates region-level classifications into a feature-set / feature-class
-summary (counts and proportions of hyper / hypo / inconclusive).
-}
-\keyword{internal}
diff --git a/man/results.Rd b/man/results.Rd
index 7b1d8b3..f5d1f62 100644
--- a/man/results.Rd
+++ b/man/results.Rd
@@ -20,3 +20,16 @@ A data frame with one row per region.
\description{
Extract the region-level results table from a \link{BreadFit}
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+fit <- fit_bread(se_ctrl, reg, ~ passage)
+
+res <- results(fit)
+head(res)
+colnames(res)
+}
diff --git a/man/summarize_features.Rd b/man/summarize_features.Rd
index fb7df65..957c2ee 100644
--- a/man/summarize_features.Rd
+++ b/man/summarize_features.Rd
@@ -50,3 +50,19 @@ M-value interpretation under \code{"pc1"}.
}
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+mapping <- map_probes_to_features(se_ctrl, reg)
+
+# As above, the packaged assay is "betas" on the beta scale; values
+# are converted to M-values internally before summarizing.
+mat <- summarize_features(se_ctrl, mapping,
+ assay_name = "betas", input_scale = "Beta")
+dim(mat)
+mat[1:3, 1:3]
+}
diff --git a/man/validate_bread_input.Rd b/man/validate_bread_input.Rd
index a38e12b..6c49c7d 100644
--- a/man/validate_bread_input.Rd
+++ b/man/validate_bread_input.Rd
@@ -37,3 +37,16 @@ coordinates, that \code{features} is a non-empty \link[GenomicRanges:GRanges-cla
that \code{assay_name} is present, and that \code{contrast} (when non-NULL) resolves
to a coefficient of the design's model matrix.
}
+\examples{
+suppressPackageStartupMessages(library(SummarizedExperiment))
+
+se <- readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+reg <- readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+se_ctrl <- se[, se$condition == "ctrl"]
+
+# The packaged data keeps beta values in an assay named "betas", so
+# both arguments are given explicitly here. fit_bread() detects them
+# for you; this lower-level helper does not.
+validate_bread_input(se_ctrl, reg, ~ passage,
+ assay_name = "betas", input_scale = "Beta")
+}
diff --git a/tests/testthat/helper-toy.R b/tests/testthat/helper-toy.R
index 6dd70b8..fbd69b6 100644
--- a/tests/testthat/helper-toy.R
+++ b/tests/testthat/helper-toy.R
@@ -15,7 +15,10 @@
cd <- S4Vectors::DataFrame(
group = factor(rep(c("young", "old"), length.out = n_samples),
levels = c("young", "old")),
- sex = factor(rep(c("F", "M"), length.out = n_samples),
+ # Period 4 against group's period 2, so `~ group + sex` is full rank.
+ # (When both alternated, the two were perfectly collinear and any design
+ # using both silently fell back on the prior.)
+ sex = factor(rep(c("F", "F", "M", "M"), length.out = n_samples),
levels = c("F", "M")),
row.names = colnames(m)
)
@@ -41,6 +44,24 @@
gr
}
+# Toy features where ONE region is defined by several disjoint ranges sharing
+# a name -- the only way to pin a region to an exact probe set, since a single
+# bounding interval would sweep in the probes between them.
+# regD = probes 1-3 (1..2500) + probes 8-10 (7001..9500) = 6 probes, 2 ranges.
+# regE = probes 16-20, 1 range. So: 2 distinct regions from 3 ranges.
+.make_toy_features_dup <- function() {
+ gr <- GenomicRanges::GRanges(
+ seqnames = "chr1",
+ ranges = IRanges::IRanges(
+ start = c( 1L, 7001L, 15001L),
+ end = c( 2500L, 9500L, 20000L)
+ ),
+ feature_class = c("PRC", "PRC", "LAD")
+ )
+ names(gr) <- c("regD", "regD", "regE")
+ gr
+}
+
# Toy SE with injected signal: regA becomes hyper, regC becomes hypo,
# in "old" vs "young" under ~ group. Sample size large enough for
# prob_cutoff = 0.95 classification to recover truth.
diff --git a/tests/testthat/test-classes.R b/tests/testthat/test-classes.R
new file mode 100644
index 0000000..10ddad1
--- /dev/null
+++ b/tests/testthat/test-classes.R
@@ -0,0 +1,57 @@
+# The S4 slot layout is a public contract: the plotting helpers and the
+# accessors reach into these slots by name. Pin them.
+
+test_that("BreadFit slots are the documented set, in order", {
+ expect_identical(
+ methods::slotNames("BreadFit"),
+ c("call", "params", "mode", "assay_name", "input_scale",
+ "mapping", "features", "model", "posterior", "results", "diagnostics")
+ )
+})
+
+test_that("BreadResults slots are the documented set", {
+ expect_identical(methods::slotNames("BreadResults"), c("table", "params"))
+})
+
+test_that("a fitted BreadFit populates every slot it promises", {
+ fit <- fit_bread(.make_toy_signal_se(), .make_toy_features(), ~ group)
+ expect_s4_class(fit, "BreadFit")
+ expect_true(is.call(fit@call))
+ expect_identical(fit@mode, "summary")
+ expect_type(fit@params, "list")
+ expect_type(fit@diagnostics, "list")
+ expect_s3_class(fit@mapping, "data.frame")
+ expect_s3_class(fit@results, "data.frame")
+ expect_s3_class(fit@posterior, "data.frame")
+ expect_type(fit@model, "list")
+ expect_identical(fit@diagnostics$backend, "conjugate")
+})
+
+test_that("BreadResults() round-trips the results table", {
+ fit <- fit_bread(.make_toy_signal_se(), .make_toy_features(), ~ group)
+ br <- BreadResults(fit)
+ expect_s4_class(br, "BreadResults")
+ expect_equal(methods::slot(br, "table"), results(fit))
+ expect_equal(methods::slot(br, "params"), fit@params)
+})
+
+test_that("BreadResults() rejects non-BreadFit input", {
+ expect_error(BreadResults(data.frame(x = 1)), "must be a BreadFit")
+ expect_error(BreadResults(NULL), "must be a BreadFit")
+})
+
+test_that("the model list shape is a stable contract", {
+ # refit_bread(), posterior_summary() and plot_region_data() all reach into
+ # these names. Pin them so a backend refactor cannot quietly break them.
+ fit <- suppressMessages(
+ fit_bread(.make_toy_signal_se(), .make_toy_features(), ~ group)
+ )
+ expect_identical(
+ names(fit@model),
+ c("fits", "design_matrix", "coef_names", "contrast", "contrast_idx",
+ "region_ids", "prior", "df_mode", "region_mat", "design", "coldata")
+ )
+ expect_identical(fit@model$df_mode, "conjugate")
+ expect_true(is.matrix(fit@model$region_mat))
+ expect_identical(rownames(fit@model$region_mat), fit@model$region_ids)
+})
diff --git a/tests/testthat/test-classify.R b/tests/testthat/test-classify.R
new file mode 100644
index 0000000..9176389
--- /dev/null
+++ b/tests/testthat/test-classify.R
@@ -0,0 +1,175 @@
+# classify_regions() applies the decision rule; it does not recompute
+# posterior probabilities. These tests drive it with hand-built posterior
+# tables so the boundaries are exact rather than approximate.
+#
+# prob_hyper, prob_hypo and prob_rope partition the posterior, so fixtures
+# must sum to 1 -- an incoherent triple can exercise states the sampler can
+# never reach and would let a real bug hide.
+
+`%|NULL|%` <- function(a, b) if (is.null(a)) b else a
+
+.fake_post <- function(p_gt, p_lt, p_rope = NULL, ids = NULL) {
+ n <- length(p_gt)
+ out <- data.frame(
+ region_id = ids %|NULL|% sprintf("r%02d", seq_len(n)),
+ prob_hyper = p_gt,
+ prob_hypo = p_lt,
+ stringsAsFactors = FALSE
+ )
+ if (!is.null(p_rope)) out$prob_rope <- p_rope
+ out
+}
+
+# Uniform draws from the 2-simplex (Dirichlet(1,1,1)).
+.coherent_post <- function(n, seed = 42L) {
+ withr::with_seed(seed, {
+ e <- matrix(stats::rexp(3L * n), ncol = 3L)
+ p <- e / rowSums(e)
+ })
+ .fake_post(p[, 1L], p[, 2L], p[, 3L])
+}
+
+
+test_that("classification levels are exact and in a fixed order", {
+ out <- classify_regions(
+ .fake_post(c(0.99, 0.01, 0.02, 0.34),
+ c(0.01, 0.99, 0.02, 0.33),
+ c(0.00, 0.00, 0.96, 0.33))
+ )
+ expect_s3_class(out$classification, "factor")
+ expect_identical(
+ levels(out$classification),
+ c("hypermethylated", "hypomethylated", "unchanged", "inconclusive")
+ )
+ expect_identical(
+ as.character(out$classification),
+ c("hypermethylated", "hypomethylated", "unchanged", "inconclusive")
+ )
+})
+
+test_that("the motivating case is called unchanged, not inconclusive", {
+ # REGULON_promoter from the mousearray_609G CArG analysis: the posterior
+ # sits almost entirely inside the ROPE. Before the fourth level existed
+ # this was labelled `inconclusive`, indistinguishable from a region whose
+ # posterior spanned the whole line.
+ out <- classify_regions(.fake_post(0.021, 0.021, 0.958), prob_cutoff = 0.95)
+ expect_identical(as.character(out$classification), "unchanged")
+})
+
+test_that("the cutoff comparison is inclusive (>=), not strict", {
+ at <- classify_regions(.fake_post(0.95, 0.0, 0.05), prob_cutoff = 0.95)
+ below <- classify_regions(.fake_post(0.95 - 1e-12, 0.0, 0.05),
+ prob_cutoff = 0.95)
+ expect_identical(as.character(at$classification), "hypermethylated")
+ expect_identical(as.character(below$classification), "inconclusive")
+
+ rope_at <- classify_regions(.fake_post(0.02, 0.03, 0.95),
+ rope_cutoff = 0.95)
+ rope_below <- classify_regions(.fake_post(0.02, 0.03, 0.95 - 1e-12),
+ rope_cutoff = 0.95)
+ expect_identical(as.character(rope_at$classification), "unchanged")
+ expect_identical(as.character(rope_below$classification), "inconclusive")
+})
+
+test_that("at sane cutoffs the four classes are mutually exclusive", {
+ post <- .coherent_post(10000L)
+ cls <- classify_regions(post, prob_cutoff = 0.95,
+ rope_cutoff = 0.95)$classification
+ n_clearing <- (post$prob_hyper >= 0.95) +
+ (post$prob_hypo >= 0.95) +
+ (post$prob_rope >= 0.95)
+ expect_true(all(n_clearing <= 1L))
+ expect_false(any(is.na(cls)))
+ # Every row that clears something is labelled, and nothing else is
+ expect_identical(cls != "inconclusive", n_clearing == 1L)
+})
+
+test_that("tightening both bars only ever moves regions to inconclusive", {
+ post <- .coherent_post(500L)
+ loose <- classify_regions(post, prob_cutoff = 0.60)$classification
+ strict <- classify_regions(post, prob_cutoff = 0.99)$classification
+ moved <- loose != strict
+ expect_true(all(strict[moved] == "inconclusive"))
+})
+
+test_that("rope_cutoff moves only the equivalence bar", {
+ post <- .coherent_post(500L)
+ base <- classify_regions(post, prob_cutoff = 0.95)$classification
+ looser <- classify_regions(post, prob_cutoff = 0.95,
+ rope_cutoff = 0.70)$classification
+ # Directional calls are untouched ...
+ dir_base <- base %in% c("hypermethylated", "hypomethylated")
+ dir_loose<- looser %in% c("hypermethylated", "hypomethylated")
+ expect_identical(dir_base, dir_loose)
+ # ... and the only movement is inconclusive -> unchanged
+ moved <- base != looser
+ expect_true(all(base[moved] == "inconclusive"))
+ expect_true(all(looser[moved] == "unchanged"))
+ expect_true(any(moved))
+
+ # It defaults to prob_cutoff
+ expect_identical(
+ classify_regions(post, prob_cutoff = 0.8)$classification,
+ classify_regions(post, prob_cutoff = 0.8, rope_cutoff = 0.8)$classification
+ )
+})
+
+test_that("failed fits (NA probabilities) become inconclusive, never NA", {
+ out <- classify_regions(.fake_post(c(NA, 0.99), c(NA, 0.001)))
+ expect_false(any(is.na(out$classification)))
+ expect_identical(as.character(out$classification)[1], "inconclusive")
+})
+
+test_that("an NA posterior is never called unchanged", {
+ # A missing posterior is absence of evidence, not evidence of equivalence.
+ out <- classify_regions(.fake_post(NA_real_, NA_real_, 1.0))
+ expect_identical(as.character(out$classification), "inconclusive")
+})
+
+test_that("when several classes clear a low cutoff the largest wins", {
+ out <- classify_regions(
+ .fake_post(c(0.40, 0.31, 0.31), c(0.31, 0.40, 0.31), c(0.29, 0.29, 0.38)),
+ prob_cutoff = 0.30, rope_cutoff = 0.30
+ )
+ expect_identical(as.character(out$classification),
+ c("hypermethylated", "hypomethylated", "unchanged"))
+
+ # Exact ties resolve hyper > hypo > unchanged, as before the fourth level
+ tie <- classify_regions(.fake_post(1/3, 1/3, 1/3), prob_cutoff = 0.30)
+ expect_identical(as.character(tie$classification), "hypermethylated")
+})
+
+test_that("prob_rope is derived when absent and clamped when incoherent", {
+ # Hand-built two-column tables remain a legitimate input
+ derived <- classify_regions(.fake_post(0.01, 0.01), rope_cutoff = 0.95)
+ expect_identical(as.character(derived$classification), "unchanged")
+
+ # hyper + hypo > 1 cannot happen from a real posterior, but must not
+ # produce a negative ROPE mass or an NA class
+ bad <- classify_regions(.fake_post(0.8, 0.8), prob_cutoff = 0.95)
+ expect_identical(as.character(bad$classification), "inconclusive")
+
+ # A supplied prob_rope is authoritative over the complement
+ supplied <- classify_regions(.fake_post(0.01, 0.01, 0.10),
+ rope_cutoff = 0.95)
+ expect_identical(as.character(supplied$classification), "inconclusive")
+})
+
+test_that("delta and both cutoffs are recorded as attributes", {
+ out <- classify_regions(.fake_post(0.99, 0.0), delta = 0.25,
+ prob_cutoff = 0.9, rope_cutoff = 0.7)
+ expect_equal(attr(out, "delta"), 0.25)
+ expect_equal(attr(out, "prob_cutoff"), 0.9)
+ expect_equal(attr(out, "rope_cutoff"), 0.7)
+})
+
+test_that("bad input is rejected with an informative error", {
+ expect_error(classify_regions("nope"), "data.frame")
+ expect_error(classify_regions(data.frame(x = 1)), "missing required columns")
+ expect_error(classify_regions(.fake_post(0.9, 0.1), prob_cutoff = 1),
+ "`prob_cutoff` must be in \\(0, 1\\)")
+ expect_error(classify_regions(.fake_post(0.9, 0.1), prob_cutoff = 0),
+ "`prob_cutoff` must be in \\(0, 1\\)")
+ expect_error(classify_regions(.fake_post(0.9, 0.1), rope_cutoff = 1),
+ "`rope_cutoff` must be in \\(0, 1\\)")
+})
diff --git a/tests/testthat/test-coerce.R b/tests/testthat/test-coerce.R
new file mode 100644
index 0000000..b8b56cd
--- /dev/null
+++ b/tests/testthat/test-coerce.R
@@ -0,0 +1,187 @@
+# Matrix input has to reach exactly the same answer as SummarizedExperiment
+# input -- otherwise the sesame workflow is a second, subtly different code
+# path rather than a convenience.
+
+.toy_parts <- function() {
+ se <- .make_toy_signal_se()
+ list(
+ se = se,
+ mat = SummarizedExperiment::assay(se, "M"),
+ cd = as.data.frame(SummarizedExperiment::colData(se)),
+ gr = SummarizedExperiment::rowRanges(se)
+ )
+}
+
+test_that("matrix input and SE input give identical results", {
+ p <- .toy_parts()
+ gr_feat <- .make_toy_features()
+
+ fit_se <- suppressMessages(fit_bread(p$se, gr_feat, ~ group))
+ fit_m <- suppressMessages(
+ fit_bread(p$mat, gr_feat, ~ group, colData = p$cd, rowRanges = p$gr)
+ )
+
+ expect_equal(results(fit_m), results(fit_se))
+ expect_equal(fit_m@posterior, fit_se@posterior)
+ expect_equal(fit_m@mapping, fit_se@mapping)
+ expect_identical(fit_m@input_scale, fit_se@input_scale)
+})
+
+test_that("bread_se() round-trips a decomposed SummarizedExperiment", {
+ p <- .toy_parts()
+ se2 <- bread_se(p$mat, colData = p$cd, rowRanges = p$gr)
+ expect_s4_class(se2, "SummarizedExperiment")
+ expect_equal(SummarizedExperiment::assay(se2, "M"), p$mat)
+ expect_equal(nrow(SummarizedExperiment::colData(se2)), ncol(p$mat))
+ expect_identical(names(SummarizedExperiment::rowRanges(se2)), rownames(p$mat))
+})
+
+test_that("a sesameData-style list is unwrapped", {
+ p <- .toy_parts()
+ se2 <- bread_se(list(betas = p$mat, sampleInfo = p$cd), rowRanges = p$gr)
+ expect_s4_class(se2, "SummarizedExperiment")
+ expect_true("group" %in% colnames(SummarizedExperiment::colData(se2)))
+})
+
+test_that("the assay is named from the value range, as fit_bread expects", {
+ p <- .toy_parts()
+ se_m <- bread_se(p$mat, colData = p$cd, rowRanges = p$gr)
+ expect_identical(SummarizedExperiment::assayNames(se_m), "M")
+
+ betas <- 2^p$mat / (2^p$mat + 1)
+ se_b <- bread_se(betas, colData = p$cd, rowRanges = p$gr)
+ expect_identical(SummarizedExperiment::assayNames(se_b), "betas")
+})
+
+
+# ---- rowRanges alignment ---------------------------------------------------
+
+test_that("a named manifest is subset and reordered to the matrix rows", {
+ p <- .toy_parts()
+ shuffled <- p$gr[sample(length(p$gr))]
+ extra <- p$gr[1:3]
+ names(extra) <- paste0("zz", 1:3)
+ manifest <- c(shuffled, extra) # longer, out of order
+
+ se2 <- bread_se(p$mat, colData = p$cd, rowRanges = manifest)
+ expect_identical(names(SummarizedExperiment::rowRanges(se2)), rownames(p$mat))
+ expect_equal(SummarizedExperiment::rowRanges(se2), p$gr)
+})
+
+test_that("probes absent from the manifest are dropped with a message", {
+ p <- .toy_parts()
+ partial <- p$gr[1:15] # 5 probes have no coordinates
+ expect_message(
+ se2 <- bread_se(p$mat, colData = p$cd, rowRanges = partial),
+ "Dropping 5 of 20 probes"
+ )
+ expect_equal(nrow(se2), 15L)
+})
+
+test_that("an entirely mismatched manifest errors rather than dropping all", {
+ p <- .toy_parts()
+ wrong <- p$gr
+ names(wrong) <- paste0("nope", seq_along(wrong))
+ expect_error(bread_se(p$mat, colData = p$cd, rowRanges = wrong),
+ "None of `rownames\\(x\\)`")
+})
+
+test_that("an unnamed rowRanges must match the row count exactly", {
+ p <- .toy_parts()
+ unnamed <- p$gr; names(unnamed) <- NULL
+ se2 <- bread_se(p$mat, colData = p$cd, rowRanges = unnamed)
+ expect_identical(names(SummarizedExperiment::rowRanges(se2)), rownames(p$mat))
+
+ expect_error(bread_se(p$mat, colData = p$cd, rowRanges = unnamed[1:5]),
+ "one range per row")
+})
+
+
+# ---- colData alignment -----------------------------------------------------
+
+test_that("colData is reordered by rownames, not trusted positionally", {
+ p <- .toy_parts()
+ shuffled <- p$cd[sample(nrow(p$cd)), , drop = FALSE]
+
+ ordered <- bread_se(p$mat, colData = p$cd, rowRanges = p$gr)
+ reorder <- bread_se(p$mat, colData = shuffled, rowRanges = p$gr)
+ expect_equal(SummarizedExperiment::colData(reorder),
+ SummarizedExperiment::colData(ordered))
+})
+
+test_that("colData that does not cover every sample errors", {
+ p <- .toy_parts()
+ expect_error(
+ bread_se(p$mat, colData = p$cd[1:4, , drop = FALSE], rowRanges = p$gr),
+ "no row for"
+ )
+})
+
+test_that("colData without rownames is accepted but warns", {
+ p <- .toy_parts()
+ bare <- p$cd; rownames(bare) <- NULL
+ expect_warning(bread_se(p$mat, colData = bare, rowRanges = p$gr),
+ "assuming its rows are in the same order")
+})
+
+
+# ---- refusals --------------------------------------------------------------
+
+test_that("coordinates are never guessed from probe IDs", {
+ p <- .toy_parts()
+ expect_error(bread_se(p$mat, colData = p$cd), "does not guess the platform")
+})
+
+test_that("matrix-only arguments are rejected alongside an SE", {
+ p <- .toy_parts()
+ expect_error(bread_se(p$se, colData = p$cd), "supplied alongside")
+ expect_error(bread_se(p$se, rowRanges = p$gr), "supplied alongside")
+ expect_error(bread_se(p$se, platform = "EPIC"),"supplied alongside")
+ expect_s4_class(bread_se(p$se), "SummarizedExperiment")
+})
+
+test_that("a matrix without dimnames is rejected", {
+ p <- .toy_parts()
+ m <- p$mat; rownames(m) <- NULL
+ expect_error(bread_se(m, colData = p$cd, rowRanges = p$gr), "rownames")
+
+ m2 <- p$mat; colnames(m2) <- NULL
+ expect_error(bread_se(m2, colData = p$cd, rowRanges = p$gr), "colnames")
+})
+
+test_that("unsupported input still names SummarizedExperiment in the error", {
+ # test-smoke.R relies on this string
+ expect_error(bread_se(NULL), "SummarizedExperiment")
+ expect_error(fit_bread(NULL, NULL, ~ 1), "SummarizedExperiment")
+})
+
+test_that("the platform route reaches sesameData", {
+ skip_on_cran()
+ skip_on_ci()
+ skip_if_not_installed("sesameData")
+ # Probe IDs are taken from the manifest itself, so this tests the lookup
+ # rather than whether some other dataset happens to share its ID
+ # convention. (The packaged vitc subset does not: it carries stripped
+ # EPICv2 IDs while the manifest keeps the replicate suffix.)
+ man <- sesameData::sesameData_getManifestGRanges("EPICv2")
+ skip_if(length(man) == 0L, "EPICv2 manifest unavailable offline")
+
+ ids <- names(man)[seq_len(50L)]
+ mat <- matrix(stats::runif(50L * 4L), nrow = 50L,
+ dimnames = list(ids, sprintf("S%d", 1:4)))
+ cd <- data.frame(group = rep(c("a", "b"), 2), row.names = colnames(mat))
+
+ se2 <- suppressMessages(bread_se(mat, colData = cd, platform = "EPICv2"))
+ expect_s4_class(se2, "SummarizedExperiment")
+ expect_equal(nrow(se2), 50L)
+ expect_identical(names(SummarizedExperiment::rowRanges(se2)), ids)
+ expect_identical(SummarizedExperiment::assayNames(se2), "betas")
+})
+
+test_that("stripped EPICv2 suffixes get a specific diagnosis", {
+ p <- .toy_parts()
+ manifest <- p$gr
+ names(manifest) <- paste0(names(manifest), "_BC11")
+ expect_error(bread_se(p$mat, colData = p$cd, rowRanges = manifest),
+ "EPICv2 replicate suffixes")
+})
diff --git a/tests/testthat/test-df-mode.R b/tests/testthat/test-df-mode.R
new file mode 100644
index 0000000..f061b7f
--- /dev/null
+++ b/tests/testthat/test-df-mode.R
@@ -0,0 +1,154 @@
+# Degrees-of-freedom handling: the n <= p guard and the "residual" mode.
+#
+# Motivation: `a_n = a0 + n/2` makes nu = 2*a_n a function of n alone, never of
+# p. Under the weak default prior that overstates precision by exactly
+# sqrt(n / (n - p)) on the posterior scale, and at n == p the residuals are
+# identically zero so the scale collapses to the prior floor.
+
+# --- helpers ---------------------------------------------------------------
+
+# Region x sample matrix with a known design; y is pure noise unless `beta` set.
+sim_mat <- function(n_per_cell, n_regions = 5L, sd = 1, beta = 0, seed = 1L) {
+ set.seed(seed)
+ g <- rep(c("a", "b"), each = 2L * n_per_cell)
+ t <- rep(rep(c("x", "y"), each = n_per_cell), 2L)
+ cd <- data.frame(g = factor(g), t = factor(t))
+ X <- stats::model.matrix(~ g * t, cd)
+ k <- which(colnames(X) == "gb:ty")
+ eta <- as.numeric(X[, k]) * beta
+ m <- matrix(rep(eta, each = n_regions) + stats::rnorm(n_regions * nrow(cd), sd = sd),
+ nrow = n_regions, dimnames = list(paste0("R", seq_len(n_regions)), rownames(cd)))
+ list(mat = m, cd = cd, p = ncol(X), contrast = "gb:ty")
+}
+
+fit_one <- function(s, df_mode = "conjugate", prior = NULL) {
+ BREAD:::fit_bread_summary(s$mat, s$cd, ~ g * t, s$contrast,
+ prior = prior, df_mode = df_mode)
+}
+
+# --- the n <= p guard ------------------------------------------------------
+
+test_that("regions with n == p are dropped rather than fitted", {
+ s <- sim_mat(n_per_cell = 1L) # n = 4, p = 4
+ expect_identical(s$p, 4L)
+ f <- suppressWarnings(fit_one(s))
+ errs <- vapply(f$fits, function(z) z$error, character(1))
+ expect_true(all(errs == "n <= number of coefficients"))
+ expect_true(all(vapply(f$fits, function(z) is.na(z$a_n), logical(1))))
+})
+
+test_that("the n <= p guard applies under both df_mode settings", {
+ s <- sim_mat(n_per_cell = 1L)
+ for (dm in c("conjugate", "residual")) {
+ f <- suppressWarnings(fit_one(s, df_mode = dm))
+ expect_true(all(vapply(f$fits, function(z) z$error, character(1)) ==
+ "n <= number of coefficients"))
+ }
+})
+
+test_that("n < 2 still reports the pre-existing reason", {
+ s <- sim_mat(n_per_cell = 2L)
+ s$mat[1, ] <- NA_real_
+ s$mat[1, 1] <- 0.5 # a single non-NA sample
+ f <- suppressWarnings(fit_one(s))
+ expect_identical(f$fits[[1]]$error, "too few non-NA samples")
+})
+
+test_that("n > p fits normally and carries no error", {
+ s <- sim_mat(n_per_cell = 3L) # n = 12, p = 4
+ f <- fit_one(s)
+ expect_true(all(is.na(vapply(f$fits, function(z) z$error, character(1)))))
+})
+
+# --- low residual df warns once, not per region ---------------------------
+
+test_that("fewer than 3 residual df warns exactly once for the whole fit", {
+ s <- sim_mat(n_per_cell = 2L, n_regions = 10L) # n = 8, p = 4 -> n - p = 4
+ expect_silent(fit_one(s))
+
+ s2 <- sim_mat(n_per_cell = 2L, n_regions = 10L)
+ s2$cd$z <- factor(rep(c("u", "v"), length.out = nrow(s2$cd)))
+ # ~ g * t + z -> p = 5, n = 8, n - p = 3 -> still silent
+ f5 <- BREAD:::fit_bread_summary(s2$mat, s2$cd, ~ g * t + z, "gb:ty")
+ expect_true(is.list(f5$fits))
+
+ s3 <- sim_mat(n_per_cell = 2L, n_regions = 10L)
+ s3$mat[, 1:3] <- NA_real_ # n drops to 5, p = 4 -> n - p = 1
+ w <- capture_warnings(fit_one(s3))
+ expect_length(w, 1L)
+ expect_match(w, "residual degrees of freedom", fixed = FALSE)
+})
+
+test_that("the warning names df_mode = residual only in conjugate mode", {
+ s <- sim_mat(n_per_cell = 2L, n_regions = 4L)
+ s$mat[, 1:3] <- NA_real_
+ expect_match(capture_warnings(fit_one(s, df_mode = "conjugate")),
+ "df_mode")
+ expect_false(any(grepl("df_mode",
+ capture_warnings(fit_one(s, df_mode = "residual")))))
+})
+
+# --- residual mode reproduces the classical answer ------------------------
+
+test_that("df_mode = 'residual' matches lm() df and standard error", {
+ s <- sim_mat(n_per_cell = 4L, n_regions = 3L, seed = 7L) # n = 16, p = 4
+ # weak prior so the posterior should collapse onto OLS
+ pr <- bread_prior(lambda0 = 1e-8, a0 = 1e-8, b0 = 1e-8)
+ f <- fit_one(s, df_mode = "residual", prior = pr)
+ k <- f$contrast_idx
+
+ for (i in seq_len(nrow(s$mat))) {
+ ml <- stats::lm(s$mat[i, ] ~ g * t, data = s$cd)
+ fo <- f$fits[[i]]
+ scale_b <- sqrt((fo$b_n / fo$a_n) * fo$Lambda_n_inv[k, k])
+ expect_equal(2 * fo$a_n, ml$df.residual, tolerance = 1e-5)
+ expect_equal(scale_b, summary(ml)$coefficients[k, 2], tolerance = 1e-4)
+ expect_equal(fo$mu_n[k], unname(coef(ml)[k]), tolerance = 1e-5)
+ }
+})
+
+test_that("conjugate vs residual differ by exactly sqrt(n / (n - p))", {
+ s <- sim_mat(n_per_cell = 4L, n_regions = 3L, seed = 11L) # n = 16, p = 4
+ pr <- bread_prior(lambda0 = 1e-8, a0 = 1e-8, b0 = 1e-8)
+ fc <- fit_one(s, df_mode = "conjugate", prior = pr)
+ fr <- fit_one(s, df_mode = "residual", prior = pr)
+ k <- fc$contrast_idx
+
+ sc <- vapply(fc$fits, function(z) sqrt((z$b_n / z$a_n) * z$Lambda_n_inv[k, k]), 0)
+ sr <- vapply(fr$fits, function(z) sqrt((z$b_n / z$a_n) * z$Lambda_n_inv[k, k]), 0)
+ n <- 16L; p <- 4L
+ expect_equal(unname(sr / sc), rep(sqrt(n / (n - p)), length(sc)), tolerance = 1e-5)
+})
+
+test_that("residual mode widens credible intervals", {
+ s <- sim_mat(n_per_cell = 4L, n_regions = 5L, seed = 3L)
+ pr <- bread_prior(lambda0 = 1e-8, a0 = 1e-8, b0 = 1e-8)
+ pc <- posterior_summary(fit_one(s, "conjugate", pr))
+ prr <- posterior_summary(fit_one(s, "residual", pr))
+ expect_true(all((prr$ci_hi - prr$ci_lo) > (pc$ci_hi - pc$ci_lo)))
+ expect_equal(prr$mean_effect, pc$mean_effect, tolerance = 1e-6)
+})
+
+# --- end-to-end through fit_bread() ---------------------------------------
+
+test_that("fit_bread() accepts df_mode and records it in params", {
+ skip_if_not_installed("SummarizedExperiment")
+ se <- .make_toy_signal_se(); gr <- .make_toy_features()
+ fit_c <- fit_bread(se, gr, ~ group)
+ fit_r <- fit_bread(se, gr, ~ group, df_mode = "residual")
+
+ expect_identical(fit_c@params$df_mode, "conjugate")
+ expect_identical(fit_r@params$df_mode, "residual")
+
+ rc <- results(fit_c); rr <- results(fit_r)
+ ok <- is.na(rc$error) & is.na(rr$error)
+ expect_true(any(ok))
+ expect_true(all(rr$df[ok] < rc$df[ok]))
+ expect_true(all(rr$scale[ok] >= rc$scale[ok]))
+ expect_equal(rr$mean_effect[ok], rc$mean_effect[ok], tolerance = 1e-8)
+})
+
+test_that("df_mode is rejected when misspelled", {
+ se <- .make_toy_signal_se(); gr <- .make_toy_features()
+ expect_error(fit_bread(se, gr, ~ group, df_mode = "residuals"))
+})
diff --git a/tests/testthat/test-extdata-contract.R b/tests/testthat/test-extdata-contract.R
new file mode 100644
index 0000000..f74db98
--- /dev/null
+++ b/tests/testthat/test-extdata-contract.R
@@ -0,0 +1,80 @@
+# Every @examples block in this package is written against the two packaged
+# extdata objects. If their shape drifts -- an assay rename, a dropped
+# colData column, a change in the feature classes -- the examples break at
+# R CMD check time with an opaque error. These tests pin the contract so the
+# failure lands here instead, with a message that says what changed.
+
+.se <- function() {
+ readRDS(system.file("extdata", "vitc_ag06561.rds", package = "BREAD"))
+}
+.reg <- function() {
+ readRDS(system.file("extdata", "vitc_regions.rds", package = "BREAD"))
+}
+
+test_that("both extdata files are installed and loadable", {
+ expect_true(nzchar(system.file("extdata", "vitc_ag06561.rds",
+ package = "BREAD")))
+ expect_true(nzchar(system.file("extdata", "vitc_regions.rds",
+ package = "BREAD")))
+})
+
+test_that("the packaged SE has the assay name and scale the examples assume", {
+ se <- .se()
+ expect_s4_class(se, "RangedSummarizedExperiment")
+ expect_identical(SummarizedExperiment::assayNames(se), "betas")
+
+ x <- SummarizedExperiment::assay(se, "betas")
+ expect_true(all(x >= 0 & x <= 1, na.rm = TRUE))
+ # Examples rely on fit_bread() auto-detecting both of these.
+ expect_identical(BREAD:::.detect_input_scale(x), "Beta")
+ expect_identical(BREAD:::.detect_assay_name(se), "betas")
+})
+
+test_that("the colData columns the examples subset on are present", {
+ cd <- SummarizedExperiment::colData(.se())
+
+ expect_true(all(c("condition", "passage") %in% colnames(cd)))
+ expect_s3_class(cd$condition, "factor")
+ expect_s3_class(cd$passage, "factor")
+ expect_identical(levels(cd$condition), c("ctrl", "aa57"))
+ expect_identical(levels(cd$passage), c("early", "late"))
+
+ # `se[, se$condition == "ctrl"]` must leave both passage levels populated,
+ # otherwise `~ passage` is not estimable.
+ ctrl <- cd[cd$condition == "ctrl", , drop = FALSE]
+ expect_gt(nrow(ctrl), 0L)
+ expect_setequal(as.character(unique(ctrl$passage)), c("early", "late"))
+})
+
+test_that("the packaged regions carry the feature_class column", {
+ reg <- .reg()
+ expect_s4_class(reg, "GRanges")
+ expect_gt(length(reg), 0L)
+ expect_true("feature_class" %in% colnames(S4Vectors::mcols(reg)))
+ expect_false(is.null(names(reg)))
+ expect_identical(anyDuplicated(names(reg)), 0L)
+})
+
+test_that("regions and probes overlap at the example threshold", {
+ # If this fails, every example calling fit_bread() on the packaged data
+ # errors with "no regions retained".
+ mapping <- map_probes_to_features(.se(), .reg(), min_probes = 3L)
+ expect_gt(nrow(mapping), 0L)
+ expect_gt(length(unique(mapping$region_id)), 0L)
+ expect_true("feature_class" %in% colnames(mapping))
+})
+
+test_that("the documented example fit runs end to end", {
+ se <- .se()
+ reg <- .reg()
+ se_ctrl <- se[, se$condition == "ctrl"]
+
+ fit <- fit_bread(se_ctrl, reg, ~ passage,
+ feature_class_col = "feature_class")
+ expect_s4_class(fit, "BreadFit")
+
+ res <- results(fit)
+ expect_gt(nrow(res), 0L)
+ expect_true(all(c("region_id", "classification") %in% colnames(res)))
+ expect_true(any(!is.na(res$prob_hyper)))
+})
diff --git a/tests/testthat/test-fit-bread.R b/tests/testthat/test-fit-bread.R
index e8015bf..c183758 100644
--- a/tests/testthat/test-fit-bread.R
+++ b/tests/testthat/test-fit-bread.R
@@ -48,7 +48,7 @@ test_that("results() and classifications() accessors return expected objects", {
min_probes = 3L))
r <- results(fit)
expect_s3_class(r, "data.frame")
- expect_true(all(c("region_id","classification","p_gt_delta","p_lt_neg_delta")
+ expect_true(all(c("region_id","classification","prob_hyper","prob_hypo")
%in% colnames(r)))
cls <- classifications(fit)
expect_type(cls, "character")
diff --git a/tests/testthat/test-fit-brms.R b/tests/testthat/test-fit-brms.R
index 53a2e7c..3b50804 100644
--- a/tests/testthat/test-fit-brms.R
+++ b/tests/testthat/test-fit-brms.R
@@ -1,7 +1,14 @@
-# Slow: Stan compile + sampling. ~60s on HPC. Skipped on CRAN, on systems
-# without brms, and when $_R_CHECK_FORCE_SUGGESTS_ is FALSE and brms missing.
+# Slow: Stan compile + sampling. ~60s on HPC. Skipped on CRAN, on CI, and
+# on systems without a working brms/rstan Stan toolchain.
test_that("fit_bread(backend = 'brms') recovers injected signal", {
skip_on_cran()
+ # skip_on_ci() is load-bearing and not redundant with the guards below:
+ # GitHub runners install brms and rstan happily but lack the headers Stan
+ # needs to compile a model (RcppEigen), so the test cleared every
+ # skip_if_not_installed() and then died with "Eigen not found".
+ # Installing a full Stan toolchain per CI run costs ~10 min and is flaky.
+ # The test still runs on the HPC and anywhere CI is unset.
+ skip_on_ci()
skip_if_not_installed("brms")
skip_if_not_installed("rstan")
@@ -41,7 +48,7 @@ test_that("fit_bread(backend = 'brms') recovers injected signal", {
res <- results(fit)
expect_true(is.na(res$df[1L])) # df undefined for empirical path
expect_true(all(!is.na(res$mean_effect)))
- expect_true(all(res$p_pos + res$p_neg >= 0.999))
+ expect_true(all(res$prob_pos + res$prob_neg >= 0.999))
# posterior_draws returns actual MCMC draws (subsampled to n)
d <- posterior_draws(fit, region_id = "regA", n = 200L, seed = 1L)
diff --git a/tests/testthat/test-fit-summary.R b/tests/testthat/test-fit-summary.R
index 2f5ef22..3a22f9e 100644
--- a/tests/testthat/test-fit-summary.R
+++ b/tests/testthat/test-fit-summary.R
@@ -44,21 +44,27 @@ test_that("posterior_summary recovers sign and orders true effects", {
expect_true(all(post$mean_effect[5:6] < -0.25))
expect_true(all(abs(post$mean_effect[1:2]) < 0.25))
# Probs in [0,1]
- for (col in c("p_pos","p_neg","p_gt_delta","p_lt_neg_delta"))
+ for (col in c("prob_pos","prob_neg","prob_hyper","prob_hypo"))
expect_true(all(post[[col]] >= 0 & post[[col]] <= 1))
- # p_pos + p_neg == 1 (within tolerance)
- expect_equal(post$p_pos + post$p_neg, rep(1, nrow(post)),
+ # prob_pos + prob_neg == 1 (within tolerance)
+ expect_equal(post$prob_pos + post$prob_neg, rep(1, nrow(post)),
tolerance = 1e-8)
})
test_that("classify_regions recovers truth with strong signal", {
+ # The ROPE call needs the whole posterior inside +/- delta, which is a much
+ # tighter demand than a directional call. At the original sigma = 0.2 the
+ # contrast SE was ~0.052 and a true null landed on prob_rope ~0.948, two
+ # thousandths under the cutoff -- a coin flip. sigma = 0.08 puts the SE at
+ # ~0.021, so even a null that happens to sit 2 SE off zero still carries
+ # >0.99 of its mass inside the ROPE.
sim <- .sim_region_mat(
n_samples = 60L,
true_betas = c(rep(0, 3), # nulls
rep(0.8, 3), # hyper
rep(-0.8, 3), # hypo
rep(0.02, 3)), # weak (below delta)
- seed = 42L, sigma = 0.2
+ seed = 42L, sigma = 0.08
)
fit <- fit_bread_summary(sim$mat, sim$coldata,
design = ~ group, contrast = "groupold")
@@ -66,10 +72,12 @@ test_that("classify_regions recovers truth with strong signal", {
cls <- classify_regions(post, delta = 0.10, prob_cutoff = 0.95)
got <- as.character(cls$classification)
- expect_true(all(got[1:3] == "inconclusive"), info = paste(got[1:3], collapse=","))
+ # The nulls and the sub-delta regions were always *scientifically*
+ # unchanged; calling them `inconclusive` was the defect this class fixes.
+ expect_true(all(got[1:3] == "unchanged"), info = paste(got[1:3], collapse=","))
expect_true(all(got[4:6] == "hypermethylated"), info = paste(got[4:6], collapse=","))
expect_true(all(got[7:9] == "hypomethylated"), info = paste(got[7:9], collapse=","))
- expect_true(all(got[10:12] == "inconclusive"), info = paste(got[10:12],collapse=","))
+ expect_true(all(got[10:12] == "unchanged"), info = paste(got[10:12],collapse=","))
expect_identical(attr(cls, "delta"), 0.10)
expect_identical(attr(cls, "prob_cutoff"), 0.95)
})
diff --git a/tests/testthat/test-kycg.R b/tests/testthat/test-kycg.R
new file mode 100644
index 0000000..d74438d
--- /dev/null
+++ b/tests/testthat/test-kycg.R
@@ -0,0 +1,76 @@
+# bread_kycg() reaches out to KnowYourCG reference databases, so these tests
+# deliberately cover only the validation that happens before any network or
+# annotation-hub access.
+
+.toy_fit <- function() {
+ fit_bread(.make_toy_signal_se(), .make_toy_features(), ~ group)
+}
+
+test_that("a non-BreadFit is rejected before any database access", {
+ expect_error(bread_kycg(data.frame(x = 1)), "BreadFit")
+ expect_error(bread_kycg(NULL), "BreadFit")
+})
+
+test_that("`which` and `platform` are matched against their allowed values", {
+ skip_if_not_installed("knowYourCG")
+ fit <- .toy_fit()
+ expect_error(bread_kycg(fit, platform = "NotAPlatform"))
+ expect_error(bread_kycg(fit, which = "sideways"))
+})
+
+test_that("the fit exposes the mapping columns bread_kycg() reads", {
+ # Not a network test: pins the columns bread_kycg() depends on, so a
+ # refactor of map_probes_to_features() cannot silently break it.
+ fit <- .toy_fit()
+ expect_true(all(c("probe_id", "region_id") %in% colnames(fit@mapping)))
+ expect_type(fit@mapping$probe_id, "character")
+})
+
+# The real group titles as registered by knowYourCG (verified against the
+# installed release). Used as a fixture so default-database selection is
+# testable without touching ExperimentHub.
+.KYCG_TITLES_MM285 <- c(
+ "KYCG.MM285.chromHMM.20210210",
+ "KYCG.MM285.chromosome.mm10.20210630",
+ "KYCG.MM285.designGroup.20210210",
+ "KYCG.MM285.HMconsensus.20220116",
+ "KYCG.MM285.Mask.20220123",
+ "KYCG.MM285.metagene.20220126",
+ "KYCG.MM285.probeType.20210630",
+ "KYCG.MM285.seqContext.20210630",
+ "KYCG.MM285.seqContextN.20210630",
+ "KYCG.MM285.TFBSconsensus.20220116",
+ "KYCG.MM285.tissueSignature.20211211"
+)
+
+test_that("mouse default databases actually match the real MM285 titles", {
+ # The regression: the old pattern required a literal "." after "TFBS", so
+ # it could never match "KYCG.MM285.TFBSconsensus.20220116" and mouse users
+ # silently received an empty data.frame.
+ dbs <- BREAD:::.kycg_default_dbs("MM285", .KYCG_TITLES_MM285)
+
+ expect_true("KYCG.MM285.TFBSconsensus.20220116" %in% dbs)
+ expect_true("KYCG.MM285.chromHMM.20210210" %in% dbs)
+ expect_true("KYCG.MM285.HMconsensus.20220116" %in% dbs)
+ expect_length(dbs, 6L)
+})
+
+test_that("technical annotation groups are deliberately excluded", {
+ dbs <- BREAD:::.kycg_default_dbs("MM285", .KYCG_TITLES_MM285)
+ for (junk in c("Mask", "chromosome", "probeType", "seqContext")) {
+ expect_false(any(grepl(junk, dbs, fixed = TRUE)), info = junk)
+ }
+})
+
+test_that("default selection returns nothing for an unlisted platform", {
+ expect_length(BREAD:::.kycg_default_dbs("EPIC", .KYCG_TITLES_MM285), 0L)
+ expect_length(BREAD:::.kycg_default_dbs("MM285", character(0)), 0L)
+})
+
+test_that("human platforms select the documented families", {
+ titles <- c("KYCG.EPIC.TFBS.20210210", "KYCG.EPIC.chromHMM.20211020",
+ "KYCG.EPIC.CGI.20210713", "KYCG.EPIC.Mask.20220123")
+ dbs <- BREAD:::.kycg_default_dbs("EPIC", titles)
+ expect_length(dbs, 3L)
+ expect_false(any(grepl("Mask", dbs, fixed = TRUE)))
+})
diff --git a/tests/testthat/test-mapping.R b/tests/testthat/test-mapping.R
index 34fc847..26f1a69 100644
--- a/tests/testthat/test-mapping.R
+++ b/tests/testthat/test-mapping.R
@@ -52,3 +52,51 @@ test_that("mapping rejects non-positive min_probes", {
expect_error(map_probes_to_features(se, gr, min_probes = 0L),
"positive integer")
})
+
+test_that("several ranges sharing a region_id collapse into one region", {
+ se <- .make_toy_se(); gr <- .make_toy_features_dup()
+ expect_length(gr, 3L) # three ranges ...
+ expect_length(unique(names(gr)), 2L) # ... but two regions
+
+ m <- suppressMessages(map_probes_to_features(se, gr, min_probes = 3L))
+
+ expect_setequal(unique(m$region_id), c("regD", "regE"))
+ # regD spans probes 1-3 and 8-10 across two disjoint ranges
+ expect_equal(sum(m$region_id == "regD"), 6L)
+ expect_true(all(m$n_probes[m$region_id == "regD"] == 6L))
+
+ # Counts are of distinct region IDs, never of ranges. This is the
+ # regression: n_features_in used to report 3 here.
+ expect_equal(attr(m, "n_features_in"), 2L)
+ expect_equal(attr(m, "n_features_out"), 2L)
+})
+
+test_that("dropped_regions is deduplicated and the message counts regions", {
+ se <- .make_toy_se()
+ gr <- .make_toy_features_dup()
+ # Raise the bar so regD (6 probes across 2 ranges) survives but regE (5) does not
+ expect_message(
+ m <- map_probes_to_features(se, gr, min_probes = 6L),
+ "Dropped 1 of 2 regions"
+ )
+ expect_equal(attr(m, "dropped_regions"), "regE")
+
+ # And when the multi-range region itself is dropped, it appears once
+ m2 <- suppressMessages(map_probes_to_features(se, gr, min_probes = 20L))
+ expect_equal(sort(attr(m2, "dropped_regions")), c("regD", "regE"))
+})
+
+test_that("fit diagnostics count regions, not ranges", {
+ se <- .make_toy_se(); gr <- .make_toy_features_dup()
+ fit <- suppressMessages(fit_bread(se, gr, ~ group, min_probes = 3L))
+
+ # The invariant that would have caught "n_regions: 788 (of 790 input)"
+ expect_equal(fit@diagnostics$n_features_out, nrow(results(fit)))
+ expect_equal(fit@diagnostics$n_features_in, 2L)
+
+ # Documented property: the features slot keeps every range of a surviving
+ # region, so it is longer than the results table when IDs repeat.
+ expect_gt(length(fit@features), nrow(results(fit)))
+
+ expect_output(show(fit), "n_regions : 2 \\(of 2 input\\)")
+})
diff --git a/tests/testthat/test-methods.R b/tests/testthat/test-methods.R
new file mode 100644
index 0000000..29a38b5
--- /dev/null
+++ b/tests/testthat/test-methods.R
@@ -0,0 +1,79 @@
+# Accessors and show methods for BreadFit / BreadResults.
+
+.toy_fit <- function() {
+ fit_bread(.make_toy_signal_se(), .make_toy_features(), ~ group)
+}
+
+test_that("results() returns the region table with a classification column", {
+ res <- results(.toy_fit())
+ expect_s3_class(res, "data.frame")
+ expect_true(all(c("region_id", "classification") %in% colnames(res)))
+ expect_gt(nrow(res), 0L)
+})
+
+test_that("classifications() names line up with results() region ids", {
+ fit <- .toy_fit()
+ cls <- classifications(fit)
+ res <- results(fit)
+ expect_type(cls, "character")
+ expect_identical(names(cls), as.character(res$region_id))
+ expect_identical(unname(cls), as.character(res$classification))
+ expect_true(all(cls %in% c("hypermethylated", "hypomethylated",
+ "unchanged", "inconclusive")))
+})
+
+test_that("posterior_draws() is reproducible for a fixed seed", {
+ fit <- .toy_fit()
+ rid <- results(fit)$region_id[1]
+ a <- posterior_draws(fit, region_id = rid, n = 200L, seed = 7L)
+ b <- posterior_draws(fit, region_id = rid, n = 200L, seed = 7L)
+ expect_equal(a, b)
+ expect_identical(colnames(a), c("region_id", "draw", "value"))
+ expect_identical(nrow(a), 200L)
+ expect_identical(unique(a$region_id), rid)
+})
+
+test_that("posterior_draws() differs across seeds", {
+ fit <- .toy_fit()
+ rid <- results(fit)$region_id[1]
+ a <- posterior_draws(fit, region_id = rid, n = 200L, seed = 1L)
+ b <- posterior_draws(fit, region_id = rid, n = 200L, seed = 2L)
+ expect_false(isTRUE(all.equal(a$value, b$value)))
+})
+
+test_that("posterior_draws() restores the caller's RNG state", {
+ # local_seed() must not leak its reseed into the calling session.
+ fit <- .toy_fit()
+ rid <- results(fit)$region_id[1]
+ set.seed(99)
+ before <- runif(1)
+ set.seed(99)
+ invisible(posterior_draws(fit, region_id = rid, n = 10L, seed = 123L))
+ after <- runif(1)
+ expect_equal(before, after)
+})
+
+test_that("posterior_draws() defaults to every region", {
+ fit <- .toy_fit()
+ n_regions <- nrow(results(fit))
+ d <- posterior_draws(fit, n = 10L, seed = 1L)
+ expect_identical(nrow(d), as.integer(n_regions * 10L))
+})
+
+test_that("an unknown region_id errors and names the offender", {
+ expect_error(posterior_draws(.toy_fit(), region_id = "no_such_region"),
+ "not found")
+})
+
+test_that("show() prints the expected BreadFit header", {
+ fit <- .toy_fit()
+ expect_output(show(fit), "")
+ expect_output(show(fit), "classifications:")
+ expect_output(show(fit), "backend")
+})
+
+test_that("show() prints the expected BreadResults header", {
+ br <- BreadResults(.toy_fit())
+ expect_output(show(br), "")
+ expect_output(show(br), "n_regions")
+})
diff --git a/tests/testthat/test-plots.R b/tests/testthat/test-plots.R
index 6a13b00..9debfc5 100644
--- a/tests/testthat/test-plots.R
+++ b/tests/testthat/test-plots.R
@@ -1,6 +1,7 @@
test_that("bread_colors() returns expected palettes", {
cl <- bread_colors("classification")
- expect_named(cl, c("hypermethylated", "hypomethylated", "inconclusive"))
+ expect_named(cl, c("hypermethylated", "hypomethylated",
+ "unchanged", "inconclusive"))
expect_match(cl, "^#[0-9a-fA-F]{6}$")
gr <- bread_colors("group")
diff --git a/tests/testthat/test-posterior.R b/tests/testthat/test-posterior.R
new file mode 100644
index 0000000..e3e3299
--- /dev/null
+++ b/tests/testthat/test-posterior.R
@@ -0,0 +1,188 @@
+# posterior_summary() is the bridge between a backend fit and the
+# classification rule. Its column contract is what downstream code and the
+# BreadFit results table both depend on.
+
+# Spelled out independently of the package's own .POST_COLS -- that is the
+# point of a contract test.
+POST_COLS <- c("region_id", "n", "mean_effect", "median_effect",
+ "ci_lo", "ci_hi", "df", "scale", "prob_pos", "prob_neg",
+ "prob_hyper", "prob_hypo", "prob_rope",
+ "ref_beta", "mean_dbeta", "dbeta_lo", "dbeta_hi", "delta_beta",
+ "error")
+
+.toy_fit <- function() {
+ fit_bread(.make_toy_signal_se(), .make_toy_features(), ~ group)
+}
+
+test_that("the returned column contract is stable", {
+ post <- posterior_summary(.toy_fit())
+ expect_s3_class(post, "data.frame")
+ expect_identical(colnames(post), POST_COLS)
+})
+
+test_that("a BreadFit is accepted and matches the internal model list", {
+ fit <- .toy_fit()
+ expect_equal(posterior_summary(fit), posterior_summary(fit@model))
+})
+
+test_that("a wider ci widens every interval but moves no point estimate", {
+ fit <- .toy_fit()
+ p95 <- posterior_summary(fit, ci = 0.95)
+ p99 <- posterior_summary(fit, ci = 0.99)
+ ok <- !is.na(p95$ci_lo) & !is.na(p99$ci_lo)
+ expect_true(all(p99$ci_lo[ok] <= p95$ci_lo[ok]))
+ expect_true(all(p99$ci_hi[ok] >= p95$ci_hi[ok]))
+ expect_equal(p95$mean_effect, p99$mean_effect)
+})
+
+test_that("directional probabilities are coherent", {
+ post <- posterior_summary(.toy_fit())
+ ok <- !is.na(post$prob_pos)
+ expect_equal(post$prob_pos[ok] + post$prob_neg[ok], rep(1, sum(ok)),
+ tolerance = 1e-8)
+ expect_true(all(post$prob_hyper[ok] <= post$prob_pos[ok] + 1e-8))
+ expect_true(all(post$prob_hypo[ok] <= post$prob_neg[ok] + 1e-8))
+ for (col in c("prob_pos", "prob_neg", "prob_hyper", "prob_hypo")) {
+ expect_true(all(post[[col]][ok] >= 0 & post[[col]][ok] <= 1))
+ }
+})
+
+test_that("a larger delta cannot increase the directional probabilities", {
+ fit <- .toy_fit()
+ small <- posterior_summary(fit, delta = 0.05)
+ large <- posterior_summary(fit, delta = 0.50)
+ ok <- !is.na(small$prob_hyper)
+ expect_true(all(large$prob_hyper[ok] <= small$prob_hyper[ok] + 1e-12))
+ expect_true(all(large$prob_hypo[ok] <= small$prob_hypo[ok] + 1e-12))
+})
+
+test_that("delta, ci and contrast are recorded as attributes", {
+ post <- posterior_summary(.toy_fit(), delta = 0.2, ci = 0.9)
+ expect_equal(attr(post, "delta"), 0.2)
+ expect_equal(attr(post, "ci"), 0.9)
+ expect_true(is.character(attr(post, "contrast")))
+})
+
+test_that("bad input is rejected", {
+ fit <- .toy_fit()
+ expect_error(posterior_summary(list(a = 1)), "fit_bread_summary")
+ expect_error(posterior_summary(fit, delta = -1), "non-negative")
+ expect_error(posterior_summary(fit, ci = 0), "must be in \\(0, 1\\)")
+ expect_error(posterior_summary(fit, ci = 1), "must be in \\(0, 1\\)")
+})
+
+
+# ---- prob_rope -------------------------------------------------------------
+
+test_that("the three posterior masses partition the line", {
+ post <- posterior_summary(.toy_fit())
+ ok <- !is.na(post$prob_hyper)
+ expect_equal(post$prob_hyper[ok] + post$prob_hypo[ok] + post$prob_rope[ok],
+ rep(1, sum(ok)), tolerance = 1e-12)
+ expect_true(all(post$prob_rope[ok] >= 0 & post$prob_rope[ok] <= 1))
+ expect_identical(is.na(post$prob_rope), is.na(post$prob_hyper))
+})
+
+test_that("a wider ROPE can only absorb more posterior mass", {
+ fit <- .toy_fit()
+ small <- posterior_summary(fit, delta = 0.05)
+ large <- posterior_summary(fit, delta = 0.50)
+ ok <- !is.na(small$prob_rope)
+ expect_true(all(large$prob_rope[ok] >= small$prob_rope[ok] - 1e-12))
+})
+
+test_that("a zero-width ROPE holds no mass", {
+ post <- posterior_summary(.toy_fit(), delta = 0)
+ ok <- !is.na(post$prob_rope)
+ expect_equal(post$prob_rope[ok], rep(0, sum(ok)), tolerance = 1e-12)
+})
+
+
+# ---- beta-scale columns ----------------------------------------------------
+
+test_that("beta columns are the linearisation of the M-scale columns", {
+ post <- posterior_summary(.toy_fit(), delta = 0.10)
+ ok <- !is.na(post$ref_beta)
+ expect_true(any(ok))
+ expect_true(all(post$ref_beta[ok] > 0 & post$ref_beta[ok] < 1))
+
+ k <- post$ref_beta[ok] * (1 - post$ref_beta[ok]) * log(2)
+ expect_equal(post$mean_dbeta[ok], post$mean_effect[ok] * k)
+ expect_equal(post$dbeta_lo[ok], post$ci_lo[ok] * k)
+ expect_equal(post$dbeta_hi[ok], post$ci_hi[ok] * k)
+ expect_equal(post$delta_beta[ok], 0.10 * k)
+ expect_true(all(post$dbeta_lo[ok] <= post$dbeta_hi[ok]))
+})
+
+test_that("one multiplier keeps the beta scale consistent with the M scale", {
+ # The whole reason for a single linearisation rather than an exact secant:
+ # the beta comparison must never contradict the classification beside it.
+ post <- posterior_summary(.toy_fit(), delta = 0.10)
+ ok <- !is.na(post$ref_beta)
+ expect_identical(post$mean_effect[ok] > 0.10,
+ post$mean_dbeta[ok] > post$delta_beta[ok])
+})
+
+test_that("ref_beta accepts a scalar and a named vector", {
+ fit <- .toy_fit()
+ ids <- posterior_summary(fit)$region_id
+
+ flat <- posterior_summary(fit, ref_beta = 0.3)
+ expect_equal(flat$ref_beta, rep(0.3, nrow(flat)))
+
+ named <- stats::setNames(seq(0.2, 0.4, length.out = length(ids)), ids)
+ byid <- posterior_summary(fit, ref_beta = named)
+ expect_equal(byid$ref_beta, unname(named[byid$region_id]))
+})
+
+test_that("ref_beta rejects impossible values and ambiguous lengths", {
+ fit <- .toy_fit()
+ expect_error(posterior_summary(fit, ref_beta = 0), "must be in \\(0, 1\\)")
+ expect_error(posterior_summary(fit, ref_beta = 1.2), "must be in \\(0, 1\\)")
+ expect_error(posterior_summary(fit, ref_beta = c(0.3, 0.4, 0.5)),
+ "named by region_id")
+})
+
+test_that("beta columns are NA when no region matrix is available", {
+ m <- .toy_fit()@model
+ m$region_mat <- NULL
+ post <- posterior_summary(m)
+ expect_true(all(is.na(post$ref_beta)))
+ expect_true(all(is.na(post$mean_dbeta)))
+ # ... but the M-scale results are untouched
+ expect_false(all(is.na(post$mean_effect)))
+})
+
+test_that("pc1 scores get no beta translation", {
+ fit <- suppressMessages(
+ fit_bread(.make_toy_signal_se(), .make_toy_features(), ~ group,
+ summary_fun = "pc1")
+ )
+ expect_message(post <- posterior_summary(fit), "PC1 scores are not M-values")
+ expect_true(all(is.na(post$ref_beta)))
+ expect_true(all(is.na(post$delta_beta)))
+ # An explicit anchor overrides the refusal
+ post2 <- posterior_summary(fit, ref_beta = 0.5)
+ expect_true(all(!is.na(post2$delta_beta)))
+})
+
+
+# ---- exported converters ---------------------------------------------------
+
+test_that("bread_delta_beta matches the documented arithmetic", {
+ # The corrected value: 0.10 M-units is ~1.7 percentage points at beta = 0.5,
+ # NOT 3.5 (that is the width of the full +/-delta window).
+ expect_equal(bread_delta_beta(0.10), 0.10 * 0.25 * log(2))
+ expect_equal(round(bread_delta_beta(0.10), 3), 0.017)
+
+ # Scale dependence: the translation shrinks toward the extremes
+ v <- bread_delta_beta(0.10, ref_beta = c(0.5, 0.2, 0.1))
+ expect_true(all(diff(v) < 0))
+
+ # Round trip
+ expect_equal(bread_delta_m(bread_delta_beta(0.10, 0.3), 0.3), 0.10)
+ expect_equal(bread_delta_beta(bread_delta_m(0.02, 0.4), 0.4), 0.02)
+
+ expect_error(bread_delta_beta(0.1, ref_beta = 0), "must be in \\(0, 1\\)")
+ expect_error(bread_delta_m(0.1, ref_beta = 1), "must be in \\(0, 1\\)")
+})
diff --git a/tests/testthat/test-refit.R b/tests/testthat/test-refit.R
new file mode 100644
index 0000000..0c7113d
--- /dev/null
+++ b/tests/testthat/test-refit.R
@@ -0,0 +1,121 @@
+# refit_bread() exists so that label-permutation calibration does not have to
+# recompute the region matrix, and does not have to reach into the namespace.
+# The load-bearing property is therefore: the matrix is reused, never rebuilt.
+
+.refit_fit <- function() {
+ suppressMessages(fit_bread(.make_toy_signal_se(), .make_toy_features(),
+ ~ group))
+}
+
+test_that("a no-op refit reproduces the original exactly", {
+ fit <- .refit_fit()
+ re <- refit_bread(fit)
+ expect_equal(re@posterior, fit@posterior)
+ expect_equal(results(re), results(fit))
+ expect_identical(re@params$contrast, fit@params$contrast)
+})
+
+test_that("nothing is re-summarized", {
+ fit <- .refit_fit()
+ re <- refit_bread(fit, delta = 0.5)
+ # The proof that mapping/summarization did not run again
+ expect_identical(re@model$region_mat, fit@model$region_mat)
+ expect_identical(re@mapping, fit@mapping)
+ expect_identical(re@features, fit@features)
+})
+
+test_that("re-thresholding matches doing it by hand", {
+ fit <- .refit_fit()
+ re <- refit_bread(fit, delta = 0.05, prob_cutoff = 0.8, rope_cutoff = 0.6,
+ ci = 0.9)
+ hand <- classify_regions(
+ posterior_summary(fit@model, delta = 0.05, ci = 0.9),
+ delta = 0.05, prob_cutoff = 0.8, rope_cutoff = 0.6
+ )
+ expect_equal(results(re), hand)
+ expect_equal(re@params$delta, 0.05)
+ expect_equal(re@params$ci, 0.9)
+ expect_equal(re@params$rope_cutoff, 0.6)
+})
+
+test_that("unspecified settings are inherited from the parent fit", {
+ fit <- suppressMessages(
+ fit_bread(.make_toy_signal_se(), .make_toy_features(), ~ group,
+ delta = 0.3, prob_cutoff = 0.9, rope_cutoff = 0.7, ci = 0.8)
+ )
+ re <- refit_bread(fit)
+ expect_equal(re@params$delta, 0.3)
+ expect_equal(re@params$prob_cutoff, 0.9)
+ expect_equal(re@params$rope_cutoff, 0.7)
+ expect_equal(re@params$ci, 0.8)
+})
+
+test_that("a permuted colData changes the fit but not the region matrix", {
+ fit <- .refit_fit()
+ cd <- as.data.frame(fit@model$coldata)
+ cd$group <- rev(cd$group)
+
+ re <- refit_bread(fit, colData = cd)
+ expect_identical(re@model$region_mat, fit@model$region_mat)
+ expect_false(isTRUE(all.equal(results(re)$mean_effect,
+ results(fit)$mean_effect)))
+ expect_identical(re@diagnostics$refit_of, fit@diagnostics$timestamp)
+})
+
+test_that("colData is matched by rowname, not position", {
+ fit <- .refit_fit()
+ cd <- as.data.frame(fit@model$coldata)
+ shuffled <- cd[sample(nrow(cd)), , drop = FALSE]
+
+ # Same information, different row order -- must give the same answer
+ expect_equal(results(refit_bread(fit, colData = shuffled)),
+ results(refit_bread(fit, colData = cd)))
+})
+
+test_that("a mis-sized or disjoint colData is rejected", {
+ fit <- .refit_fit()
+ cd <- as.data.frame(fit@model$coldata)
+ expect_error(refit_bread(fit, colData = cd[1:3, , drop = FALSE]),
+ "rows but the region matrix has")
+
+ bad <- cd; rownames(bad) <- paste0("X", seq_len(nrow(bad)))
+ expect_error(refit_bread(fit, colData = bad), "do not cover every sample")
+})
+
+test_that("an unknown contrast lists the available coefficients", {
+ fit <- .refit_fit()
+ expect_error(refit_bread(fit, contrast = "groupNOPE"),
+ "not found among design coefficients")
+})
+
+test_that("a rank-deficient design warns instead of silently regularising", {
+ fit <- .refit_fit()
+ cd <- as.data.frame(fit@model$coldata)
+ cd$dupe <- cd$group # perfectly collinear with group
+ expect_warning(refit_bread(fit, colData = cd, design = ~ group + dupe),
+ "rank deficient")
+})
+
+test_that("refit_bread rejects non-BreadFit input", {
+ expect_error(refit_bread(list()), "must be a BreadFit")
+})
+
+test_that("a label-permutation null runs end to end through the public API", {
+ # The workflow this function exists for, in miniature.
+ fit <- .refit_fit()
+ cd <- as.data.frame(fit@model$coldata)
+ rid <- results(fit)$region_id[1]
+ obs <- results(fit)$mean_effect[results(fit)$region_id == rid]
+
+ null <- withr::with_seed(7L, vapply(seq_len(24L), function(i) {
+ cdp <- cd
+ cdp$group <- sample(cdp$group)
+ r <- results(refit_bread(fit, colData = cdp))
+ r$mean_effect[r$region_id == rid]
+ }, numeric(1)))
+
+ expect_length(null, 24L)
+ expect_false(anyNA(null))
+ p <- (sum(abs(null) >= abs(obs)) + 1) / (length(null) + 1)
+ expect_true(p >= 0 && p <= 1)
+})
diff --git a/tests/testthat/test-utils.R b/tests/testthat/test-utils.R
new file mode 100644
index 0000000..e052caf
--- /dev/null
+++ b/tests/testthat/test-utils.R
@@ -0,0 +1,66 @@
+# Internal transforms, palettes, and the auto-detection helpers that make
+# fit_bread()'s three-argument form work.
+
+test_that("beta <-> M round-trips across the usable range", {
+ betas <- c(0.01, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99)
+ expect_equal(BREAD:::.m_to_beta(BREAD:::.beta_to_m(betas)), betas,
+ tolerance = 1e-8)
+})
+
+test_that("beta -> M clamps at the boundaries instead of returning Inf", {
+ out <- BREAD:::.beta_to_m(c(0, 1))
+ expect_true(all(is.finite(out)))
+ expect_lt(out[1], 0)
+ expect_gt(out[2], 0)
+})
+
+test_that("beta -> M is monotone increasing and centred at 0.5", {
+ m <- BREAD:::.beta_to_m(seq(0.05, 0.95, by = 0.05))
+ expect_true(all(diff(m) > 0))
+ expect_equal(BREAD:::.beta_to_m(0.5), 0, tolerance = 1e-6)
+})
+
+test_that("input scale detection keys off the [0, 1] range", {
+ expect_identical(BREAD:::.detect_input_scale(c(0, 0.5, 1)), "Beta")
+ expect_identical(BREAD:::.detect_input_scale(c(0.2, 0.8)), "Beta")
+ expect_identical(BREAD:::.detect_input_scale(c(-2, 0.5, 3)), "M")
+ expect_identical(BREAD:::.detect_input_scale(c(0.5, 1.5)), "M")
+})
+
+test_that("assay-name detection follows the documented priority", {
+ mk <- function(nms) {
+ m <- matrix(0.5, nrow = 2, ncol = 2,
+ dimnames = list(c("p1", "p2"), c("s1", "s2")))
+ a <- stats::setNames(replicate(length(nms), m, simplify = FALSE), nms)
+ SummarizedExperiment::SummarizedExperiment(assays = a)
+ }
+ expect_identical(BREAD:::.detect_assay_name(mk(c("betas", "M"))), "M")
+ expect_identical(BREAD:::.detect_assay_name(mk(c("Beta", "betas"))), "betas")
+ expect_identical(BREAD:::.detect_assay_name(mk(c("beta", "Beta"))), "Beta")
+ expect_identical(BREAD:::.detect_assay_name(mk("beta")), "beta")
+ expect_identical(BREAD:::.detect_assay_name(mk(c("weird", "other"))), "weird")
+})
+
+test_that("bread_colors() returns the documented shapes", {
+ cl <- bread_colors("classification")
+ expect_length(cl, 4L)
+ expect_identical(names(cl),
+ c("hypermethylated", "hypomethylated",
+ "unchanged", "inconclusive"))
+
+ grp <- bread_colors("group")
+ expect_length(grp, 2L)
+ expect_null(names(grp))
+
+ cross <- bread_colors("cross")
+ expect_length(cross, 9L)
+
+ for (pal in list(cl, grp, cross)) {
+ expect_true(all(grepl("^#[0-9A-Fa-f]{6}$", pal)))
+ }
+})
+
+test_that("bread_colors() defaults to the classification palette", {
+ expect_identical(bread_colors(), bread_colors("classification"))
+ expect_error(bread_colors("nonsense"))
+})
diff --git a/tools/run_bioccheck.R b/tools/run_bioccheck.R
new file mode 100644
index 0000000..d69cc9c
--- /dev/null
+++ b/tools/run_bioccheck.R
@@ -0,0 +1,77 @@
+## Run BiocCheck against a freshly built BREAD tarball.
+##
+## Preflight only -- the authoritative BiocCheck runs on the Bioconductor
+## devel container in CI (.github/workflows/bioc-check.yaml), because the
+## HPC R is older than Bioc devel. This wrapper exists to catch the cheap
+## problems before spending a CI cycle.
+##
+## Submit via sbatch (never the login node):
+## sbatch --partition=laird --mem=32G --cpus-per-task=4 --time=2:00:00 \
+## --wrap='cd && Rscript tools/run_bioccheck.R'
+
+Sys.setenv(
+ LANG = "C.UTF-8", LC_ALL = "C.UTF-8",
+ RSTUDIO_PANDOC = "/varidata/research/projects/laird/jaemin.park/quarto/quarto-1.6.40/bin/tools/x86_64"
+)
+Sys.setenv("_R_CHECK_FORCE_SUGGESTS_" = "false")
+
+suppressPackageStartupMessages({
+ library(BiocCheck)
+})
+
+pkg_dir <- "/varidata/research/projects/laird/jaemin.park/projects/BREAD"
+check_dir <- file.path(pkg_dir, "docs", "check")
+dir.create(check_dir, recursive = TRUE, showWarnings = FALSE)
+
+## ---- 1. Git-clone-level checks (run on the source dir, not the tarball) ----
+message("== BiocCheckGitClone ==")
+gitres <- try(BiocCheck::BiocCheckGitClone(pkg_dir), silent = TRUE)
+if (inherits(gitres, "try-error")) {
+ message("BiocCheckGitClone failed: ", conditionMessage(attr(gitres, "condition")))
+}
+
+## ---- 2. Build a tarball ----------------------------------------------------
+## BiocCheck's `new-package` checks want the built tarball, not the source dir.
+message("== R CMD build ==")
+old <- setwd(check_dir)
+on.exit(setwd(old), add = TRUE)
+
+build_log <- system2(
+ file.path(R.home("bin"), "R"),
+ c("CMD", "build", "--no-resave-data", shQuote(pkg_dir)),
+ stdout = TRUE, stderr = TRUE
+)
+cat(build_log, sep = "\n")
+
+tarballs <- list.files(check_dir, pattern = "^BREAD_.*\\.tar\\.gz$", full.names = TRUE)
+if (!length(tarballs)) stop("R CMD build produced no tarball; see log above.")
+tarball <- tarballs[order(file.mtime(tarballs), decreasing = TRUE)][1]
+message("Using tarball: ", tarball)
+
+## ---- 3. BiocCheck ----------------------------------------------------------
+message("== BiocCheck (new-package = TRUE) ==")
+res <- BiocCheck::BiocCheck(tarball, `new-package` = TRUE)
+
+saveRDS(res, file.path(check_dir, "bioccheck_result.rds"))
+
+## ---- 4. Machine-readable summary ------------------------------------------
+## The printed BiocCheck output is long; this block is what gets read back
+## over SSH to build the fix list.
+summarise <- function(res) {
+ for (sev in c("error", "warning", "note")) {
+ items <- tryCatch(res[[sev]], error = function(e) NULL)
+ cat("\n########## ", toupper(sev), " (", length(items), ") ##########\n", sep = "")
+ if (!length(items)) next
+ for (nm in names(items)) {
+ cat("- ", nm, "\n", sep = "")
+ det <- items[[nm]]
+ if (length(det)) cat(paste0(" ", unlist(det), collapse = "\n"), "\n", sep = "")
+ }
+ }
+}
+cat("\n\n================ BIOCCHECK SUMMARY ================\n")
+try(summarise(res))
+cat("\n=================== END SUMMARY ===================\n")
+
+cat("\nBiocCheck artifacts:\n")
+print(list.files(check_dir, pattern = "BiocCheck", full.names = TRUE))
diff --git a/tools/run_doc_examples.R b/tools/run_doc_examples.R
new file mode 100644
index 0000000..91a04ad
--- /dev/null
+++ b/tools/run_doc_examples.R
@@ -0,0 +1,27 @@
+## Fast inner loop: regenerate docs, run every example, run the test suite.
+## Skips vignettes and the full R CMD check entirely (~2 min vs ~14 min).
+Sys.setenv(LANG = "C.UTF-8", LC_ALL = "C.UTF-8")
+pkg <- "/varidata/research/projects/laird/jaemin.park/projects/BREAD"
+
+cat("\n########## document() ##########\n")
+suppressPackageStartupMessages(library(roxygen2))
+roxygen2::roxygenise(pkg, clean = TRUE)
+
+cat("\n########## NAMESPACE ##########\n")
+cat(readLines(file.path(pkg, "NAMESPACE")), sep = "\n")
+
+cat("\n########## run_examples() ##########\n")
+suppressPackageStartupMessages(library(devtools))
+ok <- TRUE
+res <- tryCatch(
+ devtools::run_examples(pkg, document = FALSE, run_donttest = TRUE),
+ error = function(e) { ok <<- FALSE; message("EXAMPLES FAILED: ",
+ conditionMessage(e)); NULL }
+)
+cat("\nexamples_ok:", ok, "\n")
+
+cat("\n########## test() ##########\n")
+tr <- tryCatch(devtools::test(pkg, stop_on_failure = FALSE),
+ error = function(e) { message("TESTS ERRORED: ",
+ conditionMessage(e)); NULL })
+cat("\n########## DONE ##########\n")
diff --git a/vignettes/bread-intro.Rmd b/vignettes/bread-intro.Rmd
index 5f6e002..cc0cbf7 100644
--- a/vignettes/bread-intro.Rmd
+++ b/vignettes/bread-intro.Rmd
@@ -21,6 +21,16 @@ knitr::opts_chunk$set(
set.seed(2026)
```
+```{r logo, echo=FALSE, results="asis"}
+.logo <- "../man/figures/logo.png"
+if (file.exists(.logo)) {
+ cat(sprintf(
+ '
',
+ knitr::image_uri(.logo)
+ ))
+}
+```
+
## Why BREAD?
Many methylation studies are not purely discovery-oriented. Instead of
@@ -144,9 +154,9 @@ you want to be stricter or looser.
```{r results}
res <- results(fit)
-head(res[order(res$p_gt_delta, decreasing = TRUE),
+head(res[order(res$prob_hyper, decreasing = TRUE),
c("region_id", "mean_effect", "ci_lo", "ci_hi",
- "p_gt_delta", "p_lt_neg_delta", "classification")], 5)
+ "prob_hyper", "prob_hypo", "classification")], 5)
```
```{r by-class}
@@ -180,7 +190,7 @@ underlying values. The x-axis preserves the factor order we set on
```{r one-region, fig.width = 10, fig.height = 4}
top_hyper <- res[res$classification == "hypermethylated", ]
-top_hyper <- top_hyper[order(top_hyper$p_gt_delta, decreasing = TRUE), ]
+top_hyper <- top_hyper[order(top_hyper$prob_hyper, decreasing = TRUE), ]
rid <- top_hyper$region_id[1]
p1 <- plot_region_posterior(fit, region_id = rid) +
@@ -248,14 +258,22 @@ $$
The marginal posterior of the contrast coefficient is a location–scale
Student-t with `df = 2 a_n`. BREAD uses `pt()`/`qt()` to compute
-$P(\beta > \delta)$ and $P(\beta < -\delta)$ analytically — no MCMC.
+$P(\beta > \delta)$, $P(\beta < -\delta)$ and
+$P(|\beta| \le \delta)$ analytically — no MCMC.
The classification rule is then:
- **hypermethylated** if $P(\beta > \delta) \ge$ `prob_cutoff`,
- **hypomethylated** if $P(\beta < -\delta) \ge$ `prob_cutoff`,
+- **unchanged** if $P(|\beta| \le \delta) \ge$ `rope_cutoff`,
- **inconclusive** otherwise.
+The three probabilities partition the posterior and sum to 1, so two of them
+can clear their thresholds at once only if those thresholds sum to no more
+than 1 — impossible at any sensible setting. The `unchanged` class is what
+separates *"this region demonstrably did not move by more than $\delta$"*
+from *"this region told us nothing"*; both used to be `inconclusive`.
+
Need partial pooling across regions, non-conjugate priors, or ordered
contrasts? Set `backend = "brms"`; everything else stays the same.
diff --git a/vignettes/bread-vitc.Rmd b/vignettes/bread-vitc.Rmd
index b767bee..a093029 100644
--- a/vignettes/bread-vitc.Rmd
+++ b/vignettes/bread-vitc.Rmd
@@ -20,6 +20,16 @@ knitr::opts_chunk$set(
set.seed(2026)
```
+```{r logo, echo=FALSE, results="asis"}
+.logo <- "../man/figures/logo.png"
+if (file.exists(.logo)) {
+ cat(sprintf(
+ '
',
+ knitr::image_uri(.logo)
+ ))
+}
+```
+
## Biological question
Ascorbic acid (vitamin C) is a cofactor for TET dioxygenases, which oxidize
@@ -148,8 +158,23 @@ plot_feature_set(fit_vitc, feature_class_col = "feature_class") +
VitC demethylation is broadly distributed but, with only two replicates per
arm, most regions land in the `inconclusive` class at `prob_cutoff = 0.95`.
-This is BREAD doing its job — it does not claim more certainty than the
-sample size supports.
+
+That word now carries a precise meaning. With n = 2 the posterior is wide,
+so almost nothing reaches `prob_rope >= 0.95` either: these regions are not
+being called *unchanged*, they are being called *unresolved*. BREAD is
+saying it can neither detect a $\delta = 0.10$ effect nor rule one out —
+which is the honest answer, and a strictly more informative one than a
+non-significant p-value, because the same table tells you which regions
+*did* resolve in each direction.
+
+```{r rope-at-n2}
+summary(results(fit_vitc)$prob_rope)
+```
+
+If most of that distribution sits well below 0.95, the experiment is
+underpowered rather than null. Contrast this with a well-powered design,
+where regions genuinely unaffected by the treatment accumulate `prob_rope`
+near 1 and get called `unchanged` — a positive claim of no effect.
## The biologically interesting intersection
@@ -219,9 +244,12 @@ if (length(protected) > 0L) {
## Caveats
- **n = 2 per arm.** The credible intervals are wide and many regions
- remain `inconclusive`. BREAD's posterior probabilities are the honest
- answer given the data; lowering `prob_cutoff` to, say, `0.80` will promote
- more regions to `hyper` / `hypo` but also admit more false positives.
+ remain `inconclusive` — genuinely unresolved, not shown to be flat (see
+ the `prob_rope` distribution above). BREAD's posterior probabilities are
+ the honest answer given the data; lowering `prob_cutoff` to, say, `0.80`
+ will promote more regions to `hyper` / `hypo` but also admit more false
+ positives. Use `refit_bread(fit, prob_cutoff = 0.80)` to sweep that
+ without re-fitting anything.
- Technical replicates, not biological. Real variance in the `aa57`
effect across fibroblast lines is not captured by this experiment and
would produce additional dispersion if included.