Add blessCacheAs() to vouch for a file an older version wrote
Files from the 1.3.0.9000 development cycle already hold correctly
computed scores - the corrections landed there, before the release was
cut - but they say nothing about what wrote them, so the check added for
1.4.0 recomputes them. That is right in general and wrong for exactly
the files whose owners know better, and a collocation analysis they would
have to sit through again runs for hours.
blessCacheAs() records that a file holds what this version would compute.
What actually produced it is left as it stands and the blessing noted
beside it, so cacheAsInfo() goes on saying where the numbers come from
rather than claiming a provenance the file does not have.
A file recording no parameters, as those written before 1.3.0.9000 do,
has nothing left to compare against a call once blessed, and is reused
for any call naming it. Blessing one says so.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I0c46322154441b11547da205b2aa0fb5cf98371d
diff --git a/NAMESPACE b/NAMESPACE
index c059673..299f17c 100644
--- a/NAMESPACE
+++ b/NAMESPACE
@@ -6,6 +6,7 @@
export(KorAPQuery)
export(as_tibble)
export(bind_cols)
+export(blessCacheAs)
export(buildWebUIRequestUrl)
export(buildWebUIRequestUrlFromString)
export(cacheAsInfo)
diff --git a/NEWS.md b/NEWS.md
index 47153ba..e8838ad 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,7 @@
# unpublished dev version 1.3.0.9000
+- **`blessCacheAs()`** vouches for a cache file that an older version wrote, so that it is used again as it is. Files from the 1.3.0.9000 development cycle already hold correctly computed scores, the corrections having landed there, but say nothing about the version that wrote them and would otherwise be recomputed. What actually produced a file is left as it stands and the blessing recorded beside it, so `cacheAsInfo()` keeps saying where the numbers come from. A file recording no parameters has nothing left to compare once blessed, and is then reused for any call naming it
+
- **`cacheAsInfo()`** reads back what a `cacheAs` file was produced by: the parameters of the call, the KorAP instance, the index revision its corpus had at the time, and the version of RKorAPClient that wrote it. For a result kept next to a document, that is what says which numbers it rests on
- **`cacheAs` is now offered by `frequencyQuery()`, `corpusStats()`, `collocationScoreQuery()` and `textMetadata()`** as well, not only by `collocationAnalysis()`. It is a different thing from the connection's `cache`, which is a transparent speed-up: a `cacheAs` file belongs to the caller and is what keeps an analysis reproducible, since KorAP corpora grow and the same query returns different numbers next year. That is worth having for the quick functions too, where caching for speed would be pointless
diff --git a/R/cacheAs.R b/R/cacheAs.R
index 6643d4a..c39b305 100644
--- a/R/cacheAs.R
+++ b/R/cacheAs.R
@@ -79,7 +79,10 @@
#' @return a sentence naming the reason, or `NULL` if the file can be used
#' @noRd
cacheAsRejectionReason <- function(stored, current) {
- generation <- if (is.null(stored)) NULL else stored$scoreVersion
+ # a file vouched for by blessCacheAs() counts as computed the way this version
+ # would compute it, whatever version actually wrote it
+ confirmed <- if (is.null(stored)) NULL else stored$scoresConfirmedFor
+ generation <- if (is.null(confirmed)) stored$scoreVersion else confirmed
if (is.null(generation) || package_version(generation) < package_version(cacheAsScoreVersion)) {
writtenBy <- if (is.null(stored)) NULL else stored$packageVersion
@@ -96,6 +99,12 @@
})
}
+ # a blessed file may not say what it was computed with, in which case there is
+ # nothing to compare and the blessing has to stand for it
+ if (is.null(stored$parameters)) {
+ return(NULL)
+ }
+
differing <- character(0)
for (name in union(names(stored$parameters), names(current$parameters))) {
if (!identical(stored$parameters[[name]], current$parameters[[name]])) {
@@ -116,6 +125,62 @@
}
}
+#' Vouch for a cacheAs file that an older version wrote
+#'
+#' Association scores changed in 1.4.0, so files from before it are recomputed
+#' rather than used (see [cacheAs]). Where a file is known to hold what this
+#' version would compute - because it was written by a development version that
+#' already had the corrections, for instance - this records that, and the file is
+#' used again as it is.
+#'
+#' What actually wrote a file is left as it stands; the blessing is recorded
+#' beside it, so that [cacheAsInfo()] keeps telling the truth about where the
+#' numbers come from.
+#'
+#' A file that records no parameters, as those written before 1.3.0.9000 do, has
+#' nothing left to be compared against a call once it is blessed, and is
+#' therefore reused for any call that names it. Bless such a file only if that
+#' is what you mean.
+#'
+#' @param cacheAs paths of the files to vouch for, with or without their `.rds`
+#' extension
+#' @return the paths, invisibly
+#'
+#' @examples
+#' \dontrun{
+#' blessCacheAs("klima-ca.rds")
+#' blessCacheAs(list.files("data", pattern = "\\.rds$", full.names = TRUE))
+#' }
+#'
+#' @family cacheAs
+#' @export
+blessCacheAs <- function(cacheAs) {
+ for (file in cacheAs) {
+ file <- cacheAsFileName(file)
+ if (!file.exists(file)) {
+ stop(sprintf("Cache file '%s' does not exist.", file), call. = FALSE)
+ }
+
+ content <- readRDS(file)
+ record <- attr(content, cacheAsAttribute)
+ if (is.null(record)) {
+ record <- list()
+ message(sprintf(
+ paste0(
+ "'%s' records no parameters, so it will be reused for any call ",
+ "naming it."
+ ),
+ file
+ ))
+ }
+ record$scoresConfirmedFor <- cacheAsScoreVersion
+ record$blessedAt <- Sys.time()
+ attr(content, cacheAsAttribute) <- record
+ saveRDS(content, file)
+ }
+ invisible(cacheAs)
+}
+
#' What produced a cacheAs file
#'
#' Reads back what a query function recorded in a [cacheAs] file: the parameters
diff --git a/man/blessCacheAs.Rd b/man/blessCacheAs.Rd
new file mode 100644
index 0000000..3cecdde
--- /dev/null
+++ b/man/blessCacheAs.Rd
@@ -0,0 +1,44 @@
+% Generated by roxygen2: do not edit by hand
+% Please edit documentation in R/cacheAs.R
+\name{blessCacheAs}
+\alias{blessCacheAs}
+\title{Vouch for a cacheAs file that an older version wrote}
+\usage{
+blessCacheAs(cacheAs)
+}
+\arguments{
+\item{cacheAs}{paths of the files to vouch for, with or without their \code{.rds}
+extension}
+}
+\value{
+the paths, invisibly
+}
+\description{
+Association scores changed in 1.4.0, so files from before it are recomputed
+rather than used (see \link{cacheAs}). Where a file is known to hold what this
+version would compute - because it was written by a development version that
+already had the corrections, for instance - this records that, and the file is
+used again as it is.
+}
+\details{
+What actually wrote a file is left as it stands; the blessing is recorded
+beside it, so that \code{\link[=cacheAsInfo]{cacheAsInfo()}} keeps telling the truth about where the
+numbers come from.
+
+A file that records no parameters, as those written before 1.3.0.9000 do, has
+nothing left to be compared against a call once it is blessed, and is
+therefore reused for any call that names it. Bless such a file only if that
+is what you mean.
+}
+\examples{
+\dontrun{
+blessCacheAs("klima-ca.rds")
+blessCacheAs(list.files("data", pattern = "\\\\.rds$", full.names = TRUE))
+}
+
+}
+\seealso{
+Other cacheAs:
+\code{\link[=cacheAsInfo]{cacheAsInfo()}}
+}
+\concept{cacheAs}
diff --git a/man/cacheAsInfo.Rd b/man/cacheAsInfo.Rd
index 13bd1a6..73324b3 100644
--- a/man/cacheAsInfo.Rd
+++ b/man/cacheAsInfo.Rd
@@ -29,4 +29,8 @@
}
}
+\seealso{
+Other cacheAs:
+\code{\link[=blessCacheAs]{blessCacheAs()}}
+}
\concept{cacheAs}
diff --git a/tests/testthat/test-cache-as.R b/tests/testthat/test-cache-as.R
index 0b56a9a..7b4b034 100644
--- a/tests/testthat/test-cache-as.R
+++ b/tests/testthat/test-cache-as.R
@@ -4,6 +4,51 @@
expect_equal(RKorAPClient:::cacheAsFileName("analysis.RDS"), "analysis.RDS")
})
+offlineKco <- function() {
+ methods::new(
+ "KorAPConnection",
+ apiUrl = "https://example.invalid/",
+ KorAPUrl = "https://example.invalid/",
+ authorizationSupported = FALSE,
+ verbose = FALSE
+ )
+}
+
+test_that("blessCacheAs makes an old file usable again", {
+ kco <- offlineKco()
+ file <- tempfile(fileext = ".rds")
+ on.exit(unlink(file), add = TRUE)
+ legacy <- tibble::tibble(node = "Test", collocate = "c", logDice = 7)
+ saveRDS(legacy, file)
+
+ testthat::local_mocked_bindings(
+ collocatesQuery = function(...) stop("server must not be contacted"),
+ .package = "RKorAPClient"
+ )
+
+ expect_message(blessCacheAs(file), "records no parameters")
+ expect_silent(result <- collocationAnalysis(kco, "Test", cacheAs = file))
+ expect_equal(result, legacy)
+})
+
+test_that("blessing records itself without claiming a provenance", {
+ file <- tempfile(fileext = ".rds")
+ on.exit(unlink(file), add = TRUE)
+ saveRDS(tibble::tibble(x = 1), file)
+
+ suppressMessages(blessCacheAs(file))
+ info <- cacheAsInfo(file)
+
+ expect_equal(info$scoresConfirmedFor, RKorAPClient:::cacheAsScoreVersion)
+ expect_s3_class(info$blessedAt, "POSIXct")
+ # what actually wrote the file is unknown and stays unclaimed
+ expect_null(info$packageVersion)
+})
+
+test_that("blessCacheAs refuses a file that is not there", {
+ expect_error(blessCacheAs(file.path(tempdir(), "no-such-cache.rds")), "does not exist")
+})
+
test_that("cacheAsInfo says what produced a file", {
skip_if_offline()
kco <- KorAPConnection(accessToken = NULL, verbose = FALSE)