| 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) { |
| 82 | generation <- if (is.null(stored)) NULL else stored$scoreVersion |
| 83 | |
| 84 | if (is.null(generation) || package_version(generation) < package_version(cacheAsScoreVersion)) { |
| 85 | writtenBy <- if (is.null(stored)) NULL else stored$packageVersion |
| 86 | return(if (is.null(writtenBy)) { |
| 87 | sprintf( |
| 88 | "was written before RKorAPClient %s, which corrected logDice and ll", |
| 89 | cacheAsScoreVersion |
| 90 | ) |
| 91 | } else { |
| 92 | sprintf( |
| 93 | "was written by RKorAPClient %s, whose logDice and ll differ from those of %s", |
| 94 | writtenBy, cacheAsScoreVersion |
| 95 | ) |
| 96 | }) |
| 97 | } |
| 98 | |
| 99 | differing <- character(0) |
| 100 | for (name in union(names(stored$parameters), names(current$parameters))) { |
| 101 | if (!identical(stored$parameters[[name]], current$parameters[[name]])) { |
| 102 | differing <- c(differing, name) |
| 103 | } |
| 104 | } |
| 105 | if (!identical(stored$dots, current$dots)) { |
| 106 | differing <- c(differing, "...") |
| 107 | } |
| 108 | if (!identical(stored$apiUrl, current$apiUrl)) { |
| 109 | differing <- c(differing, "KorAP instance") |
| 110 | } |
| 111 | |
| 112 | if (length(differing) == 0) { |
| 113 | NULL |
| 114 | } else { |
| 115 | sprintf("was created with different parameters (%s)", paste(differing, collapse = ", ")) |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | #' Read back a result stored in a cache file, if it is the one being asked for |
| 120 | #' |
| 121 | #' Warns and returns `NULL` where the file exists but does not match, so that |
| 122 | #' the caller recomputes and [writeCacheAs()] overwrites it. |
| 123 | #' |
| 124 | #' @param cacheAs cache file name, already passed through [cacheAsFileName()] |
| 125 | #' @param kco [KorAPConnection()] object, for its `verbose` flag |
| 126 | #' @param record what the call at hand computes, from [cacheAsRecord()] |
| 127 | #' @param what name of the result, for the log and warning messages |
| 128 | #' @return the cached result, or `NULL` if there is none to use |
| 129 | #' @noRd |
| 130 | readCacheAs <- function(cacheAs, kco, record, what) { |
| 131 | if (!file.exists(cacheAs)) { |
| 132 | return(NULL) |
| 133 | } |
| 134 | |
| 135 | cached <- readRDS(cacheAs) |
| 136 | stored <- attr(cached, cacheAsAttribute) |
| 137 | attr(cached, cacheAsAttribute) <- NULL |
| 138 | |
| 139 | reason <- cacheAsRejectionReason(stored, record) |
| 140 | if (is.null(reason)) { |
| 141 | log_info(kco@verbose, sprintf("Loading %s from cache: %s\n", what, cacheAs)) |
| 142 | return(cached) |
| 143 | } |
| 144 | |
| 145 | warning( |
| 146 | sprintf( |
| 147 | paste0( |
| 148 | "Cache file '%s' %s.\n", |
| 149 | "It is recomputed and overwritten; pass a different cacheAs file name to keep it." |
| 150 | ), |
| 151 | cacheAs, reason |
| 152 | ), |
| 153 | call. = FALSE |
| 154 | ) |
| 155 | NULL |
| 156 | } |
| 157 | |
| 158 | #' Store a result in a cache file, together with what produced it |
| 159 | #' |
| 160 | #' @param cacheAs cache file name, already passed through [cacheAsFileName()] |
| 161 | #' @param kco [KorAPConnection()] object, for its `verbose` flag |
| 162 | #' @param record what produced the result, from [cacheAsRecord()] |
| 163 | #' @param what name of the result, for the log message |
| 164 | #' @param result the result to store |
| 165 | #' @return `result`, invisibly and unchanged |
| 166 | #' @noRd |
| 167 | writeCacheAs <- function(cacheAs, kco, record, what, result) { |
| 168 | log_info(kco@verbose, sprintf("Saving %s to cache: %s\n", what, cacheAs)) |
| 169 | # only the stored copy carries the record, so that the returned value is the |
| 170 | # same whether it was cached or not |
| 171 | cachedResult <- result |
| 172 | attr(cachedResult, cacheAsAttribute) <- record |
| 173 | saveRDS(cachedResult, cacheAs) |
| 174 | invisible(result) |
| 175 | } |