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/collocationAnalysis.R b/R/collocationAnalysis.R
index c9b8695..c54b46d 100644
--- a/R/collocationAnalysis.R
+++ b/R/collocationAnalysis.R
@@ -20,61 +20,6 @@
   result[is.na(result$E) | result$O >= minObservedExpectedRatio * result$E, , drop = FALSE]
 }
 
-#' 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
 #'
@@ -135,7 +80,7 @@
 #' @param queryMissingScores     if TRUE, attempt to retrieve corpus-based association scores for vc/collocate combinations that would otherwise be imputed, by re-querying the KorAP backend without applying the collocate frequency threshold
 #' @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).
+#' @param cacheAs path to an RDS file to keep the result in. If the file exists and records the same call, it is read back instead of contacting the server; otherwise the query is run and its result stored there. Unlike the connection's `cache`, this file belongs to the caller, which is what keeps an analysis reproducible once the corpus has grown or the scores have changed. Defaults to \code{NULL} (no file).
 #'
 #'   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
@@ -261,43 +206,14 @@
            ...) {
     word <- frequency <- O <- NULL
 
-    cacheParameters <- NULL
+    cacheRecord <- 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)) {
-      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
-        ))
+      cacheAs <- cacheAsFileName(cacheAs)
+      cacheRecord <- cacheAsRecord(environment(), list(...), kco)
+      cached <- readCacheAs(cacheAs, kco, cacheRecord, "collocation analysis")
+      if (!is.null(cached)) {
         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))) {
@@ -548,12 +464,7 @@
     }
 
     if (!is.null(cacheAs)) {
-      log_info(kco@verbose, sprintf("Saving collocation analysis to cache: %s\n", 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)
+      writeCacheAs(cacheAs, kco, cacheRecord, "collocation analysis", result)
     }
 
     result