| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 1 | #' Keeping results in a file of one's own |
| 2 | #' |
| 3 | #' The `cacheAs` parameter of the query functions is a different thing from the |
| 4 | #' `cache` parameter of [KorAPConnection()]. The latter is a transparent |
| 5 | #' speed-up: it stores server responses where the package finds them again, and |
| 6 | #' throwing it away costs nothing but time. The former stores a finished result |
| 7 | #' in a file the caller names and keeps, which is what makes an analysis |
| 8 | #' reproducible: KorAP corpora grow, so the same query returns different numbers |
| 9 | #' next year, and the scores computed from them may change with the package. |
| 10 | #' |
| 11 | #' A cache file therefore records what produced it, and is not reused when that |
| 12 | #' no longer matches what is being asked for. |
| 13 | #' |
| 14 | #' @name cacheAs |
| 15 | #' @keywords internal |
| 16 | NULL |
| 17 | |
| 18 | #' Attribute under which cache files record what produced them |
| 19 | #' @noRd |
| 20 | cacheAsAttribute <- "RKorAPClient.cacheAs" |
| 21 | |
| 22 | #' First version whose association scores are still computed the same way today |
| 23 | #' |
| 24 | #' `logDice()` and `ll()` were corrected in 1.4.0, so a file written by an |
| 25 | #' earlier version holds numbers that would not be arrived at again, which no |
| 26 | #' comparison of parameters can notice. |
| 27 | #' |
| 28 | #' Deliberately a constant of its own rather than the package version: what a |
| 29 | #' cache file has to be checked against is the generation of the scores, which |
| 30 | #' changes far more rarely than the version does, and reading it out of |
| 31 | #' DESCRIPTION would make the check depend on a release having been prepared. |
| 32 | #' Raise it whenever a score changes. |
| 33 | #' @noRd |
| 34 | cacheAsScoreVersion <- "1.4.0" |
| 35 | |
| Marc Kupietz | c3de702 | 2026-09-08 14:08:12 +0200 | [diff] [blame] | 36 | #' How cache files are to be treated on this run |
| 37 | #' |
| 38 | #' `"check"`, the default, uses a file only for the call that produced it. |
| 39 | #' `"reuse"` takes what a file holds whatever it says, and `"offline"` does the |
| 40 | #' same but refuses to compute anything that is not in a file already - which is |
| 41 | #' what you want when the talk starts in ten minutes. Set through |
| 42 | #' `options(rkorap.cacheAs=)` or the `KORAP_CACHE_AS` environment variable, the |
| 43 | #' option winning where both are given. |
| 44 | #' @noRd |
| 45 | cacheAsMode <- function() { |
| 46 | mode <- getOption("rkorap.cacheAs", default = NULL) |
| 47 | if (is.null(mode)) { |
| 48 | mode <- Sys.getenv("KORAP_CACHE_AS", unset = "check") |
| 49 | } |
| 50 | mode <- tolower(trimws(as.character(mode))) |
| 51 | if (!mode %in% c("check", "reuse", "offline")) { |
| 52 | stop( |
| 53 | sprintf( |
| 54 | "Unknown cacheAs mode '%s' - expected \"check\", \"reuse\" or \"offline\".", |
| 55 | mode |
| 56 | ), |
| 57 | call. = FALSE |
| 58 | ) |
| 59 | } |
| 60 | mode |
| 61 | } |
| 62 | |
| 63 | #' Take cached results as they are for the duration of an expression |
| 64 | #' |
| 65 | #' Sets the cacheAs mode (see [cacheAs]) while `expr` is evaluated and puts it |
| 66 | #' back afterwards, so that a whole document can be knitted from the files that |
| 67 | #' are there, without a stray `options()` call outliving it. |
| 68 | #' |
| 69 | #' @param expr the code to evaluate |
| 70 | #' @param mode `"reuse"` to take what the files hold, `"offline"` to refuse |
| 71 | #' computing anything that is not in one already |
| 72 | #' @return the value of `expr` |
| 73 | #' |
| 74 | #' @examples |
| 75 | #' \dontrun{ |
| 76 | #' withCachedResults({ |
| 77 | #' ca <- KorAPConnection() |> collocationAnalysis("Klima", cacheAs = "klima.rds") |
| 78 | #' freq <- KorAPConnection() |> frequencyQuery("Klima", cacheAs = "klima-freq.rds") |
| 79 | #' }) |
| 80 | #' } |
| 81 | #' |
| 82 | #' @family cacheAs |
| 83 | #' @export |
| 84 | withCachedResults <- function(expr, mode = c("reuse", "offline")) { |
| 85 | mode <- match.arg(mode) |
| 86 | previous <- getOption("rkorap.cacheAs") |
| 87 | on.exit(options(rkorap.cacheAs = previous), add = TRUE) |
| 88 | options(rkorap.cacheAs = mode) |
| 89 | expr |
| 90 | } |
| 91 | |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 92 | #' Append .rds to a cache file name that does not end in it |
| 93 | #' @noRd |
| 94 | cacheAsFileName <- function(cacheAs) { |
| 95 | if (grepl("\\.rds$", cacheAs, ignore.case = TRUE)) cacheAs else paste0(cacheAs, ".rds") |
| 96 | } |
| 97 | |
| 98 | #' What a cached result was computed with |
| 99 | #' |
| 100 | #' Collected from the calling function's frame, so that parameters added in the |
| 101 | #' future are taken into account automatically. `kco` and `cacheAs` are |
| 102 | #' excluded: the former is not a parameter of the analysis, the latter only says |
| 103 | #' where to store it. |
| 104 | #' |
| 105 | #' @param frame environment of the calling query function |
| 106 | #' @param dots its `...` arguments, or `NULL` where it has none |
| 107 | #' @param kco [KorAPConnection()] object |
| 108 | #' @return list to store with, and compare against, a cache file |
| 109 | #' @noRd |
| 110 | cacheAsRecord <- function(frame, dots, kco) { |
| 111 | parameterNames <- setdiff( |
| 112 | names(formals(sys.function(sys.parent()))), |
| 113 | # verbose says how loud the computation is, not what it computes |
| 114 | c("kco", "cacheAs", "verbose", "...") |
| 115 | ) |
| 116 | list( |
| 117 | # what has to match on the next call |
| 118 | scoreVersion = cacheAsScoreVersion, |
| 119 | # recorded so that a file can say where its numbers come from |
| 120 | packageVersion = as.character(utils::packageVersion("RKorAPClient")), |
| 121 | parameters = mget(parameterNames, envir = frame), |
| 122 | dots = dots, |
| 123 | # reusing one cache file for two KorAP instances is a mistake worth catching |
| 124 | apiUrl = kco@apiUrl, |
| 125 | # recorded for reference only, deliberately not compared: corpus updates |
| 126 | # should not invalidate a deliberately kept result |
| 127 | indexRevision = kco@indexRevision |
| 128 | ) |
| 129 | } |
| 130 | |
| 131 | #' Why a cache file cannot be used for the current call |
| 132 | #' |
| 133 | #' @param stored record read from the cache file, `NULL` if it has none |
| 134 | #' @param current record of the call at hand |
| 135 | #' @return a sentence naming the reason, or `NULL` if the file can be used |
| 136 | #' @noRd |
| 137 | cacheAsRejectionReason <- function(stored, current) { |
| Marc Kupietz | c6e7736 | 2026-09-08 14:07:49 +0200 | [diff] [blame] | 138 | # a file vouched for by blessCacheAs() counts as computed the way this version |
| 139 | # would compute it, whatever version actually wrote it |
| 140 | confirmed <- if (is.null(stored)) NULL else stored$scoresConfirmedFor |
| 141 | generation <- if (is.null(confirmed)) stored$scoreVersion else confirmed |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 142 | |
| 143 | if (is.null(generation) || package_version(generation) < package_version(cacheAsScoreVersion)) { |
| 144 | writtenBy <- if (is.null(stored)) NULL else stored$packageVersion |
| 145 | return(if (is.null(writtenBy)) { |
| 146 | sprintf( |
| 147 | "was written before RKorAPClient %s, which corrected logDice and ll", |
| 148 | cacheAsScoreVersion |
| 149 | ) |
| 150 | } else { |
| 151 | sprintf( |
| 152 | "was written by RKorAPClient %s, whose logDice and ll differ from those of %s", |
| 153 | writtenBy, cacheAsScoreVersion |
| 154 | ) |
| 155 | }) |
| 156 | } |
| 157 | |
| Marc Kupietz | c6e7736 | 2026-09-08 14:07:49 +0200 | [diff] [blame] | 158 | # a blessed file may not say what it was computed with, in which case there is |
| 159 | # nothing to compare and the blessing has to stand for it |
| 160 | if (is.null(stored$parameters)) { |
| 161 | return(NULL) |
| 162 | } |
| 163 | |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 164 | differing <- character(0) |
| 165 | for (name in union(names(stored$parameters), names(current$parameters))) { |
| 166 | if (!identical(stored$parameters[[name]], current$parameters[[name]])) { |
| 167 | differing <- c(differing, name) |
| 168 | } |
| 169 | } |
| 170 | if (!identical(stored$dots, current$dots)) { |
| 171 | differing <- c(differing, "...") |
| 172 | } |
| 173 | if (!identical(stored$apiUrl, current$apiUrl)) { |
| 174 | differing <- c(differing, "KorAP instance") |
| 175 | } |
| 176 | |
| 177 | if (length(differing) == 0) { |
| 178 | NULL |
| 179 | } else { |
| 180 | sprintf("was created with different parameters (%s)", paste(differing, collapse = ", ")) |
| 181 | } |
| 182 | } |
| 183 | |
| Marc Kupietz | c6e7736 | 2026-09-08 14:07:49 +0200 | [diff] [blame] | 184 | #' Vouch for a cacheAs file that an older version wrote |
| 185 | #' |
| 186 | #' Association scores changed in 1.4.0, so files from before it are recomputed |
| 187 | #' rather than used (see [cacheAs]). Where a file is known to hold what this |
| 188 | #' version would compute - because it was written by a development version that |
| 189 | #' already had the corrections, for instance - this records that, and the file is |
| 190 | #' used again as it is. |
| 191 | #' |
| 192 | #' What actually wrote a file is left as it stands; the blessing is recorded |
| 193 | #' beside it, so that [cacheAsInfo()] keeps telling the truth about where the |
| 194 | #' numbers come from. |
| 195 | #' |
| 196 | #' A file that records no parameters, as those written before 1.3.0.9000 do, has |
| 197 | #' nothing left to be compared against a call once it is blessed, and is |
| 198 | #' therefore reused for any call that names it. Bless such a file only if that |
| 199 | #' is what you mean. |
| 200 | #' |
| 201 | #' @param cacheAs paths of the files to vouch for, with or without their `.rds` |
| 202 | #' extension |
| 203 | #' @return the paths, invisibly |
| 204 | #' |
| 205 | #' @examples |
| 206 | #' \dontrun{ |
| 207 | #' blessCacheAs("klima-ca.rds") |
| 208 | #' blessCacheAs(list.files("data", pattern = "\\.rds$", full.names = TRUE)) |
| 209 | #' } |
| 210 | #' |
| 211 | #' @family cacheAs |
| 212 | #' @export |
| 213 | blessCacheAs <- function(cacheAs) { |
| 214 | for (file in cacheAs) { |
| 215 | file <- cacheAsFileName(file) |
| 216 | if (!file.exists(file)) { |
| 217 | stop(sprintf("Cache file '%s' does not exist.", file), call. = FALSE) |
| 218 | } |
| 219 | |
| 220 | content <- readRDS(file) |
| 221 | record <- attr(content, cacheAsAttribute) |
| 222 | if (is.null(record)) { |
| 223 | record <- list() |
| 224 | message(sprintf( |
| 225 | paste0( |
| 226 | "'%s' records no parameters, so it will be reused for any call ", |
| 227 | "naming it." |
| 228 | ), |
| 229 | file |
| 230 | )) |
| 231 | } |
| 232 | record$scoresConfirmedFor <- cacheAsScoreVersion |
| 233 | record$blessedAt <- Sys.time() |
| 234 | attr(content, cacheAsAttribute) <- record |
| 235 | saveRDS(content, file) |
| 236 | } |
| 237 | invisible(cacheAs) |
| 238 | } |
| 239 | |
| Marc Kupietz | afac514 | 2026-09-08 10:37:42 +0200 | [diff] [blame] | 240 | #' What produced a cacheAs file |
| 241 | #' |
| 242 | #' Reads back what a query function recorded in a [cacheAs] file: the parameters |
| 243 | #' it was called with, the KorAP instance it asked, the index revision that |
| 244 | #' instance's corpus had at the time, and the version of RKorAPClient that wrote |
| 245 | #' the file. Useful for saying, of a result kept next to a document, what the |
| 246 | #' numbers in it rest on. |
| 247 | #' |
| 248 | #' @param cacheAs path to the file, with or without its `.rds` extension |
| 249 | #' @return a list with the elements `scoreVersion`, `packageVersion`, |
| 250 | #' `parameters`, `dots`, |
| 251 | #' `apiUrl` and `indexRevision`, or `NULL` for a file written by a version |
| 252 | #' before 1.4.0, which recorded none of this |
| 253 | #' |
| 254 | #' @examples |
| 255 | #' \dontrun{ |
| 256 | #' KorAPConnection() |> frequencyQuery("Ameisenplage", cacheAs = "ameisenplage.rds") |
| 257 | #' cacheAsInfo("ameisenplage.rds") |
| 258 | #' } |
| 259 | #' |
| 260 | #' @family cacheAs |
| 261 | #' @export |
| 262 | cacheAsInfo <- function(cacheAs) { |
| 263 | cacheAs <- cacheAsFileName(cacheAs) |
| 264 | if (!file.exists(cacheAs)) { |
| 265 | stop(sprintf("Cache file '%s' does not exist.", cacheAs), call. = FALSE) |
| 266 | } |
| 267 | attr(readRDS(cacheAs), cacheAsAttribute) |
| 268 | } |
| 269 | |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 270 | #' Read back a result stored in a cache file, if it is the one being asked for |
| 271 | #' |
| 272 | #' Warns and returns `NULL` where the file exists but does not match, so that |
| Marc Kupietz | 437d7af | 2026-09-09 14:40:02 +0200 | [diff] [blame^] | 273 | #' the caller recomputes and `writeCacheAs()` overwrites it. |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 274 | #' |
| Marc Kupietz | 437d7af | 2026-09-09 14:40:02 +0200 | [diff] [blame^] | 275 | #' @param cacheAs cache file name, already passed through `cacheAsFileName()` |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 276 | #' @param kco [KorAPConnection()] object, for its `verbose` flag |
| Marc Kupietz | 437d7af | 2026-09-09 14:40:02 +0200 | [diff] [blame^] | 277 | #' @param record what the call at hand computes, from `cacheAsRecord()` |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 278 | #' @param what name of the result, for the log and warning messages |
| 279 | #' @return the cached result, or `NULL` if there is none to use |
| 280 | #' @noRd |
| 281 | readCacheAs <- function(cacheAs, kco, record, what) { |
| Marc Kupietz | c3de702 | 2026-09-08 14:08:12 +0200 | [diff] [blame] | 282 | mode <- cacheAsMode() |
| 283 | |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 284 | if (!file.exists(cacheAs)) { |
| Marc Kupietz | c3de702 | 2026-09-08 14:08:12 +0200 | [diff] [blame] | 285 | if (mode == "offline") { |
| 286 | stop( |
| 287 | sprintf( |
| 288 | "Cache file '%s' does not exist, and the cacheAs mode is \"offline\".", |
| 289 | cacheAs |
| 290 | ), |
| 291 | call. = FALSE |
| 292 | ) |
| 293 | } |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 294 | return(NULL) |
| 295 | } |
| 296 | |
| 297 | cached <- readRDS(cacheAs) |
| 298 | stored <- attr(cached, cacheAsAttribute) |
| 299 | attr(cached, cacheAsAttribute) <- NULL |
| 300 | |
| 301 | reason <- cacheAsRejectionReason(stored, record) |
| 302 | if (is.null(reason)) { |
| 303 | log_info(kco@verbose, sprintf("Loading %s from cache: %s\n", what, cacheAs)) |
| 304 | return(cached) |
| 305 | } |
| 306 | |
| Marc Kupietz | c3de702 | 2026-09-08 14:08:12 +0200 | [diff] [blame] | 307 | if (mode != "check") { |
| 308 | warning( |
| 309 | sprintf("Cache file '%s' %s, and is used as it is.", cacheAs, reason), |
| 310 | call. = FALSE |
| 311 | ) |
| 312 | return(cached) |
| 313 | } |
| 314 | |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 315 | warning( |
| 316 | sprintf( |
| 317 | paste0( |
| 318 | "Cache file '%s' %s.\n", |
| Marc Kupietz | c3de702 | 2026-09-08 14:08:12 +0200 | [diff] [blame] | 319 | "It is recomputed and overwritten. To keep it, pass a different cacheAs ", |
| 320 | "file name, vouch for it with blessCacheAs(), or take it as it is with ", |
| 321 | "withCachedResults()." |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 322 | ), |
| 323 | cacheAs, reason |
| 324 | ), |
| 325 | call. = FALSE |
| 326 | ) |
| 327 | NULL |
| 328 | } |
| 329 | |
| 330 | #' Store a result in a cache file, together with what produced it |
| 331 | #' |
| Marc Kupietz | 437d7af | 2026-09-09 14:40:02 +0200 | [diff] [blame^] | 332 | #' @param cacheAs cache file name, already passed through `cacheAsFileName()` |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 333 | #' @param kco [KorAPConnection()] object, for its `verbose` flag |
| Marc Kupietz | 437d7af | 2026-09-09 14:40:02 +0200 | [diff] [blame^] | 334 | #' @param record what produced the result, from `cacheAsRecord()` |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 335 | #' @param what name of the result, for the log message |
| 336 | #' @param result the result to store |
| 337 | #' @return `result`, invisibly and unchanged |
| 338 | #' @noRd |
| 339 | writeCacheAs <- function(cacheAs, kco, record, what, result) { |
| 340 | log_info(kco@verbose, sprintf("Saving %s to cache: %s\n", what, cacheAs)) |
| 341 | # only the stored copy carries the record, so that the returned value is the |
| 342 | # same whether it was cached or not |
| 343 | cachedResult <- result |
| 344 | attr(cachedResult, cacheAsAttribute) <- record |
| 345 | saveRDS(cachedResult, cacheAs) |
| 346 | invisible(result) |
| 347 | } |