tnfr 4.5.1__py3-none-any.whl → 4.5.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of tnfr might be problematic. Click here for more details.

Files changed (78) hide show
  1. tnfr/__init__.py +91 -90
  2. tnfr/alias.py +546 -0
  3. tnfr/cache.py +578 -0
  4. tnfr/callback_utils.py +388 -0
  5. tnfr/cli/__init__.py +75 -0
  6. tnfr/cli/arguments.py +177 -0
  7. tnfr/cli/execution.py +288 -0
  8. tnfr/cli/utils.py +36 -0
  9. tnfr/collections_utils.py +300 -0
  10. tnfr/config.py +19 -28
  11. tnfr/constants/__init__.py +174 -0
  12. tnfr/constants/core.py +159 -0
  13. tnfr/constants/init.py +31 -0
  14. tnfr/constants/metric.py +110 -0
  15. tnfr/constants_glyphs.py +98 -0
  16. tnfr/dynamics/__init__.py +658 -0
  17. tnfr/dynamics/dnfr.py +733 -0
  18. tnfr/dynamics/integrators.py +267 -0
  19. tnfr/dynamics/sampling.py +31 -0
  20. tnfr/execution.py +201 -0
  21. tnfr/flatten.py +283 -0
  22. tnfr/gamma.py +302 -88
  23. tnfr/glyph_history.py +290 -0
  24. tnfr/grammar.py +285 -96
  25. tnfr/graph_utils.py +84 -0
  26. tnfr/helpers/__init__.py +71 -0
  27. tnfr/helpers/numeric.py +87 -0
  28. tnfr/immutable.py +178 -0
  29. tnfr/import_utils.py +228 -0
  30. tnfr/initialization.py +197 -0
  31. tnfr/io.py +246 -0
  32. tnfr/json_utils.py +162 -0
  33. tnfr/locking.py +37 -0
  34. tnfr/logging_utils.py +116 -0
  35. tnfr/metrics/__init__.py +41 -0
  36. tnfr/metrics/coherence.py +829 -0
  37. tnfr/metrics/common.py +151 -0
  38. tnfr/metrics/core.py +101 -0
  39. tnfr/metrics/diagnosis.py +234 -0
  40. tnfr/metrics/export.py +137 -0
  41. tnfr/metrics/glyph_timing.py +189 -0
  42. tnfr/metrics/reporting.py +148 -0
  43. tnfr/metrics/sense_index.py +120 -0
  44. tnfr/metrics/trig.py +181 -0
  45. tnfr/metrics/trig_cache.py +109 -0
  46. tnfr/node.py +214 -159
  47. tnfr/observers.py +126 -136
  48. tnfr/ontosim.py +134 -134
  49. tnfr/operators/__init__.py +420 -0
  50. tnfr/operators/jitter.py +203 -0
  51. tnfr/operators/remesh.py +485 -0
  52. tnfr/presets.py +46 -14
  53. tnfr/rng.py +254 -0
  54. tnfr/selector.py +210 -0
  55. tnfr/sense.py +284 -131
  56. tnfr/structural.py +207 -79
  57. tnfr/tokens.py +60 -0
  58. tnfr/trace.py +329 -94
  59. tnfr/types.py +43 -17
  60. tnfr/validators.py +70 -24
  61. tnfr/value_utils.py +59 -0
  62. tnfr-4.5.2.dist-info/METADATA +379 -0
  63. tnfr-4.5.2.dist-info/RECORD +67 -0
  64. tnfr/cli.py +0 -322
  65. tnfr/constants.py +0 -277
  66. tnfr/dynamics.py +0 -814
  67. tnfr/helpers.py +0 -264
  68. tnfr/main.py +0 -47
  69. tnfr/metrics.py +0 -597
  70. tnfr/operators.py +0 -525
  71. tnfr/program.py +0 -176
  72. tnfr/scenarios.py +0 -34
  73. tnfr-4.5.1.dist-info/METADATA +0 -221
  74. tnfr-4.5.1.dist-info/RECORD +0 -28
  75. {tnfr-4.5.1.dist-info → tnfr-4.5.2.dist-info}/WHEEL +0 -0
  76. {tnfr-4.5.1.dist-info → tnfr-4.5.2.dist-info}/entry_points.txt +0 -0
  77. {tnfr-4.5.1.dist-info → tnfr-4.5.2.dist-info}/licenses/LICENSE.md +0 -0
  78. {tnfr-4.5.1.dist-info → tnfr-4.5.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,829 @@
1
+ """Coherence metrics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+ from typing import Any, Sequence
8
+
9
+
10
+ from ..constants import (
11
+ get_aliases,
12
+ get_param,
13
+ )
14
+ from ..callback_utils import CallbackEvent, callback_manager
15
+ from ..glyph_history import ensure_history, append_metric
16
+ from ..alias import collect_attr, get_attr, set_attr
17
+ from ..collections_utils import normalize_weights
18
+ from ..helpers.numeric import clamp01
19
+ from ..cache import ensure_node_index_map
20
+ from .common import compute_coherence, min_max_range
21
+ from .trig_cache import compute_theta_trig, get_trig_cache
22
+ from ..observers import (
23
+ DEFAULT_GLYPH_LOAD_SPAN,
24
+ DEFAULT_WBAR_SPAN,
25
+ glyph_load,
26
+ kuramoto_order,
27
+ phase_sync,
28
+ )
29
+ from ..sense import sigma_vector
30
+ from ..import_utils import get_numpy
31
+ from ..logging_utils import get_logger
32
+
33
+ logger = get_logger(__name__)
34
+
35
+ ALIAS_THETA = get_aliases("THETA")
36
+ ALIAS_EPI = get_aliases("EPI")
37
+ ALIAS_VF = get_aliases("VF")
38
+ ALIAS_SI = get_aliases("SI")
39
+ ALIAS_DNFR = get_aliases("DNFR")
40
+ ALIAS_DEPI = get_aliases("DEPI")
41
+ ALIAS_DSI = get_aliases("DSI")
42
+ ALIAS_DVF = get_aliases("DVF")
43
+ ALIAS_D2VF = get_aliases("D2VF")
44
+
45
+
46
+ @dataclass
47
+ class SimilarityInputs:
48
+ """Similarity inputs and optional trigonometric caches."""
49
+
50
+ th_vals: Sequence[float]
51
+ epi_vals: Sequence[float]
52
+ vf_vals: Sequence[float]
53
+ si_vals: Sequence[float]
54
+ cos_vals: Sequence[float] | None = None
55
+ sin_vals: Sequence[float] | None = None
56
+
57
+
58
+ def _compute_wij_phase_epi_vf_si_vectorized(
59
+ epi,
60
+ vf,
61
+ si,
62
+ cos_th,
63
+ sin_th,
64
+ epi_range,
65
+ vf_range,
66
+ np,
67
+ ):
68
+ """Vectorized computation of similarity components.
69
+
70
+ All parameters are expected to be NumPy arrays already cast to ``float``
71
+ when appropriate. ``epi_range`` and ``vf_range`` are normalized inside the
72
+ function to avoid division by zero.
73
+ """
74
+
75
+ epi_range = epi_range if epi_range > 0 else 1.0
76
+ vf_range = vf_range if vf_range > 0 else 1.0
77
+ s_phase = 0.5 * (
78
+ 1.0
79
+ + cos_th[:, None] * cos_th[None, :]
80
+ + sin_th[:, None] * sin_th[None, :]
81
+ )
82
+ s_epi = 1.0 - np.abs(epi[:, None] - epi[None, :]) / epi_range
83
+ s_vf = 1.0 - np.abs(vf[:, None] - vf[None, :]) / vf_range
84
+ s_si = 1.0 - np.abs(si[:, None] - si[None, :])
85
+ return s_phase, s_epi, s_vf, s_si
86
+
87
+
88
+ def compute_wij_phase_epi_vf_si(
89
+ inputs: SimilarityInputs,
90
+ i: int | None = None,
91
+ j: int | None = None,
92
+ *,
93
+ trig=None,
94
+ G: Any | None = None,
95
+ nodes: Sequence[Any] | None = None,
96
+ epi_range: float = 1.0,
97
+ vf_range: float = 1.0,
98
+ np=None,
99
+ ):
100
+ """Return similarity components for nodes ``i`` and ``j``.
101
+
102
+ When ``np`` is provided and ``i`` and ``j`` are ``None`` the computation is
103
+ vectorized returning full matrices for all node pairs.
104
+ """
105
+
106
+ trig = trig or (get_trig_cache(G, np=np) if G is not None else None)
107
+ cos_vals = inputs.cos_vals
108
+ sin_vals = inputs.sin_vals
109
+ if cos_vals is None or sin_vals is None:
110
+ th_vals = inputs.th_vals
111
+ pairs = zip(nodes or range(len(th_vals)), th_vals)
112
+ trig_local = compute_theta_trig(pairs, np=np)
113
+ index_iter = nodes if nodes is not None else range(len(th_vals))
114
+ if trig is not None and nodes is not None:
115
+ cos_vals = [trig.cos.get(n, trig_local.cos[n]) for n in nodes]
116
+ sin_vals = [trig.sin.get(n, trig_local.sin[n]) for n in nodes]
117
+ else:
118
+ cos_vals = [trig_local.cos[i] for i in index_iter]
119
+ sin_vals = [trig_local.sin[i] for i in index_iter]
120
+ inputs.cos_vals = cos_vals
121
+ inputs.sin_vals = sin_vals
122
+
123
+ th_vals = inputs.th_vals
124
+ epi_vals = inputs.epi_vals
125
+ vf_vals = inputs.vf_vals
126
+ si_vals = inputs.si_vals
127
+
128
+ if np is not None and i is None and j is None:
129
+ epi = np.asarray(epi_vals)
130
+ vf = np.asarray(vf_vals)
131
+ si = np.asarray(si_vals)
132
+ cos_th = np.asarray(cos_vals, dtype=float)
133
+ sin_th = np.asarray(sin_vals, dtype=float)
134
+ return _compute_wij_phase_epi_vf_si_vectorized(
135
+ epi,
136
+ vf,
137
+ si,
138
+ cos_th,
139
+ sin_th,
140
+ epi_range,
141
+ vf_range,
142
+ np,
143
+ )
144
+
145
+ if i is None or j is None:
146
+ raise ValueError("i and j are required for non-vectorized computation")
147
+ epi_range = epi_range if epi_range > 0 else 1.0
148
+ vf_range = vf_range if vf_range > 0 else 1.0
149
+ cos_i = cos_vals[i]
150
+ sin_i = sin_vals[i]
151
+ cos_j = cos_vals[j]
152
+ sin_j = sin_vals[j]
153
+ s_phase = 0.5 * (1.0 + (cos_i * cos_j + sin_i * sin_j))
154
+ s_epi = 1.0 - abs(epi_vals[i] - epi_vals[j]) / epi_range
155
+ s_vf = 1.0 - abs(vf_vals[i] - vf_vals[j]) / vf_range
156
+ s_si = 1.0 - abs(si_vals[i] - si_vals[j])
157
+ return s_phase, s_epi, s_vf, s_si
158
+
159
+
160
+ def _combine_similarity(
161
+ s_phase,
162
+ s_epi,
163
+ s_vf,
164
+ s_si,
165
+ phase_w,
166
+ epi_w,
167
+ vf_w,
168
+ si_w,
169
+ np=None,
170
+ ):
171
+ wij = phase_w * s_phase + epi_w * s_epi + vf_w * s_vf + si_w * s_si
172
+ if np is not None:
173
+ return np.clip(wij, 0.0, 1.0)
174
+ return clamp01(wij)
175
+
176
+
177
+ def _wij_components_weights(
178
+ G,
179
+ nodes,
180
+ inputs: SimilarityInputs,
181
+ wnorm,
182
+ i: int | None = None,
183
+ j: int | None = None,
184
+ epi_range: float = 1.0,
185
+ vf_range: float = 1.0,
186
+ np=None,
187
+ ):
188
+ """Return similarity components together with their weights.
189
+
190
+ This consolidates repeated computations ensuring that both the
191
+ similarity components and the corresponding weights are derived once and
192
+ consistently across different implementations.
193
+ """
194
+
195
+ s_phase, s_epi, s_vf, s_si = compute_wij_phase_epi_vf_si(
196
+ inputs,
197
+ i,
198
+ j,
199
+ G=G,
200
+ nodes=nodes,
201
+ epi_range=epi_range,
202
+ vf_range=vf_range,
203
+ np=np,
204
+ )
205
+ phase_w = wnorm["phase"]
206
+ epi_w = wnorm["epi"]
207
+ vf_w = wnorm["vf"]
208
+ si_w = wnorm["si"]
209
+ return s_phase, s_epi, s_vf, s_si, phase_w, epi_w, vf_w, si_w
210
+
211
+
212
+ def _wij_vectorized(
213
+ G,
214
+ nodes,
215
+ inputs: SimilarityInputs,
216
+ wnorm,
217
+ epi_min,
218
+ epi_max,
219
+ vf_min,
220
+ vf_max,
221
+ self_diag,
222
+ np,
223
+ ):
224
+ epi_range = epi_max - epi_min if epi_max > epi_min else 1.0
225
+ vf_range = vf_max - vf_min if vf_max > vf_min else 1.0
226
+ (
227
+ s_phase,
228
+ s_epi,
229
+ s_vf,
230
+ s_si,
231
+ phase_w,
232
+ epi_w,
233
+ vf_w,
234
+ si_w,
235
+ ) = _wij_components_weights(
236
+ G,
237
+ nodes,
238
+ inputs,
239
+ wnorm,
240
+ epi_range=epi_range,
241
+ vf_range=vf_range,
242
+ np=np,
243
+ )
244
+ wij = _combine_similarity(
245
+ s_phase, s_epi, s_vf, s_si, phase_w, epi_w, vf_w, si_w, np=np
246
+ )
247
+ if self_diag:
248
+ np.fill_diagonal(wij, 1.0)
249
+ else:
250
+ np.fill_diagonal(wij, 0.0)
251
+ return wij
252
+
253
+
254
+ def _assign_wij(
255
+ wij: list[list[float]],
256
+ i: int,
257
+ j: int,
258
+ G: Any,
259
+ nodes: Sequence[Any],
260
+ inputs: SimilarityInputs,
261
+ epi_range: float,
262
+ vf_range: float,
263
+ wnorm: dict[str, float],
264
+ ) -> None:
265
+ (
266
+ s_phase,
267
+ s_epi,
268
+ s_vf,
269
+ s_si,
270
+ phase_w,
271
+ epi_w,
272
+ vf_w,
273
+ si_w,
274
+ ) = _wij_components_weights(
275
+ G,
276
+ nodes,
277
+ inputs,
278
+ wnorm,
279
+ i,
280
+ j,
281
+ epi_range,
282
+ vf_range,
283
+ )
284
+ wij_ij = _combine_similarity(
285
+ s_phase, s_epi, s_vf, s_si, phase_w, epi_w, vf_w, si_w
286
+ )
287
+ wij[i][j] = wij[j][i] = wij_ij
288
+
289
+
290
+ def _wij_loops(
291
+ G,
292
+ nodes: Sequence[Any],
293
+ node_to_index: dict[Any, int],
294
+ inputs: SimilarityInputs,
295
+ wnorm: dict[str, float],
296
+ epi_min: float,
297
+ epi_max: float,
298
+ vf_min: float,
299
+ vf_max: float,
300
+ neighbors_only: bool,
301
+ self_diag: bool,
302
+ ) -> list[list[float]]:
303
+ n = len(nodes)
304
+ cos_vals = inputs.cos_vals
305
+ sin_vals = inputs.sin_vals
306
+ if cos_vals is None or sin_vals is None:
307
+ th_vals = inputs.th_vals
308
+ trig_local = compute_theta_trig(zip(nodes, th_vals))
309
+ cos_vals = [trig_local.cos[n] for n in nodes]
310
+ sin_vals = [trig_local.sin[n] for n in nodes]
311
+ inputs.cos_vals = cos_vals
312
+ inputs.sin_vals = sin_vals
313
+ wij = [
314
+ [1.0 if (self_diag and i == j) else 0.0 for j in range(n)]
315
+ for i in range(n)
316
+ ]
317
+ epi_range = epi_max - epi_min if epi_max > epi_min else 1.0
318
+ vf_range = vf_max - vf_min if vf_max > vf_min else 1.0
319
+ if neighbors_only:
320
+ for u, v in G.edges():
321
+ i = node_to_index[u]
322
+ j = node_to_index[v]
323
+ if i == j:
324
+ continue
325
+ _assign_wij(
326
+ wij,
327
+ i,
328
+ j,
329
+ G,
330
+ nodes,
331
+ inputs,
332
+ epi_range,
333
+ vf_range,
334
+ wnorm,
335
+ )
336
+ else:
337
+ for i in range(n):
338
+ for j in range(i + 1, n):
339
+ _assign_wij(
340
+ wij,
341
+ i,
342
+ j,
343
+ G,
344
+ nodes,
345
+ inputs,
346
+ epi_range,
347
+ vf_range,
348
+ wnorm,
349
+ )
350
+ return wij
351
+
352
+
353
+ def _compute_stats(values, row_sum, n, self_diag, np=None):
354
+ """Return aggregate statistics for ``values`` and normalized row sums.
355
+
356
+ ``values`` and ``row_sum`` can be any iterables. They are normalized to
357
+ either NumPy arrays or Python lists depending on the availability of
358
+ NumPy. The computation then delegates to the appropriate numerical
359
+ functions with minimal branching.
360
+ """
361
+
362
+ if np is not None:
363
+ # Normalize inputs to NumPy arrays
364
+ if not isinstance(values, np.ndarray):
365
+ values = np.asarray(list(values), dtype=float)
366
+ else:
367
+ values = values.astype(float)
368
+ if not isinstance(row_sum, np.ndarray):
369
+ row_sum = np.asarray(list(row_sum), dtype=float)
370
+ else:
371
+ row_sum = row_sum.astype(float)
372
+
373
+ def size_fn(v):
374
+ return int(v.size)
375
+
376
+ def min_fn(v):
377
+ return float(v.min()) if v.size else 0.0
378
+
379
+ def max_fn(v):
380
+ return float(v.max()) if v.size else 0.0
381
+
382
+ def mean_fn(v):
383
+ return float(v.mean()) if v.size else 0.0
384
+
385
+ def wi_fn(r, d):
386
+ return (r / d).astype(float).tolist()
387
+
388
+ else:
389
+ # Fall back to pure Python lists
390
+ values = list(values)
391
+ row_sum = list(row_sum)
392
+
393
+ def size_fn(v):
394
+ return len(v)
395
+
396
+ def min_fn(v):
397
+ return min(v) if v else 0.0
398
+
399
+ def max_fn(v):
400
+ return max(v) if v else 0.0
401
+
402
+ def mean_fn(v):
403
+ return sum(v) / len(v) if v else 0.0
404
+
405
+ def wi_fn(r, d):
406
+ return [float(r[i]) / d for i in range(n)]
407
+
408
+ count_val = size_fn(values)
409
+ min_val = min_fn(values)
410
+ max_val = max_fn(values)
411
+ mean_val = mean_fn(values)
412
+ row_count = n if self_diag else n - 1
413
+ denom = max(1, row_count)
414
+ Wi = wi_fn(row_sum, denom)
415
+ return min_val, max_val, mean_val, Wi, count_val
416
+
417
+
418
+ def _coherence_numpy(wij, mode, thr, np):
419
+ """Aggregate coherence weights using vectorized operations.
420
+
421
+ Produces the structural weight matrix ``W`` along with the list of off
422
+ diagonal values and row sums ready for statistical analysis.
423
+ """
424
+
425
+ n = wij.shape[0]
426
+ mask = ~np.eye(n, dtype=bool)
427
+ values = wij[mask]
428
+ row_sum = wij.sum(axis=1)
429
+ if mode == "dense":
430
+ W = wij.tolist()
431
+ else:
432
+ idx = np.where((wij >= thr) & mask)
433
+ W = [
434
+ (int(i), int(j), float(wij[i, j]))
435
+ for i, j in zip(idx[0], idx[1])
436
+ ]
437
+ return n, values, row_sum, W
438
+
439
+
440
+ def _coherence_python(wij, mode, thr):
441
+ """Aggregate coherence weights using pure Python loops."""
442
+
443
+ n = len(wij)
444
+ values: list[float] = []
445
+ row_sum = [0.0] * n
446
+ if mode == "dense":
447
+ W = [row[:] for row in wij]
448
+ for i in range(n):
449
+ for j in range(n):
450
+ w = W[i][j]
451
+ if i != j:
452
+ values.append(w)
453
+ row_sum[i] += w
454
+ else:
455
+ W: list[tuple[int, int, float]] = []
456
+ for i in range(n):
457
+ row_i = wij[i]
458
+ for j in range(n):
459
+ w = row_i[j]
460
+ if i != j:
461
+ values.append(w)
462
+ if w >= thr:
463
+ W.append((i, j, w))
464
+ row_sum[i] += w
465
+ return n, values, row_sum, W
466
+
467
+
468
+ def _finalize_wij(G, nodes, wij, mode, thr, scope, self_diag, np=None):
469
+ """Finalize the coherence matrix ``wij`` and store results in history.
470
+
471
+ When ``np`` is provided and ``wij`` is a NumPy array, the computation is
472
+ performed using vectorized operations. Otherwise a pure Python loop-based
473
+ approach is used.
474
+ """
475
+
476
+ use_np = np is not None and isinstance(wij, np.ndarray)
477
+ n, values, row_sum, W = (
478
+ _coherence_numpy(wij, mode, thr, np)
479
+ if use_np
480
+ else _coherence_python(wij, mode, thr)
481
+ )
482
+
483
+ min_val, max_val, mean_val, Wi, count_val = _compute_stats(
484
+ values, row_sum, n, self_diag, np if use_np else None
485
+ )
486
+ stats = {
487
+ "min": min_val,
488
+ "max": max_val,
489
+ "mean": mean_val,
490
+ "n_edges": count_val,
491
+ "mode": mode,
492
+ "scope": scope,
493
+ }
494
+
495
+ hist = ensure_history(G)
496
+ cfg = get_param(G, "COHERENCE")
497
+ append_metric(hist, cfg.get("history_key", "W_sparse"), W)
498
+ append_metric(hist, cfg.get("Wi_history_key", "W_i"), Wi)
499
+ append_metric(hist, cfg.get("stats_history_key", "W_stats"), stats)
500
+ return nodes, W
501
+
502
+
503
+ def coherence_matrix(G, use_numpy: bool | None = None):
504
+ cfg = get_param(G, "COHERENCE")
505
+ if not cfg.get("enabled", True):
506
+ return None, None
507
+
508
+ node_to_index = ensure_node_index_map(G)
509
+ nodes = list(node_to_index.keys())
510
+ n = len(nodes)
511
+ if n == 0:
512
+ return nodes, []
513
+
514
+ # NumPy handling for optional vectorized operations
515
+ np = get_numpy()
516
+ use_np = (
517
+ np is not None if use_numpy is None else (use_numpy and np is not None)
518
+ )
519
+
520
+ # Precompute indices to avoid repeated list.index calls within loops
521
+
522
+ th_vals = collect_attr(G, nodes, ALIAS_THETA, 0.0, np=np if use_np else None)
523
+ epi_vals = collect_attr(G, nodes, ALIAS_EPI, 0.0, np=np if use_np else None)
524
+ vf_vals = collect_attr(G, nodes, ALIAS_VF, 0.0, np=np if use_np else None)
525
+ si_vals = collect_attr(G, nodes, ALIAS_SI, 0.0, np=np if use_np else None)
526
+ si_vals = (
527
+ np.clip(si_vals, 0.0, 1.0)
528
+ if use_np
529
+ else [clamp01(v) for v in si_vals]
530
+ )
531
+ epi_min, epi_max = min_max_range(epi_vals)
532
+ vf_min, vf_max = min_max_range(vf_vals)
533
+
534
+ wdict = dict(cfg.get("weights", {}))
535
+ for k in ("phase", "epi", "vf", "si"):
536
+ wdict.setdefault(k, 0.0)
537
+ wnorm = normalize_weights(wdict, ("phase", "epi", "vf", "si"), default=0.0)
538
+
539
+ scope = str(cfg.get("scope", "neighbors")).lower()
540
+ neighbors_only = scope != "all"
541
+ self_diag = bool(cfg.get("self_on_diag", True))
542
+ mode = str(cfg.get("store_mode", "sparse")).lower()
543
+ thr = float(cfg.get("threshold", 0.0))
544
+ if mode not in ("sparse", "dense"):
545
+ mode = "sparse"
546
+ trig = get_trig_cache(G, np=np)
547
+ cos_map, sin_map = trig.cos, trig.sin
548
+ trig_local = compute_theta_trig(zip(nodes, th_vals), np=np)
549
+ cos_vals = [cos_map.get(n, trig_local.cos[n]) for n in nodes]
550
+ sin_vals = [sin_map.get(n, trig_local.sin[n]) for n in nodes]
551
+ inputs = SimilarityInputs(
552
+ th_vals=th_vals,
553
+ epi_vals=epi_vals,
554
+ vf_vals=vf_vals,
555
+ si_vals=si_vals,
556
+ cos_vals=cos_vals,
557
+ sin_vals=sin_vals,
558
+ )
559
+ if use_np:
560
+ wij = _wij_vectorized(
561
+ G,
562
+ nodes,
563
+ inputs,
564
+ wnorm,
565
+ epi_min,
566
+ epi_max,
567
+ vf_min,
568
+ vf_max,
569
+ self_diag,
570
+ np,
571
+ )
572
+ if neighbors_only:
573
+ adj = np.eye(n, dtype=bool)
574
+ for u, v in G.edges():
575
+ i = node_to_index[u]
576
+ j = node_to_index[v]
577
+ adj[i, j] = True
578
+ adj[j, i] = True
579
+ wij = np.where(adj, wij, 0.0)
580
+ else:
581
+ wij = _wij_loops(
582
+ G,
583
+ nodes,
584
+ node_to_index,
585
+ inputs,
586
+ wnorm,
587
+ epi_min,
588
+ epi_max,
589
+ vf_min,
590
+ vf_max,
591
+ neighbors_only,
592
+ self_diag,
593
+ )
594
+
595
+ return _finalize_wij(G, nodes, wij, mode, thr, scope, self_diag, np)
596
+
597
+
598
+ def local_phase_sync_weighted(
599
+ G, n, nodes_order=None, W_row=None, node_to_index=None
600
+ ):
601
+ """Compute local phase synchrony using explicit weights.
602
+
603
+ ``nodes_order`` is the node ordering used to build the coherence matrix
604
+ and ``W_row`` contains either the dense row corresponding to ``n`` or the
605
+ sparse list of ``(i, j, w)`` tuples for the whole matrix.
606
+ """
607
+ if W_row is None or nodes_order is None:
608
+ raise ValueError(
609
+ "nodes_order and W_row are required for weighted phase synchrony"
610
+ )
611
+
612
+ if node_to_index is None:
613
+ node_to_index = ensure_node_index_map(G)
614
+ i = node_to_index.get(n)
615
+ if i is None:
616
+ i = nodes_order.index(n)
617
+
618
+ num = 0 + 0j
619
+ den = 0.0
620
+
621
+ trig = get_trig_cache(G)
622
+ cos_map, sin_map = trig.cos, trig.sin
623
+
624
+ if (
625
+ isinstance(W_row, list)
626
+ and W_row
627
+ and isinstance(W_row[0], (int, float))
628
+ ):
629
+ for w, nj in zip(W_row, nodes_order):
630
+ if nj == n:
631
+ continue
632
+ den += w
633
+ cos_j = cos_map.get(nj)
634
+ sin_j = sin_map.get(nj)
635
+ if cos_j is None or sin_j is None:
636
+ trig_j = compute_theta_trig(((nj, G.nodes[nj]),))
637
+ cos_j = trig_j.cos[nj]
638
+ sin_j = trig_j.sin[nj]
639
+ num += w * complex(cos_j, sin_j)
640
+ else:
641
+ for ii, jj, w in W_row:
642
+ if ii != i:
643
+ continue
644
+ nj = nodes_order[jj]
645
+ if nj == n:
646
+ continue
647
+ den += w
648
+ cos_j = cos_map.get(nj)
649
+ sin_j = sin_map.get(nj)
650
+ if cos_j is None or sin_j is None:
651
+ trig_j = compute_theta_trig(((nj, G.nodes[nj]),))
652
+ cos_j = trig_j.cos[nj]
653
+ sin_j = trig_j.sin[nj]
654
+ num += w * complex(cos_j, sin_j)
655
+
656
+ return abs(num / den) if den else 0.0
657
+
658
+
659
+ def local_phase_sync(G, n):
660
+ """Compute unweighted local phase synchronization for node ``n``."""
661
+ nodes, W = coherence_matrix(G)
662
+ if nodes is None:
663
+ return 0.0
664
+ return local_phase_sync_weighted(G, n, nodes_order=nodes, W_row=W)
665
+
666
+
667
+ def _coherence_step(G, ctx: dict[str, Any] | None = None):
668
+ del ctx
669
+
670
+ if not get_param(G, "COHERENCE").get("enabled", True):
671
+ return
672
+ coherence_matrix(G)
673
+
674
+
675
+ def register_coherence_callbacks(G) -> None:
676
+ callback_manager.register_callback(
677
+ G,
678
+ event=CallbackEvent.AFTER_STEP.value,
679
+ func=_coherence_step,
680
+ name="coherence_step",
681
+ )
682
+
683
+
684
+ # ---------------------------------------------------------------------------
685
+ # Coherence and observer-related metric updates
686
+ # ---------------------------------------------------------------------------
687
+
688
+
689
+ def _record_metrics(
690
+ hist: dict[str, Any], *pairs: tuple[Any, str], evaluate: bool = False
691
+ ) -> None:
692
+ """Generic recorder for metric values."""
693
+
694
+ for value, key in pairs:
695
+ append_metric(hist, key, value() if evaluate else value)
696
+
697
+
698
+ def _update_coherence(G, hist) -> None:
699
+ """Update network coherence and related means."""
700
+
701
+ C, dnfr_mean, depi_mean = compute_coherence(G, return_means=True)
702
+ _record_metrics(
703
+ hist,
704
+ (C, "C_steps"),
705
+ (dnfr_mean, "dnfr_mean"),
706
+ (depi_mean, "depi_mean"),
707
+ )
708
+
709
+ cs = hist["C_steps"]
710
+ if cs:
711
+ window = min(len(cs), DEFAULT_WBAR_SPAN)
712
+ w = max(1, window)
713
+ wbar = sum(cs[-w:]) / w
714
+ _record_metrics(hist, (wbar, "W_bar"))
715
+
716
+
717
+ def _update_phase_sync(G, hist) -> None:
718
+ """Capture phase synchrony and Kuramoto order."""
719
+
720
+ ps = phase_sync(G)
721
+ ko = kuramoto_order(G)
722
+ _record_metrics(
723
+ hist,
724
+ (ps, "phase_sync"),
725
+ (ko, "kuramoto_R"),
726
+ )
727
+
728
+
729
+ def _update_sigma(G, hist) -> None:
730
+ """Record glyph load and associated Σ⃗ vector."""
731
+
732
+ gl = glyph_load(G, window=DEFAULT_GLYPH_LOAD_SPAN)
733
+ _record_metrics(
734
+ hist,
735
+ (gl.get("_estabilizadores", 0.0), "glyph_load_estab"),
736
+ (gl.get("_disruptivos", 0.0), "glyph_load_disr"),
737
+ )
738
+
739
+ dist = {k: v for k, v in gl.items() if not k.startswith("_")}
740
+ sig = sigma_vector(dist)
741
+ _record_metrics(
742
+ hist,
743
+ (sig.get("x", 0.0), "sense_sigma_x"),
744
+ (sig.get("y", 0.0), "sense_sigma_y"),
745
+ (sig.get("mag", 0.0), "sense_sigma_mag"),
746
+ (sig.get("angle", 0.0), "sense_sigma_angle"),
747
+ )
748
+
749
+
750
+ def _track_stability(G, hist, dt, eps_dnfr, eps_depi):
751
+ """Track per-node stability and derivative metrics."""
752
+
753
+ stables = 0
754
+ total = max(1, G.number_of_nodes())
755
+ delta_si_sum = 0.0
756
+ delta_si_count = 0
757
+ B_sum = 0.0
758
+ B_count = 0
759
+
760
+ for _, nd in G.nodes(data=True):
761
+ if (
762
+ abs(get_attr(nd, ALIAS_DNFR, 0.0)) <= eps_dnfr
763
+ and abs(get_attr(nd, ALIAS_DEPI, 0.0)) <= eps_depi
764
+ ):
765
+ stables += 1
766
+
767
+ Si_curr = get_attr(nd, ALIAS_SI, 0.0)
768
+ Si_prev = nd.get("_prev_Si", Si_curr)
769
+ dSi = Si_curr - Si_prev
770
+ nd["_prev_Si"] = Si_curr
771
+ set_attr(nd, ALIAS_DSI, dSi)
772
+ delta_si_sum += dSi
773
+ delta_si_count += 1
774
+
775
+ vf_curr = get_attr(nd, ALIAS_VF, 0.0)
776
+ vf_prev = nd.get("_prev_vf", vf_curr)
777
+ dvf_dt = (vf_curr - vf_prev) / dt
778
+ dvf_prev = nd.get("_prev_dvf", dvf_dt)
779
+ B = (dvf_dt - dvf_prev) / dt
780
+ nd["_prev_vf"] = vf_curr
781
+ nd["_prev_dvf"] = dvf_dt
782
+ set_attr(nd, ALIAS_DVF, dvf_dt)
783
+ set_attr(nd, ALIAS_D2VF, B)
784
+ B_sum += B
785
+ B_count += 1
786
+
787
+ hist["stable_frac"].append(stables / total)
788
+ hist["delta_Si"].append(
789
+ delta_si_sum / delta_si_count if delta_si_count else 0.0
790
+ )
791
+ hist["B"].append(B_sum / B_count if B_count else 0.0)
792
+
793
+
794
+ def _aggregate_si(G, hist):
795
+ """Aggregate Si statistics across nodes."""
796
+
797
+ try:
798
+ thr_sel = get_param(G, "SELECTOR_THRESHOLDS")
799
+ thr_def = get_param(G, "GLYPH_THRESHOLDS")
800
+ si_hi = float(thr_sel.get("si_hi", thr_def.get("hi", 0.66)))
801
+ si_lo = float(thr_sel.get("si_lo", thr_def.get("lo", 0.33)))
802
+
803
+ sis = [
804
+ s
805
+ for _, nd in G.nodes(data=True)
806
+ if not math.isnan(s := get_attr(nd, ALIAS_SI, float("nan")))
807
+ ]
808
+
809
+ total = 0.0
810
+ hi_count = 0
811
+ lo_count = 0
812
+ for s in sis:
813
+ total += s
814
+ if s >= si_hi:
815
+ hi_count += 1
816
+ if s <= si_lo:
817
+ lo_count += 1
818
+
819
+ n = len(sis)
820
+ if n:
821
+ hist["Si_mean"].append(total / n)
822
+ hist["Si_hi_frac"].append(hi_count / n)
823
+ hist["Si_lo_frac"].append(lo_count / n)
824
+ else:
825
+ hist["Si_mean"].append(0.0)
826
+ hist["Si_hi_frac"].append(0.0)
827
+ hist["Si_lo_frac"].append(0.0)
828
+ except (KeyError, AttributeError, TypeError) as exc:
829
+ logger.debug("Si aggregation failed: %s", exc)