pymc-cache 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pymc_cache/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Model-aware result caching for standalone PyMC sampling."""
2
+ from pymc_cache._version import __version__
3
+ from pymc_cache.api import sample, sample_smc
4
+ from pymc_cache.backends import CacheBackend, CacheEntry, DiskCache, MemoryCache, cleanup_cache
5
+ from pymc_cache.identity import UncacheableError, cache_key, model_digest
6
+ from pymc_cache.policy import CachePolicy, CacheWarning
7
+ from pymc_cache.session import CacheLookup, CacheSession
8
+
9
+ __all__ = [
10
+ "sample", "sample_smc", "CacheBackend", "MemoryCache", "DiskCache", "CacheSession",
11
+ "CachePolicy", "CacheEntry", "CacheLookup", "UncacheableError", "CacheWarning",
12
+ "cleanup_cache", "model_digest", "cache_key", "__version__",
13
+ ]
pymc_cache/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
pymc_cache/api.py ADDED
@@ -0,0 +1,127 @@
1
+ """Explicit (non-monkeypatching) PyMC sampling with reusable results."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import importlib.util
6
+ import os
7
+ import warnings
8
+
9
+ from pymc_cache.policy import CachePolicy, CacheWarning, report_from_idata
10
+ from pymc_cache.session import CacheSession
11
+
12
+
13
+ def _model(model):
14
+ import pymc as pm
15
+
16
+ return model if model is not None else pm.modelcontext(None)
17
+
18
+
19
+ def _native_kwargs(model, kwargs: dict, *, operation: str) -> tuple[dict, dict, str]:
20
+ import pymc as pm
21
+
22
+ call = dict(kwargs)
23
+ key = dict(kwargs)
24
+ key.pop("progressbar", None)
25
+ if operation == "smc":
26
+ call.setdefault("draws", 2000)
27
+ call.setdefault("chains", max(2, min(4, os.cpu_count() or 1)))
28
+ key.update({"draws": call["draws"], "chains": call["chains"]})
29
+ kernel = call.get("kernel")
30
+ if kernel is not None and getattr(kernel, "__module__", "").startswith("pymc.smc"):
31
+ key["kernel"] = f"{kernel.__module__}.{kernel.__qualname__}"
32
+ return call, key, "smc"
33
+ call.setdefault("draws", 1000)
34
+ cores = call.get("cores") or min(4, os.cpu_count() or 1)
35
+ call.setdefault("cores", cores)
36
+ call.setdefault("chains", max(2, min(4, cores)))
37
+ sampler = call.get("nuts_sampler")
38
+ if sampler is None:
39
+ if int(pm.__version__.split(".")[0]) >= 6 and importlib.util.find_spec("nutpie"):
40
+ discrete = any(str(rv.dtype).startswith(("int", "uint", "bool"))
41
+ for rv in model.free_RVs)
42
+ sampler = "pymc" if discrete else "nutpie"
43
+ else:
44
+ sampler = "pymc"
45
+ call["nuts_sampler"] = sampler
46
+ call["tune"] = call.get("tune") if call.get("tune") is not None else (
47
+ 400 if int(pm.__version__.split(".")[0]) >= 6 and sampler == "nutpie" else 1000
48
+ )
49
+ key.update({k: call[k] for k in ("draws", "tune", "chains", "cores", "nuts_sampler")})
50
+ return call, key, sampler
51
+
52
+
53
+ def _runner(model, call_kwargs, sample_fn, *, operation):
54
+ import pymc as pm
55
+
56
+ func = sample_fn if sample_fn is not None else (
57
+ pm.sample_smc if operation == "smc" else pm.sample
58
+ )
59
+ with model:
60
+ return func(model=model, **call_kwargs)
61
+
62
+
63
+ def _call(*, model=None, cache=True, overwrite=False, policy=None, namespace="",
64
+ model_key=None, sampler_key=None, sample_fn=None, operation="sample", **sample_kwargs):
65
+ model = _model(model)
66
+ if policy is None:
67
+ policy = CachePolicy()
68
+ if not isinstance(policy, CachePolicy):
69
+ raise TypeError("policy must be CachePolicy")
70
+ call = dict(sample_kwargs)
71
+ key_kwargs = dict(call)
72
+ sampler_identity = operation
73
+ versions = None
74
+ hook = getattr(sample_fn, "__pymc_cache_normalize__", None)
75
+ if sample_fn is not None and hook is not None:
76
+ normalized = hook(model, copy.deepcopy(call))
77
+ if not isinstance(normalized, dict) or set(normalized) != {
78
+ "call_kwargs", "key_kwargs", "sampler_identity", "versions"
79
+ }:
80
+ raise TypeError("sample_fn normalization must return call_kwargs, key_kwargs, "
81
+ "sampler_identity, versions")
82
+ call = dict(normalized["call_kwargs"])
83
+ key_kwargs = dict(normalized["key_kwargs"])
84
+ sampler_identity = normalized["sampler_identity"]
85
+ versions = dict(normalized["versions"])
86
+ elif sample_fn is None:
87
+ call, key_kwargs, sampler_identity = _native_kwargs(model, call, operation=operation)
88
+ elif cache is not False and sampler_key is None:
89
+ warnings.warn("sampling result is not cacheable: sample_fn requires sampler_key or "
90
+ "__pymc_cache_normalize__", CacheWarning, stacklevel=2)
91
+ cache = False
92
+ if cache is not False and call.get("return_inferencedata") is False:
93
+ warnings.warn("sampling result is not cacheable: return_inferencedata=False",
94
+ CacheWarning, stacklevel=2)
95
+ cache = False
96
+ session = CacheSession(cache, model=model, policy=policy, namespace=namespace,
97
+ model_key=model_key, sampler_key=sampler_key)
98
+ lookup = session.lookup(key_kwargs, operation=operation, overwrite=overwrite,
99
+ versions=versions, sampler_identity=sampler_identity)
100
+ if lookup.hit:
101
+ return lookup.result
102
+ result = _runner(model, call, sample_fn, operation=operation)
103
+ report = report_from_idata(result)
104
+ status = report.get("status", "complete") if report else "complete"
105
+ session.store(lookup, result, status=status, effective_sampler=sampler_identity)
106
+ return result
107
+
108
+
109
+ def sample(*, model=None, cache=True, overwrite=False, policy=None, namespace="",
110
+ model_key=None, sampler_key=None, sample_fn=None, **sample_kwargs):
111
+ """Run ``pm.sample`` (or a trusted runner), reusing a matching result.
112
+
113
+ No global PyMC function is replaced. ``cache='disk'`` persists under
114
+ ``.pymc-cache``; ``cache=False`` always samples anew. A user-supplied
115
+ sample_fn must opt into safe identity or supply a trusted sampler_key.
116
+ """
117
+ return _call(model=model, cache=cache, overwrite=overwrite, policy=policy,
118
+ namespace=namespace, model_key=model_key, sampler_key=sampler_key,
119
+ sample_fn=sample_fn, operation="sample", **sample_kwargs)
120
+
121
+
122
+ def sample_smc(*, model=None, cache=True, overwrite=False, policy=None, namespace="",
123
+ model_key=None, sampler_key=None, **sample_kwargs):
124
+ """Run and cache ``pm.sample_smc`` without conflating it with NUTS."""
125
+ return _call(model=model, cache=cache, overwrite=overwrite, policy=policy,
126
+ namespace=namespace, model_key=model_key, sampler_key=sampler_key,
127
+ operation="smc", **sample_kwargs)
pymc_cache/backends.py ADDED
@@ -0,0 +1,262 @@
1
+ """Cache backends: bounded in-memory LRU and atomic, inspectable NetCDF disk storage."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import json
6
+ import os
7
+ import re
8
+ import threading
9
+ import uuid
10
+ import warnings
11
+ from collections import OrderedDict
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Protocol
15
+
16
+ from pymc_cache.io import get_group, load_inference_data, save_inference_data
17
+ from pymc_cache.naming import model_slug, params_label
18
+ from pymc_cache.policy import CacheWarning
19
+
20
+ _ENTRY_SCHEMA = 3
21
+ _ENTRY_META = "_pymc_cache_entry_v3"
22
+
23
+
24
+ class CacheBackend(Protocol):
25
+ """Minimal backend interface; extra management operations are optional."""
26
+
27
+ def load(self, key: str, **kwargs): ...
28
+
29
+ def save(self, key: str, idata, **kwargs) -> None: ...
30
+
31
+
32
+ class MemoryCache:
33
+ """Thread-safe, copy-isolated LRU of posterior results."""
34
+
35
+ def __init__(self, max_entries: int = 8):
36
+ if isinstance(max_entries, bool) or not isinstance(max_entries, int) or max_entries < 1:
37
+ raise ValueError("max_entries must be a positive integer")
38
+ self._max_entries = max_entries
39
+ self._store: OrderedDict[str, object] = OrderedDict()
40
+ self._lock = threading.RLock()
41
+
42
+ def load(self, key: str, **kwargs):
43
+ with self._lock:
44
+ if key not in self._store:
45
+ return None
46
+ self._store.move_to_end(key)
47
+ return copy.deepcopy(self._store[key])
48
+
49
+ def save(self, key: str, idata, **kwargs) -> None:
50
+ snapshot = copy.deepcopy(idata)
51
+ with self._lock:
52
+ self._store[key] = snapshot
53
+ self._store.move_to_end(key)
54
+ while len(self._store) > self._max_entries:
55
+ self._store.popitem(last=False)
56
+
57
+ def clear(self) -> int:
58
+ """Discard stored entries and return the number removed."""
59
+ with self._lock:
60
+ removed = len(self._store)
61
+ self._store.clear()
62
+ return removed
63
+
64
+ def __len__(self):
65
+ with self._lock:
66
+ return len(self._store)
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class CacheEntry:
71
+ """Metadata for an owned, valid disk entry (without loading posterior arrays)."""
72
+
73
+ key: str
74
+ model_slug: str
75
+ operation: str
76
+ created_at: float
77
+ size_bytes: int
78
+ status: str
79
+ path: Path
80
+ software_versions: dict[str, str]
81
+
82
+
83
+ class DiskCache:
84
+ """One-file, full-key-verified atomic NetCDF result cache.
85
+
86
+ Entries live at ``<root>/<model>/<parameters>-<full-sha256>.nc``. Contents
87
+ have an independently checked full key and schema; legacy files are never
88
+ reused. The directory is a trusted local cache, not an untrusted file reader.
89
+ """
90
+
91
+ def __init__(self, base_dir: str | Path | None = None, model=None, *,
92
+ max_entries_per_model: int = 20):
93
+ if (isinstance(max_entries_per_model, bool) or
94
+ not isinstance(max_entries_per_model, int) or max_entries_per_model < 1):
95
+ raise ValueError("max_entries_per_model must be a positive integer")
96
+ self._base = Path(base_dir) if base_dir is not None else Path(".pymc-cache")
97
+ self._model_slug = model_slug(model)
98
+ self._keep = max_entries_per_model
99
+
100
+ def _path(self, key: str, sample_kwargs: dict | None = None) -> Path:
101
+ if not isinstance(key, str) or not re.fullmatch(r"[a-f0-9]{64}", key):
102
+ raise ValueError("cache key must be a full 64-character SHA-256 hexadecimal digest")
103
+ path = self._base / self._model_slug / f"{params_label(sample_kwargs)}-{key}.nc"
104
+ if not path.resolve().is_relative_to(self._base.resolve()):
105
+ raise ValueError("cache path escapes the cache root")
106
+ return path
107
+
108
+ @staticmethod
109
+ def _read_metadata(path: Path) -> dict:
110
+ import h5py
111
+
112
+ with h5py.File(path, "r") as f:
113
+ # Both ArviZ 0 and ArviZ 1 write group attrs to the HDF5 group.
114
+ raw = f["posterior"].attrs[_ENTRY_META]
115
+ if isinstance(raw, bytes):
116
+ raw = raw.decode("utf-8")
117
+ data = json.loads(raw)
118
+ if data.get("schema") != _ENTRY_SCHEMA:
119
+ raise ValueError("unknown cache schema")
120
+ return data
121
+
122
+ def load(self, key: str, sample_kwargs: dict | None = None):
123
+ path = self._path(key, sample_kwargs)
124
+ if self._base.is_symlink() or path.parent.is_symlink() or path.is_symlink() or not path.exists():
125
+ return None
126
+ try:
127
+ metadata = self._read_metadata(path)
128
+ if metadata.get("key") != key or metadata.get("status") not in {"complete", "met"}:
129
+ raise ValueError("cache entry identity or status does not match")
130
+ idata = load_inference_data(path)
131
+ post = get_group(idata, "posterior")
132
+ post.attrs.pop(_ENTRY_META, None)
133
+ return idata
134
+ except (OSError, ValueError, KeyError, TypeError, RuntimeError) as exc:
135
+ warnings.warn(f"pymc-cache: ignoring unreadable cache file {path} ({exc})",
136
+ CacheWarning, stacklevel=2)
137
+ return None
138
+
139
+ def save(self, key: str, idata, sample_kwargs: dict | None = None, *,
140
+ operation="sample", status="complete", software_versions=None) -> None:
141
+ path = self._path(key, sample_kwargs)
142
+ if status not in ("complete", "met"):
143
+ raise ValueError("only complete or met results may be stored")
144
+ if self._base.is_symlink() or path.parent.is_symlink() or path.is_symlink():
145
+ raise ValueError("refusing to write through a cache symlink")
146
+ path.parent.mkdir(parents=True, exist_ok=True)
147
+ if self._base.is_symlink() or path.parent.is_symlink():
148
+ raise ValueError("refusing to write through a cache symlink")
149
+ if path.exists():
150
+ import h5py
151
+
152
+ try:
153
+ with h5py.File(path, "r") as existing:
154
+ legacy = "posterior" in existing and any(
155
+ f"_pymc_cache_entry_v{version}" in existing["posterior"].attrs
156
+ for version in (1, 2)
157
+ )
158
+ except OSError:
159
+ legacy = False
160
+ if legacy:
161
+ raise ValueError("refusing to overwrite a schema-1/2 cache entry")
162
+ import time
163
+
164
+ metadata = {"key": key, "schema": _ENTRY_SCHEMA, "status": status,
165
+ "operation": operation, "created_at": time.time(),
166
+ "software_versions": software_versions or {}}
167
+ tmp = path.with_name(f"{path.name}.{os.getpid()}.{uuid.uuid4().hex}.tmp")
168
+ try:
169
+ save_inference_data(idata, tmp, attrs={_ENTRY_META: json.dumps(metadata)})
170
+ os.replace(tmp, path)
171
+ finally:
172
+ tmp.unlink(missing_ok=True)
173
+ self.prune(self._keep)
174
+
175
+ def entries(self) -> list[CacheEntry]:
176
+ """List this format's entries without materializing posterior data."""
177
+ results = []
178
+ if not self._base.exists() or self._base.is_symlink():
179
+ return results
180
+ for directory in self._base.iterdir():
181
+ if not directory.is_dir() or directory.is_symlink():
182
+ continue
183
+ for path in directory.glob("*.nc"):
184
+ if path.is_symlink() or not path.is_file():
185
+ continue
186
+ try:
187
+ metadata = self._read_metadata(path)
188
+ key = metadata["key"]
189
+ if not re.fullmatch(r"[0-9a-f]{64}", key) or not path.name.endswith(
190
+ f"-{key}.nc"
191
+ ):
192
+ continue
193
+ results.append(CacheEntry(key, directory.name, metadata["operation"],
194
+ float(metadata["created_at"]), path.stat().st_size,
195
+ metadata["status"], path,
196
+ metadata.get("software_versions", {})))
197
+ except (OSError, ValueError, KeyError, TypeError):
198
+ continue
199
+ return sorted(results, key=lambda entry: entry.created_at, reverse=True)
200
+
201
+ def delete(self, key: str) -> bool:
202
+ """Remove matching owned entries, never arbitrary files or symlinks."""
203
+ matches = [entry for entry in self.entries() if entry.key == key]
204
+ for entry in matches:
205
+ entry.path.unlink()
206
+ return bool(matches)
207
+
208
+ def prune(self, keep: int | None = None) -> int:
209
+ """Keep the N newest entries per model; return count removed."""
210
+ if keep is None:
211
+ keep = self._keep
212
+ if isinstance(keep, bool) or not isinstance(keep, int) or keep < 0:
213
+ raise ValueError("keep must be a nonnegative integer")
214
+ counts: dict[str, int] = {}
215
+ removed = 0
216
+ for entry in self.entries():
217
+ current = counts.get(entry.model_slug, 0)
218
+ counts[entry.model_slug] = current + 1
219
+ if current >= keep:
220
+ entry.path.unlink()
221
+ removed += 1
222
+ return removed
223
+
224
+ def clear(self) -> int:
225
+ """Delete only format-owned entries, preserving other files."""
226
+ entries = self.entries()
227
+ for entry in entries:
228
+ entry.path.unlink()
229
+ return len(entries)
230
+
231
+
232
+ _default_memory_cache: MemoryCache | None = None
233
+ _default_lock = threading.Lock()
234
+
235
+
236
+ def get_default_cache() -> MemoryCache:
237
+ """Lazily construct the process-wide memory cache."""
238
+ global _default_memory_cache
239
+ with _default_lock:
240
+ if _default_memory_cache is None:
241
+ _default_memory_cache = MemoryCache()
242
+ return _default_memory_cache
243
+
244
+
245
+ def resolve_cache(cache_arg, model=None, *, default_directory=".pymc-cache") -> CacheBackend | None:
246
+ """Resolve bool, memory/disk alias, directory, or custom backend."""
247
+ if cache_arg is False:
248
+ return None
249
+ if cache_arg is True or cache_arg == "memory":
250
+ return get_default_cache()
251
+ if cache_arg == "disk":
252
+ return DiskCache(default_directory, model=model)
253
+ if isinstance(cache_arg, (str, Path)):
254
+ return DiskCache(cache_arg, model=model)
255
+ if callable(getattr(cache_arg, "load", None)) and callable(getattr(cache_arg, "save", None)):
256
+ return cache_arg
257
+ raise TypeError(f"cache must be bool | 'disk' | str | Path | CacheBackend; got {cache_arg!r}")
258
+
259
+
260
+ def cleanup_cache(base_dir: str | Path | None = None) -> int:
261
+ """Remove only new-format entries in a directory; do not delete the root."""
262
+ return DiskCache(base_dir).clear()
pymc_cache/cli.py ADDED
@@ -0,0 +1,50 @@
1
+ """Inspect and manage only entries owned by pymc-cache."""
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from pymc_cache.backends import DiskCache
9
+
10
+
11
+ def main(argv: list[str] | None = None) -> int:
12
+ parser = argparse.ArgumentParser(prog="pymc-cache", description=__doc__)
13
+ parser.add_argument("--directory", "--dir", dest="directory", default=".pymc-cache",
14
+ help="cache directory (default: .pymc-cache)")
15
+ sub = parser.add_subparsers(dest="command", required=True)
16
+ listed = sub.add_parser("list", help="inspect owned cache entries")
17
+ listed.add_argument("--json", action="store_true", help="machine-readable metadata")
18
+ prune = sub.add_parser("prune", help="keep the newest entries per model")
19
+ prune.add_argument("--keep", type=int, default=20)
20
+ clear = sub.add_parser("clear", help="delete only entries owned by pymc-cache")
21
+ clear.add_argument("--yes", action="store_true", required=True,
22
+ help="confirm permanent removal of owned entries")
23
+ for command in (listed, prune, clear):
24
+ command.add_argument("--directory", "--dir", dest="directory",
25
+ default=argparse.SUPPRESS, help="cache directory")
26
+ args = parser.parse_args(argv)
27
+ cache = DiskCache(Path(args.directory))
28
+ if args.command == "list":
29
+ entries = cache.entries()
30
+ if args.json:
31
+ print(json.dumps([{"key": e.key, "model": e.model_slug,
32
+ "operation": e.operation, "status": e.status,
33
+ "path": str(e.path), "size_bytes": e.size_bytes,
34
+ "created_at": e.created_at,
35
+ "software_versions": e.software_versions}
36
+ for e in entries], indent=2))
37
+ else:
38
+ for entry in entries:
39
+ print(f"{entry.key} {entry.model_slug} {entry.status} "
40
+ f"{entry.size_bytes} B {entry.path}")
41
+ print(f"{len(entries)} entries")
42
+ elif args.command == "prune":
43
+ print(f"Removed {cache.prune(args.keep)} entries")
44
+ else:
45
+ print(f"Removed {cache.clear()} entries")
46
+ return 0
47
+
48
+
49
+ if __name__ == "__main__":
50
+ raise SystemExit(main())