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
tnfr/dynamics/runtime.py CHANGED
@@ -3,34 +3,47 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import inspect
6
- import math
7
6
  import sys
7
+ from copy import deepcopy
8
8
  from collections import deque
9
- from collections.abc import Mapping, MutableMapping, MutableSequence
9
+ from collections.abc import Iterable, Mapping, MutableMapping
10
+ from numbers import Real
10
11
  from typing import Any, cast
11
12
 
12
- from ..alias import (
13
- get_attr,
14
- get_theta_attr,
15
- set_attr,
16
- set_theta,
17
- set_theta_attr,
18
- set_vf,
19
- multi_recompute_abs_max,
20
- )
13
+ from ..alias import get_attr
21
14
  from ..callback_utils import CallbackEvent, callback_manager
22
- from ..constants import DEFAULTS, get_graph_param, get_param
15
+ from ..constants import get_graph_param, get_param
23
16
  from ..glyph_history import ensure_history
24
- from ..helpers.numeric import clamp
25
17
  from ..metrics.sense_index import compute_Si
18
+ from ..operators import apply_remesh_if_globally_stable
19
+ from ..telemetry import publish_graph_cache_metrics
26
20
  from ..types import HistoryState, NodeId, TNFRGraph
27
- from .aliases import ALIAS_DNFR, ALIAS_EPI, ALIAS_SI, ALIAS_VF
21
+ from ..utils import normalize_optional_int
22
+ from ..validation import apply_canonical_clamps, validate_canon
28
23
  from . import adaptation, coordination, integrators, selectors
24
+ from .aliases import ALIAS_DNFR, ALIAS_EPI, ALIAS_SI, ALIAS_THETA, ALIAS_VF
25
+
26
+ try: # pragma: no cover - optional NumPy dependency
27
+ import numpy as np
28
+ except ImportError: # pragma: no cover - optional dependency missing
29
+ np = None # type: ignore[assignment]
30
+
31
+ try: # pragma: no cover - optional math extras
32
+ from ..mathematics.dynamics import MathematicalDynamicsEngine
33
+ from ..mathematics.projection import BasicStateProjector
34
+ from ..mathematics.runtime import (
35
+ coherence as runtime_coherence,
36
+ frequency_positive as runtime_frequency_positive,
37
+ normalized as runtime_normalized,
38
+ )
39
+ except Exception: # pragma: no cover - fallback when extras not available
40
+ MathematicalDynamicsEngine = None # type: ignore[assignment]
41
+ BasicStateProjector = None # type: ignore[assignment]
42
+ runtime_coherence = None # type: ignore[assignment]
43
+ runtime_frequency_positive = None # type: ignore[assignment]
44
+ runtime_normalized = None # type: ignore[assignment]
29
45
  from .dnfr import default_compute_delta_nfr
30
46
  from .sampling import update_node_sample as _update_node_sample
31
- from ..operators import apply_remesh_if_globally_stable
32
-
33
- HistoryLog = MutableSequence[MutableMapping[str, object]]
34
47
 
35
48
  __all__ = (
36
49
  "ALIAS_VF",
@@ -51,23 +64,36 @@ __all__ = (
51
64
  "step",
52
65
  "run",
53
66
  )
54
-
55
-
56
- def _log_clamp(
57
- hist: HistoryLog,
58
- node: NodeId | None,
59
- attr: str,
60
- value: float,
61
- lo: float,
62
- hi: float,
63
- ) -> None:
64
- if value < lo or value > hi:
65
- hist.append({"node": node, "attr": attr, "value": float(value)})
66
-
67
-
68
67
  def _normalize_job_overrides(
69
68
  job_overrides: Mapping[str, Any] | None,
70
69
  ) -> dict[str, Any]:
70
+ """Canonicalise job override keys for ΔNFR, νf and phase orchestration.
71
+
72
+ Parameters
73
+ ----------
74
+ job_overrides : Mapping[str, Any] | None
75
+ User-provided mapping whose keys may use legacy ``*_N_JOBS`` forms or
76
+ mixed casing. The values tune the parallel workloads that update ΔNFR,
77
+ νf adaptation and global phase coordination.
78
+
79
+ Returns
80
+ -------
81
+ dict[str, Any]
82
+ A dictionary where keys are upper-cased without the ``_N_JOBS`` suffix,
83
+ ready for downstream lookup in the runtime schedulers.
84
+
85
+ Notes
86
+ -----
87
+ ``None`` keys are silently skipped to preserve resiliency when
88
+ orchestrating ΔNFR workers.
89
+
90
+ Examples
91
+ --------
92
+ >>> _normalize_job_overrides({"dnfr_n_jobs": 2, "vf_adapt": 4})
93
+ {'DNFR': 2, 'VF_ADAPT': 4}
94
+ >>> _normalize_job_overrides(None)
95
+ {}
96
+ """
71
97
  if not job_overrides:
72
98
  return {}
73
99
 
@@ -82,23 +108,6 @@ def _normalize_job_overrides(
82
108
  return normalized
83
109
 
84
110
 
85
- def _coerce_jobs_value(raw: Any) -> int | None:
86
- if raw is None:
87
- return None
88
- try:
89
- return int(raw)
90
- except (TypeError, ValueError):
91
- return None
92
-
93
-
94
- def _sanitize_jobs(value: int | None, *, allow_non_positive: bool) -> int | None:
95
- if value is None:
96
- return None
97
- if not allow_non_positive and value <= 0:
98
- return None
99
- return value
100
-
101
-
102
111
  def _resolve_jobs_override(
103
112
  overrides: Mapping[str, Any],
104
113
  key: str,
@@ -106,16 +115,54 @@ def _resolve_jobs_override(
106
115
  *,
107
116
  allow_non_positive: bool,
108
117
  ) -> int | None:
118
+ """Resolve job overrides prioritising user hints over graph defaults.
119
+
120
+ Parameters
121
+ ----------
122
+ overrides : Mapping[str, Any]
123
+ Normalised overrides produced by :func:`_normalize_job_overrides` that
124
+ steer the ΔNFR computation, νf adaptation or phase coupling workers.
125
+ key : str
126
+ Logical subsystem key such as ``"DNFR"`` or ``"VF_ADAPT"``.
127
+ graph_value : Any
128
+ Baseline job count stored in the graph configuration.
129
+ allow_non_positive : bool
130
+ Propagated policy describing whether zero or negative values are valid
131
+ for the subsystem.
132
+
133
+ Returns
134
+ -------
135
+ int | None
136
+ Final job count that each scheduler will use, or ``None`` when no
137
+ explicit override or valid fallback exists.
138
+
139
+ Notes
140
+ -----
141
+ Preference resolution is pure and returns ``None`` instead of raising when
142
+ overrides cannot be coerced into valid integers.
143
+
144
+ Examples
145
+ --------
146
+ >>> overrides = _normalize_job_overrides({"phase": 0})
147
+ >>> _resolve_jobs_override(overrides, "phase", 2, allow_non_positive=True)
148
+ 0
149
+ >>> _resolve_jobs_override({}, "vf_adapt", 4, allow_non_positive=False)
150
+ 4
151
+ """
109
152
  norm_key = key.upper()
110
153
  if overrides and norm_key in overrides:
111
- return _sanitize_jobs(
112
- _coerce_jobs_value(overrides.get(norm_key)),
154
+ return normalize_optional_int(
155
+ overrides.get(norm_key),
113
156
  allow_non_positive=allow_non_positive,
157
+ strict=False,
158
+ sentinels=None,
114
159
  )
115
160
 
116
- return _sanitize_jobs(
117
- _coerce_jobs_value(graph_value),
161
+ return normalize_optional_int(
162
+ graph_value,
118
163
  allow_non_positive=allow_non_positive,
164
+ strict=False,
165
+ sentinels=None,
119
166
  )
120
167
 
121
168
 
@@ -220,64 +267,6 @@ def _resolve_integrator_instance(G: TNFRGraph) -> integrators.AbstractIntegrator
220
267
  return instance
221
268
 
222
269
 
223
- def apply_canonical_clamps(
224
- nd: MutableMapping[str, Any],
225
- G: TNFRGraph | None = None,
226
- node: NodeId | None = None,
227
- ) -> None:
228
- if G is not None:
229
- graph_dict = cast(MutableMapping[str, Any], G.graph)
230
- graph_data: Mapping[str, Any] = graph_dict
231
- else:
232
- graph_dict = None
233
- graph_data = DEFAULTS
234
- eps_min = float(graph_data.get("EPI_MIN", DEFAULTS["EPI_MIN"]))
235
- eps_max = float(graph_data.get("EPI_MAX", DEFAULTS["EPI_MAX"]))
236
- vf_min = float(graph_data.get("VF_MIN", DEFAULTS["VF_MIN"]))
237
- vf_max = float(graph_data.get("VF_MAX", DEFAULTS["VF_MAX"]))
238
- theta_wrap = bool(graph_data.get("THETA_WRAP", DEFAULTS["THETA_WRAP"]))
239
-
240
- epi = cast(float, get_attr(nd, ALIAS_EPI, 0.0))
241
- vf = get_attr(nd, ALIAS_VF, 0.0)
242
- th_val = get_theta_attr(nd, 0.0)
243
- th = 0.0 if th_val is None else float(th_val)
244
-
245
- strict = bool(
246
- graph_data.get("VALIDATORS_STRICT", DEFAULTS.get("VALIDATORS_STRICT", False))
247
- )
248
- if strict and graph_dict is not None:
249
- history = cast(MutableMapping[str, Any], graph_dict.setdefault("history", {}))
250
- hist = cast(
251
- HistoryLog,
252
- history.setdefault("clamp_alerts", []),
253
- )
254
- _log_clamp(hist, node, "EPI", float(epi), eps_min, eps_max)
255
- _log_clamp(hist, node, "VF", float(vf), vf_min, vf_max)
256
-
257
- set_attr(nd, ALIAS_EPI, clamp(epi, eps_min, eps_max))
258
-
259
- vf_val = float(clamp(vf, vf_min, vf_max))
260
- if G is not None and node is not None:
261
- set_vf(G, node, vf_val, update_max=False)
262
- else:
263
- set_attr(nd, ALIAS_VF, vf_val)
264
-
265
- if theta_wrap:
266
- new_th = (th + math.pi) % (2 * math.pi) - math.pi
267
- if G is not None and node is not None:
268
- set_theta(G, node, new_th)
269
- else:
270
- set_theta_attr(nd, new_th)
271
-
272
-
273
- def validate_canon(G: TNFRGraph) -> TNFRGraph:
274
- for n, nd in G.nodes(data=True):
275
- apply_canonical_clamps(cast(MutableMapping[str, Any], nd), G, cast(NodeId, n))
276
- maxes = multi_recompute_abs_max(G, {"_vfmax": ALIAS_VF})
277
- G.graph.update(maxes)
278
- return G
279
-
280
-
281
270
  def _run_before_callbacks(
282
271
  G: TNFRGraph,
283
272
  *,
@@ -286,6 +275,8 @@ def _run_before_callbacks(
286
275
  use_Si: bool,
287
276
  apply_glyphs: bool,
288
277
  ) -> None:
278
+ """Notify ``BEFORE_STEP`` observers with execution context."""
279
+
289
280
  callback_manager.invoke_callbacks(
290
281
  G,
291
282
  CallbackEvent.BEFORE_STEP.value,
@@ -304,9 +295,9 @@ def _prepare_dnfr(
304
295
  use_Si: bool,
305
296
  job_overrides: Mapping[str, Any] | None = None,
306
297
  ) -> None:
307
- compute_dnfr_cb = G.graph.get(
308
- "compute_delta_nfr", default_compute_delta_nfr
309
- )
298
+ """Recompute ΔNFR (and optionally Si) ahead of an integration step."""
299
+
300
+ compute_dnfr_cb = G.graph.get("compute_delta_nfr", default_compute_delta_nfr)
310
301
  overrides = job_overrides or {}
311
302
  n_jobs = _resolve_jobs_override(
312
303
  overrides,
@@ -328,9 +319,7 @@ def _prepare_dnfr(
328
319
  inspect.Parameter.POSITIONAL_OR_KEYWORD,
329
320
  inspect.Parameter.KEYWORD_ONLY,
330
321
  )
331
- elif any(
332
- p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
333
- ):
322
+ elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
334
323
  supports_n_jobs = True
335
324
 
336
325
  if supports_n_jobs:
@@ -372,6 +361,8 @@ def _update_nodes(
372
361
  hist: HistoryState,
373
362
  job_overrides: Mapping[str, Any] | None = None,
374
363
  ) -> None:
364
+ """Apply glyphs, integrate ΔNFR and refresh derived nodal state."""
365
+
375
366
  _update_node_sample(G, step=step_idx)
376
367
  overrides = job_overrides or {}
377
368
  _prepare_dnfr(G, use_Si=use_Si, job_overrides=overrides)
@@ -395,9 +386,7 @@ def _update_nodes(
395
386
  n_jobs=n_jobs,
396
387
  )
397
388
  for n, nd in G.nodes(data=True):
398
- apply_canonical_clamps(
399
- cast(MutableMapping[str, Any], nd), G, cast(NodeId, n)
400
- )
389
+ apply_canonical_clamps(cast(MutableMapping[str, Any], nd), G, cast(NodeId, n))
401
390
  phase_jobs = _resolve_jobs_override(
402
391
  overrides,
403
392
  "PHASE",
@@ -415,6 +404,8 @@ def _update_nodes(
415
404
 
416
405
 
417
406
  def _update_epi_hist(G: TNFRGraph) -> None:
407
+ """Maintain the rolling EPI history used by remeshing heuristics."""
408
+
418
409
  tau_g = int(get_param(G, "REMESH_TAU_GLOBAL"))
419
410
  tau_l = int(get_param(G, "REMESH_TAU_LOCAL"))
420
411
  tau = max(tau_g, tau_l)
@@ -423,22 +414,26 @@ def _update_epi_hist(G: TNFRGraph) -> None:
423
414
  if not isinstance(epi_hist, deque) or epi_hist.maxlen != maxlen:
424
415
  epi_hist = deque(list(epi_hist or [])[-maxlen:], maxlen=maxlen)
425
416
  G.graph["_epi_hist"] = epi_hist
426
- epi_hist.append(
427
- {n: get_attr(nd, ALIAS_EPI, 0.0) for n, nd in G.nodes(data=True)}
428
- )
417
+ epi_hist.append({n: get_attr(nd, ALIAS_EPI, 0.0) for n, nd in G.nodes(data=True)})
429
418
 
430
419
 
431
420
  def _maybe_remesh(G: TNFRGraph) -> None:
421
+ """Trigger remeshing when stability thresholds are satisfied."""
422
+
432
423
  apply_remesh_if_globally_stable(G)
433
424
 
434
425
 
435
426
  def _run_validators(G: TNFRGraph) -> None:
436
- from ..utils import run_validators
427
+ """Execute registered validators ensuring canonical invariants hold."""
428
+
429
+ from ..validation import run_validators
437
430
 
438
431
  run_validators(G)
439
432
 
440
433
 
441
434
  def _run_after_callbacks(G, *, step_idx: int) -> None:
435
+ """Notify ``AFTER_STEP`` observers with the latest structural metrics."""
436
+
442
437
  h = ensure_history(G)
443
438
  ctx = {"step": step_idx}
444
439
  metric_pairs = [
@@ -454,6 +449,254 @@ def _run_after_callbacks(G, *, step_idx: int) -> None:
454
449
  ctx[dst] = values[-1]
455
450
  callback_manager.invoke_callbacks(G, CallbackEvent.AFTER_STEP.value, ctx)
456
451
 
452
+ telemetry = G.graph.get("telemetry")
453
+ if isinstance(telemetry, MutableMapping):
454
+ history = telemetry.get("nu_f")
455
+ history_key = "nu_f_history"
456
+ if isinstance(history, list) and history_key not in telemetry:
457
+ telemetry[history_key] = history
458
+ payload = telemetry.get("nu_f_snapshot")
459
+ if isinstance(payload, Mapping):
460
+ bridge_raw = telemetry.get("nu_f_bridge")
461
+ try:
462
+ bridge = float(bridge_raw) if bridge_raw is not None else None
463
+ except (TypeError, ValueError):
464
+ bridge = None
465
+ nu_f_summary = {
466
+ "total_reorganisations": payload.get("total_reorganisations"),
467
+ "total_duration": payload.get("total_duration"),
468
+ "rate_hz_str": payload.get("rate_hz_str"),
469
+ "rate_hz": payload.get("rate_hz"),
470
+ "variance_hz_str": payload.get("variance_hz_str"),
471
+ "variance_hz": payload.get("variance_hz"),
472
+ "confidence_level": payload.get("confidence_level"),
473
+ "ci_hz_str": {
474
+ "lower": payload.get("ci_lower_hz_str"),
475
+ "upper": payload.get("ci_upper_hz_str"),
476
+ },
477
+ "ci_hz": {
478
+ "lower": payload.get("ci_lower_hz"),
479
+ "upper": payload.get("ci_upper_hz"),
480
+ },
481
+ "bridge": bridge,
482
+ }
483
+ telemetry["nu_f"] = nu_f_summary
484
+ math_summary = telemetry.get("math_engine")
485
+ if isinstance(math_summary, MutableMapping):
486
+ math_summary["nu_f"] = dict(nu_f_summary)
487
+
488
+
489
+ def _get_math_engine_config(G: TNFRGraph) -> MutableMapping[str, Any] | None:
490
+ """Return the mutable math-engine configuration stored on ``G``."""
491
+
492
+ cfg_raw = G.graph.get("MATH_ENGINE")
493
+ if not isinstance(cfg_raw, Mapping) or not cfg_raw.get("enabled", False):
494
+ return None
495
+ if isinstance(cfg_raw, MutableMapping):
496
+ return cfg_raw
497
+ cfg_mutable: MutableMapping[str, Any] = dict(cfg_raw)
498
+ G.graph["MATH_ENGINE"] = cfg_mutable
499
+ return cfg_mutable
500
+
501
+
502
+ def _initialise_math_state(
503
+ G: TNFRGraph,
504
+ cfg: MutableMapping[str, Any],
505
+ *,
506
+ hilbert_space: Any,
507
+ projector: BasicStateProjector,
508
+ ) -> np.ndarray | None:
509
+ """Project graph nodes into the Hilbert space to seed the math engine."""
510
+
511
+ dimension = getattr(hilbert_space, "dimension", None)
512
+ if dimension is None:
513
+ raise AttributeError("Hilbert space configuration is missing 'dimension'.")
514
+
515
+ vectors: list[np.ndarray] = []
516
+ for _, nd in G.nodes(data=True):
517
+ epi = float(get_attr(nd, ALIAS_EPI, 0.0) or 0.0)
518
+ nu_f = float(get_attr(nd, ALIAS_VF, 0.0) or 0.0)
519
+ theta_val = float(get_attr(nd, ALIAS_THETA, 0.0) or 0.0)
520
+ try:
521
+ vector = projector(epi=epi, nu_f=nu_f, theta=theta_val, dim=int(dimension))
522
+ except ValueError:
523
+ continue
524
+ vectors.append(np.asarray(vector, dtype=np.complex128))
525
+
526
+ if not vectors:
527
+ return None
528
+
529
+ stacked = np.vstack(vectors)
530
+ averaged = np.mean(stacked, axis=0)
531
+ atol = float(getattr(projector, "atol", 1e-9))
532
+ norm = float(getattr(hilbert_space, "norm")(averaged))
533
+ if np.isclose(norm, 0.0, atol=atol):
534
+ averaged = vectors[0]
535
+ norm = float(getattr(hilbert_space, "norm")(averaged))
536
+ if np.isclose(norm, 0.0, atol=atol):
537
+ return None
538
+ normalised = averaged / norm
539
+ cfg.setdefault("_state_origin", "projected")
540
+ return normalised
541
+
542
+
543
+ def _advance_math_engine(
544
+ G: TNFRGraph,
545
+ *,
546
+ dt: float,
547
+ step_idx: int,
548
+ hist: HistoryState,
549
+ ) -> None:
550
+ """Advance the optional math engine and record spectral telemetry."""
551
+
552
+ cfg = _get_math_engine_config(G)
553
+ if cfg is None:
554
+ return
555
+
556
+ if (
557
+ np is None
558
+ or MathematicalDynamicsEngine is None
559
+ or runtime_normalized is None
560
+ or runtime_coherence is None
561
+ ):
562
+ raise RuntimeError(
563
+ "Mathematical dynamics require NumPy and tnfr.mathematics extras to be installed."
564
+ )
565
+
566
+ hilbert_space = cfg.get("hilbert_space")
567
+ coherence_operator = cfg.get("coherence_operator")
568
+ coherence_threshold = cfg.get("coherence_threshold")
569
+ if hilbert_space is None or coherence_operator is None or coherence_threshold is None:
570
+ raise ValueError(
571
+ "MATH_ENGINE requires 'hilbert_space', 'coherence_operator' and "
572
+ "'coherence_threshold' entries."
573
+ )
574
+
575
+ if BasicStateProjector is None: # pragma: no cover - guarded by import above
576
+ raise RuntimeError("Mathematical dynamics require the BasicStateProjector helper.")
577
+
578
+ projector = cfg.get("state_projector")
579
+ if not isinstance(projector, BasicStateProjector):
580
+ projector = BasicStateProjector()
581
+ cfg["state_projector"] = projector
582
+
583
+ engine = cfg.get("dynamics_engine")
584
+ if not isinstance(engine, MathematicalDynamicsEngine):
585
+ generator = cfg.get("generator_matrix")
586
+ if generator is None:
587
+ raise ValueError(
588
+ "MATH_ENGINE requires either a 'dynamics_engine' instance or a "
589
+ "'generator_matrix'."
590
+ )
591
+ generator_matrix = np.asarray(generator, dtype=np.complex128)
592
+ engine = MathematicalDynamicsEngine(generator_matrix, hilbert_space=hilbert_space)
593
+ cfg["dynamics_engine"] = engine
594
+
595
+ state_vector = cfg.get("_state_vector")
596
+ if state_vector is None:
597
+ state_vector = _initialise_math_state(
598
+ G,
599
+ cfg,
600
+ hilbert_space=hilbert_space,
601
+ projector=projector,
602
+ )
603
+ if state_vector is None:
604
+ return
605
+ else:
606
+ state_vector = np.asarray(state_vector, dtype=np.complex128)
607
+ dimension = getattr(hilbert_space, "dimension", state_vector.shape[0])
608
+ if state_vector.shape != (int(dimension),):
609
+ state_vector = _initialise_math_state(
610
+ G,
611
+ cfg,
612
+ hilbert_space=hilbert_space,
613
+ projector=projector,
614
+ )
615
+ if state_vector is None:
616
+ return
617
+
618
+ advanced = engine.step(state_vector, dt=float(dt), normalize=True)
619
+ cfg["_state_vector"] = advanced
620
+
621
+ atol = float(cfg.get("atol", getattr(engine, "atol", 1e-9)))
622
+ label = f"step[{step_idx}]"
623
+
624
+ normalized_passed, norm_value = runtime_normalized(
625
+ advanced,
626
+ hilbert_space,
627
+ atol=atol,
628
+ label=label,
629
+ )
630
+ coherence_passed, coherence_value = runtime_coherence(
631
+ advanced,
632
+ coherence_operator,
633
+ float(coherence_threshold),
634
+ normalise=False,
635
+ atol=atol,
636
+ label=label,
637
+ )
638
+
639
+ frequency_operator = cfg.get("frequency_operator")
640
+ frequency_summary: dict[str, Any] | None = None
641
+ if frequency_operator is not None:
642
+ if runtime_frequency_positive is None: # pragma: no cover - guarded above
643
+ raise RuntimeError(
644
+ "Frequency positivity checks require tnfr.mathematics extras."
645
+ )
646
+ freq_raw = runtime_frequency_positive(
647
+ advanced,
648
+ frequency_operator,
649
+ normalise=False,
650
+ enforce=True,
651
+ atol=atol,
652
+ label=label,
653
+ )
654
+ frequency_summary = {
655
+ "passed": bool(freq_raw.get("passed", False)),
656
+ "value": float(freq_raw.get("value", 0.0)),
657
+ "projection_passed": bool(freq_raw.get("projection_passed", False)),
658
+ "spectrum_psd": bool(freq_raw.get("spectrum_psd", False)),
659
+ "enforced": bool(freq_raw.get("enforce", True)),
660
+ }
661
+ if "spectrum_min" in freq_raw:
662
+ frequency_summary["spectrum_min"] = float(freq_raw.get("spectrum_min", 0.0))
663
+
664
+ summary = {
665
+ "step": step_idx,
666
+ "normalized": bool(normalized_passed),
667
+ "norm": float(norm_value),
668
+ "coherence": {
669
+ "passed": bool(coherence_passed),
670
+ "value": float(coherence_value),
671
+ "threshold": float(coherence_threshold),
672
+ },
673
+ "frequency": frequency_summary,
674
+ }
675
+
676
+ hist.setdefault("math_engine_summary", []).append(summary)
677
+ hist.setdefault("math_engine_norm", []).append(summary["norm"])
678
+ hist.setdefault("math_engine_normalized", []).append(summary["normalized"])
679
+ hist.setdefault("math_engine_coherence", []).append(summary["coherence"]["value"])
680
+ hist.setdefault("math_engine_coherence_passed", []).append(
681
+ summary["coherence"]["passed"]
682
+ )
683
+
684
+ if frequency_summary is None:
685
+ hist.setdefault("math_engine_frequency", []).append(None)
686
+ hist.setdefault("math_engine_frequency_passed", []).append(None)
687
+ hist.setdefault("math_engine_frequency_projection_passed", []).append(None)
688
+ else:
689
+ hist.setdefault("math_engine_frequency", []).append(frequency_summary["value"])
690
+ hist.setdefault("math_engine_frequency_passed", []).append(
691
+ frequency_summary["passed"]
692
+ )
693
+ hist.setdefault("math_engine_frequency_projection_passed", []).append(
694
+ frequency_summary["projection_passed"]
695
+ )
696
+
697
+ cfg["last_summary"] = summary
698
+ telemetry = G.graph.setdefault("telemetry", {})
699
+ telemetry["math_engine"] = deepcopy(summary)
457
700
 
458
701
  def step(
459
702
  G: TNFRGraph,
@@ -463,6 +706,59 @@ def step(
463
706
  apply_glyphs: bool = True,
464
707
  n_jobs: Mapping[str, Any] | None = None,
465
708
  ) -> None:
709
+ """Advance the runtime one ΔNFR step updating νf, phase and glyphs.
710
+
711
+ Parameters
712
+ ----------
713
+ G : TNFRGraph
714
+ Graph whose nodes store EPI, νf and phase metadata. The graph must
715
+ expose a ΔNFR hook under ``G.graph['compute_delta_nfr']`` and optional
716
+ selector or callback registrations.
717
+ dt : float | None, optional
718
+ Time increment injected into the integrator. ``None`` falls back to the
719
+ ``DT`` attribute stored in ``G.graph`` which keeps ΔNFR integration
720
+ aligned with the nodal equation.
721
+ use_Si : bool, default True
722
+ When ``True`` the Sense Index (Si) is recomputed to modulate ΔNFR and
723
+ νf adaptation heuristics.
724
+ apply_glyphs : bool, default True
725
+ Enables canonical glyph selection so that phase and coherence glyphs
726
+ continue to modulate ΔNFR.
727
+ n_jobs : Mapping[str, Any] | None, optional
728
+ Optional overrides that tune the parallel workers used for ΔNFR, phase
729
+ coordination and νf adaptation. The mapping is processed by
730
+ :func:`_normalize_job_overrides`.
731
+
732
+ Returns
733
+ -------
734
+ None
735
+ Mutates ``G`` in place by recomputing ΔNFR, νf and phase metrics.
736
+
737
+ Notes
738
+ -----
739
+ Registered callbacks execute within :func:`step` and any exceptions they
740
+ raise propagate according to the callback manager configuration.
741
+
742
+ Examples
743
+ --------
744
+ Register a hook that records phase synchrony while using the parametric
745
+ selector to choose glyphs before advancing one runtime step.
746
+
747
+ >>> from tnfr.callback_utils import CallbackEvent, callback_manager
748
+ >>> from tnfr.dynamics import selectors
749
+ >>> from tnfr.dynamics.runtime import ALIAS_VF
750
+ >>> from tnfr.structural import create_nfr
751
+ >>> G, node = create_nfr("seed", epi=0.2, vf=1.5)
752
+ >>> callback_manager.register_callback(
753
+ ... G,
754
+ ... CallbackEvent.AFTER_STEP,
755
+ ... lambda graph, ctx: graph.graph.setdefault("phase_log", []).append(ctx.get("phase_sync")),
756
+ ... )
757
+ >>> G.graph["glyph_selector"] = selectors.ParametricGlyphSelector()
758
+ >>> step(G, dt=0.05, n_jobs={"dnfr_n_jobs": 1})
759
+ >>> ALIAS_VF in G.nodes[node]
760
+ True
761
+ """
466
762
  job_overrides = _normalize_job_overrides(n_jobs)
467
763
  hist = ensure_history(G)
468
764
  step_idx = len(hist.setdefault("C_steps", []))
@@ -478,10 +774,18 @@ def step(
478
774
  hist=hist,
479
775
  job_overrides=job_overrides,
480
776
  )
777
+ resolved_dt = get_graph_param(G, "DT") if dt is None else float(dt)
778
+ _advance_math_engine(
779
+ G,
780
+ dt=resolved_dt,
781
+ step_idx=step_idx,
782
+ hist=hist,
783
+ )
481
784
  _update_epi_hist(G)
482
785
  _maybe_remesh(G)
483
786
  _run_validators(G)
484
787
  _run_after_callbacks(G, step_idx=step_idx)
788
+ publish_graph_cache_metrics(G)
485
789
 
486
790
 
487
791
  def run(
@@ -493,13 +797,63 @@ def run(
493
797
  apply_glyphs: bool = True,
494
798
  n_jobs: Mapping[str, Any] | None = None,
495
799
  ) -> None:
800
+ """Iterate :func:`step` to evolve ΔNFR, νf and phase trajectories.
801
+
802
+ Parameters
803
+ ----------
804
+ G : TNFRGraph
805
+ Graph that stores the coherent structures. Callbacks and selectors
806
+ configured on ``G.graph`` orchestrate glyph application and telemetry.
807
+ steps : int
808
+ Number of times :func:`step` is invoked. Each iteration integrates ΔNFR
809
+ and νf according to ``dt`` and the configured selector.
810
+ dt : float | None, optional
811
+ Time increment for each step. ``None`` uses the graph's default ``DT``.
812
+ use_Si : bool, default True
813
+ Recompute the Sense Index during each iteration to keep ΔNFR feedback
814
+ loops tied to νf adjustments.
815
+ apply_glyphs : bool, default True
816
+ Enables glyph selection and application per step.
817
+ n_jobs : Mapping[str, Any] | None, optional
818
+ Shared overrides forwarded to each :func:`step` call.
819
+
820
+ Returns
821
+ -------
822
+ None
823
+ The graph ``G`` is updated in place.
824
+
825
+ Raises
826
+ ------
827
+ ValueError
828
+ Raised when ``steps`` is negative because the runtime cannot evolve a
829
+ negative number of ΔNFR updates.
830
+
831
+ Examples
832
+ --------
833
+ Install a before-step callback and use the default glyph selector while
834
+ running two iterations that synchronise phase and νf.
835
+
836
+ >>> from tnfr.callback_utils import CallbackEvent, callback_manager
837
+ >>> from tnfr.dynamics import selectors
838
+ >>> from tnfr.structural import create_nfr
839
+ >>> G, node = create_nfr("seed", epi=0.3, vf=1.2)
840
+ >>> callback_manager.register_callback(
841
+ ... G,
842
+ ... CallbackEvent.BEFORE_STEP,
843
+ ... lambda graph, ctx: graph.graph.setdefault("dt_trace", []).append(ctx["dt"]),
844
+ ... )
845
+ >>> G.graph["glyph_selector"] = selectors.default_glyph_selector
846
+ >>> run(G, 2, dt=0.1)
847
+ >>> len(G.graph["dt_trace"])
848
+ 2
849
+ """
496
850
  steps_int = int(steps)
497
851
  if steps_int < 0:
498
852
  raise ValueError("'steps' must be non-negative")
499
853
  stop_cfg = get_graph_param(G, "STOP_EARLY", dict)
500
854
  stop_enabled = False
501
855
  if stop_cfg and stop_cfg.get("enabled", False):
502
- w = int(stop_cfg.get("window", 25))
856
+ w = max(1, int(stop_cfg.get("window", 25)))
503
857
  frac = float(stop_cfg.get("fraction", 0.90))
504
858
  stop_enabled = True
505
859
  job_overrides = _normalize_job_overrides(n_jobs)
@@ -513,9 +867,15 @@ def run(
513
867
  )
514
868
  if stop_enabled:
515
869
  history = ensure_history(G)
516
- series = history.get("stable_frac", [])
517
- if not isinstance(series, list):
518
- series = list(series)
519
- if len(series) >= w and all(v >= frac for v in series[-w:]):
870
+ raw_series = dict.get(history, "stable_frac", [])
871
+ if not isinstance(raw_series, Iterable):
872
+ series = []
873
+ elif isinstance(raw_series, list):
874
+ series = raw_series
875
+ else:
876
+ series = list(raw_series)
877
+ numeric_series = [v for v in series if isinstance(v, Real)]
878
+ if len(numeric_series) >= w and all(
879
+ v >= frac for v in numeric_series[-w:]
880
+ ):
520
881
  break
521
-