| 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 | |
| 36 | #' Append .rds to a cache file name that does not end in it |
| 37 | #' @noRd |
| 38 | cacheAsFileName <- function(cacheAs) { |
| 39 | if (grepl("\\.rds$", cacheAs, ignore.case = TRUE)) cacheAs else paste0(cacheAs, ".rds") |
| 40 | } |
| 41 | |
| 42 | #' What a cached result was computed with |
| 43 | #' |
| 44 | #' Collected from the calling function's frame, so that parameters added in the |
| 45 | #' future are taken into account automatically. `kco` and `cacheAs` are |
| 46 | #' excluded: the former is not a parameter of the analysis, the latter only says |
| 47 | #' where to store it. |
| 48 | #' |
| 49 | #' @param frame environment of the calling query function |
| 50 | #' @param dots its `...` arguments, or `NULL` where it has none |
| 51 | #' @param kco [KorAPConnection()] object |
| 52 | #' @return list to store with, and compare against, a cache file |
| 53 | #' @noRd |
| 54 | cacheAsRecord <- function(frame, dots, kco) { |
| 55 | parameterNames <- setdiff( |
| 56 | names(formals(sys.function(sys.parent()))), |
| 57 | # verbose says how loud the computation is, not what it computes |
| 58 | c("kco", "cacheAs", "verbose", "...") |
| 59 | ) |
| 60 | list( |
| 61 | # what has to match on the next call |
| 62 | scoreVersion = cacheAsScoreVersion, |
| 63 | # recorded so that a file can say where its numbers come from |
| 64 | packageVersion = as.character(utils::packageVersion("RKorAPClient")), |
| 65 | parameters = mget(parameterNames, envir = frame), |
| 66 | dots = dots, |
| 67 | # reusing one cache file for two KorAP instances is a mistake worth catching |
| 68 | apiUrl = kco@apiUrl, |
| 69 | # recorded for reference only, deliberately not compared: corpus updates |
| 70 | # should not invalidate a deliberately kept result |
| 71 | indexRevision = kco@indexRevision |
| 72 | ) |
| 73 | } |
| 74 | |
| 75 | #' Why a cache file cannot be used for the current call |
| 76 | #' |
| 77 | #' @param stored record read from the cache file, `NULL` if it has none |
| 78 | #' @param current record of the call at hand |
| 79 | #' @return a sentence naming the reason, or `NULL` if the file can be used |
| 80 | #' @noRd |
| 81 | cacheAsRejectionReason <- function(stored, current) { |
| Marc Kupietz | c6e7736 | 2026-09-08 14:07:49 +0200 | [diff] [blame^] | 82 | # a file vouched for by blessCacheAs() counts as computed the way this version |
| 83 | # would compute it, whatever version actually wrote it |
| 84 | confirmed <- if (is.null(stored)) NULL else stored$scoresConfirmedFor |
| 85 | generation <- if (is.null(confirmed)) stored$scoreVersion else confirmed |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 86 | |
| 87 | if (is.null(generation) || package_version(generation) < package_version(cacheAsScoreVersion)) { |
| 88 | writtenBy <- if (is.null(stored)) NULL else stored$packageVersion |
| 89 | return(if (is.null(writtenBy)) { |
| 90 | sprintf( |
| 91 | "was written before RKorAPClient %s, which corrected logDice and ll", |
| 92 | cacheAsScoreVersion |
| 93 | ) |
| 94 | } else { |
| 95 | sprintf( |
| 96 | "was written by RKorAPClient %s, whose logDice and ll differ from those of %s", |
| 97 | writtenBy, cacheAsScoreVersion |
| 98 | ) |
| 99 | }) |
| 100 | } |
| 101 | |
| Marc Kupietz | c6e7736 | 2026-09-08 14:07:49 +0200 | [diff] [blame^] | 102 | # a blessed file may not say what it was computed with, in which case there is |
| 103 | # nothing to compare and the blessing has to stand for it |
| 104 | if (is.null(stored$parameters)) { |
| 105 | return(NULL) |
| 106 | } |
| 107 | |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 108 | differing <- character(0) |
| 109 | for (name in union(names(stored$parameters), names(current$parameters))) { |
| 110 | if (!identical(stored$parameters[[name]], current$parameters[[name]])) { |
| 111 | differing <- c(differing, name) |
| 112 | } |
| 113 | } |
| 114 | if (!identical(stored$dots, current$dots)) { |
| 115 | differing <- c(differing, "...") |
| 116 | } |
| 117 | if (!identical(stored$apiUrl, current$apiUrl)) { |
| 118 | differing <- c(differing, "KorAP instance") |
| 119 | } |
| 120 | |
| 121 | if (length(differing) == 0) { |
| 122 | NULL |
| 123 | } else { |
| 124 | sprintf("was created with different parameters (%s)", paste(differing, collapse = ", ")) |
| 125 | } |
| 126 | } |
| 127 | |
| Marc Kupietz | c6e7736 | 2026-09-08 14:07:49 +0200 | [diff] [blame^] | 128 | #' Vouch for a cacheAs file that an older version wrote |
| 129 | #' |
| 130 | #' Association scores changed in 1.4.0, so files from before it are recomputed |
| 131 | #' rather than used (see [cacheAs]). Where a file is known to hold what this |
| 132 | #' version would compute - because it was written by a development version that |
| 133 | #' already had the corrections, for instance - this records that, and the file is |
| 134 | #' used again as it is. |
| 135 | #' |
| 136 | #' What actually wrote a file is left as it stands; the blessing is recorded |
| 137 | #' beside it, so that [cacheAsInfo()] keeps telling the truth about where the |
| 138 | #' numbers come from. |
| 139 | #' |
| 140 | #' A file that records no parameters, as those written before 1.3.0.9000 do, has |
| 141 | #' nothing left to be compared against a call once it is blessed, and is |
| 142 | #' therefore reused for any call that names it. Bless such a file only if that |
| 143 | #' is what you mean. |
| 144 | #' |
| 145 | #' @param cacheAs paths of the files to vouch for, with or without their `.rds` |
| 146 | #' extension |
| 147 | #' @return the paths, invisibly |
| 148 | #' |
| 149 | #' @examples |
| 150 | #' \dontrun{ |
| 151 | #' blessCacheAs("klima-ca.rds") |
| 152 | #' blessCacheAs(list.files("data", pattern = "\\.rds$", full.names = TRUE)) |
| 153 | #' } |
| 154 | #' |
| 155 | #' @family cacheAs |
| 156 | #' @export |
| 157 | blessCacheAs <- function(cacheAs) { |
| 158 | for (file in cacheAs) { |
| 159 | file <- cacheAsFileName(file) |
| 160 | if (!file.exists(file)) { |
| 161 | stop(sprintf("Cache file '%s' does not exist.", file), call. = FALSE) |
| 162 | } |
| 163 | |
| 164 | content <- readRDS(file) |
| 165 | record <- attr(content, cacheAsAttribute) |
| 166 | if (is.null(record)) { |
| 167 | record <- list() |
| 168 | message(sprintf( |
| 169 | paste0( |
| 170 | "'%s' records no parameters, so it will be reused for any call ", |
| 171 | "naming it." |
| 172 | ), |
| 173 | file |
| 174 | )) |
| 175 | } |
| 176 | record$scoresConfirmedFor <- cacheAsScoreVersion |
| 177 | record$blessedAt <- Sys.time() |
| 178 | attr(content, cacheAsAttribute) <- record |
| 179 | saveRDS(content, file) |
| 180 | } |
| 181 | invisible(cacheAs) |
| 182 | } |
| 183 | |
| Marc Kupietz | afac514 | 2026-09-08 10:37:42 +0200 | [diff] [blame] | 184 | #' What produced a cacheAs file |
| 185 | #' |
| 186 | #' Reads back what a query function recorded in a [cacheAs] file: the parameters |
| 187 | #' it was called with, the KorAP instance it asked, the index revision that |
| 188 | #' instance's corpus had at the time, and the version of RKorAPClient that wrote |
| 189 | #' the file. Useful for saying, of a result kept next to a document, what the |
| 190 | #' numbers in it rest on. |
| 191 | #' |
| 192 | #' @param cacheAs path to the file, with or without its `.rds` extension |
| 193 | #' @return a list with the elements `scoreVersion`, `packageVersion`, |
| 194 | #' `parameters`, `dots`, |
| 195 | #' `apiUrl` and `indexRevision`, or `NULL` for a file written by a version |
| 196 | #' before 1.4.0, which recorded none of this |
| 197 | #' |
| 198 | #' @examples |
| 199 | #' \dontrun{ |
| 200 | #' KorAPConnection() |> frequencyQuery("Ameisenplage", cacheAs = "ameisenplage.rds") |
| 201 | #' cacheAsInfo("ameisenplage.rds") |
| 202 | #' } |
| 203 | #' |
| 204 | #' @family cacheAs |
| 205 | #' @export |
| 206 | cacheAsInfo <- function(cacheAs) { |
| 207 | cacheAs <- cacheAsFileName(cacheAs) |
| 208 | if (!file.exists(cacheAs)) { |
| 209 | stop(sprintf("Cache file '%s' does not exist.", cacheAs), call. = FALSE) |
| 210 | } |
| 211 | attr(readRDS(cacheAs), cacheAsAttribute) |
| 212 | } |
| 213 | |
| Marc Kupietz | c902c23 | 2026-09-08 07:58:01 +0200 | [diff] [blame] | 214 | #' Read back a result stored in a cache file, if it is the one being asked for |
| 215 | #' |
| 216 | #' Warns and returns `NULL` where the file exists but does not match, so that |
| 217 | #' the caller recomputes and [writeCacheAs()] overwrites it. |
| 218 | #' |
| 219 | #' @param cacheAs cache file name, already passed through [cacheAsFileName()] |
| 220 | #' @param kco [KorAPConnection()] object, for its `verbose` flag |
| 221 | #' @param record what the call at hand computes, from [cacheAsRecord()] |
| 222 | #' @param what name of the result, for the log and warning messages |
| 223 | #' @return the cached result, or `NULL` if there is none to use |
| 224 | #' @noRd |
| 225 | readCacheAs <- function(cacheAs, kco, record, what) { |
| 226 | if (!file.exists(cacheAs)) { |
| 227 | return(NULL) |
| 228 | } |
| 229 | |
| 230 | cached <- readRDS(cacheAs) |
| 231 | stored <- attr(cached, cacheAsAttribute) |
| 232 | attr(cached, cacheAsAttribute) <- NULL |
| 233 | |
| 234 | reason <- cacheAsRejectionReason(stored, record) |
| 235 | if (is.null(reason)) { |
| 236 | log_info(kco@verbose, sprintf("Loading %s from cache: %s\n", what, cacheAs)) |
| 237 | return(cached) |
| 238 | } |
| 239 | |
| 240 | warning( |
| 241 | sprintf( |
| 242 | paste0( |
| 243 | "Cache file '%s' %s.\n", |
| 244 | "It is recomputed and overwritten; pass a different cacheAs file name to keep it." |
| 245 | ), |
| 246 | cacheAs, reason |
| 247 | ), |
| 248 | call. = FALSE |
| 249 | ) |
| 250 | NULL |
| 251 | } |
| 252 | |
| 253 | #' Store a result in a cache file, together with what produced it |
| 254 | #' |
| 255 | #' @param cacheAs cache file name, already passed through [cacheAsFileName()] |
| 256 | #' @param kco [KorAPConnection()] object, for its `verbose` flag |
| 257 | #' @param record what produced the result, from [cacheAsRecord()] |
| 258 | #' @param what name of the result, for the log message |
| 259 | #' @param result the result to store |
| 260 | #' @return `result`, invisibly and unchanged |
| 261 | #' @noRd |
| 262 | writeCacheAs <- function(cacheAs, kco, record, what, result) { |
| 263 | log_info(kco@verbose, sprintf("Saving %s to cache: %s\n", what, cacheAs)) |
| 264 | # only the stored copy carries the record, so that the returned value is the |
| 265 | # same whether it was cached or not |
| 266 | cachedResult <- result |
| 267 | attr(cachedResult, cacheAsAttribute) <- record |
| 268 | saveRDS(cachedResult, cacheAs) |
| 269 | invisible(result) |
| 270 | } |