blob: c39b305b8f3bc1ac9b781352ed48c5afee965591 [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
36#' Append .rds to a cache file name that does not end in it
37#' @noRd
38cacheAsFileName <- 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
54cacheAsRecord <- 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
81cacheAsRejectionReason <- function(stored, current) {
Marc Kupietzc6e77362026-09-08 14:07:49 +020082 # 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 Kupietzc902c232026-09-08 07:58:01 +020086
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 Kupietzc6e77362026-09-08 14:07:49 +0200102 # 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 Kupietzc902c232026-09-08 07:58:01 +0200108 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 Kupietzc6e77362026-09-08 14:07:49 +0200128#' 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
157blessCacheAs <- 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 Kupietzafac5142026-09-08 10:37:42 +0200184#' 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
206cacheAsInfo <- 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 Kupietzc902c232026-09-08 07:58:01 +0200214#' 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
225readCacheAs <- 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
262writeCacheAs <- 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}