Every assay in a frame is backed by an array source. A
source is a serializable description of a two-dimensional numerical
array plus the S3 methods that read from it. The package ships
in-memory, NIfTI, HDF5 (through fmristore), and
experimental Zarr sources. A storage package adds its own by
implementing a small set of generics. It then passes the same
conformance checks the built-in sources pass.
This vignette implements a complete source backed by an
.rds file, in about sixty lines, and validates it. The file
format is deliberately trivial so the protocol is the whole lesson.
The protocol
An array source is an S3 object with class
c("<your_class>", "array_source") and methods for
these generics:
| Generic | Returns |
|---|---|
source_shape() |
Two non-negative integers: observations, features |
source_dtype() |
One storage dtype such as "float64",
"float32", "int16",
"logical"
|
source_chunks() |
Two positive integers bounded by the shape: the natural read grid |
source_capabilities() |
Unique strings, including "block_slice" and
"serializable"
|
source_fingerprint() |
One non-empty string identifying this descriptor and revision |
source_open() |
A handle inheriting from "array_source_handle"
|
source_read() |
An observation-by-feature matrix for integer selectors, in requested order |
source_read_native() |
A native spatial object, or an error if the source has none |
source_close() |
Releases the handle |
The package provides source_descriptor(),
validate_array_source(),
source_realization_cost(), and content_hash()
on top of these; you do not implement them.
Two rules hold everything together.
A descriptor is not a handle. The source object
holds only plain serializable values: paths, shapes, dtypes, sizes,
timestamps, tokens. It holds no open connections, environments, external
pointers, or closures. validate_array_source() rejects a
descriptor that contains any of them. Runtime state belongs in the
handle that source_open() returns. It never becomes
semantic state.
Fingerprints are cheap and content hashes are
explicit. The fingerprint is computed once, at construction,
and stored in the descriptor. Its inputs are the descriptor fields and
any physical evidence you can observe without reading values. It must
never hash the array. Value identity is content_hash(),
which the package computes by streaming your
source_read().
An RDS-backed source
The constructor reads the file once to learn the shape, keeps the
file’s size and modification time as revision evidence, computes the
fingerprint, and validates itself. canonical_sha256() is
the package’s canonical hash and is the right tool for a
fingerprint.
rds_source <- function(path, chunks = NULL) {
path <- normalizePath(path, winslash = "/", mustWork = TRUE)
values <- readRDS(path)
if (!is.matrix(values) || !is.numeric(values)) {
stop("rds_source() requires a numeric matrix.", call. = FALSE)
}
shape <- as.integer(dim(values))
if (is.null(chunks)) chunks <- pmax(1L, shape)
info <- file.info(path)
out <- structure(
list(
path = path,
shape = shape,
dtype = "float64",
chunks = pmin(as.integer(chunks), pmax(1L, shape)),
size = as.numeric(info$size),
mtime = as.numeric(info$mtime),
schema_version = 1L
),
class = c("rds_source", "array_source")
)
out$fingerprint <- canonical_sha256(list(
type = "rds_source",
schema_version = out$schema_version,
path = out$path,
shape = out$shape,
dtype = out$dtype,
chunks = out$chunks,
size = out$size,
mtime = out$mtime
))
validate_array_source(out)
}The descriptor accessors return stored fields.
source_shape.rds_source <- function(x, ...) x$shape
source_dtype.rds_source <- function(x, ...) x$dtype
source_chunks.rds_source <- function(x, ...) x$chunks
source_capabilities.rds_source <- function(x, ...) {
c("row_slice", "column_slice", "block_slice", "serializable")
}
source_fingerprint.rds_source <- function(x, ...) x$fingerprintOpening re-observes the file and compares it with the evidence
captured at construction. When they differ the descriptor is stale. That
is not an I/O failure: the file opened fine, it is simply not the file
the descriptor was made from. The package reserves
fmridataset_error_source_stale for this case and
fmridataset_error_backend_io for genuine failures. Callers
rely on the distinction. Extension packages raise both as plain
conditions with those classes.
rds_assert_fresh <- function(x) {
info <- file.info(x$path)
actual <- list(size = as.numeric(info$size), mtime = as.numeric(info$mtime))
expected <- list(size = x$size, mtime = x$mtime)
if (!identical(actual, expected)) {
stop(structure(
list(
message = paste("RDS file changed since the descriptor was built:", x$path),
call = NULL,
source = list(type = "rds_source", path = x$path),
expected = expected,
actual = actual
),
class = c("fmridataset_error_source_stale", "fmridataset_error", "error", "condition")
))
}
invisible(TRUE)
}
source_open.rds_source <- function(x, ...) {
rds_assert_fresh(x)
structure(
list(source = x, values = readRDS(x$path)),
class = c("rds_source_handle", "array_source_handle")
)
}
source_close.rds_source <- function(x, ...) invisible(TRUE)Reads take integer selectors on both axes and honor the requested
order, and return a zero-extent matrix for an empty selection. The
selection law
(inst/architecture/ADR-010-selection-algebra.md) is checked
at the source_read() generic before your method runs:
positions arrive whole, in bounds, and never repeated, so a method need
not defend against a repeated or out-of-range position, though refusing
one again is harmless. Reading through the handle is the real
implementation; reading from the descriptor opens, reads, and
closes.
source_read.rds_source_handle <- function(x, observations = NULL, features = NULL, ...) {
values <- x$values
if (is.null(observations)) observations <- seq_len(nrow(values))
if (is.null(features)) features <- seq_len(ncol(values))
if (any(observations < 1L | observations > nrow(values)) ||
any(features < 1L | features > ncol(values))) {
stop("rds_source selector is out of bounds.", call. = FALSE)
}
values[observations, features, drop = FALSE]
}
source_read.rds_source <- function(x, observations = NULL, features = NULL, ...) {
handle <- source_open(x)
on.exit(source_close(handle), add = TRUE)
source_read(handle, observations = observations, features = features, ...)
}
source_read_native.rds_source <- function(x, observations = NULL, ...) {
stop("rds_source has no native spatial representation.", call. = FALSE)
}That is the whole source. In a package these methods would be
registered in the NAMESPACE with S3method();
in a vignette, defining them at top level is enough for dispatch.
Try it
set.seed(4)
reference <- matrix(round(rnorm(30), 2), nrow = 6, ncol = 5)
path <- file.path(tempdir(), "toy.rds")
saveRDS(reference, path)
src <- rds_source(path, chunks = c(2L, 5L))
source_descriptor(src)
#> $shape
#> [1] 6 5
#>
#> $dtype
#> [1] "float64"
#>
#> $chunks
#> [1] 2 5
#>
#> $capabilities
#> [1] "row_slice" "column_slice" "block_slice" "serializable"
#>
#> $fingerprint
#> [1] "9b1777a7987808c3dc821fa2633e6c95c2d9a599c9d3ef602a889a7447f99bea"Reads honor order and shape.
source_read(src, observations = c(6L, 1L), features = c(5L, 2L))
#> [,1] [,2]
#> [1,] 1.24 0.02
#> [2,] 0.59 -1.28
reference[c(6, 1), c(5, 2)]
#> [,1] [,2]
#> [1,] 1.24 0.02
#> [2,] 0.59 -1.28
dim(source_read(src, observations = integer(), features = 1:2))
#> [1] 0 2The source drops straight into a frame. explain()
reports it by class, and views over it are as lazy as any other.
frame <- fmri_frame(
assays = list(bold = src),
observations = data.frame(.obs_id = sprintf("t%02d", 1:6)),
space = index_space(5L, ids = paste0("f", 1:5))
)
explain(frame)$assays$bold[c("source_type", "chunks")]
#> $source_type
#> [1] "rds_source"
#>
#> $chunks
#> [1] 2 5
collect_assay(frame[c("t02", "t01"), "f5"])
#> [,1]
#> [1,] -0.28
#> [2,] 0.59The cost estimate the budget checks use comes for free from the shape and dtype.
source_realization_cost(src, observations = 1:2)[c("storage_bytes", "estimated_peak_bytes")]
#> $storage_bytes
#> [1] 80
#>
#> $estimated_peak_bytes
#> [1] 160Validate it
validate_array_source() checks the descriptor contract:
two-dimensional shape, supported dtype, chunk grid bounded by the shape,
the required capabilities, a non-empty fingerprint, and no runtime
state.
A descriptor that smuggles in a function, however well-formed otherwise, fails.
leaky <- src
leaky$loader <- function() readRDS(leaky$path)
validate_array_source(leaky)
#> Error:
#> ! Canonical source descriptors cannot contain functions, environments, or external pointers.The behavioral laws live in the package’s conformance helper,
tests/testthat/helper-frame-conformance.R. It is a function
that takes a source and its reference matrix. A storage package should
copy that helper into its own tests and call it on every source type it
ships. It checks, in order:
- the descriptor is valid and its shape matches the reference;
- the descriptor survives
serialize()andunserialize()with the same fingerprint, shape, dtype, and chunks; - opening and closing a handle is safe and leaves the fingerprint untouched;
- reads match the reference for full, reversed, permuted, and empty selections, and are pure;
- out-of-range and repeated selections raise an error, and views composed over the source read what the equivalent flat positions read;
-
content_hash()equals the content hash of a memory source over the reference, is accepted byidentity_descriptor()as a content receipt, and does not change the fingerprint.
The last law is the one that matters most for interoperability, and the cheapest to check by hand.
restored <- unserialize(serialize(src, NULL))
identical(source_fingerprint(restored), source_fingerprint(src))
#> [1] TRUE
handle <- source_open(src)
class(handle)
#> [1] "rds_source_handle" "array_source_handle"
source_close(handle)
identical(content_hash(src), content_hash(memory_source(reference)))
#> [1] TRUEcounting_source() wraps any source and records what
reaches it. That is how the zero-I/O laws are certified: build a frame
over the wrapper, take views, and assert the read count is still
zero.
counted <- counting_source(src)
view <- source_view(counted, observations = 1:2)
source_counts(counted)$reads
#> [1] 0
invisible(source_read(view))
source_counts(counted)[c("reads", "values")]
#> $reads
#> [1] 1
#>
#> $values
#> [1] 10Staleness behaves as designed. Rewriting the file does not change the fingerprint stored in the descriptor. Every read now fails with the stale class, and a fresh descriptor has a new fingerprint.
Sys.sleep(1.1)
saveRDS(reference * 2, path)
class(tryCatch(source_read(src, 1L, 1L), error = identity))[1]
#> [1] "fmridataset_error_source_stale"
identical(source_fingerprint(rds_source(path)), source_fingerprint(src))
#> [1] FALSEComposition comes for free
The protocol is uniform, so the package’s compositions accept the new
source without knowing anything about it.
row_sharded_source() presents several sources as one array
with stable shard IDs and an inspectable manifest. That is how multi-run
and multi-subject data are bound.
saveRDS(reference[1:2, ], run_1 <- file.path(tempdir(), "run-1.rds"))
saveRDS(reference[3:6, ], run_2 <- file.path(tempdir(), "run-2.rds"))
sharded <- row_sharded_source(
list(rds_source(run_1), rds_source(run_2)),
shard_ids = c("run-1", "run-2")
)
shard_manifest(sharded)[, c(".shard_id", ".start", ".end")]
#> .shard_id .start .end
#> 1 run-1 1 2
#> 2 run-2 3 6
locate_source_rows(sharded, c(5L, 1L))[, c(".observation", ".shard_id", ".local_observation")]
#> .observation .shard_id .local_observation
#> 1 5 run-2 3
#> 2 1 run-1 1
identical(content_hash(sharded), content_hash(memory_source(reference)))
#> [1] TRUEas_delarr() wraps any source as a lazy
delarr array, so chunk-aware numerical plans can pull from
it through source_read() under the same budget.
Checklist for a storage package
Keep the descriptor serializable and put every handle behind
source_open(). Compute the fingerprint at construction from
descriptor fields and cheap physical evidence, never from values. Raise
fmridataset_error_source_stale when the evidence no longer
matches. Raise fmridataset_error_backend_io when the
backend fails. Return blocks in the requested order, non-dropping, and
refuse out-of-range selectors. Run the conformance helper on every
source type before release. The frame, view, binding, planning,
persistence, and hashing machinery will then work without a single
special case.