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.
- tnfr/__init__.py +270 -90
- tnfr/__init__.pyi +40 -0
- tnfr/_compat.py +11 -0
- tnfr/_version.py +7 -0
- tnfr/_version.pyi +7 -0
- tnfr/alias.py +631 -0
- tnfr/alias.pyi +140 -0
- tnfr/cache.py +732 -0
- tnfr/cache.pyi +232 -0
- tnfr/callback_utils.py +381 -0
- tnfr/callback_utils.pyi +105 -0
- tnfr/cli/__init__.py +89 -0
- tnfr/cli/__init__.pyi +47 -0
- tnfr/cli/arguments.py +199 -0
- tnfr/cli/arguments.pyi +33 -0
- tnfr/cli/execution.py +322 -0
- tnfr/cli/execution.pyi +80 -0
- tnfr/cli/utils.py +34 -0
- tnfr/cli/utils.pyi +8 -0
- tnfr/config/__init__.py +12 -0
- tnfr/config/__init__.pyi +8 -0
- tnfr/config/constants.py +104 -0
- tnfr/config/constants.pyi +12 -0
- tnfr/config/init.py +36 -0
- tnfr/config/init.pyi +8 -0
- tnfr/config/operator_names.py +106 -0
- tnfr/config/operator_names.pyi +28 -0
- tnfr/config/presets.py +104 -0
- tnfr/config/presets.pyi +7 -0
- tnfr/constants/__init__.py +228 -0
- tnfr/constants/__init__.pyi +104 -0
- tnfr/constants/core.py +158 -0
- tnfr/constants/core.pyi +17 -0
- tnfr/constants/init.py +31 -0
- tnfr/constants/init.pyi +12 -0
- tnfr/constants/metric.py +102 -0
- tnfr/constants/metric.pyi +19 -0
- tnfr/constants_glyphs.py +16 -0
- tnfr/constants_glyphs.pyi +12 -0
- tnfr/dynamics/__init__.py +136 -0
- tnfr/dynamics/__init__.pyi +83 -0
- tnfr/dynamics/adaptation.py +201 -0
- tnfr/dynamics/aliases.py +22 -0
- tnfr/dynamics/coordination.py +343 -0
- tnfr/dynamics/dnfr.py +2315 -0
- tnfr/dynamics/dnfr.pyi +33 -0
- tnfr/dynamics/integrators.py +561 -0
- tnfr/dynamics/integrators.pyi +35 -0
- tnfr/dynamics/runtime.py +521 -0
- tnfr/dynamics/sampling.py +34 -0
- tnfr/dynamics/sampling.pyi +7 -0
- tnfr/dynamics/selectors.py +680 -0
- tnfr/execution.py +216 -0
- tnfr/execution.pyi +65 -0
- tnfr/flatten.py +283 -0
- tnfr/flatten.pyi +28 -0
- tnfr/gamma.py +320 -89
- tnfr/gamma.pyi +40 -0
- tnfr/glyph_history.py +337 -0
- tnfr/glyph_history.pyi +53 -0
- tnfr/grammar.py +23 -153
- tnfr/grammar.pyi +13 -0
- tnfr/helpers/__init__.py +151 -0
- tnfr/helpers/__init__.pyi +66 -0
- tnfr/helpers/numeric.py +88 -0
- tnfr/helpers/numeric.pyi +12 -0
- tnfr/immutable.py +214 -0
- tnfr/immutable.pyi +37 -0
- tnfr/initialization.py +199 -0
- tnfr/initialization.pyi +73 -0
- tnfr/io.py +311 -0
- tnfr/io.pyi +11 -0
- tnfr/locking.py +37 -0
- tnfr/locking.pyi +7 -0
- tnfr/metrics/__init__.py +41 -0
- tnfr/metrics/__init__.pyi +20 -0
- tnfr/metrics/coherence.py +1469 -0
- tnfr/metrics/common.py +149 -0
- tnfr/metrics/common.pyi +15 -0
- tnfr/metrics/core.py +259 -0
- tnfr/metrics/core.pyi +13 -0
- tnfr/metrics/diagnosis.py +840 -0
- tnfr/metrics/diagnosis.pyi +89 -0
- tnfr/metrics/export.py +151 -0
- tnfr/metrics/glyph_timing.py +369 -0
- tnfr/metrics/reporting.py +152 -0
- tnfr/metrics/reporting.pyi +12 -0
- tnfr/metrics/sense_index.py +294 -0
- tnfr/metrics/sense_index.pyi +9 -0
- tnfr/metrics/trig.py +216 -0
- tnfr/metrics/trig.pyi +12 -0
- tnfr/metrics/trig_cache.py +105 -0
- tnfr/metrics/trig_cache.pyi +10 -0
- tnfr/node.py +255 -177
- tnfr/node.pyi +161 -0
- tnfr/observers.py +154 -150
- tnfr/observers.pyi +46 -0
- tnfr/ontosim.py +135 -134
- tnfr/ontosim.pyi +33 -0
- tnfr/operators/__init__.py +452 -0
- tnfr/operators/__init__.pyi +31 -0
- tnfr/operators/definitions.py +181 -0
- tnfr/operators/definitions.pyi +92 -0
- tnfr/operators/jitter.py +266 -0
- tnfr/operators/jitter.pyi +11 -0
- tnfr/operators/registry.py +80 -0
- tnfr/operators/registry.pyi +15 -0
- tnfr/operators/remesh.py +569 -0
- tnfr/presets.py +10 -23
- tnfr/presets.pyi +7 -0
- tnfr/py.typed +0 -0
- tnfr/rng.py +440 -0
- tnfr/rng.pyi +14 -0
- tnfr/selector.py +217 -0
- tnfr/selector.pyi +19 -0
- tnfr/sense.py +307 -142
- tnfr/sense.pyi +30 -0
- tnfr/structural.py +69 -164
- tnfr/structural.pyi +46 -0
- tnfr/telemetry/__init__.py +13 -0
- tnfr/telemetry/verbosity.py +37 -0
- tnfr/tokens.py +61 -0
- tnfr/tokens.pyi +41 -0
- tnfr/trace.py +520 -95
- tnfr/trace.pyi +68 -0
- tnfr/types.py +382 -17
- tnfr/types.pyi +145 -0
- tnfr/utils/__init__.py +158 -0
- tnfr/utils/__init__.pyi +133 -0
- tnfr/utils/cache.py +755 -0
- tnfr/utils/cache.pyi +156 -0
- tnfr/utils/data.py +267 -0
- tnfr/utils/data.pyi +73 -0
- tnfr/utils/graph.py +87 -0
- tnfr/utils/graph.pyi +10 -0
- tnfr/utils/init.py +746 -0
- tnfr/utils/init.pyi +85 -0
- tnfr/utils/io.py +157 -0
- tnfr/utils/io.pyi +10 -0
- tnfr/utils/validators.py +130 -0
- tnfr/utils/validators.pyi +19 -0
- tnfr/validation/__init__.py +25 -0
- tnfr/validation/__init__.pyi +17 -0
- tnfr/validation/compatibility.py +59 -0
- tnfr/validation/compatibility.pyi +8 -0
- tnfr/validation/grammar.py +149 -0
- tnfr/validation/grammar.pyi +11 -0
- tnfr/validation/rules.py +194 -0
- tnfr/validation/rules.pyi +18 -0
- tnfr/validation/syntax.py +151 -0
- tnfr/validation/syntax.pyi +7 -0
- tnfr-6.0.0.dist-info/METADATA +135 -0
- tnfr-6.0.0.dist-info/RECORD +157 -0
- tnfr/cli.py +0 -322
- tnfr/config.py +0 -41
- tnfr/constants.py +0 -277
- tnfr/dynamics.py +0 -814
- tnfr/helpers.py +0 -264
- tnfr/main.py +0 -47
- tnfr/metrics.py +0 -597
- tnfr/operators.py +0 -525
- tnfr/program.py +0 -176
- tnfr/scenarios.py +0 -34
- tnfr/validators.py +0 -38
- tnfr-4.5.1.dist-info/METADATA +0 -221
- tnfr-4.5.1.dist-info/RECORD +0 -28
- {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
- {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
- {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
- {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
|
+
)
|