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