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