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.
- tnfr/__init__.py +50 -5
- tnfr/__init__.pyi +0 -7
- tnfr/_compat.py +0 -1
- tnfr/_generated_version.py +34 -0
- tnfr/_version.py +44 -2
- tnfr/alias.py +14 -13
- tnfr/alias.pyi +5 -37
- tnfr/cache.py +9 -729
- tnfr/cache.pyi +8 -224
- tnfr/callback_utils.py +16 -31
- tnfr/callback_utils.pyi +3 -29
- tnfr/cli/__init__.py +17 -11
- tnfr/cli/__init__.pyi +0 -21
- tnfr/cli/arguments.py +175 -14
- tnfr/cli/arguments.pyi +5 -11
- tnfr/cli/execution.py +434 -48
- tnfr/cli/execution.pyi +14 -24
- tnfr/cli/utils.py +20 -3
- tnfr/cli/utils.pyi +5 -5
- tnfr/config/__init__.py +2 -1
- tnfr/config/__init__.pyi +2 -0
- tnfr/config/feature_flags.py +83 -0
- tnfr/config/init.py +1 -1
- tnfr/config/operator_names.py +1 -14
- tnfr/config/presets.py +6 -26
- tnfr/constants/__init__.py +10 -13
- tnfr/constants/__init__.pyi +10 -22
- tnfr/constants/aliases.py +31 -0
- tnfr/constants/core.py +4 -3
- tnfr/constants/init.py +1 -1
- tnfr/constants/metric.py +3 -3
- tnfr/dynamics/__init__.py +64 -10
- tnfr/dynamics/__init__.pyi +3 -4
- tnfr/dynamics/adaptation.py +79 -13
- tnfr/dynamics/aliases.py +10 -9
- tnfr/dynamics/coordination.py +77 -35
- tnfr/dynamics/dnfr.py +575 -274
- tnfr/dynamics/dnfr.pyi +1 -10
- tnfr/dynamics/integrators.py +47 -33
- tnfr/dynamics/integrators.pyi +0 -1
- tnfr/dynamics/runtime.py +489 -129
- tnfr/dynamics/sampling.py +2 -0
- tnfr/dynamics/selectors.py +101 -62
- tnfr/execution.py +15 -8
- tnfr/execution.pyi +5 -25
- tnfr/flatten.py +7 -3
- tnfr/flatten.pyi +1 -8
- tnfr/gamma.py +22 -26
- tnfr/gamma.pyi +0 -6
- tnfr/glyph_history.py +37 -26
- tnfr/glyph_history.pyi +1 -19
- tnfr/glyph_runtime.py +16 -0
- tnfr/glyph_runtime.pyi +9 -0
- tnfr/immutable.py +20 -15
- tnfr/immutable.pyi +4 -7
- tnfr/initialization.py +5 -7
- tnfr/initialization.pyi +1 -9
- tnfr/io.py +6 -305
- tnfr/io.pyi +13 -8
- tnfr/mathematics/__init__.py +81 -0
- tnfr/mathematics/backend.py +426 -0
- tnfr/mathematics/dynamics.py +398 -0
- tnfr/mathematics/epi.py +254 -0
- tnfr/mathematics/generators.py +222 -0
- tnfr/mathematics/metrics.py +119 -0
- tnfr/mathematics/operators.py +233 -0
- tnfr/mathematics/operators_factory.py +71 -0
- tnfr/mathematics/projection.py +78 -0
- tnfr/mathematics/runtime.py +173 -0
- tnfr/mathematics/spaces.py +247 -0
- tnfr/mathematics/transforms.py +292 -0
- tnfr/metrics/__init__.py +10 -10
- tnfr/metrics/coherence.py +123 -94
- tnfr/metrics/common.py +22 -13
- tnfr/metrics/common.pyi +42 -11
- tnfr/metrics/core.py +72 -14
- tnfr/metrics/diagnosis.py +48 -57
- tnfr/metrics/diagnosis.pyi +3 -7
- tnfr/metrics/export.py +3 -5
- tnfr/metrics/glyph_timing.py +41 -31
- tnfr/metrics/reporting.py +13 -6
- tnfr/metrics/sense_index.py +884 -114
- tnfr/metrics/trig.py +167 -11
- tnfr/metrics/trig.pyi +1 -0
- tnfr/metrics/trig_cache.py +112 -15
- tnfr/node.py +400 -17
- tnfr/node.pyi +55 -38
- tnfr/observers.py +111 -8
- tnfr/observers.pyi +0 -15
- tnfr/ontosim.py +9 -6
- tnfr/ontosim.pyi +0 -5
- tnfr/operators/__init__.py +529 -42
- tnfr/operators/__init__.pyi +14 -0
- tnfr/operators/definitions.py +350 -18
- tnfr/operators/definitions.pyi +0 -14
- tnfr/operators/grammar.py +760 -0
- tnfr/operators/jitter.py +28 -22
- tnfr/operators/registry.py +7 -12
- tnfr/operators/registry.pyi +0 -2
- tnfr/operators/remesh.py +38 -61
- tnfr/rng.py +17 -300
- tnfr/schemas/__init__.py +8 -0
- tnfr/schemas/grammar.json +94 -0
- tnfr/selector.py +3 -4
- tnfr/selector.pyi +1 -1
- tnfr/sense.py +22 -24
- tnfr/sense.pyi +0 -7
- tnfr/structural.py +504 -21
- tnfr/structural.pyi +41 -18
- tnfr/telemetry/__init__.py +23 -1
- tnfr/telemetry/cache_metrics.py +226 -0
- tnfr/telemetry/nu_f.py +423 -0
- tnfr/telemetry/nu_f.pyi +123 -0
- tnfr/tokens.py +1 -4
- tnfr/tokens.pyi +1 -6
- tnfr/trace.py +20 -53
- tnfr/trace.pyi +9 -37
- tnfr/types.py +244 -15
- tnfr/types.pyi +200 -14
- tnfr/units.py +69 -0
- tnfr/units.pyi +16 -0
- tnfr/utils/__init__.py +107 -48
- tnfr/utils/__init__.pyi +80 -11
- tnfr/utils/cache.py +1705 -65
- tnfr/utils/cache.pyi +370 -58
- tnfr/utils/chunks.py +104 -0
- tnfr/utils/chunks.pyi +21 -0
- tnfr/utils/data.py +95 -5
- tnfr/utils/data.pyi +8 -17
- tnfr/utils/graph.py +2 -4
- tnfr/utils/init.py +31 -7
- tnfr/utils/init.pyi +4 -11
- tnfr/utils/io.py +313 -14
- tnfr/{helpers → utils}/numeric.py +50 -24
- tnfr/utils/numeric.pyi +21 -0
- tnfr/validation/__init__.py +92 -4
- tnfr/validation/__init__.pyi +77 -17
- tnfr/validation/compatibility.py +79 -43
- tnfr/validation/compatibility.pyi +4 -6
- tnfr/validation/grammar.py +55 -133
- tnfr/validation/grammar.pyi +37 -8
- tnfr/validation/graph.py +138 -0
- tnfr/validation/graph.pyi +17 -0
- tnfr/validation/rules.py +161 -74
- tnfr/validation/rules.pyi +55 -18
- tnfr/validation/runtime.py +263 -0
- tnfr/validation/runtime.pyi +31 -0
- tnfr/validation/soft_filters.py +170 -0
- tnfr/validation/soft_filters.pyi +37 -0
- tnfr/validation/spectral.py +159 -0
- tnfr/validation/spectral.pyi +46 -0
- tnfr/validation/syntax.py +28 -139
- tnfr/validation/syntax.pyi +7 -4
- tnfr/validation/window.py +39 -0
- tnfr/validation/window.pyi +1 -0
- tnfr/viz/__init__.py +9 -0
- tnfr/viz/matplotlib.py +246 -0
- {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/METADATA +63 -19
- tnfr-7.0.0.dist-info/RECORD +185 -0
- {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/licenses/LICENSE.md +1 -1
- tnfr/constants_glyphs.py +0 -16
- tnfr/constants_glyphs.pyi +0 -12
- tnfr/grammar.py +0 -25
- tnfr/grammar.pyi +0 -13
- tnfr/helpers/__init__.py +0 -151
- tnfr/helpers/__init__.pyi +0 -66
- tnfr/helpers/numeric.pyi +0 -12
- tnfr/presets.py +0 -15
- tnfr/presets.pyi +0 -7
- tnfr/utils/io.pyi +0 -10
- tnfr/utils/validators.py +0 -130
- tnfr/utils/validators.pyi +0 -19
- tnfr-6.0.0.dist-info/RECORD +0 -157
- {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/WHEEL +0 -0
- {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
|