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/metrics/diagnosis.py CHANGED
@@ -2,47 +2,305 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- from statistics import fmean, StatisticsError
5
+ import math
6
+ from collections import deque
7
+ from collections.abc import Mapping, MutableMapping, Sequence
8
+ from concurrent.futures import ProcessPoolExecutor
9
+ from dataclasses import dataclass
10
+ from functools import partial
6
11
  from operator import ge, le
7
- from typing import Any
12
+ from statistics import StatisticsError, fmean
13
+ from typing import Any, Callable, Iterable, cast
8
14
 
15
+ from ..alias import get_attr
16
+ from ..callback_utils import CallbackEvent, callback_manager
9
17
  from ..constants import (
18
+ STATE_DISSONANT,
19
+ STATE_STABLE,
20
+ STATE_TRANSITION,
10
21
  VF_KEY,
11
- get_aliases,
12
22
  get_param,
23
+ normalise_state_token,
13
24
  )
14
- from ..callback_utils import CallbackEvent, callback_manager
15
- from ..glyph_history import ensure_history, append_metric
16
- from ..alias import get_attr
17
- from ..helpers.numeric import clamp01, similarity_abs
18
- from .common import compute_dnfr_accel_max, min_max_range, normalize_dnfr
19
- from .coherence import (
20
- local_phase_sync,
21
- local_phase_sync_weighted,
25
+ from ..constants.aliases import ALIAS_DNFR, ALIAS_EPI, ALIAS_SI, ALIAS_VF
26
+ from ..glyph_history import append_metric, ensure_history
27
+ from ..utils import clamp01, resolve_chunk_size, similarity_abs
28
+ from ..types import (
29
+ DiagnosisNodeData,
30
+ DiagnosisPayload,
31
+ DiagnosisPayloadChunk,
32
+ DiagnosisResult,
33
+ DiagnosisResultList,
34
+ DiagnosisSharedState,
35
+ NodeId,
36
+ TNFRGraph,
22
37
  )
23
-
24
- ALIAS_EPI = get_aliases("EPI")
25
- ALIAS_VF = get_aliases("VF")
26
- ALIAS_SI = get_aliases("SI")
27
-
28
- def _symmetry_index(
29
- G, n, epi_min: float | None = None, epi_max: float | None = None
30
- ):
31
- """Compute the symmetry index for node ``n`` based on EPI values."""
32
- nd = G.nodes[n]
33
- epi_i = get_attr(nd, ALIAS_EPI, 0.0)
34
- vec = G.neighbors(n)
35
- try:
36
- epi_bar = fmean(get_attr(G.nodes[v], ALIAS_EPI, epi_i) for v in vec)
37
- except StatisticsError:
38
- return 1.0
39
- if epi_min is None or epi_max is None:
40
- epi_iter = (get_attr(G.nodes[v], ALIAS_EPI, 0.0) for v in G.nodes())
41
- epi_min, epi_max = min_max_range(epi_iter, default=(0.0, 1.0))
42
- return similarity_abs(epi_i, epi_bar, epi_min, epi_max)
43
-
44
-
45
- def _state_from_thresholds(Rloc, dnfr_n, cfg):
38
+ from ..utils import get_numpy
39
+ from .coherence import CoherenceMatrixPayload, coherence_matrix, local_phase_sync
40
+ from .common import (
41
+ _coerce_jobs,
42
+ compute_dnfr_accel_max,
43
+ min_max_range,
44
+ normalize_dnfr,
45
+ )
46
+ from .trig_cache import compute_theta_trig, get_trig_cache
47
+
48
+ CoherenceSeries = Sequence[CoherenceMatrixPayload | None]
49
+ CoherenceHistory = Mapping[str, CoherenceSeries]
50
+ def _coherence_matrix_to_numpy(
51
+ weight_matrix: Any,
52
+ size: int,
53
+ np_mod: Any,
54
+ ) -> Any:
55
+ """Convert stored coherence weights into a dense NumPy array."""
56
+
57
+ if weight_matrix is None or np_mod is None or size <= 0:
58
+ return None
59
+
60
+ ndarray_type: Any = getattr(np_mod, "ndarray", tuple())
61
+ if ndarray_type and isinstance(weight_matrix, ndarray_type):
62
+ matrix = weight_matrix.astype(float, copy=True)
63
+ elif isinstance(weight_matrix, (list, tuple)):
64
+ weight_seq = list(weight_matrix)
65
+ if not weight_seq:
66
+ matrix = np_mod.zeros((size, size), dtype=float)
67
+ else:
68
+ first = weight_seq[0]
69
+ if isinstance(first, (list, tuple)) and len(first) == size:
70
+ matrix = np_mod.array(weight_seq, dtype=float)
71
+ elif (
72
+ isinstance(first, (list, tuple))
73
+ and len(first) == 3
74
+ and not isinstance(first[0], (list, tuple))
75
+ ):
76
+ matrix = np_mod.zeros((size, size), dtype=float)
77
+ for i, j, weight in weight_seq:
78
+ matrix[int(i), int(j)] = float(weight)
79
+ else:
80
+ return None
81
+ else:
82
+ return None
83
+
84
+ if matrix.shape != (size, size):
85
+ return None
86
+ np_mod.fill_diagonal(matrix, 0.0)
87
+ return matrix
88
+
89
+
90
+ def _weighted_phase_sync_vectorized(
91
+ matrix: Any,
92
+ cos_vals: Any,
93
+ sin_vals: Any,
94
+ np_mod: Any,
95
+ ) -> Any:
96
+ """Vectorised computation of weighted local phase synchrony."""
97
+
98
+ denom = np_mod.sum(matrix, axis=1)
99
+ if np_mod.all(denom == 0.0):
100
+ return np_mod.zeros_like(denom, dtype=float)
101
+ real = matrix @ cos_vals
102
+ imag = matrix @ sin_vals
103
+ magnitude = np_mod.hypot(real, imag)
104
+ safe_denom = np_mod.where(denom == 0.0, 1.0, denom)
105
+ return magnitude / safe_denom
106
+
107
+
108
+ def _unweighted_phase_sync_vectorized(
109
+ nodes: Sequence[Any],
110
+ neighbors_map: Mapping[Any, tuple[Any, ...]],
111
+ cos_arr: Any,
112
+ sin_arr: Any,
113
+ index_map: Mapping[Any, int],
114
+ np_mod: Any,
115
+ ) -> list[float]:
116
+ """Compute unweighted phase synchrony using NumPy helpers."""
117
+
118
+ results: list[float] = []
119
+ for node in nodes:
120
+ neighbors = neighbors_map.get(node, ())
121
+ if not neighbors:
122
+ results.append(0.0)
123
+ continue
124
+ indices = [index_map[nb] for nb in neighbors if nb in index_map]
125
+ if not indices:
126
+ results.append(0.0)
127
+ continue
128
+ cos_vals = np_mod.take(cos_arr, indices)
129
+ sin_vals = np_mod.take(sin_arr, indices)
130
+ real = np_mod.sum(cos_vals)
131
+ imag = np_mod.sum(sin_vals)
132
+ denom = float(len(indices))
133
+ if denom == 0.0:
134
+ results.append(0.0)
135
+ else:
136
+ results.append(float(np_mod.hypot(real, imag) / denom))
137
+ return results
138
+
139
+
140
+ def _neighbor_means_vectorized(
141
+ nodes: Sequence[Any],
142
+ neighbors_map: Mapping[Any, tuple[Any, ...]],
143
+ epi_arr: Any,
144
+ index_map: Mapping[Any, int],
145
+ np_mod: Any,
146
+ ) -> list[float | None]:
147
+ """Vectorized helper to compute neighbour EPI means."""
148
+
149
+ results: list[float | None] = []
150
+ for node in nodes:
151
+ neighbors = neighbors_map.get(node, ())
152
+ if not neighbors:
153
+ results.append(None)
154
+ continue
155
+ indices = [index_map[nb] for nb in neighbors if nb in index_map]
156
+ if not indices:
157
+ results.append(None)
158
+ continue
159
+ values = np_mod.take(epi_arr, indices)
160
+ results.append(float(np_mod.mean(values)))
161
+ return results
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class RLocalWorkerArgs:
166
+ """Typed payload passed to :func:`_rlocal_worker`."""
167
+
168
+ chunk: Sequence[Any]
169
+ coherence_nodes: Sequence[Any]
170
+ weight_matrix: Any
171
+ weight_index: Mapping[Any, int]
172
+ neighbors_map: Mapping[Any, tuple[Any, ...]]
173
+ cos_map: Mapping[Any, float]
174
+ sin_map: Mapping[Any, float]
175
+
176
+
177
+ @dataclass(frozen=True)
178
+ class NeighborMeanWorkerArgs:
179
+ """Typed payload passed to :func:`_neighbor_mean_worker`."""
180
+
181
+ chunk: Sequence[Any]
182
+ neighbors_map: Mapping[Any, tuple[Any, ...]]
183
+ epi_map: Mapping[Any, float]
184
+
185
+
186
+ def _rlocal_worker(args: RLocalWorkerArgs) -> list[float]:
187
+ """Worker used to compute ``R_local`` in Python fallbacks."""
188
+
189
+ results: list[float] = []
190
+ for node in args.chunk:
191
+ if args.coherence_nodes and args.weight_matrix is not None:
192
+ idx = args.weight_index.get(node)
193
+ if idx is None:
194
+ rloc = 0.0
195
+ else:
196
+ rloc = _weighted_phase_sync_from_matrix(
197
+ idx,
198
+ node,
199
+ args.coherence_nodes,
200
+ args.weight_matrix,
201
+ args.cos_map,
202
+ args.sin_map,
203
+ )
204
+ else:
205
+ rloc = _local_phase_sync_unweighted(
206
+ args.neighbors_map.get(node, ()),
207
+ args.cos_map,
208
+ args.sin_map,
209
+ )
210
+ results.append(float(rloc))
211
+ return results
212
+
213
+
214
+ def _neighbor_mean_worker(args: NeighborMeanWorkerArgs) -> list[float | None]:
215
+ """Worker used to compute neighbour EPI means in Python mode."""
216
+
217
+ results: list[float | None] = []
218
+ for node in args.chunk:
219
+ neighbors = args.neighbors_map.get(node, ())
220
+ if not neighbors:
221
+ results.append(None)
222
+ continue
223
+ try:
224
+ results.append(fmean(args.epi_map[nb] for nb in neighbors))
225
+ except StatisticsError:
226
+ results.append(None)
227
+ return results
228
+
229
+
230
+ def _weighted_phase_sync_from_matrix(
231
+ node_index: int,
232
+ node: Any,
233
+ nodes_order: Sequence[Any],
234
+ matrix: Any,
235
+ cos_map: Mapping[Any, float],
236
+ sin_map: Mapping[Any, float],
237
+ ) -> float:
238
+ """Compute weighted phase synchrony using a cached matrix."""
239
+
240
+ if matrix is None or not nodes_order:
241
+ return 0.0
242
+
243
+ num = 0.0 + 0.0j
244
+ den = 0.0
245
+
246
+ if isinstance(matrix, list) and matrix and isinstance(matrix[0], list):
247
+ row = matrix[node_index]
248
+ for weight, neighbor in zip(row, nodes_order):
249
+ if neighbor == node:
250
+ continue
251
+ w = float(weight)
252
+ if w == 0.0:
253
+ continue
254
+ cos_j = cos_map.get(neighbor)
255
+ sin_j = sin_map.get(neighbor)
256
+ if cos_j is None or sin_j is None:
257
+ continue
258
+ den += w
259
+ num += w * complex(cos_j, sin_j)
260
+ else:
261
+ for ii, jj, weight in matrix:
262
+ if ii != node_index:
263
+ continue
264
+ neighbor = nodes_order[jj]
265
+ if neighbor == node:
266
+ continue
267
+ w = float(weight)
268
+ if w == 0.0:
269
+ continue
270
+ cos_j = cos_map.get(neighbor)
271
+ sin_j = sin_map.get(neighbor)
272
+ if cos_j is None or sin_j is None:
273
+ continue
274
+ den += w
275
+ num += w * complex(cos_j, sin_j)
276
+
277
+ return abs(num / den) if den else 0.0
278
+
279
+
280
+ def _local_phase_sync_unweighted(
281
+ neighbors: Iterable[Any],
282
+ cos_map: Mapping[Any, float],
283
+ sin_map: Mapping[Any, float],
284
+ ) -> float:
285
+ """Fallback unweighted phase synchrony based on neighbours."""
286
+
287
+ num = 0.0 + 0.0j
288
+ den = 0.0
289
+ for neighbor in neighbors:
290
+ cos_j = cos_map.get(neighbor)
291
+ sin_j = sin_map.get(neighbor)
292
+ if cos_j is None or sin_j is None:
293
+ continue
294
+ num += complex(cos_j, sin_j)
295
+ den += 1.0
296
+ return abs(num / den) if den else 0.0
297
+
298
+
299
+ def _state_from_thresholds(
300
+ Rloc: float,
301
+ dnfr_n: float,
302
+ cfg: Mapping[str, Any],
303
+ ) -> str:
46
304
  stb = cfg.get("stable", {"Rloc_hi": 0.8, "dnfr_lo": 0.2, "persist": 3})
47
305
  dsr = cfg.get("dissonance", {"Rloc_lo": 0.4, "dnfr_hi": 0.5, "persist": 3})
48
306
 
@@ -51,29 +309,28 @@ def _state_from_thresholds(Rloc, dnfr_n, cfg):
51
309
  "dnfr": (dnfr_n, float(stb["dnfr_lo"]), le),
52
310
  }
53
311
  if all(comp(val, thr) for val, thr, comp in stable_checks.values()):
54
- return "estable"
312
+ return STATE_STABLE
55
313
 
56
314
  dissonant_checks = {
57
315
  "Rloc": (Rloc, float(dsr["Rloc_lo"]), le),
58
316
  "dnfr": (dnfr_n, float(dsr["dnfr_hi"]), ge),
59
317
  }
60
318
  if all(comp(val, thr) for val, thr, comp in dissonant_checks.values()):
61
- return "disonante"
319
+ return STATE_DISSONANT
62
320
 
63
- return "transicion"
321
+ return STATE_TRANSITION
64
322
 
65
323
 
66
- def _recommendation(state, cfg):
324
+ def _recommendation(state: str, cfg: Mapping[str, Any]) -> list[Any]:
67
325
  adv = cfg.get("advice", {})
68
- key = {
69
- "estable": "stable",
70
- "transicion": "transition",
71
- "disonante": "dissonant",
72
- }[state]
73
- return list(adv.get(key, []))
326
+ canonical_state = normalise_state_token(state)
327
+ return list(adv.get(canonical_state, []))
74
328
 
75
329
 
76
- def _get_last_weights(G, hist):
330
+ def _get_last_weights(
331
+ G: TNFRGraph,
332
+ hist: CoherenceHistory,
333
+ ) -> tuple[CoherenceMatrixPayload | None, CoherenceMatrixPayload | None]:
77
334
  """Return last Wi and Wm matrices from history."""
78
335
  CfgW = get_param(G, "COHERENCE")
79
336
  Wkey = CfgW.get("Wi_history_key", "W_i")
@@ -86,105 +343,440 @@ def _get_last_weights(G, hist):
86
343
 
87
344
 
88
345
  def _node_diagnostics(
89
- G,
90
- n,
91
- i,
92
- nodes,
93
- node_to_index,
94
- Wi_last,
95
- Wm_last,
96
- epi_min,
97
- epi_max,
98
- dnfr_max,
99
- dcfg,
100
- ):
101
- nd = G.nodes[n]
102
- Si = clamp01(get_attr(nd, ALIAS_SI, 0.0))
103
- EPI = get_attr(nd, ALIAS_EPI, 0.0)
104
- vf = get_attr(nd, ALIAS_VF, 0.0)
105
- dnfr_n = normalize_dnfr(nd, dnfr_max)
106
-
107
- if Wm_last is not None:
108
- if Wm_last and isinstance(Wm_last[0], list):
109
- row = Wm_last[i]
110
- else:
111
- row = Wm_last
112
- Rloc = local_phase_sync_weighted(
113
- G, n, nodes_order=nodes, W_row=row, node_to_index=node_to_index
346
+ node_data: DiagnosisNodeData,
347
+ shared: DiagnosisSharedState,
348
+ ) -> DiagnosisResult:
349
+ """Compute diagnostic payload for a single node."""
350
+
351
+ dcfg = shared["dcfg"]
352
+ compute_symmetry = shared["compute_symmetry"]
353
+ epi_min = shared["epi_min"]
354
+ epi_max = shared["epi_max"]
355
+
356
+ node = node_data["node"]
357
+ Si = clamp01(float(node_data["Si"]))
358
+ EPI = float(node_data["EPI"])
359
+ vf = float(node_data["VF"])
360
+ dnfr_n = clamp01(float(node_data["dnfr_norm"]))
361
+ Rloc = float(node_data["R_local"])
362
+
363
+ if compute_symmetry:
364
+ epi_bar = node_data.get("neighbor_epi_mean")
365
+ symm = (
366
+ 1.0 if epi_bar is None else similarity_abs(EPI, epi_bar, epi_min, epi_max)
114
367
  )
115
368
  else:
116
- Rloc = local_phase_sync(G, n)
369
+ symm = None
117
370
 
118
- symm = (
119
- _symmetry_index(G, n, epi_min=epi_min, epi_max=epi_max)
120
- if dcfg.get("compute_symmetry", True)
121
- else None
122
- )
123
371
  state = _state_from_thresholds(Rloc, dnfr_n, dcfg)
372
+ canonical_state = normalise_state_token(state)
124
373
 
125
374
  alerts = []
126
- if state == "disonante" and dnfr_n >= float(
127
- dcfg.get("dissonance", {}).get("dnfr_hi", 0.5)
128
- ):
375
+ if canonical_state == STATE_DISSONANT and dnfr_n >= shared["dissonance_hi"]:
129
376
  alerts.append("high structural tension")
130
377
 
131
- advice = _recommendation(state, dcfg)
378
+ advice = _recommendation(canonical_state, dcfg)
132
379
 
133
- return {
134
- "node": n,
380
+ payload: DiagnosisPayload = {
381
+ "node": node,
135
382
  "Si": Si,
136
383
  "EPI": EPI,
137
384
  VF_KEY: vf,
138
385
  "dnfr_norm": dnfr_n,
139
- "W_i": (Wi_last[i] if (Wi_last and i < len(Wi_last)) else None),
386
+ "W_i": node_data.get("W_i"),
140
387
  "R_local": Rloc,
141
388
  "symmetry": symm,
142
- "state": state,
389
+ "state": canonical_state,
143
390
  "advice": advice,
144
391
  "alerts": alerts,
145
392
  }
146
393
 
394
+ return node, payload
395
+
396
+
397
+ def _diagnosis_worker_chunk(
398
+ chunk: DiagnosisPayloadChunk,
399
+ shared: DiagnosisSharedState,
400
+ ) -> DiagnosisResultList:
401
+ """Evaluate diagnostics for a chunk of nodes."""
402
+
403
+ return [_node_diagnostics(item, shared) for item in chunk]
147
404
 
148
- def _diagnosis_step(G, ctx: dict[str, Any] | None = None):
405
+
406
+ def _diagnosis_step(
407
+ G: TNFRGraph,
408
+ ctx: DiagnosisSharedState | None = None,
409
+ *,
410
+ n_jobs: int | None = None,
411
+ ) -> None:
149
412
  del ctx
150
413
 
414
+ if n_jobs is None:
415
+ n_jobs = _coerce_jobs(G.graph.get("DIAGNOSIS_N_JOBS"))
416
+ else:
417
+ n_jobs = _coerce_jobs(n_jobs)
418
+
151
419
  dcfg = get_param(G, "DIAGNOSIS")
152
420
  if not dcfg.get("enabled", True):
153
421
  return
154
422
 
155
423
  hist = ensure_history(G)
424
+ coherence_hist = cast(CoherenceHistory, hist)
156
425
  key = dcfg.get("history_key", "nodal_diag")
157
426
 
427
+ existing_diag_history = hist.get(key, [])
428
+ if isinstance(existing_diag_history, deque):
429
+ snapshots = list(existing_diag_history)
430
+ elif isinstance(existing_diag_history, list):
431
+ snapshots = existing_diag_history
432
+ else:
433
+ snapshots = []
434
+
435
+ for snapshot in snapshots:
436
+ if not isinstance(snapshot, Mapping):
437
+ continue
438
+ for node, payload in snapshot.items():
439
+ if not isinstance(payload, Mapping):
440
+ continue
441
+ state_value = payload.get("state")
442
+ if not isinstance(state_value, str):
443
+ continue
444
+ canonical = normalise_state_token(state_value)
445
+ if canonical == state_value:
446
+ continue
447
+ if isinstance(payload, MutableMapping):
448
+ payload["state"] = canonical
449
+ elif isinstance(snapshot, MutableMapping):
450
+ new_payload = dict(payload)
451
+ new_payload["state"] = canonical
452
+ snapshot[node] = new_payload
453
+
158
454
  norms = compute_dnfr_accel_max(G)
159
455
  G.graph["_sel_norms"] = norms
160
456
  dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
161
- epi_iter = (get_attr(nd, ALIAS_EPI, 0.0) for _, nd in G.nodes(data=True))
162
- epi_min, epi_max = min_max_range(epi_iter, default=(0.0, 1.0))
163
-
164
- Wi_last, Wm_last = _get_last_weights(G, hist)
165
-
166
- nodes = list(G.nodes())
167
- node_to_index = {v: i for i, v in enumerate(nodes)}
168
- diag = {}
169
- for i, n in enumerate(nodes):
170
- diag[n] = _node_diagnostics(
171
- G,
172
- n,
173
- i,
174
- nodes,
175
- node_to_index,
176
- Wi_last,
177
- Wm_last,
178
- epi_min,
179
- epi_max,
180
- dnfr_max,
181
- dcfg,
457
+
458
+ nodes_data: list[tuple[NodeId, dict[str, Any]]] = list(G.nodes(data=True))
459
+ nodes: list[NodeId] = [n for n, _ in nodes_data]
460
+
461
+ Wi_last, Wm_last = _get_last_weights(G, coherence_hist)
462
+
463
+ np_mod = get_numpy()
464
+ supports_vector = bool(
465
+ np_mod is not None
466
+ and all(
467
+ hasattr(np_mod, attr)
468
+ for attr in (
469
+ "fromiter",
470
+ "clip",
471
+ "abs",
472
+ "maximum",
473
+ "minimum",
474
+ "array",
475
+ "zeros",
476
+ "zeros_like",
477
+ "sum",
478
+ "hypot",
479
+ "where",
480
+ "take",
481
+ "mean",
482
+ "fill_diagonal",
483
+ "all",
484
+ )
485
+ )
486
+ )
487
+
488
+ if not nodes:
489
+ append_metric(hist, key, {})
490
+ return
491
+
492
+ rloc_values: list[float]
493
+
494
+ if supports_vector:
495
+ epi_arr = np_mod.fromiter(
496
+ (cast(float, get_attr(nd, ALIAS_EPI, 0.0)) for _, nd in nodes_data),
497
+ dtype=float,
498
+ count=len(nodes_data),
499
+ )
500
+ epi_min = float(np_mod.min(epi_arr))
501
+ epi_max = float(np_mod.max(epi_arr))
502
+ epi_vals = epi_arr.tolist()
503
+
504
+ si_arr = np_mod.clip(
505
+ np_mod.fromiter(
506
+ (cast(float, get_attr(nd, ALIAS_SI, 0.0)) for _, nd in nodes_data),
507
+ dtype=float,
508
+ count=len(nodes_data),
509
+ ),
510
+ 0.0,
511
+ 1.0,
182
512
  )
513
+ si_vals = si_arr.tolist()
514
+
515
+ vf_arr = np_mod.fromiter(
516
+ (cast(float, get_attr(nd, ALIAS_VF, 0.0)) for _, nd in nodes_data),
517
+ dtype=float,
518
+ count=len(nodes_data),
519
+ )
520
+ vf_vals = vf_arr.tolist()
521
+
522
+ if dnfr_max > 0:
523
+ dnfr_arr = np_mod.clip(
524
+ np_mod.fromiter(
525
+ (
526
+ abs(cast(float, get_attr(nd, ALIAS_DNFR, 0.0)))
527
+ for _, nd in nodes_data
528
+ ),
529
+ dtype=float,
530
+ count=len(nodes_data),
531
+ )
532
+ / dnfr_max,
533
+ 0.0,
534
+ 1.0,
535
+ )
536
+ dnfr_norms = dnfr_arr.tolist()
537
+ else:
538
+ dnfr_norms = [0.0] * len(nodes)
539
+ else:
540
+ epi_vals = [cast(float, get_attr(nd, ALIAS_EPI, 0.0)) for _, nd in nodes_data]
541
+ epi_min, epi_max = min_max_range(epi_vals, default=(0.0, 1.0))
542
+ si_vals = [clamp01(get_attr(nd, ALIAS_SI, 0.0)) for _, nd in nodes_data]
543
+ vf_vals = [cast(float, get_attr(nd, ALIAS_VF, 0.0)) for _, nd in nodes_data]
544
+ dnfr_norms = [
545
+ normalize_dnfr(nd, dnfr_max) if dnfr_max > 0 else 0.0
546
+ for _, nd in nodes_data
547
+ ]
548
+
549
+ epi_map = {node: epi_vals[idx] for idx, node in enumerate(nodes)}
550
+
551
+ trig_cache = get_trig_cache(G, np=np_mod)
552
+ trig_local = compute_theta_trig(nodes_data, np=np_mod)
553
+ cos_map = dict(trig_cache.cos)
554
+ sin_map = dict(trig_cache.sin)
555
+ cos_map.update(trig_local.cos)
556
+ sin_map.update(trig_local.sin)
557
+
558
+ neighbors_map = {n: tuple(G.neighbors(n)) for n in nodes}
559
+
560
+ if Wm_last is None:
561
+ coherence_nodes, weight_matrix = coherence_matrix(G)
562
+ if coherence_nodes is None:
563
+ coherence_nodes = []
564
+ weight_matrix = None
565
+ else:
566
+ coherence_nodes = list(nodes)
567
+ weight_matrix = Wm_last
568
+
569
+ coherence_nodes = list(coherence_nodes)
570
+ weight_index = {node: idx for idx, node in enumerate(coherence_nodes)}
571
+
572
+ node_index_map: dict[Any, int] | None = None
573
+
574
+ if supports_vector:
575
+ size = len(coherence_nodes)
576
+ matrix_np = (
577
+ _coherence_matrix_to_numpy(weight_matrix, size, np_mod) if size else None
578
+ )
579
+ if matrix_np is not None and size:
580
+ cos_weight = np_mod.fromiter(
581
+ (float(cos_map.get(node, 0.0)) for node in coherence_nodes),
582
+ dtype=float,
583
+ count=size,
584
+ )
585
+ sin_weight = np_mod.fromiter(
586
+ (float(sin_map.get(node, 0.0)) for node in coherence_nodes),
587
+ dtype=float,
588
+ count=size,
589
+ )
590
+ weighted_sync = _weighted_phase_sync_vectorized(
591
+ matrix_np,
592
+ cos_weight,
593
+ sin_weight,
594
+ np_mod,
595
+ )
596
+ rloc_map = {
597
+ coherence_nodes[idx]: float(weighted_sync[idx]) for idx in range(size)
598
+ }
599
+ else:
600
+ rloc_map = {}
601
+
602
+ node_index_map = {node: idx for idx, node in enumerate(nodes)}
603
+ if not rloc_map:
604
+ cos_arr = np_mod.fromiter(
605
+ (float(cos_map.get(node, 0.0)) for node in nodes),
606
+ dtype=float,
607
+ count=len(nodes),
608
+ )
609
+ sin_arr = np_mod.fromiter(
610
+ (float(sin_map.get(node, 0.0)) for node in nodes),
611
+ dtype=float,
612
+ count=len(nodes),
613
+ )
614
+ rloc_values = _unweighted_phase_sync_vectorized(
615
+ nodes,
616
+ neighbors_map,
617
+ cos_arr,
618
+ sin_arr,
619
+ node_index_map,
620
+ np_mod,
621
+ )
622
+ else:
623
+ rloc_values = [rloc_map.get(node, 0.0) for node in nodes]
624
+ else:
625
+ if n_jobs and n_jobs > 1 and len(nodes) > 1:
626
+ approx_chunk = math.ceil(len(nodes) / n_jobs) if n_jobs else None
627
+ chunk_size = resolve_chunk_size(
628
+ approx_chunk,
629
+ len(nodes),
630
+ minimum=1,
631
+ )
632
+ rloc_values = []
633
+ with ProcessPoolExecutor(max_workers=n_jobs) as executor:
634
+ futures = [
635
+ executor.submit(
636
+ _rlocal_worker,
637
+ RLocalWorkerArgs(
638
+ chunk=nodes[idx : idx + chunk_size],
639
+ coherence_nodes=coherence_nodes,
640
+ weight_matrix=weight_matrix,
641
+ weight_index=weight_index,
642
+ neighbors_map=neighbors_map,
643
+ cos_map=cos_map,
644
+ sin_map=sin_map,
645
+ ),
646
+ )
647
+ for idx in range(0, len(nodes), chunk_size)
648
+ ]
649
+ for fut in futures:
650
+ rloc_values.extend(fut.result())
651
+ else:
652
+ rloc_values = _rlocal_worker(
653
+ RLocalWorkerArgs(
654
+ chunk=nodes,
655
+ coherence_nodes=coherence_nodes,
656
+ weight_matrix=weight_matrix,
657
+ weight_index=weight_index,
658
+ neighbors_map=neighbors_map,
659
+ cos_map=cos_map,
660
+ sin_map=sin_map,
661
+ )
662
+ )
663
+
664
+ if isinstance(Wi_last, (list, tuple)) and Wi_last:
665
+ wi_values = [
666
+ Wi_last[i] if i < len(Wi_last) else None for i in range(len(nodes))
667
+ ]
668
+ else:
669
+ wi_values = [None] * len(nodes)
670
+
671
+ compute_symmetry = bool(dcfg.get("compute_symmetry", True))
672
+ neighbor_means: list[float | None]
673
+ if compute_symmetry:
674
+ if supports_vector and node_index_map is not None and len(nodes):
675
+ neighbor_means = _neighbor_means_vectorized(
676
+ nodes,
677
+ neighbors_map,
678
+ epi_arr,
679
+ node_index_map,
680
+ np_mod,
681
+ )
682
+ elif n_jobs and n_jobs > 1 and len(nodes) > 1:
683
+ approx_chunk = math.ceil(len(nodes) / n_jobs) if n_jobs else None
684
+ chunk_size = resolve_chunk_size(
685
+ approx_chunk,
686
+ len(nodes),
687
+ minimum=1,
688
+ )
689
+ neighbor_means = cast(list[float | None], [])
690
+ with ProcessPoolExecutor(max_workers=n_jobs) as executor:
691
+ submit = cast(Callable[..., Any], executor.submit)
692
+ futures = [
693
+ submit(
694
+ cast(
695
+ Callable[[NeighborMeanWorkerArgs], list[float | None]],
696
+ _neighbor_mean_worker,
697
+ ),
698
+ NeighborMeanWorkerArgs(
699
+ chunk=nodes[idx : idx + chunk_size],
700
+ neighbors_map=neighbors_map,
701
+ epi_map=epi_map,
702
+ ),
703
+ )
704
+ for idx in range(0, len(nodes), chunk_size)
705
+ ]
706
+ for fut in futures:
707
+ neighbor_means.extend(cast(list[float | None], fut.result()))
708
+ else:
709
+ neighbor_means = _neighbor_mean_worker(
710
+ NeighborMeanWorkerArgs(
711
+ chunk=nodes,
712
+ neighbors_map=neighbors_map,
713
+ epi_map=epi_map,
714
+ )
715
+ )
716
+ else:
717
+ neighbor_means = [None] * len(nodes)
718
+
719
+ node_payload: DiagnosisPayloadChunk = []
720
+ for idx, node in enumerate(nodes):
721
+ node_payload.append(
722
+ {
723
+ "node": node,
724
+ "Si": si_vals[idx],
725
+ "EPI": epi_vals[idx],
726
+ "VF": vf_vals[idx],
727
+ "dnfr_norm": dnfr_norms[idx],
728
+ "R_local": rloc_values[idx],
729
+ "W_i": wi_values[idx],
730
+ "neighbor_epi_mean": neighbor_means[idx],
731
+ }
732
+ )
733
+
734
+ shared = {
735
+ "dcfg": dcfg,
736
+ "compute_symmetry": compute_symmetry,
737
+ "epi_min": float(epi_min),
738
+ "epi_max": float(epi_max),
739
+ "dissonance_hi": float(dcfg.get("dissonance", {}).get("dnfr_hi", 0.5)),
740
+ }
741
+
742
+ if n_jobs and n_jobs > 1 and len(node_payload) > 1:
743
+ approx_chunk = math.ceil(len(node_payload) / n_jobs) if n_jobs else None
744
+ chunk_size = resolve_chunk_size(
745
+ approx_chunk,
746
+ len(node_payload),
747
+ minimum=1,
748
+ )
749
+ diag_pairs: DiagnosisResultList = []
750
+ with ProcessPoolExecutor(max_workers=n_jobs) as executor:
751
+ submit = cast(Callable[..., Any], executor.submit)
752
+ futures = [
753
+ submit(
754
+ cast(
755
+ Callable[
756
+ [list[dict[str, Any]], dict[str, Any]],
757
+ list[tuple[Any, dict[str, Any]]],
758
+ ],
759
+ _diagnosis_worker_chunk,
760
+ ),
761
+ node_payload[idx : idx + chunk_size],
762
+ shared,
763
+ )
764
+ for idx in range(0, len(node_payload), chunk_size)
765
+ ]
766
+ for fut in futures:
767
+ diag_pairs.extend(cast(DiagnosisResultList, fut.result()))
768
+ else:
769
+ diag_pairs = [_node_diagnostics(item, shared) for item in node_payload]
770
+
771
+ diag_map = dict(diag_pairs)
772
+ diag: dict[NodeId, DiagnosisPayload] = {
773
+ node: diag_map.get(node, {}) for node in nodes
774
+ }
183
775
 
184
776
  append_metric(hist, key, diag)
185
777
 
186
778
 
187
- def dissonance_events(G, ctx: dict[str, Any] | None = None):
779
+ def dissonance_events(G: TNFRGraph, ctx: DiagnosisSharedState | None = None) -> None:
188
780
  """Emit per-node structural dissonance start/end events.
189
781
 
190
782
  Events are recorded as ``"dissonance_start"`` and ``"dissonance_end"``.
@@ -193,11 +785,11 @@ def dissonance_events(G, ctx: dict[str, Any] | None = None):
193
785
  del ctx
194
786
 
195
787
  hist = ensure_history(G)
196
- # eventos de disonancia se registran en ``history['events']``
788
+ # Dissonance events are recorded in ``history['events']``
197
789
  norms = G.graph.get("_sel_norms", {})
198
790
  dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
199
791
  step_idx = len(hist.get("C_steps", []))
200
- nodes = list(G.nodes())
792
+ nodes: list[NodeId] = list(G.nodes())
201
793
  for n in nodes:
202
794
  nd = G.nodes[n]
203
795
  dn = normalize_dnfr(nd, dnfr_max)
@@ -219,11 +811,16 @@ def dissonance_events(G, ctx: dict[str, Any] | None = None):
219
811
  )
220
812
 
221
813
 
222
- def register_diagnosis_callbacks(G) -> None:
814
+ def register_diagnosis_callbacks(G: TNFRGraph) -> None:
815
+ """Attach diagnosis observers (Si/dissonance tracking) to ``G``."""
816
+
817
+ raw_jobs = G.graph.get("DIAGNOSIS_N_JOBS")
818
+ n_jobs = _coerce_jobs(raw_jobs)
819
+
223
820
  callback_manager.register_callback(
224
821
  G,
225
822
  event=CallbackEvent.AFTER_STEP.value,
226
- func=_diagnosis_step,
823
+ func=partial(_diagnosis_step, n_jobs=n_jobs),
227
824
  name="diagnosis_step",
228
825
  )
229
826
  callback_manager.register_callback(