tnfr 4.5.0__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 -89
  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 -128
  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.0.dist-info/METADATA +0 -109
  74. tnfr-4.5.0.dist-info/RECORD +0 -28
  75. {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/WHEEL +0 -0
  76. {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/entry_points.txt +0 -0
  77. {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/licenses/LICENSE.md +0 -0
  78. {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/top_level.txt +0 -0
tnfr/dynamics/dnfr.py ADDED
@@ -0,0 +1,733 @@
1
+ """ΔNFR (dynamic network field response) utilities and strategies.
2
+
3
+ This module provides helper functions to configure, cache and apply ΔNFR
4
+ components such as phase, epidemiological state and vortex fields during
5
+ simulations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from dataclasses import dataclass
12
+ from typing import Any, Callable
13
+
14
+ from ..collections_utils import normalize_weights
15
+ from ..constants import DEFAULTS, get_aliases, get_param
16
+ from ..cache import cached_nodes_and_A
17
+ from ..helpers.numeric import angle_diff
18
+ from ..metrics.trig import neighbor_phase_mean, _phase_mean_from_iter
19
+ from ..alias import (
20
+ get_attr,
21
+ set_dnfr,
22
+ )
23
+ from ..metrics.trig_cache import compute_theta_trig
24
+ from ..metrics.common import merge_and_normalize_weights
25
+ from ..import_utils import get_numpy
26
+ ALIAS_THETA = get_aliases("THETA")
27
+ ALIAS_EPI = get_aliases("EPI")
28
+ ALIAS_VF = get_aliases("VF")
29
+
30
+
31
+
32
+
33
+ @dataclass
34
+ class DnfrCache:
35
+ idx: dict[Any, int]
36
+ theta: list[float]
37
+ epi: list[float]
38
+ vf: list[float]
39
+ cos_theta: list[float]
40
+ sin_theta: list[float]
41
+ degs: dict[Any, float] | None = None
42
+ deg_list: list[float] | None = None
43
+ theta_np: Any | None = None
44
+ epi_np: Any | None = None
45
+ vf_np: Any | None = None
46
+ cos_theta_np: Any | None = None
47
+ sin_theta_np: Any | None = None
48
+ deg_array: Any | None = None
49
+ checksum: Any | None = None
50
+
51
+
52
+ __all__ = (
53
+ "default_compute_delta_nfr",
54
+ "set_delta_nfr_hook",
55
+ "dnfr_phase_only",
56
+ "dnfr_epi_vf_mixed",
57
+ "dnfr_laplacian",
58
+ )
59
+
60
+
61
+ def _write_dnfr_metadata(
62
+ G, *, weights: dict, hook_name: str, note: str | None = None
63
+ ) -> None:
64
+ """Write a ``_DNFR_META`` block in ``G.graph`` with the mix and hook name.
65
+
66
+ ``weights`` may include arbitrary components (phase/epi/vf/topo/etc.).
67
+ """
68
+ weights_norm = normalize_weights(weights, weights.keys())
69
+ meta = {
70
+ "hook": hook_name,
71
+ "weights_raw": dict(weights),
72
+ "weights_norm": weights_norm,
73
+ "components": [k for k, v in weights_norm.items() if v != 0.0],
74
+ "doc": "ΔNFR = Σ w_i·g_i",
75
+ }
76
+ if note:
77
+ meta["note"] = str(note)
78
+ G.graph["_DNFR_META"] = meta
79
+ G.graph["_dnfr_hook_name"] = hook_name # string friendly
80
+
81
+
82
+ def _configure_dnfr_weights(G) -> dict:
83
+ """Normalise and store ΔNFR weights in ``G.graph['_dnfr_weights']``.
84
+
85
+ Uses ``G.graph['DNFR_WEIGHTS']`` or default values. The result is a
86
+ dictionary of normalised components reused at each simulation step
87
+ without recomputing the mix.
88
+ """
89
+ weights = merge_and_normalize_weights(
90
+ G, "DNFR_WEIGHTS", ("phase", "epi", "vf", "topo"), default=0.0
91
+ )
92
+ G.graph["_dnfr_weights"] = weights
93
+ return weights
94
+
95
+
96
+ def _init_dnfr_cache(G, nodes, prev_cache: DnfrCache | None, checksum, dirty):
97
+ """Initialise or reuse cached ΔNFR arrays."""
98
+ if prev_cache and prev_cache.checksum == checksum and not dirty:
99
+ return (
100
+ prev_cache,
101
+ prev_cache.idx,
102
+ prev_cache.theta,
103
+ prev_cache.epi,
104
+ prev_cache.vf,
105
+ prev_cache.cos_theta,
106
+ prev_cache.sin_theta,
107
+ False,
108
+ )
109
+
110
+ idx = {n: i for i, n in enumerate(nodes)}
111
+ theta = [0.0] * len(nodes)
112
+ epi = [0.0] * len(nodes)
113
+ vf = [0.0] * len(nodes)
114
+ cos_theta = [1.0] * len(nodes)
115
+ sin_theta = [0.0] * len(nodes)
116
+ cache = DnfrCache(
117
+ idx=idx,
118
+ theta=theta,
119
+ epi=epi,
120
+ vf=vf,
121
+ cos_theta=cos_theta,
122
+ sin_theta=sin_theta,
123
+ degs=prev_cache.degs if prev_cache else None,
124
+ checksum=checksum,
125
+ )
126
+ G.graph["_dnfr_prep_cache"] = cache
127
+ return (
128
+ cache,
129
+ cache.idx,
130
+ cache.theta,
131
+ cache.epi,
132
+ cache.vf,
133
+ cache.cos_theta,
134
+ cache.sin_theta,
135
+ True,
136
+ )
137
+
138
+
139
+ def _ensure_numpy_vectors(cache: DnfrCache, np):
140
+ """Ensure NumPy copies of cached vectors are initialised and up to date."""
141
+
142
+ if cache is None:
143
+ return (None, None, None, None, None)
144
+
145
+ arrays = []
146
+ for attr_np, source_attr in (
147
+ ("theta_np", "theta"),
148
+ ("epi_np", "epi"),
149
+ ("vf_np", "vf"),
150
+ ("cos_theta_np", "cos_theta"),
151
+ ("sin_theta_np", "sin_theta"),
152
+ ):
153
+ src = getattr(cache, source_attr)
154
+ arr = getattr(cache, attr_np)
155
+ if src is None:
156
+ setattr(cache, attr_np, None)
157
+ arrays.append(None)
158
+ continue
159
+ if arr is None or len(arr) != len(src):
160
+ arr = np.array(src, dtype=float)
161
+ else:
162
+ np.copyto(arr, src, casting="unsafe")
163
+ setattr(cache, attr_np, arr)
164
+ arrays.append(arr)
165
+ return tuple(arrays)
166
+
167
+
168
+ def _ensure_numpy_degrees(cache: DnfrCache, deg_list, np):
169
+ """Initialise/update NumPy array mirroring ``deg_list``."""
170
+
171
+ if cache is None or deg_list is None:
172
+ if cache is not None:
173
+ cache.deg_array = None
174
+ return None
175
+ arr = cache.deg_array
176
+ if arr is None or len(arr) != len(deg_list):
177
+ arr = np.array(deg_list, dtype=float)
178
+ else:
179
+ np.copyto(arr, deg_list, casting="unsafe")
180
+ cache.deg_array = arr
181
+ return arr
182
+
183
+
184
+ def _refresh_dnfr_vectors(G, nodes, cache: DnfrCache):
185
+ """Update cached angle and state vectors for ΔNFR."""
186
+ np = get_numpy()
187
+ trig = compute_theta_trig(((n, G.nodes[n]) for n in nodes), np=np)
188
+ use_numpy = np is not None and G.graph.get("vectorized_dnfr")
189
+ for i, n in enumerate(nodes):
190
+ nd = G.nodes[n]
191
+ cache.theta[i] = trig.theta[n]
192
+ cache.epi[i] = get_attr(nd, ALIAS_EPI, 0.0)
193
+ cache.vf[i] = get_attr(nd, ALIAS_VF, 0.0)
194
+ cache.cos_theta[i] = trig.cos[n]
195
+ cache.sin_theta[i] = trig.sin[n]
196
+ if use_numpy and np is not None:
197
+ _ensure_numpy_vectors(cache, np)
198
+ else:
199
+ cache.theta_np = None
200
+ cache.epi_np = None
201
+ cache.vf_np = None
202
+ cache.cos_theta_np = None
203
+ cache.sin_theta_np = None
204
+
205
+
206
+ def _prepare_dnfr_data(G, *, cache_size: int | None = 128) -> dict:
207
+ """Precompute common data for ΔNFR strategies."""
208
+ weights = G.graph.get("_dnfr_weights")
209
+ if weights is None:
210
+ weights = _configure_dnfr_weights(G)
211
+
212
+ np = get_numpy()
213
+ use_numpy = np is not None and G.graph.get("vectorized_dnfr")
214
+
215
+ nodes, A = cached_nodes_and_A(G, cache_size=cache_size)
216
+ cache: DnfrCache | None = G.graph.get("_dnfr_prep_cache")
217
+ checksum = G.graph.get("_dnfr_nodes_checksum")
218
+ dirty = bool(G.graph.pop("_dnfr_prep_dirty", False))
219
+ cache, idx, theta, epi, vf, cos_theta, sin_theta, refreshed = (
220
+ _init_dnfr_cache(G, nodes, cache, checksum, dirty)
221
+ )
222
+ if cache is not None:
223
+ _refresh_dnfr_vectors(G, nodes, cache)
224
+
225
+ w_phase = float(weights.get("phase", 0.0))
226
+ w_epi = float(weights.get("epi", 0.0))
227
+ w_vf = float(weights.get("vf", 0.0))
228
+ w_topo = float(weights.get("topo", 0.0))
229
+ degs = cache.degs if cache else None
230
+ if w_topo != 0 and (dirty or degs is None):
231
+ degs = dict(G.degree())
232
+ cache.degs = degs
233
+ elif w_topo == 0:
234
+ degs = None
235
+ if cache is not None:
236
+ cache.degs = None
237
+
238
+ G.graph["_dnfr_prep_dirty"] = False
239
+
240
+ deg_list: list[float] | None = None
241
+ if w_topo != 0.0 and degs is not None:
242
+ if cache.deg_list is None or dirty or len(cache.deg_list) != len(nodes):
243
+ cache.deg_list = [float(degs.get(node, 0.0)) for node in nodes]
244
+ deg_list = cache.deg_list
245
+ else:
246
+ cache.deg_list = None
247
+
248
+ if use_numpy and np is not None:
249
+ theta_np, epi_np, vf_np, cos_theta_np, sin_theta_np = _ensure_numpy_vectors(
250
+ cache, np
251
+ )
252
+ deg_array = _ensure_numpy_degrees(cache, deg_list, np)
253
+ else:
254
+ theta_np = None
255
+ epi_np = None
256
+ vf_np = None
257
+ cos_theta_np = None
258
+ sin_theta_np = None
259
+ deg_array = None
260
+ cache.deg_array = None
261
+
262
+ return {
263
+ "weights": weights,
264
+ "nodes": nodes,
265
+ "idx": idx,
266
+ "theta": theta,
267
+ "epi": epi,
268
+ "vf": vf,
269
+ "cos_theta": cos_theta,
270
+ "sin_theta": sin_theta,
271
+ "theta_np": theta_np,
272
+ "epi_np": epi_np,
273
+ "vf_np": vf_np,
274
+ "cos_theta_np": cos_theta_np,
275
+ "sin_theta_np": sin_theta_np,
276
+ "w_phase": w_phase,
277
+ "w_epi": w_epi,
278
+ "w_vf": w_vf,
279
+ "w_topo": w_topo,
280
+ "degs": degs,
281
+ "deg_list": deg_list,
282
+ "deg_array": deg_array,
283
+ "A": A,
284
+ "cache_size": cache_size,
285
+ "cache": cache,
286
+ }
287
+
288
+
289
+ def _apply_dnfr_gradients(
290
+ G,
291
+ data,
292
+ th_bar,
293
+ epi_bar,
294
+ vf_bar,
295
+ deg_bar=None,
296
+ degs=None,
297
+ ):
298
+ """Combine precomputed gradients and write ΔNFR to each node."""
299
+ nodes = data["nodes"]
300
+ theta = data["theta"]
301
+ epi = data["epi"]
302
+ vf = data["vf"]
303
+ w_phase = data["w_phase"]
304
+ w_epi = data["w_epi"]
305
+ w_vf = data["w_vf"]
306
+ w_topo = data["w_topo"]
307
+ if degs is None:
308
+ degs = data.get("degs")
309
+
310
+ for i, n in enumerate(nodes):
311
+ g_phase = -angle_diff(theta[i], th_bar[i]) / math.pi
312
+ g_epi = epi_bar[i] - epi[i]
313
+ g_vf = vf_bar[i] - vf[i]
314
+ if w_topo != 0.0 and deg_bar is not None and degs is not None:
315
+ if isinstance(degs, dict):
316
+ deg_i = float(degs.get(n, 0))
317
+ else:
318
+ deg_i = float(degs[i])
319
+ g_topo = deg_bar[i] - deg_i
320
+ else:
321
+ g_topo = 0.0
322
+ dnfr = (
323
+ w_phase * g_phase + w_epi * g_epi + w_vf * g_vf + w_topo * g_topo
324
+ )
325
+ set_dnfr(G, n, float(dnfr))
326
+
327
+
328
+ def _init_bar_arrays(data, *, degs=None, np=None):
329
+ """Prepare containers for neighbour means.
330
+
331
+ If ``np`` is provided, NumPy arrays are created; otherwise lists are used.
332
+ ``degs`` is optional and only initialised when the topological term is
333
+ active.
334
+ """
335
+
336
+ theta = data["theta"]
337
+ epi = data["epi"]
338
+ vf = data["vf"]
339
+ w_topo = data["w_topo"]
340
+ if np is None:
341
+ np = get_numpy()
342
+ if np is not None:
343
+ th_bar = np.array(theta, dtype=float)
344
+ epi_bar = np.array(epi, dtype=float)
345
+ vf_bar = np.array(vf, dtype=float)
346
+ deg_bar = (
347
+ np.array(degs, dtype=float)
348
+ if w_topo != 0.0 and degs is not None
349
+ else None
350
+ )
351
+ else:
352
+ th_bar = list(theta)
353
+ epi_bar = list(epi)
354
+ vf_bar = list(vf)
355
+ deg_bar = list(degs) if w_topo != 0.0 and degs is not None else None
356
+ return th_bar, epi_bar, vf_bar, deg_bar
357
+
358
+
359
+ def _compute_neighbor_means(
360
+ G,
361
+ data,
362
+ *,
363
+ x,
364
+ y,
365
+ epi_sum,
366
+ vf_sum,
367
+ count,
368
+ deg_sum=None,
369
+ degs=None,
370
+ np=None,
371
+ ):
372
+ """Return neighbour mean arrays for ΔNFR."""
373
+ w_topo = data["w_topo"]
374
+ theta = data["theta"]
375
+ is_numpy = np is not None and isinstance(count, np.ndarray)
376
+ th_bar, epi_bar, vf_bar, deg_bar = _init_bar_arrays(
377
+ data, degs=degs, np=np if is_numpy else None
378
+ )
379
+
380
+ if is_numpy:
381
+ mask = count > 0
382
+ if np.any(mask):
383
+ th_bar[mask] = np.arctan2(
384
+ y[mask] / count[mask], x[mask] / count[mask]
385
+ )
386
+ epi_bar[mask] = epi_sum[mask] / count[mask]
387
+ vf_bar[mask] = vf_sum[mask] / count[mask]
388
+ if w_topo != 0.0 and deg_bar is not None and deg_sum is not None:
389
+ deg_bar[mask] = deg_sum[mask] / count[mask]
390
+ return th_bar, epi_bar, vf_bar, deg_bar
391
+
392
+ n = len(theta)
393
+ cos_th = data["cos_theta"]
394
+ sin_th = data["sin_theta"]
395
+ idx = data["idx"]
396
+ nodes = data["nodes"]
397
+ deg_list = data.get("deg_list")
398
+ for i in range(n):
399
+ c = count[i]
400
+ if c:
401
+ node = nodes[i]
402
+ th_bar[i] = _phase_mean_from_iter(
403
+ ((cos_th[idx[v]], sin_th[idx[v]]) for v in G.neighbors(node)),
404
+ theta[i],
405
+ )
406
+ epi_bar[i] = epi_sum[i] / c
407
+ vf_bar[i] = vf_sum[i] / c
408
+ if w_topo != 0.0 and deg_bar is not None and deg_sum is not None:
409
+ deg_bar[i] = deg_sum[i] / c
410
+ return th_bar, epi_bar, vf_bar, deg_bar
411
+
412
+
413
+ def _compute_dnfr_common(
414
+ G,
415
+ data,
416
+ *,
417
+ x,
418
+ y,
419
+ epi_sum,
420
+ vf_sum,
421
+ count,
422
+ deg_sum=None,
423
+ degs=None,
424
+ ):
425
+ """Compute neighbour means and apply ΔNFR gradients."""
426
+ np = get_numpy()
427
+ th_bar, epi_bar, vf_bar, deg_bar = _compute_neighbor_means(
428
+ G,
429
+ data,
430
+ x=x,
431
+ y=y,
432
+ epi_sum=epi_sum,
433
+ vf_sum=vf_sum,
434
+ count=count,
435
+ deg_sum=deg_sum,
436
+ degs=degs,
437
+ np=np,
438
+ )
439
+ _apply_dnfr_gradients(G, data, th_bar, epi_bar, vf_bar, deg_bar, degs)
440
+
441
+
442
+ def _init_neighbor_sums(data, *, np=None):
443
+ """Initialise containers for neighbour sums."""
444
+ nodes = data["nodes"]
445
+ n = len(nodes)
446
+ w_topo = data["w_topo"]
447
+ if np is not None:
448
+ x = np.zeros(n, dtype=float)
449
+ y = np.zeros(n, dtype=float)
450
+ epi_sum = np.zeros(n, dtype=float)
451
+ vf_sum = np.zeros(n, dtype=float)
452
+ count = np.zeros(n, dtype=float)
453
+ deg_sum = np.zeros(n, dtype=float) if w_topo != 0.0 else None
454
+ degs = None
455
+ else:
456
+ x = [0.0] * n
457
+ y = [0.0] * n
458
+ epi_sum = [0.0] * n
459
+ vf_sum = [0.0] * n
460
+ count = [0] * n
461
+ deg_list = data.get("deg_list")
462
+ if w_topo != 0 and deg_list is not None:
463
+ deg_sum = [0.0] * n
464
+ degs = list(deg_list)
465
+ else:
466
+ deg_sum = None
467
+ degs = None
468
+ return x, y, epi_sum, vf_sum, count, deg_sum, degs
469
+
470
+
471
+ def _build_neighbor_sums_common(G, data, *, use_numpy: bool):
472
+ np = get_numpy()
473
+ nodes = data["nodes"]
474
+ w_topo = data["w_topo"]
475
+ if use_numpy:
476
+ if np is None: # pragma: no cover - runtime check
477
+ raise RuntimeError(
478
+ "numpy no disponible para la versión vectorizada",
479
+ )
480
+ if not nodes:
481
+ return None
482
+ x, y, epi_sum, vf_sum, count, deg_sum, degs = _init_neighbor_sums(
483
+ data, np=np
484
+ )
485
+ A = data.get("A")
486
+ if A is None:
487
+ _, A = cached_nodes_and_A(G, cache_size=data.get("cache_size"))
488
+ data["A"] = A
489
+ epi = data.get("epi_np")
490
+ vf = data.get("vf_np")
491
+ cos_th = data.get("cos_theta_np")
492
+ sin_th = data.get("sin_theta_np")
493
+ cache = data.get("cache")
494
+ if epi is None or vf is None or cos_th is None or sin_th is None:
495
+ epi = np.array(data["epi"], dtype=float)
496
+ vf = np.array(data["vf"], dtype=float)
497
+ cos_th = np.array(data["cos_theta"], dtype=float)
498
+ sin_th = np.array(data["sin_theta"], dtype=float)
499
+ data["epi_np"] = epi
500
+ data["vf_np"] = vf
501
+ data["cos_theta_np"] = cos_th
502
+ data["sin_theta_np"] = sin_th
503
+ if cache is not None:
504
+ cache.epi_np = epi
505
+ cache.vf_np = vf
506
+ cache.cos_theta_np = cos_th
507
+ cache.sin_theta_np = sin_th
508
+ x[:] = A @ cos_th
509
+ y[:] = A @ sin_th
510
+ epi_sum[:] = A @ epi
511
+ vf_sum[:] = A @ vf
512
+ count[:] = A.sum(axis=1)
513
+ if w_topo != 0.0:
514
+ deg_array = data.get("deg_array")
515
+ if deg_array is None:
516
+ deg_list = data.get("deg_list")
517
+ if deg_list is not None:
518
+ deg_array = np.array(deg_list, dtype=float)
519
+ data["deg_array"] = deg_array
520
+ if cache is not None:
521
+ cache.deg_array = deg_array
522
+ else:
523
+ deg_array = count
524
+ deg_sum[:] = A @ deg_array
525
+ degs = deg_array
526
+ return x, y, epi_sum, vf_sum, count, deg_sum, degs
527
+ else:
528
+ x, y, epi_sum, vf_sum, count, deg_sum, degs_list = _init_neighbor_sums(
529
+ data
530
+ )
531
+ idx = data["idx"]
532
+ epi = data["epi"]
533
+ vf = data["vf"]
534
+ cos_th = data["cos_theta"]
535
+ sin_th = data["sin_theta"]
536
+ deg_list = data.get("deg_list")
537
+ for i, node in enumerate(nodes):
538
+ deg_i = degs_list[i] if degs_list is not None else 0.0
539
+ for v in G.neighbors(node):
540
+ j = idx[v]
541
+ x[i] += cos_th[j]
542
+ y[i] += sin_th[j]
543
+ epi_sum[i] += epi[j]
544
+ vf_sum[i] += vf[j]
545
+ count[i] += 1
546
+ if deg_sum is not None:
547
+ deg_sum[i] += deg_list[j] if deg_list is not None else deg_i
548
+ return x, y, epi_sum, vf_sum, count, deg_sum, degs_list
549
+
550
+
551
+ def _compute_dnfr(G, data, *, use_numpy: bool = False) -> None:
552
+ """Compute ΔNFR using neighbour sums.
553
+
554
+ Parameters
555
+ ----------
556
+ G : nx.Graph
557
+ Graph on which the computation is performed.
558
+ data : dict
559
+ Precomputed ΔNFR data as returned by :func:`_prepare_dnfr_data`.
560
+ use_numpy : bool, optional
561
+ When ``True`` the vectorised ``numpy`` strategy is used. Defaults to
562
+ ``False`` to fall back to the loop-based implementation.
563
+ """
564
+ res = _build_neighbor_sums_common(G, data, use_numpy=use_numpy)
565
+ if res is None:
566
+ return
567
+ x, y, epi_sum, vf_sum, count, deg_sum, degs = res
568
+ _compute_dnfr_common(
569
+ G,
570
+ data,
571
+ x=x,
572
+ y=y,
573
+ epi_sum=epi_sum,
574
+ vf_sum=vf_sum,
575
+ count=count,
576
+ deg_sum=deg_sum,
577
+ degs=degs,
578
+ )
579
+
580
+
581
+ def default_compute_delta_nfr(G, *, cache_size: int | None = 1) -> None:
582
+ """Compute ΔNFR by mixing phase, EPI, νf and a topological term.
583
+
584
+ Parameters
585
+ ----------
586
+ G : nx.Graph
587
+ Graph on which the computation is performed.
588
+ cache_size : int | None, optional
589
+ Maximum number of edge configurations cached in ``G.graph``. Values
590
+ ``None`` or <= 0 imply unlimited cache. Defaults to ``1`` to keep the
591
+ previous behaviour.
592
+ """
593
+ data = _prepare_dnfr_data(G, cache_size=cache_size)
594
+ _write_dnfr_metadata(
595
+ G,
596
+ weights=data["weights"],
597
+ hook_name="default_compute_delta_nfr",
598
+ )
599
+ np = get_numpy()
600
+ use_numpy = np is not None and G.graph.get("vectorized_dnfr")
601
+ _compute_dnfr(G, data, use_numpy=use_numpy)
602
+
603
+
604
+ def set_delta_nfr_hook(
605
+ G, func, *, name: str | None = None, note: str | None = None
606
+ ) -> None:
607
+ """Set a stable hook to compute ΔNFR.
608
+ Required signature: ``func(G) -> None`` and it must write ``ALIAS_DNFR``
609
+ in each node. Basic metadata in ``G.graph`` is updated accordingly.
610
+ """
611
+ G.graph["compute_delta_nfr"] = func
612
+ G.graph["_dnfr_hook_name"] = str(
613
+ name or getattr(func, "__name__", "custom_dnfr")
614
+ )
615
+ if "_dnfr_weights" not in G.graph:
616
+ _configure_dnfr_weights(G)
617
+ if note:
618
+ meta = G.graph.get("_DNFR_META", {})
619
+ meta["note"] = str(note)
620
+ G.graph["_DNFR_META"] = meta
621
+
622
+
623
+ def _apply_dnfr_hook(
624
+ G,
625
+ grads: dict[str, Callable[[Any, Any], float]],
626
+ *,
627
+ weights: dict[str, float],
628
+ hook_name: str,
629
+ note: str | None = None,
630
+ ) -> None:
631
+ """Generic helper to compute and store ΔNFR using ``grads``.
632
+
633
+ ``grads`` maps component names to functions ``(G, n, nd) -> float``.
634
+ Each gradient is multiplied by its corresponding weight from ``weights``.
635
+ Metadata is recorded through :func:`_write_dnfr_metadata`.
636
+ """
637
+
638
+ for n, nd in G.nodes(data=True):
639
+ total = 0.0
640
+ for name, func in grads.items():
641
+ w = weights.get(name, 0.0)
642
+ if w:
643
+ total += w * func(G, n, nd)
644
+ set_dnfr(G, n, total)
645
+
646
+ _write_dnfr_metadata(G, weights=weights, hook_name=hook_name, note=note)
647
+
648
+
649
+ # --- Hooks de ejemplo (opcionales) ---
650
+ def dnfr_phase_only(G) -> None:
651
+ """Example: ΔNFR from phase only (Kuramoto-like)."""
652
+
653
+ def g_phase(G, n, nd):
654
+ th_i = get_attr(nd, ALIAS_THETA, 0.0)
655
+ th_bar = neighbor_phase_mean(G, n)
656
+ return -angle_diff(th_i, th_bar) / math.pi
657
+
658
+ _apply_dnfr_hook(
659
+ G,
660
+ {"phase": g_phase},
661
+ weights={"phase": 1.0},
662
+ hook_name="dnfr_phase_only",
663
+ note="Hook de ejemplo.",
664
+ )
665
+
666
+
667
+ def dnfr_epi_vf_mixed(G) -> None:
668
+ """Example: ΔNFR without phase, mixing EPI and νf."""
669
+
670
+ def g_epi(G, n, nd):
671
+ epi_i = get_attr(nd, ALIAS_EPI, 0.0)
672
+ neighbors = list(G.neighbors(n))
673
+ if neighbors:
674
+ total = 0.0
675
+ for v in neighbors:
676
+ total += float(get_attr(G.nodes[v], ALIAS_EPI, epi_i))
677
+ epi_bar = total / len(neighbors)
678
+ else:
679
+ epi_bar = float(epi_i)
680
+ return epi_bar - epi_i
681
+
682
+ def g_vf(G, n, nd):
683
+ vf_i = get_attr(nd, ALIAS_VF, 0.0)
684
+ neighbors = list(G.neighbors(n))
685
+ if neighbors:
686
+ total = 0.0
687
+ for v in neighbors:
688
+ total += float(get_attr(G.nodes[v], ALIAS_VF, vf_i))
689
+ vf_bar = total / len(neighbors)
690
+ else:
691
+ vf_bar = float(vf_i)
692
+ return vf_bar - vf_i
693
+
694
+ _apply_dnfr_hook(
695
+ G,
696
+ {"epi": g_epi, "vf": g_vf},
697
+ weights={"phase": 0.0, "epi": 0.5, "vf": 0.5},
698
+ hook_name="dnfr_epi_vf_mixed",
699
+ note="Hook de ejemplo.",
700
+ )
701
+
702
+
703
+ def dnfr_laplacian(G) -> None:
704
+ """Explicit topological gradient using Laplacian over EPI and νf."""
705
+ weights_cfg = get_param(G, "DNFR_WEIGHTS")
706
+ wE = float(weights_cfg.get("epi", DEFAULTS["DNFR_WEIGHTS"]["epi"]))
707
+ wV = float(weights_cfg.get("vf", DEFAULTS["DNFR_WEIGHTS"]["vf"]))
708
+
709
+ def g_epi(G, n, nd):
710
+ epi = get_attr(nd, ALIAS_EPI, 0.0)
711
+ neigh = list(G.neighbors(n))
712
+ deg = len(neigh) or 1
713
+ epi_bar = (
714
+ sum(get_attr(G.nodes[v], ALIAS_EPI, epi) for v in neigh) / deg
715
+ )
716
+ return epi_bar - epi
717
+
718
+ def g_vf(G, n, nd):
719
+ vf = get_attr(nd, ALIAS_VF, 0.0)
720
+ neigh = list(G.neighbors(n))
721
+ deg = len(neigh) or 1
722
+ vf_bar = sum(get_attr(G.nodes[v], ALIAS_VF, vf) for v in neigh) / deg
723
+ return vf_bar - vf
724
+
725
+ _apply_dnfr_hook(
726
+ G,
727
+ {"epi": g_epi, "vf": g_vf},
728
+ weights={"epi": wE, "vf": wV},
729
+ hook_name="dnfr_laplacian",
730
+ note="Gradiente topológico",
731
+ )
732
+
733
+