From b62b23be7abc13195d6cc00f43fb51991e118d75 Mon Sep 17 00:00:00 2001 From: Yann Richet Date: Fri, 4 Sep 2026 21:28:34 +0200 Subject: [PATCH] Memoize.function: fix cross-closure cache collision loadCache()/saveCache() key on the raw call arguments only, so two different Memoize.function(fun) wrappers sharing the default cache `suffix` can collide whenever they're called with the same argument value -- one wrapper silently gets served *another* wrapper's cached result instead of evaluating its own `fun`. Reproduced: make_fun <- function(offset) function(x) x + offset f1 <- Memoize.function(make_fun(3000)) f2 <- Memoize.function(make_fun(4000)) f1(5) # 3005 (correct) f2(5) # 3005 (WRONG: should be 4005 -- served f1's cached value) This is exactly the shape that arises from wrapping a fresh closure in a loop over several models/datasets (e.g. cristalgo's EGRI/EGRIw/WEGRI robust-inversion algorithms: `mesh_exsets(f = Memoize.function(fi), ...)` inside a per-seed loop, where `fi` closes over that seed's own fitted model). A stale cross-seed value fed into a root-finder that assumes it is bisecting one consistent function silently corrupts its bisection invariants; DiceView:::root() also has no working recursion-depth guard (`max.rec` defaults to NA), so a single corrupted value is enough to make it recurse until R's node stack overflows. Fix: each call to Memoize.function() now gets its own private cache namespace (`.memoize_id`, folded into the cache key), so independent wrappings can never collide regardless of argument overlap, while a single wrapper's own repeated-argument caching (the documented example: `F <- Memoize.function(f); F(5); F(6); F(5)`) is unchanged. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01A5w4ZLUZpoHhtsbGyWcVtc --- R/function.R | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/R/function.R b/R/function.R index de3fd0d..fb0db93 100644 --- a/R/function.R +++ b/R/function.R @@ -70,8 +70,20 @@ Vectorize.function = function(fun, dim, .combine=rbind, .lapply=safe_mclapply, . #' F=Memoize.function(f); #' F(5); F(6); F(5) Memoize.function <- function(fun, suffix=".RcacheDiceView") { + # Each call to Memoize.function() gets its own private cache namespace + # (.memoize_id), so that two different wrappings never share cache + # entries just because they happen to be called with the same argument + # value. Without this, loadCache()/saveCache() are keyed only on the raw + # call arguments, so e.g. two Memoize.function(fun) wrappers created in a + # loop over several models/datasets (each capturing a different `fun` + # via lexical scoping, but sharing the same default cache `suffix`) can + # silently serve one wrapper's cached result to another wrapper called + # with the same argument -- almost always wrong, and can corrupt callers + # that assume `fun` is evaluated fresh for their own state (e.g. a + # root-finder that assumes it is bisecting a single, consistent function). + id <- basename(tempfile(pattern = "")) function(...) { - arg = list(...) + arg = c(list(.memoize_id = id), list(...)) res <- loadCache(arg, suffix=suffix) if (!is.null(res)) { # cat("Loaded cached result\n")