tnfr 4.5.1__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 (170) hide show
  1. tnfr/__init__.py +270 -90
  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 +631 -0
  7. tnfr/alias.pyi +140 -0
  8. tnfr/cache.py +732 -0
  9. tnfr/cache.pyi +232 -0
  10. tnfr/callback_utils.py +381 -0
  11. tnfr/callback_utils.pyi +105 -0
  12. tnfr/cli/__init__.py +89 -0
  13. tnfr/cli/__init__.pyi +47 -0
  14. tnfr/cli/arguments.py +199 -0
  15. tnfr/cli/arguments.pyi +33 -0
  16. tnfr/cli/execution.py +322 -0
  17. tnfr/cli/execution.pyi +80 -0
  18. tnfr/cli/utils.py +34 -0
  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/init.py +36 -0
  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 +228 -0
  31. tnfr/constants/__init__.pyi +104 -0
  32. tnfr/constants/core.py +158 -0
  33. tnfr/constants/core.pyi +17 -0
  34. tnfr/constants/init.py +31 -0
  35. tnfr/constants/init.pyi +12 -0
  36. tnfr/constants/metric.py +102 -0
  37. tnfr/constants/metric.pyi +19 -0
  38. tnfr/constants_glyphs.py +16 -0
  39. tnfr/constants_glyphs.pyi +12 -0
  40. tnfr/dynamics/__init__.py +136 -0
  41. tnfr/dynamics/__init__.pyi +83 -0
  42. tnfr/dynamics/adaptation.py +201 -0
  43. tnfr/dynamics/aliases.py +22 -0
  44. tnfr/dynamics/coordination.py +343 -0
  45. tnfr/dynamics/dnfr.py +2315 -0
  46. tnfr/dynamics/dnfr.pyi +33 -0
  47. tnfr/dynamics/integrators.py +561 -0
  48. tnfr/dynamics/integrators.pyi +35 -0
  49. tnfr/dynamics/runtime.py +521 -0
  50. tnfr/dynamics/sampling.py +34 -0
  51. tnfr/dynamics/sampling.pyi +7 -0
  52. tnfr/dynamics/selectors.py +680 -0
  53. tnfr/execution.py +216 -0
  54. tnfr/execution.pyi +65 -0
  55. tnfr/flatten.py +283 -0
  56. tnfr/flatten.pyi +28 -0
  57. tnfr/gamma.py +320 -89
  58. tnfr/gamma.pyi +40 -0
  59. tnfr/glyph_history.py +337 -0
  60. tnfr/glyph_history.pyi +53 -0
  61. tnfr/grammar.py +23 -153
  62. tnfr/grammar.pyi +13 -0
  63. tnfr/helpers/__init__.py +151 -0
  64. tnfr/helpers/__init__.pyi +66 -0
  65. tnfr/helpers/numeric.py +88 -0
  66. tnfr/helpers/numeric.pyi +12 -0
  67. tnfr/immutable.py +214 -0
  68. tnfr/immutable.pyi +37 -0
  69. tnfr/initialization.py +199 -0
  70. tnfr/initialization.pyi +73 -0
  71. tnfr/io.py +311 -0
  72. tnfr/io.pyi +11 -0
  73. tnfr/locking.py +37 -0
  74. tnfr/locking.pyi +7 -0
  75. tnfr/metrics/__init__.py +41 -0
  76. tnfr/metrics/__init__.pyi +20 -0
  77. tnfr/metrics/coherence.py +1469 -0
  78. tnfr/metrics/common.py +149 -0
  79. tnfr/metrics/common.pyi +15 -0
  80. tnfr/metrics/core.py +259 -0
  81. tnfr/metrics/core.pyi +13 -0
  82. tnfr/metrics/diagnosis.py +840 -0
  83. tnfr/metrics/diagnosis.pyi +89 -0
  84. tnfr/metrics/export.py +151 -0
  85. tnfr/metrics/glyph_timing.py +369 -0
  86. tnfr/metrics/reporting.py +152 -0
  87. tnfr/metrics/reporting.pyi +12 -0
  88. tnfr/metrics/sense_index.py +294 -0
  89. tnfr/metrics/sense_index.pyi +9 -0
  90. tnfr/metrics/trig.py +216 -0
  91. tnfr/metrics/trig.pyi +12 -0
  92. tnfr/metrics/trig_cache.py +105 -0
  93. tnfr/metrics/trig_cache.pyi +10 -0
  94. tnfr/node.py +255 -177
  95. tnfr/node.pyi +161 -0
  96. tnfr/observers.py +154 -150
  97. tnfr/observers.pyi +46 -0
  98. tnfr/ontosim.py +135 -134
  99. tnfr/ontosim.pyi +33 -0
  100. tnfr/operators/__init__.py +452 -0
  101. tnfr/operators/__init__.pyi +31 -0
  102. tnfr/operators/definitions.py +181 -0
  103. tnfr/operators/definitions.pyi +92 -0
  104. tnfr/operators/jitter.py +266 -0
  105. tnfr/operators/jitter.pyi +11 -0
  106. tnfr/operators/registry.py +80 -0
  107. tnfr/operators/registry.pyi +15 -0
  108. tnfr/operators/remesh.py +569 -0
  109. tnfr/presets.py +10 -23
  110. tnfr/presets.pyi +7 -0
  111. tnfr/py.typed +0 -0
  112. tnfr/rng.py +440 -0
  113. tnfr/rng.pyi +14 -0
  114. tnfr/selector.py +217 -0
  115. tnfr/selector.pyi +19 -0
  116. tnfr/sense.py +307 -142
  117. tnfr/sense.pyi +30 -0
  118. tnfr/structural.py +69 -164
  119. tnfr/structural.pyi +46 -0
  120. tnfr/telemetry/__init__.py +13 -0
  121. tnfr/telemetry/verbosity.py +37 -0
  122. tnfr/tokens.py +61 -0
  123. tnfr/tokens.pyi +41 -0
  124. tnfr/trace.py +520 -95
  125. tnfr/trace.pyi +68 -0
  126. tnfr/types.py +382 -17
  127. tnfr/types.pyi +145 -0
  128. tnfr/utils/__init__.py +158 -0
  129. tnfr/utils/__init__.pyi +133 -0
  130. tnfr/utils/cache.py +755 -0
  131. tnfr/utils/cache.pyi +156 -0
  132. tnfr/utils/data.py +267 -0
  133. tnfr/utils/data.pyi +73 -0
  134. tnfr/utils/graph.py +87 -0
  135. tnfr/utils/graph.pyi +10 -0
  136. tnfr/utils/init.py +746 -0
  137. tnfr/utils/init.pyi +85 -0
  138. tnfr/utils/io.py +157 -0
  139. tnfr/utils/io.pyi +10 -0
  140. tnfr/utils/validators.py +130 -0
  141. tnfr/utils/validators.pyi +19 -0
  142. tnfr/validation/__init__.py +25 -0
  143. tnfr/validation/__init__.pyi +17 -0
  144. tnfr/validation/compatibility.py +59 -0
  145. tnfr/validation/compatibility.pyi +8 -0
  146. tnfr/validation/grammar.py +149 -0
  147. tnfr/validation/grammar.pyi +11 -0
  148. tnfr/validation/rules.py +194 -0
  149. tnfr/validation/rules.pyi +18 -0
  150. tnfr/validation/syntax.py +151 -0
  151. tnfr/validation/syntax.pyi +7 -0
  152. tnfr-6.0.0.dist-info/METADATA +135 -0
  153. tnfr-6.0.0.dist-info/RECORD +157 -0
  154. tnfr/cli.py +0 -322
  155. tnfr/config.py +0 -41
  156. tnfr/constants.py +0 -277
  157. tnfr/dynamics.py +0 -814
  158. tnfr/helpers.py +0 -264
  159. tnfr/main.py +0 -47
  160. tnfr/metrics.py +0 -597
  161. tnfr/operators.py +0 -525
  162. tnfr/program.py +0 -176
  163. tnfr/scenarios.py +0 -34
  164. tnfr/validators.py +0 -38
  165. tnfr-4.5.1.dist-info/METADATA +0 -221
  166. tnfr-4.5.1.dist-info/RECORD +0 -28
  167. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
  168. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
  169. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
  170. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/top_level.txt +0 -0
tnfr/cache.pyi ADDED
@@ -0,0 +1,232 @@
1
+ import logging
2
+ import threading
3
+ from collections.abc import Callable, Iterable, Iterator, Mapping, MutableMapping
4
+ from dataclasses import dataclass
5
+ from typing import Any, ClassVar, Generic, Hashable, TypeVar
6
+
7
+ from cachetools import LRUCache
8
+
9
+ from .types import TimingContext
10
+
11
+ __all__ = [
12
+ "CacheManager",
13
+ "CacheCapacityConfig",
14
+ "CacheStatistics",
15
+ "InstrumentedLRUCache",
16
+ "ManagedLRUCache",
17
+ "prune_lock_mapping",
18
+ ]
19
+
20
+ K = TypeVar("K", bound=Hashable)
21
+ V = TypeVar("V")
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CacheCapacityConfig:
26
+ default_capacity: int | None
27
+ overrides: dict[str, int | None]
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class CacheStatistics:
32
+ hits: int = ...
33
+ misses: int = ...
34
+ evictions: int = ...
35
+ total_time: float = ...
36
+ timings: int = ...
37
+
38
+ def merge(self, other: CacheStatistics) -> CacheStatistics: ...
39
+
40
+
41
+ class CacheManager:
42
+ _MISSING: ClassVar[object]
43
+
44
+ def __init__(
45
+ self,
46
+ storage: MutableMapping[str, Any] | None = ...,
47
+ *,
48
+ default_capacity: int | None = ...,
49
+ overrides: Mapping[str, int | None] | None = ...,
50
+ ) -> None: ...
51
+
52
+ @staticmethod
53
+ def _normalise_capacity(value: int | None) -> int | None: ...
54
+
55
+ def register(
56
+ self,
57
+ name: str,
58
+ factory: Callable[[], Any],
59
+ *,
60
+ lock_factory: Callable[[], threading.Lock | threading.RLock] | None = ...,
61
+ reset: Callable[[Any], Any] | None = ...,
62
+ create: bool = ...,
63
+ ) -> None: ...
64
+
65
+ def configure(
66
+ self,
67
+ *,
68
+ default_capacity: int | None | object = ...,
69
+ overrides: Mapping[str, int | None] | None = ...,
70
+ replace_overrides: bool = ...,
71
+ ) -> None: ...
72
+
73
+ def configure_from_mapping(self, config: Mapping[str, Any]) -> None: ...
74
+
75
+ def export_config(self) -> CacheCapacityConfig: ...
76
+
77
+ def get_capacity(
78
+ self,
79
+ name: str,
80
+ *,
81
+ requested: int | None = ...,
82
+ fallback: int | None = ...,
83
+ use_default: bool = ...,
84
+ ) -> int | None: ...
85
+
86
+ def has_override(self, name: str) -> bool: ...
87
+
88
+ def get_lock(self, name: str) -> threading.Lock | threading.RLock: ...
89
+
90
+ def names(self) -> Iterator[str]: ...
91
+
92
+ def get(self, name: str, *, create: bool = ...) -> Any: ...
93
+
94
+ def peek(self, name: str) -> Any: ...
95
+
96
+ def store(self, name: str, value: Any) -> None: ...
97
+
98
+ def update(
99
+ self,
100
+ name: str,
101
+ updater: Callable[[Any], Any],
102
+ *,
103
+ create: bool = ...,
104
+ ) -> Any: ...
105
+
106
+ def clear(self, name: str | None = ...) -> None: ...
107
+
108
+ def increment_hit(
109
+ self,
110
+ name: str,
111
+ *,
112
+ amount: int = ...,
113
+ duration: float | None = ...,
114
+ ) -> None: ...
115
+
116
+ def increment_miss(
117
+ self,
118
+ name: str,
119
+ *,
120
+ amount: int = ...,
121
+ duration: float | None = ...,
122
+ ) -> None: ...
123
+
124
+ def increment_eviction(self, name: str, *, amount: int = ...) -> None: ...
125
+
126
+ def record_timing(self, name: str, duration: float) -> None: ...
127
+
128
+ def timer(self, name: str) -> TimingContext: ...
129
+
130
+ def get_metrics(self, name: str) -> CacheStatistics: ...
131
+
132
+ def iter_metrics(self) -> Iterator[tuple[str, CacheStatistics]]: ...
133
+
134
+ def aggregate_metrics(self) -> CacheStatistics: ...
135
+
136
+ def register_metrics_publisher(
137
+ self, publisher: Callable[[str, CacheStatistics], None]
138
+ ) -> None: ...
139
+
140
+ def publish_metrics(
141
+ self,
142
+ *,
143
+ publisher: Callable[[str, CacheStatistics], None] | None = ...,
144
+ ) -> None: ...
145
+
146
+ def log_metrics(
147
+ self, logger: logging.Logger, *, level: int = ...
148
+ ) -> None: ...
149
+
150
+
151
+ class InstrumentedLRUCache(MutableMapping[K, V], Generic[K, V]):
152
+ _MISSING: ClassVar[object]
153
+
154
+ def __init__(
155
+ self,
156
+ maxsize: int,
157
+ *,
158
+ manager: CacheManager | None = ...,
159
+ metrics_key: str | None = ...,
160
+ telemetry_callbacks: Iterable[Callable[[K, V], None]]
161
+ | Callable[[K, V], None]
162
+ | None = ...,
163
+ eviction_callbacks: Iterable[Callable[[K, V], None]]
164
+ | Callable[[K, V], None]
165
+ | None = ...,
166
+ locks: MutableMapping[K, Any] | None = ...,
167
+ getsizeof: Callable[[V], int] | None = ...,
168
+ count_overwrite_hit: bool = ...,
169
+ ) -> None: ...
170
+
171
+ @property
172
+ def telemetry_callbacks(self) -> tuple[Callable[[K, V], None], ...]: ...
173
+
174
+ @property
175
+ def eviction_callbacks(self) -> tuple[Callable[[K, V], None], ...]: ...
176
+
177
+ def set_telemetry_callbacks(
178
+ self,
179
+ callbacks: Iterable[Callable[[K, V], None]]
180
+ | Callable[[K, V], None]
181
+ | None,
182
+ *,
183
+ append: bool = ...,
184
+ ) -> None: ...
185
+
186
+ def set_eviction_callbacks(
187
+ self,
188
+ callbacks: Iterable[Callable[[K, V], None]]
189
+ | Callable[[K, V], None]
190
+ | None,
191
+ *,
192
+ append: bool = ...,
193
+ ) -> None: ...
194
+
195
+ def pop(self, key: K, default: Any = ...) -> V: ...
196
+
197
+ def popitem(self) -> tuple[K, V]: ...
198
+
199
+ def clear(self) -> None: ...
200
+
201
+ @property
202
+ def maxsize(self) -> int: ...
203
+
204
+ @property
205
+ def currsize(self) -> int: ...
206
+
207
+ def get(self, key: K, default: V | None = ...) -> V | None: ...
208
+
209
+
210
+ class ManagedLRUCache(LRUCache[K, V], Generic[K, V]):
211
+ def __init__(
212
+ self,
213
+ maxsize: int,
214
+ *,
215
+ manager: CacheManager | None = ...,
216
+ metrics_key: str | None = ...,
217
+ eviction_callbacks: Iterable[Callable[[K, V], None]]
218
+ | Callable[[K, V], None]
219
+ | None = ...,
220
+ telemetry_callbacks: Iterable[Callable[[K, V], None]]
221
+ | Callable[[K, V], None]
222
+ | None = ...,
223
+ locks: MutableMapping[K, Any] | None = ...,
224
+ ) -> None: ...
225
+
226
+ def popitem(self) -> tuple[K, V]: ...
227
+
228
+
229
+ def prune_lock_mapping(
230
+ cache: Mapping[K, Any] | MutableMapping[K, Any] | None,
231
+ locks: MutableMapping[K, Any] | None,
232
+ ) -> None: ...
tnfr/callback_utils.py ADDED
@@ -0,0 +1,381 @@
1
+ """Callback registration and invocation helpers.
2
+
3
+ This module is thread-safe: all mutations of the callback registry stored in a
4
+ graph's ``G.graph`` are serialised using a process-wide lock obtained via
5
+ ``locking.get_lock("callbacks")``. Callback functions themselves execute
6
+ outside of the lock and must therefore be independently thread-safe if they
7
+ modify shared state.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ from typing import Any, TypedDict
14
+ from enum import Enum
15
+ from collections import defaultdict, deque
16
+ from collections.abc import Callable, Mapping, Iterable
17
+
18
+ import traceback
19
+ import threading
20
+ from .utils import get_logger, is_non_string_sequence
21
+ from .constants import DEFAULTS
22
+ from .locking import get_lock
23
+
24
+ from .trace import CallbackSpec
25
+
26
+ import networkx as nx
27
+
28
+ __all__ = (
29
+ "CallbackEvent",
30
+ "CallbackManager",
31
+ "callback_manager",
32
+ "CallbackError",
33
+ )
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ class CallbackEvent(str, Enum):
39
+ """Supported callback events."""
40
+
41
+ BEFORE_STEP = "before_step"
42
+ AFTER_STEP = "after_step"
43
+ ON_REMESH = "on_remesh"
44
+
45
+
46
+ class CallbackManager:
47
+ """Centralised registry and error tracking for callbacks."""
48
+
49
+ def __init__(self) -> None:
50
+ self._lock = get_lock("callbacks")
51
+ self._error_limit_lock = threading.Lock()
52
+ self._error_limit = 100
53
+ self._error_limit_cache = self._error_limit
54
+
55
+ # ------------------------------------------------------------------
56
+ # Error limit management
57
+ # ------------------------------------------------------------------
58
+ def get_callback_error_limit(self) -> int:
59
+ """Return the current callback error retention limit."""
60
+ with self._error_limit_lock:
61
+ return self._error_limit
62
+
63
+ def set_callback_error_limit(self, limit: int) -> int:
64
+ """Set the maximum number of callback errors retained."""
65
+ if limit < 1:
66
+ raise ValueError("limit must be positive")
67
+ with self._error_limit_lock:
68
+ previous = self._error_limit
69
+ self._error_limit = int(limit)
70
+ self._error_limit_cache = self._error_limit
71
+ return previous
72
+
73
+ # ------------------------------------------------------------------
74
+ # Registry helpers
75
+ # ------------------------------------------------------------------
76
+ def _record_callback_error(
77
+ self,
78
+ G: "nx.Graph",
79
+ event: str,
80
+ ctx: dict[str, Any],
81
+ spec: CallbackSpec,
82
+ err: Exception,
83
+ ) -> None:
84
+ """Log and store a callback error for later inspection."""
85
+
86
+ logger.exception("callback %r failed for %s: %s", spec.name, event, err)
87
+ limit = self._error_limit_cache
88
+ err_list = G.graph.setdefault(
89
+ "_callback_errors", deque[CallbackError](maxlen=limit)
90
+ )
91
+ if err_list.maxlen != limit:
92
+ err_list = deque[CallbackError](err_list, maxlen=limit)
93
+ G.graph["_callback_errors"] = err_list
94
+ error: CallbackError = {
95
+ "event": event,
96
+ "step": ctx.get("step"),
97
+ "error": repr(err),
98
+ "traceback": traceback.format_exc(),
99
+ "fn": _func_id(spec.func),
100
+ "name": spec.name,
101
+ }
102
+ err_list.append(error)
103
+
104
+ def _ensure_callbacks_nolock(self, G: "nx.Graph") -> CallbackRegistry:
105
+ cbs = G.graph.setdefault("callbacks", defaultdict(dict))
106
+ dirty: set[str] = set(G.graph.pop("_callbacks_dirty", ()))
107
+ return _validate_registry(G, cbs, dirty)
108
+
109
+ def _ensure_callbacks(self, G: "nx.Graph") -> CallbackRegistry:
110
+ with self._lock:
111
+ return self._ensure_callbacks_nolock(G)
112
+
113
+ def register_callback(
114
+ self,
115
+ G: "nx.Graph",
116
+ event: CallbackEvent | str,
117
+ func: Callback,
118
+ *,
119
+ name: str | None = None,
120
+ ) -> Callback:
121
+ """Register ``func`` as callback for ``event``."""
122
+
123
+ event = _normalize_event(event)
124
+ _ensure_known_event(event)
125
+ if not callable(func):
126
+ raise TypeError("func must be callable")
127
+ with self._lock:
128
+ cbs = self._ensure_callbacks_nolock(G)
129
+
130
+ cb_name = name or getattr(func, "__name__", None)
131
+ spec = CallbackSpec(cb_name, func)
132
+ existing_map = cbs[event]
133
+ strict = bool(
134
+ G.graph.get("CALLBACKS_STRICT", DEFAULTS["CALLBACKS_STRICT"])
135
+ )
136
+ key = _reconcile_callback(event, existing_map, spec, strict)
137
+
138
+ existing_map[key] = spec
139
+ dirty = G.graph.setdefault("_callbacks_dirty", set())
140
+ dirty.add(event)
141
+ return func
142
+
143
+ def invoke_callbacks(
144
+ self,
145
+ G: "nx.Graph",
146
+ event: CallbackEvent | str,
147
+ ctx: dict[str, Any] | None = None,
148
+ ) -> None:
149
+ """Invoke all callbacks registered for ``event`` with context ``ctx``."""
150
+
151
+ event = _normalize_event(event)
152
+ with self._lock:
153
+ cbs = dict(self._ensure_callbacks_nolock(G).get(event, {}))
154
+ strict = bool(
155
+ G.graph.get("CALLBACKS_STRICT", DEFAULTS["CALLBACKS_STRICT"])
156
+ )
157
+ if ctx is None:
158
+ ctx = {}
159
+ for spec in cbs.values():
160
+ try:
161
+ spec.func(G, ctx)
162
+ except (
163
+ RuntimeError,
164
+ ValueError,
165
+ TypeError,
166
+ ) as e:
167
+ with self._lock:
168
+ self._record_callback_error(G, event, ctx, spec, e)
169
+ if strict:
170
+ raise
171
+ except nx.NetworkXError as err:
172
+ with self._lock:
173
+ self._record_callback_error(G, event, ctx, spec, err)
174
+ logger.exception(
175
+ "callback %r raised NetworkXError for %s with ctx=%r",
176
+ spec.name,
177
+ event,
178
+ ctx,
179
+ )
180
+ raise
181
+
182
+
183
+ Callback = Callable[["nx.Graph", dict[str, Any]], None]
184
+ CallbackRegistry = dict[str, dict[str, "CallbackSpec"]]
185
+
186
+
187
+ class CallbackError(TypedDict):
188
+ """Metadata for a failed callback invocation."""
189
+
190
+ event: str
191
+ step: int | None
192
+ error: str
193
+ traceback: str
194
+ fn: str
195
+ name: str | None
196
+
197
+
198
+ def _func_id(fn: Callable[..., Any]) -> str:
199
+ """Return a deterministic identifier for ``fn``.
200
+
201
+ Combines the function's module and qualified name to avoid the
202
+ nondeterminism of ``repr(fn)`` which includes the memory address.
203
+ """
204
+ module = getattr(fn, "__module__", fn.__class__.__module__)
205
+ qualname = getattr(
206
+ fn,
207
+ "__qualname__",
208
+ getattr(fn, "__name__", fn.__class__.__qualname__),
209
+ )
210
+ return f"{module}.{qualname}"
211
+
212
+
213
+ def _validate_registry(
214
+ G: "nx.Graph", cbs: Any, dirty: set[str]
215
+ ) -> CallbackRegistry:
216
+ """Validate and normalise the callback registry.
217
+
218
+ ``cbs`` is coerced to a ``defaultdict(dict)`` and any events listed in
219
+ ``dirty`` are rebuilt using :func:`_normalize_callbacks`. Unknown events are
220
+ removed. The cleaned registry is stored back on the graph and returned.
221
+ """
222
+
223
+ if not isinstance(cbs, Mapping):
224
+ logger.warning(
225
+ "Invalid callbacks registry on graph; resetting to empty",
226
+ )
227
+ cbs = defaultdict(dict)
228
+ elif not isinstance(cbs, defaultdict) or cbs.default_factory is not dict:
229
+ cbs = defaultdict(
230
+ dict,
231
+ {
232
+ event: _normalize_callbacks(entries)
233
+ for event, entries in dict(cbs).items()
234
+ if _is_known_event(event)
235
+ },
236
+ )
237
+ else:
238
+ for event in dirty:
239
+ if _is_known_event(event):
240
+ cbs[event] = _normalize_callbacks(cbs.get(event))
241
+ else:
242
+ cbs.pop(event, None)
243
+
244
+ G.graph["callbacks"] = cbs
245
+ return cbs
246
+
247
+
248
+ def _normalize_callbacks(entries: Any) -> dict[str, CallbackSpec]:
249
+ """Return ``entries`` normalised into a callback mapping."""
250
+ if isinstance(entries, Mapping):
251
+ entries_iter = entries.values()
252
+ elif isinstance(entries, Iterable) and not isinstance(entries, (str, bytes, bytearray)):
253
+ entries_iter = entries
254
+ else:
255
+ return {}
256
+
257
+ new_map: dict[str, CallbackSpec] = {}
258
+ for entry in entries_iter:
259
+ spec = _normalize_callback_entry(entry)
260
+ if spec is None:
261
+ continue
262
+ key = spec.name or _func_id(spec.func)
263
+ new_map[key] = spec
264
+ return new_map
265
+
266
+
267
+ def _normalize_event(event: CallbackEvent | str) -> str:
268
+ """Return ``event`` as a string."""
269
+ return event.value if isinstance(event, CallbackEvent) else str(event)
270
+
271
+
272
+ def _is_known_event(event: str) -> bool:
273
+ """Return ``True`` when ``event`` matches a declared :class:`CallbackEvent`."""
274
+
275
+ try:
276
+ CallbackEvent(event)
277
+ except ValueError:
278
+ return False
279
+ else:
280
+ return True
281
+
282
+
283
+ def _ensure_known_event(event: str) -> None:
284
+ """Raise :class:`ValueError` when ``event`` is not a known callback."""
285
+
286
+ try:
287
+ CallbackEvent(event)
288
+ except ValueError as exc: # pragma: no cover - defensive branch
289
+ raise ValueError(f"Unknown event: {event}") from exc
290
+
291
+
292
+ def _normalize_callback_entry(entry: Any) -> "CallbackSpec | None":
293
+ """Normalize a callback specification.
294
+
295
+ Supported formats
296
+ -----------------
297
+ * :class:`CallbackSpec` instances (returned unchanged).
298
+ * Sequences ``(name: str, func: Callable)`` such as lists, tuples or other
299
+ iterables.
300
+ * Bare callables ``func`` whose name is taken from ``func.__name__``.
301
+
302
+ ``None`` is returned when ``entry`` does not match any of the accepted
303
+ formats. The original ``entry`` is never mutated. Sequence inputs are
304
+ converted to ``tuple`` before validation to support generators; the
305
+ materialization consumes the iterable and failure results in ``None``.
306
+ """
307
+
308
+ if isinstance(entry, CallbackSpec):
309
+ return entry
310
+ elif is_non_string_sequence(entry):
311
+ try:
312
+ entry = tuple(entry)
313
+ except TypeError:
314
+ return None
315
+ if len(entry) != 2:
316
+ return None
317
+ name, fn = entry
318
+ if not isinstance(name, str) or not callable(fn):
319
+ return None
320
+ return CallbackSpec(name, fn)
321
+ elif callable(entry):
322
+ name = getattr(entry, "__name__", None)
323
+ return CallbackSpec(name, entry)
324
+ else:
325
+ return None
326
+
327
+
328
+ def _reconcile_callback(
329
+ event: str,
330
+ existing_map: dict[str, CallbackSpec],
331
+ spec: CallbackSpec,
332
+ strict: bool,
333
+ ) -> str:
334
+ """Reconcile ``spec`` with ``existing_map``.
335
+
336
+ Ensures that callbacks remain unique by explicit name or function identity.
337
+ When a name collision occurs with a different function, ``strict`` controls
338
+ whether a :class:`ValueError` is raised or a warning is logged.
339
+
340
+ Parameters
341
+ ----------
342
+ event:
343
+ Event under which ``spec`` will be registered. Only used for messages.
344
+ existing_map:
345
+ Current mapping of callbacks for ``event``.
346
+ spec:
347
+ Callback specification being registered.
348
+ strict:
349
+ Whether to raise on name collisions instead of logging a warning.
350
+
351
+ Returns
352
+ -------
353
+ str
354
+ Key under which ``spec`` should be stored in ``existing_map``.
355
+ """
356
+
357
+ key = spec.name or _func_id(spec.func)
358
+
359
+ if spec.name is not None:
360
+ existing_spec = existing_map.get(key)
361
+ if existing_spec is not None and existing_spec.func is not spec.func:
362
+ msg = f"Callback {spec.name!r} already registered for {event}"
363
+ if strict:
364
+ raise ValueError(msg)
365
+ logger.warning(msg)
366
+
367
+ # Remove existing entries under the same key and any other using the same
368
+ # function identity to avoid duplicates.
369
+ existing_map.pop(key, None)
370
+ fn_key = next((k for k, s in existing_map.items() if s.func is spec.func), None)
371
+ if fn_key is not None:
372
+ existing_map.pop(fn_key, None)
373
+
374
+ return key
375
+
376
+
377
+ # ---------------------------------------------------------------------------
378
+ # Default manager instance and convenience wrappers
379
+ # ---------------------------------------------------------------------------
380
+
381
+ callback_manager = CallbackManager()
@@ -0,0 +1,105 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ import traceback
5
+ from collections import defaultdict, deque
6
+ from collections.abc import Callable, Iterable, Mapping
7
+ from enum import Enum
8
+ from typing import Any, TypedDict
9
+
10
+ import networkx as nx
11
+
12
+ from .constants import DEFAULTS
13
+ from .locking import get_lock
14
+ from .trace import CallbackSpec
15
+ from .utils import get_logger, is_non_string_sequence
16
+
17
+ __all__ = (
18
+ "CallbackEvent",
19
+ "CallbackManager",
20
+ "callback_manager",
21
+ "CallbackError",
22
+ )
23
+
24
+ logger: Any
25
+
26
+
27
+ class CallbackEvent(str, Enum):
28
+ BEFORE_STEP = "before_step"
29
+ AFTER_STEP = "after_step"
30
+ ON_REMESH = "on_remesh"
31
+
32
+
33
+ Callback = Callable[[nx.Graph, dict[str, Any]], None]
34
+ CallbackRegistry = dict[str, dict[str, CallbackSpec]]
35
+
36
+
37
+ class CallbackError(TypedDict):
38
+ event: str
39
+ step: int | None
40
+ error: str
41
+ traceback: str
42
+ fn: str
43
+ name: str | None
44
+
45
+
46
+ class CallbackManager:
47
+ def __init__(self) -> None: ...
48
+
49
+ def get_callback_error_limit(self) -> int: ...
50
+
51
+ def set_callback_error_limit(self, limit: int) -> int: ...
52
+
53
+ def register_callback(
54
+ self,
55
+ G: nx.Graph,
56
+ event: CallbackEvent | str,
57
+ func: Callback,
58
+ *,
59
+ name: str | None = ...,
60
+ ) -> Callback: ...
61
+
62
+ def invoke_callbacks(
63
+ self,
64
+ G: nx.Graph,
65
+ event: CallbackEvent | str,
66
+ ctx: dict[str, Any] | None = ...,
67
+ ) -> None: ...
68
+
69
+ def _record_callback_error(
70
+ self,
71
+ G: nx.Graph,
72
+ event: str,
73
+ ctx: dict[str, Any],
74
+ spec: CallbackSpec,
75
+ err: Exception,
76
+ ) -> None: ...
77
+
78
+ def _ensure_callbacks_nolock(self, G: nx.Graph) -> CallbackRegistry: ...
79
+
80
+ def _ensure_callbacks(self, G: nx.Graph) -> CallbackRegistry: ...
81
+
82
+
83
+ callback_manager: CallbackManager
84
+
85
+
86
+ def _func_id(fn: Callable[..., Any]) -> str: ...
87
+
88
+ def _validate_registry(G: nx.Graph, cbs: Any, dirty: set[str]) -> CallbackRegistry: ...
89
+
90
+ def _normalize_callbacks(entries: Any) -> dict[str, CallbackSpec]: ...
91
+
92
+ def _normalize_event(event: CallbackEvent | str) -> str: ...
93
+
94
+ def _is_known_event(event: str) -> bool: ...
95
+
96
+ def _ensure_known_event(event: str) -> None: ...
97
+
98
+ def _normalize_callback_entry(entry: Any) -> CallbackSpec | None: ...
99
+
100
+ def _reconcile_callback(
101
+ event: str,
102
+ existing_map: dict[str, CallbackSpec],
103
+ spec: CallbackSpec,
104
+ strict: bool,
105
+ ) -> str: ...