CA: check the parameters a cacheAs file was created with
Until now, a cacheAs file was used whenever it existed. Changing a
parameter and forgetting to delete the file therefore silently returned
an analysis computed with the old parameters. Inferring the parameters
from the cached data frame would not help much: of the 23 parameters
that affect the result, only node, vc, leftContextSize and
rightContextSize are recoverable from it, which leaves out exactly the
ones that are typically tuned between runs, such as minOccur,
topCollocatesLimit, searchHitsSampleLimit, exactFrequencies, stopwords
and threshold.
The parameters are therefore recorded when the file is written, and
compared on the next call. They are collected from the call frame rather
than listed explicitly, so that parameters added later are covered
without having to remember to register them. Also recorded is the API
URL, since reusing one cache file for two KorAP instances is a mistake
worth catching, and, for reference only, the index revision: corpus
updates should not invalidate an analysis that was deliberately kept.
If parameters differ, the analysis is recomputed and the file
overwritten, with a warning naming the differing parameters. That is
preferred over an error: it never returns results that disagree with the
requested parameters, and never breaks a long pipeline. Users who want
to keep an old analysis can pass a different cacheAs file name.
The parameters are stored in an attribute, so the file stays a plain
data frame for anyone reading it with readRDS(), and the attribute is
stripped when loading, so the returned value is the same whether it came
from the cache or not. Cache files written by 1.3.0 carry no attribute
and are used as they are.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: Ib21aa93fa3c2bf1cd42dd07dbfcbd2970841a412
diff --git a/NEWS.md b/NEWS.md
index 732f895..49b4319 100644
--- a/NEWS.md
+++ b/NEWS.md
@@ -1,5 +1,7 @@
# unpublished dev version 1.3.0.9000
+- `collocationAnalysis()` now stores the analysis parameters in its `cacheAs` file and compares them on the next call. If they differ, the cached result is not the one that was asked for, so it is recomputed and the file overwritten, with a warning naming the parameters that differ. This catches the case of a parameter being changed while an old cache file is still lying around. Cache files written by 1.3.0 do not contain the parameters yet and keep being used as they are
+
- dropped the `PTXQC` dependency, which was imported for two small string functions (`lcpCount()` and `lcsCount()`, used by `queryStringToLabel()`) but pulled in `rmzqc`, `jsonvalidate` and `V8`, and with them the only dependency requiring a `libv8` installation. The two functions are now implemented in the package itself, 10 to 65 times faster than the originals, and with unchanged results
# RKorAPClient 1.3.0
diff --git a/R/collocationAnalysis.R b/R/collocationAnalysis.R
index d185a0c..2d68636 100644
--- a/R/collocationAnalysis.R
+++ b/R/collocationAnalysis.R
@@ -1,6 +1,62 @@
#' @include logging.R
setGeneric("collocationAnalysis", function(kco, ...) standardGeneric("collocationAnalysis"))
+#' Name of the attribute under which cache files record their analysis parameters
+#' @noRd
+collocationCacheAttribute <- "RKorAPClient.collocationAnalysis"
+
+#' Parameters that a cached collocation analysis was computed with
+#'
+#' Collected from the calling `collocationAnalysis()` 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 `collocationAnalysis()` call
+#' @param dots arguments passed on to [collocationScoreQuery()]
+#' @param kco [KorAPConnection()] object
+#' @return list of parameters to store with, and compare against, a cache file
+#' @noRd
+collocationCacheParameters <- function(frame, dots, kco) {
+ parameterNames <- setdiff(
+ names(formals(sys.function(sys.parent()))),
+ c("kco", "cacheAs", "...")
+ )
+ list(
+ 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 analysis
+ indexRevision = kco@indexRevision
+ )
+}
+
+#' Parameters in which a cached collocation analysis differs from the current call
+#'
+#' @param stored parameters recorded in the cache file
+#' @param current parameters of the current call
+#' @return names of the differing parameters, empty if the cache is still valid
+#' @noRd
+differingCollocationCacheParameters <- function(stored, current) {
+ 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")
+ }
+
+ differing
+}
+
#' Collocation analysis
#'
#' @family collocation analysis functions
@@ -46,6 +102,13 @@
#' @param missingScoreQuantile lower quantile (evaluated per association measure over the pooled result set) that anchors the adaptive floor used for imputing missing scores between virtual corpora; a robust spread is subtracted from this anchor so the imputed values stay at or below the weakest observed scores. Imputed cells are marked in the `imputed*` columns; see the section on interpreting multi-VC comparisons below
#' @param vcLabel optional label override for the current virtual corpus (used internally when named VC collections are expanded)
#' @param cacheAs path to an RDS file for caching the result. If the file already exists, the cached result is loaded and returned immediately without contacting the server. Otherwise the analysis is run normally and the result is saved to the file before returning. Defaults to \code{NULL} (no caching).
+#'
+#' The analysis parameters are stored alongside the result. If they differ from
+#' those of the current call, the cached result would not be the one that was
+#' asked for, so it is recomputed and the file overwritten, with a warning
+#' naming the parameters that differ. Pass a different \code{cacheAs} file name
+#' to keep an existing analysis. Cache files written by RKorAPClient 1.3.0 do
+#' not contain the parameters yet and are used as they are.
#' @param ... more arguments will be passed to [collocationScoreQuery()]
#' @inheritParams collocationScoreQuery,KorAPConnection-method
#' @return
@@ -163,13 +226,43 @@
...) {
word <- frequency <- O <- NULL
- if (!is.null(cacheAs) && !grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
- cacheAs <- paste0(cacheAs, ".rds")
+ cacheParameters <- NULL
+ if (!is.null(cacheAs)) {
+ if (!grepl("\\.rds$", cacheAs, ignore.case = TRUE)) {
+ cacheAs <- paste0(cacheAs, ".rds")
+ }
+ cacheParameters <- collocationCacheParameters(environment(), list(...), kco)
}
if (!is.null(cacheAs) && file.exists(cacheAs)) {
- log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
- return(readRDS(cacheAs))
+ cached <- readRDS(cacheAs)
+ storedParameters <- attr(cached, collocationCacheAttribute)
+ attr(cached, collocationCacheAttribute) <- NULL
+
+ if (is.null(storedParameters)) {
+ # written before parameter checking existed, so there is nothing to check
+ log_info(kco@verbose, sprintf(
+ "Loading collocation analysis from cache (written without parameters): %s\n", cacheAs
+ ))
+ return(cached)
+ }
+
+ differing <- differingCollocationCacheParameters(storedParameters, cacheParameters)
+ if (length(differing) == 0) {
+ log_info(kco@verbose, sprintf("Loading collocation analysis from cache: %s\n", cacheAs))
+ return(cached)
+ }
+
+ warning(
+ sprintf(
+ paste0(
+ "Cache file '%s' was created with different parameters (%s) and is recomputed and overwritten.\n",
+ "Pass a different cacheAs file name to keep the cached analysis."
+ ),
+ cacheAs, paste(differing, collapse = ", ")
+ ),
+ call. = FALSE
+ )
}
if (!exactFrequencies && (!is.na(withinSpan) && !is.null(withinSpan) && nzchar(withinSpan))) {
@@ -417,7 +510,11 @@
if (!is.null(cacheAs)) {
log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", cacheAs))
- saveRDS(result, cacheAs)
+ # only the stored copy carries the parameters, so that the returned value
+ # is the same whether it was cached or not
+ cachedResult <- result
+ attr(cachedResult, collocationCacheAttribute) <- cacheParameters
+ saveRDS(cachedResult, cacheAs)
}
result
diff --git a/man/collocationAnalysis-KorAPConnection-method.Rd b/man/collocationAnalysis-KorAPConnection-method.Rd
index 02c3f03..734a2f6 100644
--- a/man/collocationAnalysis-KorAPConnection-method.Rd
+++ b/man/collocationAnalysis-KorAPConnection-method.Rd
@@ -83,7 +83,14 @@
\item{vcLabel}{optional label override for the current virtual corpus (used internally when named VC collections are expanded)}
-\item{cacheAs}{path to an RDS file for caching the result. If the file already exists, the cached result is loaded and returned immediately without contacting the server. Otherwise the analysis is run normally and the result is saved to the file before returning. Defaults to \code{NULL} (no caching).}
+\item{cacheAs}{path to an RDS file for caching the result. If the file already exists, the cached result is loaded and returned immediately without contacting the server. Otherwise the analysis is run normally and the result is saved to the file before returning. Defaults to \code{NULL} (no caching).
+
+The analysis parameters are stored alongside the result. If they differ from
+those of the current call, the cached result would not be the one that was
+asked for, so it is recomputed and the file overwritten, with a warning
+naming the parameters that differ. Pass a different \code{cacheAs} file name
+to keep an existing analysis. Cache files written by RKorAPClient 1.3.0 do
+not contain the parameters yet and are used as they are.}
\item{...}{more arguments will be passed to \code{\link[=collocationScoreQuery]{collocationScoreQuery()}}}
}
diff --git a/tests/testthat/test-collocation-cache-parameters.R b/tests/testthat/test-collocation-cache-parameters.R
new file mode 100644
index 0000000..06e8ca1
--- /dev/null
+++ b/tests/testthat/test-collocation-cache-parameters.R
@@ -0,0 +1,174 @@
+offlineConnection <- function() {
+ methods::new(
+ "KorAPConnection",
+ apiUrl = "https://example.invalid/",
+ KorAPUrl = "https://example.invalid/",
+ # keeps the unrelated "authorize your application" warning out of the way
+ authorizationSupported = FALSE,
+ verbose = FALSE
+ )
+}
+
+# Returning no candidates lets collocationAnalysis() finish without contacting
+# the server, so that the caching around it can be tested offline.
+mockEmptyAnalysis <- function() {
+ testthat::local_mocked_bindings(
+ collocatesQuery = function(...) tibble::tibble(),
+ .package = "RKorAPClient",
+ .env = parent.frame()
+ )
+}
+
+test_that("cache files record the parameters of the analysis", {
+ mockEmptyAnalysis()
+ cacheFile <- tempfile(fileext = ".rds")
+ on.exit(unlink(cacheFile), add = TRUE)
+
+ collocationAnalysis(offlineConnection(), "Test", minOccur = 3, cacheAs = cacheFile)
+
+ stored <- attr(readRDS(cacheFile), RKorAPClient:::collocationCacheAttribute)
+ expect_false(is.null(stored))
+ expect_equal(stored$parameters$node, "Test")
+ expect_equal(stored$parameters$minOccur, 3)
+ expect_equal(stored$apiUrl, "https://example.invalid/")
+ # every parameter of the analysis is recorded, not just those visible in the
+ # result, so that added parameters are covered automatically
+ expect_true(all(c(
+ "topCollocatesLimit", "searchHitsSampleLimit", "exactFrequencies",
+ "stopwords", "threshold", "collocateFilterRegex", "seed"
+ ) %in% names(stored$parameters)))
+ # kco and cacheAs are not parameters of the analysis
+ expect_false(any(c("kco", "cacheAs") %in% names(stored$parameters)))
+})
+
+test_that("the returned result is the same whether it was cached or not", {
+ mockEmptyAnalysis()
+ cacheFile <- tempfile(fileext = ".rds")
+ on.exit(unlink(cacheFile), add = TRUE)
+ kco <- offlineConnection()
+
+ fresh <- collocationAnalysis(kco, "Test", cacheAs = cacheFile)
+ fromCache <- collocationAnalysis(kco, "Test", cacheAs = cacheFile)
+
+ expect_equal(fromCache, fresh)
+ # the parameters live in the file only, not in the returned value
+ expect_null(attr(fromCache, RKorAPClient:::collocationCacheAttribute))
+})
+
+test_that("an unchanged call is served from the cache without contacting the server", {
+ cacheFile <- tempfile(fileext = ".rds")
+ on.exit(unlink(cacheFile), add = TRUE)
+ kco <- offlineConnection()
+
+ local({
+ mockEmptyAnalysis()
+ collocationAnalysis(kco, "Test", minOccur = 3, cacheAs = cacheFile)
+ })
+
+ testthat::local_mocked_bindings(
+ collocatesQuery = function(...) stop("server must not be contacted"),
+ .package = "RKorAPClient"
+ )
+ expect_no_warning(collocationAnalysis(kco, "Test", minOccur = 3, cacheAs = cacheFile))
+})
+
+test_that("changed parameters make the analysis be recomputed, with a warning", {
+ cacheFile <- tempfile(fileext = ".rds")
+ on.exit(unlink(cacheFile), add = TRUE)
+ kco <- offlineConnection()
+
+ local({
+ mockEmptyAnalysis()
+ collocationAnalysis(kco, "Test", minOccur = 3, cacheAs = cacheFile)
+ })
+
+ testthat::local_mocked_bindings(
+ collocatesQuery = function(...) stop("recomputed"),
+ .package = "RKorAPClient"
+ )
+
+ # the warning names the parameter that differs, and the analysis is rerun
+ expect_warning(
+ expect_error(
+ collocationAnalysis(kco, "Test", minOccur = 5, cacheAs = cacheFile),
+ "recomputed"
+ ),
+ "minOccur"
+ )
+
+ # a changed node is caught as well
+ expect_warning(
+ expect_error(
+ collocationAnalysis(kco, "Other", minOccur = 3, cacheAs = cacheFile),
+ "recomputed"
+ ),
+ "node"
+ )
+})
+
+test_that("a recomputed analysis overwrites the stale cache file", {
+ cacheFile <- tempfile(fileext = ".rds")
+ on.exit(unlink(cacheFile), add = TRUE)
+ kco <- offlineConnection()
+
+ local({
+ mockEmptyAnalysis()
+ collocationAnalysis(kco, "Test", minOccur = 3, cacheAs = cacheFile)
+ })
+
+ local({
+ mockEmptyAnalysis()
+ expect_warning(
+ collocationAnalysis(kco, "Test", minOccur = 5, cacheAs = cacheFile),
+ "minOccur"
+ )
+ })
+
+ stored <- attr(readRDS(cacheFile), RKorAPClient:::collocationCacheAttribute)
+ expect_equal(stored$parameters$minOccur, 5)
+})
+
+test_that("cache files written without parameters are still used", {
+ cacheFile <- tempfile(fileext = ".rds")
+ on.exit(unlink(cacheFile), add = TRUE)
+ kco <- offlineConnection()
+
+ # as written by RKorAPClient 1.3.0
+ legacy <- tibble::tibble(node = "Test", collocate = "c", logDice = 7)
+ saveRDS(legacy, cacheFile)
+
+ testthat::local_mocked_bindings(
+ collocatesQuery = function(...) stop("server must not be contacted"),
+ .package = "RKorAPClient"
+ )
+ expect_equal(collocationAnalysis(kco, "Test", cacheAs = cacheFile), legacy)
+})
+
+test_that("differingCollocationCacheParameters reports what changed", {
+ differing <- RKorAPClient:::differingCollocationCacheParameters
+
+ stored <- list(
+ parameters = list(node = "Test", minOccur = 3, vc = ""),
+ dots = list(),
+ apiUrl = "https://korap.ids-mannheim.de/api/v1.0/"
+ )
+
+ expect_equal(differing(stored, stored), character(0))
+
+ changed <- stored
+ changed$parameters$minOccur <- 5
+ expect_equal(differing(stored, changed), "minOccur")
+
+ changed <- stored
+ changed$parameters$minOccur <- 5
+ changed$parameters$vc <- "textType=/Zeit.*/"
+ expect_setequal(differing(stored, changed), c("minOccur", "vc"))
+
+ changed <- stored
+ changed$dots <- list(smoothingConstant = 1)
+ expect_equal(differing(stored, changed), "...")
+
+ changed <- stored
+ changed$apiUrl <- "https://korap.dnb.de/api/v1.0/"
+ expect_equal(differing(stored, changed), "KorAP instance")
+})