tnfr 6.0.0__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 (176) hide show
  1. tnfr/__init__.py +50 -5
  2. tnfr/__init__.pyi +0 -7
  3. tnfr/_compat.py +0 -1
  4. tnfr/_generated_version.py +34 -0
  5. tnfr/_version.py +44 -2
  6. tnfr/alias.py +14 -13
  7. tnfr/alias.pyi +5 -37
  8. tnfr/cache.py +9 -729
  9. tnfr/cache.pyi +8 -224
  10. tnfr/callback_utils.py +16 -31
  11. tnfr/callback_utils.pyi +3 -29
  12. tnfr/cli/__init__.py +17 -11
  13. tnfr/cli/__init__.pyi +0 -21
  14. tnfr/cli/arguments.py +175 -14
  15. tnfr/cli/arguments.pyi +5 -11
  16. tnfr/cli/execution.py +434 -48
  17. tnfr/cli/execution.pyi +14 -24
  18. tnfr/cli/utils.py +20 -3
  19. tnfr/cli/utils.pyi +5 -5
  20. tnfr/config/__init__.py +2 -1
  21. tnfr/config/__init__.pyi +2 -0
  22. tnfr/config/feature_flags.py +83 -0
  23. tnfr/config/init.py +1 -1
  24. tnfr/config/operator_names.py +1 -14
  25. tnfr/config/presets.py +6 -26
  26. tnfr/constants/__init__.py +10 -13
  27. tnfr/constants/__init__.pyi +10 -22
  28. tnfr/constants/aliases.py +31 -0
  29. tnfr/constants/core.py +4 -3
  30. tnfr/constants/init.py +1 -1
  31. tnfr/constants/metric.py +3 -3
  32. tnfr/dynamics/__init__.py +64 -10
  33. tnfr/dynamics/__init__.pyi +3 -4
  34. tnfr/dynamics/adaptation.py +79 -13
  35. tnfr/dynamics/aliases.py +10 -9
  36. tnfr/dynamics/coordination.py +77 -35
  37. tnfr/dynamics/dnfr.py +575 -274
  38. tnfr/dynamics/dnfr.pyi +1 -10
  39. tnfr/dynamics/integrators.py +47 -33
  40. tnfr/dynamics/integrators.pyi +0 -1
  41. tnfr/dynamics/runtime.py +489 -129
  42. tnfr/dynamics/sampling.py +2 -0
  43. tnfr/dynamics/selectors.py +101 -62
  44. tnfr/execution.py +15 -8
  45. tnfr/execution.pyi +5 -25
  46. tnfr/flatten.py +7 -3
  47. tnfr/flatten.pyi +1 -8
  48. tnfr/gamma.py +22 -26
  49. tnfr/gamma.pyi +0 -6
  50. tnfr/glyph_history.py +37 -26
  51. tnfr/glyph_history.pyi +1 -19
  52. tnfr/glyph_runtime.py +16 -0
  53. tnfr/glyph_runtime.pyi +9 -0
  54. tnfr/immutable.py +20 -15
  55. tnfr/immutable.pyi +4 -7
  56. tnfr/initialization.py +5 -7
  57. tnfr/initialization.pyi +1 -9
  58. tnfr/io.py +6 -305
  59. tnfr/io.pyi +13 -8
  60. tnfr/mathematics/__init__.py +81 -0
  61. tnfr/mathematics/backend.py +426 -0
  62. tnfr/mathematics/dynamics.py +398 -0
  63. tnfr/mathematics/epi.py +254 -0
  64. tnfr/mathematics/generators.py +222 -0
  65. tnfr/mathematics/metrics.py +119 -0
  66. tnfr/mathematics/operators.py +233 -0
  67. tnfr/mathematics/operators_factory.py +71 -0
  68. tnfr/mathematics/projection.py +78 -0
  69. tnfr/mathematics/runtime.py +173 -0
  70. tnfr/mathematics/spaces.py +247 -0
  71. tnfr/mathematics/transforms.py +292 -0
  72. tnfr/metrics/__init__.py +10 -10
  73. tnfr/metrics/coherence.py +123 -94
  74. tnfr/metrics/common.py +22 -13
  75. tnfr/metrics/common.pyi +42 -11
  76. tnfr/metrics/core.py +72 -14
  77. tnfr/metrics/diagnosis.py +48 -57
  78. tnfr/metrics/diagnosis.pyi +3 -7
  79. tnfr/metrics/export.py +3 -5
  80. tnfr/metrics/glyph_timing.py +41 -31
  81. tnfr/metrics/reporting.py +13 -6
  82. tnfr/metrics/sense_index.py +884 -114
  83. tnfr/metrics/trig.py +167 -11
  84. tnfr/metrics/trig.pyi +1 -0
  85. tnfr/metrics/trig_cache.py +112 -15
  86. tnfr/node.py +400 -17
  87. tnfr/node.pyi +55 -38
  88. tnfr/observers.py +111 -8
  89. tnfr/observers.pyi +0 -15
  90. tnfr/ontosim.py +9 -6
  91. tnfr/ontosim.pyi +0 -5
  92. tnfr/operators/__init__.py +529 -42
  93. tnfr/operators/__init__.pyi +14 -0
  94. tnfr/operators/definitions.py +350 -18
  95. tnfr/operators/definitions.pyi +0 -14
  96. tnfr/operators/grammar.py +760 -0
  97. tnfr/operators/jitter.py +28 -22
  98. tnfr/operators/registry.py +7 -12
  99. tnfr/operators/registry.pyi +0 -2
  100. tnfr/operators/remesh.py +38 -61
  101. tnfr/rng.py +17 -300
  102. tnfr/schemas/__init__.py +8 -0
  103. tnfr/schemas/grammar.json +94 -0
  104. tnfr/selector.py +3 -4
  105. tnfr/selector.pyi +1 -1
  106. tnfr/sense.py +22 -24
  107. tnfr/sense.pyi +0 -7
  108. tnfr/structural.py +504 -21
  109. tnfr/structural.pyi +41 -18
  110. tnfr/telemetry/__init__.py +23 -1
  111. tnfr/telemetry/cache_metrics.py +226 -0
  112. tnfr/telemetry/nu_f.py +423 -0
  113. tnfr/telemetry/nu_f.pyi +123 -0
  114. tnfr/tokens.py +1 -4
  115. tnfr/tokens.pyi +1 -6
  116. tnfr/trace.py +20 -53
  117. tnfr/trace.pyi +9 -37
  118. tnfr/types.py +244 -15
  119. tnfr/types.pyi +200 -14
  120. tnfr/units.py +69 -0
  121. tnfr/units.pyi +16 -0
  122. tnfr/utils/__init__.py +107 -48
  123. tnfr/utils/__init__.pyi +80 -11
  124. tnfr/utils/cache.py +1705 -65
  125. tnfr/utils/cache.pyi +370 -58
  126. tnfr/utils/chunks.py +104 -0
  127. tnfr/utils/chunks.pyi +21 -0
  128. tnfr/utils/data.py +95 -5
  129. tnfr/utils/data.pyi +8 -17
  130. tnfr/utils/graph.py +2 -4
  131. tnfr/utils/init.py +31 -7
  132. tnfr/utils/init.pyi +4 -11
  133. tnfr/utils/io.py +313 -14
  134. tnfr/{helpers → utils}/numeric.py +50 -24
  135. tnfr/utils/numeric.pyi +21 -0
  136. tnfr/validation/__init__.py +92 -4
  137. tnfr/validation/__init__.pyi +77 -17
  138. tnfr/validation/compatibility.py +79 -43
  139. tnfr/validation/compatibility.pyi +4 -6
  140. tnfr/validation/grammar.py +55 -133
  141. tnfr/validation/grammar.pyi +37 -8
  142. tnfr/validation/graph.py +138 -0
  143. tnfr/validation/graph.pyi +17 -0
  144. tnfr/validation/rules.py +161 -74
  145. tnfr/validation/rules.pyi +55 -18
  146. tnfr/validation/runtime.py +263 -0
  147. tnfr/validation/runtime.pyi +31 -0
  148. tnfr/validation/soft_filters.py +170 -0
  149. tnfr/validation/soft_filters.pyi +37 -0
  150. tnfr/validation/spectral.py +159 -0
  151. tnfr/validation/spectral.pyi +46 -0
  152. tnfr/validation/syntax.py +28 -139
  153. tnfr/validation/syntax.pyi +7 -4
  154. tnfr/validation/window.py +39 -0
  155. tnfr/validation/window.pyi +1 -0
  156. tnfr/viz/__init__.py +9 -0
  157. tnfr/viz/matplotlib.py +246 -0
  158. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/METADATA +63 -19
  159. tnfr-7.0.0.dist-info/RECORD +185 -0
  160. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/licenses/LICENSE.md +1 -1
  161. tnfr/constants_glyphs.py +0 -16
  162. tnfr/constants_glyphs.pyi +0 -12
  163. tnfr/grammar.py +0 -25
  164. tnfr/grammar.pyi +0 -13
  165. tnfr/helpers/__init__.py +0 -151
  166. tnfr/helpers/__init__.pyi +0 -66
  167. tnfr/helpers/numeric.pyi +0 -12
  168. tnfr/presets.py +0 -15
  169. tnfr/presets.pyi +0 -7
  170. tnfr/utils/io.pyi +0 -10
  171. tnfr/utils/validators.py +0 -130
  172. tnfr/utils/validators.pyi +0 -19
  173. tnfr-6.0.0.dist-info/RECORD +0 -157
  174. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/WHEEL +0 -0
  175. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/entry_points.txt +0 -0
  176. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,226 @@
1
+ """Cache telemetry publishers for structured observability channels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import weakref
7
+ from dataclasses import dataclass
8
+ from typing import Any, MutableMapping, TYPE_CHECKING
9
+
10
+ from ..utils import (
11
+ _graph_cache_manager,
12
+ CacheManager,
13
+ CacheStatistics,
14
+ get_logger,
15
+ json_dumps,
16
+ )
17
+
18
+ if TYPE_CHECKING: # pragma: no cover - typing helpers
19
+ from networkx import Graph
20
+
21
+ from ..types import TNFRGraph
22
+
23
+ __all__ = (
24
+ "CacheMetricsSnapshot",
25
+ "CacheTelemetryPublisher",
26
+ "ensure_cache_metrics_publisher",
27
+ "publish_graph_cache_metrics",
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CacheMetricsSnapshot:
33
+ """Structured cache metrics enriched with ratios and latency estimates."""
34
+
35
+ cache: str
36
+ hits: int
37
+ misses: int
38
+ evictions: int
39
+ total_time: float
40
+ timings: int
41
+ hit_ratio: float | None
42
+ miss_ratio: float | None
43
+ avg_latency: float | None
44
+
45
+ @classmethod
46
+ def from_statistics(
47
+ cls, name: str, stats: CacheStatistics
48
+ ) -> "CacheMetricsSnapshot":
49
+ """Build a snapshot computing ratios from :class:`CacheStatistics`."""
50
+
51
+ hits = int(stats.hits)
52
+ misses = int(stats.misses)
53
+ evictions = int(stats.evictions)
54
+ total_time = float(stats.total_time)
55
+ timings = int(stats.timings)
56
+ requests = hits + misses
57
+ hit_ratio = (hits / requests) if requests else None
58
+ miss_ratio = (misses / requests) if requests else None
59
+ avg_latency = (total_time / timings) if timings else None
60
+ return cls(
61
+ cache=name,
62
+ hits=hits,
63
+ misses=misses,
64
+ evictions=evictions,
65
+ total_time=total_time,
66
+ timings=timings,
67
+ hit_ratio=hit_ratio,
68
+ miss_ratio=miss_ratio,
69
+ avg_latency=avg_latency,
70
+ )
71
+
72
+ def as_payload(self) -> dict[str, Any]:
73
+ """Return a dictionary suitable for structured logging."""
74
+
75
+ return {
76
+ "cache": self.cache,
77
+ "hits": self.hits,
78
+ "misses": self.misses,
79
+ "evictions": self.evictions,
80
+ "total_time": self.total_time,
81
+ "timings": self.timings,
82
+ "hit_ratio": self.hit_ratio,
83
+ "miss_ratio": self.miss_ratio,
84
+ "avg_latency": self.avg_latency,
85
+ }
86
+
87
+
88
+ class CacheTelemetryPublisher:
89
+ """Metrics publisher broadcasting cache counters to observability channels."""
90
+
91
+ def __init__(
92
+ self,
93
+ *,
94
+ graph: "TNFRGraph | Graph | MutableMapping[str, Any] | None" = None,
95
+ logger: logging.Logger | None = None,
96
+ hit_ratio_alert: float = 0.5,
97
+ latency_alert: float = 0.1,
98
+ ) -> None:
99
+ self._logger = logger or get_logger("tnfr.telemetry.cache")
100
+ self._graph_ref: weakref.ReferenceType[
101
+ "TNFRGraph | Graph | MutableMapping[str, Any]"
102
+ ] | None = None
103
+ self._hit_ratio_alert = float(hit_ratio_alert)
104
+ self._latency_alert = float(latency_alert)
105
+ self.attach_graph(graph)
106
+
107
+ @property
108
+ def logger(self) -> logging.Logger:
109
+ """Logger used for structured cache telemetry."""
110
+
111
+ return self._logger
112
+
113
+ def attach_graph(
114
+ self, graph: "TNFRGraph | Graph | MutableMapping[str, Any] | None"
115
+ ) -> None:
116
+ """Attach ``graph`` so observability callbacks receive metrics."""
117
+
118
+ if graph is None:
119
+ return
120
+ try:
121
+ self._graph_ref = weakref.ref(graph) # type: ignore[arg-type]
122
+ except TypeError: # pragma: no cover - defensive path for exotic graphs
123
+ self._graph_ref = None
124
+
125
+ def _resolve_graph(
126
+ self,
127
+ ) -> "TNFRGraph | Graph | MutableMapping[str, Any] | None":
128
+ return self._graph_ref() if self._graph_ref is not None else None
129
+
130
+ def __call__(self, name: str, stats: CacheStatistics) -> None:
131
+ """Emit structured telemetry and invoke observability hooks."""
132
+
133
+ snapshot = CacheMetricsSnapshot.from_statistics(name, stats)
134
+ payload = snapshot.as_payload()
135
+ message = json_dumps({"event": "cache_metrics", **payload}, sort_keys=True)
136
+ self._logger.info(message)
137
+
138
+ if (
139
+ snapshot.hit_ratio is not None
140
+ and snapshot.hit_ratio < self._hit_ratio_alert
141
+ and snapshot.misses > 0
142
+ ):
143
+ warning = json_dumps(
144
+ {
145
+ "event": "cache_metrics.low_hit_ratio",
146
+ "cache": name,
147
+ "hit_ratio": snapshot.hit_ratio,
148
+ "threshold": self._hit_ratio_alert,
149
+ "requests": snapshot.hits + snapshot.misses,
150
+ },
151
+ sort_keys=True,
152
+ )
153
+ self._logger.warning(warning)
154
+
155
+ if (
156
+ snapshot.avg_latency is not None
157
+ and snapshot.avg_latency > self._latency_alert
158
+ and snapshot.timings > 0
159
+ ):
160
+ warning = json_dumps(
161
+ {
162
+ "event": "cache_metrics.high_latency",
163
+ "cache": name,
164
+ "avg_latency": snapshot.avg_latency,
165
+ "threshold": self._latency_alert,
166
+ "timings": snapshot.timings,
167
+ },
168
+ sort_keys=True,
169
+ )
170
+ self._logger.warning(warning)
171
+
172
+ graph = self._resolve_graph()
173
+ if graph is not None:
174
+ from ..callback_utils import CallbackEvent, callback_manager
175
+
176
+ ctx = {"cache": name, "metrics": payload}
177
+ callback_manager.invoke_callbacks(graph, CallbackEvent.CACHE_METRICS, ctx)
178
+
179
+
180
+ _PUBLISHER_ATTR = "_tnfr_cache_metrics_publisher"
181
+
182
+
183
+ def ensure_cache_metrics_publisher(
184
+ manager: CacheManager,
185
+ *,
186
+ graph: "TNFRGraph | Graph | MutableMapping[str, Any] | None" = None,
187
+ logger: logging.Logger | None = None,
188
+ hit_ratio_alert: float = 0.5,
189
+ latency_alert: float = 0.1,
190
+ ) -> CacheTelemetryPublisher:
191
+ """Attach a :class:`CacheTelemetryPublisher` to ``manager`` if missing."""
192
+
193
+ publisher = getattr(manager, _PUBLISHER_ATTR, None)
194
+ if not isinstance(publisher, CacheTelemetryPublisher):
195
+ publisher = CacheTelemetryPublisher(
196
+ graph=graph,
197
+ logger=logger,
198
+ hit_ratio_alert=hit_ratio_alert,
199
+ latency_alert=latency_alert,
200
+ )
201
+ manager.register_metrics_publisher(publisher)
202
+ setattr(manager, _PUBLISHER_ATTR, publisher)
203
+ else:
204
+ if graph is not None:
205
+ publisher.attach_graph(graph)
206
+ return publisher
207
+
208
+
209
+ def publish_graph_cache_metrics(
210
+ graph: "TNFRGraph | Graph | MutableMapping[str, Any]",
211
+ *,
212
+ manager: CacheManager | None = None,
213
+ hit_ratio_alert: float = 0.5,
214
+ latency_alert: float = 0.1,
215
+ ) -> None:
216
+ """Publish cache metrics for ``graph`` using the shared manager."""
217
+
218
+ if manager is None:
219
+ manager = _graph_cache_manager(getattr(graph, "graph", graph))
220
+ ensure_cache_metrics_publisher(
221
+ manager,
222
+ graph=graph,
223
+ hit_ratio_alert=hit_ratio_alert,
224
+ latency_alert=latency_alert,
225
+ )
226
+ manager.publish_metrics()
tnfr/telemetry/nu_f.py ADDED
@@ -0,0 +1,423 @@
1
+ """Structural frequency (νf) telemetry estimators.
2
+
3
+ This module aggregates discrete reorganisation counts observed over
4
+ time windows and exposes Poisson maximum likelihood estimators (MLE) for
5
+ the structural frequency νf. Results are provided both in canonical
6
+ ``Hz_str`` and converted ``Hz`` using :mod:`tnfr.units`, allowing callers
7
+ to surface telemetry without duplicating conversion logic.
8
+
9
+ Snapshots emitted by :class:`NuFTelemetryAccumulator` are appended to the
10
+ ``G.graph["telemetry"]["nu_f_history"]`` channel so downstream observers
11
+ and structured logging hooks can consume them without interfering with
12
+ runtime summaries stored under ``G.graph["telemetry"]["nu_f"]``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ import weakref
19
+ from collections import deque
20
+ from collections.abc import MutableMapping
21
+ from dataclasses import dataclass
22
+ from statistics import NormalDist
23
+ from typing import Any, Deque, Mapping
24
+
25
+ from ..types import GraphLike
26
+ from ..units import get_hz_bridge, hz_str_to_hz
27
+
28
+ __all__ = (
29
+ "NuFWindow",
30
+ "NuFSnapshot",
31
+ "NuFTelemetryAccumulator",
32
+ "ensure_nu_f_telemetry",
33
+ "record_nu_f_window",
34
+ )
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class NuFWindow:
39
+ """Discrete reorganisation observations captured over a time window."""
40
+
41
+ reorganisations: int
42
+ """Number of reorganisations counted within the window."""
43
+
44
+ duration: float
45
+ """Duration of the window expressed in structural time units."""
46
+
47
+ start: float | None = None
48
+ """Optional inclusive window start timestamp."""
49
+
50
+ end: float | None = None
51
+ """Optional exclusive window end timestamp."""
52
+
53
+ def __post_init__(self) -> None:
54
+ reorganisations = int(self.reorganisations)
55
+ duration = float(self.duration)
56
+ object.__setattr__(self, "reorganisations", reorganisations)
57
+ object.__setattr__(self, "duration", duration)
58
+ if reorganisations < 0:
59
+ raise ValueError("reorganisations must be non-negative")
60
+ if not math.isfinite(duration) or duration <= 0.0:
61
+ raise ValueError("duration must be a positive finite number")
62
+ if self.start is not None and self.end is not None:
63
+ start = float(self.start)
64
+ end = float(self.end)
65
+ object.__setattr__(self, "start", start)
66
+ object.__setattr__(self, "end", end)
67
+ if end < start:
68
+ raise ValueError("end must be greater than or equal to start")
69
+ window = end - start
70
+ if window <= 0.0:
71
+ raise ValueError("start and end must describe a non-empty window")
72
+ # Allow minor numerical discrepancies when duration is supplied
73
+ # independently from ``start``/``end``.
74
+ if not math.isclose(window, duration, rel_tol=1e-9, abs_tol=1e-9):
75
+ raise ValueError(
76
+ "duration does not match the difference between start and end",
77
+ )
78
+
79
+ @classmethod
80
+ def from_bounds(cls, reorganisations: int, start: float, end: float) -> "NuFWindow":
81
+ """Construct a window inferring the duration from ``start``/``end``."""
82
+
83
+ start_f = float(start)
84
+ end_f = float(end)
85
+ if end_f <= start_f:
86
+ raise ValueError("end must be greater than start")
87
+ return cls(
88
+ reorganisations=int(reorganisations),
89
+ duration=end_f - start_f,
90
+ start=start_f,
91
+ end=end_f,
92
+ )
93
+
94
+ def as_payload(self) -> Mapping[str, float | int | None]:
95
+ """Return a JSON-serialisable representation of the window."""
96
+
97
+ return {
98
+ "reorganisations": int(self.reorganisations),
99
+ "duration": float(self.duration),
100
+ "start": float(self.start) if self.start is not None else None,
101
+ "end": float(self.end) if self.end is not None else None,
102
+ }
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class NuFSnapshot:
107
+ """Aggregate νf estimates computed from recorded windows."""
108
+
109
+ windows: tuple[NuFWindow, ...]
110
+ total_reorganisations: int
111
+ total_duration: float
112
+ rate_hz_str: float | None
113
+ rate_hz: float | None
114
+ variance_hz_str: float | None
115
+ variance_hz: float | None
116
+ confidence_level: float | None
117
+ ci_lower_hz_str: float | None
118
+ ci_upper_hz_str: float | None
119
+ ci_lower_hz: float | None
120
+ ci_upper_hz: float | None
121
+
122
+ def as_payload(self) -> dict[str, Any]:
123
+ """Return a structured representation suitable for telemetry sinks."""
124
+
125
+ return {
126
+ "windows": [window.as_payload() for window in self.windows],
127
+ "total_reorganisations": self.total_reorganisations,
128
+ "total_duration": self.total_duration,
129
+ "rate_hz_str": self.rate_hz_str,
130
+ "rate_hz": self.rate_hz,
131
+ "variance_hz_str": self.variance_hz_str,
132
+ "variance_hz": self.variance_hz,
133
+ "confidence_level": self.confidence_level,
134
+ "ci_lower_hz_str": self.ci_lower_hz_str,
135
+ "ci_upper_hz_str": self.ci_upper_hz_str,
136
+ "ci_lower_hz": self.ci_lower_hz,
137
+ "ci_upper_hz": self.ci_upper_hz,
138
+ }
139
+
140
+
141
+ class NuFTelemetryAccumulator:
142
+ """Accumulate reorganisation telemetry and produce νf estimates."""
143
+
144
+ def __init__(
145
+ self,
146
+ *,
147
+ confidence_level: float = 0.95,
148
+ history_limit: int | None = 128,
149
+ window_limit: int | None = None,
150
+ graph: GraphLike | MutableMapping[str, Any] | None = None,
151
+ ) -> None:
152
+ if not 0.0 < confidence_level < 1.0:
153
+ raise ValueError("confidence_level must be in the open interval (0, 1)")
154
+ if history_limit is not None and history_limit <= 0:
155
+ raise ValueError("history_limit must be positive when provided")
156
+ if window_limit is not None and window_limit <= 0:
157
+ raise ValueError("window_limit must be positive when provided")
158
+
159
+ self._confidence_level = float(confidence_level)
160
+ self._history_limit = history_limit
161
+ self._window_limit = window_limit
162
+ self._windows: Deque[NuFWindow] = deque()
163
+ self._total_reorganisations = 0
164
+ self._total_duration = 0.0
165
+ self._graph_ref: weakref.ReferenceType[
166
+ GraphLike | MutableMapping[str, Any]
167
+ ] | None = None
168
+ self.attach_graph(graph)
169
+
170
+ @property
171
+ def confidence_level(self) -> float:
172
+ """Return the configured confidence level for interval estimation."""
173
+
174
+ return self._confidence_level
175
+
176
+ @property
177
+ def history_limit(self) -> int | None:
178
+ """Return the maximum number of snapshots retained on the graph."""
179
+
180
+ return self._history_limit
181
+
182
+ @property
183
+ def window_limit(self) -> int | None:
184
+ """Return the maximum number of windows stored in memory."""
185
+
186
+ return self._window_limit
187
+
188
+ def attach_graph(
189
+ self, graph: GraphLike | MutableMapping[str, Any] | None
190
+ ) -> None:
191
+ """Attach ``graph`` for unit conversions and telemetry persistence."""
192
+
193
+ if graph is None:
194
+ return
195
+ try:
196
+ self._graph_ref = weakref.ref(graph) # type: ignore[arg-type]
197
+ except TypeError: # pragma: no cover - mapping instances are not weakrefable
198
+ self._graph_ref = None
199
+
200
+ def _resolve_graph(
201
+ self,
202
+ ) -> GraphLike | MutableMapping[str, Any] | None:
203
+ return self._graph_ref() if self._graph_ref is not None else None
204
+
205
+ def _coerce_window(self, window: NuFWindow) -> None:
206
+ if self._window_limit is not None and len(self._windows) >= self._window_limit:
207
+ removed = self._windows.popleft()
208
+ self._total_reorganisations -= removed.reorganisations
209
+ self._total_duration -= removed.duration
210
+ self._windows.append(window)
211
+ self._total_reorganisations += window.reorganisations
212
+ self._total_duration += window.duration
213
+
214
+ def record_window(
215
+ self,
216
+ window: NuFWindow,
217
+ *,
218
+ graph: GraphLike | MutableMapping[str, Any] | None = None,
219
+ ) -> NuFSnapshot:
220
+ """Record ``window`` and return the updated telemetry snapshot."""
221
+
222
+ self._coerce_window(window)
223
+ graph_obj = graph or self._resolve_graph()
224
+ snapshot = self.snapshot(graph=graph_obj)
225
+ self._persist_snapshot(snapshot, graph_obj)
226
+ return snapshot
227
+
228
+ def record_counts(
229
+ self,
230
+ reorganisations: int,
231
+ duration: float,
232
+ *,
233
+ start: float | None = None,
234
+ end: float | None = None,
235
+ graph: GraphLike | MutableMapping[str, Any] | None = None,
236
+ ) -> NuFSnapshot:
237
+ """Record a window described by ``reorganisations`` and ``duration``."""
238
+
239
+ window = NuFWindow(
240
+ reorganisations=int(reorganisations),
241
+ duration=float(duration),
242
+ start=float(start) if start is not None else None,
243
+ end=float(end) if end is not None else None,
244
+ )
245
+ return self.record_window(window, graph=graph)
246
+
247
+ def reset(self) -> None:
248
+ """Clear accumulated windows and totals."""
249
+
250
+ self._windows.clear()
251
+ self._total_reorganisations = 0
252
+ self._total_duration = 0.0
253
+
254
+ def _normal_dist(self) -> NormalDist:
255
+ return NormalDist()
256
+
257
+ def _graph_mapping(
258
+ self, graph: GraphLike | MutableMapping[str, Any] | None
259
+ ) -> MutableMapping[str, Any] | None:
260
+ if graph is None:
261
+ return None
262
+ if isinstance(graph, MutableMapping):
263
+ return graph
264
+ graph_data = getattr(graph, "graph", None)
265
+ return graph_data if isinstance(graph_data, MutableMapping) else None
266
+
267
+ def snapshot(
268
+ self,
269
+ *,
270
+ graph: GraphLike | MutableMapping[str, Any] | None = None,
271
+ ) -> NuFSnapshot:
272
+ """Return a νf telemetry snapshot without mutating internal state."""
273
+
274
+ total_duration = self._total_duration
275
+ total_reorganisations = self._total_reorganisations
276
+ windows = tuple(self._windows)
277
+
278
+ if total_duration <= 0.0:
279
+ rate_hz_str = None
280
+ variance_hz_str = None
281
+ ci_lower_str = None
282
+ ci_upper_str = None
283
+ confidence_level: float | None = None
284
+ else:
285
+ rate_hz_str = total_reorganisations / total_duration
286
+ variance_hz_str = rate_hz_str / total_duration
287
+ std_error = math.sqrt(variance_hz_str)
288
+ z = self._normal_dist().inv_cdf(
289
+ 0.5 + (self._confidence_level / 2.0)
290
+ )
291
+ ci_lower_str = max(rate_hz_str - z * std_error, 0.0)
292
+ ci_upper_str = rate_hz_str + z * std_error
293
+ confidence_level = self._confidence_level
294
+
295
+ graph_obj = graph or self._resolve_graph()
296
+ rate_hz = variance_hz = ci_lower_hz = ci_upper_hz = None
297
+ if rate_hz_str is not None and graph_obj is not None:
298
+ if not isinstance(graph_obj, MutableMapping):
299
+ bridge = get_hz_bridge(graph_obj)
300
+ rate_hz = hz_str_to_hz(rate_hz_str, graph_obj)
301
+ if variance_hz_str is not None:
302
+ variance_hz = variance_hz_str * (bridge**2)
303
+ if ci_lower_str is not None and ci_upper_str is not None:
304
+ ci_lower_hz = hz_str_to_hz(ci_lower_str, graph_obj)
305
+ ci_upper_hz = hz_str_to_hz(ci_upper_str, graph_obj)
306
+
307
+ return NuFSnapshot(
308
+ windows=windows,
309
+ total_reorganisations=total_reorganisations,
310
+ total_duration=total_duration,
311
+ rate_hz_str=rate_hz_str,
312
+ rate_hz=rate_hz,
313
+ variance_hz_str=variance_hz_str,
314
+ variance_hz=variance_hz,
315
+ confidence_level=confidence_level,
316
+ ci_lower_hz_str=ci_lower_str,
317
+ ci_upper_hz_str=ci_upper_str,
318
+ ci_lower_hz=ci_lower_hz,
319
+ ci_upper_hz=ci_upper_hz,
320
+ )
321
+
322
+ def _persist_snapshot(
323
+ self,
324
+ snapshot: NuFSnapshot,
325
+ graph: GraphLike | MutableMapping[str, Any] | None,
326
+ ) -> None:
327
+ mapping = self._graph_mapping(graph)
328
+ if mapping is None:
329
+ return
330
+
331
+ telemetry = mapping.setdefault("telemetry", {})
332
+ if not isinstance(telemetry, MutableMapping):
333
+ telemetry = {}
334
+ mapping["telemetry"] = telemetry
335
+ payload = snapshot.as_payload()
336
+ history_key = "nu_f_history"
337
+ history = telemetry.get(history_key)
338
+ if not isinstance(history, list):
339
+ legacy_history = telemetry.get("nu_f")
340
+ if isinstance(legacy_history, list):
341
+ history = legacy_history
342
+ else:
343
+ history = []
344
+ telemetry[history_key] = history
345
+ history.append(payload)
346
+ if self._history_limit is not None and len(history) > self._history_limit:
347
+ del history[: len(history) - self._history_limit]
348
+
349
+
350
+ _ACCUMULATOR_KEY = "_tnfr_nu_f_accumulator"
351
+
352
+
353
+ def ensure_nu_f_telemetry(
354
+ graph: GraphLike,
355
+ *,
356
+ confidence_level: float | None = None,
357
+ history_limit: int | None = 128,
358
+ window_limit: int | None = None,
359
+ ) -> NuFTelemetryAccumulator:
360
+ """Ensure ``graph`` exposes a :class:`NuFTelemetryAccumulator`.
361
+
362
+ When ``confidence_level`` is ``None`` the existing accumulator is preserved
363
+ and new accumulators default to ``0.95``.
364
+ """
365
+
366
+ mapping = getattr(graph, "graph", None)
367
+ if not isinstance(mapping, MutableMapping):
368
+ raise TypeError("graph.graph must be a mutable mapping for telemetry storage")
369
+
370
+ accumulator = mapping.get(_ACCUMULATOR_KEY)
371
+ replace = False
372
+ if isinstance(accumulator, NuFTelemetryAccumulator):
373
+ if (
374
+ confidence_level is not None
375
+ and abs(accumulator.confidence_level - confidence_level) > 1e-12
376
+ ) or (history_limit is not None and accumulator.history_limit != history_limit) or (
377
+ window_limit is not None and accumulator.window_limit != window_limit
378
+ ):
379
+ replace = True
380
+ if not isinstance(accumulator, NuFTelemetryAccumulator) or replace:
381
+ requested_confidence = 0.95 if confidence_level is None else confidence_level
382
+ accumulator = NuFTelemetryAccumulator(
383
+ confidence_level=requested_confidence,
384
+ history_limit=history_limit,
385
+ window_limit=window_limit,
386
+ graph=graph,
387
+ )
388
+ mapping[_ACCUMULATOR_KEY] = accumulator
389
+ else:
390
+ accumulator.attach_graph(graph)
391
+ return accumulator
392
+
393
+
394
+ def record_nu_f_window(
395
+ graph: GraphLike,
396
+ reorganisations: int,
397
+ duration: float,
398
+ *,
399
+ start: float | None = None,
400
+ end: float | None = None,
401
+ confidence_level: float | None = None,
402
+ history_limit: int | None = None,
403
+ window_limit: int | None = None,
404
+ ) -> NuFSnapshot:
405
+ """Record a νf observation for ``graph`` and persist the snapshot."""
406
+
407
+ kwargs: dict[str, Any] = {}
408
+ if confidence_level is not None:
409
+ kwargs["confidence_level"] = confidence_level
410
+ if history_limit is not None:
411
+ kwargs["history_limit"] = history_limit
412
+ if window_limit is not None:
413
+ kwargs["window_limit"] = window_limit
414
+
415
+ accumulator = ensure_nu_f_telemetry(graph, **kwargs)
416
+ return accumulator.record_counts(
417
+ reorganisations,
418
+ duration,
419
+ start=start,
420
+ end=end,
421
+ graph=graph,
422
+ )
423
+