tnfr 4.5.1__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 (170) hide show
  1. tnfr/__init__.py +270 -90
  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 +631 -0
  7. tnfr/alias.pyi +140 -0
  8. tnfr/cache.py +732 -0
  9. tnfr/cache.pyi +232 -0
  10. tnfr/callback_utils.py +381 -0
  11. tnfr/callback_utils.pyi +105 -0
  12. tnfr/cli/__init__.py +89 -0
  13. tnfr/cli/__init__.pyi +47 -0
  14. tnfr/cli/arguments.py +199 -0
  15. tnfr/cli/arguments.pyi +33 -0
  16. tnfr/cli/execution.py +322 -0
  17. tnfr/cli/execution.pyi +80 -0
  18. tnfr/cli/utils.py +34 -0
  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/init.py +36 -0
  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 +228 -0
  31. tnfr/constants/__init__.pyi +104 -0
  32. tnfr/constants/core.py +158 -0
  33. tnfr/constants/core.pyi +17 -0
  34. tnfr/constants/init.py +31 -0
  35. tnfr/constants/init.pyi +12 -0
  36. tnfr/constants/metric.py +102 -0
  37. tnfr/constants/metric.pyi +19 -0
  38. tnfr/constants_glyphs.py +16 -0
  39. tnfr/constants_glyphs.pyi +12 -0
  40. tnfr/dynamics/__init__.py +136 -0
  41. tnfr/dynamics/__init__.pyi +83 -0
  42. tnfr/dynamics/adaptation.py +201 -0
  43. tnfr/dynamics/aliases.py +22 -0
  44. tnfr/dynamics/coordination.py +343 -0
  45. tnfr/dynamics/dnfr.py +2315 -0
  46. tnfr/dynamics/dnfr.pyi +33 -0
  47. tnfr/dynamics/integrators.py +561 -0
  48. tnfr/dynamics/integrators.pyi +35 -0
  49. tnfr/dynamics/runtime.py +521 -0
  50. tnfr/dynamics/sampling.py +34 -0
  51. tnfr/dynamics/sampling.pyi +7 -0
  52. tnfr/dynamics/selectors.py +680 -0
  53. tnfr/execution.py +216 -0
  54. tnfr/execution.pyi +65 -0
  55. tnfr/flatten.py +283 -0
  56. tnfr/flatten.pyi +28 -0
  57. tnfr/gamma.py +320 -89
  58. tnfr/gamma.pyi +40 -0
  59. tnfr/glyph_history.py +337 -0
  60. tnfr/glyph_history.pyi +53 -0
  61. tnfr/grammar.py +23 -153
  62. tnfr/grammar.pyi +13 -0
  63. tnfr/helpers/__init__.py +151 -0
  64. tnfr/helpers/__init__.pyi +66 -0
  65. tnfr/helpers/numeric.py +88 -0
  66. tnfr/helpers/numeric.pyi +12 -0
  67. tnfr/immutable.py +214 -0
  68. tnfr/immutable.pyi +37 -0
  69. tnfr/initialization.py +199 -0
  70. tnfr/initialization.pyi +73 -0
  71. tnfr/io.py +311 -0
  72. tnfr/io.pyi +11 -0
  73. tnfr/locking.py +37 -0
  74. tnfr/locking.pyi +7 -0
  75. tnfr/metrics/__init__.py +41 -0
  76. tnfr/metrics/__init__.pyi +20 -0
  77. tnfr/metrics/coherence.py +1469 -0
  78. tnfr/metrics/common.py +149 -0
  79. tnfr/metrics/common.pyi +15 -0
  80. tnfr/metrics/core.py +259 -0
  81. tnfr/metrics/core.pyi +13 -0
  82. tnfr/metrics/diagnosis.py +840 -0
  83. tnfr/metrics/diagnosis.pyi +89 -0
  84. tnfr/metrics/export.py +151 -0
  85. tnfr/metrics/glyph_timing.py +369 -0
  86. tnfr/metrics/reporting.py +152 -0
  87. tnfr/metrics/reporting.pyi +12 -0
  88. tnfr/metrics/sense_index.py +294 -0
  89. tnfr/metrics/sense_index.pyi +9 -0
  90. tnfr/metrics/trig.py +216 -0
  91. tnfr/metrics/trig.pyi +12 -0
  92. tnfr/metrics/trig_cache.py +105 -0
  93. tnfr/metrics/trig_cache.pyi +10 -0
  94. tnfr/node.py +255 -177
  95. tnfr/node.pyi +161 -0
  96. tnfr/observers.py +154 -150
  97. tnfr/observers.pyi +46 -0
  98. tnfr/ontosim.py +135 -134
  99. tnfr/ontosim.pyi +33 -0
  100. tnfr/operators/__init__.py +452 -0
  101. tnfr/operators/__init__.pyi +31 -0
  102. tnfr/operators/definitions.py +181 -0
  103. tnfr/operators/definitions.pyi +92 -0
  104. tnfr/operators/jitter.py +266 -0
  105. tnfr/operators/jitter.pyi +11 -0
  106. tnfr/operators/registry.py +80 -0
  107. tnfr/operators/registry.pyi +15 -0
  108. tnfr/operators/remesh.py +569 -0
  109. tnfr/presets.py +10 -23
  110. tnfr/presets.pyi +7 -0
  111. tnfr/py.typed +0 -0
  112. tnfr/rng.py +440 -0
  113. tnfr/rng.pyi +14 -0
  114. tnfr/selector.py +217 -0
  115. tnfr/selector.pyi +19 -0
  116. tnfr/sense.py +307 -142
  117. tnfr/sense.pyi +30 -0
  118. tnfr/structural.py +69 -164
  119. tnfr/structural.pyi +46 -0
  120. tnfr/telemetry/__init__.py +13 -0
  121. tnfr/telemetry/verbosity.py +37 -0
  122. tnfr/tokens.py +61 -0
  123. tnfr/tokens.pyi +41 -0
  124. tnfr/trace.py +520 -95
  125. tnfr/trace.pyi +68 -0
  126. tnfr/types.py +382 -17
  127. tnfr/types.pyi +145 -0
  128. tnfr/utils/__init__.py +158 -0
  129. tnfr/utils/__init__.pyi +133 -0
  130. tnfr/utils/cache.py +755 -0
  131. tnfr/utils/cache.pyi +156 -0
  132. tnfr/utils/data.py +267 -0
  133. tnfr/utils/data.pyi +73 -0
  134. tnfr/utils/graph.py +87 -0
  135. tnfr/utils/graph.pyi +10 -0
  136. tnfr/utils/init.py +746 -0
  137. tnfr/utils/init.pyi +85 -0
  138. tnfr/utils/io.py +157 -0
  139. tnfr/utils/io.pyi +10 -0
  140. tnfr/utils/validators.py +130 -0
  141. tnfr/utils/validators.pyi +19 -0
  142. tnfr/validation/__init__.py +25 -0
  143. tnfr/validation/__init__.pyi +17 -0
  144. tnfr/validation/compatibility.py +59 -0
  145. tnfr/validation/compatibility.pyi +8 -0
  146. tnfr/validation/grammar.py +149 -0
  147. tnfr/validation/grammar.pyi +11 -0
  148. tnfr/validation/rules.py +194 -0
  149. tnfr/validation/rules.pyi +18 -0
  150. tnfr/validation/syntax.py +151 -0
  151. tnfr/validation/syntax.pyi +7 -0
  152. tnfr-6.0.0.dist-info/METADATA +135 -0
  153. tnfr-6.0.0.dist-info/RECORD +157 -0
  154. tnfr/cli.py +0 -322
  155. tnfr/config.py +0 -41
  156. tnfr/constants.py +0 -277
  157. tnfr/dynamics.py +0 -814
  158. tnfr/helpers.py +0 -264
  159. tnfr/main.py +0 -47
  160. tnfr/metrics.py +0 -597
  161. tnfr/operators.py +0 -525
  162. tnfr/program.py +0 -176
  163. tnfr/scenarios.py +0 -34
  164. tnfr/validators.py +0 -38
  165. tnfr-4.5.1.dist-info/METADATA +0 -221
  166. tnfr-4.5.1.dist-info/RECORD +0 -28
  167. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
  168. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
  169. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
  170. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,840 @@
1
+ """Diagnostic metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from concurrent.futures import ProcessPoolExecutor
7
+ from dataclasses import dataclass
8
+ from functools import partial
9
+ from operator import ge, le
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
14
+
15
+ from ..constants import (
16
+ STATE_DISSONANT,
17
+ STATE_STABLE,
18
+ STATE_TRANSITION,
19
+ VF_KEY,
20
+ get_aliases,
21
+ get_param,
22
+ normalise_state_token,
23
+ )
24
+ from ..callback_utils import CallbackEvent, callback_manager
25
+ from ..glyph_history import append_metric, ensure_history
26
+ from ..alias import get_attr
27
+ from ..helpers.numeric import clamp01, similarity_abs
28
+ from ..types import (
29
+ DiagnosisNodeData,
30
+ DiagnosisPayload,
31
+ DiagnosisPayloadChunk,
32
+ DiagnosisResult,
33
+ DiagnosisResultList,
34
+ DiagnosisSharedState,
35
+ NodeId,
36
+ TNFRGraph,
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
42
+
43
+ ALIAS_EPI = get_aliases("EPI")
44
+ ALIAS_VF = get_aliases("VF")
45
+ ALIAS_SI = get_aliases("SI")
46
+ ALIAS_DNFR = get_aliases("DNFR")
47
+
48
+ CoherenceSeries = Sequence[CoherenceMatrixPayload | None]
49
+ CoherenceHistory = Mapping[str, CoherenceSeries]
50
+
51
+
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:
318
+ stb = cfg.get("stable", {"Rloc_hi": 0.8, "dnfr_lo": 0.2, "persist": 3})
319
+ dsr = cfg.get("dissonance", {"Rloc_lo": 0.4, "dnfr_hi": 0.5, "persist": 3})
320
+
321
+ stable_checks = {
322
+ "Rloc": (Rloc, float(stb["Rloc_hi"]), ge),
323
+ "dnfr": (dnfr_n, float(stb["dnfr_lo"]), le),
324
+ }
325
+ if all(comp(val, thr) for val, thr, comp in stable_checks.values()):
326
+ return STATE_STABLE
327
+
328
+ dissonant_checks = {
329
+ "Rloc": (Rloc, float(dsr["Rloc_lo"]), le),
330
+ "dnfr": (dnfr_n, float(dsr["dnfr_hi"]), ge),
331
+ }
332
+ if all(comp(val, thr) for val, thr, comp in dissonant_checks.values()):
333
+ return STATE_DISSONANT
334
+
335
+ return STATE_TRANSITION
336
+
337
+
338
+ def _recommendation(state: str, cfg: Mapping[str, Any]) -> list[Any]:
339
+ adv = cfg.get("advice", {})
340
+ canonical_state = normalise_state_token(state)
341
+ return list(adv.get(canonical_state, []))
342
+
343
+
344
+ def _get_last_weights(
345
+ G: TNFRGraph,
346
+ hist: CoherenceHistory,
347
+ ) -> tuple[CoherenceMatrixPayload | None, CoherenceMatrixPayload | None]:
348
+ """Return last Wi and Wm matrices from history."""
349
+ CfgW = get_param(G, "COHERENCE")
350
+ Wkey = CfgW.get("Wi_history_key", "W_i")
351
+ Wm_key = CfgW.get("history_key", "W_sparse")
352
+ Wi_series = hist.get(Wkey, [])
353
+ Wm_series = hist.get(Wm_key, [])
354
+ Wi_last = Wi_series[-1] if Wi_series else None
355
+ Wm_last = Wm_series[-1] if Wm_series else None
356
+ return Wi_last, Wm_last
357
+
358
+
359
+ def _node_diagnostics(
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)
380
+ else:
381
+ symm = None
382
+
383
+ state = _state_from_thresholds(Rloc, dnfr_n, dcfg)
384
+ canonical_state = normalise_state_token(state)
385
+
386
+ alerts = []
387
+ if canonical_state == STATE_DISSONANT and dnfr_n >= shared["dissonance_hi"]:
388
+ alerts.append("high structural tension")
389
+
390
+ advice = _recommendation(canonical_state, dcfg)
391
+
392
+ payload: DiagnosisPayload = {
393
+ "node": node,
394
+ "Si": Si,
395
+ "EPI": EPI,
396
+ VF_KEY: vf,
397
+ "dnfr_norm": dnfr_n,
398
+ "W_i": node_data.get("W_i"),
399
+ "R_local": Rloc,
400
+ "symmetry": symm,
401
+ "state": canonical_state,
402
+ "advice": advice,
403
+ "alerts": alerts,
404
+ }
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."""
414
+
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:
424
+ del ctx
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
+
431
+ dcfg = get_param(G, "DIAGNOSIS")
432
+ if not dcfg.get("enabled", True):
433
+ return
434
+
435
+ hist = ensure_history(G)
436
+ coherence_hist = cast(CoherenceHistory, hist)
437
+ key = dcfg.get("history_key", "nodal_diag")
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
+
466
+ norms = compute_dnfr_accel_max(G)
467
+ G.graph["_sel_norms"] = norms
468
+ dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
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),
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
+ }
784
+
785
+ append_metric(hist, key, diag)
786
+
787
+
788
+ def dissonance_events(
789
+ G: TNFRGraph, ctx: DiagnosisSharedState | None = None
790
+ ) -> None:
791
+ """Emit per-node structural dissonance start/end events.
792
+
793
+ Events are recorded as ``"dissonance_start"`` and ``"dissonance_end"``.
794
+ """
795
+
796
+ del ctx
797
+
798
+ hist = ensure_history(G)
799
+ # Dissonance events are recorded in ``history['events']``
800
+ norms = G.graph.get("_sel_norms", {})
801
+ dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
802
+ step_idx = len(hist.get("C_steps", []))
803
+ nodes: list[NodeId] = list(G.nodes())
804
+ for n in nodes:
805
+ nd = G.nodes[n]
806
+ dn = normalize_dnfr(nd, dnfr_max)
807
+ Rloc = local_phase_sync(G, n)
808
+ st = bool(nd.get("_disr_state", False))
809
+ if (not st) and dn >= 0.5 and Rloc <= 0.4:
810
+ nd["_disr_state"] = True
811
+ append_metric(
812
+ hist,
813
+ "events",
814
+ ("dissonance_start", {"node": n, "step": step_idx}),
815
+ )
816
+ elif st and dn <= 0.2 and Rloc >= 0.7:
817
+ nd["_disr_state"] = False
818
+ append_metric(
819
+ hist,
820
+ "events",
821
+ ("dissonance_end", {"node": n, "step": step_idx}),
822
+ )
823
+
824
+
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
+
829
+ callback_manager.register_callback(
830
+ G,
831
+ event=CallbackEvent.AFTER_STEP.value,
832
+ func=partial(_diagnosis_step, n_jobs=n_jobs),
833
+ name="diagnosis_step",
834
+ )
835
+ callback_manager.register_callback(
836
+ G,
837
+ event=CallbackEvent.AFTER_STEP.value,
838
+ func=dissonance_events,
839
+ name="dissonance_events",
840
+ )