blob: 63204bf4fb00ff1feae33d112bf5a60d4920f301 [file] [log] [blame]
Marc Kupietzc902c232026-09-08 07:58:01 +02001#' 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
16NULL
17
18#' Attribute under which cache files record what produced them
19#' @noRd
20cacheAsAttribute <- "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
34cacheAsScoreVersion <- "1.4.0"
35
Marc Kupietzc3de7022026-09-08 14:08:12 +020036#' 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
45cacheAsMode <- 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
84withCachedResults <- 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 Kupietzc902c232026-09-08 07:58:01 +020092#' Append .rds to a cache file name that does not end in it
93#' @noRd
94cacheAsFileName <- 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
110cacheAsRecord <- 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
137cacheAsRejectionReason <- function(stored, current) {
Marc Kupietzc6e77362026-09-08 14:07:49 +0200138 # 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 Kupietzc902c232026-09-08 07:58:01 +0200142
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 Kupietzc6e77362026-09-08 14:07:49 +0200158 # 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 Kupietzc902c232026-09-08 07:58:01 +0200164 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 Kupietzc6e77362026-09-08 14:07:49 +0200184#' 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
213blessCacheAs <- 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 Kupietzafac5142026-09-08 10:37:42 +0200240#' 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
262cacheAsInfo <- 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 Kupietzc902c232026-09-08 07:58:01 +0200270#' 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 Kupietz437d7af2026-09-09 14:40:02 +0200273#' the caller recomputes and `writeCacheAs()` overwrites it.
Marc Kupietzc902c232026-09-08 07:58:01 +0200274#'
Marc Kupietz437d7af2026-09-09 14:40:02 +0200275#' @param cacheAs cache file name, already passed through `cacheAsFileName()`
Marc Kupietzc902c232026-09-08 07:58:01 +0200276#' @param kco [KorAPConnection()] object, for its `verbose` flag
Marc Kupietz437d7af2026-09-09 14:40:02 +0200277#' @param record what the call at hand computes, from `cacheAsRecord()`
Marc Kupietzc902c232026-09-08 07:58:01 +0200278#' @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
281readCacheAs <- function(cacheAs, kco, record, what) {
Marc Kupietzc3de7022026-09-08 14:08:12 +0200282 mode <- cacheAsMode()
283
Marc Kupietzc902c232026-09-08 07:58:01 +0200284 if (!file.exists(cacheAs)) {
Marc Kupietzc3de7022026-09-08 14:08:12 +0200285 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 Kupietzc902c232026-09-08 07:58:01 +0200294 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 Kupietzc3de7022026-09-08 14:08:12 +0200307 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 Kupietzc902c232026-09-08 07:58:01 +0200315 warning(
316 sprintf(
317 paste0(
318 "Cache file '%s' %s.\n",
Marc Kupietzc3de7022026-09-08 14:08:12 +0200319 "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 Kupietzc902c232026-09-08 07:58:01 +0200322 ),
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 Kupietz437d7af2026-09-09 14:40:02 +0200332#' @param cacheAs cache file name, already passed through `cacheAsFileName()`
Marc Kupietzc902c232026-09-08 07:58:01 +0200333#' @param kco [KorAPConnection()] object, for its `verbose` flag
Marc Kupietz437d7af2026-09-09 14:40:02 +0200334#' @param record what produced the result, from `cacheAsRecord()`
Marc Kupietzc902c232026-09-08 07:58:01 +0200335#' @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
339writeCacheAs <- 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}