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