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