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
tnfr/dynamics/dnfr.pyi ADDED
@@ -0,0 +1,24 @@
1
+ from typing import Any
2
+
3
+ from tnfr.types import DeltaNFRHook, TNFRGraph
4
+ from tnfr.utils.cache import DnfrCache as DnfrCache
5
+
6
+ __all__: tuple[str, ...]
7
+
8
+ def default_compute_delta_nfr(
9
+ G: TNFRGraph,
10
+ *,
11
+ cache_size: int | None = ...,
12
+ n_jobs: int | None = ...,
13
+ ) -> None: ...
14
+ def dnfr_epi_vf_mixed(G: TNFRGraph, *, n_jobs: int | None = ...) -> None: ...
15
+ def dnfr_laplacian(G: TNFRGraph, *, n_jobs: int | None = ...) -> None: ...
16
+ def dnfr_phase_only(G: TNFRGraph, *, n_jobs: int | None = ...) -> None: ...
17
+ def set_delta_nfr_hook(
18
+ G: TNFRGraph,
19
+ func: DeltaNFRHook,
20
+ *,
21
+ name: str | None = ...,
22
+ note: str | None = ...,
23
+ ) -> None: ...
24
+ def __getattr__(name: str) -> Any: ...
@@ -1,42 +1,171 @@
1
+ """Canonical ΔNFR integrators driving TNFR runtime evolution."""
2
+
1
3
  from __future__ import annotations
2
4
 
3
5
  import math
4
- from collections.abc import Mapping
5
- from typing import Any, Literal
6
-
7
- import networkx as nx # type: ignore[import-untyped]
8
-
9
- from ..constants import (
10
- DEFAULTS,
11
- get_aliases,
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Iterable, Mapping
8
+ from concurrent.futures import ProcessPoolExecutor
9
+ from multiprocessing import get_context
10
+ from typing import Any, Literal, cast
11
+
12
+ import networkx as nx
13
+
14
+ from .._compat import TypeAlias
15
+ from ..alias import collect_attr, get_attr, get_attr_str, set_attr, set_attr_str
16
+ from ..constants import DEFAULTS
17
+ from ..constants.aliases import (
18
+ ALIAS_D2EPI,
19
+ ALIAS_DEPI,
20
+ ALIAS_DNFR,
21
+ ALIAS_EPI,
22
+ ALIAS_EPI_KIND,
23
+ ALIAS_VF,
12
24
  )
13
25
  from ..gamma import _get_gamma_spec, eval_gamma
14
- from ..alias import get_attr, get_attr_str, set_attr, set_attr_str
15
-
16
- ALIAS_VF = get_aliases("VF")
17
- ALIAS_DNFR = get_aliases("DNFR")
18
- ALIAS_DEPI = get_aliases("DEPI")
19
- ALIAS_EPI = get_aliases("EPI")
20
- ALIAS_EPI_KIND = get_aliases("EPI_KIND")
21
- ALIAS_D2EPI = get_aliases("D2EPI")
26
+ from ..types import NodeId, TNFRGraph
27
+ from ..utils import get_numpy, resolve_chunk_size
22
28
 
23
29
  __all__ = (
30
+ "AbstractIntegrator",
31
+ "DefaultIntegrator",
24
32
  "prepare_integration_params",
25
33
  "update_epi_via_nodal_equation",
26
34
  )
27
35
 
28
36
 
37
+ GammaMap: TypeAlias = dict[NodeId, float]
38
+ """Γ evaluation cache keyed by node identifier."""
39
+
40
+ NodeIncrements: TypeAlias = dict[NodeId, tuple[float, ...]]
41
+ """Mapping of nodes to staged integration increments."""
42
+
43
+ NodalUpdate: TypeAlias = dict[NodeId, tuple[float, float, float]]
44
+ """Mapping of nodes to ``(EPI, dEPI/dt, ∂²EPI/∂t²)`` tuples."""
45
+
46
+ IntegratorMethod: TypeAlias = Literal["euler", "rk4"]
47
+ """Supported explicit integration schemes for nodal updates."""
48
+
49
+
50
+ _PARALLEL_GRAPH: TNFRGraph | None = None
51
+
52
+
53
+ def _gamma_worker_init(graph: TNFRGraph) -> None:
54
+ """Initialise process-local graph reference for Γ evaluation."""
55
+
56
+ global _PARALLEL_GRAPH
57
+ _PARALLEL_GRAPH = graph
58
+
59
+
60
+ def _gamma_worker(task: tuple[list[NodeId], float]) -> list[tuple[NodeId, float]]:
61
+ """Evaluate Γ for ``task`` chunk using process-local graph."""
62
+
63
+ chunk, t = task
64
+ if _PARALLEL_GRAPH is None:
65
+ raise RuntimeError("Parallel Γ worker initialised without graph reference")
66
+ return [(node, float(eval_gamma(_PARALLEL_GRAPH, node, t))) for node in chunk]
67
+
68
+
69
+ def _normalise_jobs(n_jobs: int | None, total: int) -> int | None:
70
+ """Return an effective worker count respecting serial fallbacks."""
71
+
72
+ if n_jobs is None:
73
+ return None
74
+ try:
75
+ workers = int(n_jobs)
76
+ except (TypeError, ValueError):
77
+ return None
78
+ if workers <= 1 or total <= 1:
79
+ return None
80
+ return max(1, min(workers, total))
81
+
82
+
83
+ def _chunk_nodes(nodes: list[NodeId], chunk_size: int) -> Iterable[list[NodeId]]:
84
+ """Yield deterministic chunks from ``nodes`` respecting insertion order."""
85
+
86
+ for idx in range(0, len(nodes), chunk_size):
87
+ yield nodes[idx : idx + chunk_size]
88
+
89
+
90
+ def _apply_increment_chunk(
91
+ chunk: list[tuple[NodeId, float, float, tuple[float, ...]]],
92
+ dt_step: float,
93
+ method: str,
94
+ ) -> list[tuple[NodeId, tuple[float, float, float]]]:
95
+ """Compute updated states for ``chunk`` using scalar arithmetic."""
96
+
97
+ results: list[tuple[NodeId, tuple[float, float, float]]] = []
98
+ dt_nonzero = dt_step != 0
99
+
100
+ for node, epi_i, dEPI_prev, ks in chunk:
101
+ if method == "rk4":
102
+ k1, k2, k3, k4 = ks
103
+ epi = epi_i + (dt_step / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
104
+ dEPI_dt = k4
105
+ else:
106
+ (k1,) = ks
107
+ epi = epi_i + dt_step * k1
108
+ dEPI_dt = k1
109
+ d2epi = (dEPI_dt - dEPI_prev) / dt_step if dt_nonzero else 0.0
110
+ results.append((node, (float(epi), float(dEPI_dt), float(d2epi))))
111
+
112
+ return results
113
+
114
+
115
+ def _evaluate_gamma_map(
116
+ G: TNFRGraph,
117
+ nodes: list[NodeId],
118
+ t: float,
119
+ *,
120
+ n_jobs: int | None = None,
121
+ ) -> GammaMap:
122
+ """Return Γ evaluations for ``nodes`` at time ``t`` respecting parallelism."""
123
+
124
+ workers = _normalise_jobs(n_jobs, len(nodes))
125
+ if workers is None:
126
+ return {n: float(eval_gamma(G, n, t)) for n in nodes}
127
+
128
+ approx_chunk = math.ceil(len(nodes) / (workers * 4)) if workers > 0 else None
129
+ chunk_size = resolve_chunk_size(
130
+ approx_chunk,
131
+ len(nodes),
132
+ minimum=1,
133
+ )
134
+ mp_ctx = get_context("spawn")
135
+ tasks = ((chunk, t) for chunk in _chunk_nodes(nodes, chunk_size))
136
+
137
+ results: GammaMap = {}
138
+ with ProcessPoolExecutor(
139
+ max_workers=workers,
140
+ mp_context=mp_ctx,
141
+ initializer=_gamma_worker_init,
142
+ initargs=(G,),
143
+ ) as executor:
144
+ futures = [executor.submit(_gamma_worker, task) for task in tasks]
145
+ for fut in futures:
146
+ for node, value in fut.result():
147
+ results[node] = value
148
+ return results
149
+
150
+
29
151
  def prepare_integration_params(
30
- G,
152
+ G: TNFRGraph,
31
153
  dt: float | None = None,
32
154
  t: float | None = None,
33
155
  method: Literal["euler", "rk4"] | None = None,
34
- ):
156
+ ) -> tuple[float, int, float, Literal["euler", "rk4"]]:
35
157
  """Validate and normalise ``dt``, ``t`` and ``method`` for integration.
36
158
 
37
- Returns ``(dt_step, steps, t0, method)`` where ``dt_step`` is the
38
- effective step, ``steps`` the number of substeps and ``t0`` the prepared
39
- initial time.
159
+ The function raises :class:`TypeError` when ``dt`` cannot be coerced to a
160
+ number, :class:`ValueError` if ``dt`` is negative, and another
161
+ :class:`ValueError` when an unsupported method is requested. When ``dt``
162
+ exceeds a positive ``DT_MIN`` stored on ``G`` the span is deterministically
163
+ subdivided into integer steps so that the resulting ``dt_step`` never falls
164
+ below that minimum threshold.
165
+
166
+ Returns ``(dt_step, steps, t0, method)`` where ``dt_step`` is the effective
167
+ step, ``steps`` the number of substeps and ``t0`` the prepared initial
168
+ time.
40
169
  """
41
170
  if dt is None:
42
171
  dt = float(G.graph.get("DT", DEFAULTS["DT"]))
@@ -52,60 +181,129 @@ def prepare_integration_params(
52
181
  else:
53
182
  t = float(t)
54
183
 
55
- method = (
184
+ method_value = (
56
185
  method
57
- or G.graph.get(
58
- "INTEGRATOR_METHOD", DEFAULTS.get("INTEGRATOR_METHOD", "euler")
59
- )
186
+ or G.graph.get("INTEGRATOR_METHOD", DEFAULTS.get("INTEGRATOR_METHOD", "euler"))
60
187
  ).lower()
61
- if method not in ("euler", "rk4"):
188
+ if method_value not in ("euler", "rk4"):
62
189
  raise ValueError("method must be 'euler' or 'rk4'")
63
190
 
64
191
  dt_min = float(G.graph.get("DT_MIN", DEFAULTS.get("DT_MIN", 0.0)))
192
+ steps = 1
65
193
  if dt_min > 0 and dt > dt_min:
66
- steps = int(math.ceil(dt / dt_min))
67
- else:
68
- steps = 1
69
- # ``steps`` is guaranteed to be ≥1 at this point
70
- dt_step = dt / steps
194
+ ratio = dt / dt_min
195
+ steps = max(1, int(math.floor(ratio + 1e-12)))
196
+ if dt / steps < dt_min:
197
+ steps = int(math.ceil(ratio))
198
+ dt_step = dt / steps if steps else 0.0
71
199
 
72
- return dt_step, steps, t, method
200
+ return dt_step, steps, t, cast(Literal["euler", "rk4"], method_value)
73
201
 
74
202
 
75
203
  def _apply_increments(
76
- G: Any,
204
+ G: TNFRGraph,
77
205
  dt_step: float,
78
- increments: dict[Any, tuple[float, ...]],
206
+ increments: NodeIncrements,
79
207
  *,
80
208
  method: str,
81
- ) -> dict[Any, tuple[float, float, float]]:
209
+ n_jobs: int | None = None,
210
+ ) -> NodalUpdate:
82
211
  """Combine precomputed increments to update node states."""
83
212
 
84
- new_states: dict[Any, tuple[float, float, float]] = {}
85
- for n, nd in G.nodes(data=True):
86
- vf, dnfr, dEPI_dt_prev, epi_i = _node_state(nd)
87
- ks = increments[n]
213
+ nodes: list[NodeId] = list(G.nodes)
214
+ if not nodes:
215
+ return {}
216
+
217
+ np = get_numpy()
218
+
219
+ epi_initial: list[float] = []
220
+ dEPI_prev: list[float] = []
221
+ ordered_increments: list[tuple[float, ...]] = []
222
+
223
+ for node in nodes:
224
+ nd = G.nodes[node]
225
+ _, _, dEPI_dt_prev, epi_i = _node_state(nd)
226
+ epi_initial.append(float(epi_i))
227
+ dEPI_prev.append(float(dEPI_dt_prev))
228
+ ordered_increments.append(increments[node])
229
+
230
+ if np is not None:
231
+ epi_arr = np.asarray(epi_initial, dtype=float)
232
+ dEPI_prev_arr = np.asarray(dEPI_prev, dtype=float)
233
+ k_arr = np.asarray(ordered_increments, dtype=float)
234
+
88
235
  if method == "rk4":
89
- k1, k2, k3, k4 = ks
90
- # RK4: EPIₙ₊₁ = EPIᵢ + Δt/6·(k1 + 2k2 + 2k3 + k4)
91
- epi = epi_i + (dt_step / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
236
+ if k_arr.ndim != 2 or k_arr.shape[1] != 4:
237
+ raise ValueError("rk4 increments require four staged values")
238
+ dt_factor = dt_step / 6.0
239
+ k1 = k_arr[:, 0]
240
+ k2 = k_arr[:, 1]
241
+ k3 = k_arr[:, 2]
242
+ k4 = k_arr[:, 3]
243
+ epi = epi_arr + dt_factor * (k1 + 2 * k2 + 2 * k3 + k4)
92
244
  dEPI_dt = k4
93
245
  else:
94
- (k1,) = ks
95
- # Euler: EPIₙ₊₁ = EPIᵢ + Δt·k1 where k1 = νf·ΔNFR + Γ
96
- epi = epi_i + dt_step * k1
246
+ if k_arr.ndim == 1:
247
+ k1 = k_arr
248
+ else:
249
+ k1 = k_arr[:, 0]
250
+ epi = epi_arr + dt_step * k1
97
251
  dEPI_dt = k1
98
- d2epi = (dEPI_dt - dEPI_dt_prev) / dt_step if dt_step != 0 else 0.0
99
- new_states[n] = (epi, dEPI_dt, d2epi)
100
- return new_states
252
+
253
+ if dt_step != 0:
254
+ d2epi = (dEPI_dt - dEPI_prev_arr) / dt_step
255
+ else:
256
+ d2epi = np.zeros_like(dEPI_dt)
257
+
258
+ results: NodalUpdate = {}
259
+ for idx, node in enumerate(nodes):
260
+ results[node] = (
261
+ float(epi[idx]),
262
+ float(dEPI_dt[idx]),
263
+ float(d2epi[idx]),
264
+ )
265
+ return results
266
+
267
+ payload: list[tuple[NodeId, float, float, tuple[float, ...]]] = list(
268
+ zip(nodes, epi_initial, dEPI_prev, ordered_increments)
269
+ )
270
+
271
+ workers = _normalise_jobs(n_jobs, len(nodes))
272
+ if workers is None:
273
+ return dict(_apply_increment_chunk(payload, dt_step, method))
274
+
275
+ approx_chunk = math.ceil(len(nodes) / (workers * 4)) if workers > 0 else None
276
+ chunk_size = resolve_chunk_size(
277
+ approx_chunk,
278
+ len(nodes),
279
+ minimum=1,
280
+ )
281
+ mp_ctx = get_context("spawn")
282
+
283
+ results: NodalUpdate = {}
284
+ with ProcessPoolExecutor(max_workers=workers, mp_context=mp_ctx) as executor:
285
+ futures = [
286
+ executor.submit(
287
+ _apply_increment_chunk,
288
+ chunk,
289
+ dt_step,
290
+ method,
291
+ )
292
+ for chunk in _chunk_nodes(payload, chunk_size)
293
+ ]
294
+ for fut in futures:
295
+ for node, value in fut.result():
296
+ results[node] = value
297
+
298
+ return {node: results[node] for node in nodes}
101
299
 
102
300
 
103
301
  def _collect_nodal_increments(
104
- G: Any,
105
- gamma_maps: tuple[dict[Any, float], ...],
302
+ G: TNFRGraph,
303
+ gamma_maps: tuple[GammaMap, ...],
106
304
  *,
107
305
  method: str,
108
- ) -> dict[Any, tuple[float, ...]]:
306
+ ) -> NodeIncrements:
109
307
  """Combine node base state with staged Γ contributions.
110
308
 
111
309
  ``gamma_maps`` must contain one entry for Euler integration and four for
@@ -113,38 +311,71 @@ def _collect_nodal_increments(
113
311
  with the supplied Γ evaluations.
114
312
  """
115
313
 
116
- increments: dict[Any, tuple[float, ...]] = {}
117
- for n, nd in G.nodes(data=True):
314
+ nodes: list[NodeId] = list(G.nodes())
315
+ if not nodes:
316
+ return {}
317
+
318
+ if method == "rk4":
319
+ expected_maps = 4
320
+ elif method == "euler":
321
+ expected_maps = 1
322
+ else:
323
+ raise ValueError("method must be 'euler' or 'rk4'")
324
+
325
+ if len(gamma_maps) != expected_maps:
326
+ raise ValueError(f"{method} integration requires {expected_maps} gamma maps")
327
+
328
+ np = get_numpy()
329
+ if np is not None:
330
+ vf = collect_attr(G, nodes, ALIAS_VF, 0.0, np=np)
331
+ dnfr = collect_attr(G, nodes, ALIAS_DNFR, 0.0, np=np)
332
+ base = vf * dnfr
333
+
334
+ gamma_arrays = [
335
+ np.fromiter((gm.get(n, 0.0) for n in nodes), float, count=len(nodes))
336
+ for gm in gamma_maps
337
+ ]
338
+ if gamma_arrays:
339
+ gamma_stack = np.stack(gamma_arrays, axis=1)
340
+ combined = base[:, None] + gamma_stack
341
+ else:
342
+ combined = base[:, None]
343
+
344
+ return {
345
+ node: tuple(float(value) for value in combined[idx])
346
+ for idx, node in enumerate(nodes)
347
+ }
348
+
349
+ increments: NodeIncrements = {}
350
+ for node in nodes:
351
+ nd = G.nodes[node]
118
352
  vf, dnfr, *_ = _node_state(nd)
119
353
  base = vf * dnfr
120
- gammas = [gm.get(n, 0.0) for gm in gamma_maps]
354
+ gammas = [gm.get(node, 0.0) for gm in gamma_maps]
121
355
 
122
356
  if method == "rk4":
123
- if len(gammas) != 4:
124
- raise ValueError("rk4 integration requires four gamma maps")
125
357
  k1, k2, k3, k4 = gammas
126
- increments[n] = (
358
+ increments[node] = (
127
359
  base + k1,
128
360
  base + k2,
129
361
  base + k3,
130
362
  base + k4,
131
363
  )
132
364
  else:
133
- if len(gammas) != 1:
134
- raise ValueError("euler integration requires one gamma map")
135
365
  (k1,) = gammas
136
- increments[n] = (base + k1,)
366
+ increments[node] = (base + k1,)
137
367
 
138
368
  return increments
139
369
 
140
370
 
141
371
  def _build_gamma_increments(
142
- G: Any,
372
+ G: TNFRGraph,
143
373
  dt_step: float,
144
374
  t_local: float,
145
375
  *,
146
376
  method: str,
147
- ) -> dict[Any, tuple[float, ...]]:
377
+ n_jobs: int | None = None,
378
+ ) -> NodeIncrements:
148
379
  """Evaluate Γ contributions and merge them with ``νf·ΔNFR`` base terms."""
149
380
 
150
381
  if method == "rk4":
@@ -163,50 +394,146 @@ def _build_gamma_increments(
163
394
  gamma_type = str(gamma_spec.get("type", "")).lower()
164
395
 
165
396
  if gamma_type == "none":
166
- gamma_maps = tuple({} for _ in range(gamma_count))
397
+ gamma_maps: tuple[GammaMap, ...] = tuple(
398
+ cast(GammaMap, {}) for _ in range(gamma_count)
399
+ )
400
+ return _collect_nodal_increments(G, gamma_maps, method=method)
401
+
402
+ nodes: list[NodeId] = list(G.nodes)
403
+ if not nodes:
404
+ gamma_maps = tuple(cast(GammaMap, {}) for _ in range(gamma_count))
167
405
  return _collect_nodal_increments(G, gamma_maps, method=method)
168
406
 
169
407
  if method == "rk4":
170
408
  t_mid = t_local + dt_step / 2.0
171
409
  t_end = t_local + dt_step
172
- g1_map = {n: eval_gamma(G, n, t_local) for n in G.nodes}
173
- g_mid_map = {n: eval_gamma(G, n, t_mid) for n in G.nodes}
174
- g4_map = {n: eval_gamma(G, n, t_end) for n in G.nodes}
410
+ g1_map = _evaluate_gamma_map(G, nodes, t_local, n_jobs=n_jobs)
411
+ g_mid_map = _evaluate_gamma_map(G, nodes, t_mid, n_jobs=n_jobs)
412
+ g4_map = _evaluate_gamma_map(G, nodes, t_end, n_jobs=n_jobs)
175
413
  gamma_maps = (g1_map, g_mid_map, g_mid_map, g4_map)
176
414
  else: # method == "euler"
177
- gamma_maps = ({n: eval_gamma(G, n, t_local) for n in G.nodes},)
415
+ gamma_maps = (_evaluate_gamma_map(G, nodes, t_local, n_jobs=n_jobs),)
178
416
 
179
417
  return _collect_nodal_increments(G, gamma_maps, method=method)
180
418
 
181
419
 
182
- def _integrate_euler(G, dt_step: float, t_local: float):
420
+ def _integrate_euler(
421
+ G: TNFRGraph,
422
+ dt_step: float,
423
+ t_local: float,
424
+ *,
425
+ n_jobs: int | None = None,
426
+ ) -> NodalUpdate:
183
427
  """One explicit Euler integration step."""
184
428
  increments = _build_gamma_increments(
185
429
  G,
186
430
  dt_step,
187
431
  t_local,
188
432
  method="euler",
433
+ n_jobs=n_jobs,
434
+ )
435
+ return _apply_increments(
436
+ G,
437
+ dt_step,
438
+ increments,
439
+ method="euler",
440
+ n_jobs=n_jobs,
189
441
  )
190
- return _apply_increments(G, dt_step, increments, method="euler")
191
442
 
192
443
 
193
- def _integrate_rk4(G, dt_step: float, t_local: float):
444
+ def _integrate_rk4(
445
+ G: TNFRGraph,
446
+ dt_step: float,
447
+ t_local: float,
448
+ *,
449
+ n_jobs: int | None = None,
450
+ ) -> NodalUpdate:
194
451
  """One Runge–Kutta order-4 integration step."""
195
452
  increments = _build_gamma_increments(
196
453
  G,
197
454
  dt_step,
198
455
  t_local,
199
456
  method="rk4",
457
+ n_jobs=n_jobs,
200
458
  )
201
- return _apply_increments(G, dt_step, increments, method="rk4")
459
+ return _apply_increments(
460
+ G,
461
+ dt_step,
462
+ increments,
463
+ method="rk4",
464
+ n_jobs=n_jobs,
465
+ )
466
+
467
+
468
+ class AbstractIntegrator(ABC):
469
+ """Abstract base class encapsulating nodal equation integration."""
470
+
471
+ @abstractmethod
472
+ def integrate(
473
+ self,
474
+ graph: TNFRGraph,
475
+ *,
476
+ dt: float | None,
477
+ t: float | None,
478
+ method: str | None,
479
+ n_jobs: int | None,
480
+ ) -> None:
481
+ """Advance ``graph`` coherence states according to the nodal equation."""
482
+
483
+
484
+ class DefaultIntegrator(AbstractIntegrator):
485
+ """Explicit integrator combining Euler and RK4 step implementations."""
486
+
487
+ def integrate(
488
+ self,
489
+ graph: TNFRGraph,
490
+ *,
491
+ dt: float | None,
492
+ t: float | None,
493
+ method: str | None,
494
+ n_jobs: int | None,
495
+ ) -> None:
496
+ """Integrate the nodal equation updating EPI, ΔEPI and Δ²EPI."""
497
+
498
+ if not isinstance(
499
+ graph, (nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph)
500
+ ):
501
+ raise TypeError("G must be a networkx graph instance")
502
+
503
+ dt_step, steps, t0, resolved_method = prepare_integration_params(
504
+ graph, dt, t, cast(IntegratorMethod | None, method)
505
+ )
506
+
507
+ t_local = t0
508
+ for _ in range(steps):
509
+ if resolved_method == "rk4":
510
+ updates: NodalUpdate = _integrate_rk4(
511
+ graph, dt_step, t_local, n_jobs=n_jobs
512
+ )
513
+ else:
514
+ updates = _integrate_euler(graph, dt_step, t_local, n_jobs=n_jobs)
515
+
516
+ for n, (epi, dEPI_dt, d2epi) in updates.items():
517
+ nd = graph.nodes[n]
518
+ epi_kind = get_attr_str(nd, ALIAS_EPI_KIND, "")
519
+ set_attr(nd, ALIAS_EPI, epi)
520
+ if epi_kind:
521
+ set_attr_str(nd, ALIAS_EPI_KIND, epi_kind)
522
+ set_attr(nd, ALIAS_DEPI, dEPI_dt)
523
+ set_attr(nd, ALIAS_D2EPI, d2epi)
524
+
525
+ t_local += dt_step
526
+
527
+ graph.graph["_t"] = t_local
202
528
 
203
529
 
204
530
  def update_epi_via_nodal_equation(
205
- G,
531
+ G: TNFRGraph,
206
532
  *,
207
533
  dt: float | None = None,
208
534
  t: float | None = None,
209
535
  method: Literal["euler", "rk4"] | None = None,
536
+ n_jobs: int | None = None,
210
537
  ) -> None:
211
538
  """TNFR nodal equation.
212
539
 
@@ -224,32 +551,13 @@ def update_epi_via_nodal_equation(
224
551
  TNFR references: nodal equation (manual), νf/ΔNFR/EPI glossary, Γ operator.
225
552
  Side effects: caches dEPI and updates EPI via explicit integration.
226
553
  """
227
- if not isinstance(
228
- G, (nx.Graph, nx.DiGraph, nx.MultiGraph, nx.MultiDiGraph)
229
- ):
230
- raise TypeError("G must be a networkx graph instance")
231
-
232
- dt_step, steps, t0, method = prepare_integration_params(G, dt, t, method)
233
-
234
- t_local = t0
235
- for _ in range(steps):
236
- if method == "rk4":
237
- updates = _integrate_rk4(G, dt_step, t_local)
238
- else:
239
- updates = _integrate_euler(G, dt_step, t_local)
240
-
241
- for n, (epi, dEPI_dt, d2epi) in updates.items():
242
- nd = G.nodes[n]
243
- epi_kind = get_attr_str(nd, ALIAS_EPI_KIND, "")
244
- set_attr(nd, ALIAS_EPI, epi)
245
- if epi_kind:
246
- set_attr_str(nd, ALIAS_EPI_KIND, epi_kind)
247
- set_attr(nd, ALIAS_DEPI, dEPI_dt)
248
- set_attr(nd, ALIAS_D2EPI, d2epi)
249
-
250
- t_local += dt_step
251
-
252
- G.graph["_t"] = t_local
554
+ DefaultIntegrator().integrate(
555
+ G,
556
+ dt=dt,
557
+ t=t,
558
+ method=method,
559
+ n_jobs=n_jobs,
560
+ )
253
561
 
254
562
 
255
563
  def _node_state(nd: dict[str, Any]) -> tuple[float, float, float, float]:
@@ -0,0 +1,34 @@
1
+ from typing import Literal
2
+
3
+ from tnfr.types import TNFRGraph
4
+
5
+ __all__: tuple[str, ...]
6
+
7
+ class AbstractIntegrator:
8
+ def integrate(
9
+ self,
10
+ graph: TNFRGraph,
11
+ *,
12
+ dt: float | None = ...,
13
+ t: float | None = ...,
14
+ method: str | None = ...,
15
+ n_jobs: int | None = ...,
16
+ ) -> None: ...
17
+
18
+ class DefaultIntegrator(AbstractIntegrator):
19
+ def __init__(self) -> None: ...
20
+
21
+ def prepare_integration_params(
22
+ G: TNFRGraph,
23
+ dt: float | None = ...,
24
+ t: float | None = ...,
25
+ method: Literal["euler", "rk4"] | None = ...,
26
+ ) -> tuple[float, int, float, Literal["euler", "rk4"]]: ...
27
+ def update_epi_via_nodal_equation(
28
+ G: TNFRGraph,
29
+ *,
30
+ dt: float | None = ...,
31
+ t: float | None = ...,
32
+ method: Literal["euler", "rk4"] | None = ...,
33
+ n_jobs: int | None = ...,
34
+ ) -> None: ...