Refuse cacheAs files written before the scores were corrected
A cacheAs file holds a finished analysis, association scores included,
and 1.4.0 computes those differently. A file written by 1.3.0 would
therefore be handed back with numbers that would not be arrived at again,
and the comparison of parameters added during this cycle cannot notice
it: the parameters did not change, the formula did. The version that
wrote a file is now recorded in it, and one from before 1.4.0 is
recomputed and overwritten, with a warning saying why.
The machinery moves to a file of its own on the way, since it is no
longer about collocation analysis alone, and verbose drops out of what a
file records: how loud a query is does not change what it returns.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I8af9eac21ae53fe5d583578c3689b37d140a4af4
diff --git a/R/cacheAs.R b/R/cacheAs.R
new file mode 100644
index 0000000..dfb41bb
--- /dev/null
+++ b/R/cacheAs.R
@@ -0,0 +1,175 @@
+#' Keeping results in a file of one's own
+#'
+#' The `cacheAs` parameter of the query functions is a different thing from the
+#' `cache` parameter of [KorAPConnection()]. The latter is a transparent
+#' speed-up: it stores server responses where the package finds them again, and
+#' throwing it away costs nothing but time. The former stores a finished result
+#' in a file the caller names and keeps, which is what makes an analysis
+#' reproducible: KorAP corpora grow, so the same query returns different numbers
+#' next year, and the scores computed from them may change with the package.
+#'
+#' A cache file therefore records what produced it, and is not reused when that
+#' no longer matches what is being asked for.
+#'
+#' @name cacheAs
+#' @keywords internal
+NULL
+
+#' Attribute under which cache files record what produced them
+#' @noRd
+cacheAsAttribute <- "RKorAPClient.cacheAs"
+
+#' First version whose association scores are still computed the same way today
+#'
+#' `logDice()` and `ll()` were corrected in 1.4.0, so a file written by an
+#' earlier version holds numbers that would not be arrived at again, which no
+#' comparison of parameters can notice.
+#'
+#' Deliberately a constant of its own rather than the package version: what a
+#' cache file has to be checked against is the generation of the scores, which
+#' changes far more rarely than the version does, and reading it out of
+#' DESCRIPTION would make the check depend on a release having been prepared.
+#' Raise it whenever a score changes.
+#' @noRd
+cacheAsScoreVersion <- "1.4.0"
+
+#' Append .rds to a cache file name that does not end in it
+#' @noRd
+cacheAsFileName <- function(cacheAs) {
+ if (grepl("\\.rds$", cacheAs, ignore.case = TRUE)) cacheAs else paste0(cacheAs, ".rds")
+}
+
+#' What a cached result was computed with
+#'
+#' Collected from the calling function's frame, so that parameters added in the
+#' future are taken into account automatically. `kco` and `cacheAs` are
+#' excluded: the former is not a parameter of the analysis, the latter only says
+#' where to store it.
+#'
+#' @param frame environment of the calling query function
+#' @param dots its `...` arguments, or `NULL` where it has none
+#' @param kco [KorAPConnection()] object
+#' @return list to store with, and compare against, a cache file
+#' @noRd
+cacheAsRecord <- function(frame, dots, kco) {
+ parameterNames <- setdiff(
+ names(formals(sys.function(sys.parent()))),
+ # verbose says how loud the computation is, not what it computes
+ c("kco", "cacheAs", "verbose", "...")
+ )
+ list(
+ # what has to match on the next call
+ scoreVersion = cacheAsScoreVersion,
+ # recorded so that a file can say where its numbers come from
+ packageVersion = as.character(utils::packageVersion("RKorAPClient")),
+ parameters = mget(parameterNames, envir = frame),
+ dots = dots,
+ # reusing one cache file for two KorAP instances is a mistake worth catching
+ apiUrl = kco@apiUrl,
+ # recorded for reference only, deliberately not compared: corpus updates
+ # should not invalidate a deliberately kept result
+ indexRevision = kco@indexRevision
+ )
+}
+
+#' Why a cache file cannot be used for the current call
+#'
+#' @param stored record read from the cache file, `NULL` if it has none
+#' @param current record of the call at hand
+#' @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
+
+ if (is.null(generation) || package_version(generation) < package_version(cacheAsScoreVersion)) {
+ writtenBy <- if (is.null(stored)) NULL else stored$packageVersion
+ return(if (is.null(writtenBy)) {
+ sprintf(
+ "was written before RKorAPClient %s, which corrected logDice and ll",
+ cacheAsScoreVersion
+ )
+ } else {
+ sprintf(
+ "was written by RKorAPClient %s, whose logDice and ll differ from those of %s",
+ writtenBy, cacheAsScoreVersion
+ )
+ })
+ }
+
+ differing <- character(0)
+ for (name in union(names(stored$parameters), names(current$parameters))) {
+ if (!identical(stored$parameters[[name]], current$parameters[[name]])) {
+ differing <- c(differing, name)
+ }
+ }
+ if (!identical(stored$dots, current$dots)) {
+ differing <- c(differing, "...")
+ }
+ if (!identical(stored$apiUrl, current$apiUrl)) {
+ differing <- c(differing, "KorAP instance")
+ }
+
+ if (length(differing) == 0) {
+ NULL
+ } else {
+ sprintf("was created with different parameters (%s)", paste(differing, collapse = ", "))
+ }
+}
+
+#' Read back a result stored in a cache file, if it is the one being asked for
+#'
+#' Warns and returns `NULL` where the file exists but does not match, so that
+#' the caller recomputes and [writeCacheAs()] overwrites it.
+#'
+#' @param cacheAs cache file name, already passed through [cacheAsFileName()]
+#' @param kco [KorAPConnection()] object, for its `verbose` flag
+#' @param record what the call at hand computes, from [cacheAsRecord()]
+#' @param what name of the result, for the log and warning messages
+#' @return the cached result, or `NULL` if there is none to use
+#' @noRd
+readCacheAs <- function(cacheAs, kco, record, what) {
+ if (!file.exists(cacheAs)) {
+ return(NULL)
+ }
+
+ cached <- readRDS(cacheAs)
+ stored <- attr(cached, cacheAsAttribute)
+ attr(cached, cacheAsAttribute) <- NULL
+
+ reason <- cacheAsRejectionReason(stored, record)
+ if (is.null(reason)) {
+ log_info(kco@verbose, sprintf("Loading %s from cache: %s\n", what, cacheAs))
+ return(cached)
+ }
+
+ warning(
+ sprintf(
+ paste0(
+ "Cache file '%s' %s.\n",
+ "It is recomputed and overwritten; pass a different cacheAs file name to keep it."
+ ),
+ cacheAs, reason
+ ),
+ call. = FALSE
+ )
+ NULL
+}
+
+#' Store a result in a cache file, together with what produced it
+#'
+#' @param cacheAs cache file name, already passed through [cacheAsFileName()]
+#' @param kco [KorAPConnection()] object, for its `verbose` flag
+#' @param record what produced the result, from [cacheAsRecord()]
+#' @param what name of the result, for the log message
+#' @param result the result to store
+#' @return `result`, invisibly and unchanged
+#' @noRd
+writeCacheAs <- function(cacheAs, kco, record, what, result) {
+ log_info(kco@verbose, sprintf("Saving %s to cache: %s\n", what, cacheAs))
+ # only the stored copy carries the record, so that the returned value is the
+ # same whether it was cached or not
+ cachedResult <- result
+ attr(cachedResult, cacheAsAttribute) <- record
+ saveRDS(cachedResult, cacheAs)
+ invisible(result)
+}