| #' Keeping results in a file of one's own |
| #' |
| #' The `cacheAs` parameter of the query functions is a different thing from the |
| #' `cache` parameter of [KorAPConnection()]. The latter is a transparent |
| #' speed-up: it stores server responses where the package finds them again, and |
| #' throwing it away costs nothing but time. The former stores a finished result |
| #' in a file the caller names and keeps, which is what makes an analysis |
| #' reproducible: KorAP corpora grow, so the same query returns different numbers |
| #' next year, and the scores computed from them may change with the package. |
| #' |
| #' A cache file therefore records what produced it, and is not reused when that |
| #' no longer matches what is being asked for. |
| #' |
| #' @name cacheAs |
| #' @keywords internal |
| NULL |
| |
| #' Attribute under which cache files record what produced them |
| #' @noRd |
| cacheAsAttribute <- "RKorAPClient.cacheAs" |
| |
| #' First version whose association scores are still computed the same way today |
| #' |
| #' `logDice()` and `ll()` were corrected in 1.4.0, so a file written by an |
| #' earlier version holds numbers that would not be arrived at again, which no |
| #' comparison of parameters can notice. |
| #' |
| #' Deliberately a constant of its own rather than the package version: what a |
| #' cache file has to be checked against is the generation of the scores, which |
| #' changes far more rarely than the version does, and reading it out of |
| #' DESCRIPTION would make the check depend on a release having been prepared. |
| #' Raise it whenever a score changes. |
| #' @noRd |
| cacheAsScoreVersion <- "1.4.0" |
| |
| #' How cache files are to be treated on this run |
| #' |
| #' `"check"`, the default, uses a file only for the call that produced it. |
| #' `"reuse"` takes what a file holds whatever it says, and `"offline"` does the |
| #' same but refuses to compute anything that is not in a file already - which is |
| #' what you want when the talk starts in ten minutes. Set through |
| #' `options(rkorap.cacheAs=)` or the `KORAP_CACHE_AS` environment variable, the |
| #' option winning where both are given. |
| #' @noRd |
| cacheAsMode <- function() { |
| mode <- getOption("rkorap.cacheAs", default = NULL) |
| if (is.null(mode)) { |
| mode <- Sys.getenv("KORAP_CACHE_AS", unset = "check") |
| } |
| mode <- tolower(trimws(as.character(mode))) |
| if (!mode %in% c("check", "reuse", "offline")) { |
| stop( |
| sprintf( |
| "Unknown cacheAs mode '%s' - expected \"check\", \"reuse\" or \"offline\".", |
| mode |
| ), |
| call. = FALSE |
| ) |
| } |
| mode |
| } |
| |
| #' Take cached results as they are for the duration of an expression |
| #' |
| #' Sets the cacheAs mode (see [cacheAs]) while `expr` is evaluated and puts it |
| #' back afterwards, so that a whole document can be knitted from the files that |
| #' are there, without a stray `options()` call outliving it. |
| #' |
| #' @param expr the code to evaluate |
| #' @param mode `"reuse"` to take what the files hold, `"offline"` to refuse |
| #' computing anything that is not in one already |
| #' @return the value of `expr` |
| #' |
| #' @examples |
| #' \dontrun{ |
| #' withCachedResults({ |
| #' ca <- KorAPConnection() |> collocationAnalysis("Klima", cacheAs = "klima.rds") |
| #' freq <- KorAPConnection() |> frequencyQuery("Klima", cacheAs = "klima-freq.rds") |
| #' }) |
| #' } |
| #' |
| #' @family cacheAs |
| #' @export |
| withCachedResults <- function(expr, mode = c("reuse", "offline")) { |
| mode <- match.arg(mode) |
| previous <- getOption("rkorap.cacheAs") |
| on.exit(options(rkorap.cacheAs = previous), add = TRUE) |
| options(rkorap.cacheAs = mode) |
| expr |
| } |
| |
| #' Append .rds to a cache file name that does not end in it |
| #' @noRd |
| cacheAsFileName <- function(cacheAs) { |
| if (grepl("\\.rds$", cacheAs, ignore.case = TRUE)) cacheAs else paste0(cacheAs, ".rds") |
| } |
| |
| #' What a cached result was computed with |
| #' |
| #' Collected from the calling function's frame, so that parameters added in the |
| #' future are taken into account automatically. `kco` and `cacheAs` are |
| #' excluded: the former is not a parameter of the analysis, the latter only says |
| #' where to store it. |
| #' |
| #' @param frame environment of the calling query function |
| #' @param dots its `...` arguments, or `NULL` where it has none |
| #' @param kco [KorAPConnection()] object |
| #' @return list to store with, and compare against, a cache file |
| #' @noRd |
| cacheAsRecord <- function(frame, dots, kco) { |
| parameterNames <- setdiff( |
| names(formals(sys.function(sys.parent()))), |
| # verbose says how loud the computation is, not what it computes |
| c("kco", "cacheAs", "verbose", "...") |
| ) |
| list( |
| # what has to match on the next call |
| scoreVersion = cacheAsScoreVersion, |
| # recorded so that a file can say where its numbers come from |
| packageVersion = as.character(utils::packageVersion("RKorAPClient")), |
| parameters = mget(parameterNames, envir = frame), |
| dots = dots, |
| # reusing one cache file for two KorAP instances is a mistake worth catching |
| apiUrl = kco@apiUrl, |
| # recorded for reference only, deliberately not compared: corpus updates |
| # should not invalidate a deliberately kept result |
| indexRevision = kco@indexRevision |
| ) |
| } |
| |
| #' Why a cache file cannot be used for the current call |
| #' |
| #' @param stored record read from the cache file, `NULL` if it has none |
| #' @param current record of the call at hand |
| #' @return a sentence naming the reason, or `NULL` if the file can be used |
| #' @noRd |
| cacheAsRejectionReason <- function(stored, current) { |
| # a file vouched for by blessCacheAs() counts as computed the way this version |
| # would compute it, whatever version actually wrote it |
| confirmed <- if (is.null(stored)) NULL else stored$scoresConfirmedFor |
| generation <- if (is.null(confirmed)) stored$scoreVersion else confirmed |
| |
| if (is.null(generation) || package_version(generation) < package_version(cacheAsScoreVersion)) { |
| writtenBy <- if (is.null(stored)) NULL else stored$packageVersion |
| return(if (is.null(writtenBy)) { |
| sprintf( |
| "was written before RKorAPClient %s, which corrected logDice and ll", |
| cacheAsScoreVersion |
| ) |
| } else { |
| sprintf( |
| "was written by RKorAPClient %s, whose logDice and ll differ from those of %s", |
| writtenBy, cacheAsScoreVersion |
| ) |
| }) |
| } |
| |
| # a blessed file may not say what it was computed with, in which case there is |
| # nothing to compare and the blessing has to stand for it |
| if (is.null(stored$parameters)) { |
| return(NULL) |
| } |
| |
| differing <- character(0) |
| for (name in union(names(stored$parameters), names(current$parameters))) { |
| if (!identical(stored$parameters[[name]], current$parameters[[name]])) { |
| differing <- c(differing, name) |
| } |
| } |
| if (!identical(stored$dots, current$dots)) { |
| differing <- c(differing, "...") |
| } |
| if (!identical(stored$apiUrl, current$apiUrl)) { |
| differing <- c(differing, "KorAP instance") |
| } |
| |
| if (length(differing) == 0) { |
| NULL |
| } else { |
| sprintf("was created with different parameters (%s)", paste(differing, collapse = ", ")) |
| } |
| } |
| |
| #' Vouch for a cacheAs file that an older version wrote |
| #' |
| #' Association scores changed in 1.4.0, so files from before it are recomputed |
| #' rather than used (see [cacheAs]). Where a file is known to hold what this |
| #' version would compute - because it was written by a development version that |
| #' already had the corrections, for instance - this records that, and the file is |
| #' used again as it is. |
| #' |
| #' What actually wrote a file is left as it stands; the blessing is recorded |
| #' beside it, so that [cacheAsInfo()] keeps telling the truth about where the |
| #' numbers come from. |
| #' |
| #' A file that records no parameters, as those written before 1.3.0.9000 do, has |
| #' nothing left to be compared against a call once it is blessed, and is |
| #' therefore reused for any call that names it. Bless such a file only if that |
| #' is what you mean. |
| #' |
| #' @param cacheAs paths of the files to vouch for, with or without their `.rds` |
| #' extension |
| #' @return the paths, invisibly |
| #' |
| #' @examples |
| #' \dontrun{ |
| #' blessCacheAs("klima-ca.rds") |
| #' blessCacheAs(list.files("data", pattern = "\\.rds$", full.names = TRUE)) |
| #' } |
| #' |
| #' @family cacheAs |
| #' @export |
| blessCacheAs <- function(cacheAs) { |
| for (file in cacheAs) { |
| file <- cacheAsFileName(file) |
| if (!file.exists(file)) { |
| stop(sprintf("Cache file '%s' does not exist.", file), call. = FALSE) |
| } |
| |
| content <- readRDS(file) |
| record <- attr(content, cacheAsAttribute) |
| if (is.null(record)) { |
| record <- list() |
| message(sprintf( |
| paste0( |
| "'%s' records no parameters, so it will be reused for any call ", |
| "naming it." |
| ), |
| file |
| )) |
| } |
| record$scoresConfirmedFor <- cacheAsScoreVersion |
| record$blessedAt <- Sys.time() |
| attr(content, cacheAsAttribute) <- record |
| saveRDS(content, file) |
| } |
| invisible(cacheAs) |
| } |
| |
| #' What produced a cacheAs file |
| #' |
| #' Reads back what a query function recorded in a [cacheAs] file: the parameters |
| #' it was called with, the KorAP instance it asked, the index revision that |
| #' instance's corpus had at the time, and the version of RKorAPClient that wrote |
| #' the file. Useful for saying, of a result kept next to a document, what the |
| #' numbers in it rest on. |
| #' |
| #' @param cacheAs path to the file, with or without its `.rds` extension |
| #' @return a list with the elements `scoreVersion`, `packageVersion`, |
| #' `parameters`, `dots`, |
| #' `apiUrl` and `indexRevision`, or `NULL` for a file written by a version |
| #' before 1.4.0, which recorded none of this |
| #' |
| #' @examples |
| #' \dontrun{ |
| #' KorAPConnection() |> frequencyQuery("Ameisenplage", cacheAs = "ameisenplage.rds") |
| #' cacheAsInfo("ameisenplage.rds") |
| #' } |
| #' |
| #' @family cacheAs |
| #' @export |
| cacheAsInfo <- function(cacheAs) { |
| cacheAs <- cacheAsFileName(cacheAs) |
| if (!file.exists(cacheAs)) { |
| stop(sprintf("Cache file '%s' does not exist.", cacheAs), call. = FALSE) |
| } |
| attr(readRDS(cacheAs), cacheAsAttribute) |
| } |
| |
| #' Read back a result stored in a cache file, if it is the one being asked for |
| #' |
| #' Warns and returns `NULL` where the file exists but does not match, so that |
| #' the caller recomputes and [writeCacheAs()] overwrites it. |
| #' |
| #' @param cacheAs cache file name, already passed through [cacheAsFileName()] |
| #' @param kco [KorAPConnection()] object, for its `verbose` flag |
| #' @param record what the call at hand computes, from [cacheAsRecord()] |
| #' @param what name of the result, for the log and warning messages |
| #' @return the cached result, or `NULL` if there is none to use |
| #' @noRd |
| readCacheAs <- function(cacheAs, kco, record, what) { |
| mode <- cacheAsMode() |
| |
| if (!file.exists(cacheAs)) { |
| if (mode == "offline") { |
| stop( |
| sprintf( |
| "Cache file '%s' does not exist, and the cacheAs mode is \"offline\".", |
| cacheAs |
| ), |
| call. = FALSE |
| ) |
| } |
| return(NULL) |
| } |
| |
| cached <- readRDS(cacheAs) |
| stored <- attr(cached, cacheAsAttribute) |
| attr(cached, cacheAsAttribute) <- NULL |
| |
| reason <- cacheAsRejectionReason(stored, record) |
| if (is.null(reason)) { |
| log_info(kco@verbose, sprintf("Loading %s from cache: %s\n", what, cacheAs)) |
| return(cached) |
| } |
| |
| if (mode != "check") { |
| warning( |
| sprintf("Cache file '%s' %s, and is used as it is.", cacheAs, reason), |
| call. = FALSE |
| ) |
| return(cached) |
| } |
| |
| warning( |
| sprintf( |
| paste0( |
| "Cache file '%s' %s.\n", |
| "It is recomputed and overwritten. To keep it, pass a different cacheAs ", |
| "file name, vouch for it with blessCacheAs(), or take it as it is with ", |
| "withCachedResults()." |
| ), |
| cacheAs, reason |
| ), |
| call. = FALSE |
| ) |
| NULL |
| } |
| |
| #' Store a result in a cache file, together with what produced it |
| #' |
| #' @param cacheAs cache file name, already passed through [cacheAsFileName()] |
| #' @param kco [KorAPConnection()] object, for its `verbose` flag |
| #' @param record what produced the result, from [cacheAsRecord()] |
| #' @param what name of the result, for the log message |
| #' @param result the result to store |
| #' @return `result`, invisibly and unchanged |
| #' @noRd |
| writeCacheAs <- function(cacheAs, kco, record, what, result) { |
| log_info(kco@verbose, sprintf("Saving %s to cache: %s\n", what, cacheAs)) |
| # only the stored copy carries the record, so that the returned value is the |
| # same whether it was cached or not |
| cachedResult <- result |
| attr(cachedResult, cacheAsAttribute) <- record |
| saveRDS(cachedResult, cacheAs) |
| invisible(result) |
| } |