tnfr 4.5.2__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 (195) hide show
  1. tnfr/__init__.py +275 -51
  2. tnfr/__init__.pyi +33 -0
  3. tnfr/_compat.py +10 -0
  4. tnfr/_generated_version.py +34 -0
  5. tnfr/_version.py +49 -0
  6. tnfr/_version.pyi +7 -0
  7. tnfr/alias.py +117 -31
  8. tnfr/alias.pyi +108 -0
  9. tnfr/cache.py +6 -572
  10. tnfr/cache.pyi +16 -0
  11. tnfr/callback_utils.py +16 -38
  12. tnfr/callback_utils.pyi +79 -0
  13. tnfr/cli/__init__.py +34 -14
  14. tnfr/cli/__init__.pyi +26 -0
  15. tnfr/cli/arguments.py +211 -28
  16. tnfr/cli/arguments.pyi +27 -0
  17. tnfr/cli/execution.py +470 -50
  18. tnfr/cli/execution.pyi +70 -0
  19. tnfr/cli/utils.py +18 -3
  20. tnfr/cli/utils.pyi +8 -0
  21. tnfr/config/__init__.py +13 -0
  22. tnfr/config/__init__.pyi +10 -0
  23. tnfr/{constants_glyphs.py → config/constants.py} +26 -20
  24. tnfr/config/constants.pyi +12 -0
  25. tnfr/config/feature_flags.py +83 -0
  26. tnfr/{config.py → config/init.py} +11 -7
  27. tnfr/config/init.pyi +8 -0
  28. tnfr/config/operator_names.py +93 -0
  29. tnfr/config/operator_names.pyi +28 -0
  30. tnfr/config/presets.py +84 -0
  31. tnfr/config/presets.pyi +7 -0
  32. tnfr/constants/__init__.py +80 -29
  33. tnfr/constants/__init__.pyi +92 -0
  34. tnfr/constants/aliases.py +31 -0
  35. tnfr/constants/core.py +4 -4
  36. tnfr/constants/core.pyi +17 -0
  37. tnfr/constants/init.py +1 -1
  38. tnfr/constants/init.pyi +12 -0
  39. tnfr/constants/metric.py +7 -15
  40. tnfr/constants/metric.pyi +19 -0
  41. tnfr/dynamics/__init__.py +165 -633
  42. tnfr/dynamics/__init__.pyi +82 -0
  43. tnfr/dynamics/adaptation.py +267 -0
  44. tnfr/dynamics/aliases.py +23 -0
  45. tnfr/dynamics/coordination.py +385 -0
  46. tnfr/dynamics/dnfr.py +2283 -400
  47. tnfr/dynamics/dnfr.pyi +24 -0
  48. tnfr/dynamics/integrators.py +406 -98
  49. tnfr/dynamics/integrators.pyi +34 -0
  50. tnfr/dynamics/runtime.py +881 -0
  51. tnfr/dynamics/sampling.py +10 -5
  52. tnfr/dynamics/sampling.pyi +7 -0
  53. tnfr/dynamics/selectors.py +719 -0
  54. tnfr/execution.py +70 -48
  55. tnfr/execution.pyi +45 -0
  56. tnfr/flatten.py +13 -9
  57. tnfr/flatten.pyi +21 -0
  58. tnfr/gamma.py +66 -53
  59. tnfr/gamma.pyi +34 -0
  60. tnfr/glyph_history.py +110 -52
  61. tnfr/glyph_history.pyi +35 -0
  62. tnfr/glyph_runtime.py +16 -0
  63. tnfr/glyph_runtime.pyi +9 -0
  64. tnfr/immutable.py +69 -28
  65. tnfr/immutable.pyi +34 -0
  66. tnfr/initialization.py +16 -16
  67. tnfr/initialization.pyi +65 -0
  68. tnfr/io.py +6 -240
  69. tnfr/io.pyi +16 -0
  70. tnfr/locking.pyi +7 -0
  71. tnfr/mathematics/__init__.py +81 -0
  72. tnfr/mathematics/backend.py +426 -0
  73. tnfr/mathematics/dynamics.py +398 -0
  74. tnfr/mathematics/epi.py +254 -0
  75. tnfr/mathematics/generators.py +222 -0
  76. tnfr/mathematics/metrics.py +119 -0
  77. tnfr/mathematics/operators.py +233 -0
  78. tnfr/mathematics/operators_factory.py +71 -0
  79. tnfr/mathematics/projection.py +78 -0
  80. tnfr/mathematics/runtime.py +173 -0
  81. tnfr/mathematics/spaces.py +247 -0
  82. tnfr/mathematics/transforms.py +292 -0
  83. tnfr/metrics/__init__.py +10 -10
  84. tnfr/metrics/__init__.pyi +20 -0
  85. tnfr/metrics/coherence.py +993 -324
  86. tnfr/metrics/common.py +23 -16
  87. tnfr/metrics/common.pyi +46 -0
  88. tnfr/metrics/core.py +251 -35
  89. tnfr/metrics/core.pyi +13 -0
  90. tnfr/metrics/diagnosis.py +708 -111
  91. tnfr/metrics/diagnosis.pyi +85 -0
  92. tnfr/metrics/export.py +27 -15
  93. tnfr/metrics/glyph_timing.py +232 -42
  94. tnfr/metrics/reporting.py +33 -22
  95. tnfr/metrics/reporting.pyi +12 -0
  96. tnfr/metrics/sense_index.py +987 -43
  97. tnfr/metrics/sense_index.pyi +9 -0
  98. tnfr/metrics/trig.py +214 -23
  99. tnfr/metrics/trig.pyi +13 -0
  100. tnfr/metrics/trig_cache.py +115 -22
  101. tnfr/metrics/trig_cache.pyi +10 -0
  102. tnfr/node.py +542 -136
  103. tnfr/node.pyi +178 -0
  104. tnfr/observers.py +152 -35
  105. tnfr/observers.pyi +31 -0
  106. tnfr/ontosim.py +23 -19
  107. tnfr/ontosim.pyi +28 -0
  108. tnfr/operators/__init__.py +601 -82
  109. tnfr/operators/__init__.pyi +45 -0
  110. tnfr/operators/definitions.py +513 -0
  111. tnfr/operators/definitions.pyi +78 -0
  112. tnfr/operators/grammar.py +760 -0
  113. tnfr/operators/jitter.py +107 -38
  114. tnfr/operators/jitter.pyi +11 -0
  115. tnfr/operators/registry.py +75 -0
  116. tnfr/operators/registry.pyi +13 -0
  117. tnfr/operators/remesh.py +149 -88
  118. tnfr/py.typed +0 -0
  119. tnfr/rng.py +46 -143
  120. tnfr/rng.pyi +14 -0
  121. tnfr/schemas/__init__.py +8 -0
  122. tnfr/schemas/grammar.json +94 -0
  123. tnfr/selector.py +25 -19
  124. tnfr/selector.pyi +19 -0
  125. tnfr/sense.py +72 -62
  126. tnfr/sense.pyi +23 -0
  127. tnfr/structural.py +522 -262
  128. tnfr/structural.pyi +69 -0
  129. tnfr/telemetry/__init__.py +35 -0
  130. tnfr/telemetry/cache_metrics.py +226 -0
  131. tnfr/telemetry/nu_f.py +423 -0
  132. tnfr/telemetry/nu_f.pyi +123 -0
  133. tnfr/telemetry/verbosity.py +37 -0
  134. tnfr/tokens.py +1 -3
  135. tnfr/tokens.pyi +36 -0
  136. tnfr/trace.py +270 -113
  137. tnfr/trace.pyi +40 -0
  138. tnfr/types.py +574 -6
  139. tnfr/types.pyi +331 -0
  140. tnfr/units.py +69 -0
  141. tnfr/units.pyi +16 -0
  142. tnfr/utils/__init__.py +217 -0
  143. tnfr/utils/__init__.pyi +202 -0
  144. tnfr/utils/cache.py +2395 -0
  145. tnfr/utils/cache.pyi +468 -0
  146. tnfr/utils/chunks.py +104 -0
  147. tnfr/utils/chunks.pyi +21 -0
  148. tnfr/{collections_utils.py → utils/data.py} +147 -90
  149. tnfr/utils/data.pyi +64 -0
  150. tnfr/utils/graph.py +85 -0
  151. tnfr/utils/graph.pyi +10 -0
  152. tnfr/utils/init.py +770 -0
  153. tnfr/utils/init.pyi +78 -0
  154. tnfr/utils/io.py +456 -0
  155. tnfr/{helpers → utils}/numeric.py +51 -24
  156. tnfr/utils/numeric.pyi +21 -0
  157. tnfr/validation/__init__.py +113 -0
  158. tnfr/validation/__init__.pyi +77 -0
  159. tnfr/validation/compatibility.py +95 -0
  160. tnfr/validation/compatibility.pyi +6 -0
  161. tnfr/validation/grammar.py +71 -0
  162. tnfr/validation/grammar.pyi +40 -0
  163. tnfr/validation/graph.py +138 -0
  164. tnfr/validation/graph.pyi +17 -0
  165. tnfr/validation/rules.py +281 -0
  166. tnfr/validation/rules.pyi +55 -0
  167. tnfr/validation/runtime.py +263 -0
  168. tnfr/validation/runtime.pyi +31 -0
  169. tnfr/validation/soft_filters.py +170 -0
  170. tnfr/validation/soft_filters.pyi +37 -0
  171. tnfr/validation/spectral.py +159 -0
  172. tnfr/validation/spectral.pyi +46 -0
  173. tnfr/validation/syntax.py +40 -0
  174. tnfr/validation/syntax.pyi +10 -0
  175. tnfr/validation/window.py +39 -0
  176. tnfr/validation/window.pyi +1 -0
  177. tnfr/viz/__init__.py +9 -0
  178. tnfr/viz/matplotlib.py +246 -0
  179. tnfr-7.0.0.dist-info/METADATA +179 -0
  180. tnfr-7.0.0.dist-info/RECORD +185 -0
  181. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/licenses/LICENSE.md +1 -1
  182. tnfr/grammar.py +0 -344
  183. tnfr/graph_utils.py +0 -84
  184. tnfr/helpers/__init__.py +0 -71
  185. tnfr/import_utils.py +0 -228
  186. tnfr/json_utils.py +0 -162
  187. tnfr/logging_utils.py +0 -116
  188. tnfr/presets.py +0 -60
  189. tnfr/validators.py +0 -84
  190. tnfr/value_utils.py +0 -59
  191. tnfr-4.5.2.dist-info/METADATA +0 -379
  192. tnfr-4.5.2.dist-info/RECORD +0 -67
  193. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/WHEEL +0 -0
  194. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/entry_points.txt +0 -0
  195. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,881 @@
1
+ """Runtime orchestration for TNFR dynamics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+ import sys
7
+ from copy import deepcopy
8
+ from collections import deque
9
+ from collections.abc import Iterable, Mapping, MutableMapping
10
+ from numbers import Real
11
+ from typing import Any, cast
12
+
13
+ from ..alias import get_attr
14
+ from ..callback_utils import CallbackEvent, callback_manager
15
+ from ..constants import get_graph_param, get_param
16
+ from ..glyph_history import ensure_history
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
20
+ from ..types import HistoryState, NodeId, TNFRGraph
21
+ from ..utils import normalize_optional_int
22
+ from ..validation import apply_canonical_clamps, validate_canon
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]
45
+ from .dnfr import default_compute_delta_nfr
46
+ from .sampling import update_node_sample as _update_node_sample
47
+
48
+ __all__ = (
49
+ "ALIAS_VF",
50
+ "ALIAS_DNFR",
51
+ "ALIAS_EPI",
52
+ "ALIAS_SI",
53
+ "apply_canonical_clamps",
54
+ "validate_canon",
55
+ "_normalize_job_overrides",
56
+ "_resolve_jobs_override",
57
+ "_prepare_dnfr",
58
+ "_update_nodes",
59
+ "_update_epi_hist",
60
+ "_maybe_remesh",
61
+ "_run_validators",
62
+ "_run_before_callbacks",
63
+ "_run_after_callbacks",
64
+ "step",
65
+ "run",
66
+ )
67
+ def _normalize_job_overrides(
68
+ job_overrides: Mapping[str, Any] | None,
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
+ """
97
+ if not job_overrides:
98
+ return {}
99
+
100
+ normalized: dict[str, Any] = {}
101
+ for key, value in job_overrides.items():
102
+ if key is None:
103
+ continue
104
+ key_str = str(key).upper()
105
+ if key_str.endswith("_N_JOBS"):
106
+ key_str = key_str[: -len("_N_JOBS")]
107
+ normalized[key_str] = value
108
+ return normalized
109
+
110
+
111
+ def _resolve_jobs_override(
112
+ overrides: Mapping[str, Any],
113
+ key: str,
114
+ graph_value: Any,
115
+ *,
116
+ allow_non_positive: bool,
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
+ """
152
+ norm_key = key.upper()
153
+ if overrides and norm_key in overrides:
154
+ return normalize_optional_int(
155
+ overrides.get(norm_key),
156
+ allow_non_positive=allow_non_positive,
157
+ strict=False,
158
+ sentinels=None,
159
+ )
160
+
161
+ return normalize_optional_int(
162
+ graph_value,
163
+ allow_non_positive=allow_non_positive,
164
+ strict=False,
165
+ sentinels=None,
166
+ )
167
+
168
+
169
+ _INTEGRATOR_CACHE_KEY = "_integrator_cache"
170
+
171
+
172
+ def _call_integrator_factory(factory: Any, G: TNFRGraph) -> Any:
173
+ """Invoke an integrator factory respecting optional graph injection."""
174
+
175
+ try:
176
+ signature = inspect.signature(factory)
177
+ except (TypeError, ValueError):
178
+ return factory()
179
+
180
+ params = list(signature.parameters.values())
181
+ required = [
182
+ p
183
+ for p in params
184
+ if p.default is inspect._empty
185
+ and p.kind
186
+ in (
187
+ inspect.Parameter.POSITIONAL_ONLY,
188
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
189
+ inspect.Parameter.KEYWORD_ONLY,
190
+ )
191
+ ]
192
+
193
+ if any(p.kind is inspect.Parameter.KEYWORD_ONLY for p in required):
194
+ raise TypeError(
195
+ "Integrator factory cannot require keyword-only arguments",
196
+ )
197
+
198
+ positional_required = [
199
+ p
200
+ for p in required
201
+ if p.kind
202
+ in (
203
+ inspect.Parameter.POSITIONAL_ONLY,
204
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
205
+ )
206
+ ]
207
+ if len(positional_required) > 1:
208
+ raise TypeError(
209
+ "Integrator factory must accept at most one positional argument",
210
+ )
211
+
212
+ if positional_required:
213
+ return factory(G)
214
+
215
+ positional = [
216
+ p
217
+ for p in params
218
+ if p.kind
219
+ in (
220
+ inspect.Parameter.POSITIONAL_ONLY,
221
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
222
+ )
223
+ ]
224
+ if positional:
225
+ return factory(G)
226
+
227
+ return factory()
228
+
229
+
230
+ def _resolve_integrator_instance(G: TNFRGraph) -> integrators.AbstractIntegrator:
231
+ """Return an integrator instance configured on ``G`` or a default."""
232
+
233
+ cache_entry = G.graph.get(_INTEGRATOR_CACHE_KEY)
234
+ candidate = G.graph.get("integrator")
235
+ if (
236
+ isinstance(cache_entry, tuple)
237
+ and len(cache_entry) == 2
238
+ and cache_entry[0] is candidate
239
+ and isinstance(cache_entry[1], integrators.AbstractIntegrator)
240
+ ):
241
+ return cache_entry[1]
242
+
243
+ if isinstance(candidate, integrators.AbstractIntegrator):
244
+ instance = candidate
245
+ elif inspect.isclass(candidate) and issubclass(
246
+ candidate, integrators.AbstractIntegrator
247
+ ):
248
+ instance = candidate()
249
+ elif callable(candidate):
250
+ instance = cast(
251
+ integrators.AbstractIntegrator,
252
+ _call_integrator_factory(candidate, G),
253
+ )
254
+ elif candidate is None:
255
+ instance = integrators.DefaultIntegrator()
256
+ else:
257
+ raise TypeError(
258
+ "Graph integrator must be an AbstractIntegrator, subclass or callable",
259
+ )
260
+
261
+ if not isinstance(instance, integrators.AbstractIntegrator):
262
+ raise TypeError(
263
+ "Configured integrator must implement AbstractIntegrator.integrate",
264
+ )
265
+
266
+ G.graph[_INTEGRATOR_CACHE_KEY] = (candidate, instance)
267
+ return instance
268
+
269
+
270
+ def _run_before_callbacks(
271
+ G: TNFRGraph,
272
+ *,
273
+ step_idx: int,
274
+ dt: float | None,
275
+ use_Si: bool,
276
+ apply_glyphs: bool,
277
+ ) -> None:
278
+ """Notify ``BEFORE_STEP`` observers with execution context."""
279
+
280
+ callback_manager.invoke_callbacks(
281
+ G,
282
+ CallbackEvent.BEFORE_STEP.value,
283
+ {
284
+ "step": step_idx,
285
+ "dt": dt,
286
+ "use_Si": use_Si,
287
+ "apply_glyphs": apply_glyphs,
288
+ },
289
+ )
290
+
291
+
292
+ def _prepare_dnfr(
293
+ G: TNFRGraph,
294
+ *,
295
+ use_Si: bool,
296
+ job_overrides: Mapping[str, Any] | None = None,
297
+ ) -> None:
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)
301
+ overrides = job_overrides or {}
302
+ n_jobs = _resolve_jobs_override(
303
+ overrides,
304
+ "DNFR",
305
+ G.graph.get("DNFR_N_JOBS"),
306
+ allow_non_positive=False,
307
+ )
308
+
309
+ supports_n_jobs = False
310
+ try:
311
+ signature = inspect.signature(compute_dnfr_cb)
312
+ except (TypeError, ValueError):
313
+ signature = None
314
+ if signature is not None:
315
+ params = signature.parameters
316
+ if "n_jobs" in params:
317
+ kind = params["n_jobs"].kind
318
+ supports_n_jobs = kind in (
319
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
320
+ inspect.Parameter.KEYWORD_ONLY,
321
+ )
322
+ elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
323
+ supports_n_jobs = True
324
+
325
+ if supports_n_jobs:
326
+ compute_dnfr_cb(G, n_jobs=n_jobs)
327
+ else:
328
+ try:
329
+ compute_dnfr_cb(G, n_jobs=n_jobs)
330
+ except TypeError as exc:
331
+ if "n_jobs" in str(exc):
332
+ compute_dnfr_cb(G)
333
+ else:
334
+ raise
335
+ G.graph.pop("_sel_norms", None)
336
+ if use_Si:
337
+ si_jobs = _resolve_jobs_override(
338
+ overrides,
339
+ "SI",
340
+ G.graph.get("SI_N_JOBS"),
341
+ allow_non_positive=False,
342
+ )
343
+ dynamics_module = sys.modules.get("tnfr.dynamics")
344
+ compute_si_fn = (
345
+ getattr(dynamics_module, "compute_Si", None)
346
+ if dynamics_module is not None
347
+ else None
348
+ )
349
+ if compute_si_fn is None:
350
+ compute_si_fn = compute_Si
351
+ compute_si_fn(G, inplace=True, n_jobs=si_jobs)
352
+
353
+
354
+ def _update_nodes(
355
+ G: TNFRGraph,
356
+ *,
357
+ dt: float | None,
358
+ use_Si: bool,
359
+ apply_glyphs: bool,
360
+ step_idx: int,
361
+ hist: HistoryState,
362
+ job_overrides: Mapping[str, Any] | None = None,
363
+ ) -> None:
364
+ """Apply glyphs, integrate ΔNFR and refresh derived nodal state."""
365
+
366
+ _update_node_sample(G, step=step_idx)
367
+ overrides = job_overrides or {}
368
+ _prepare_dnfr(G, use_Si=use_Si, job_overrides=overrides)
369
+ selector = selectors._apply_selector(G)
370
+ if apply_glyphs:
371
+ selectors._apply_glyphs(G, selector, hist)
372
+ _dt = get_graph_param(G, "DT") if dt is None else float(dt)
373
+ method = get_graph_param(G, "INTEGRATOR_METHOD", str)
374
+ n_jobs = _resolve_jobs_override(
375
+ overrides,
376
+ "INTEGRATOR",
377
+ G.graph.get("INTEGRATOR_N_JOBS"),
378
+ allow_non_positive=True,
379
+ )
380
+ integrator = _resolve_integrator_instance(G)
381
+ integrator.integrate(
382
+ G,
383
+ dt=_dt,
384
+ t=None,
385
+ method=cast(str | None, method),
386
+ n_jobs=n_jobs,
387
+ )
388
+ for n, nd in G.nodes(data=True):
389
+ apply_canonical_clamps(cast(MutableMapping[str, Any], nd), G, cast(NodeId, n))
390
+ phase_jobs = _resolve_jobs_override(
391
+ overrides,
392
+ "PHASE",
393
+ G.graph.get("PHASE_N_JOBS"),
394
+ allow_non_positive=True,
395
+ )
396
+ coordination.coordinate_global_local_phase(G, None, None, n_jobs=phase_jobs)
397
+ vf_jobs = _resolve_jobs_override(
398
+ overrides,
399
+ "VF_ADAPT",
400
+ G.graph.get("VF_ADAPT_N_JOBS"),
401
+ allow_non_positive=False,
402
+ )
403
+ adaptation.adapt_vf_by_coherence(G, n_jobs=vf_jobs)
404
+
405
+
406
+ def _update_epi_hist(G: TNFRGraph) -> None:
407
+ """Maintain the rolling EPI history used by remeshing heuristics."""
408
+
409
+ tau_g = int(get_param(G, "REMESH_TAU_GLOBAL"))
410
+ tau_l = int(get_param(G, "REMESH_TAU_LOCAL"))
411
+ tau = max(tau_g, tau_l)
412
+ maxlen = max(2 * tau + 5, 64)
413
+ epi_hist = G.graph.get("_epi_hist")
414
+ if not isinstance(epi_hist, deque) or epi_hist.maxlen != maxlen:
415
+ epi_hist = deque(list(epi_hist or [])[-maxlen:], maxlen=maxlen)
416
+ G.graph["_epi_hist"] = epi_hist
417
+ epi_hist.append({n: get_attr(nd, ALIAS_EPI, 0.0) for n, nd in G.nodes(data=True)})
418
+
419
+
420
+ def _maybe_remesh(G: TNFRGraph) -> None:
421
+ """Trigger remeshing when stability thresholds are satisfied."""
422
+
423
+ apply_remesh_if_globally_stable(G)
424
+
425
+
426
+ def _run_validators(G: TNFRGraph) -> None:
427
+ """Execute registered validators ensuring canonical invariants hold."""
428
+
429
+ from ..validation import run_validators
430
+
431
+ run_validators(G)
432
+
433
+
434
+ def _run_after_callbacks(G, *, step_idx: int) -> None:
435
+ """Notify ``AFTER_STEP`` observers with the latest structural metrics."""
436
+
437
+ h = ensure_history(G)
438
+ ctx = {"step": step_idx}
439
+ metric_pairs = [
440
+ ("C", "C_steps"),
441
+ ("stable_frac", "stable_frac"),
442
+ ("phase_sync", "phase_sync"),
443
+ ("glyph_disr", "glyph_load_disr"),
444
+ ("Si_mean", "Si_mean"),
445
+ ]
446
+ for dst, src in metric_pairs:
447
+ values = h.get(src)
448
+ if values:
449
+ ctx[dst] = values[-1]
450
+ callback_manager.invoke_callbacks(G, CallbackEvent.AFTER_STEP.value, ctx)
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)
700
+
701
+ def step(
702
+ G: TNFRGraph,
703
+ *,
704
+ dt: float | None = None,
705
+ use_Si: bool = True,
706
+ apply_glyphs: bool = True,
707
+ n_jobs: Mapping[str, Any] | None = None,
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
+ """
762
+ job_overrides = _normalize_job_overrides(n_jobs)
763
+ hist = ensure_history(G)
764
+ step_idx = len(hist.setdefault("C_steps", []))
765
+ _run_before_callbacks(
766
+ G, step_idx=step_idx, dt=dt, use_Si=use_Si, apply_glyphs=apply_glyphs
767
+ )
768
+ _update_nodes(
769
+ G,
770
+ dt=dt,
771
+ use_Si=use_Si,
772
+ apply_glyphs=apply_glyphs,
773
+ step_idx=step_idx,
774
+ hist=hist,
775
+ job_overrides=job_overrides,
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
+ )
784
+ _update_epi_hist(G)
785
+ _maybe_remesh(G)
786
+ _run_validators(G)
787
+ _run_after_callbacks(G, step_idx=step_idx)
788
+ publish_graph_cache_metrics(G)
789
+
790
+
791
+ def run(
792
+ G: TNFRGraph,
793
+ steps: int,
794
+ *,
795
+ dt: float | None = None,
796
+ use_Si: bool = True,
797
+ apply_glyphs: bool = True,
798
+ n_jobs: Mapping[str, Any] | None = None,
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
+ """
850
+ steps_int = int(steps)
851
+ if steps_int < 0:
852
+ raise ValueError("'steps' must be non-negative")
853
+ stop_cfg = get_graph_param(G, "STOP_EARLY", dict)
854
+ stop_enabled = False
855
+ if stop_cfg and stop_cfg.get("enabled", False):
856
+ w = max(1, int(stop_cfg.get("window", 25)))
857
+ frac = float(stop_cfg.get("fraction", 0.90))
858
+ stop_enabled = True
859
+ job_overrides = _normalize_job_overrides(n_jobs)
860
+ for _ in range(steps_int):
861
+ step(
862
+ G,
863
+ dt=dt,
864
+ use_Si=use_Si,
865
+ apply_glyphs=apply_glyphs,
866
+ n_jobs=job_overrides,
867
+ )
868
+ if stop_enabled:
869
+ history = ensure_history(G)
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
+ ):
881
+ break