tnfr 4.5.2__py3-none-any.whl → 7.0.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.

Potentially problematic release.


This version of tnfr might be problematic. Click here for more details.

Files changed (195) hide show
  1. tnfr/__init__.py +275 -51
  2. tnfr/__init__.pyi +33 -0
  3. tnfr/_compat.py +10 -0
  4. tnfr/_generated_version.py +34 -0
  5. tnfr/_version.py +49 -0
  6. tnfr/_version.pyi +7 -0
  7. tnfr/alias.py +117 -31
  8. tnfr/alias.pyi +108 -0
  9. tnfr/cache.py +6 -572
  10. tnfr/cache.pyi +16 -0
  11. tnfr/callback_utils.py +16 -38
  12. tnfr/callback_utils.pyi +79 -0
  13. tnfr/cli/__init__.py +34 -14
  14. tnfr/cli/__init__.pyi +26 -0
  15. tnfr/cli/arguments.py +211 -28
  16. tnfr/cli/arguments.pyi +27 -0
  17. tnfr/cli/execution.py +470 -50
  18. tnfr/cli/execution.pyi +70 -0
  19. tnfr/cli/utils.py +18 -3
  20. tnfr/cli/utils.pyi +8 -0
  21. tnfr/config/__init__.py +13 -0
  22. tnfr/config/__init__.pyi +10 -0
  23. tnfr/{constants_glyphs.py → config/constants.py} +26 -20
  24. tnfr/config/constants.pyi +12 -0
  25. tnfr/config/feature_flags.py +83 -0
  26. tnfr/{config.py → config/init.py} +11 -7
  27. tnfr/config/init.pyi +8 -0
  28. tnfr/config/operator_names.py +93 -0
  29. tnfr/config/operator_names.pyi +28 -0
  30. tnfr/config/presets.py +84 -0
  31. tnfr/config/presets.pyi +7 -0
  32. tnfr/constants/__init__.py +80 -29
  33. tnfr/constants/__init__.pyi +92 -0
  34. tnfr/constants/aliases.py +31 -0
  35. tnfr/constants/core.py +4 -4
  36. tnfr/constants/core.pyi +17 -0
  37. tnfr/constants/init.py +1 -1
  38. tnfr/constants/init.pyi +12 -0
  39. tnfr/constants/metric.py +7 -15
  40. tnfr/constants/metric.pyi +19 -0
  41. tnfr/dynamics/__init__.py +165 -633
  42. tnfr/dynamics/__init__.pyi +82 -0
  43. tnfr/dynamics/adaptation.py +267 -0
  44. tnfr/dynamics/aliases.py +23 -0
  45. tnfr/dynamics/coordination.py +385 -0
  46. tnfr/dynamics/dnfr.py +2283 -400
  47. tnfr/dynamics/dnfr.pyi +24 -0
  48. tnfr/dynamics/integrators.py +406 -98
  49. tnfr/dynamics/integrators.pyi +34 -0
  50. tnfr/dynamics/runtime.py +881 -0
  51. tnfr/dynamics/sampling.py +10 -5
  52. tnfr/dynamics/sampling.pyi +7 -0
  53. tnfr/dynamics/selectors.py +719 -0
  54. tnfr/execution.py +70 -48
  55. tnfr/execution.pyi +45 -0
  56. tnfr/flatten.py +13 -9
  57. tnfr/flatten.pyi +21 -0
  58. tnfr/gamma.py +66 -53
  59. tnfr/gamma.pyi +34 -0
  60. tnfr/glyph_history.py +110 -52
  61. tnfr/glyph_history.pyi +35 -0
  62. tnfr/glyph_runtime.py +16 -0
  63. tnfr/glyph_runtime.pyi +9 -0
  64. tnfr/immutable.py +69 -28
  65. tnfr/immutable.pyi +34 -0
  66. tnfr/initialization.py +16 -16
  67. tnfr/initialization.pyi +65 -0
  68. tnfr/io.py +6 -240
  69. tnfr/io.pyi +16 -0
  70. tnfr/locking.pyi +7 -0
  71. tnfr/mathematics/__init__.py +81 -0
  72. tnfr/mathematics/backend.py +426 -0
  73. tnfr/mathematics/dynamics.py +398 -0
  74. tnfr/mathematics/epi.py +254 -0
  75. tnfr/mathematics/generators.py +222 -0
  76. tnfr/mathematics/metrics.py +119 -0
  77. tnfr/mathematics/operators.py +233 -0
  78. tnfr/mathematics/operators_factory.py +71 -0
  79. tnfr/mathematics/projection.py +78 -0
  80. tnfr/mathematics/runtime.py +173 -0
  81. tnfr/mathematics/spaces.py +247 -0
  82. tnfr/mathematics/transforms.py +292 -0
  83. tnfr/metrics/__init__.py +10 -10
  84. tnfr/metrics/__init__.pyi +20 -0
  85. tnfr/metrics/coherence.py +993 -324
  86. tnfr/metrics/common.py +23 -16
  87. tnfr/metrics/common.pyi +46 -0
  88. tnfr/metrics/core.py +251 -35
  89. tnfr/metrics/core.pyi +13 -0
  90. tnfr/metrics/diagnosis.py +708 -111
  91. tnfr/metrics/diagnosis.pyi +85 -0
  92. tnfr/metrics/export.py +27 -15
  93. tnfr/metrics/glyph_timing.py +232 -42
  94. tnfr/metrics/reporting.py +33 -22
  95. tnfr/metrics/reporting.pyi +12 -0
  96. tnfr/metrics/sense_index.py +987 -43
  97. tnfr/metrics/sense_index.pyi +9 -0
  98. tnfr/metrics/trig.py +214 -23
  99. tnfr/metrics/trig.pyi +13 -0
  100. tnfr/metrics/trig_cache.py +115 -22
  101. tnfr/metrics/trig_cache.pyi +10 -0
  102. tnfr/node.py +542 -136
  103. tnfr/node.pyi +178 -0
  104. tnfr/observers.py +152 -35
  105. tnfr/observers.pyi +31 -0
  106. tnfr/ontosim.py +23 -19
  107. tnfr/ontosim.pyi +28 -0
  108. tnfr/operators/__init__.py +601 -82
  109. tnfr/operators/__init__.pyi +45 -0
  110. tnfr/operators/definitions.py +513 -0
  111. tnfr/operators/definitions.pyi +78 -0
  112. tnfr/operators/grammar.py +760 -0
  113. tnfr/operators/jitter.py +107 -38
  114. tnfr/operators/jitter.pyi +11 -0
  115. tnfr/operators/registry.py +75 -0
  116. tnfr/operators/registry.pyi +13 -0
  117. tnfr/operators/remesh.py +149 -88
  118. tnfr/py.typed +0 -0
  119. tnfr/rng.py +46 -143
  120. tnfr/rng.pyi +14 -0
  121. tnfr/schemas/__init__.py +8 -0
  122. tnfr/schemas/grammar.json +94 -0
  123. tnfr/selector.py +25 -19
  124. tnfr/selector.pyi +19 -0
  125. tnfr/sense.py +72 -62
  126. tnfr/sense.pyi +23 -0
  127. tnfr/structural.py +522 -262
  128. tnfr/structural.pyi +69 -0
  129. tnfr/telemetry/__init__.py +35 -0
  130. tnfr/telemetry/cache_metrics.py +226 -0
  131. tnfr/telemetry/nu_f.py +423 -0
  132. tnfr/telemetry/nu_f.pyi +123 -0
  133. tnfr/telemetry/verbosity.py +37 -0
  134. tnfr/tokens.py +1 -3
  135. tnfr/tokens.pyi +36 -0
  136. tnfr/trace.py +270 -113
  137. tnfr/trace.pyi +40 -0
  138. tnfr/types.py +574 -6
  139. tnfr/types.pyi +331 -0
  140. tnfr/units.py +69 -0
  141. tnfr/units.pyi +16 -0
  142. tnfr/utils/__init__.py +217 -0
  143. tnfr/utils/__init__.pyi +202 -0
  144. tnfr/utils/cache.py +2395 -0
  145. tnfr/utils/cache.pyi +468 -0
  146. tnfr/utils/chunks.py +104 -0
  147. tnfr/utils/chunks.pyi +21 -0
  148. tnfr/{collections_utils.py → utils/data.py} +147 -90
  149. tnfr/utils/data.pyi +64 -0
  150. tnfr/utils/graph.py +85 -0
  151. tnfr/utils/graph.pyi +10 -0
  152. tnfr/utils/init.py +770 -0
  153. tnfr/utils/init.pyi +78 -0
  154. tnfr/utils/io.py +456 -0
  155. tnfr/{helpers → utils}/numeric.py +51 -24
  156. tnfr/utils/numeric.pyi +21 -0
  157. tnfr/validation/__init__.py +113 -0
  158. tnfr/validation/__init__.pyi +77 -0
  159. tnfr/validation/compatibility.py +95 -0
  160. tnfr/validation/compatibility.pyi +6 -0
  161. tnfr/validation/grammar.py +71 -0
  162. tnfr/validation/grammar.pyi +40 -0
  163. tnfr/validation/graph.py +138 -0
  164. tnfr/validation/graph.pyi +17 -0
  165. tnfr/validation/rules.py +281 -0
  166. tnfr/validation/rules.pyi +55 -0
  167. tnfr/validation/runtime.py +263 -0
  168. tnfr/validation/runtime.pyi +31 -0
  169. tnfr/validation/soft_filters.py +170 -0
  170. tnfr/validation/soft_filters.pyi +37 -0
  171. tnfr/validation/spectral.py +159 -0
  172. tnfr/validation/spectral.pyi +46 -0
  173. tnfr/validation/syntax.py +40 -0
  174. tnfr/validation/syntax.pyi +10 -0
  175. tnfr/validation/window.py +39 -0
  176. tnfr/validation/window.pyi +1 -0
  177. tnfr/viz/__init__.py +9 -0
  178. tnfr/viz/matplotlib.py +246 -0
  179. tnfr-7.0.0.dist-info/METADATA +179 -0
  180. tnfr-7.0.0.dist-info/RECORD +185 -0
  181. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/licenses/LICENSE.md +1 -1
  182. tnfr/grammar.py +0 -344
  183. tnfr/graph_utils.py +0 -84
  184. tnfr/helpers/__init__.py +0 -71
  185. tnfr/import_utils.py +0 -228
  186. tnfr/json_utils.py +0 -162
  187. tnfr/logging_utils.py +0 -116
  188. tnfr/presets.py +0 -60
  189. tnfr/validators.py +0 -84
  190. tnfr/value_utils.py +0 -59
  191. tnfr-4.5.2.dist-info/METADATA +0 -379
  192. tnfr-4.5.2.dist-info/RECORD +0 -67
  193. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/WHEEL +0 -0
  194. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/entry_points.txt +0 -0
  195. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/top_level.txt +0 -0
tnfr/utils/cache.py ADDED
@@ -0,0 +1,2395 @@
1
+ """Cache infrastructure primitives and graph-level helpers for TNFR.
2
+
3
+ This module consolidates structural cache helpers that previously lived in
4
+ legacy helper modules and are now exposed under :mod:`tnfr.utils`. The
5
+ functions exposed here are responsible for maintaining deterministic node
6
+ digests, scoped graph caches guarded by locks, and version counters that keep
7
+ edge artifacts in sync with ΔNFR driven updates.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from abc import ABC, abstractmethod
13
+ import hashlib
14
+ import logging
15
+ import pickle
16
+ import shelve
17
+ import threading
18
+ from collections import defaultdict
19
+ from collections.abc import (
20
+ Callable,
21
+ Hashable,
22
+ Iterable,
23
+ Iterator,
24
+ Mapping,
25
+ MutableMapping,
26
+ )
27
+ from contextlib import contextmanager
28
+ from dataclasses import dataclass, field
29
+ from functools import lru_cache
30
+ from time import perf_counter
31
+ from typing import TYPE_CHECKING, Any, Generic, TypeVar, cast
32
+
33
+ import networkx as nx
34
+ from cachetools import LRUCache
35
+
36
+ from ..locking import get_lock
37
+ from ..types import GraphLike, NodeId, TimingContext, TNFRGraph
38
+ from .graph import get_graph, mark_dnfr_prep_dirty
39
+
40
+ K = TypeVar("K", bound=Hashable)
41
+ V = TypeVar("V")
42
+ T = TypeVar("T")
43
+
44
+ __all__ = (
45
+ "CacheLayer",
46
+ "CacheManager",
47
+ "CacheCapacityConfig",
48
+ "CacheStatistics",
49
+ "InstrumentedLRUCache",
50
+ "ManagedLRUCache",
51
+ "MappingCacheLayer",
52
+ "RedisCacheLayer",
53
+ "ShelveCacheLayer",
54
+ "prune_lock_mapping",
55
+ "EdgeCacheManager",
56
+ "NODE_SET_CHECKSUM_KEY",
57
+ "cached_node_list",
58
+ "cached_nodes_and_A",
59
+ "clear_node_repr_cache",
60
+ "edge_version_cache",
61
+ "edge_version_update",
62
+ "ensure_node_index_map",
63
+ "ensure_node_offset_map",
64
+ "get_graph_version",
65
+ "increment_edge_version",
66
+ "increment_graph_version",
67
+ "node_set_checksum",
68
+ "stable_json",
69
+ "configure_graph_cache_limits",
70
+ "DNFR_PREP_STATE_KEY",
71
+ "DnfrPrepState",
72
+ "build_cache_manager",
73
+ "configure_global_cache_layers",
74
+ "reset_global_cache_manager",
75
+ "_GRAPH_CACHE_LAYERS_KEY",
76
+ "_SeedHashCache",
77
+ "ScopedCounterCache",
78
+ "DnfrCache",
79
+ "new_dnfr_cache",
80
+ )
81
+
82
+ @dataclass(frozen=True)
83
+ class CacheCapacityConfig:
84
+ """Configuration snapshot for cache capacity policies."""
85
+
86
+ default_capacity: int | None
87
+ overrides: dict[str, int | None]
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class CacheStatistics:
92
+ """Immutable snapshot of cache telemetry counters."""
93
+
94
+ hits: int = 0
95
+ misses: int = 0
96
+ evictions: int = 0
97
+ total_time: float = 0.0
98
+ timings: int = 0
99
+
100
+ def merge(self, other: CacheStatistics) -> CacheStatistics:
101
+ """Return aggregated metrics combining ``self`` and ``other``."""
102
+
103
+ return CacheStatistics(
104
+ hits=self.hits + other.hits,
105
+ misses=self.misses + other.misses,
106
+ evictions=self.evictions + other.evictions,
107
+ total_time=self.total_time + other.total_time,
108
+ timings=self.timings + other.timings,
109
+ )
110
+
111
+
112
+ @dataclass
113
+ class DnfrCache:
114
+ idx: dict[Any, int]
115
+ theta: list[float]
116
+ epi: list[float]
117
+ vf: list[float]
118
+ cos_theta: list[float]
119
+ sin_theta: list[float]
120
+ neighbor_x: list[float]
121
+ neighbor_y: list[float]
122
+ neighbor_epi_sum: list[float]
123
+ neighbor_vf_sum: list[float]
124
+ neighbor_count: list[float]
125
+ neighbor_deg_sum: list[float] | None
126
+ th_bar: list[float] | None = None
127
+ epi_bar: list[float] | None = None
128
+ vf_bar: list[float] | None = None
129
+ deg_bar: list[float] | None = None
130
+ degs: dict[Any, float] | None = None
131
+ deg_list: list[float] | None = None
132
+ theta_np: Any | None = None
133
+ epi_np: Any | None = None
134
+ vf_np: Any | None = None
135
+ cos_theta_np: Any | None = None
136
+ sin_theta_np: Any | None = None
137
+ deg_array: Any | None = None
138
+ edge_src: Any | None = None
139
+ edge_dst: Any | None = None
140
+ checksum: Any | None = None
141
+ neighbor_x_np: Any | None = None
142
+ neighbor_y_np: Any | None = None
143
+ neighbor_epi_sum_np: Any | None = None
144
+ neighbor_vf_sum_np: Any | None = None
145
+ neighbor_count_np: Any | None = None
146
+ neighbor_deg_sum_np: Any | None = None
147
+ th_bar_np: Any | None = None
148
+ epi_bar_np: Any | None = None
149
+ vf_bar_np: Any | None = None
150
+ deg_bar_np: Any | None = None
151
+ grad_phase_np: Any | None = None
152
+ grad_epi_np: Any | None = None
153
+ grad_vf_np: Any | None = None
154
+ grad_topo_np: Any | None = None
155
+ grad_total_np: Any | None = None
156
+ dense_components_np: Any | None = None
157
+ dense_accum_np: Any | None = None
158
+ dense_degree_np: Any | None = None
159
+ neighbor_accum_np: Any | None = None
160
+ neighbor_inv_count_np: Any | None = None
161
+ neighbor_cos_avg_np: Any | None = None
162
+ neighbor_sin_avg_np: Any | None = None
163
+ neighbor_mean_tmp_np: Any | None = None
164
+ neighbor_mean_length_np: Any | None = None
165
+ edge_signature: Any | None = None
166
+ neighbor_accum_signature: Any | None = None
167
+ neighbor_edge_values_np: Any | None = None
168
+
169
+
170
+ def new_dnfr_cache() -> DnfrCache:
171
+ """Return an empty :class:`DnfrCache` prepared for ΔNFR orchestration."""
172
+
173
+ return DnfrCache(
174
+ idx={},
175
+ theta=[],
176
+ epi=[],
177
+ vf=[],
178
+ cos_theta=[],
179
+ sin_theta=[],
180
+ neighbor_x=[],
181
+ neighbor_y=[],
182
+ neighbor_epi_sum=[],
183
+ neighbor_vf_sum=[],
184
+ neighbor_count=[],
185
+ neighbor_deg_sum=[],
186
+ )
187
+
188
+
189
+ @dataclass
190
+ class _CacheMetrics:
191
+ hits: int = 0
192
+ misses: int = 0
193
+ evictions: int = 0
194
+ total_time: float = 0.0
195
+ timings: int = 0
196
+ lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
197
+
198
+ def snapshot(self) -> CacheStatistics:
199
+ return CacheStatistics(
200
+ hits=self.hits,
201
+ misses=self.misses,
202
+ evictions=self.evictions,
203
+ total_time=self.total_time,
204
+ timings=self.timings,
205
+ )
206
+
207
+
208
+ @dataclass
209
+ class _CacheEntry:
210
+ factory: Callable[[], Any]
211
+ lock: threading.Lock
212
+ reset: Callable[[Any], Any] | None = None
213
+ encoder: Callable[[Any], Any] | None = None
214
+ decoder: Callable[[Any], Any] | None = None
215
+
216
+
217
+ class CacheLayer(ABC):
218
+ """Abstract interface implemented by storage backends orchestrated by :class:`CacheManager`."""
219
+
220
+ @abstractmethod
221
+ def load(self, name: str) -> Any:
222
+ """Return the stored payload for ``name`` or raise :class:`KeyError`."""
223
+
224
+ @abstractmethod
225
+ def store(self, name: str, value: Any) -> None:
226
+ """Persist ``value`` under ``name``."""
227
+
228
+ @abstractmethod
229
+ def delete(self, name: str) -> None:
230
+ """Remove ``name`` from the backend if present."""
231
+
232
+ @abstractmethod
233
+ def clear(self) -> None:
234
+ """Remove every entry maintained by the layer."""
235
+
236
+ def close(self) -> None: # pragma: no cover - optional hook
237
+ """Release resources held by the backend."""
238
+
239
+
240
+ class MappingCacheLayer(CacheLayer):
241
+ """In-memory cache layer backed by a mutable mapping."""
242
+
243
+ def __init__(self, storage: MutableMapping[str, Any] | None = None) -> None:
244
+ self._storage: MutableMapping[str, Any] = {} if storage is None else storage
245
+ self._lock = threading.RLock()
246
+
247
+ @property
248
+ def storage(self) -> MutableMapping[str, Any]:
249
+ """Return the mapping used to store cache entries."""
250
+
251
+ return self._storage
252
+
253
+ def load(self, name: str) -> Any:
254
+ with self._lock:
255
+ if name not in self._storage:
256
+ raise KeyError(name)
257
+ return self._storage[name]
258
+
259
+ def store(self, name: str, value: Any) -> None:
260
+ with self._lock:
261
+ self._storage[name] = value
262
+
263
+ def delete(self, name: str) -> None:
264
+ with self._lock:
265
+ self._storage.pop(name, None)
266
+
267
+ def clear(self) -> None:
268
+ with self._lock:
269
+ self._storage.clear()
270
+
271
+
272
+ class ShelveCacheLayer(CacheLayer):
273
+ """Persistent cache layer backed by :mod:`shelve`."""
274
+
275
+ def __init__(
276
+ self,
277
+ path: str,
278
+ *,
279
+ flag: str = "c",
280
+ protocol: int | None = None,
281
+ writeback: bool = False,
282
+ ) -> None:
283
+ self._path = path
284
+ self._flag = flag
285
+ self._protocol = pickle.HIGHEST_PROTOCOL if protocol is None else protocol
286
+ self._shelf = shelve.open(path, flag=flag, protocol=self._protocol, writeback=writeback)
287
+ self._lock = threading.RLock()
288
+
289
+ def load(self, name: str) -> Any:
290
+ with self._lock:
291
+ if name not in self._shelf:
292
+ raise KeyError(name)
293
+ return self._shelf[name]
294
+
295
+ def store(self, name: str, value: Any) -> None:
296
+ with self._lock:
297
+ self._shelf[name] = value
298
+ self._shelf.sync()
299
+
300
+ def delete(self, name: str) -> None:
301
+ with self._lock:
302
+ try:
303
+ del self._shelf[name]
304
+ except KeyError:
305
+ return
306
+ self._shelf.sync()
307
+
308
+ def clear(self) -> None:
309
+ with self._lock:
310
+ self._shelf.clear()
311
+ self._shelf.sync()
312
+
313
+ def close(self) -> None: # pragma: no cover - exercised indirectly
314
+ with self._lock:
315
+ self._shelf.close()
316
+
317
+
318
+ class RedisCacheLayer(CacheLayer):
319
+ """Distributed cache layer backed by a Redis client."""
320
+
321
+ def __init__(self, client: Any | None = None, *, namespace: str = "tnfr:cache") -> None:
322
+ if client is None:
323
+ try: # pragma: no cover - import guarded for optional dependency
324
+ import redis # type: ignore
325
+ except Exception as exc: # pragma: no cover - defensive import
326
+ raise RuntimeError("redis-py is required to initialise RedisCacheLayer") from exc
327
+ client = redis.Redis()
328
+ self._client = client
329
+ self._namespace = namespace.rstrip(":") or "tnfr:cache"
330
+ self._lock = threading.RLock()
331
+
332
+ def _format_key(self, name: str) -> str:
333
+ return f"{self._namespace}:{name}"
334
+
335
+ def load(self, name: str) -> Any:
336
+ key = self._format_key(name)
337
+ with self._lock:
338
+ value = self._client.get(key)
339
+ if value is None:
340
+ raise KeyError(name)
341
+ if isinstance(value, (bytes, bytearray, memoryview)):
342
+ return pickle.loads(bytes(value))
343
+ return value
344
+
345
+ def store(self, name: str, value: Any) -> None:
346
+ key = self._format_key(name)
347
+ payload = value
348
+ if not isinstance(value, (bytes, bytearray, memoryview)):
349
+ payload = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
350
+ with self._lock:
351
+ self._client.set(key, payload)
352
+
353
+ def delete(self, name: str) -> None:
354
+ key = self._format_key(name)
355
+ with self._lock:
356
+ self._client.delete(key)
357
+
358
+ def clear(self) -> None:
359
+ pattern = f"{self._namespace}:*"
360
+ with self._lock:
361
+ if hasattr(self._client, "scan_iter"):
362
+ keys = list(self._client.scan_iter(match=pattern))
363
+ elif hasattr(self._client, "keys"):
364
+ keys = list(self._client.keys(pattern))
365
+ else: # pragma: no cover - extremely defensive
366
+ keys = []
367
+ if keys:
368
+ self._client.delete(*keys)
369
+
370
+
371
+ class CacheManager:
372
+ """Coordinate named caches guarded by per-entry locks."""
373
+
374
+ _MISSING = object()
375
+
376
+ def __init__(
377
+ self,
378
+ storage: MutableMapping[str, Any] | None = None,
379
+ *,
380
+ default_capacity: int | None = None,
381
+ overrides: Mapping[str, int | None] | None = None,
382
+ layers: Iterable[CacheLayer] | None = None,
383
+ ) -> None:
384
+ mapping_layer = MappingCacheLayer(storage)
385
+ extra_layers: tuple[CacheLayer, ...]
386
+ if layers is None:
387
+ extra_layers = ()
388
+ else:
389
+ extra_layers = tuple(layers)
390
+ for layer in extra_layers:
391
+ if not isinstance(layer, CacheLayer): # pragma: no cover - defensive typing
392
+ raise TypeError(f"unsupported cache layer type: {type(layer)!r}")
393
+ self._layers: tuple[CacheLayer, ...] = (mapping_layer, *extra_layers)
394
+ self._storage_layer = mapping_layer
395
+ self._storage: MutableMapping[str, Any] = mapping_layer.storage
396
+ self._entries: dict[str, _CacheEntry] = {}
397
+ self._registry_lock = threading.RLock()
398
+ self._default_capacity = self._normalise_capacity(default_capacity)
399
+ self._capacity_overrides: dict[str, int | None] = {}
400
+ self._metrics: dict[str, _CacheMetrics] = {}
401
+ self._metrics_publishers: list[Callable[[str, CacheStatistics], None]] = []
402
+ if overrides:
403
+ self.configure(overrides=overrides)
404
+
405
+ @staticmethod
406
+ def _normalise_capacity(value: int | None) -> int | None:
407
+ if value is None:
408
+ return None
409
+ size = int(value)
410
+ if size < 0:
411
+ raise ValueError("capacity must be non-negative or None")
412
+ return size
413
+
414
+ def register(
415
+ self,
416
+ name: str,
417
+ factory: Callable[[], Any],
418
+ *,
419
+ lock_factory: Callable[[], threading.Lock | threading.RLock] | None = None,
420
+ reset: Callable[[Any], Any] | None = None,
421
+ create: bool = True,
422
+ encoder: Callable[[Any], Any] | None = None,
423
+ decoder: Callable[[Any], Any] | None = None,
424
+ ) -> None:
425
+ """Register ``name`` with ``factory`` and optional lifecycle hooks."""
426
+
427
+ if lock_factory is None:
428
+ lock_factory = threading.RLock
429
+ with self._registry_lock:
430
+ entry = self._entries.get(name)
431
+ if entry is None:
432
+ entry = _CacheEntry(
433
+ factory=factory,
434
+ lock=lock_factory(),
435
+ reset=reset,
436
+ encoder=encoder,
437
+ decoder=decoder,
438
+ )
439
+ self._entries[name] = entry
440
+ else:
441
+ # Update hooks when re-registering the same cache name.
442
+ entry.factory = factory
443
+ entry.reset = reset
444
+ entry.encoder = encoder
445
+ entry.decoder = decoder
446
+ self._ensure_metrics(name)
447
+ if create:
448
+ self.get(name)
449
+
450
+ def configure(
451
+ self,
452
+ *,
453
+ default_capacity: int | None | object = _MISSING,
454
+ overrides: Mapping[str, int | None] | None = None,
455
+ replace_overrides: bool = False,
456
+ ) -> None:
457
+ """Update the cache capacity policy shared by registered entries."""
458
+
459
+ with self._registry_lock:
460
+ if default_capacity is not self._MISSING:
461
+ self._default_capacity = self._normalise_capacity(
462
+ default_capacity if default_capacity is not None else None
463
+ )
464
+ if overrides is not None:
465
+ if replace_overrides:
466
+ self._capacity_overrides.clear()
467
+ for key, value in overrides.items():
468
+ self._capacity_overrides[key] = self._normalise_capacity(value)
469
+
470
+ def configure_from_mapping(self, config: Mapping[str, Any]) -> None:
471
+ """Load configuration produced by :meth:`export_config`."""
472
+
473
+ default = config.get("default_capacity", self._MISSING)
474
+ overrides = config.get("overrides")
475
+ overrides_mapping: Mapping[str, int | None] | None
476
+ overrides_mapping = overrides if isinstance(overrides, Mapping) else None
477
+ self.configure(default_capacity=default, overrides=overrides_mapping)
478
+
479
+ def export_config(self) -> CacheCapacityConfig:
480
+ """Return a copy of the current capacity configuration."""
481
+
482
+ with self._registry_lock:
483
+ return CacheCapacityConfig(
484
+ default_capacity=self._default_capacity,
485
+ overrides=dict(self._capacity_overrides),
486
+ )
487
+
488
+ def get_capacity(
489
+ self,
490
+ name: str,
491
+ *,
492
+ requested: int | None = None,
493
+ fallback: int | None = None,
494
+ use_default: bool = True,
495
+ ) -> int | None:
496
+ """Return capacity for ``name`` considering overrides and defaults."""
497
+
498
+ with self._registry_lock:
499
+ override = self._capacity_overrides.get(name, self._MISSING)
500
+ default = self._default_capacity
501
+ if override is not self._MISSING:
502
+ return override
503
+ values: tuple[int | None, ...]
504
+ if use_default:
505
+ values = (requested, default, fallback)
506
+ else:
507
+ values = (requested, fallback)
508
+ for value in values:
509
+ if value is self._MISSING:
510
+ continue
511
+ normalised = self._normalise_capacity(value)
512
+ if normalised is not None:
513
+ return normalised
514
+ return None
515
+
516
+ def has_override(self, name: str) -> bool:
517
+ """Return ``True`` if ``name`` has an explicit capacity override."""
518
+
519
+ with self._registry_lock:
520
+ return name in self._capacity_overrides
521
+
522
+ def get_lock(self, name: str) -> threading.Lock | threading.RLock:
523
+ """Return the lock guarding cache ``name`` for external coordination."""
524
+
525
+ entry = self._entries.get(name)
526
+ if entry is None:
527
+ raise KeyError(name)
528
+ return entry.lock
529
+
530
+ def names(self) -> Iterator[str]:
531
+ """Iterate over registered cache names."""
532
+
533
+ with self._registry_lock:
534
+ return iter(tuple(self._entries))
535
+
536
+ def get(self, name: str, *, create: bool = True) -> Any:
537
+ """Return cache ``name`` creating it on demand when ``create`` is true."""
538
+
539
+ entry = self._entries.get(name)
540
+ if entry is None:
541
+ raise KeyError(name)
542
+ with entry.lock:
543
+ value = self._load_from_layers(name, entry)
544
+ if create and value is None:
545
+ value = entry.factory()
546
+ self._persist_layers(name, entry, value)
547
+ return value
548
+
549
+ def peek(self, name: str) -> Any:
550
+ """Return cache ``name`` without creating a missing entry."""
551
+
552
+ entry = self._entries.get(name)
553
+ if entry is None:
554
+ raise KeyError(name)
555
+ with entry.lock:
556
+ return self._load_from_layers(name, entry)
557
+
558
+ def store(self, name: str, value: Any) -> None:
559
+ """Replace the stored value for cache ``name`` with ``value``."""
560
+
561
+ entry = self._entries.get(name)
562
+ if entry is None:
563
+ raise KeyError(name)
564
+ with entry.lock:
565
+ self._persist_layers(name, entry, value)
566
+
567
+ def update(
568
+ self,
569
+ name: str,
570
+ updater: Callable[[Any], Any],
571
+ *,
572
+ create: bool = True,
573
+ ) -> Any:
574
+ """Apply ``updater`` to cache ``name`` storing the resulting value."""
575
+
576
+ entry = self._entries.get(name)
577
+ if entry is None:
578
+ raise KeyError(name)
579
+ with entry.lock:
580
+ current = self._load_from_layers(name, entry)
581
+ if create and current is None:
582
+ current = entry.factory()
583
+ new_value = updater(current)
584
+ self._persist_layers(name, entry, new_value)
585
+ return new_value
586
+
587
+ def clear(self, name: str | None = None) -> None:
588
+ """Reset caches either selectively or for every registered name."""
589
+
590
+ if name is not None:
591
+ names = (name,)
592
+ else:
593
+ with self._registry_lock:
594
+ names = tuple(self._entries)
595
+ for cache_name in names:
596
+ entry = self._entries.get(cache_name)
597
+ if entry is None:
598
+ continue
599
+ with entry.lock:
600
+ current = self._load_from_layers(cache_name, entry)
601
+ new_value = None
602
+ if entry.reset is not None:
603
+ try:
604
+ new_value = entry.reset(current)
605
+ except Exception: # pragma: no cover - defensive logging
606
+ _logger.exception("cache reset failed for %s", cache_name)
607
+ if new_value is None:
608
+ try:
609
+ new_value = entry.factory()
610
+ except Exception:
611
+ self._delete_from_layers(cache_name)
612
+ continue
613
+ self._persist_layers(cache_name, entry, new_value)
614
+
615
+ # ------------------------------------------------------------------
616
+ # Layer orchestration helpers
617
+
618
+ def _encode_value(self, entry: _CacheEntry, value: Any) -> Any:
619
+ encoder = entry.encoder
620
+ if encoder is None:
621
+ return value
622
+ return encoder(value)
623
+
624
+ def _decode_value(self, entry: _CacheEntry, payload: Any) -> Any:
625
+ decoder = entry.decoder
626
+ if decoder is None:
627
+ return payload
628
+ return decoder(payload)
629
+
630
+ def _store_layer(self, name: str, entry: _CacheEntry, value: Any, *, layer_index: int) -> None:
631
+ layer = self._layers[layer_index]
632
+ if layer_index == 0:
633
+ payload = value
634
+ else:
635
+ try:
636
+ payload = self._encode_value(entry, value)
637
+ except Exception: # pragma: no cover - defensive logging
638
+ _logger.exception("cache encoding failed for %s", name)
639
+ return
640
+ try:
641
+ layer.store(name, payload)
642
+ except Exception: # pragma: no cover - defensive logging
643
+ _logger.exception(
644
+ "cache layer store failed for %s on %s", name, layer.__class__.__name__
645
+ )
646
+
647
+ def _persist_layers(self, name: str, entry: _CacheEntry, value: Any) -> None:
648
+ for index in range(len(self._layers)):
649
+ self._store_layer(name, entry, value, layer_index=index)
650
+
651
+ def _delete_from_layers(self, name: str) -> None:
652
+ for layer in self._layers:
653
+ try:
654
+ layer.delete(name)
655
+ except KeyError:
656
+ continue
657
+ except Exception: # pragma: no cover - defensive logging
658
+ _logger.exception(
659
+ "cache layer delete failed for %s on %s", name, layer.__class__.__name__
660
+ )
661
+
662
+ def _load_from_layers(self, name: str, entry: _CacheEntry) -> Any:
663
+ # Primary in-memory layer first for fast-path lookups.
664
+ try:
665
+ value = self._layers[0].load(name)
666
+ except KeyError:
667
+ value = None
668
+ except Exception: # pragma: no cover - defensive logging
669
+ _logger.exception(
670
+ "cache layer load failed for %s on %s", name, self._layers[0].__class__.__name__
671
+ )
672
+ value = None
673
+ if value is not None:
674
+ return value
675
+
676
+ # Fall back to slower layers and hydrate preceding caches on success.
677
+ for index in range(1, len(self._layers)):
678
+ layer = self._layers[index]
679
+ try:
680
+ payload = layer.load(name)
681
+ except KeyError:
682
+ continue
683
+ except Exception: # pragma: no cover - defensive logging
684
+ _logger.exception(
685
+ "cache layer load failed for %s on %s", name, layer.__class__.__name__
686
+ )
687
+ continue
688
+ try:
689
+ value = self._decode_value(entry, payload)
690
+ except Exception: # pragma: no cover - defensive logging
691
+ _logger.exception("cache decoding failed for %s", name)
692
+ continue
693
+ if value is None:
694
+ continue
695
+ for prev_index in range(index):
696
+ self._store_layer(name, entry, value, layer_index=prev_index)
697
+ return value
698
+ return None
699
+
700
+ # ------------------------------------------------------------------
701
+ # Metrics helpers
702
+
703
+ def _ensure_metrics(self, name: str) -> _CacheMetrics:
704
+ metrics = self._metrics.get(name)
705
+ if metrics is None:
706
+ with self._registry_lock:
707
+ metrics = self._metrics.get(name)
708
+ if metrics is None:
709
+ metrics = _CacheMetrics()
710
+ self._metrics[name] = metrics
711
+ return metrics
712
+
713
+ def increment_hit(
714
+ self,
715
+ name: str,
716
+ *,
717
+ amount: int = 1,
718
+ duration: float | None = None,
719
+ ) -> None:
720
+ """Increase cache hit counters for ``name`` (optionally logging latency)."""
721
+
722
+ metrics = self._ensure_metrics(name)
723
+ with metrics.lock:
724
+ metrics.hits += int(amount)
725
+ if duration is not None:
726
+ metrics.total_time += float(duration)
727
+ metrics.timings += 1
728
+
729
+ def increment_miss(
730
+ self,
731
+ name: str,
732
+ *,
733
+ amount: int = 1,
734
+ duration: float | None = None,
735
+ ) -> None:
736
+ """Increase cache miss counters for ``name`` (optionally logging latency)."""
737
+
738
+ metrics = self._ensure_metrics(name)
739
+ with metrics.lock:
740
+ metrics.misses += int(amount)
741
+ if duration is not None:
742
+ metrics.total_time += float(duration)
743
+ metrics.timings += 1
744
+
745
+ def increment_eviction(self, name: str, *, amount: int = 1) -> None:
746
+ """Increase eviction count for cache ``name``."""
747
+
748
+ metrics = self._ensure_metrics(name)
749
+ with metrics.lock:
750
+ metrics.evictions += int(amount)
751
+
752
+ def record_timing(self, name: str, duration: float) -> None:
753
+ """Accumulate ``duration`` into latency telemetry for ``name``."""
754
+
755
+ metrics = self._ensure_metrics(name)
756
+ with metrics.lock:
757
+ metrics.total_time += float(duration)
758
+ metrics.timings += 1
759
+
760
+ @contextmanager
761
+ def timer(self, name: str) -> TimingContext:
762
+ """Context manager recording execution time for ``name``."""
763
+
764
+ start = perf_counter()
765
+ try:
766
+ yield
767
+ finally:
768
+ self.record_timing(name, perf_counter() - start)
769
+
770
+ def get_metrics(self, name: str) -> CacheStatistics:
771
+ """Return a snapshot of telemetry collected for cache ``name``."""
772
+
773
+ metrics = self._metrics.get(name)
774
+ if metrics is None:
775
+ return CacheStatistics()
776
+ with metrics.lock:
777
+ return metrics.snapshot()
778
+
779
+ def iter_metrics(self) -> Iterator[tuple[str, CacheStatistics]]:
780
+ """Yield ``(name, stats)`` pairs for every cache with telemetry."""
781
+
782
+ with self._registry_lock:
783
+ items = tuple(self._metrics.items())
784
+ for name, metrics in items:
785
+ with metrics.lock:
786
+ yield name, metrics.snapshot()
787
+
788
+ def aggregate_metrics(self) -> CacheStatistics:
789
+ """Return aggregated telemetry statistics across all caches."""
790
+
791
+ aggregate = CacheStatistics()
792
+ for _, stats in self.iter_metrics():
793
+ aggregate = aggregate.merge(stats)
794
+ return aggregate
795
+
796
+ def register_metrics_publisher(
797
+ self, publisher: Callable[[str, CacheStatistics], None]
798
+ ) -> None:
799
+ """Register ``publisher`` to receive metrics snapshots on demand."""
800
+
801
+ with self._registry_lock:
802
+ self._metrics_publishers.append(publisher)
803
+
804
+ def publish_metrics(
805
+ self,
806
+ *,
807
+ publisher: Callable[[str, CacheStatistics], None] | None = None,
808
+ ) -> None:
809
+ """Send cached telemetry to ``publisher`` or all registered publishers."""
810
+
811
+ if publisher is None:
812
+ with self._registry_lock:
813
+ publishers = tuple(self._metrics_publishers)
814
+ else:
815
+ publishers = (publisher,)
816
+ if not publishers:
817
+ return
818
+ snapshot = tuple(self.iter_metrics())
819
+ for emit in publishers:
820
+ for name, stats in snapshot:
821
+ try:
822
+ emit(name, stats)
823
+ except Exception: # pragma: no cover - defensive logging
824
+ _logger.exception("Cache metrics publisher failed for %s", name)
825
+
826
+ def log_metrics(self, logger: logging.Logger, *, level: int = logging.INFO) -> None:
827
+ """Emit cache metrics using ``logger`` for telemetry hooks."""
828
+
829
+ for name, stats in self.iter_metrics():
830
+ logger.log(
831
+ level,
832
+ "cache=%s hits=%d misses=%d evictions=%d timings=%d total_time=%.6f",
833
+ name,
834
+ stats.hits,
835
+ stats.misses,
836
+ stats.evictions,
837
+ stats.timings,
838
+ stats.total_time,
839
+ )
840
+
841
+
842
+ try:
843
+ from .init import get_logger as _get_logger
844
+ except ImportError: # pragma: no cover - circular bootstrap fallback
845
+
846
+ def _get_logger(name: str) -> logging.Logger:
847
+ return logging.getLogger(name)
848
+
849
+ _logger = _get_logger(__name__)
850
+ get_logger = _get_logger
851
+
852
+
853
+ def _normalise_callbacks(
854
+ callbacks: Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None,
855
+ ) -> tuple[Callable[[K, V], None], ...]:
856
+ if callbacks is None:
857
+ return ()
858
+ if callable(callbacks):
859
+ return (callbacks,)
860
+ return tuple(callbacks)
861
+
862
+
863
+ def prune_lock_mapping(
864
+ cache: Mapping[K, Any] | MutableMapping[K, Any] | None,
865
+ locks: MutableMapping[K, Any] | None,
866
+ ) -> None:
867
+ """Drop lock entries not present in ``cache``."""
868
+
869
+ if locks is None:
870
+ return
871
+ if cache is None:
872
+ cache_keys: set[K] = set()
873
+ else:
874
+ cache_keys = set(cache.keys())
875
+ for key in list(locks.keys()):
876
+ if key not in cache_keys:
877
+ locks.pop(key, None)
878
+
879
+
880
+ class InstrumentedLRUCache(MutableMapping[K, V], Generic[K, V]):
881
+ """LRU cache wrapper that synchronises telemetry, callbacks and locks.
882
+
883
+ The wrapper owns an internal :class:`cachetools.LRUCache` instance and
884
+ forwards all read operations to it. Mutating operations are instrumented to
885
+ update :class:`CacheManager` metrics, execute registered callbacks and keep
886
+ an optional lock mapping aligned with the stored keys. Telemetry callbacks
887
+ always execute before eviction callbacks, preserving the registration order
888
+ for deterministic side effects.
889
+
890
+ Callbacks can be extended or replaced after construction via
891
+ :meth:`set_telemetry_callbacks` and :meth:`set_eviction_callbacks`. When
892
+ ``append`` is ``False`` (default) the provided callbacks replace the
893
+ existing sequence; otherwise they are appended at the end while keeping the
894
+ previous ordering intact.
895
+ """
896
+
897
+ _MISSING = object()
898
+
899
+ def __init__(
900
+ self,
901
+ maxsize: int,
902
+ *,
903
+ manager: CacheManager | None = None,
904
+ metrics_key: str | None = None,
905
+ telemetry_callbacks: (
906
+ Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None
907
+ ) = None,
908
+ eviction_callbacks: (
909
+ Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None
910
+ ) = None,
911
+ locks: MutableMapping[K, Any] | None = None,
912
+ getsizeof: Callable[[V], int] | None = None,
913
+ count_overwrite_hit: bool = True,
914
+ ) -> None:
915
+ self._cache: LRUCache[K, V] = LRUCache(maxsize, getsizeof=getsizeof)
916
+ original_popitem = self._cache.popitem
917
+
918
+ def _instrumented_popitem() -> tuple[K, V]:
919
+ key, value = original_popitem()
920
+ self._dispatch_removal(key, value)
921
+ return key, value
922
+
923
+ self._cache.popitem = _instrumented_popitem # type: ignore[assignment]
924
+ self._manager = manager
925
+ self._metrics_key = metrics_key
926
+ self._locks = locks
927
+ self._count_overwrite_hit = bool(count_overwrite_hit)
928
+ self._telemetry_callbacks: list[Callable[[K, V], None]]
929
+ self._telemetry_callbacks = list(_normalise_callbacks(telemetry_callbacks))
930
+ self._eviction_callbacks: list[Callable[[K, V], None]]
931
+ self._eviction_callbacks = list(_normalise_callbacks(eviction_callbacks))
932
+
933
+ # ------------------------------------------------------------------
934
+ # Callback registration helpers
935
+
936
+ @property
937
+ def telemetry_callbacks(self) -> tuple[Callable[[K, V], None], ...]:
938
+ """Return currently registered telemetry callbacks."""
939
+
940
+ return tuple(self._telemetry_callbacks)
941
+
942
+ @property
943
+ def eviction_callbacks(self) -> tuple[Callable[[K, V], None], ...]:
944
+ """Return currently registered eviction callbacks."""
945
+
946
+ return tuple(self._eviction_callbacks)
947
+
948
+ def set_telemetry_callbacks(
949
+ self,
950
+ callbacks: Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None,
951
+ *,
952
+ append: bool = False,
953
+ ) -> None:
954
+ """Update telemetry callbacks executed on removals.
955
+
956
+ When ``append`` is ``True`` the provided callbacks are added to the end
957
+ of the execution chain while preserving relative order. Otherwise, the
958
+ previous callbacks are replaced.
959
+ """
960
+
961
+ new_callbacks = list(_normalise_callbacks(callbacks))
962
+ if append:
963
+ self._telemetry_callbacks.extend(new_callbacks)
964
+ else:
965
+ self._telemetry_callbacks = new_callbacks
966
+
967
+ def set_eviction_callbacks(
968
+ self,
969
+ callbacks: Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None,
970
+ *,
971
+ append: bool = False,
972
+ ) -> None:
973
+ """Update eviction callbacks executed on removals.
974
+
975
+ Behaviour matches :meth:`set_telemetry_callbacks`.
976
+ """
977
+
978
+ new_callbacks = list(_normalise_callbacks(callbacks))
979
+ if append:
980
+ self._eviction_callbacks.extend(new_callbacks)
981
+ else:
982
+ self._eviction_callbacks = new_callbacks
983
+
984
+ # ------------------------------------------------------------------
985
+ # MutableMapping interface
986
+
987
+ def __getitem__(self, key: K) -> V:
988
+ """Return the cached value for ``key``."""
989
+
990
+ return self._cache[key]
991
+
992
+ def __setitem__(self, key: K, value: V) -> None:
993
+ """Store ``value`` under ``key`` updating telemetry accordingly."""
994
+
995
+ exists = key in self._cache
996
+ self._cache[key] = value
997
+ if exists:
998
+ if self._count_overwrite_hit:
999
+ self._record_hit(1)
1000
+ else:
1001
+ self._record_miss(1)
1002
+
1003
+ def __delitem__(self, key: K) -> None:
1004
+ """Remove ``key`` from the cache and dispatch removal callbacks."""
1005
+
1006
+ try:
1007
+ value = self._cache[key]
1008
+ except KeyError:
1009
+ self._record_miss(1)
1010
+ raise
1011
+ del self._cache[key]
1012
+ self._dispatch_removal(key, value, hits=1)
1013
+
1014
+ def __iter__(self) -> Iterator[K]:
1015
+ """Iterate over cached keys in eviction order."""
1016
+
1017
+ return iter(self._cache)
1018
+
1019
+ def __len__(self) -> int:
1020
+ """Return the number of cached entries."""
1021
+
1022
+ return len(self._cache)
1023
+
1024
+ def __contains__(self, key: object) -> bool:
1025
+ """Return ``True`` when ``key`` is stored in the cache."""
1026
+
1027
+ return key in self._cache
1028
+
1029
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
1030
+ """Return a debug representation including the underlying cache."""
1031
+
1032
+ return f"{self.__class__.__name__}({self._cache!r})"
1033
+
1034
+ # ------------------------------------------------------------------
1035
+ # Cache helpers
1036
+
1037
+ @property
1038
+ def maxsize(self) -> int:
1039
+ """Return the configured maximum cache size."""
1040
+
1041
+ return self._cache.maxsize
1042
+
1043
+ @property
1044
+ def currsize(self) -> int:
1045
+ """Return the current weighted size reported by :mod:`cachetools`."""
1046
+
1047
+ return self._cache.currsize
1048
+
1049
+ def get(self, key: K, default: V | None = None) -> V | None:
1050
+ """Return ``key`` if present, otherwise ``default``."""
1051
+
1052
+ return self._cache.get(key, default)
1053
+
1054
+ def pop(self, key: K, default: Any = _MISSING) -> V:
1055
+ """Remove ``key`` returning its value or ``default`` when provided."""
1056
+
1057
+ try:
1058
+ value = self._cache[key]
1059
+ except KeyError:
1060
+ self._record_miss(1)
1061
+ if default is self._MISSING:
1062
+ raise
1063
+ return cast(V, default)
1064
+ del self._cache[key]
1065
+ self._dispatch_removal(key, value, hits=1)
1066
+ return value
1067
+
1068
+ def popitem(self) -> tuple[K, V]:
1069
+ """Remove and return the LRU entry ensuring instrumentation fires."""
1070
+
1071
+ return self._cache.popitem()
1072
+
1073
+ def clear(self) -> None: # type: ignore[override]
1074
+ """Evict every entry while keeping telemetry and locks consistent."""
1075
+
1076
+ while True:
1077
+ try:
1078
+ self.popitem()
1079
+ except KeyError:
1080
+ break
1081
+ if self._locks is not None:
1082
+ try:
1083
+ self._locks.clear()
1084
+ except Exception: # pragma: no cover - defensive logging
1085
+ _logger.exception("lock cleanup failed during cache clear")
1086
+
1087
+ # ------------------------------------------------------------------
1088
+ # Internal helpers
1089
+
1090
+ def _record_hit(self, amount: int) -> None:
1091
+ if amount and self._manager is not None and self._metrics_key is not None:
1092
+ self._manager.increment_hit(self._metrics_key, amount=amount)
1093
+
1094
+ def _record_miss(self, amount: int) -> None:
1095
+ if amount and self._manager is not None and self._metrics_key is not None:
1096
+ self._manager.increment_miss(self._metrics_key, amount=amount)
1097
+
1098
+ def _record_eviction(self, amount: int) -> None:
1099
+ if amount and self._manager is not None and self._metrics_key is not None:
1100
+ self._manager.increment_eviction(self._metrics_key, amount=amount)
1101
+
1102
+ def _dispatch_removal(
1103
+ self,
1104
+ key: K,
1105
+ value: V,
1106
+ *,
1107
+ hits: int = 0,
1108
+ misses: int = 0,
1109
+ eviction_amount: int = 1,
1110
+ purge_lock: bool = True,
1111
+ ) -> None:
1112
+ if hits:
1113
+ self._record_hit(hits)
1114
+ if misses:
1115
+ self._record_miss(misses)
1116
+ if eviction_amount:
1117
+ self._record_eviction(eviction_amount)
1118
+ self._emit_callbacks(self._telemetry_callbacks, key, value, "telemetry")
1119
+ self._emit_callbacks(self._eviction_callbacks, key, value, "eviction")
1120
+ if purge_lock:
1121
+ self._purge_lock(key)
1122
+
1123
+ def _emit_callbacks(
1124
+ self,
1125
+ callbacks: Iterable[Callable[[K, V], None]],
1126
+ key: K,
1127
+ value: V,
1128
+ kind: str,
1129
+ ) -> None:
1130
+ for callback in callbacks:
1131
+ try:
1132
+ callback(key, value)
1133
+ except Exception: # pragma: no cover - defensive logging
1134
+ _logger.exception("%s callback failed for %r", kind, key)
1135
+
1136
+ def _purge_lock(self, key: K) -> None:
1137
+ if self._locks is None:
1138
+ return
1139
+ try:
1140
+ self._locks.pop(key, None)
1141
+ except Exception: # pragma: no cover - defensive logging
1142
+ _logger.exception("lock cleanup failed for %r", key)
1143
+
1144
+
1145
+ class ManagedLRUCache(LRUCache[K, V]):
1146
+ """LRU cache wrapper with telemetry hooks and lock synchronisation."""
1147
+
1148
+ def __init__(
1149
+ self,
1150
+ maxsize: int,
1151
+ *,
1152
+ manager: CacheManager | None = None,
1153
+ metrics_key: str | None = None,
1154
+ eviction_callbacks: (
1155
+ Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None
1156
+ ) = None,
1157
+ telemetry_callbacks: (
1158
+ Iterable[Callable[[K, V], None]] | Callable[[K, V], None] | None
1159
+ ) = None,
1160
+ locks: MutableMapping[K, Any] | None = None,
1161
+ ) -> None:
1162
+ super().__init__(maxsize)
1163
+ self._manager = manager
1164
+ self._metrics_key = metrics_key
1165
+ self._locks = locks
1166
+ self._eviction_callbacks = _normalise_callbacks(eviction_callbacks)
1167
+ self._telemetry_callbacks = _normalise_callbacks(telemetry_callbacks)
1168
+
1169
+ def popitem(self) -> tuple[K, V]: # type: ignore[override]
1170
+ """Evict the LRU entry while updating telemetry and lock state."""
1171
+
1172
+ key, value = super().popitem()
1173
+ if self._locks is not None:
1174
+ try:
1175
+ self._locks.pop(key, None)
1176
+ except Exception: # pragma: no cover - defensive logging
1177
+ _logger.exception("lock cleanup failed for %r", key)
1178
+ if self._manager is not None and self._metrics_key is not None:
1179
+ self._manager.increment_eviction(self._metrics_key)
1180
+ for callback in self._telemetry_callbacks:
1181
+ try:
1182
+ callback(key, value)
1183
+ except Exception: # pragma: no cover - defensive logging
1184
+ _logger.exception("telemetry callback failed for %r", key)
1185
+ for callback in self._eviction_callbacks:
1186
+ try:
1187
+ callback(key, value)
1188
+ except Exception: # pragma: no cover - defensive logging
1189
+ _logger.exception("eviction callback failed for %r", key)
1190
+ return key, value
1191
+
1192
+
1193
+ @dataclass
1194
+ class _SeedCacheState:
1195
+ """Container tracking the state for :class:`_SeedHashCache`."""
1196
+
1197
+ cache: InstrumentedLRUCache[tuple[int, int], int] | None
1198
+ maxsize: int
1199
+
1200
+
1201
+ @dataclass
1202
+ class _CounterState(Generic[K]):
1203
+ """State bundle used by :class:`ScopedCounterCache`."""
1204
+
1205
+ cache: InstrumentedLRUCache[K, int]
1206
+ locks: dict[K, threading.RLock]
1207
+ max_entries: int
1208
+
1209
+ # Key used to store the node set checksum in a graph's ``graph`` attribute.
1210
+ NODE_SET_CHECKSUM_KEY = "_node_set_checksum_cache"
1211
+
1212
+ logger = _logger
1213
+
1214
+ # Helper to avoid importing ``tnfr.utils.init`` at module import time and keep
1215
+ # circular dependencies at bay while still reusing the canonical numpy loader.
1216
+ def _require_numpy():
1217
+ from .init import get_numpy
1218
+
1219
+ return get_numpy()
1220
+
1221
+
1222
+ # Graph key storing per-graph layer configuration overrides.
1223
+ _GRAPH_CACHE_LAYERS_KEY = "_tnfr_cache_layers"
1224
+
1225
+ # Process-wide configuration for shared cache layers (Shelve/Redis).
1226
+ _GLOBAL_CACHE_LAYER_CONFIG: dict[str, dict[str, Any]] = {}
1227
+ _GLOBAL_CACHE_LOCK = threading.RLock()
1228
+ _GLOBAL_CACHE_MANAGER: CacheManager | None = None
1229
+
1230
+ # Keys of cache entries dependent on the edge version. Any change to the edge
1231
+ # set requires these to be dropped to avoid stale data.
1232
+ EDGE_VERSION_CACHE_KEYS = ("_trig_version",)
1233
+
1234
+
1235
+ def get_graph_version(graph: Any, key: str, default: int = 0) -> int:
1236
+ """Return integer version stored in ``graph`` under ``key``."""
1237
+
1238
+ return int(graph.get(key, default))
1239
+
1240
+
1241
+ def increment_graph_version(graph: Any, key: str) -> int:
1242
+ """Increment and store a version counter in ``graph`` under ``key``."""
1243
+
1244
+ version = get_graph_version(graph, key) + 1
1245
+ graph[key] = version
1246
+ return version
1247
+
1248
+
1249
+ def stable_json(obj: Any) -> str:
1250
+ """Return a JSON string with deterministic ordering for ``obj``."""
1251
+
1252
+ from .io import json_dumps
1253
+
1254
+ return json_dumps(
1255
+ obj,
1256
+ sort_keys=True,
1257
+ ensure_ascii=False,
1258
+ to_bytes=False,
1259
+ )
1260
+
1261
+
1262
+ @lru_cache(maxsize=1024)
1263
+ def _node_repr_digest(obj: Any) -> tuple[str, bytes]:
1264
+ """Return cached stable representation and digest for ``obj``."""
1265
+
1266
+ try:
1267
+ repr_ = stable_json(obj)
1268
+ except TypeError:
1269
+ repr_ = repr(obj)
1270
+ digest = hashlib.blake2b(repr_.encode("utf-8"), digest_size=16).digest()
1271
+ return repr_, digest
1272
+
1273
+
1274
+ def clear_node_repr_cache() -> None:
1275
+ """Clear cached node representations used for checksums."""
1276
+
1277
+ _node_repr_digest.cache_clear()
1278
+
1279
+
1280
+ def configure_global_cache_layers(
1281
+ *,
1282
+ shelve: Mapping[str, Any] | None = None,
1283
+ redis: Mapping[str, Any] | None = None,
1284
+ replace: bool = False,
1285
+ ) -> None:
1286
+ """Update process-wide cache layer configuration.
1287
+
1288
+ Parameters mirror the per-layer specifications accepted via graph metadata.
1289
+ Passing ``replace=True`` clears previous settings before applying new ones.
1290
+ Providing ``None`` for a layer while ``replace`` is true removes that layer
1291
+ from the configuration.
1292
+ """
1293
+
1294
+ global _GLOBAL_CACHE_MANAGER
1295
+ with _GLOBAL_CACHE_LOCK:
1296
+ manager = _GLOBAL_CACHE_MANAGER
1297
+ _GLOBAL_CACHE_MANAGER = None
1298
+ if replace:
1299
+ _GLOBAL_CACHE_LAYER_CONFIG.clear()
1300
+ if shelve is not None:
1301
+ _GLOBAL_CACHE_LAYER_CONFIG["shelve"] = dict(shelve)
1302
+ elif replace:
1303
+ _GLOBAL_CACHE_LAYER_CONFIG.pop("shelve", None)
1304
+ if redis is not None:
1305
+ _GLOBAL_CACHE_LAYER_CONFIG["redis"] = dict(redis)
1306
+ elif replace:
1307
+ _GLOBAL_CACHE_LAYER_CONFIG.pop("redis", None)
1308
+ _close_cache_layers(manager)
1309
+
1310
+
1311
+ def _resolve_layer_config(
1312
+ graph: MutableMapping[str, Any] | None,
1313
+ ) -> dict[str, dict[str, Any]]:
1314
+ resolved: dict[str, dict[str, Any]] = {}
1315
+ with _GLOBAL_CACHE_LOCK:
1316
+ for name, spec in _GLOBAL_CACHE_LAYER_CONFIG.items():
1317
+ resolved[name] = dict(spec)
1318
+ if graph is not None:
1319
+ overrides = graph.get(_GRAPH_CACHE_LAYERS_KEY)
1320
+ if isinstance(overrides, Mapping):
1321
+ for name in ("shelve", "redis"):
1322
+ layer_spec = overrides.get(name)
1323
+ if isinstance(layer_spec, Mapping):
1324
+ resolved[name] = dict(layer_spec)
1325
+ elif layer_spec is None:
1326
+ resolved.pop(name, None)
1327
+ return resolved
1328
+
1329
+
1330
+ def _build_shelve_layer(spec: Mapping[str, Any]) -> ShelveCacheLayer | None:
1331
+ path = spec.get("path")
1332
+ if not path:
1333
+ return None
1334
+ flag = spec.get("flag", "c")
1335
+ protocol = spec.get("protocol")
1336
+ writeback = bool(spec.get("writeback", False))
1337
+ try:
1338
+ proto_arg = None if protocol is None else int(protocol)
1339
+ except (TypeError, ValueError):
1340
+ logger.warning("Invalid shelve protocol %r; falling back to default", protocol)
1341
+ proto_arg = None
1342
+ try:
1343
+ return ShelveCacheLayer(
1344
+ str(path),
1345
+ flag=str(flag),
1346
+ protocol=proto_arg,
1347
+ writeback=writeback,
1348
+ )
1349
+ except Exception: # pragma: no cover - defensive logging
1350
+ logger.exception("Failed to initialise ShelveCacheLayer for path %r", path)
1351
+ return None
1352
+
1353
+
1354
+ def _build_redis_layer(spec: Mapping[str, Any]) -> RedisCacheLayer | None:
1355
+ enabled = spec.get("enabled", True)
1356
+ if not enabled:
1357
+ return None
1358
+ namespace = spec.get("namespace")
1359
+ client = spec.get("client")
1360
+ if client is None:
1361
+ factory = spec.get("client_factory")
1362
+ if callable(factory):
1363
+ try:
1364
+ client = factory()
1365
+ except Exception: # pragma: no cover - defensive logging
1366
+ logger.exception("Redis cache client factory failed")
1367
+ return None
1368
+ else:
1369
+ kwargs = spec.get("client_kwargs")
1370
+ if isinstance(kwargs, Mapping):
1371
+ try: # pragma: no cover - optional dependency
1372
+ import redis # type: ignore
1373
+ except Exception: # pragma: no cover - defensive logging
1374
+ logger.exception("redis-py is required to build the configured Redis client")
1375
+ return None
1376
+ try:
1377
+ client = redis.Redis(**dict(kwargs))
1378
+ except Exception: # pragma: no cover - defensive logging
1379
+ logger.exception("Failed to initialise redis client with %r", kwargs)
1380
+ return None
1381
+ try:
1382
+ if namespace is None:
1383
+ return RedisCacheLayer(client=client)
1384
+ return RedisCacheLayer(client=client, namespace=str(namespace))
1385
+ except Exception: # pragma: no cover - defensive logging
1386
+ logger.exception("Failed to initialise RedisCacheLayer")
1387
+ return None
1388
+
1389
+
1390
+ def _build_cache_layers(config: Mapping[str, dict[str, Any]]) -> tuple[CacheLayer, ...]:
1391
+ layers: list[CacheLayer] = []
1392
+ shelve_spec = config.get("shelve")
1393
+ if isinstance(shelve_spec, Mapping):
1394
+ layer = _build_shelve_layer(shelve_spec)
1395
+ if layer is not None:
1396
+ layers.append(layer)
1397
+ redis_spec = config.get("redis")
1398
+ if isinstance(redis_spec, Mapping):
1399
+ layer = _build_redis_layer(redis_spec)
1400
+ if layer is not None:
1401
+ layers.append(layer)
1402
+ return tuple(layers)
1403
+
1404
+
1405
+ def _close_cache_layers(manager: CacheManager | None) -> None:
1406
+ if manager is None:
1407
+ return
1408
+ layers = getattr(manager, "_layers", ())
1409
+ for layer in layers:
1410
+ close = getattr(layer, "close", None)
1411
+ if callable(close):
1412
+ try:
1413
+ close()
1414
+ except Exception: # pragma: no cover - defensive logging
1415
+ logger.exception(
1416
+ "Cache layer close failed for %s", layer.__class__.__name__
1417
+ )
1418
+
1419
+
1420
+ def reset_global_cache_manager() -> None:
1421
+ """Dispose the shared cache manager and close attached layers."""
1422
+
1423
+ global _GLOBAL_CACHE_MANAGER
1424
+ with _GLOBAL_CACHE_LOCK:
1425
+ manager = _GLOBAL_CACHE_MANAGER
1426
+ _GLOBAL_CACHE_MANAGER = None
1427
+ _close_cache_layers(manager)
1428
+
1429
+
1430
+ def build_cache_manager(
1431
+ *,
1432
+ graph: MutableMapping[str, Any] | None = None,
1433
+ storage: MutableMapping[str, Any] | None = None,
1434
+ default_capacity: int | None = None,
1435
+ overrides: Mapping[str, int | None] | None = None,
1436
+ ) -> CacheManager:
1437
+ """Construct a :class:`CacheManager` honouring configured cache layers."""
1438
+
1439
+ global _GLOBAL_CACHE_MANAGER
1440
+ if graph is None:
1441
+ with _GLOBAL_CACHE_LOCK:
1442
+ manager = _GLOBAL_CACHE_MANAGER
1443
+ if manager is not None:
1444
+ return manager
1445
+
1446
+ layers = _build_cache_layers(_resolve_layer_config(graph))
1447
+ manager = CacheManager(
1448
+ storage,
1449
+ default_capacity=default_capacity,
1450
+ overrides=overrides,
1451
+ layers=layers,
1452
+ )
1453
+
1454
+ if graph is None:
1455
+ with _GLOBAL_CACHE_LOCK:
1456
+ global_manager = _GLOBAL_CACHE_MANAGER
1457
+ if global_manager is None:
1458
+ _GLOBAL_CACHE_MANAGER = manager
1459
+ return manager
1460
+ _close_cache_layers(manager)
1461
+ return global_manager
1462
+
1463
+ return manager
1464
+
1465
+
1466
+ def _node_repr(n: Any) -> str:
1467
+ """Stable representation for node hashing and sorting."""
1468
+
1469
+ return _node_repr_digest(n)[0]
1470
+
1471
+
1472
+ def _iter_node_digests(nodes: Iterable[Any], *, presorted: bool) -> Iterable[bytes]:
1473
+ """Yield node digests in a deterministic order."""
1474
+
1475
+ if presorted:
1476
+ for node in nodes:
1477
+ yield _node_repr_digest(node)[1]
1478
+ else:
1479
+ for _, digest in sorted(
1480
+ (_node_repr_digest(n) for n in nodes), key=lambda x: x[0]
1481
+ ):
1482
+ yield digest
1483
+
1484
+
1485
+ def _node_set_checksum_no_nodes(
1486
+ G: nx.Graph,
1487
+ graph: Any,
1488
+ *,
1489
+ presorted: bool,
1490
+ store: bool,
1491
+ ) -> str:
1492
+ """Checksum helper when no explicit node set is provided."""
1493
+
1494
+ nodes_view = G.nodes()
1495
+ current_nodes = frozenset(nodes_view)
1496
+ cached = graph.get(NODE_SET_CHECKSUM_KEY)
1497
+ if cached and len(cached) == 3 and cached[2] == current_nodes:
1498
+ return cached[1]
1499
+
1500
+ hasher = hashlib.blake2b(digest_size=16)
1501
+ for digest in _iter_node_digests(nodes_view, presorted=presorted):
1502
+ hasher.update(digest)
1503
+
1504
+ checksum = hasher.hexdigest()
1505
+ if store:
1506
+ token = checksum[:16]
1507
+ if cached and cached[0] == token:
1508
+ return cached[1]
1509
+ graph[NODE_SET_CHECKSUM_KEY] = (token, checksum, current_nodes)
1510
+ else:
1511
+ graph.pop(NODE_SET_CHECKSUM_KEY, None)
1512
+ return checksum
1513
+
1514
+
1515
+ def node_set_checksum(
1516
+ G: nx.Graph,
1517
+ nodes: Iterable[Any] | None = None,
1518
+ *,
1519
+ presorted: bool = False,
1520
+ store: bool = True,
1521
+ ) -> str:
1522
+ """Return a BLAKE2b checksum of ``G``'s node set."""
1523
+
1524
+ graph = get_graph(G)
1525
+ if nodes is None:
1526
+ return _node_set_checksum_no_nodes(G, graph, presorted=presorted, store=store)
1527
+
1528
+ hasher = hashlib.blake2b(digest_size=16)
1529
+ for digest in _iter_node_digests(nodes, presorted=presorted):
1530
+ hasher.update(digest)
1531
+
1532
+ checksum = hasher.hexdigest()
1533
+ if store:
1534
+ token = checksum[:16]
1535
+ cached = graph.get(NODE_SET_CHECKSUM_KEY)
1536
+ if cached and cached[0] == token:
1537
+ return cached[1]
1538
+ graph[NODE_SET_CHECKSUM_KEY] = (token, checksum)
1539
+ else:
1540
+ graph.pop(NODE_SET_CHECKSUM_KEY, None)
1541
+ return checksum
1542
+
1543
+
1544
+ @dataclass(slots=True)
1545
+ class NodeCache:
1546
+ """Container for cached node data."""
1547
+
1548
+ checksum: str
1549
+ nodes: tuple[Any, ...]
1550
+ sorted_nodes: tuple[Any, ...] | None = None
1551
+ idx: dict[Any, int] | None = None
1552
+ offset: dict[Any, int] | None = None
1553
+
1554
+ @property
1555
+ def n(self) -> int:
1556
+ return len(self.nodes)
1557
+
1558
+
1559
+ def _update_node_cache(
1560
+ graph: Any,
1561
+ nodes: tuple[Any, ...],
1562
+ key: str,
1563
+ *,
1564
+ checksum: str,
1565
+ sorted_nodes: tuple[Any, ...] | None = None,
1566
+ ) -> None:
1567
+ """Store ``nodes`` and ``checksum`` in ``graph`` under ``key``."""
1568
+
1569
+ graph[f"{key}_cache"] = NodeCache(
1570
+ checksum=checksum, nodes=nodes, sorted_nodes=sorted_nodes
1571
+ )
1572
+ graph[f"{key}_checksum"] = checksum
1573
+
1574
+
1575
+ def _refresh_node_list_cache(
1576
+ G: nx.Graph,
1577
+ graph: Any,
1578
+ *,
1579
+ sort_nodes: bool,
1580
+ current_n: int,
1581
+ ) -> tuple[Any, ...]:
1582
+ """Refresh the cached node list and return the nodes."""
1583
+
1584
+ nodes = tuple(G.nodes())
1585
+ checksum = node_set_checksum(G, nodes, store=True)
1586
+ sorted_nodes = tuple(sorted(nodes, key=_node_repr)) if sort_nodes else None
1587
+ _update_node_cache(
1588
+ graph,
1589
+ nodes,
1590
+ "_node_list",
1591
+ checksum=checksum,
1592
+ sorted_nodes=sorted_nodes,
1593
+ )
1594
+ graph["_node_list_len"] = current_n
1595
+ return nodes
1596
+
1597
+
1598
+ def _reuse_node_list_cache(
1599
+ graph: Any,
1600
+ cache: NodeCache,
1601
+ nodes: tuple[Any, ...],
1602
+ sorted_nodes: tuple[Any, ...] | None,
1603
+ *,
1604
+ sort_nodes: bool,
1605
+ new_checksum: str | None,
1606
+ ) -> None:
1607
+ """Reuse existing node cache and record its checksum if missing."""
1608
+
1609
+ checksum = cache.checksum if new_checksum is None else new_checksum
1610
+ if sort_nodes and sorted_nodes is None:
1611
+ sorted_nodes = tuple(sorted(nodes, key=_node_repr))
1612
+ _update_node_cache(
1613
+ graph,
1614
+ nodes,
1615
+ "_node_list",
1616
+ checksum=checksum,
1617
+ sorted_nodes=sorted_nodes,
1618
+ )
1619
+
1620
+
1621
+ def _cache_node_list(G: nx.Graph) -> tuple[Any, ...]:
1622
+ """Cache and return the tuple of nodes for ``G``."""
1623
+
1624
+ graph = get_graph(G)
1625
+ cache: NodeCache | None = graph.get("_node_list_cache")
1626
+ nodes = cache.nodes if cache else None
1627
+ sorted_nodes = cache.sorted_nodes if cache else None
1628
+ stored_len = graph.get("_node_list_len")
1629
+ current_n = G.number_of_nodes()
1630
+ dirty = bool(graph.pop("_node_list_dirty", False))
1631
+
1632
+ invalid = nodes is None or stored_len != current_n or dirty
1633
+ new_checksum: str | None = None
1634
+
1635
+ if not invalid and cache:
1636
+ new_checksum = node_set_checksum(G)
1637
+ invalid = cache.checksum != new_checksum
1638
+
1639
+ sort_nodes = bool(graph.get("SORT_NODES", False))
1640
+
1641
+ if invalid:
1642
+ nodes = _refresh_node_list_cache(
1643
+ G, graph, sort_nodes=sort_nodes, current_n=current_n
1644
+ )
1645
+ elif cache and "_node_list_checksum" not in graph:
1646
+ _reuse_node_list_cache(
1647
+ graph,
1648
+ cache,
1649
+ nodes,
1650
+ sorted_nodes,
1651
+ sort_nodes=sort_nodes,
1652
+ new_checksum=new_checksum,
1653
+ )
1654
+ else:
1655
+ if sort_nodes and sorted_nodes is None and cache is not None:
1656
+ cache.sorted_nodes = tuple(sorted(nodes, key=_node_repr))
1657
+ return nodes
1658
+
1659
+
1660
+ def cached_node_list(G: nx.Graph) -> tuple[Any, ...]:
1661
+ """Public wrapper returning the cached node tuple for ``G``."""
1662
+
1663
+ return _cache_node_list(G)
1664
+
1665
+
1666
+ def _ensure_node_map(
1667
+ G: TNFRGraph,
1668
+ *,
1669
+ attrs: tuple[str, ...],
1670
+ sort: bool = False,
1671
+ ) -> dict[NodeId, int]:
1672
+ """Return cached node-to-index/offset mappings stored on ``NodeCache``."""
1673
+
1674
+ graph = G.graph
1675
+ _cache_node_list(G)
1676
+ cache: NodeCache = graph["_node_list_cache"]
1677
+
1678
+ missing = [attr for attr in attrs if getattr(cache, attr) is None]
1679
+ if missing:
1680
+ if sort:
1681
+ nodes_opt = cache.sorted_nodes
1682
+ if nodes_opt is None:
1683
+ nodes_opt = tuple(sorted(cache.nodes, key=_node_repr))
1684
+ cache.sorted_nodes = nodes_opt
1685
+ nodes_seq = nodes_opt
1686
+ else:
1687
+ nodes_seq = cache.nodes
1688
+ node_ids = cast(tuple[NodeId, ...], nodes_seq)
1689
+ mappings: dict[str, dict[NodeId, int]] = {attr: {} for attr in missing}
1690
+ for idx, node in enumerate(node_ids):
1691
+ for attr in missing:
1692
+ mappings[attr][node] = idx
1693
+ for attr in missing:
1694
+ setattr(cache, attr, mappings[attr])
1695
+ return cast(dict[NodeId, int], getattr(cache, attrs[0]))
1696
+
1697
+
1698
+ def ensure_node_index_map(G: TNFRGraph) -> dict[NodeId, int]:
1699
+ """Return cached node-to-index mapping for ``G``."""
1700
+
1701
+ return _ensure_node_map(G, attrs=("idx",), sort=False)
1702
+
1703
+
1704
+ def ensure_node_offset_map(G: TNFRGraph) -> dict[NodeId, int]:
1705
+ """Return cached node-to-offset mapping for ``G``."""
1706
+
1707
+ sort = bool(G.graph.get("SORT_NODES", False))
1708
+ return _ensure_node_map(G, attrs=("offset",), sort=sort)
1709
+
1710
+
1711
+ @dataclass
1712
+ class EdgeCacheState:
1713
+ cache: MutableMapping[Hashable, Any]
1714
+ locks: defaultdict[Hashable, threading.RLock]
1715
+ max_entries: int | None
1716
+ dirty: bool = False
1717
+
1718
+
1719
+ _GRAPH_CACHE_MANAGER_KEY = "_tnfr_cache_manager"
1720
+ _GRAPH_CACHE_CONFIG_KEY = "_tnfr_cache_config"
1721
+ DNFR_PREP_STATE_KEY = "_dnfr_prep_state"
1722
+
1723
+
1724
+ @dataclass(slots=True)
1725
+ class DnfrPrepState:
1726
+ """State container coordinating ΔNFR preparation caches."""
1727
+
1728
+ cache: DnfrCache
1729
+ cache_lock: threading.RLock
1730
+ vector_lock: threading.RLock
1731
+
1732
+
1733
+ def _build_dnfr_prep_state(
1734
+ graph: MutableMapping[str, Any],
1735
+ previous: DnfrPrepState | None = None,
1736
+ ) -> DnfrPrepState:
1737
+ """Construct a :class:`DnfrPrepState` and mirror it on ``graph``."""
1738
+
1739
+ cache_lock: threading.RLock
1740
+ vector_lock: threading.RLock
1741
+ if isinstance(previous, DnfrPrepState):
1742
+ cache_lock = previous.cache_lock
1743
+ vector_lock = previous.vector_lock
1744
+ else:
1745
+ cache_lock = threading.RLock()
1746
+ vector_lock = threading.RLock()
1747
+ state = DnfrPrepState(
1748
+ cache=new_dnfr_cache(),
1749
+ cache_lock=cache_lock,
1750
+ vector_lock=vector_lock,
1751
+ )
1752
+ graph["_dnfr_prep_cache"] = state.cache
1753
+ return state
1754
+
1755
+
1756
+ def _coerce_dnfr_state(
1757
+ graph: MutableMapping[str, Any],
1758
+ current: Any,
1759
+ ) -> DnfrPrepState:
1760
+ """Return ``current`` normalised into :class:`DnfrPrepState`."""
1761
+
1762
+ if isinstance(current, DnfrPrepState):
1763
+ graph["_dnfr_prep_cache"] = current.cache
1764
+ return current
1765
+ if isinstance(current, DnfrCache):
1766
+ state = DnfrPrepState(
1767
+ cache=current,
1768
+ cache_lock=threading.RLock(),
1769
+ vector_lock=threading.RLock(),
1770
+ )
1771
+ graph["_dnfr_prep_cache"] = current
1772
+ return state
1773
+ return _build_dnfr_prep_state(graph)
1774
+
1775
+
1776
+ def _graph_cache_manager(graph: MutableMapping[str, Any]) -> CacheManager:
1777
+ manager = graph.get(_GRAPH_CACHE_MANAGER_KEY)
1778
+ if not isinstance(manager, CacheManager):
1779
+ manager = build_cache_manager(graph=graph, default_capacity=128)
1780
+ graph[_GRAPH_CACHE_MANAGER_KEY] = manager
1781
+ config = graph.get(_GRAPH_CACHE_CONFIG_KEY)
1782
+ if isinstance(config, dict):
1783
+ manager.configure_from_mapping(config)
1784
+
1785
+ def _dnfr_factory() -> DnfrPrepState:
1786
+ return _build_dnfr_prep_state(graph)
1787
+
1788
+ def _dnfr_reset(current: Any) -> DnfrPrepState:
1789
+ if isinstance(current, DnfrPrepState):
1790
+ return _build_dnfr_prep_state(graph, current)
1791
+ return _build_dnfr_prep_state(graph)
1792
+
1793
+ manager.register(
1794
+ DNFR_PREP_STATE_KEY,
1795
+ _dnfr_factory,
1796
+ reset=_dnfr_reset,
1797
+ )
1798
+ manager.update(
1799
+ DNFR_PREP_STATE_KEY,
1800
+ lambda current: _coerce_dnfr_state(graph, current),
1801
+ )
1802
+ return manager
1803
+
1804
+
1805
+ def configure_graph_cache_limits(
1806
+ G: GraphLike | TNFRGraph | MutableMapping[str, Any],
1807
+ *,
1808
+ default_capacity: int | None | object = CacheManager._MISSING,
1809
+ overrides: Mapping[str, int | None] | None = None,
1810
+ replace_overrides: bool = False,
1811
+ ) -> CacheCapacityConfig:
1812
+ """Update cache capacity policy stored on ``G.graph``."""
1813
+
1814
+ graph = get_graph(G)
1815
+ manager = _graph_cache_manager(graph)
1816
+ manager.configure(
1817
+ default_capacity=default_capacity,
1818
+ overrides=overrides,
1819
+ replace_overrides=replace_overrides,
1820
+ )
1821
+ snapshot = manager.export_config()
1822
+ graph[_GRAPH_CACHE_CONFIG_KEY] = {
1823
+ "default_capacity": snapshot.default_capacity,
1824
+ "overrides": dict(snapshot.overrides),
1825
+ }
1826
+ return snapshot
1827
+
1828
+
1829
+ class EdgeCacheManager:
1830
+ """Coordinate cache storage and per-key locks for edge version caches."""
1831
+
1832
+ _STATE_KEY = "_edge_version_state"
1833
+
1834
+ def __init__(self, graph: MutableMapping[str, Any]) -> None:
1835
+ self.graph: MutableMapping[str, Any] = graph
1836
+ self._manager = _graph_cache_manager(graph)
1837
+
1838
+ def _encode_state(state: EdgeCacheState) -> Mapping[str, Any]:
1839
+ if not isinstance(state, EdgeCacheState):
1840
+ raise TypeError("EdgeCacheState expected")
1841
+ return {
1842
+ "max_entries": state.max_entries,
1843
+ "entries": list(state.cache.items()),
1844
+ }
1845
+
1846
+ def _decode_state(payload: Any) -> EdgeCacheState:
1847
+ if isinstance(payload, EdgeCacheState):
1848
+ return payload
1849
+ if not isinstance(payload, Mapping):
1850
+ raise TypeError("invalid edge cache payload")
1851
+ max_entries = payload.get("max_entries")
1852
+ state = self._build_state(max_entries)
1853
+ for key, value in payload.get("entries", []):
1854
+ state.cache[key] = value
1855
+ state.dirty = False
1856
+ return state
1857
+
1858
+ self._manager.register(
1859
+ self._STATE_KEY,
1860
+ self._default_state,
1861
+ reset=self._reset_state,
1862
+ encoder=_encode_state,
1863
+ decoder=_decode_state,
1864
+ )
1865
+
1866
+ def record_hit(self) -> None:
1867
+ """Record a cache hit for telemetry."""
1868
+
1869
+ self._manager.increment_hit(self._STATE_KEY)
1870
+
1871
+ def record_miss(self, *, track_metrics: bool = True) -> None:
1872
+ """Record a cache miss for telemetry.
1873
+
1874
+ When ``track_metrics`` is ``False`` the miss is acknowledged without
1875
+ mutating the aggregated metrics.
1876
+ """
1877
+
1878
+ if track_metrics:
1879
+ self._manager.increment_miss(self._STATE_KEY)
1880
+
1881
+ def record_eviction(self, *, track_metrics: bool = True) -> None:
1882
+ """Record cache eviction events for telemetry.
1883
+
1884
+ When ``track_metrics`` is ``False`` the underlying metrics counter is
1885
+ left untouched while still signalling that an eviction occurred.
1886
+ """
1887
+
1888
+ if track_metrics:
1889
+ self._manager.increment_eviction(self._STATE_KEY)
1890
+
1891
+ def timer(self) -> TimingContext:
1892
+ """Return a timing context linked to this cache."""
1893
+
1894
+ return self._manager.timer(self._STATE_KEY)
1895
+
1896
+ def _default_state(self) -> EdgeCacheState:
1897
+ return self._build_state(None)
1898
+
1899
+ def resolve_max_entries(self, max_entries: int | None | object) -> int | None:
1900
+ """Return effective capacity for the edge cache."""
1901
+
1902
+ if max_entries is CacheManager._MISSING:
1903
+ return self._manager.get_capacity(self._STATE_KEY)
1904
+ return self._manager.get_capacity(
1905
+ self._STATE_KEY,
1906
+ requested=None if max_entries is None else int(max_entries),
1907
+ use_default=False,
1908
+ )
1909
+
1910
+ def _build_state(self, max_entries: int | None) -> EdgeCacheState:
1911
+ locks: defaultdict[Hashable, threading.RLock] = defaultdict(threading.RLock)
1912
+ capacity = float("inf") if max_entries is None else int(max_entries)
1913
+ cache = InstrumentedLRUCache(
1914
+ capacity,
1915
+ manager=self._manager,
1916
+ metrics_key=self._STATE_KEY,
1917
+ locks=locks,
1918
+ count_overwrite_hit=False,
1919
+ )
1920
+ state = EdgeCacheState(cache=cache, locks=locks, max_entries=max_entries)
1921
+
1922
+ def _on_eviction(key: Hashable, _: Any) -> None:
1923
+ self.record_eviction(track_metrics=False)
1924
+ locks.pop(key, None)
1925
+ state.dirty = True
1926
+
1927
+ cache.set_eviction_callbacks(_on_eviction)
1928
+ return state
1929
+
1930
+ def _ensure_state(
1931
+ self, state: EdgeCacheState | None, max_entries: int | None | object
1932
+ ) -> EdgeCacheState:
1933
+ target = self.resolve_max_entries(max_entries)
1934
+ if target is not None:
1935
+ target = int(target)
1936
+ if target < 0:
1937
+ raise ValueError("max_entries must be non-negative or None")
1938
+ if not isinstance(state, EdgeCacheState) or state.max_entries != target:
1939
+ return self._build_state(target)
1940
+ return state
1941
+
1942
+ def _reset_state(self, state: EdgeCacheState | None) -> EdgeCacheState:
1943
+ if isinstance(state, EdgeCacheState):
1944
+ state.cache.clear()
1945
+ state.dirty = False
1946
+ return state
1947
+ return self._build_state(None)
1948
+
1949
+ def get_cache(
1950
+ self,
1951
+ max_entries: int | None | object,
1952
+ *,
1953
+ create: bool = True,
1954
+ ) -> EdgeCacheState | None:
1955
+ """Return the cache state for the manager's graph."""
1956
+
1957
+ if not create:
1958
+ state = self._manager.peek(self._STATE_KEY)
1959
+ return state if isinstance(state, EdgeCacheState) else None
1960
+
1961
+ state = self._manager.update(
1962
+ self._STATE_KEY,
1963
+ lambda current: self._ensure_state(current, max_entries),
1964
+ )
1965
+ if not isinstance(state, EdgeCacheState):
1966
+ raise RuntimeError("edge cache state failed to initialise")
1967
+ return state
1968
+
1969
+ def flush_state(self, state: EdgeCacheState) -> None:
1970
+ """Persist ``state`` through the configured cache layers when dirty."""
1971
+
1972
+ if not isinstance(state, EdgeCacheState) or not state.dirty:
1973
+ return
1974
+ self._manager.store(self._STATE_KEY, state)
1975
+ state.dirty = False
1976
+
1977
+ def clear(self) -> None:
1978
+ """Reset cached data managed by this instance."""
1979
+
1980
+ self._manager.clear(self._STATE_KEY)
1981
+
1982
+
1983
+ def edge_version_cache(
1984
+ G: Any,
1985
+ key: Hashable,
1986
+ builder: Callable[[], T],
1987
+ *,
1988
+ max_entries: int | None | object = CacheManager._MISSING,
1989
+ ) -> T:
1990
+ """Return cached ``builder`` output tied to the edge version of ``G``."""
1991
+
1992
+ graph = get_graph(G)
1993
+ manager = graph.get("_edge_cache_manager") # type: ignore[assignment]
1994
+ if not isinstance(manager, EdgeCacheManager) or manager.graph is not graph:
1995
+ manager = EdgeCacheManager(graph)
1996
+ graph["_edge_cache_manager"] = manager
1997
+
1998
+ resolved = manager.resolve_max_entries(max_entries)
1999
+ if resolved == 0:
2000
+ return builder()
2001
+
2002
+ state = manager.get_cache(resolved)
2003
+ if state is None:
2004
+ return builder()
2005
+
2006
+ cache = state.cache
2007
+ locks = state.locks
2008
+ edge_version = get_graph_version(graph, "_edge_version")
2009
+ lock = locks[key]
2010
+
2011
+ with lock:
2012
+ entry = cache.get(key)
2013
+ if entry is not None and entry[0] == edge_version:
2014
+ manager.record_hit()
2015
+ return entry[1]
2016
+
2017
+ try:
2018
+ with manager.timer():
2019
+ value = builder()
2020
+ except (RuntimeError, ValueError) as exc: # pragma: no cover - logging side effect
2021
+ logger.exception("edge_version_cache builder failed for %r: %s", key, exc)
2022
+ raise
2023
+ else:
2024
+ result = value
2025
+ with lock:
2026
+ entry = cache.get(key)
2027
+ if entry is not None:
2028
+ cached_version, cached_value = entry
2029
+ manager.record_miss()
2030
+ if cached_version == edge_version:
2031
+ manager.record_hit()
2032
+ return cached_value
2033
+ manager.record_eviction()
2034
+ cache[key] = (edge_version, value)
2035
+ state.dirty = True
2036
+ result = value
2037
+ if state.dirty:
2038
+ manager.flush_state(state)
2039
+ return result
2040
+
2041
+
2042
+ def cached_nodes_and_A(
2043
+ G: nx.Graph,
2044
+ *,
2045
+ cache_size: int | None = 1,
2046
+ require_numpy: bool = False,
2047
+ prefer_sparse: bool = False,
2048
+ nodes: tuple[Any, ...] | None = None,
2049
+ ) -> tuple[tuple[Any, ...], Any]:
2050
+ """Return cached nodes tuple and adjacency matrix for ``G``.
2051
+
2052
+ When ``prefer_sparse`` is true the adjacency matrix construction is skipped
2053
+ unless a caller later requests it explicitly. This lets ΔNFR reuse the
2054
+ edge-index buffers stored on :class:`~tnfr.dynamics.dnfr.DnfrCache` without
2055
+ paying for ``nx.to_numpy_array`` on sparse graphs while keeping the
2056
+ canonical cache interface unchanged.
2057
+ """
2058
+
2059
+ if nodes is None:
2060
+ nodes = cached_node_list(G)
2061
+ graph = G.graph
2062
+
2063
+ checksum = getattr(graph.get("_node_list_cache"), "checksum", None)
2064
+ if checksum is None:
2065
+ checksum = graph.get("_node_list_checksum")
2066
+ if checksum is None:
2067
+ node_set_cache = graph.get(NODE_SET_CHECKSUM_KEY)
2068
+ if isinstance(node_set_cache, tuple) and len(node_set_cache) >= 2:
2069
+ checksum = node_set_cache[1]
2070
+ if checksum is None:
2071
+ checksum = ""
2072
+
2073
+ key = f"_dnfr_{len(nodes)}_{checksum}"
2074
+ graph["_dnfr_nodes_checksum"] = checksum
2075
+
2076
+ def builder() -> tuple[tuple[Any, ...], Any]:
2077
+ np = _require_numpy()
2078
+ if np is None or prefer_sparse:
2079
+ return nodes, None
2080
+ A = nx.to_numpy_array(G, nodelist=nodes, weight=None, dtype=float)
2081
+ return nodes, A
2082
+
2083
+ nodes, A = edge_version_cache(G, key, builder, max_entries=cache_size)
2084
+
2085
+ if require_numpy and A is None:
2086
+ raise RuntimeError("NumPy is required for adjacency caching")
2087
+
2088
+ return nodes, A
2089
+
2090
+
2091
+ def _reset_edge_caches(graph: Any, G: Any) -> None:
2092
+ """Clear caches affected by edge updates."""
2093
+
2094
+ EdgeCacheManager(graph).clear()
2095
+ _graph_cache_manager(graph).clear(DNFR_PREP_STATE_KEY)
2096
+ mark_dnfr_prep_dirty(G)
2097
+ clear_node_repr_cache()
2098
+ for key in EDGE_VERSION_CACHE_KEYS:
2099
+ graph.pop(key, None)
2100
+
2101
+
2102
+ def increment_edge_version(G: Any) -> None:
2103
+ """Increment the edge version counter in ``G.graph``."""
2104
+
2105
+ graph = get_graph(G)
2106
+ increment_graph_version(graph, "_edge_version")
2107
+ _reset_edge_caches(graph, G)
2108
+
2109
+
2110
+ @contextmanager
2111
+ def edge_version_update(G: TNFRGraph) -> Iterator[None]:
2112
+ """Scope a batch of edge mutations."""
2113
+
2114
+ increment_edge_version(G)
2115
+ try:
2116
+ yield
2117
+ finally:
2118
+ increment_edge_version(G)
2119
+
2120
+
2121
+ class _SeedHashCache(MutableMapping[tuple[int, int], int]):
2122
+ """Mutable mapping proxy exposing a configurable LRU cache."""
2123
+
2124
+ def __init__(
2125
+ self,
2126
+ *,
2127
+ manager: CacheManager | None = None,
2128
+ state_key: str = "seed_hash_cache",
2129
+ default_maxsize: int = 128,
2130
+ ) -> None:
2131
+ self._default_maxsize = int(default_maxsize)
2132
+ self._manager = manager or build_cache_manager(
2133
+ default_capacity=self._default_maxsize
2134
+ )
2135
+ self._state_key = state_key
2136
+ if not self._manager.has_override(self._state_key):
2137
+ self._manager.configure(
2138
+ overrides={self._state_key: self._default_maxsize}
2139
+ )
2140
+ self._manager.register(
2141
+ self._state_key,
2142
+ self._create_state,
2143
+ reset=self._reset_state,
2144
+ )
2145
+
2146
+ def _resolved_size(self, requested: int | None = None) -> int:
2147
+ size = self._manager.get_capacity(
2148
+ self._state_key,
2149
+ requested=requested,
2150
+ fallback=self._default_maxsize,
2151
+ )
2152
+ if size is None:
2153
+ return 0
2154
+ return int(size)
2155
+
2156
+ def _create_state(self) -> _SeedCacheState:
2157
+ size = self._resolved_size()
2158
+ if size <= 0:
2159
+ return _SeedCacheState(cache=None, maxsize=0)
2160
+ return _SeedCacheState(
2161
+ cache=InstrumentedLRUCache(
2162
+ size,
2163
+ manager=self._manager,
2164
+ metrics_key=self._state_key,
2165
+ ),
2166
+ maxsize=size,
2167
+ )
2168
+
2169
+ def _reset_state(self, state: _SeedCacheState | None) -> _SeedCacheState:
2170
+ return self._create_state()
2171
+
2172
+ def _get_state(self, *, create: bool = True) -> _SeedCacheState | None:
2173
+ state = self._manager.get(self._state_key, create=create)
2174
+ if state is None:
2175
+ return None
2176
+ if not isinstance(state, _SeedCacheState):
2177
+ state = self._create_state()
2178
+ self._manager.store(self._state_key, state)
2179
+ return state
2180
+
2181
+ def configure(self, maxsize: int) -> None:
2182
+ size = int(maxsize)
2183
+ if size < 0:
2184
+ raise ValueError("maxsize must be non-negative")
2185
+ self._manager.configure(overrides={self._state_key: size})
2186
+ self._manager.update(self._state_key, lambda _: self._create_state())
2187
+
2188
+ def __getitem__(self, key: tuple[int, int]) -> int:
2189
+ state = self._get_state()
2190
+ if state is None or state.cache is None:
2191
+ raise KeyError(key)
2192
+ value = state.cache[key]
2193
+ self._manager.increment_hit(self._state_key)
2194
+ return value
2195
+
2196
+ def __setitem__(self, key: tuple[int, int], value: int) -> None:
2197
+ state = self._get_state()
2198
+ if state is not None and state.cache is not None:
2199
+ state.cache[key] = value
2200
+
2201
+ def __delitem__(self, key: tuple[int, int]) -> None:
2202
+ state = self._get_state()
2203
+ if state is None or state.cache is None:
2204
+ raise KeyError(key)
2205
+ del state.cache[key]
2206
+
2207
+ def __iter__(self) -> Iterator[tuple[int, int]]:
2208
+ state = self._get_state(create=False)
2209
+ if state is None or state.cache is None:
2210
+ return iter(())
2211
+ return iter(state.cache)
2212
+
2213
+ def __len__(self) -> int:
2214
+ state = self._get_state(create=False)
2215
+ if state is None or state.cache is None:
2216
+ return 0
2217
+ return len(state.cache)
2218
+
2219
+ def clear(self) -> None: # type: ignore[override]
2220
+ self._manager.clear(self._state_key)
2221
+
2222
+ @property
2223
+ def maxsize(self) -> int:
2224
+ state = self._get_state()
2225
+ return 0 if state is None else state.maxsize
2226
+
2227
+ @property
2228
+ def enabled(self) -> bool:
2229
+ state = self._get_state(create=False)
2230
+ return bool(state and state.cache is not None)
2231
+
2232
+ @property
2233
+ def data(self) -> InstrumentedLRUCache[tuple[int, int], int] | None:
2234
+ """Expose the underlying cache for diagnostics/tests."""
2235
+
2236
+ state = self._get_state(create=False)
2237
+ return None if state is None else state.cache
2238
+
2239
+
2240
+ class ScopedCounterCache(Generic[K]):
2241
+ """Thread-safe LRU cache storing monotonic counters by ``key``."""
2242
+
2243
+ def __init__(
2244
+ self,
2245
+ name: str,
2246
+ max_entries: int | None = None,
2247
+ *,
2248
+ manager: CacheManager | None = None,
2249
+ default_max_entries: int = 128,
2250
+ ) -> None:
2251
+ self._name = name
2252
+ self._state_key = f"scoped_counter:{name}"
2253
+ self._default_max_entries = int(default_max_entries)
2254
+ requested = None if max_entries is None else int(max_entries)
2255
+ if requested is not None and requested < 0:
2256
+ raise ValueError("max_entries must be non-negative")
2257
+ self._manager = manager or build_cache_manager(
2258
+ default_capacity=self._default_max_entries
2259
+ )
2260
+ if not self._manager.has_override(self._state_key):
2261
+ fallback = requested
2262
+ if fallback is None:
2263
+ fallback = self._default_max_entries
2264
+ self._manager.configure(overrides={self._state_key: fallback})
2265
+ elif requested is not None:
2266
+ self._manager.configure(overrides={self._state_key: requested})
2267
+ self._manager.register(
2268
+ self._state_key,
2269
+ self._create_state,
2270
+ lock_factory=lambda: get_lock(name),
2271
+ reset=self._reset_state,
2272
+ )
2273
+
2274
+ def _resolved_entries(self, requested: int | None = None) -> int:
2275
+ size = self._manager.get_capacity(
2276
+ self._state_key,
2277
+ requested=requested,
2278
+ fallback=self._default_max_entries,
2279
+ )
2280
+ if size is None:
2281
+ return 0
2282
+ return int(size)
2283
+
2284
+ def _create_state(self, requested: int | None = None) -> _CounterState[K]:
2285
+ size = self._resolved_entries(requested)
2286
+ locks: dict[K, threading.RLock] = {}
2287
+ return _CounterState(
2288
+ cache=InstrumentedLRUCache(
2289
+ size,
2290
+ manager=self._manager,
2291
+ metrics_key=self._state_key,
2292
+ locks=locks,
2293
+ ),
2294
+ locks=locks,
2295
+ max_entries=size,
2296
+ )
2297
+
2298
+ def _reset_state(self, state: _CounterState[K] | None) -> _CounterState[K]:
2299
+ return self._create_state()
2300
+
2301
+ def _get_state(self) -> _CounterState[K]:
2302
+ state = self._manager.get(self._state_key)
2303
+ if not isinstance(state, _CounterState):
2304
+ state = self._create_state(0)
2305
+ self._manager.store(self._state_key, state)
2306
+ return state
2307
+
2308
+ @property
2309
+ def lock(self) -> threading.Lock | threading.RLock:
2310
+ """Return the lock guarding access to the underlying cache."""
2311
+
2312
+ return self._manager.get_lock(self._state_key)
2313
+
2314
+ @property
2315
+ def max_entries(self) -> int:
2316
+ """Return the configured maximum number of cached entries."""
2317
+
2318
+ return self._get_state().max_entries
2319
+
2320
+ @property
2321
+ def cache(self) -> InstrumentedLRUCache[K, int]:
2322
+ """Expose the instrumented cache for inspection."""
2323
+
2324
+ return self._get_state().cache
2325
+
2326
+ @property
2327
+ def locks(self) -> dict[K, threading.RLock]:
2328
+ """Return the mapping of per-key locks tracked by the cache."""
2329
+
2330
+ return self._get_state().locks
2331
+
2332
+ def configure(self, *, force: bool = False, max_entries: int | None = None) -> None:
2333
+ """Resize or reset the cache keeping previous settings."""
2334
+
2335
+ if max_entries is None:
2336
+ size = self._resolved_entries()
2337
+ update_policy = False
2338
+ else:
2339
+ size = int(max_entries)
2340
+ if size < 0:
2341
+ raise ValueError("max_entries must be non-negative")
2342
+ update_policy = True
2343
+
2344
+ def _update(state: _CounterState[K] | None) -> _CounterState[K]:
2345
+ if (
2346
+ not isinstance(state, _CounterState)
2347
+ or force
2348
+ or state.max_entries != size
2349
+ ):
2350
+ locks: dict[K, threading.RLock] = {}
2351
+ return _CounterState(
2352
+ cache=InstrumentedLRUCache(
2353
+ size,
2354
+ manager=self._manager,
2355
+ metrics_key=self._state_key,
2356
+ locks=locks,
2357
+ ),
2358
+ locks=locks,
2359
+ max_entries=size,
2360
+ )
2361
+ return cast(_CounterState[K], state)
2362
+
2363
+ if update_policy:
2364
+ self._manager.configure(overrides={self._state_key: size})
2365
+ self._manager.update(self._state_key, _update)
2366
+
2367
+ def clear(self) -> None:
2368
+ """Clear stored counters preserving ``max_entries``."""
2369
+
2370
+ self.configure(force=True)
2371
+
2372
+ def bump(self, key: K) -> int:
2373
+ """Return current counter for ``key`` and increment it atomically."""
2374
+
2375
+ result: dict[str, Any] = {}
2376
+
2377
+ def _update(state: _CounterState[K] | None) -> _CounterState[K]:
2378
+ if not isinstance(state, _CounterState):
2379
+ state = self._create_state(0)
2380
+ cache = state.cache
2381
+ locks = state.locks
2382
+ if key not in locks:
2383
+ locks[key] = threading.RLock()
2384
+ value = int(cache.get(key, 0))
2385
+ cache[key] = value + 1
2386
+ result["value"] = value
2387
+ return state
2388
+
2389
+ self._manager.update(self._state_key, _update)
2390
+ return int(result.get("value", 0))
2391
+
2392
+ def __len__(self) -> int:
2393
+ """Return the number of tracked counters."""
2394
+
2395
+ return len(self.cache)