tnfr 4.5.2__py3-none-any.whl → 6.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.
Files changed (161) hide show
  1. tnfr/__init__.py +228 -49
  2. tnfr/__init__.pyi +40 -0
  3. tnfr/_compat.py +11 -0
  4. tnfr/_version.py +7 -0
  5. tnfr/_version.pyi +7 -0
  6. tnfr/alias.py +106 -21
  7. tnfr/alias.pyi +140 -0
  8. tnfr/cache.py +666 -512
  9. tnfr/cache.pyi +232 -0
  10. tnfr/callback_utils.py +2 -9
  11. tnfr/callback_utils.pyi +105 -0
  12. tnfr/cli/__init__.py +21 -7
  13. tnfr/cli/__init__.pyi +47 -0
  14. tnfr/cli/arguments.py +42 -20
  15. tnfr/cli/arguments.pyi +33 -0
  16. tnfr/cli/execution.py +54 -20
  17. tnfr/cli/execution.pyi +80 -0
  18. tnfr/cli/utils.py +0 -2
  19. tnfr/cli/utils.pyi +8 -0
  20. tnfr/config/__init__.py +12 -0
  21. tnfr/config/__init__.pyi +8 -0
  22. tnfr/config/constants.py +104 -0
  23. tnfr/config/constants.pyi +12 -0
  24. tnfr/{config.py → config/init.py} +11 -7
  25. tnfr/config/init.pyi +8 -0
  26. tnfr/config/operator_names.py +106 -0
  27. tnfr/config/operator_names.pyi +28 -0
  28. tnfr/config/presets.py +104 -0
  29. tnfr/config/presets.pyi +7 -0
  30. tnfr/constants/__init__.py +78 -24
  31. tnfr/constants/__init__.pyi +104 -0
  32. tnfr/constants/core.py +1 -2
  33. tnfr/constants/core.pyi +17 -0
  34. tnfr/constants/init.pyi +12 -0
  35. tnfr/constants/metric.py +4 -12
  36. tnfr/constants/metric.pyi +19 -0
  37. tnfr/constants_glyphs.py +9 -91
  38. tnfr/constants_glyphs.pyi +12 -0
  39. tnfr/dynamics/__init__.py +112 -634
  40. tnfr/dynamics/__init__.pyi +83 -0
  41. tnfr/dynamics/adaptation.py +201 -0
  42. tnfr/dynamics/aliases.py +22 -0
  43. tnfr/dynamics/coordination.py +343 -0
  44. tnfr/dynamics/dnfr.py +1936 -354
  45. tnfr/dynamics/dnfr.pyi +33 -0
  46. tnfr/dynamics/integrators.py +369 -75
  47. tnfr/dynamics/integrators.pyi +35 -0
  48. tnfr/dynamics/runtime.py +521 -0
  49. tnfr/dynamics/sampling.py +8 -5
  50. tnfr/dynamics/sampling.pyi +7 -0
  51. tnfr/dynamics/selectors.py +680 -0
  52. tnfr/execution.py +56 -41
  53. tnfr/execution.pyi +65 -0
  54. tnfr/flatten.py +7 -7
  55. tnfr/flatten.pyi +28 -0
  56. tnfr/gamma.py +54 -37
  57. tnfr/gamma.pyi +40 -0
  58. tnfr/glyph_history.py +85 -38
  59. tnfr/glyph_history.pyi +53 -0
  60. tnfr/grammar.py +19 -338
  61. tnfr/grammar.pyi +13 -0
  62. tnfr/helpers/__init__.py +110 -30
  63. tnfr/helpers/__init__.pyi +66 -0
  64. tnfr/helpers/numeric.py +1 -0
  65. tnfr/helpers/numeric.pyi +12 -0
  66. tnfr/immutable.py +55 -19
  67. tnfr/immutable.pyi +37 -0
  68. tnfr/initialization.py +12 -10
  69. tnfr/initialization.pyi +73 -0
  70. tnfr/io.py +99 -34
  71. tnfr/io.pyi +11 -0
  72. tnfr/locking.pyi +7 -0
  73. tnfr/metrics/__init__.pyi +20 -0
  74. tnfr/metrics/coherence.py +934 -294
  75. tnfr/metrics/common.py +1 -3
  76. tnfr/metrics/common.pyi +15 -0
  77. tnfr/metrics/core.py +192 -34
  78. tnfr/metrics/core.pyi +13 -0
  79. tnfr/metrics/diagnosis.py +707 -101
  80. tnfr/metrics/diagnosis.pyi +89 -0
  81. tnfr/metrics/export.py +27 -13
  82. tnfr/metrics/glyph_timing.py +218 -38
  83. tnfr/metrics/reporting.py +22 -18
  84. tnfr/metrics/reporting.pyi +12 -0
  85. tnfr/metrics/sense_index.py +199 -25
  86. tnfr/metrics/sense_index.pyi +9 -0
  87. tnfr/metrics/trig.py +53 -18
  88. tnfr/metrics/trig.pyi +12 -0
  89. tnfr/metrics/trig_cache.py +3 -7
  90. tnfr/metrics/trig_cache.pyi +10 -0
  91. tnfr/node.py +148 -125
  92. tnfr/node.pyi +161 -0
  93. tnfr/observers.py +44 -30
  94. tnfr/observers.pyi +46 -0
  95. tnfr/ontosim.py +14 -13
  96. tnfr/ontosim.pyi +33 -0
  97. tnfr/operators/__init__.py +84 -52
  98. tnfr/operators/__init__.pyi +31 -0
  99. tnfr/operators/definitions.py +181 -0
  100. tnfr/operators/definitions.pyi +92 -0
  101. tnfr/operators/jitter.py +86 -23
  102. tnfr/operators/jitter.pyi +11 -0
  103. tnfr/operators/registry.py +80 -0
  104. tnfr/operators/registry.pyi +15 -0
  105. tnfr/operators/remesh.py +141 -57
  106. tnfr/presets.py +9 -54
  107. tnfr/presets.pyi +7 -0
  108. tnfr/py.typed +0 -0
  109. tnfr/rng.py +259 -73
  110. tnfr/rng.pyi +14 -0
  111. tnfr/selector.py +24 -17
  112. tnfr/selector.pyi +19 -0
  113. tnfr/sense.py +55 -43
  114. tnfr/sense.pyi +30 -0
  115. tnfr/structural.py +44 -267
  116. tnfr/structural.pyi +46 -0
  117. tnfr/telemetry/__init__.py +13 -0
  118. tnfr/telemetry/verbosity.py +37 -0
  119. tnfr/tokens.py +3 -2
  120. tnfr/tokens.pyi +41 -0
  121. tnfr/trace.py +272 -82
  122. tnfr/trace.pyi +68 -0
  123. tnfr/types.py +345 -6
  124. tnfr/types.pyi +145 -0
  125. tnfr/utils/__init__.py +158 -0
  126. tnfr/utils/__init__.pyi +133 -0
  127. tnfr/utils/cache.py +755 -0
  128. tnfr/utils/cache.pyi +156 -0
  129. tnfr/{collections_utils.py → utils/data.py} +57 -90
  130. tnfr/utils/data.pyi +73 -0
  131. tnfr/utils/graph.py +87 -0
  132. tnfr/utils/graph.pyi +10 -0
  133. tnfr/utils/init.py +746 -0
  134. tnfr/utils/init.pyi +85 -0
  135. tnfr/{json_utils.py → utils/io.py} +13 -18
  136. tnfr/utils/io.pyi +10 -0
  137. tnfr/utils/validators.py +130 -0
  138. tnfr/utils/validators.pyi +19 -0
  139. tnfr/validation/__init__.py +25 -0
  140. tnfr/validation/__init__.pyi +17 -0
  141. tnfr/validation/compatibility.py +59 -0
  142. tnfr/validation/compatibility.pyi +8 -0
  143. tnfr/validation/grammar.py +149 -0
  144. tnfr/validation/grammar.pyi +11 -0
  145. tnfr/validation/rules.py +194 -0
  146. tnfr/validation/rules.pyi +18 -0
  147. tnfr/validation/syntax.py +151 -0
  148. tnfr/validation/syntax.pyi +7 -0
  149. tnfr-6.0.0.dist-info/METADATA +135 -0
  150. tnfr-6.0.0.dist-info/RECORD +157 -0
  151. tnfr/graph_utils.py +0 -84
  152. tnfr/import_utils.py +0 -228
  153. tnfr/logging_utils.py +0 -116
  154. tnfr/validators.py +0 -84
  155. tnfr/value_utils.py +0 -59
  156. tnfr-4.5.2.dist-info/METADATA +0 -379
  157. tnfr-4.5.2.dist-info/RECORD +0 -67
  158. {tnfr-4.5.2.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
  159. {tnfr-4.5.2.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
  160. {tnfr-4.5.2.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
  161. {tnfr-4.5.2.dist-info → tnfr-6.0.0.dist-info}/top_level.txt +0 -0
tnfr/rng.py CHANGED
@@ -2,17 +2,21 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import random
6
5
  import hashlib
6
+ import random
7
7
  import struct
8
+ import threading
8
9
  from collections.abc import Iterator, MutableMapping
9
- from typing import Any, Generic, Hashable, TypeVar
10
+ from dataclasses import dataclass
11
+ from typing import Any, Generic, Hashable, TypeVar, cast
10
12
 
11
13
 
12
- from cachetools import LRUCache, cached
14
+ from cachetools import cached # type: ignore[import-untyped]
13
15
  from .constants import DEFAULTS, get_param
14
- from .graph_utils import get_graph
16
+ from .cache import CacheManager, InstrumentedLRUCache
17
+ from .utils import get_graph
15
18
  from .locking import get_lock
19
+ from .types import GraphLike, TNFRGraph
16
20
 
17
21
  MASK64 = 0xFFFFFFFFFFFFFFFF
18
22
 
@@ -22,110 +26,264 @@ _CACHE_MAXSIZE = _DEFAULT_CACHE_MAXSIZE
22
26
  _CACHE_LOCKED = False
23
27
 
24
28
  K = TypeVar("K", bound=Hashable)
29
+ V = TypeVar("V")
30
+
31
+
32
+ @dataclass
33
+ class _SeedCacheState:
34
+ cache: InstrumentedLRUCache[tuple[int, int], int] | None
35
+ maxsize: int
36
+
37
+
38
+ @dataclass
39
+ class _CounterState(Generic[K]):
40
+ cache: InstrumentedLRUCache[K, int]
41
+ locks: dict[K, threading.RLock]
42
+ max_entries: int
43
+
44
+
45
+ _RNG_CACHE_MANAGER = CacheManager(default_capacity=_DEFAULT_CACHE_MAXSIZE)
25
46
 
26
47
 
27
48
  class _SeedHashCache(MutableMapping[tuple[int, int], int]):
28
49
  """Mutable mapping proxy exposing a configurable LRU cache."""
29
50
 
30
- def __init__(self, maxsize: int) -> None:
31
- self._maxsize = 0
32
- self._cache: LRUCache[tuple[int, int], int] | None = None
33
- self.configure(maxsize)
51
+ def __init__(
52
+ self,
53
+ *,
54
+ manager: CacheManager | None = None,
55
+ state_key: str = "seed_hash_cache",
56
+ default_maxsize: int = _DEFAULT_CACHE_MAXSIZE,
57
+ ) -> None:
58
+ self._manager = manager or _RNG_CACHE_MANAGER
59
+ self._state_key = state_key
60
+ self._default_maxsize = int(default_maxsize)
61
+ if not self._manager.has_override(self._state_key):
62
+ self._manager.configure(
63
+ overrides={self._state_key: self._default_maxsize}
64
+ )
65
+ self._manager.register(
66
+ self._state_key,
67
+ self._create_state,
68
+ reset=self._reset_state,
69
+ )
70
+
71
+ def _resolved_size(self, requested: int | None = None) -> int:
72
+ size = self._manager.get_capacity(
73
+ self._state_key,
74
+ requested=requested,
75
+ fallback=self._default_maxsize,
76
+ )
77
+ if size is None:
78
+ return 0
79
+ return int(size)
80
+
81
+ def _create_state(self) -> _SeedCacheState:
82
+ size = self._resolved_size()
83
+ if size <= 0:
84
+ return _SeedCacheState(cache=None, maxsize=0)
85
+ return _SeedCacheState(
86
+ cache=InstrumentedLRUCache(
87
+ size,
88
+ manager=self._manager,
89
+ metrics_key=self._state_key,
90
+ ),
91
+ maxsize=size,
92
+ )
93
+
94
+ def _reset_state(self, state: _SeedCacheState | None) -> _SeedCacheState:
95
+ return self._create_state()
96
+
97
+ def _get_state(self, *, create: bool = True) -> _SeedCacheState | None:
98
+ state = self._manager.get(self._state_key, create=create)
99
+ if state is None:
100
+ return None
101
+ if not isinstance(state, _SeedCacheState):
102
+ state = self._create_state()
103
+ self._manager.store(self._state_key, state)
104
+ return state
34
105
 
35
106
  def configure(self, maxsize: int) -> None:
36
- """Configure internal cache size, clearing previous entries."""
37
-
38
- self._maxsize = int(maxsize)
39
- if self._maxsize <= 0:
40
- self._cache = None
41
- else:
42
- self._cache = LRUCache(maxsize=self._maxsize)
107
+ size = int(maxsize)
108
+ if size < 0:
109
+ raise ValueError("maxsize must be non-negative")
110
+ self._manager.configure(overrides={self._state_key: size})
111
+ self._manager.update(self._state_key, lambda _: self._create_state())
43
112
 
44
113
  def __getitem__(self, key: tuple[int, int]) -> int:
45
- if self._cache is None:
114
+ state = self._get_state()
115
+ if state is None or state.cache is None:
46
116
  raise KeyError(key)
47
- return self._cache[key]
117
+ value = state.cache[key]
118
+ self._manager.increment_hit(self._state_key)
119
+ return value
48
120
 
49
121
  def __setitem__(self, key: tuple[int, int], value: int) -> None:
50
- if self._cache is not None:
51
- self._cache[key] = value
122
+ state = self._get_state()
123
+ if state is not None and state.cache is not None:
124
+ state.cache[key] = value
52
125
 
53
126
  def __delitem__(self, key: tuple[int, int]) -> None:
54
- if self._cache is None:
127
+ state = self._get_state()
128
+ if state is None or state.cache is None:
55
129
  raise KeyError(key)
56
- del self._cache[key]
130
+ del state.cache[key]
57
131
 
58
132
  def __iter__(self) -> Iterator[tuple[int, int]]:
59
- if self._cache is None:
133
+ state = self._get_state(create=False)
134
+ if state is None or state.cache is None:
60
135
  return iter(())
61
- return iter(self._cache)
136
+ return iter(state.cache)
62
137
 
63
138
  def __len__(self) -> int:
64
- if self._cache is None:
139
+ state = self._get_state(create=False)
140
+ if state is None or state.cache is None:
65
141
  return 0
66
- return len(self._cache)
142
+ return len(state.cache)
67
143
 
68
144
  def clear(self) -> None: # type: ignore[override]
69
- if self._cache is not None:
70
- self._cache.clear()
145
+ self._manager.clear(self._state_key)
71
146
 
72
147
  @property
73
148
  def maxsize(self) -> int:
74
- return self._maxsize
149
+ state = self._get_state()
150
+ return 0 if state is None else state.maxsize
75
151
 
76
152
  @property
77
153
  def enabled(self) -> bool:
78
- return self._cache is not None
154
+ state = self._get_state(create=False)
155
+ return bool(state and state.cache is not None)
79
156
 
80
157
  @property
81
- def data(self) -> LRUCache[tuple[int, int], int] | None:
158
+ def data(self) -> InstrumentedLRUCache[tuple[int, int], int] | None:
82
159
  """Expose the underlying cache for diagnostics/tests."""
83
160
 
84
- return self._cache
161
+ state = self._get_state(create=False)
162
+ return None if state is None else state.cache
85
163
 
86
164
 
87
165
  class ScopedCounterCache(Generic[K]):
88
166
  """Thread-safe LRU cache storing monotonic counters by ``key``."""
89
167
 
90
- def __init__(self, name: str, max_entries: int) -> None:
91
- if max_entries < 0:
168
+ def __init__(
169
+ self,
170
+ name: str,
171
+ max_entries: int | None = None,
172
+ *,
173
+ manager: CacheManager | None = None,
174
+ default_max_entries: int = _DEFAULT_CACHE_MAXSIZE,
175
+ ) -> None:
176
+ self._name = name
177
+ self._manager = manager or _RNG_CACHE_MANAGER
178
+ self._state_key = f"scoped_counter:{name}"
179
+ self._default_max_entries = int(default_max_entries)
180
+ requested = None if max_entries is None else int(max_entries)
181
+ if requested is not None and requested < 0:
92
182
  raise ValueError("max_entries must be non-negative")
93
- self._lock = get_lock(name)
94
- self._max_entries = int(max_entries)
95
- self._cache: LRUCache[K, int] = LRUCache(maxsize=self._max_entries)
183
+ if not self._manager.has_override(self._state_key):
184
+ fallback = requested
185
+ if fallback is None:
186
+ fallback = self._default_max_entries
187
+ self._manager.configure(overrides={self._state_key: fallback})
188
+ elif requested is not None:
189
+ self._manager.configure(overrides={self._state_key: requested})
190
+ self._manager.register(
191
+ self._state_key,
192
+ self._create_state,
193
+ lock_factory=lambda: get_lock(name),
194
+ reset=self._reset_state,
195
+ )
196
+
197
+ def _resolved_entries(self, requested: int | None = None) -> int:
198
+ size = self._manager.get_capacity(
199
+ self._state_key,
200
+ requested=requested,
201
+ fallback=self._default_max_entries,
202
+ )
203
+ if size is None:
204
+ return 0
205
+ return int(size)
206
+
207
+ def _create_state(self, requested: int | None = None) -> _CounterState[K]:
208
+ size = self._resolved_entries(requested)
209
+ locks: dict[K, threading.RLock] = {}
210
+ return _CounterState(
211
+ cache=InstrumentedLRUCache(
212
+ size,
213
+ manager=self._manager,
214
+ metrics_key=self._state_key,
215
+ locks=locks,
216
+ ),
217
+ locks=locks,
218
+ max_entries=size,
219
+ )
220
+
221
+ def _reset_state(self, state: _CounterState[K] | None) -> _CounterState[K]:
222
+ return self._create_state()
223
+
224
+ def _get_state(self) -> _CounterState[K]:
225
+ state = self._manager.get(self._state_key)
226
+ if not isinstance(state, _CounterState):
227
+ state = self._create_state(0)
228
+ self._manager.store(self._state_key, state)
229
+ return state
96
230
 
97
231
  @property
98
- def lock(self):
232
+ def lock(self) -> threading.Lock | threading.RLock:
99
233
  """Return the lock guarding access to the underlying cache."""
100
234
 
101
- return self._lock
235
+ return self._manager.get_lock(self._state_key)
102
236
 
103
237
  @property
104
238
  def max_entries(self) -> int:
105
239
  """Return the configured maximum number of cached entries."""
106
240
 
107
- return self._max_entries
241
+ return self._get_state().max_entries
108
242
 
109
243
  @property
110
- def cache(self) -> LRUCache[K, int]:
111
- """Expose the underlying ``LRUCache`` for inspection."""
244
+ def cache(self) -> InstrumentedLRUCache[K, int]:
245
+ """Expose the instrumented cache for inspection."""
112
246
 
113
- return self._cache
247
+ return self._get_state().cache
248
+
249
+ @property
250
+ def locks(self) -> dict[K, threading.RLock]:
251
+ """Return the mapping of per-key locks tracked by the cache."""
252
+
253
+ return self._get_state().locks
114
254
 
115
255
  def configure(
116
256
  self, *, force: bool = False, max_entries: int | None = None
117
257
  ) -> None:
118
258
  """Resize or reset the cache keeping previous settings."""
119
259
 
120
- size = self._max_entries if max_entries is None else int(max_entries)
121
- if size < 0:
122
- raise ValueError("max_entries must be non-negative")
123
- with self._lock:
124
- if size != self._max_entries:
125
- self._max_entries = size
126
- force = True
127
- if force:
128
- self._cache = LRUCache(maxsize=self._max_entries)
260
+ if max_entries is None:
261
+ size = self._resolved_entries()
262
+ update_policy = False
263
+ else:
264
+ size = int(max_entries)
265
+ if size < 0:
266
+ raise ValueError("max_entries must be non-negative")
267
+ update_policy = True
268
+
269
+ def _update(state: _CounterState[K] | None) -> _CounterState[K]:
270
+ if not isinstance(state, _CounterState) or force or state.max_entries != size:
271
+ locks: dict[K, threading.RLock] = {}
272
+ return _CounterState(
273
+ cache=InstrumentedLRUCache(
274
+ size,
275
+ manager=self._manager,
276
+ metrics_key=self._state_key,
277
+ locks=locks,
278
+ ),
279
+ locks=locks,
280
+ max_entries=size,
281
+ )
282
+ return cast(_CounterState[K], state)
283
+
284
+ if update_policy:
285
+ self._manager.configure(overrides={self._state_key: size})
286
+ self._manager.update(self._state_key, _update)
129
287
 
130
288
  def clear(self) -> None:
131
289
  """Clear stored counters preserving ``max_entries``."""
@@ -135,22 +293,31 @@ class ScopedCounterCache(Generic[K]):
135
293
  def bump(self, key: K) -> int:
136
294
  """Return current counter for ``key`` and increment it atomically."""
137
295
 
138
- with self._lock:
139
- value = int(self._cache.get(key, 0))
140
- self._cache[key] = value + 1
141
- return value
296
+ result: dict[str, Any] = {}
142
297
 
143
- def __len__(self) -> int:
144
- return len(self._cache)
298
+ def _update(state: _CounterState[K] | None) -> _CounterState[K]:
299
+ if not isinstance(state, _CounterState):
300
+ state = self._create_state(0)
301
+ cache = state.cache
302
+ locks = state.locks
303
+ if key not in locks:
304
+ locks[key] = threading.RLock()
305
+ value = int(cache.get(key, 0))
306
+ cache[key] = value + 1
307
+ result["value"] = value
308
+ return state
145
309
 
310
+ self._manager.update(self._state_key, _update)
311
+ return int(result.get("value", 0))
146
312
 
147
- _seed_hash_cache = _SeedHashCache(_CACHE_MAXSIZE)
313
+ def __len__(self) -> int:
314
+ return len(self.cache)
148
315
 
149
316
 
150
- @cached(cache=_seed_hash_cache, lock=_RNG_LOCK)
151
- def seed_hash(seed_int: int, key_int: int) -> int:
152
- """Return a 64-bit hash derived from ``seed_int`` and ``key_int``."""
317
+ _seed_hash_cache = _SeedHashCache()
318
+
153
319
 
320
+ def _compute_seed_hash(seed_int: int, key_int: int) -> int:
154
321
  seed_bytes = struct.pack(
155
322
  ">QQ",
156
323
  seed_int & MASK64,
@@ -161,7 +328,24 @@ def seed_hash(seed_int: int, key_int: int) -> int:
161
328
  )
162
329
 
163
330
 
164
- def _sync_cache_size(G: Any | None) -> None:
331
+ @cached(cache=_seed_hash_cache, lock=_RNG_LOCK)
332
+ def _cached_seed_hash(seed_int: int, key_int: int) -> int:
333
+ return _compute_seed_hash(seed_int, key_int)
334
+
335
+
336
+ def seed_hash(seed_int: int, key_int: int) -> int:
337
+ """Return a 64-bit hash derived from ``seed_int`` and ``key_int``."""
338
+
339
+ if _CACHE_MAXSIZE <= 0 or not _seed_hash_cache.enabled:
340
+ return _compute_seed_hash(seed_int, key_int)
341
+ return _cached_seed_hash(seed_int, key_int)
342
+
343
+
344
+ seed_hash.cache_clear = cast(Any, _cached_seed_hash).cache_clear # type: ignore[attr-defined]
345
+ seed_hash.cache = _seed_hash_cache # type: ignore[attr-defined]
346
+
347
+
348
+ def _sync_cache_size(G: TNFRGraph | GraphLike | None) -> None:
165
349
  """Synchronise cache size with ``G`` when needed."""
166
350
 
167
351
  global _CACHE_MAXSIZE
@@ -169,12 +353,14 @@ def _sync_cache_size(G: Any | None) -> None:
169
353
  return
170
354
  size = get_cache_maxsize(G)
171
355
  with _RNG_LOCK:
172
- if size != _CACHE_MAXSIZE:
356
+ if size != _seed_hash_cache.maxsize:
173
357
  _seed_hash_cache.configure(size)
174
- _CACHE_MAXSIZE = size
358
+ _CACHE_MAXSIZE = _seed_hash_cache.maxsize
175
359
 
176
360
 
177
- def make_rng(seed: int, key: int, G: Any | None = None) -> random.Random:
361
+ def make_rng(
362
+ seed: int, key: int, G: TNFRGraph | GraphLike | None = None
363
+ ) -> random.Random:
178
364
  """Return a ``random.Random`` for ``seed`` and ``key``.
179
365
 
180
366
  When ``G`` is provided, ``JITTER_CACHE_SIZE`` is read from ``G`` and the
@@ -188,17 +374,17 @@ def make_rng(seed: int, key: int, G: Any | None = None) -> random.Random:
188
374
 
189
375
  def clear_rng_cache() -> None:
190
376
  """Clear cached seed hashes."""
191
- if _CACHE_MAXSIZE <= 0 or not _seed_hash_cache.enabled:
377
+ if _seed_hash_cache.maxsize <= 0 or not _seed_hash_cache.enabled:
192
378
  return
193
- seed_hash.cache_clear()
379
+ seed_hash.cache_clear() # type: ignore[attr-defined]
194
380
 
195
381
 
196
- def get_cache_maxsize(G: Any) -> int:
382
+ def get_cache_maxsize(G: TNFRGraph | GraphLike) -> int:
197
383
  """Return RNG cache maximum size for ``G``."""
198
384
  return int(get_param(G, "JITTER_CACHE_SIZE"))
199
385
 
200
386
 
201
- def cache_enabled(G: Any | None = None) -> bool:
387
+ def cache_enabled(G: TNFRGraph | GraphLike | None = None) -> bool:
202
388
  """Return ``True`` if RNG caching is enabled.
203
389
 
204
390
  When ``G`` is provided, the cache size is synchronised with
@@ -207,12 +393,12 @@ def cache_enabled(G: Any | None = None) -> bool:
207
393
  # Only synchronise the cache size with ``G`` when caching is enabled. This
208
394
  # preserves explicit calls to :func:`set_cache_maxsize(0)` which are used in
209
395
  # tests to temporarily disable caching regardless of graph defaults.
210
- if _CACHE_MAXSIZE > 0:
396
+ if _seed_hash_cache.maxsize > 0:
211
397
  _sync_cache_size(G)
212
- return _CACHE_MAXSIZE > 0
398
+ return _seed_hash_cache.maxsize > 0
213
399
 
214
400
 
215
- def base_seed(G: Any) -> int:
401
+ def base_seed(G: TNFRGraph | GraphLike) -> int:
216
402
  """Return base RNG seed stored in ``G.graph``."""
217
403
  graph = get_graph(G)
218
404
  return int(graph.get("RANDOM_SEED", 0))
@@ -238,7 +424,7 @@ def set_cache_maxsize(size: int) -> None:
238
424
  raise ValueError("size must be non-negative")
239
425
  with _RNG_LOCK:
240
426
  _seed_hash_cache.configure(new_size)
241
- _CACHE_MAXSIZE = new_size
427
+ _CACHE_MAXSIZE = _seed_hash_cache.maxsize
242
428
  _CACHE_LOCKED = new_size != _DEFAULT_CACHE_MAXSIZE
243
429
 
244
430
 
tnfr/rng.pyi ADDED
@@ -0,0 +1,14 @@
1
+ from typing import Any
2
+
3
+ __all__: Any
4
+
5
+ def __getattr__(name: str) -> Any: ...
6
+
7
+ ScopedCounterCache: Any
8
+ base_seed: Any
9
+ cache_enabled: Any
10
+ clear_rng_cache: Any
11
+ get_cache_maxsize: Any
12
+ make_rng: Any
13
+ seed_hash: Any
14
+ set_cache_maxsize: Any
tnfr/selector.py CHANGED
@@ -8,32 +8,34 @@ from __future__ import annotations
8
8
 
9
9
  import threading
10
10
  from operator import itemgetter
11
- from typing import Any, Mapping, TYPE_CHECKING
11
+ from typing import Any, Mapping, TYPE_CHECKING, cast
12
12
  from weakref import WeakKeyDictionary
13
13
 
14
14
  if TYPE_CHECKING: # pragma: no cover
15
- import networkx as nx # type: ignore[import-untyped]
15
+ import networkx as nx
16
16
 
17
17
  from .constants import DEFAULTS
18
18
  from .constants.core import SELECTOR_THRESHOLD_DEFAULTS
19
19
  from .helpers.numeric import clamp01
20
20
  from .metrics.common import compute_dnfr_accel_max
21
- from .collections_utils import is_non_string_sequence
21
+ from .utils import is_non_string_sequence
22
+ from .types import SelectorNorms, SelectorThresholds, SelectorWeights
22
23
 
23
24
 
24
25
  HYSTERESIS_GLYPHS: set[str] = {"IL", "OZ", "ZHIR", "THOL", "NAV", "RA"}
25
26
 
26
27
  __all__ = (
27
28
  "_selector_thresholds",
28
- "_norms_para_selector",
29
+ "_selector_norms",
29
30
  "_calc_selector_score",
30
31
  "_apply_selector_hysteresis",
31
32
  )
32
33
 
33
34
 
35
+ _SelectorThresholdItems = tuple[tuple[str, float], ...]
34
36
  _SelectorThresholdCacheEntry = tuple[
35
- tuple[tuple[str, float], ...],
36
- dict[str, float],
37
+ _SelectorThresholdItems,
38
+ SelectorThresholds,
37
39
  ]
38
40
  _SELECTOR_THRESHOLD_CACHE: WeakKeyDictionary[
39
41
  "nx.Graph",
@@ -42,7 +44,7 @@ _SELECTOR_THRESHOLD_CACHE: WeakKeyDictionary[
42
44
  _SELECTOR_THRESHOLD_CACHE_LOCK = threading.Lock()
43
45
 
44
46
 
45
- def _sorted_items(mapping: Mapping[str, float]) -> tuple[tuple[str, float], ...]:
47
+ def _sorted_items(mapping: Mapping[str, float]) -> _SelectorThresholdItems:
46
48
  """Return mapping items sorted by key.
47
49
 
48
50
  Parameters
@@ -59,8 +61,8 @@ def _sorted_items(mapping: Mapping[str, float]) -> tuple[tuple[str, float], ...]
59
61
 
60
62
 
61
63
  def _compute_selector_thresholds(
62
- thr_sel_items: tuple[tuple[str, float], ...],
63
- ) -> dict[str, float]:
64
+ thr_sel_items: _SelectorThresholdItems,
65
+ ) -> SelectorThresholds:
64
66
  """Construct selector thresholds for a graph.
65
67
 
66
68
  Parameters
@@ -79,10 +81,10 @@ def _compute_selector_thresholds(
79
81
  for key, default in SELECTOR_THRESHOLD_DEFAULTS.items():
80
82
  val = thr_sel.get(key, default)
81
83
  out[key] = clamp01(float(val))
82
- return out
84
+ return cast(SelectorThresholds, out)
83
85
 
84
86
 
85
- def _selector_thresholds(G: "nx.Graph") -> dict[str, float]:
87
+ def _selector_thresholds(G: "nx.Graph") -> SelectorThresholds:
86
88
  """Return normalised thresholds for Si, ΔNFR and acceleration.
87
89
 
88
90
  Parameters
@@ -114,8 +116,8 @@ def _selector_thresholds(G: "nx.Graph") -> dict[str, float]:
114
116
  return thresholds
115
117
 
116
118
 
117
- def _norms_para_selector(G: "nx.Graph") -> dict:
118
- """Compute and cache norms for ΔNFR and acceleration.
119
+ def _selector_norms(G: "nx.Graph") -> SelectorNorms:
120
+ """Compute and cache selector norms for ΔNFR and acceleration.
119
121
 
120
122
  Parameters
121
123
  ----------
@@ -134,7 +136,7 @@ def _norms_para_selector(G: "nx.Graph") -> dict:
134
136
 
135
137
 
136
138
  def _calc_selector_score(
137
- Si: float, dnfr: float, accel: float, weights: dict[str, float]
139
+ Si: float, dnfr: float, accel: float, weights: SelectorWeights
138
140
  ) -> float:
139
141
  """Compute weighted selector score.
140
142
 
@@ -167,7 +169,7 @@ def _apply_selector_hysteresis(
167
169
  dnfr: float,
168
170
  accel: float,
169
171
  thr: dict[str, float],
170
- margin: float,
172
+ margin: float | None,
171
173
  ) -> str | None:
172
174
  """Apply hysteresis when values are near thresholds.
173
175
 
@@ -183,8 +185,10 @@ def _apply_selector_hysteresis(
183
185
  Normalised acceleration.
184
186
  thr : dict[str, float]
185
187
  Thresholds returned by :func:`_selector_thresholds`.
186
- margin : float
187
- Distance from thresholds below which the previous glyph is reused.
188
+ margin : float or None
189
+ When positive, distance from thresholds below which the previous
190
+ glyph is reused. Falsy margins disable hysteresis entirely, letting
191
+ selectors bypass the reuse logic.
188
192
 
189
193
  Returns
190
194
  -------
@@ -192,6 +196,9 @@ def _apply_selector_hysteresis(
192
196
  Previous glyph if hysteresis applies, otherwise ``None``.
193
197
  """
194
198
  # Batch extraction reduces dictionary lookups inside loops.
199
+ if not margin:
200
+ return None
201
+
195
202
  si_hi, si_lo, dnfr_hi, dnfr_lo, accel_hi, accel_lo = itemgetter(
196
203
  "si_hi", "si_lo", "dnfr_hi", "dnfr_lo", "accel_hi", "accel_lo"
197
204
  )(thr)
tnfr/selector.pyi ADDED
@@ -0,0 +1,19 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping
4
+
5
+ __all__: Any
6
+
7
+ def __getattr__(name: str) -> Any: ...
8
+
9
+ def _apply_selector_hysteresis(
10
+ nd: dict[str, Any],
11
+ Si: float,
12
+ dnfr: float,
13
+ accel: float,
14
+ thr: Mapping[str, float],
15
+ margin: float | None,
16
+ ) -> str | None: ...
17
+ _calc_selector_score: Any
18
+ _selector_norms: Any
19
+ _selector_thresholds: Any