Offer cacheAs on the other query functions as well

frequencyQuery(), corpusStats(), collocationScoreQuery() and
textMetadata() take a cacheAs file too now, not because they are slow -
they are not, next to a collocation analysis running for hours - but
because a file of one's own is what keeps 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 result kept
next to the script stays what it was, and the script runs without a
server at all.

The parameter is documented as what it is, next to the connection's
cache, which is a transparent speed-up one can throw away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Change-Id: I9bac8931e7a38a91adcbfe58ef4cb5bcdaaf3c47
diff --git a/R/KorAPCorpusStats.R b/R/KorAPCorpusStats.R
index 6fe44d8..0c23304 100644
--- a/R/KorAPCorpusStats.R
+++ b/R/KorAPCorpusStats.R
@@ -34,6 +34,7 @@
 #' @param vc string describing the virtual corpus. An empty string (default) means the whole corpus, as far as it is license-wise accessible.
 #' @param verbose logical. If `TRUE`, additional diagnostics are printed.
 #' @param as.df return result as data frame instead of as S4 object?
+#' @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).
 #' @return Object containing corpus statistics with the following information:
 #' \describe{
 #'   \item{`vc`}{Virtual corpus definition used (empty string for entire corpus)}
@@ -73,8 +74,19 @@
 setMethod("corpusStats", "KorAPConnection", function(kco,
                                                      vc = "",
                                                      verbose = kco@verbose,
-                                                     as.df = FALSE) {
-  if (length(vc) > 1) {
+                                                     as.df = FALSE,
+                                                     cacheAs = NULL) {
+  cacheRecord <- NULL
+  if (!is.null(cacheAs)) {
+    cacheAs <- cacheAsFileName(cacheAs)
+    cacheRecord <- cacheAsRecord(environment(), NULL, kco)
+    cached <- readCacheAs(cacheAs, kco, cacheRecord, "corpus statistics")
+    if (!is.null(cached)) {
+      return(cached)
+    }
+  }
+
+  stats <- if (length(vc) > 1) {
     # the names of a named vc vector would end up as row names, which the first
     # bind_rows() drops, so they are kept as a column instead
     vcLabel <- vcLabels(vc)
@@ -186,6 +198,11 @@
       )
     }
   }
+
+  if (!is.null(cacheAs)) {
+    writeCacheAs(cacheAs, kco, cacheRecord, "corpus statistics", stats)
+  }
+  stats
 })
 
 #' @rdname KorAPCorpusStats-class
diff --git a/R/KorAPQuery.R b/R/KorAPQuery.R
index 77774c8..a68f03e 100644
--- a/R/KorAPQuery.R
+++ b/R/KorAPQuery.R
@@ -1382,6 +1382,7 @@
 #' @param vc virtual corpus definition(s) (can be a vector)
 #' @param conf.level confidence level of the returned confidence interval (passed through [ci()]  to [prop.test()]).
 #' @param as.alternatives LOGICAL that specifies if the query terms should be treated as alternatives. If `as.alternatives` is TRUE, the sum over all query hits, instead of the respective vc token sizes is used as total for the calculation of relative frequencies.
+#' @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).
 #' @param ... further arguments passed to or from other methods (see [corpusQuery()]), most notably `expand`, a logical that decides if `query` and `vc` parameters are expanded to all of their combinations. It defaults to `TRUE`, if `query` and `vc` have different lengths, and to `FALSE` otherwise.
 #' @export
 #'
@@ -1397,8 +1398,19 @@
 
 setMethod(
   "frequencyQuery", "KorAPConnection",
-  function(kco, query, vc = "", conf.level = 0.95, as.alternatives = FALSE, ...) {
-    (if (as.alternatives) {
+  function(kco, query, vc = "", conf.level = 0.95, as.alternatives = FALSE,
+           cacheAs = NULL, ...) {
+    cacheRecord <- NULL
+    if (!is.null(cacheAs)) {
+      cacheAs <- cacheAsFileName(cacheAs)
+      cacheRecord <- cacheAsRecord(environment(), list(...), kco)
+      cached <- readCacheAs(cacheAs, kco, cacheRecord, "frequency query")
+      if (!is.null(cached)) {
+        return(cached)
+      }
+    }
+
+    result <- (if (as.alternatives) {
       corpusQuery(kco, query, vc, metadataOnly = TRUE, as.df = TRUE, ...) |>
         group_by(vc) |>
         mutate(total = sum(totalResults))
@@ -1407,6 +1419,11 @@
         mutate(total = corpusStats(kco, vc = vc, as.df = TRUE)$tokens)
     }) |>
       ci(conf.level = conf.level)
+
+    if (!is.null(cacheAs)) {
+      writeCacheAs(cacheAs, kco, cacheRecord, "frequency query", result)
+    }
+    result
   }
 )
 
diff --git a/R/collocationScoreQuery.R b/R/collocationScoreQuery.R
index 6f28bc4..8458583 100644
--- a/R/collocationScoreQuery.R
+++ b/R/collocationScoreQuery.R
@@ -25,6 +25,7 @@
 #' @param observed           if collocation frequencies are already known (or estimated from a sample) they can be passed as a vector here, otherwise: NA
 #' @param ignoreCollocateCase     logical, set to TRUE if collocate case should be ignored
 #' @param withinSpan         KorAP span specification (see <https://korap.ids-mannheim.de/doc/ql/poliqarp-plus?embedded=true#spans>) for collocations to be searched within. Defaults to `base/s=s`.
+#' @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).
 #'
 #' @return tibble with query KorAP web request URL, all observed values and association scores
 #'
@@ -71,8 +72,19 @@
                    smoothingConstant = .5,
                    observed = NA,
                    ignoreCollocateCase = FALSE,
-                   withinSpan = "base/s=s"
+                   withinSpan = "base/s=s",
+                   cacheAs = NULL
           ) {
+            cacheRecord <- NULL
+            if (!is.null(cacheAs)) {
+              cacheAs <- cacheAsFileName(cacheAs)
+              cacheRecord <- cacheAsRecord(environment(), NULL, kco)
+              cached <- readCacheAs(cacheAs, kco, cacheRecord, "collocation scores")
+              if (!is.null(cached)) {
+                return(cached)
+              }
+            }
+
             # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
             O1 <- O2 <- O <- N <- E <- w <- 0
 
@@ -122,7 +134,7 @@
               NULL
             }
 
-            tibble(
+            result <- tibble(
               node = node,
               collocate = combinations$collocate,
               # the names the caller gave their virtual corpora, where there
@@ -158,6 +170,10 @@
             ) %>%
               mutate(!!! lapply(scoreFunctions, mapply, .$O1, .$O2, .$O, .$N, .$E, .$w))
 
+            if (!is.null(cacheAs)) {
+              writeCacheAs(cacheAs, kco, cacheRecord, "collocation scores", result)
+            }
+            result
           })
 
 # #' @export
diff --git a/R/textMetadata.R b/R/textMetadata.R
index a0b115a..182bf97 100644
--- a/R/textMetadata.R
+++ b/R/textMetadata.R
@@ -15,6 +15,7 @@
 #' @param kco [KorAPConnection()] object (obtained e.g. from `KorAPConnection()`)
 #' @param textSigle unique text id (concatenation of corpus, document and text ids, separated by `/`, e.g. ) or vector thereof
 #' @param verbose logical. If `TRUE`, additional diagnostics are printed. Defaults to `kco@verbose`.
+#' @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).
 #'
 #' @return Tibble with columns for each metadata property. In case of errors, such as non-existing texts/sigles, the tibble will also contain a column called `errors`.
 #' If there are metadata columns you cannot make sense of, please ignore them. The function simply returns all the metadata it gets from the server.
@@ -32,10 +33,20 @@
 #' @export
 setMethod(
   "textMetadata", "KorAPConnection",
-  function(kco, textSigle, verbose = kco@verbose) {
+  function(kco, textSigle, verbose = kco@verbose, cacheAs = NULL) {
+    cacheRecord <- NULL
+    if (!is.null(cacheAs)) {
+      cacheAs <- cacheAsFileName(cacheAs)
+      cacheRecord <- cacheAsRecord(environment(), NULL, kco)
+      cached <- readCacheAs(cacheAs, kco, cacheRecord, "text metadata")
+      if (!is.null(cached)) {
+        return(cached)
+      }
+    }
+
     # https://stackoverflow.com/questions/8096313/no-visible-binding-for-global-variable-note-in-r-cmd-check
     key <- 0
-    if (length(textSigle) > 1) {
+    metadata <- if (length(textSigle) > 1) {
       do.call(bind_rows, Map(function(atomicSigle) {
         textMetadata(kco, atomicSigle)
       }, textSigle))
@@ -78,5 +89,10 @@
       }
       res
     }
+
+    if (!is.null(cacheAs)) {
+      writeCacheAs(cacheAs, kco, cacheRecord, "text metadata", metadata)
+    }
+    metadata
   }
 )