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.

Files changed (195) hide show
  1. tnfr/__init__.py +275 -51
  2. tnfr/__init__.pyi +33 -0
  3. tnfr/_compat.py +10 -0
  4. tnfr/_generated_version.py +34 -0
  5. tnfr/_version.py +49 -0
  6. tnfr/_version.pyi +7 -0
  7. tnfr/alias.py +117 -31
  8. tnfr/alias.pyi +108 -0
  9. tnfr/cache.py +6 -572
  10. tnfr/cache.pyi +16 -0
  11. tnfr/callback_utils.py +16 -38
  12. tnfr/callback_utils.pyi +79 -0
  13. tnfr/cli/__init__.py +34 -14
  14. tnfr/cli/__init__.pyi +26 -0
  15. tnfr/cli/arguments.py +211 -28
  16. tnfr/cli/arguments.pyi +27 -0
  17. tnfr/cli/execution.py +470 -50
  18. tnfr/cli/execution.pyi +70 -0
  19. tnfr/cli/utils.py +18 -3
  20. tnfr/cli/utils.pyi +8 -0
  21. tnfr/config/__init__.py +13 -0
  22. tnfr/config/__init__.pyi +10 -0
  23. tnfr/{constants_glyphs.py → config/constants.py} +26 -20
  24. tnfr/config/constants.pyi +12 -0
  25. tnfr/config/feature_flags.py +83 -0
  26. tnfr/{config.py → config/init.py} +11 -7
  27. tnfr/config/init.pyi +8 -0
  28. tnfr/config/operator_names.py +93 -0
  29. tnfr/config/operator_names.pyi +28 -0
  30. tnfr/config/presets.py +84 -0
  31. tnfr/config/presets.pyi +7 -0
  32. tnfr/constants/__init__.py +80 -29
  33. tnfr/constants/__init__.pyi +92 -0
  34. tnfr/constants/aliases.py +31 -0
  35. tnfr/constants/core.py +4 -4
  36. tnfr/constants/core.pyi +17 -0
  37. tnfr/constants/init.py +1 -1
  38. tnfr/constants/init.pyi +12 -0
  39. tnfr/constants/metric.py +7 -15
  40. tnfr/constants/metric.pyi +19 -0
  41. tnfr/dynamics/__init__.py +165 -633
  42. tnfr/dynamics/__init__.pyi +82 -0
  43. tnfr/dynamics/adaptation.py +267 -0
  44. tnfr/dynamics/aliases.py +23 -0
  45. tnfr/dynamics/coordination.py +385 -0
  46. tnfr/dynamics/dnfr.py +2283 -400
  47. tnfr/dynamics/dnfr.pyi +24 -0
  48. tnfr/dynamics/integrators.py +406 -98
  49. tnfr/dynamics/integrators.pyi +34 -0
  50. tnfr/dynamics/runtime.py +881 -0
  51. tnfr/dynamics/sampling.py +10 -5
  52. tnfr/dynamics/sampling.pyi +7 -0
  53. tnfr/dynamics/selectors.py +719 -0
  54. tnfr/execution.py +70 -48
  55. tnfr/execution.pyi +45 -0
  56. tnfr/flatten.py +13 -9
  57. tnfr/flatten.pyi +21 -0
  58. tnfr/gamma.py +66 -53
  59. tnfr/gamma.pyi +34 -0
  60. tnfr/glyph_history.py +110 -52
  61. tnfr/glyph_history.pyi +35 -0
  62. tnfr/glyph_runtime.py +16 -0
  63. tnfr/glyph_runtime.pyi +9 -0
  64. tnfr/immutable.py +69 -28
  65. tnfr/immutable.pyi +34 -0
  66. tnfr/initialization.py +16 -16
  67. tnfr/initialization.pyi +65 -0
  68. tnfr/io.py +6 -240
  69. tnfr/io.pyi +16 -0
  70. tnfr/locking.pyi +7 -0
  71. tnfr/mathematics/__init__.py +81 -0
  72. tnfr/mathematics/backend.py +426 -0
  73. tnfr/mathematics/dynamics.py +398 -0
  74. tnfr/mathematics/epi.py +254 -0
  75. tnfr/mathematics/generators.py +222 -0
  76. tnfr/mathematics/metrics.py +119 -0
  77. tnfr/mathematics/operators.py +233 -0
  78. tnfr/mathematics/operators_factory.py +71 -0
  79. tnfr/mathematics/projection.py +78 -0
  80. tnfr/mathematics/runtime.py +173 -0
  81. tnfr/mathematics/spaces.py +247 -0
  82. tnfr/mathematics/transforms.py +292 -0
  83. tnfr/metrics/__init__.py +10 -10
  84. tnfr/metrics/__init__.pyi +20 -0
  85. tnfr/metrics/coherence.py +993 -324
  86. tnfr/metrics/common.py +23 -16
  87. tnfr/metrics/common.pyi +46 -0
  88. tnfr/metrics/core.py +251 -35
  89. tnfr/metrics/core.pyi +13 -0
  90. tnfr/metrics/diagnosis.py +708 -111
  91. tnfr/metrics/diagnosis.pyi +85 -0
  92. tnfr/metrics/export.py +27 -15
  93. tnfr/metrics/glyph_timing.py +232 -42
  94. tnfr/metrics/reporting.py +33 -22
  95. tnfr/metrics/reporting.pyi +12 -0
  96. tnfr/metrics/sense_index.py +987 -43
  97. tnfr/metrics/sense_index.pyi +9 -0
  98. tnfr/metrics/trig.py +214 -23
  99. tnfr/metrics/trig.pyi +13 -0
  100. tnfr/metrics/trig_cache.py +115 -22
  101. tnfr/metrics/trig_cache.pyi +10 -0
  102. tnfr/node.py +542 -136
  103. tnfr/node.pyi +178 -0
  104. tnfr/observers.py +152 -35
  105. tnfr/observers.pyi +31 -0
  106. tnfr/ontosim.py +23 -19
  107. tnfr/ontosim.pyi +28 -0
  108. tnfr/operators/__init__.py +601 -82
  109. tnfr/operators/__init__.pyi +45 -0
  110. tnfr/operators/definitions.py +513 -0
  111. tnfr/operators/definitions.pyi +78 -0
  112. tnfr/operators/grammar.py +760 -0
  113. tnfr/operators/jitter.py +107 -38
  114. tnfr/operators/jitter.pyi +11 -0
  115. tnfr/operators/registry.py +75 -0
  116. tnfr/operators/registry.pyi +13 -0
  117. tnfr/operators/remesh.py +149 -88
  118. tnfr/py.typed +0 -0
  119. tnfr/rng.py +46 -143
  120. tnfr/rng.pyi +14 -0
  121. tnfr/schemas/__init__.py +8 -0
  122. tnfr/schemas/grammar.json +94 -0
  123. tnfr/selector.py +25 -19
  124. tnfr/selector.pyi +19 -0
  125. tnfr/sense.py +72 -62
  126. tnfr/sense.pyi +23 -0
  127. tnfr/structural.py +522 -262
  128. tnfr/structural.pyi +69 -0
  129. tnfr/telemetry/__init__.py +35 -0
  130. tnfr/telemetry/cache_metrics.py +226 -0
  131. tnfr/telemetry/nu_f.py +423 -0
  132. tnfr/telemetry/nu_f.pyi +123 -0
  133. tnfr/telemetry/verbosity.py +37 -0
  134. tnfr/tokens.py +1 -3
  135. tnfr/tokens.pyi +36 -0
  136. tnfr/trace.py +270 -113
  137. tnfr/trace.pyi +40 -0
  138. tnfr/types.py +574 -6
  139. tnfr/types.pyi +331 -0
  140. tnfr/units.py +69 -0
  141. tnfr/units.pyi +16 -0
  142. tnfr/utils/__init__.py +217 -0
  143. tnfr/utils/__init__.pyi +202 -0
  144. tnfr/utils/cache.py +2395 -0
  145. tnfr/utils/cache.pyi +468 -0
  146. tnfr/utils/chunks.py +104 -0
  147. tnfr/utils/chunks.pyi +21 -0
  148. tnfr/{collections_utils.py → utils/data.py} +147 -90
  149. tnfr/utils/data.pyi +64 -0
  150. tnfr/utils/graph.py +85 -0
  151. tnfr/utils/graph.pyi +10 -0
  152. tnfr/utils/init.py +770 -0
  153. tnfr/utils/init.pyi +78 -0
  154. tnfr/utils/io.py +456 -0
  155. tnfr/{helpers → utils}/numeric.py +51 -24
  156. tnfr/utils/numeric.pyi +21 -0
  157. tnfr/validation/__init__.py +113 -0
  158. tnfr/validation/__init__.pyi +77 -0
  159. tnfr/validation/compatibility.py +95 -0
  160. tnfr/validation/compatibility.pyi +6 -0
  161. tnfr/validation/grammar.py +71 -0
  162. tnfr/validation/grammar.pyi +40 -0
  163. tnfr/validation/graph.py +138 -0
  164. tnfr/validation/graph.pyi +17 -0
  165. tnfr/validation/rules.py +281 -0
  166. tnfr/validation/rules.pyi +55 -0
  167. tnfr/validation/runtime.py +263 -0
  168. tnfr/validation/runtime.pyi +31 -0
  169. tnfr/validation/soft_filters.py +170 -0
  170. tnfr/validation/soft_filters.pyi +37 -0
  171. tnfr/validation/spectral.py +159 -0
  172. tnfr/validation/spectral.pyi +46 -0
  173. tnfr/validation/syntax.py +40 -0
  174. tnfr/validation/syntax.pyi +10 -0
  175. tnfr/validation/window.py +39 -0
  176. tnfr/validation/window.pyi +1 -0
  177. tnfr/viz/__init__.py +9 -0
  178. tnfr/viz/matplotlib.py +246 -0
  179. tnfr-7.0.0.dist-info/METADATA +179 -0
  180. tnfr-7.0.0.dist-info/RECORD +185 -0
  181. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/licenses/LICENSE.md +1 -1
  182. tnfr/grammar.py +0 -344
  183. tnfr/graph_utils.py +0 -84
  184. tnfr/helpers/__init__.py +0 -71
  185. tnfr/import_utils.py +0 -228
  186. tnfr/json_utils.py +0 -162
  187. tnfr/logging_utils.py +0 -116
  188. tnfr/presets.py +0 -60
  189. tnfr/validators.py +0 -84
  190. tnfr/value_utils.py +0 -59
  191. tnfr-4.5.2.dist-info/METADATA +0 -379
  192. tnfr-4.5.2.dist-info/RECORD +0 -67
  193. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/WHEEL +0 -0
  194. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/entry_points.txt +0 -0
  195. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/top_level.txt +0 -0
tnfr/dynamics/dnfr.py CHANGED
@@ -2,51 +2,294 @@
2
2
 
3
3
  This module provides helper functions to configure, cache and apply ΔNFR
4
4
  components such as phase, epidemiological state and vortex fields during
5
- simulations.
5
+ simulations. The neighbour accumulation helpers reuse cached edge indices
6
+ and NumPy workspaces whenever available so cosine, sine, EPI, νf and topology
7
+ means remain faithful to the canonical ΔNFR reorganisation without redundant
8
+ allocations.
6
9
  """
7
10
 
8
11
  from __future__ import annotations
9
12
 
10
13
  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
14
+ import sys
15
+ from collections.abc import Callable, Iterator, Mapping, MutableMapping, Sequence
16
+ from concurrent.futures import ProcessPoolExecutor
17
+ from types import ModuleType
18
+ from typing import TYPE_CHECKING, Any, cast
19
+
20
+ from time import perf_counter
21
+
22
+ from ..alias import get_attr, get_theta_attr, set_dnfr
23
+ from ..constants import DEFAULTS, get_param
24
+ from ..constants.aliases import ALIAS_EPI, ALIAS_VF
24
25
  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")
26
+ from ..metrics.trig import neighbor_phase_mean_list
27
+ from ..metrics.trig_cache import compute_theta_trig
28
+ from ..types import (
29
+ DeltaNFRHook,
30
+ DnfrCacheVectors,
31
+ DnfrVectorMap,
32
+ NeighborStats,
33
+ NodeId,
34
+ TNFRGraph,
35
+ )
36
+ from ..utils import (
37
+ DNFR_PREP_STATE_KEY,
38
+ DnfrPrepState,
39
+ DnfrCache,
40
+ CacheManager,
41
+ _graph_cache_manager,
42
+ angle_diff,
43
+ angle_diff_array,
44
+ cached_node_list,
45
+ cached_nodes_and_A,
46
+ get_numpy,
47
+ normalize_weights,
48
+ resolve_chunk_size,
49
+ new_dnfr_cache,
50
+ )
51
+
52
+ if TYPE_CHECKING: # pragma: no cover - import-time typing hook
53
+ import numpy as np
54
+
55
+ _MEAN_VECTOR_EPS = 1e-12
56
+ _SPARSE_DENSITY_THRESHOLD = 0.25
57
+ _DNFR_APPROX_BYTES_PER_EDGE = 48
58
+
59
+
60
+ def _should_vectorize(G: TNFRGraph, np_module: ModuleType | None) -> bool:
61
+ """Return ``True`` when NumPy is available unless the graph disables it."""
62
+
63
+ if np_module is None:
64
+ return False
65
+ flag = G.graph.get("vectorized_dnfr")
66
+ if flag is None:
67
+ return True
68
+ return bool(flag)
69
+
70
+
71
+ _NUMPY_CACHE_ATTRS = (
72
+ "theta_np",
73
+ "epi_np",
74
+ "vf_np",
75
+ "cos_theta_np",
76
+ "sin_theta_np",
77
+ "deg_array",
78
+ "neighbor_x_np",
79
+ "neighbor_y_np",
80
+ "neighbor_epi_sum_np",
81
+ "neighbor_vf_sum_np",
82
+ "neighbor_count_np",
83
+ "neighbor_deg_sum_np",
84
+ "neighbor_inv_count_np",
85
+ "neighbor_cos_avg_np",
86
+ "neighbor_sin_avg_np",
87
+ "neighbor_mean_tmp_np",
88
+ "neighbor_mean_length_np",
89
+ "neighbor_accum_np",
90
+ "neighbor_edge_values_np",
91
+ "dense_components_np",
92
+ "dense_accum_np",
93
+ "dense_degree_np",
94
+ )
29
95
 
30
96
 
97
+ def _profile_start_stop(
98
+ profile: MutableMapping[str, float] | None,
99
+ *,
100
+ keys: Sequence[str] = (),
101
+ ) -> tuple[Callable[[], float], Callable[[str, float], None]]:
102
+ """Return helpers to measure wall-clock durations for ``profile`` keys."""
31
103
 
104
+ if profile is not None:
105
+ for key in keys:
106
+ profile.setdefault(key, 0.0)
32
107
 
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
108
+ def _start() -> float:
109
+ return perf_counter()
110
+
111
+ def _stop(metric: str, start: float) -> None:
112
+ profile[metric] = float(profile.get(metric, 0.0)) + (perf_counter() - start)
113
+
114
+ else:
115
+
116
+ def _start() -> float:
117
+ return 0.0
118
+
119
+ def _stop(metric: str, start: float) -> None: # noqa: ARG001 - uniform signature
120
+ return None
121
+
122
+ return _start, _stop
123
+
124
+
125
+ def _iter_chunk_offsets(total: int, jobs: int) -> Iterator[tuple[int, int]]:
126
+ """Yield ``(start, end)`` offsets splitting ``total`` items across ``jobs``."""
127
+
128
+ if total <= 0 or jobs <= 1:
129
+ return
130
+
131
+ jobs = max(1, min(int(jobs), total))
132
+ base, extra = divmod(total, jobs)
133
+ start = 0
134
+ for i in range(jobs):
135
+ size = base + (1 if i < extra else 0)
136
+ if size <= 0:
137
+ continue
138
+ end = start + size
139
+ yield start, end
140
+ start = end
141
+
142
+
143
+ def _neighbor_sums_worker(
144
+ start: int,
145
+ end: int,
146
+ neighbor_indices: Sequence[Sequence[int]],
147
+ cos_th: Sequence[float],
148
+ sin_th: Sequence[float],
149
+ epi: Sequence[float],
150
+ vf: Sequence[float],
151
+ x_base: Sequence[float],
152
+ y_base: Sequence[float],
153
+ epi_base: Sequence[float],
154
+ vf_base: Sequence[float],
155
+ count_base: Sequence[float],
156
+ deg_base: Sequence[float] | None,
157
+ deg_list: Sequence[float] | None,
158
+ degs_list: Sequence[float] | None,
159
+ ) -> tuple[
160
+ int,
161
+ list[float],
162
+ list[float],
163
+ list[float],
164
+ list[float],
165
+ list[float],
166
+ list[float] | None,
167
+ ]:
168
+ """Return partial neighbour sums for the ``[start, end)`` range."""
169
+
170
+ chunk_x: list[float] = []
171
+ chunk_y: list[float] = []
172
+ chunk_epi: list[float] = []
173
+ chunk_vf: list[float] = []
174
+ chunk_count: list[float] = []
175
+ chunk_deg: list[float] | None = [] if deg_base is not None else None
176
+
177
+ for offset, idx in enumerate(range(start, end)):
178
+ neighbors = neighbor_indices[idx]
179
+ x_i = float(x_base[offset])
180
+ y_i = float(y_base[offset])
181
+ epi_i = float(epi_base[offset])
182
+ vf_i = float(vf_base[offset])
183
+ count_i = float(count_base[offset])
184
+ if deg_base is not None and chunk_deg is not None:
185
+ deg_i_acc = float(deg_base[offset])
186
+ else:
187
+ deg_i_acc = 0.0
188
+ deg_i = float(degs_list[idx]) if degs_list is not None else 0.0
189
+
190
+ for neighbor_idx in neighbors:
191
+ x_i += float(cos_th[neighbor_idx])
192
+ y_i += float(sin_th[neighbor_idx])
193
+ epi_i += float(epi[neighbor_idx])
194
+ vf_i += float(vf[neighbor_idx])
195
+ count_i += 1.0
196
+ if chunk_deg is not None:
197
+ if deg_list is not None:
198
+ deg_i_acc += float(deg_list[neighbor_idx])
199
+ else:
200
+ deg_i_acc += deg_i
201
+
202
+ chunk_x.append(x_i)
203
+ chunk_y.append(y_i)
204
+ chunk_epi.append(epi_i)
205
+ chunk_vf.append(vf_i)
206
+ chunk_count.append(count_i)
207
+ if chunk_deg is not None:
208
+ chunk_deg.append(deg_i_acc)
209
+
210
+ return (
211
+ start,
212
+ chunk_x,
213
+ chunk_y,
214
+ chunk_epi,
215
+ chunk_vf,
216
+ chunk_count,
217
+ chunk_deg,
218
+ )
219
+
220
+
221
+ def _dnfr_gradients_worker(
222
+ start: int,
223
+ end: int,
224
+ nodes: Sequence[NodeId],
225
+ theta: list[float],
226
+ epi: list[float],
227
+ vf: list[float],
228
+ th_bar: list[float],
229
+ epi_bar: list[float],
230
+ vf_bar: list[float],
231
+ deg_bar: list[float] | None,
232
+ degs: Mapping[Any, float] | Sequence[float] | None,
233
+ w_phase: float,
234
+ w_epi: float,
235
+ w_vf: float,
236
+ w_topo: float,
237
+ ) -> tuple[int, list[float]]:
238
+ """Return partial ΔNFR gradients for the ``[start, end)`` range."""
239
+
240
+ chunk: list[float] = []
241
+ for idx in range(start, end):
242
+ n = nodes[idx]
243
+ g_phase = -angle_diff(theta[idx], th_bar[idx]) / math.pi
244
+ g_epi = epi_bar[idx] - epi[idx]
245
+ g_vf = vf_bar[idx] - vf[idx]
246
+ if w_topo != 0.0 and deg_bar is not None and degs is not None:
247
+ if isinstance(degs, dict):
248
+ deg_i = float(degs.get(n, 0))
249
+ else:
250
+ deg_i = float(degs[idx])
251
+ g_topo = deg_bar[idx] - deg_i
252
+ else:
253
+ g_topo = 0.0
254
+ chunk.append(w_phase * g_phase + w_epi * g_epi + w_vf * g_vf + w_topo * g_topo)
255
+ return start, chunk
256
+
257
+
258
+ def _resolve_parallel_jobs(n_jobs: int | None, total: int) -> int | None:
259
+ """Return an effective worker count for ``total`` items or ``None``."""
260
+
261
+ if n_jobs is None:
262
+ return None
263
+ try:
264
+ jobs = int(n_jobs)
265
+ except (TypeError, ValueError):
266
+ return None
267
+ if jobs <= 1 or total <= 1:
268
+ return None
269
+ return max(1, min(jobs, total))
270
+
271
+
272
+ def _is_numpy_like(obj) -> bool:
273
+ return (
274
+ getattr(obj, "dtype", None) is not None
275
+ and getattr(obj, "shape", None) is not None
276
+ )
277
+
278
+
279
+ def _has_cached_numpy_buffers(data: dict, cache: DnfrCache | None) -> bool:
280
+ for attr in _NUMPY_CACHE_ATTRS:
281
+ arr = data.get(attr)
282
+ if _is_numpy_like(arr):
283
+ return True
284
+ if cache is not None:
285
+ for attr in _NUMPY_CACHE_ATTRS:
286
+ arr = getattr(cache, attr, None)
287
+ if _is_numpy_like(arr):
288
+ return True
289
+ A = data.get("A")
290
+ if _is_numpy_like(A):
291
+ return True
292
+ return False
50
293
 
51
294
 
52
295
  __all__ = (
@@ -93,37 +336,130 @@ def _configure_dnfr_weights(G) -> dict:
93
336
  return weights
94
337
 
95
338
 
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:
339
+ def _init_dnfr_cache(
340
+ G: TNFRGraph,
341
+ nodes: Sequence[NodeId],
342
+ cache_or_manager: CacheManager | DnfrCache | None = None,
343
+ checksum: Any | None = None,
344
+ force_refresh: bool = False,
345
+ *,
346
+ manager: CacheManager | None = None,
347
+ ) -> tuple[
348
+ DnfrCache,
349
+ dict[NodeId, int],
350
+ list[float],
351
+ list[float],
352
+ list[float],
353
+ list[float],
354
+ list[float],
355
+ bool,
356
+ ]:
357
+ """Initialise or reuse cached ΔNFR arrays.
358
+
359
+ ``manager`` telemetry became mandatory in TNFR 9.0 to expose cache hits,
360
+ misses and timings. Older callers still pass a ``cache`` instance as the
361
+ third positional argument; this helper supports both signatures by seeding
362
+ the manager-backed state with the provided cache when necessary.
363
+ """
364
+
365
+ if manager is None and isinstance(cache_or_manager, CacheManager):
366
+ manager = cache_or_manager
367
+ cache_or_manager = None
368
+
369
+ if manager is None:
370
+ manager = _graph_cache_manager(G.graph)
371
+
372
+ graph = G.graph
373
+ state = manager.get(DNFR_PREP_STATE_KEY)
374
+ if not isinstance(state, DnfrPrepState):
375
+ manager.clear(DNFR_PREP_STATE_KEY)
376
+ state = manager.get(DNFR_PREP_STATE_KEY)
377
+
378
+ if isinstance(cache_or_manager, DnfrCache):
379
+ state.cache = cache_or_manager
380
+ if checksum is None:
381
+ checksum = cache_or_manager.checksum
382
+
383
+ cache = state.cache
384
+ reuse = (
385
+ not force_refresh
386
+ and isinstance(cache, DnfrCache)
387
+ and cache.checksum == checksum
388
+ and len(cache.theta) == len(nodes)
389
+ )
390
+ if reuse:
391
+ manager.increment_hit(DNFR_PREP_STATE_KEY)
392
+ graph["_dnfr_prep_cache"] = cache
99
393
  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,
394
+ cache,
395
+ cache.idx,
396
+ cache.theta,
397
+ cache.epi,
398
+ cache.vf,
399
+ cache.cos_theta,
400
+ cache.sin_theta,
107
401
  False,
108
402
  )
109
403
 
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
404
+ def _rebuild(current: DnfrPrepState | Any) -> DnfrPrepState:
405
+ if not isinstance(current, DnfrPrepState):
406
+ raise RuntimeError("ΔNFR prep state unavailable during rebuild")
407
+ prev_cache = current.cache if isinstance(current.cache, DnfrCache) else None
408
+ idx_local = {n: i for i, n in enumerate(nodes)}
409
+ size = len(nodes)
410
+ zeros = [0.0] * size
411
+ cache_new = prev_cache if prev_cache is not None else new_dnfr_cache()
412
+ cache_new.idx = idx_local
413
+ cache_new.theta = zeros.copy()
414
+ cache_new.epi = zeros.copy()
415
+ cache_new.vf = zeros.copy()
416
+ cache_new.cos_theta = [1.0] * size
417
+ cache_new.sin_theta = [0.0] * size
418
+ cache_new.neighbor_x = zeros.copy()
419
+ cache_new.neighbor_y = zeros.copy()
420
+ cache_new.neighbor_epi_sum = zeros.copy()
421
+ cache_new.neighbor_vf_sum = zeros.copy()
422
+ cache_new.neighbor_count = zeros.copy()
423
+ cache_new.neighbor_deg_sum = zeros.copy() if size else []
424
+ cache_new.degs = None
425
+ cache_new.edge_src = None
426
+ cache_new.edge_dst = None
427
+ cache_new.checksum = checksum
428
+
429
+ # Reset any numpy mirrors or aggregated buffers to avoid leaking
430
+ # state across refresh cycles (e.g. switching between vectorised
431
+ # and Python paths or reusing legacy caches).
432
+ if prev_cache is not None:
433
+ for attr in _NUMPY_CACHE_ATTRS:
434
+ setattr(cache_new, attr, None)
435
+ for attr in (
436
+ "th_bar_np",
437
+ "epi_bar_np",
438
+ "vf_bar_np",
439
+ "deg_bar_np",
440
+ "grad_phase_np",
441
+ "grad_epi_np",
442
+ "grad_vf_np",
443
+ "grad_topo_np",
444
+ "grad_total_np",
445
+ ):
446
+ setattr(cache_new, attr, None)
447
+ cache_new.edge_src = None
448
+ cache_new.edge_dst = None
449
+ cache_new.edge_signature = None
450
+ cache_new.neighbor_accum_signature = None
451
+ cache_new.degs = prev_cache.degs if prev_cache else None
452
+ cache_new.checksum = checksum
453
+ current.cache = cache_new
454
+ graph["_dnfr_prep_cache"] = cache_new
455
+ return current
456
+
457
+ with manager.timer(DNFR_PREP_STATE_KEY):
458
+ state = manager.update(DNFR_PREP_STATE_KEY, _rebuild)
459
+ manager.increment_miss(DNFR_PREP_STATE_KEY)
460
+ cache = state.cache
461
+ if not isinstance(cache, DnfrCache): # pragma: no cover - defensive guard
462
+ raise RuntimeError("ΔNFR cache initialisation failed")
127
463
  return (
128
464
  cache,
129
465
  cache.idx,
@@ -136,13 +472,14 @@ def _init_dnfr_cache(G, nodes, prev_cache: DnfrCache | None, checksum, dirty):
136
472
  )
137
473
 
138
474
 
139
- def _ensure_numpy_vectors(cache: DnfrCache, np):
475
+ def _ensure_numpy_vectors(cache: DnfrCache, np: ModuleType) -> DnfrCacheVectors:
140
476
  """Ensure NumPy copies of cached vectors are initialised and up to date."""
141
477
 
142
478
  if cache is None:
143
479
  return (None, None, None, None, None)
144
480
 
145
- arrays = []
481
+ arrays: list[Any | None] = []
482
+ size = len(cache.theta)
146
483
  for attr_np, source_attr in (
147
484
  ("theta_np", "theta"),
148
485
  ("epi_np", "epi"),
@@ -150,28 +487,36 @@ def _ensure_numpy_vectors(cache: DnfrCache, np):
150
487
  ("cos_theta_np", "cos_theta"),
151
488
  ("sin_theta_np", "sin_theta"),
152
489
  ):
153
- src = getattr(cache, source_attr)
154
490
  arr = getattr(cache, attr_np)
491
+ if arr is not None and getattr(arr, "shape", None) == (size,):
492
+ arrays.append(arr)
493
+ continue
494
+ src = getattr(cache, source_attr)
155
495
  if src is None:
156
496
  setattr(cache, attr_np, None)
157
497
  arrays.append(None)
158
498
  continue
159
- if arr is None or len(arr) != len(src):
499
+ arr = np.asarray(src, dtype=float)
500
+ if getattr(arr, "shape", None) != (size,):
160
501
  arr = np.array(src, dtype=float)
161
- else:
162
- np.copyto(arr, src, casting="unsafe")
163
502
  setattr(cache, attr_np, arr)
164
503
  arrays.append(arr)
165
504
  return tuple(arrays)
166
505
 
167
506
 
168
- def _ensure_numpy_degrees(cache: DnfrCache, deg_list, np):
507
+ def _ensure_numpy_degrees(
508
+ cache: DnfrCache,
509
+ deg_list: Sequence[float] | None,
510
+ np: ModuleType,
511
+ ) -> np.ndarray | None:
169
512
  """Initialise/update NumPy array mirroring ``deg_list``."""
170
513
 
171
- if cache is None or deg_list is None:
514
+ if deg_list is None:
172
515
  if cache is not None:
173
516
  cache.deg_array = None
174
517
  return None
518
+ if cache is None:
519
+ return np.array(deg_list, dtype=float)
175
520
  arr = cache.deg_array
176
521
  if arr is None or len(arr) != len(deg_list):
177
522
  arr = np.array(deg_list, dtype=float)
@@ -181,121 +526,498 @@ def _ensure_numpy_degrees(cache: DnfrCache, deg_list, np):
181
526
  return arr
182
527
 
183
528
 
184
- def _refresh_dnfr_vectors(G, nodes, cache: DnfrCache):
529
+ def _resolve_numpy_degree_array(
530
+ data: MutableMapping[str, Any],
531
+ count: np.ndarray | None,
532
+ *,
533
+ cache: DnfrCache | None,
534
+ np: ModuleType,
535
+ ) -> np.ndarray | None:
536
+ """Return the vector of node degrees required for topology gradients."""
537
+
538
+ if data["w_topo"] == 0.0:
539
+ return None
540
+ deg_array = data.get("deg_array")
541
+ if deg_array is not None:
542
+ return deg_array
543
+ deg_list = data.get("deg_list")
544
+ if deg_list is not None:
545
+ deg_array = np.array(deg_list, dtype=float)
546
+ data["deg_array"] = deg_array
547
+ if cache is not None:
548
+ cache.deg_array = deg_array
549
+ return deg_array
550
+ return count
551
+
552
+
553
+ def _ensure_cached_array(
554
+ cache: DnfrCache | None,
555
+ attr: str,
556
+ shape: tuple[int, ...],
557
+ np: ModuleType,
558
+ ) -> np.ndarray:
559
+ """Return a cached NumPy buffer with ``shape`` creating/reusing it."""
560
+
561
+ if np is None:
562
+ raise RuntimeError("NumPy is required to build cached arrays")
563
+ arr = getattr(cache, attr) if cache is not None else None
564
+ if arr is None or getattr(arr, "shape", None) != shape:
565
+ arr = np.empty(shape, dtype=float)
566
+ if cache is not None:
567
+ setattr(cache, attr, arr)
568
+ return arr
569
+
570
+
571
+ def _ensure_numpy_state_vectors(
572
+ data: MutableMapping[str, Any], np: ModuleType
573
+ ) -> DnfrVectorMap:
574
+ """Synchronise list-based state vectors with their NumPy counterparts."""
575
+
576
+ nodes = data.get("nodes") or ()
577
+ size = len(nodes)
578
+ cache: DnfrCache | None = data.get("cache")
579
+
580
+ cache_arrays: DnfrCacheVectors = (None, None, None, None, None)
581
+ if cache is not None:
582
+ cache_arrays = _ensure_numpy_vectors(cache, np)
583
+
584
+ result: dict[str, Any | None] = {}
585
+ for (plain_key, np_key, cached_arr, result_key) in (
586
+ ("theta", "theta_np", cache_arrays[0], "theta"),
587
+ ("epi", "epi_np", cache_arrays[1], "epi"),
588
+ ("vf", "vf_np", cache_arrays[2], "vf"),
589
+ ("cos_theta", "cos_theta_np", cache_arrays[3], "cos"),
590
+ ("sin_theta", "sin_theta_np", cache_arrays[4], "sin"),
591
+ ):
592
+ arr = data.get(np_key)
593
+ if arr is None:
594
+ arr = cached_arr
595
+ if arr is None or getattr(arr, "shape", None) != (size,):
596
+ src = data.get(plain_key)
597
+ if src is None and cache is not None:
598
+ src = getattr(cache, plain_key)
599
+ if src is None:
600
+ arr = None
601
+ else:
602
+ arr = np.asarray(src, dtype=float)
603
+ if getattr(arr, "shape", None) != (size,):
604
+ arr = np.array(src, dtype=float)
605
+ if arr is not None:
606
+ data[np_key] = arr
607
+ data[plain_key] = arr
608
+ if cache is not None:
609
+ setattr(cache, np_key, arr)
610
+ else:
611
+ data[np_key] = None
612
+ result[result_key] = arr
613
+
614
+ return result
615
+
616
+
617
+ def _build_edge_index_arrays(
618
+ G: TNFRGraph,
619
+ nodes: Sequence[NodeId],
620
+ idx: Mapping[NodeId, int],
621
+ np: ModuleType,
622
+ ) -> tuple[np.ndarray, np.ndarray]:
623
+ """Create (src, dst) index arrays for ``G`` respecting ``nodes`` order."""
624
+
625
+ if np is None:
626
+ return None, None
627
+ if not nodes:
628
+ empty = np.empty(0, dtype=np.intp)
629
+ return empty, empty
630
+
631
+ src = []
632
+ dst = []
633
+ append_src = src.append
634
+ append_dst = dst.append
635
+ for node in nodes:
636
+ i = idx.get(node)
637
+ if i is None:
638
+ continue
639
+ for neighbor in G.neighbors(node):
640
+ j = idx.get(neighbor)
641
+ if j is None:
642
+ continue
643
+ append_src(i)
644
+ append_dst(j)
645
+ if not src:
646
+ empty = np.empty(0, dtype=np.intp)
647
+ return empty, empty
648
+ edge_src = np.asarray(src, dtype=np.intp)
649
+ edge_dst = np.asarray(dst, dtype=np.intp)
650
+ return edge_src, edge_dst
651
+
652
+
653
+ def _refresh_dnfr_vectors(
654
+ G: TNFRGraph, nodes: Sequence[NodeId], cache: DnfrCache
655
+ ) -> None:
185
656
  """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)
657
+ np_module = get_numpy()
658
+ trig = compute_theta_trig(((n, G.nodes[n]) for n in nodes), np=np_module)
659
+ use_numpy = _should_vectorize(G, np_module)
660
+ node_count = len(nodes)
661
+ trig_theta = getattr(trig, "theta_values", None)
662
+ trig_cos = getattr(trig, "cos_values", None)
663
+ trig_sin = getattr(trig, "sin_values", None)
664
+ np_ready = (
665
+ use_numpy
666
+ and np_module is not None
667
+ and isinstance(trig_theta, getattr(np_module, "ndarray", tuple()))
668
+ and isinstance(trig_cos, getattr(np_module, "ndarray", tuple()))
669
+ and isinstance(trig_sin, getattr(np_module, "ndarray", tuple()))
670
+ and getattr(trig_theta, "shape", None) == getattr(trig_cos, "shape", None)
671
+ and getattr(trig_theta, "shape", None) == getattr(trig_sin, "shape", None)
672
+ and (trig_theta.shape[0] if getattr(trig_theta, "ndim", 0) else 0) == node_count
673
+ )
674
+
675
+ if np_ready:
676
+ if node_count:
677
+ epi_arr = np_module.fromiter(
678
+ (get_attr(G.nodes[node], ALIAS_EPI, 0.0) for node in nodes),
679
+ dtype=float,
680
+ count=node_count,
681
+ )
682
+ vf_arr = np_module.fromiter(
683
+ (get_attr(G.nodes[node], ALIAS_VF, 0.0) for node in nodes),
684
+ dtype=float,
685
+ count=node_count,
686
+ )
687
+ else:
688
+ epi_arr = np_module.empty(0, dtype=float)
689
+ vf_arr = np_module.empty(0, dtype=float)
690
+
691
+ theta_arr = np_module.asarray(trig_theta, dtype=float)
692
+ cos_arr = np_module.asarray(trig_cos, dtype=float)
693
+ sin_arr = np_module.asarray(trig_sin, dtype=float)
694
+
695
+ def _sync_numpy(attr: str, source: Any) -> Any:
696
+ dest = getattr(cache, attr)
697
+ if dest is None or getattr(dest, "shape", None) != source.shape:
698
+ dest = np_module.array(source, dtype=float)
699
+ else:
700
+ np_module.copyto(dest, source, casting="unsafe")
701
+ setattr(cache, attr, dest)
702
+ return dest
703
+
704
+ _sync_numpy("theta_np", theta_arr)
705
+ _sync_numpy("epi_np", epi_arr)
706
+ _sync_numpy("vf_np", vf_arr)
707
+ _sync_numpy("cos_theta_np", cos_arr)
708
+ _sync_numpy("sin_theta_np", sin_arr)
709
+
710
+ # Python mirrors remain untouched while the vectorised path is active.
711
+ # They will be rebuilt the next time the runtime falls back to lists.
712
+ if cache.theta is not None and len(cache.theta) != node_count:
713
+ cache.theta = [0.0] * node_count
714
+ if cache.epi is not None and len(cache.epi) != node_count:
715
+ cache.epi = [0.0] * node_count
716
+ if cache.vf is not None and len(cache.vf) != node_count:
717
+ cache.vf = [0.0] * node_count
718
+ if cache.cos_theta is not None and len(cache.cos_theta) != node_count:
719
+ cache.cos_theta = [1.0] * node_count
720
+ if cache.sin_theta is not None and len(cache.sin_theta) != node_count:
721
+ cache.sin_theta = [0.0] * node_count
198
722
  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
723
+ for index, node in enumerate(nodes):
724
+ i: int = int(index)
725
+ node_id: NodeId = node
726
+ nd = G.nodes[node_id]
727
+ cache.theta[i] = trig.theta[node_id]
728
+ cache.epi[i] = get_attr(nd, ALIAS_EPI, 0.0)
729
+ cache.vf[i] = get_attr(nd, ALIAS_VF, 0.0)
730
+ cache.cos_theta[i] = trig.cos[node_id]
731
+ cache.sin_theta[i] = trig.sin[node_id]
732
+ if use_numpy and np_module is not None:
733
+ _ensure_numpy_vectors(cache, np_module)
734
+ else:
735
+ cache.theta_np = None
736
+ cache.epi_np = None
737
+ cache.vf_np = None
738
+ cache.cos_theta_np = None
739
+ cache.sin_theta_np = None
740
+
741
+
742
+ def _prepare_dnfr_data(
743
+ G: TNFRGraph,
744
+ *,
745
+ cache_size: int | None = 128,
746
+ profile: MutableMapping[str, float] | None = None,
747
+ ) -> dict[str, Any]:
748
+ """Precompute common data for ΔNFR strategies.
749
+
750
+ The helper decides between edge-wise and dense adjacency accumulation
751
+ heuristically. Graphs whose edge density exceeds
752
+ ``_SPARSE_DENSITY_THRESHOLD`` receive a cached adjacency matrix so the
753
+ dense path can be exercised; callers may also force the dense mode by
754
+ setting ``G.graph['dnfr_force_dense']`` to a truthy value.
204
755
 
756
+ Parameters
757
+ ----------
758
+ profile : MutableMapping[str, float] or None, optional
759
+ Mutable mapping that accumulates wall-clock timings for ΔNFR
760
+ preparation. When provided the helper increases the
761
+ ``"dnfr_cache_rebuild"`` bucket with the time spent refreshing cached
762
+ node vectors and associated NumPy workspaces.
763
+ """
764
+ start_timer, stop_timer = _profile_start_stop(
765
+ profile,
766
+ keys=("dnfr_cache_rebuild",),
767
+ )
205
768
 
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")
769
+ graph = G.graph
770
+ weights = graph.get("_dnfr_weights")
209
771
  if weights is None:
210
772
  weights = _configure_dnfr_weights(G)
211
773
 
212
- np = get_numpy()
213
- use_numpy = np is not None and G.graph.get("vectorized_dnfr")
774
+ result: dict[str, Any] = {
775
+ "weights": weights,
776
+ "cache_size": cache_size,
777
+ }
214
778
 
215
- nodes, A = cached_nodes_and_A(G, cache_size=cache_size)
216
- cache: DnfrCache | None = G.graph.get("_dnfr_prep_cache")
779
+ np_module = get_numpy()
780
+ use_numpy = _should_vectorize(G, np_module)
781
+
782
+ nodes = cast(tuple[NodeId, ...], cached_node_list(G))
783
+ edge_count = G.number_of_edges()
784
+ prefer_sparse = False
785
+ dense_override = bool(G.graph.get("dnfr_force_dense"))
786
+ if use_numpy:
787
+ prefer_sparse = _prefer_sparse_accumulation(len(nodes), edge_count)
788
+ if dense_override:
789
+ prefer_sparse = False
790
+ nodes_cached, A_untyped = cached_nodes_and_A(
791
+ G,
792
+ cache_size=cache_size,
793
+ require_numpy=False,
794
+ prefer_sparse=prefer_sparse,
795
+ nodes=nodes,
796
+ )
797
+ nodes = cast(tuple[NodeId, ...], nodes_cached)
798
+ A: np.ndarray | None = A_untyped
799
+ result["nodes"] = nodes
800
+ result["A"] = A
801
+ manager = _graph_cache_manager(G.graph)
217
802
  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)
803
+ dirty_flag = bool(G.graph.pop("_dnfr_prep_dirty", False))
804
+ existing_cache = cast(DnfrCache | None, graph.get("_dnfr_prep_cache"))
805
+ cache_timer = start_timer()
806
+ cache, idx, theta, epi, vf, cos_theta, sin_theta, refreshed = _init_dnfr_cache(
807
+ G,
808
+ nodes,
809
+ existing_cache,
810
+ checksum,
811
+ force_refresh=dirty_flag,
812
+ manager=manager,
221
813
  )
814
+ stop_timer("dnfr_cache_rebuild", cache_timer)
815
+ dirty = dirty_flag or refreshed
816
+ caching_enabled = cache is not None and (cache_size is None or cache_size > 0)
817
+ result["cache"] = cache
818
+ result["idx"] = idx
819
+ result["theta"] = theta
820
+ result["epi"] = epi
821
+ result["vf"] = vf
822
+ result["cos_theta"] = cos_theta
823
+ result["sin_theta"] = sin_theta
222
824
  if cache is not None:
223
825
  _refresh_dnfr_vectors(G, nodes, cache)
826
+ if np_module is None and not caching_enabled:
827
+ for attr in (
828
+ "neighbor_x_np",
829
+ "neighbor_y_np",
830
+ "neighbor_epi_sum_np",
831
+ "neighbor_vf_sum_np",
832
+ "neighbor_count_np",
833
+ "neighbor_deg_sum_np",
834
+ "neighbor_inv_count_np",
835
+ "neighbor_cos_avg_np",
836
+ "neighbor_sin_avg_np",
837
+ "neighbor_mean_tmp_np",
838
+ "neighbor_mean_length_np",
839
+ "neighbor_accum_np",
840
+ "neighbor_edge_values_np",
841
+ ):
842
+ setattr(cache, attr, None)
843
+ cache.neighbor_accum_signature = None
844
+ for attr in (
845
+ "th_bar_np",
846
+ "epi_bar_np",
847
+ "vf_bar_np",
848
+ "deg_bar_np",
849
+ "grad_phase_np",
850
+ "grad_epi_np",
851
+ "grad_vf_np",
852
+ "grad_topo_np",
853
+ "grad_total_np",
854
+ ):
855
+ setattr(cache, attr, None)
224
856
 
225
857
  w_phase = float(weights.get("phase", 0.0))
226
858
  w_epi = float(weights.get("epi", 0.0))
227
859
  w_vf = float(weights.get("vf", 0.0))
228
860
  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
861
+ result["w_phase"] = w_phase
862
+ result["w_epi"] = w_epi
863
+ result["w_vf"] = w_vf
864
+ result["w_topo"] = w_topo
865
+ degree_map = cast(dict[NodeId, float] | None, cache.degs if cache else None)
866
+ if cache is not None and dirty:
867
+ cache.degs = None
868
+ cache.deg_list = None
869
+ cache.deg_array = None
870
+ cache.edge_src = None
871
+ cache.edge_dst = None
872
+ cache.edge_signature = None
873
+ cache.neighbor_accum_signature = None
874
+ cache.neighbor_accum_np = None
875
+ cache.neighbor_edge_values_np = None
876
+ degree_map = None
239
877
 
240
878
  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:
879
+ degs: dict[NodeId, float] | None = None
880
+ deg_array: np.ndarray | None = None
881
+
882
+ if w_topo != 0.0 or caching_enabled:
883
+ if degree_map is None or len(degree_map) != len(G):
884
+ degree_map = {cast(NodeId, node): float(deg) for node, deg in G.degree()}
885
+ if cache is not None:
886
+ cache.degs = degree_map
887
+
888
+ if (
889
+ cache is not None
890
+ and cache.deg_list is not None
891
+ and not dirty
892
+ and len(cache.deg_list) == len(nodes)
893
+ ):
894
+ deg_list = cache.deg_list
895
+ else:
896
+ deg_list = [float(degree_map.get(node, 0.0)) for node in nodes]
897
+ if cache is not None:
898
+ cache.deg_list = deg_list
899
+
900
+ degs = degree_map
901
+
902
+ if np_module is not None and deg_list is not None:
903
+ if cache is not None:
904
+ deg_array = _ensure_numpy_degrees(cache, deg_list, np_module)
905
+ else:
906
+ deg_array = np_module.array(deg_list, dtype=float)
907
+ elif cache is not None:
908
+ cache.deg_array = None
909
+ elif cache is not None and dirty:
246
910
  cache.deg_list = None
911
+ cache.deg_array = None
912
+
913
+ G.graph["_dnfr_prep_dirty"] = False
247
914
 
248
- if use_numpy and np is not None:
915
+ result["degs"] = degs
916
+ result["deg_list"] = deg_list
917
+
918
+ theta_np: np.ndarray | None
919
+ epi_np: np.ndarray | None
920
+ vf_np: np.ndarray | None
921
+ cos_theta_np: np.ndarray | None
922
+ sin_theta_np: np.ndarray | None
923
+ edge_src: np.ndarray | None
924
+ edge_dst: np.ndarray | None
925
+ if use_numpy:
249
926
  theta_np, epi_np, vf_np, cos_theta_np, sin_theta_np = _ensure_numpy_vectors(
250
- cache, np
927
+ cache, np_module
251
928
  )
252
- deg_array = _ensure_numpy_degrees(cache, deg_list, np)
929
+ edge_src = None
930
+ edge_dst = None
931
+ if cache is not None:
932
+ edge_src = cache.edge_src
933
+ edge_dst = cache.edge_dst
934
+ if edge_src is None or edge_dst is None or dirty:
935
+ edge_src, edge_dst = _build_edge_index_arrays(G, nodes, idx, np_module)
936
+ cache.edge_src = edge_src
937
+ cache.edge_dst = edge_dst
938
+ else:
939
+ edge_src, edge_dst = _build_edge_index_arrays(G, nodes, idx, np_module)
940
+
941
+ if cache is not None:
942
+ for attr in ("neighbor_accum_np", "neighbor_edge_values_np"):
943
+ arr = getattr(cache, attr, None)
944
+ if arr is not None:
945
+ result[attr] = arr
946
+ if edge_src is not None and edge_dst is not None:
947
+ signature = (id(edge_src), id(edge_dst), len(nodes))
948
+ result["edge_signature"] = signature
949
+ if cache is not None:
950
+ cache.edge_signature = signature
253
951
  else:
254
952
  theta_np = None
255
953
  epi_np = None
256
954
  vf_np = None
257
955
  cos_theta_np = None
258
956
  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
- }
957
+ edge_src = None
958
+ edge_dst = None
959
+ if cache is not None:
960
+ cache.edge_src = None
961
+ cache.edge_dst = None
962
+
963
+ result.setdefault("neighbor_edge_values_np", None)
964
+ if cache is not None and "edge_signature" not in result:
965
+ result["edge_signature"] = cache.edge_signature
966
+
967
+ result["theta_np"] = theta_np
968
+ result["epi_np"] = epi_np
969
+ result["vf_np"] = vf_np
970
+ result["cos_theta_np"] = cos_theta_np
971
+ result["sin_theta_np"] = sin_theta_np
972
+ if theta_np is not None and getattr(theta_np, "shape", None) == (len(nodes),):
973
+ result["theta"] = theta_np
974
+ if epi_np is not None and getattr(epi_np, "shape", None) == (len(nodes),):
975
+ result["epi"] = epi_np
976
+ if vf_np is not None and getattr(vf_np, "shape", None) == (len(nodes),):
977
+ result["vf"] = vf_np
978
+ if cos_theta_np is not None and getattr(cos_theta_np, "shape", None) == (len(nodes),):
979
+ result["cos_theta"] = cos_theta_np
980
+ if sin_theta_np is not None and getattr(sin_theta_np, "shape", None) == (len(nodes),):
981
+ result["sin_theta"] = sin_theta_np
982
+ result["deg_array"] = deg_array
983
+ result["edge_src"] = edge_src
984
+ result["edge_dst"] = edge_dst
985
+ result["edge_count"] = edge_count
986
+ result["prefer_sparse"] = prefer_sparse
987
+ result["dense_override"] = dense_override
988
+ result.setdefault("neighbor_accum_np", None)
989
+ result.setdefault("neighbor_accum_signature", None)
990
+
991
+ return result
287
992
 
288
993
 
289
994
  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."""
995
+ G: TNFRGraph,
996
+ data: MutableMapping[str, Any],
997
+ th_bar: Sequence[float] | np.ndarray,
998
+ epi_bar: Sequence[float] | np.ndarray,
999
+ vf_bar: Sequence[float] | np.ndarray,
1000
+ deg_bar: Sequence[float] | np.ndarray | None = None,
1001
+ degs: Mapping[Any, float] | Sequence[float] | np.ndarray | None = None,
1002
+ *,
1003
+ n_jobs: int | None = None,
1004
+ profile: MutableMapping[str, float] | None = None,
1005
+ ) -> None:
1006
+ """Combine precomputed gradients and write ΔNFR to each node.
1007
+
1008
+ Parameters
1009
+ ----------
1010
+ profile : MutableMapping[str, float] or None, optional
1011
+ Mutable mapping receiving aggregated timings for the gradient assembly
1012
+ (``"dnfr_gradient_assembly"``) and in-place writes
1013
+ (``"dnfr_inplace_write"``).
1014
+ """
1015
+ start_timer, stop_timer = _profile_start_stop(
1016
+ profile,
1017
+ keys=("dnfr_gradient_assembly", "dnfr_inplace_write"),
1018
+ )
1019
+
1020
+ np = get_numpy()
299
1021
  nodes = data["nodes"]
300
1022
  theta = data["theta"]
301
1023
  epi = data["epi"]
@@ -307,25 +1029,146 @@ def _apply_dnfr_gradients(
307
1029
  if degs is None:
308
1030
  degs = data.get("degs")
309
1031
 
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
1032
+ cache: DnfrCache | None = data.get("cache")
1033
+
1034
+ theta_np = data.get("theta_np")
1035
+ epi_np = data.get("epi_np")
1036
+ vf_np = data.get("vf_np")
1037
+ deg_array = data.get("deg_array") if w_topo != 0.0 else None
1038
+
1039
+ use_vector = (
1040
+ np is not None
1041
+ and theta_np is not None
1042
+ and epi_np is not None
1043
+ and vf_np is not None
1044
+ and isinstance(th_bar, np.ndarray)
1045
+ and isinstance(epi_bar, np.ndarray)
1046
+ and isinstance(vf_bar, np.ndarray)
1047
+ )
1048
+ if use_vector and w_topo != 0.0:
1049
+ use_vector = (
1050
+ deg_bar is not None
1051
+ and isinstance(deg_bar, np.ndarray)
1052
+ and isinstance(deg_array, np.ndarray)
324
1053
  )
325
- set_dnfr(G, n, float(dnfr))
326
1054
 
1055
+ grad_timer = start_timer()
1056
+
1057
+ if use_vector:
1058
+ grad_phase = _ensure_cached_array(cache, "grad_phase_np", theta_np.shape, np)
1059
+ grad_epi = _ensure_cached_array(cache, "grad_epi_np", epi_np.shape, np)
1060
+ grad_vf = _ensure_cached_array(cache, "grad_vf_np", vf_np.shape, np)
1061
+ grad_total = _ensure_cached_array(cache, "grad_total_np", theta_np.shape, np)
1062
+ grad_topo = None
1063
+ if w_topo != 0.0:
1064
+ grad_topo = _ensure_cached_array(cache, "grad_topo_np", deg_array.shape, np)
1065
+
1066
+ angle_diff_array(theta_np, th_bar, np=np, out=grad_phase)
1067
+ np.multiply(grad_phase, -1.0 / math.pi, out=grad_phase)
1068
+
1069
+ np.copyto(grad_epi, epi_bar, casting="unsafe")
1070
+ grad_epi -= epi_np
1071
+
1072
+ np.copyto(grad_vf, vf_bar, casting="unsafe")
1073
+ grad_vf -= vf_np
1074
+
1075
+ if grad_topo is not None and deg_bar is not None:
1076
+ np.copyto(grad_topo, deg_bar, casting="unsafe")
1077
+ grad_topo -= deg_array
327
1078
 
328
- def _init_bar_arrays(data, *, degs=None, np=None):
1079
+ if w_phase != 0.0:
1080
+ np.multiply(grad_phase, w_phase, out=grad_total)
1081
+ else:
1082
+ grad_total.fill(0.0)
1083
+ if w_epi != 0.0:
1084
+ if w_epi != 1.0:
1085
+ np.multiply(grad_epi, w_epi, out=grad_epi)
1086
+ np.add(grad_total, grad_epi, out=grad_total)
1087
+ if w_vf != 0.0:
1088
+ if w_vf != 1.0:
1089
+ np.multiply(grad_vf, w_vf, out=grad_vf)
1090
+ np.add(grad_total, grad_vf, out=grad_total)
1091
+ if w_topo != 0.0 and grad_topo is not None:
1092
+ if w_topo != 1.0:
1093
+ np.multiply(grad_topo, w_topo, out=grad_topo)
1094
+ np.add(grad_total, grad_topo, out=grad_total)
1095
+
1096
+ dnfr_values = grad_total
1097
+ else:
1098
+ effective_jobs = _resolve_parallel_jobs(n_jobs, len(nodes))
1099
+ if effective_jobs:
1100
+ chunk_results = []
1101
+ with ProcessPoolExecutor(max_workers=effective_jobs) as executor:
1102
+ futures = []
1103
+ for start, end in _iter_chunk_offsets(len(nodes), effective_jobs):
1104
+ if start == end:
1105
+ continue
1106
+ futures.append(
1107
+ executor.submit(
1108
+ _dnfr_gradients_worker,
1109
+ start,
1110
+ end,
1111
+ nodes,
1112
+ theta,
1113
+ epi,
1114
+ vf,
1115
+ th_bar,
1116
+ epi_bar,
1117
+ vf_bar,
1118
+ deg_bar,
1119
+ degs,
1120
+ w_phase,
1121
+ w_epi,
1122
+ w_vf,
1123
+ w_topo,
1124
+ )
1125
+ )
1126
+ for future in futures:
1127
+ chunk_results.append(future.result())
1128
+
1129
+ dnfr_values = [0.0] * len(nodes)
1130
+ for start, chunk in sorted(chunk_results, key=lambda item: item[0]):
1131
+ end = start + len(chunk)
1132
+ dnfr_values[start:end] = chunk
1133
+ else:
1134
+ dnfr_values = []
1135
+ for i, n in enumerate(nodes):
1136
+ g_phase = -angle_diff(theta[i], th_bar[i]) / math.pi
1137
+ g_epi = epi_bar[i] - epi[i]
1138
+ g_vf = vf_bar[i] - vf[i]
1139
+ if w_topo != 0.0 and deg_bar is not None and degs is not None:
1140
+ if isinstance(degs, dict):
1141
+ deg_i = float(degs.get(n, 0))
1142
+ else:
1143
+ deg_i = float(degs[i])
1144
+ g_topo = deg_bar[i] - deg_i
1145
+ else:
1146
+ g_topo = 0.0
1147
+ dnfr_values.append(
1148
+ w_phase * g_phase + w_epi * g_epi + w_vf * g_vf + w_topo * g_topo
1149
+ )
1150
+
1151
+ if cache is not None:
1152
+ cache.grad_phase_np = None
1153
+ cache.grad_epi_np = None
1154
+ cache.grad_vf_np = None
1155
+ cache.grad_topo_np = None
1156
+ cache.grad_total_np = None
1157
+
1158
+ stop_timer("dnfr_gradient_assembly", grad_timer)
1159
+
1160
+ write_timer = start_timer()
1161
+ for i, n in enumerate(nodes):
1162
+ set_dnfr(G, n, float(dnfr_values[i]))
1163
+ stop_timer("dnfr_inplace_write", write_timer)
1164
+
1165
+
1166
+ def _init_bar_arrays(
1167
+ data: MutableMapping[str, Any],
1168
+ *,
1169
+ degs: Mapping[Any, float] | Sequence[float] | None = None,
1170
+ np: ModuleType | None = None,
1171
+ ) -> tuple[Sequence[float], Sequence[float], Sequence[float], Sequence[float] | None]:
329
1172
  """Prepare containers for neighbour means.
330
1173
 
331
1174
  If ``np`` is provided, NumPy arrays are created; otherwise lists are used.
@@ -333,97 +1176,234 @@ def _init_bar_arrays(data, *, degs=None, np=None):
333
1176
  active.
334
1177
  """
335
1178
 
1179
+ nodes = data["nodes"]
336
1180
  theta = data["theta"]
337
1181
  epi = data["epi"]
338
1182
  vf = data["vf"]
339
1183
  w_topo = data["w_topo"]
1184
+ cache: DnfrCache | None = data.get("cache")
340
1185
  if np is None:
341
1186
  np = get_numpy()
342
1187
  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
- )
1188
+ size = len(theta)
1189
+ if cache is not None:
1190
+ th_bar = cache.th_bar_np
1191
+ if th_bar is None or getattr(th_bar, "shape", None) != (size,):
1192
+ th_bar = np.array(theta, dtype=float)
1193
+ else:
1194
+ np.copyto(th_bar, theta, casting="unsafe")
1195
+ cache.th_bar_np = th_bar
1196
+
1197
+ epi_bar = cache.epi_bar_np
1198
+ if epi_bar is None or getattr(epi_bar, "shape", None) != (size,):
1199
+ epi_bar = np.array(epi, dtype=float)
1200
+ else:
1201
+ np.copyto(epi_bar, epi, casting="unsafe")
1202
+ cache.epi_bar_np = epi_bar
1203
+
1204
+ vf_bar = cache.vf_bar_np
1205
+ if vf_bar is None or getattr(vf_bar, "shape", None) != (size,):
1206
+ vf_bar = np.array(vf, dtype=float)
1207
+ else:
1208
+ np.copyto(vf_bar, vf, casting="unsafe")
1209
+ cache.vf_bar_np = vf_bar
1210
+
1211
+ if w_topo != 0.0 and degs is not None:
1212
+ if isinstance(degs, dict):
1213
+ deg_size = len(nodes)
1214
+ else:
1215
+ deg_size = len(degs)
1216
+ deg_bar = cache.deg_bar_np
1217
+ if deg_bar is None or getattr(deg_bar, "shape", None) != (deg_size,):
1218
+ if isinstance(degs, dict):
1219
+ deg_bar = np.array(
1220
+ [float(degs.get(node, 0.0)) for node in nodes],
1221
+ dtype=float,
1222
+ )
1223
+ else:
1224
+ deg_bar = np.array(degs, dtype=float)
1225
+ else:
1226
+ if isinstance(degs, dict):
1227
+ for i, node in enumerate(nodes):
1228
+ deg_bar[i] = float(degs.get(node, 0.0))
1229
+ else:
1230
+ np.copyto(deg_bar, degs, casting="unsafe")
1231
+ cache.deg_bar_np = deg_bar
1232
+ else:
1233
+ deg_bar = None
1234
+ if cache is not None:
1235
+ cache.deg_bar_np = None
1236
+ else:
1237
+ th_bar = np.array(theta, dtype=float)
1238
+ epi_bar = np.array(epi, dtype=float)
1239
+ vf_bar = np.array(vf, dtype=float)
1240
+ deg_bar = (
1241
+ np.array(degs, dtype=float)
1242
+ if w_topo != 0.0 and degs is not None
1243
+ else None
1244
+ )
351
1245
  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
1246
+ size = len(theta)
1247
+ if cache is not None:
1248
+ th_bar = cache.th_bar
1249
+ if th_bar is None or len(th_bar) != size:
1250
+ th_bar = [0.0] * size
1251
+ th_bar[:] = theta
1252
+ cache.th_bar = th_bar
1253
+
1254
+ epi_bar = cache.epi_bar
1255
+ if epi_bar is None or len(epi_bar) != size:
1256
+ epi_bar = [0.0] * size
1257
+ epi_bar[:] = epi
1258
+ cache.epi_bar = epi_bar
1259
+
1260
+ vf_bar = cache.vf_bar
1261
+ if vf_bar is None or len(vf_bar) != size:
1262
+ vf_bar = [0.0] * size
1263
+ vf_bar[:] = vf
1264
+ cache.vf_bar = vf_bar
1265
+
1266
+ if w_topo != 0.0 and degs is not None:
1267
+ if isinstance(degs, dict):
1268
+ deg_size = len(nodes)
1269
+ else:
1270
+ deg_size = len(degs)
1271
+ deg_bar = cache.deg_bar
1272
+ if deg_bar is None or len(deg_bar) != deg_size:
1273
+ deg_bar = [0.0] * deg_size
1274
+ if isinstance(degs, dict):
1275
+ for i, node in enumerate(nodes):
1276
+ deg_bar[i] = float(degs.get(node, 0.0))
1277
+ else:
1278
+ for i, value in enumerate(degs):
1279
+ deg_bar[i] = float(value)
1280
+ cache.deg_bar = deg_bar
1281
+ else:
1282
+ deg_bar = None
1283
+ cache.deg_bar = None
1284
+ else:
1285
+ th_bar = list(theta)
1286
+ epi_bar = list(epi)
1287
+ vf_bar = list(vf)
1288
+ deg_bar = list(degs) if w_topo != 0.0 and degs is not None else None
356
1289
  return th_bar, epi_bar, vf_bar, deg_bar
357
1290
 
358
1291
 
359
1292
  def _compute_neighbor_means(
360
- G,
361
- data,
1293
+ G: TNFRGraph,
1294
+ data: MutableMapping[str, Any],
362
1295
  *,
363
- x,
364
- y,
365
- epi_sum,
366
- vf_sum,
367
- count,
368
- deg_sum=None,
369
- degs=None,
370
- np=None,
371
- ):
1296
+ x: Sequence[float],
1297
+ y: Sequence[float],
1298
+ epi_sum: Sequence[float],
1299
+ vf_sum: Sequence[float],
1300
+ count: Sequence[float] | np.ndarray,
1301
+ deg_sum: Sequence[float] | None = None,
1302
+ degs: Mapping[Any, float] | Sequence[float] | None = None,
1303
+ np: ModuleType | None = None,
1304
+ ) -> tuple[Sequence[float], Sequence[float], Sequence[float], Sequence[float] | None]:
372
1305
  """Return neighbour mean arrays for ΔNFR."""
373
1306
  w_topo = data["w_topo"]
374
1307
  theta = data["theta"]
1308
+ cache: DnfrCache | None = data.get("cache")
375
1309
  is_numpy = np is not None and isinstance(count, np.ndarray)
376
1310
  th_bar, epi_bar, vf_bar, deg_bar = _init_bar_arrays(
377
1311
  data, degs=degs, np=np if is_numpy else None
378
1312
  )
379
1313
 
380
1314
  if is_numpy:
1315
+ n = count.shape[0]
381
1316
  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]
1317
+ if not np.any(mask):
1318
+ return th_bar, epi_bar, vf_bar, deg_bar
1319
+
1320
+ inv = _ensure_cached_array(cache, "neighbor_inv_count_np", (n,), np)
1321
+ inv.fill(0.0)
1322
+ np.divide(1.0, count, out=inv, where=mask)
1323
+
1324
+ cos_avg = _ensure_cached_array(cache, "neighbor_cos_avg_np", (n,), np)
1325
+ cos_avg.fill(0.0)
1326
+ np.multiply(x, inv, out=cos_avg, where=mask)
1327
+
1328
+ sin_avg = _ensure_cached_array(cache, "neighbor_sin_avg_np", (n,), np)
1329
+ sin_avg.fill(0.0)
1330
+ np.multiply(y, inv, out=sin_avg, where=mask)
1331
+
1332
+ lengths = _ensure_cached_array(cache, "neighbor_mean_length_np", (n,), np)
1333
+ np.hypot(cos_avg, sin_avg, out=lengths)
1334
+
1335
+ temp = _ensure_cached_array(cache, "neighbor_mean_tmp_np", (n,), np)
1336
+ np.arctan2(sin_avg, cos_avg, out=temp)
1337
+
1338
+ theta_src = data.get("theta_np")
1339
+ if theta_src is None:
1340
+ theta_src = np.asarray(theta, dtype=float)
1341
+ zero_mask = lengths <= _MEAN_VECTOR_EPS
1342
+ np.copyto(temp, theta_src, where=zero_mask)
1343
+ np.copyto(th_bar, temp, where=mask, casting="unsafe")
1344
+
1345
+ np.divide(epi_sum, count, out=epi_bar, where=mask)
1346
+ np.divide(vf_sum, count, out=vf_bar, where=mask)
1347
+ if w_topo != 0.0 and deg_bar is not None and deg_sum is not None:
1348
+ np.divide(deg_sum, count, out=deg_bar, where=mask)
390
1349
  return th_bar, epi_bar, vf_bar, deg_bar
391
1350
 
392
1351
  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
1352
  for i in range(n):
399
1353
  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
1354
+ if not c:
1355
+ continue
1356
+ inv = 1.0 / float(c)
1357
+ cos_avg = x[i] * inv
1358
+ sin_avg = y[i] * inv
1359
+ if math.hypot(cos_avg, sin_avg) <= _MEAN_VECTOR_EPS:
1360
+ th_bar[i] = theta[i]
1361
+ else:
1362
+ th_bar[i] = math.atan2(sin_avg, cos_avg)
1363
+ epi_bar[i] = epi_sum[i] * inv
1364
+ vf_bar[i] = vf_sum[i] * inv
1365
+ if w_topo != 0.0 and deg_bar is not None and deg_sum is not None:
1366
+ deg_bar[i] = deg_sum[i] * inv
410
1367
  return th_bar, epi_bar, vf_bar, deg_bar
411
1368
 
412
1369
 
413
1370
  def _compute_dnfr_common(
414
- G,
415
- data,
1371
+ G: TNFRGraph,
1372
+ data: MutableMapping[str, Any],
416
1373
  *,
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()
1374
+ x: Sequence[float],
1375
+ y: Sequence[float],
1376
+ epi_sum: Sequence[float],
1377
+ vf_sum: Sequence[float],
1378
+ count: Sequence[float] | None,
1379
+ deg_sum: Sequence[float] | None = None,
1380
+ degs: Sequence[float] | None = None,
1381
+ n_jobs: int | None = None,
1382
+ profile: MutableMapping[str, float] | None = None,
1383
+ ) -> None:
1384
+ """Compute neighbour means and apply ΔNFR gradients.
1385
+
1386
+ Parameters
1387
+ ----------
1388
+ profile : MutableMapping[str, float] or None, optional
1389
+ Mutable mapping that records wall-clock durations for the neighbour
1390
+ mean computation (``"dnfr_neighbor_means"``), the gradient assembly
1391
+ (``"dnfr_gradient_assembly"``) and the final in-place writes to the
1392
+ graph (``"dnfr_inplace_write"``).
1393
+ """
1394
+ start_timer, stop_timer = _profile_start_stop(
1395
+ profile,
1396
+ keys=("dnfr_neighbor_means", "dnfr_gradient_assembly", "dnfr_inplace_write"),
1397
+ )
1398
+
1399
+ np_module = get_numpy()
1400
+ if np_module is not None and isinstance(
1401
+ count, getattr(np_module, "ndarray", tuple)
1402
+ ):
1403
+ np_arg = np_module
1404
+ else:
1405
+ np_arg = None
1406
+ neighbor_timer = start_timer()
427
1407
  th_bar, epi_bar, vf_bar, deg_bar = _compute_neighbor_means(
428
1408
  G,
429
1409
  data,
@@ -434,121 +1414,724 @@ def _compute_dnfr_common(
434
1414
  count=count,
435
1415
  deg_sum=deg_sum,
436
1416
  degs=degs,
437
- np=np,
1417
+ np=np_arg,
1418
+ )
1419
+ stop_timer("dnfr_neighbor_means", neighbor_timer)
1420
+ _apply_dnfr_gradients(
1421
+ G,
1422
+ data,
1423
+ th_bar,
1424
+ epi_bar,
1425
+ vf_bar,
1426
+ deg_bar,
1427
+ degs,
1428
+ n_jobs=n_jobs,
1429
+ profile=profile,
438
1430
  )
439
- _apply_dnfr_gradients(G, data, th_bar, epi_bar, vf_bar, deg_bar, degs)
440
1431
 
441
1432
 
442
- def _init_neighbor_sums(data, *, np=None):
1433
+ def _reset_numpy_buffer(
1434
+ buffer: np.ndarray | None,
1435
+ size: int,
1436
+ np: ModuleType,
1437
+ ) -> np.ndarray:
1438
+ if (
1439
+ buffer is None
1440
+ or getattr(buffer, "shape", None) is None
1441
+ or buffer.shape[0] != size
1442
+ ):
1443
+ return np.zeros(size, dtype=float)
1444
+ buffer.fill(0.0)
1445
+ return buffer
1446
+
1447
+
1448
+ def _init_neighbor_sums(
1449
+ data: MutableMapping[str, Any],
1450
+ *,
1451
+ np: ModuleType | None = None,
1452
+ ) -> NeighborStats:
443
1453
  """Initialise containers for neighbour sums."""
444
1454
  nodes = data["nodes"]
445
1455
  n = len(nodes)
446
1456
  w_topo = data["w_topo"]
1457
+ cache: DnfrCache | None = data.get("cache")
1458
+
1459
+ def _reset_list(buffer: list[float] | None, value: float = 0.0) -> list[float]:
1460
+ if buffer is None or len(buffer) != n:
1461
+ return [value] * n
1462
+ for i in range(n):
1463
+ buffer[i] = value
1464
+ return buffer
1465
+
447
1466
  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
1467
+ if cache is not None:
1468
+ x = cache.neighbor_x_np
1469
+ y = cache.neighbor_y_np
1470
+ epi_sum = cache.neighbor_epi_sum_np
1471
+ vf_sum = cache.neighbor_vf_sum_np
1472
+ count = cache.neighbor_count_np
1473
+ x = _reset_numpy_buffer(x, n, np)
1474
+ y = _reset_numpy_buffer(y, n, np)
1475
+ epi_sum = _reset_numpy_buffer(epi_sum, n, np)
1476
+ vf_sum = _reset_numpy_buffer(vf_sum, n, np)
1477
+ count = _reset_numpy_buffer(count, n, np)
1478
+ cache.neighbor_x_np = x
1479
+ cache.neighbor_y_np = y
1480
+ cache.neighbor_epi_sum_np = epi_sum
1481
+ cache.neighbor_vf_sum_np = vf_sum
1482
+ cache.neighbor_count_np = count
1483
+ cache.neighbor_x = _reset_list(cache.neighbor_x)
1484
+ cache.neighbor_y = _reset_list(cache.neighbor_y)
1485
+ cache.neighbor_epi_sum = _reset_list(cache.neighbor_epi_sum)
1486
+ cache.neighbor_vf_sum = _reset_list(cache.neighbor_vf_sum)
1487
+ cache.neighbor_count = _reset_list(cache.neighbor_count)
1488
+ if w_topo != 0.0:
1489
+ deg_sum = _reset_numpy_buffer(cache.neighbor_deg_sum_np, n, np)
1490
+ cache.neighbor_deg_sum_np = deg_sum
1491
+ cache.neighbor_deg_sum = _reset_list(cache.neighbor_deg_sum)
1492
+ else:
1493
+ cache.neighbor_deg_sum_np = None
1494
+ cache.neighbor_deg_sum = None
1495
+ deg_sum = None
1496
+ else:
1497
+ x = np.zeros(n, dtype=float)
1498
+ y = np.zeros(n, dtype=float)
1499
+ epi_sum = np.zeros(n, dtype=float)
1500
+ vf_sum = np.zeros(n, dtype=float)
1501
+ count = np.zeros(n, dtype=float)
1502
+ deg_sum = np.zeros(n, dtype=float) if w_topo != 0.0 else None
454
1503
  degs = None
455
1504
  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
1505
+ if cache is not None:
1506
+ x = _reset_list(cache.neighbor_x)
1507
+ y = _reset_list(cache.neighbor_y)
1508
+ epi_sum = _reset_list(cache.neighbor_epi_sum)
1509
+ vf_sum = _reset_list(cache.neighbor_vf_sum)
1510
+ count = _reset_list(cache.neighbor_count)
1511
+ cache.neighbor_x = x
1512
+ cache.neighbor_y = y
1513
+ cache.neighbor_epi_sum = epi_sum
1514
+ cache.neighbor_vf_sum = vf_sum
1515
+ cache.neighbor_count = count
1516
+ if w_topo != 0.0:
1517
+ deg_sum = _reset_list(cache.neighbor_deg_sum)
1518
+ cache.neighbor_deg_sum = deg_sum
1519
+ else:
1520
+ cache.neighbor_deg_sum = None
1521
+ deg_sum = None
1522
+ else:
1523
+ x = [0.0] * n
1524
+ y = [0.0] * n
1525
+ epi_sum = [0.0] * n
1526
+ vf_sum = [0.0] * n
1527
+ count = [0.0] * n
1528
+ deg_sum = [0.0] * n if w_topo != 0.0 else None
461
1529
  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)
1530
+ if w_topo != 0.0 and deg_list is not None:
1531
+ degs = deg_list
465
1532
  else:
466
- deg_sum = None
467
1533
  degs = None
468
1534
  return x, y, epi_sum, vf_sum, count, deg_sum, degs
469
1535
 
470
1536
 
471
- def _build_neighbor_sums_common(G, data, *, use_numpy: bool):
472
- np = get_numpy()
1537
+ def _prefer_sparse_accumulation(n: int, edge_count: int | None) -> bool:
1538
+ """Return ``True`` when neighbour sums should use edge accumulation."""
1539
+
1540
+ if n <= 1 or not edge_count:
1541
+ return False
1542
+ possible_edges = n * (n - 1)
1543
+ if possible_edges <= 0:
1544
+ return False
1545
+ density = edge_count / possible_edges
1546
+ return density <= _SPARSE_DENSITY_THRESHOLD
1547
+
1548
+
1549
+ def _accumulate_neighbors_dense(
1550
+ G: TNFRGraph,
1551
+ data: MutableMapping[str, Any],
1552
+ *,
1553
+ x: np.ndarray,
1554
+ y: np.ndarray,
1555
+ epi_sum: np.ndarray,
1556
+ vf_sum: np.ndarray,
1557
+ count: np.ndarray,
1558
+ deg_sum: np.ndarray | None,
1559
+ np: ModuleType,
1560
+ ) -> NeighborStats:
1561
+ """Vectorised neighbour accumulation using a dense adjacency matrix."""
1562
+
473
1563
  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",
1564
+ if not nodes:
1565
+ return x, y, epi_sum, vf_sum, count, deg_sum, None
1566
+
1567
+ A = data.get("A")
1568
+ if A is None:
1569
+ return _accumulate_neighbors_numpy(
1570
+ G,
1571
+ data,
1572
+ x=x,
1573
+ y=y,
1574
+ epi_sum=epi_sum,
1575
+ vf_sum=vf_sum,
1576
+ count=count,
1577
+ deg_sum=deg_sum,
1578
+ np=np,
1579
+ )
1580
+
1581
+ cache: DnfrCache | None = data.get("cache")
1582
+ n = len(nodes)
1583
+
1584
+ state = _ensure_numpy_state_vectors(data, np)
1585
+ vectors = [state["cos"], state["sin"], state["epi"], state["vf"]]
1586
+
1587
+ components = _ensure_cached_array(cache, "dense_components_np", (n, 4), np)
1588
+ accum = _ensure_cached_array(cache, "dense_accum_np", (n, 4), np)
1589
+
1590
+ # ``components`` retains the last source copies so callers relying on
1591
+ # cached buffers (e.g. diagnostics) still observe meaningful values.
1592
+ np.copyto(components, np.column_stack(vectors), casting="unsafe")
1593
+
1594
+ np.matmul(A, components, out=accum)
1595
+
1596
+ np.copyto(x, accum[:, 0], casting="unsafe")
1597
+ np.copyto(y, accum[:, 1], casting="unsafe")
1598
+ np.copyto(epi_sum, accum[:, 2], casting="unsafe")
1599
+ np.copyto(vf_sum, accum[:, 3], casting="unsafe")
1600
+
1601
+ degree_counts = data.get("dense_degree_np")
1602
+ if degree_counts is None or getattr(degree_counts, "shape", (0,))[0] != n:
1603
+ degree_counts = None
1604
+ if degree_counts is None and cache is not None:
1605
+ cached_counts = cache.dense_degree_np
1606
+ if cached_counts is not None and getattr(cached_counts, "shape", (0,))[0] == n:
1607
+ degree_counts = cached_counts
1608
+ if degree_counts is None:
1609
+ degree_counts = A.sum(axis=1)
1610
+ if cache is not None:
1611
+ cache.dense_degree_np = degree_counts
1612
+ data["dense_degree_np"] = degree_counts
1613
+ np.copyto(count, degree_counts, casting="unsafe")
1614
+
1615
+ degs = None
1616
+ if deg_sum is not None:
1617
+ deg_array = data.get("deg_array")
1618
+ if deg_array is None:
1619
+ deg_array = _resolve_numpy_degree_array(
1620
+ data,
1621
+ count,
1622
+ cache=cache,
1623
+ np=np,
479
1624
  )
1625
+ if deg_array is None:
1626
+ deg_sum.fill(0.0)
1627
+ else:
1628
+ np.matmul(A, deg_array, out=deg_sum)
1629
+ degs = deg_array
1630
+
1631
+ return x, y, epi_sum, vf_sum, count, deg_sum, degs
1632
+
1633
+
1634
+ def _accumulate_neighbors_broadcasted(
1635
+ *,
1636
+ edge_src: np.ndarray,
1637
+ edge_dst: np.ndarray,
1638
+ cos: np.ndarray,
1639
+ sin: np.ndarray,
1640
+ epi: np.ndarray,
1641
+ vf: np.ndarray,
1642
+ x: np.ndarray,
1643
+ y: np.ndarray,
1644
+ epi_sum: np.ndarray,
1645
+ vf_sum: np.ndarray,
1646
+ count: np.ndarray | None,
1647
+ deg_sum: np.ndarray | None,
1648
+ deg_array: np.ndarray | None,
1649
+ cache: DnfrCache | None,
1650
+ np: ModuleType,
1651
+ chunk_size: int | None = None,
1652
+ ) -> dict[str, np.ndarray]:
1653
+ """Accumulate neighbour contributions using direct indexed reductions."""
1654
+
1655
+ n = x.shape[0]
1656
+ edge_count = int(edge_src.size)
1657
+
1658
+ include_count = count is not None
1659
+ use_topology = deg_sum is not None and deg_array is not None
1660
+
1661
+ component_rows = 4 + (1 if include_count else 0) + (1 if use_topology else 0)
1662
+
1663
+ if edge_count:
1664
+ if chunk_size is None:
1665
+ resolved_chunk = edge_count
1666
+ else:
1667
+ try:
1668
+ resolved_chunk = int(chunk_size)
1669
+ except (TypeError, ValueError):
1670
+ resolved_chunk = edge_count
1671
+ else:
1672
+ if resolved_chunk <= 0:
1673
+ resolved_chunk = edge_count
1674
+ resolved_chunk = max(1, min(edge_count, resolved_chunk))
1675
+ else:
1676
+ resolved_chunk = 0
1677
+
1678
+ use_chunks = bool(edge_count and resolved_chunk < edge_count)
1679
+
1680
+ if cache is not None:
1681
+ base_signature = (id(edge_src), id(edge_dst), n, edge_count)
1682
+ cache.edge_signature = base_signature
1683
+ signature = (base_signature, component_rows)
1684
+ previous_signature = cache.neighbor_accum_signature
1685
+
1686
+ accum = cache.neighbor_accum_np
1687
+ if (
1688
+ accum is None
1689
+ or getattr(accum, "shape", None) != (component_rows, n)
1690
+ or previous_signature != signature
1691
+ ):
1692
+ accum = np.zeros((component_rows, n), dtype=float)
1693
+ cache.neighbor_accum_np = accum
1694
+ else:
1695
+ accum.fill(0.0)
1696
+
1697
+ workspace = cache.neighbor_edge_values_np
1698
+ if use_chunks:
1699
+ workspace_length = resolved_chunk
1700
+ else:
1701
+ workspace_length = component_rows
1702
+ if workspace_length:
1703
+ expected_shape = (component_rows, workspace_length)
1704
+ if workspace is None or getattr(workspace, "shape", None) != expected_shape:
1705
+ workspace = np.empty(expected_shape, dtype=float)
1706
+ else:
1707
+ workspace = None
1708
+ cache.neighbor_edge_values_np = workspace
1709
+
1710
+ cache.neighbor_accum_signature = signature
1711
+ else:
1712
+ accum = np.zeros((component_rows, n), dtype=float)
1713
+ workspace_length = resolved_chunk if use_chunks else component_rows
1714
+ workspace = (
1715
+ np.empty((component_rows, workspace_length), dtype=float)
1716
+ if workspace_length
1717
+ else None
1718
+ )
1719
+
1720
+ if edge_count:
1721
+ row = 0
1722
+ cos_row = row
1723
+ row += 1
1724
+ sin_row = row
1725
+ row += 1
1726
+ epi_row = row
1727
+ row += 1
1728
+ vf_row = row
1729
+ row += 1
1730
+ count_row = row if include_count and count is not None else None
1731
+ if count_row is not None:
1732
+ row += 1
1733
+ deg_row = row if use_topology and deg_array is not None else None
1734
+
1735
+ edge_src_int = edge_src.astype(np.intp, copy=False)
1736
+ edge_dst_int = edge_dst.astype(np.intp, copy=False)
1737
+
1738
+ if use_chunks:
1739
+ chunk_step = resolved_chunk if resolved_chunk else edge_count
1740
+ chunk_indices = range(0, edge_count, chunk_step)
1741
+
1742
+ for start in chunk_indices:
1743
+ end = min(start + chunk_step, edge_count)
1744
+ if start >= end:
1745
+ continue
1746
+ src_slice = edge_src_int[start:end]
1747
+ dst_slice = edge_dst_int[start:end]
1748
+ slice_len = end - start
1749
+ if slice_len <= 0:
1750
+ continue
1751
+
1752
+ if workspace is not None:
1753
+ chunk_matrix = workspace[:, :slice_len]
1754
+ else:
1755
+ chunk_matrix = np.empty((component_rows, slice_len), dtype=float)
1756
+
1757
+ np.take(cos, dst_slice, out=chunk_matrix[cos_row, :slice_len])
1758
+ np.take(sin, dst_slice, out=chunk_matrix[sin_row, :slice_len])
1759
+ np.take(epi, dst_slice, out=chunk_matrix[epi_row, :slice_len])
1760
+ np.take(vf, dst_slice, out=chunk_matrix[vf_row, :slice_len])
1761
+
1762
+ if count_row is not None:
1763
+ chunk_matrix[count_row, :slice_len].fill(1.0)
1764
+ if deg_row is not None and deg_array is not None:
1765
+ np.take(deg_array, dst_slice, out=chunk_matrix[deg_row, :slice_len])
1766
+
1767
+ def _accumulate_into(
1768
+ target_row: int | None,
1769
+ values: np.ndarray | None = None,
1770
+ *,
1771
+ unit_weight: bool = False,
1772
+ ) -> None:
1773
+ if target_row is None:
1774
+ return
1775
+ row_view = accum[target_row]
1776
+ if unit_weight:
1777
+ np.add.at(row_view, src_slice, 1.0)
1778
+ else:
1779
+ if values is None:
1780
+ return
1781
+ np.add.at(row_view, src_slice, values)
1782
+
1783
+ _accumulate_into(cos_row, chunk_matrix[cos_row, :slice_len])
1784
+ _accumulate_into(sin_row, chunk_matrix[sin_row, :slice_len])
1785
+ _accumulate_into(epi_row, chunk_matrix[epi_row, :slice_len])
1786
+ _accumulate_into(vf_row, chunk_matrix[vf_row, :slice_len])
1787
+
1788
+ if count_row is not None:
1789
+ _accumulate_into(count_row, unit_weight=True)
1790
+
1791
+ if deg_row is not None and deg_array is not None:
1792
+ _accumulate_into(deg_row, chunk_matrix[deg_row, :slice_len])
1793
+ else:
1794
+ def _apply_full_bincount(
1795
+ target_row: int | None,
1796
+ values: np.ndarray | None = None,
1797
+ *,
1798
+ unit_weight: bool = False,
1799
+ ) -> None:
1800
+ if target_row is None:
1801
+ return
1802
+ if values is None and not unit_weight:
1803
+ return
1804
+ if unit_weight:
1805
+ component_accum = np.bincount(
1806
+ edge_src_int,
1807
+ minlength=n,
1808
+ )
1809
+ else:
1810
+ component_accum = np.bincount(
1811
+ edge_src_int,
1812
+ weights=values,
1813
+ minlength=n,
1814
+ )
1815
+ np.copyto(
1816
+ accum[target_row, : n],
1817
+ component_accum[:n],
1818
+ casting="unsafe",
1819
+ )
1820
+
1821
+ _apply_full_bincount(cos_row, np.take(cos, edge_dst_int))
1822
+ _apply_full_bincount(sin_row, np.take(sin, edge_dst_int))
1823
+ _apply_full_bincount(epi_row, np.take(epi, edge_dst_int))
1824
+ _apply_full_bincount(vf_row, np.take(vf, edge_dst_int))
1825
+
1826
+ if count_row is not None:
1827
+ _apply_full_bincount(count_row, unit_weight=True)
1828
+
1829
+ if deg_row is not None and deg_array is not None:
1830
+ _apply_full_bincount(deg_row, np.take(deg_array, edge_dst_int))
1831
+ else:
1832
+ accum.fill(0.0)
1833
+ if workspace is not None:
1834
+ workspace.fill(0.0)
1835
+
1836
+ row = 0
1837
+ np.copyto(x, accum[row], casting="unsafe")
1838
+ row += 1
1839
+ np.copyto(y, accum[row], casting="unsafe")
1840
+ row += 1
1841
+ np.copyto(epi_sum, accum[row], casting="unsafe")
1842
+ row += 1
1843
+ np.copyto(vf_sum, accum[row], casting="unsafe")
1844
+ row += 1
1845
+
1846
+ if include_count and count is not None:
1847
+ np.copyto(count, accum[row], casting="unsafe")
1848
+ row += 1
1849
+
1850
+ if use_topology and deg_sum is not None:
1851
+ np.copyto(deg_sum, accum[row], casting="unsafe")
1852
+
1853
+ return {
1854
+ "accumulator": accum,
1855
+ "edge_values": workspace,
1856
+ }
1857
+
1858
+
1859
+ def _build_neighbor_sums_common(
1860
+ G: TNFRGraph,
1861
+ data: MutableMapping[str, Any],
1862
+ *,
1863
+ use_numpy: bool,
1864
+ n_jobs: int | None = None,
1865
+ ) -> NeighborStats:
1866
+ """Build neighbour accumulators honouring cached NumPy buffers when possible."""
1867
+
1868
+ nodes = data["nodes"]
1869
+ cache: DnfrCache | None = data.get("cache")
1870
+ np_module = get_numpy()
1871
+ has_numpy_buffers = _has_cached_numpy_buffers(data, cache)
1872
+ if use_numpy and np_module is None and has_numpy_buffers:
1873
+ np_module = sys.modules.get("numpy")
1874
+
1875
+ if np_module is not None:
480
1876
  if not nodes:
481
- return None
1877
+ return _init_neighbor_sums(data, np=np_module)
1878
+
482
1879
  x, y, epi_sum, vf_sum, count, deg_sum, degs = _init_neighbor_sums(
483
- data, np=np
1880
+ data, np=np_module
484
1881
  )
1882
+ prefer_sparse = data.get("prefer_sparse")
1883
+ if prefer_sparse is None:
1884
+ prefer_sparse = _prefer_sparse_accumulation(
1885
+ len(nodes), data.get("edge_count")
1886
+ )
1887
+ data["prefer_sparse"] = prefer_sparse
1888
+
1889
+ use_dense = False
485
1890
  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
1891
+ if use_numpy and not prefer_sparse and A is not None:
1892
+ shape = getattr(A, "shape", (0, 0))
1893
+ use_dense = shape[0] == len(nodes) and shape[1] == len(nodes)
1894
+ if use_numpy and data.get("dense_override") and A is not None:
1895
+ shape = getattr(A, "shape", (0, 0))
1896
+ if shape[0] == len(nodes) and shape[1] == len(nodes):
1897
+ use_dense = True
1898
+
1899
+ if use_dense:
1900
+ accumulator = _accumulate_neighbors_dense
1901
+ else:
1902
+ _ensure_numpy_state_vectors(data, np_module)
1903
+ accumulator = _accumulate_neighbors_numpy
1904
+ return accumulator(
1905
+ G,
1906
+ data,
1907
+ x=x,
1908
+ y=y,
1909
+ epi_sum=epi_sum,
1910
+ vf_sum=vf_sum,
1911
+ count=count,
1912
+ deg_sum=deg_sum,
1913
+ np=np_module,
530
1914
  )
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
1915
+
1916
+ if not nodes:
1917
+ return _init_neighbor_sums(data)
1918
+
1919
+ x, y, epi_sum, vf_sum, count, deg_sum, degs_list = _init_neighbor_sums(data)
1920
+ idx = data["idx"]
1921
+ epi = data["epi"]
1922
+ vf = data["vf"]
1923
+ cos_th = data["cos_theta"]
1924
+ sin_th = data["sin_theta"]
1925
+ deg_list = data.get("deg_list")
1926
+
1927
+ effective_jobs = _resolve_parallel_jobs(n_jobs, len(nodes))
1928
+ if effective_jobs:
1929
+ neighbor_indices: list[list[int]] = []
1930
+ for node in nodes:
1931
+ indices: list[int] = []
539
1932
  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
1933
+ indices.append(idx[v])
1934
+ neighbor_indices.append(indices)
1935
+
1936
+ chunk_results = []
1937
+ with ProcessPoolExecutor(max_workers=effective_jobs) as executor:
1938
+ futures = []
1939
+ for start, end in _iter_chunk_offsets(len(nodes), effective_jobs):
1940
+ if start == end:
1941
+ continue
1942
+ futures.append(
1943
+ executor.submit(
1944
+ _neighbor_sums_worker,
1945
+ start,
1946
+ end,
1947
+ neighbor_indices,
1948
+ cos_th,
1949
+ sin_th,
1950
+ epi,
1951
+ vf,
1952
+ x[start:end],
1953
+ y[start:end],
1954
+ epi_sum[start:end],
1955
+ vf_sum[start:end],
1956
+ count[start:end],
1957
+ deg_sum[start:end] if deg_sum is not None else None,
1958
+ deg_list,
1959
+ degs_list,
1960
+ )
1961
+ )
1962
+ for future in futures:
1963
+ chunk_results.append(future.result())
1964
+
1965
+ for (
1966
+ start,
1967
+ chunk_x,
1968
+ chunk_y,
1969
+ chunk_epi,
1970
+ chunk_vf,
1971
+ chunk_count,
1972
+ chunk_deg,
1973
+ ) in sorted(chunk_results, key=lambda item: item[0]):
1974
+ end = start + len(chunk_x)
1975
+ x[start:end] = chunk_x
1976
+ y[start:end] = chunk_y
1977
+ epi_sum[start:end] = chunk_epi
1978
+ vf_sum[start:end] = chunk_vf
1979
+ count[start:end] = chunk_count
1980
+ if deg_sum is not None and chunk_deg is not None:
1981
+ deg_sum[start:end] = chunk_deg
548
1982
  return x, y, epi_sum, vf_sum, count, deg_sum, degs_list
549
1983
 
1984
+ for i, node in enumerate(nodes):
1985
+ deg_i = degs_list[i] if degs_list is not None else 0.0
1986
+ x_i = x[i]
1987
+ y_i = y[i]
1988
+ epi_i = epi_sum[i]
1989
+ vf_i = vf_sum[i]
1990
+ count_i = count[i]
1991
+ deg_acc = deg_sum[i] if deg_sum is not None else 0.0
1992
+ for v in G.neighbors(node):
1993
+ j = idx[v]
1994
+ cos_j = cos_th[j]
1995
+ sin_j = sin_th[j]
1996
+ epi_j = epi[j]
1997
+ vf_j = vf[j]
1998
+ x_i += cos_j
1999
+ y_i += sin_j
2000
+ epi_i += epi_j
2001
+ vf_i += vf_j
2002
+ count_i += 1
2003
+ if deg_sum is not None:
2004
+ deg_acc += deg_list[j] if deg_list is not None else deg_i
2005
+ x[i] = x_i
2006
+ y[i] = y_i
2007
+ epi_sum[i] = epi_i
2008
+ vf_sum[i] = vf_i
2009
+ count[i] = count_i
2010
+ if deg_sum is not None:
2011
+ deg_sum[i] = deg_acc
2012
+ return x, y, epi_sum, vf_sum, count, deg_sum, degs_list
2013
+
2014
+
2015
+ def _accumulate_neighbors_numpy(
2016
+ G: TNFRGraph,
2017
+ data: MutableMapping[str, Any],
2018
+ *,
2019
+ x: np.ndarray,
2020
+ y: np.ndarray,
2021
+ epi_sum: np.ndarray,
2022
+ vf_sum: np.ndarray,
2023
+ count: np.ndarray | None,
2024
+ deg_sum: np.ndarray | None,
2025
+ np: ModuleType,
2026
+ ) -> NeighborStats:
2027
+ """Vectorised neighbour accumulation reusing cached NumPy buffers."""
550
2028
 
551
- def _compute_dnfr(G, data, *, use_numpy: bool = False) -> None:
2029
+ nodes = data["nodes"]
2030
+ if not nodes:
2031
+ return x, y, epi_sum, vf_sum, count, deg_sum, None
2032
+
2033
+ cache: DnfrCache | None = data.get("cache")
2034
+
2035
+ state = _ensure_numpy_state_vectors(data, np)
2036
+ cos_th = state["cos"]
2037
+ sin_th = state["sin"]
2038
+ epi = state["epi"]
2039
+ vf = state["vf"]
2040
+
2041
+ edge_src = data.get("edge_src")
2042
+ edge_dst = data.get("edge_dst")
2043
+ if edge_src is None or edge_dst is None:
2044
+ edge_src, edge_dst = _build_edge_index_arrays(G, nodes, data["idx"], np)
2045
+ data["edge_src"] = edge_src
2046
+ data["edge_dst"] = edge_dst
2047
+ if cache is not None:
2048
+ cache.edge_src = edge_src
2049
+ cache.edge_dst = edge_dst
2050
+ if edge_src is not None:
2051
+ data["edge_count"] = int(edge_src.size)
2052
+
2053
+ cached_deg_array = data.get("deg_array")
2054
+ reuse_count_from_deg = bool(count is not None and cached_deg_array is not None)
2055
+ count_for_accum = count
2056
+ if count is not None:
2057
+ if reuse_count_from_deg:
2058
+ # Reuse the cached degree vector as neighbour counts to avoid
2059
+ # allocating an extra accumulator row in the broadcast routine.
2060
+ np.copyto(count, cached_deg_array, casting="unsafe")
2061
+ count_for_accum = None
2062
+ else:
2063
+ count.fill(0.0)
2064
+
2065
+ deg_array = None
2066
+ if deg_sum is not None:
2067
+ deg_sum.fill(0.0)
2068
+ deg_array = _resolve_numpy_degree_array(
2069
+ data, count if count is not None else None, cache=cache, np=np
2070
+ )
2071
+ elif cached_deg_array is not None:
2072
+ deg_array = cached_deg_array
2073
+
2074
+ edge_count = int(edge_src.size) if edge_src is not None else 0
2075
+ chunk_hint = data.get("neighbor_chunk_hint")
2076
+ if chunk_hint is None:
2077
+ chunk_hint = G.graph.get("DNFR_CHUNK_SIZE")
2078
+ resolved_neighbor_chunk = (
2079
+ resolve_chunk_size(
2080
+ chunk_hint,
2081
+ edge_count,
2082
+ minimum=1,
2083
+ approx_bytes_per_item=_DNFR_APPROX_BYTES_PER_EDGE,
2084
+ clamp_to=None,
2085
+ )
2086
+ if edge_count
2087
+ else 0
2088
+ )
2089
+ data["neighbor_chunk_hint"] = chunk_hint
2090
+ data["neighbor_chunk_size"] = resolved_neighbor_chunk
2091
+
2092
+ accum = _accumulate_neighbors_broadcasted(
2093
+ edge_src=edge_src,
2094
+ edge_dst=edge_dst,
2095
+ cos=cos_th,
2096
+ sin=sin_th,
2097
+ epi=epi,
2098
+ vf=vf,
2099
+ x=x,
2100
+ y=y,
2101
+ epi_sum=epi_sum,
2102
+ vf_sum=vf_sum,
2103
+ count=count_for_accum,
2104
+ deg_sum=deg_sum,
2105
+ deg_array=deg_array,
2106
+ cache=cache,
2107
+ np=np,
2108
+ chunk_size=resolved_neighbor_chunk,
2109
+ )
2110
+
2111
+ data["neighbor_accum_np"] = accum.get("accumulator")
2112
+ edge_values = accum.get("edge_values")
2113
+ data["neighbor_edge_values_np"] = edge_values
2114
+ if edge_values is not None:
2115
+ width = getattr(edge_values, "shape", (0, 0))[1]
2116
+ data["neighbor_chunk_size"] = int(width)
2117
+ else:
2118
+ data["neighbor_chunk_size"] = resolved_neighbor_chunk
2119
+ if cache is not None:
2120
+ data["neighbor_accum_signature"] = cache.neighbor_accum_signature
2121
+ if reuse_count_from_deg and cached_deg_array is not None:
2122
+ count = cached_deg_array
2123
+ degs = deg_array if deg_sum is not None and deg_array is not None else None
2124
+ return x, y, epi_sum, vf_sum, count, deg_sum, degs
2125
+
2126
+
2127
+ def _compute_dnfr(
2128
+ G: TNFRGraph,
2129
+ data: MutableMapping[str, Any],
2130
+ *,
2131
+ use_numpy: bool | None = None,
2132
+ n_jobs: int | None = None,
2133
+ profile: MutableMapping[str, float] | None = None,
2134
+ ) -> None:
552
2135
  """Compute ΔNFR using neighbour sums.
553
2136
 
554
2137
  Parameters
@@ -557,11 +2140,56 @@ def _compute_dnfr(G, data, *, use_numpy: bool = False) -> None:
557
2140
  Graph on which the computation is performed.
558
2141
  data : dict
559
2142
  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.
2143
+ use_numpy : bool | None, optional
2144
+ Backwards compatibility flag. When ``True`` the function eagerly
2145
+ prepares NumPy buffers (if available). When ``False`` the engine still
2146
+ prefers the vectorised path whenever :func:`get_numpy` returns a module
2147
+ and the graph does not set ``vectorized_dnfr`` to ``False``.
2148
+ profile : MutableMapping[str, float] or None, optional
2149
+ Mutable mapping that aggregates wall-clock durations for neighbour
2150
+ accumulation and records which execution branch was used. The
2151
+ ``"dnfr_neighbor_accumulation"`` bucket gathers the time spent inside
2152
+ :func:`_build_neighbor_sums_common`, while ``"dnfr_path"`` stores the
2153
+ string ``"vectorized"`` or ``"fallback"`` describing the active
2154
+ implementation.
563
2155
  """
564
- res = _build_neighbor_sums_common(G, data, use_numpy=use_numpy)
2156
+ start_timer, stop_timer = _profile_start_stop(
2157
+ profile,
2158
+ keys=("dnfr_neighbor_accumulation",),
2159
+ )
2160
+
2161
+ np_module = get_numpy()
2162
+ data["dnfr_numpy_available"] = bool(np_module)
2163
+ vector_disabled = G.graph.get("vectorized_dnfr") is False
2164
+ prefer_dense = np_module is not None and not vector_disabled
2165
+ if use_numpy is True and np_module is not None:
2166
+ prefer_dense = True
2167
+ if use_numpy is False or vector_disabled:
2168
+ prefer_dense = False
2169
+ data["dnfr_used_numpy"] = bool(prefer_dense and np_module is not None)
2170
+ if profile is not None:
2171
+ profile["dnfr_path"] = "vectorized" if data["dnfr_used_numpy"] else "fallback"
2172
+
2173
+ data["n_jobs"] = n_jobs
2174
+ try:
2175
+ neighbor_timer = start_timer()
2176
+ res = _build_neighbor_sums_common(
2177
+ G,
2178
+ data,
2179
+ use_numpy=prefer_dense,
2180
+ n_jobs=n_jobs,
2181
+ )
2182
+ stop_timer("dnfr_neighbor_accumulation", neighbor_timer)
2183
+ except TypeError as exc:
2184
+ if "n_jobs" not in str(exc):
2185
+ raise
2186
+ neighbor_timer = start_timer()
2187
+ res = _build_neighbor_sums_common(
2188
+ G,
2189
+ data,
2190
+ use_numpy=prefer_dense,
2191
+ )
2192
+ stop_timer("dnfr_neighbor_accumulation", neighbor_timer)
565
2193
  if res is None:
566
2194
  return
567
2195
  x, y, epi_sum, vf_sum, count, deg_sum, degs = res
@@ -575,10 +2203,18 @@ def _compute_dnfr(G, data, *, use_numpy: bool = False) -> None:
575
2203
  count=count,
576
2204
  deg_sum=deg_sum,
577
2205
  degs=degs,
2206
+ n_jobs=n_jobs,
2207
+ profile=profile,
578
2208
  )
579
2209
 
580
2210
 
581
- def default_compute_delta_nfr(G, *, cache_size: int | None = 1) -> None:
2211
+ def default_compute_delta_nfr(
2212
+ G: TNFRGraph,
2213
+ *,
2214
+ cache_size: int | None = 1,
2215
+ n_jobs: int | None = None,
2216
+ profile: MutableMapping[str, float] | None = None,
2217
+ ) -> None:
582
2218
  """Compute ΔNFR by mixing phase, EPI, νf and a topological term.
583
2219
 
584
2220
  Parameters
@@ -589,29 +2225,97 @@ def default_compute_delta_nfr(G, *, cache_size: int | None = 1) -> None:
589
2225
  Maximum number of edge configurations cached in ``G.graph``. Values
590
2226
  ``None`` or <= 0 imply unlimited cache. Defaults to ``1`` to keep the
591
2227
  previous behaviour.
2228
+ n_jobs : int | None, optional
2229
+ Parallel worker count for the pure-Python accumulation path. ``None``
2230
+ or values <= 1 preserve the serial behaviour. The vectorised NumPy
2231
+ branch ignores this parameter as it already operates in bulk.
2232
+ profile : MutableMapping[str, float] or None, optional
2233
+ Mutable mapping that aggregates the wall-clock timings captured during
2234
+ the ΔNFR computation. The mapping receives the buckets documented in
2235
+ :func:`_prepare_dnfr_data` and :func:`_compute_dnfr`, plus
2236
+ ``"dnfr_neighbor_means"``, ``"dnfr_gradient_assembly"`` and
2237
+ ``"dnfr_inplace_write"`` describing the internal stages of
2238
+ :func:`_compute_dnfr_common`. ``"dnfr_path"`` reflects whether the
2239
+ vectorised or fallback implementation executed the call.
592
2240
  """
593
- data = _prepare_dnfr_data(G, cache_size=cache_size)
2241
+ if profile is not None:
2242
+ for key in (
2243
+ "dnfr_cache_rebuild",
2244
+ "dnfr_neighbor_accumulation",
2245
+ "dnfr_neighbor_means",
2246
+ "dnfr_gradient_assembly",
2247
+ "dnfr_inplace_write",
2248
+ ):
2249
+ profile.setdefault(key, 0.0)
2250
+
2251
+ data = _prepare_dnfr_data(G, cache_size=cache_size, profile=profile)
594
2252
  _write_dnfr_metadata(
595
2253
  G,
596
2254
  weights=data["weights"],
597
2255
  hook_name="default_compute_delta_nfr",
598
2256
  )
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)
2257
+ _compute_dnfr(G, data, n_jobs=n_jobs, profile=profile)
2258
+ if not data.get("dnfr_numpy_available"):
2259
+ cache = data.get("cache")
2260
+ cache_size = data.get("cache_size")
2261
+ caching_enabled = (
2262
+ isinstance(cache, DnfrCache)
2263
+ and (cache_size is None or int(cache_size) > 0)
2264
+ )
2265
+ if isinstance(cache, DnfrCache) and not caching_enabled:
2266
+ for attr in (
2267
+ "neighbor_x_np",
2268
+ "neighbor_y_np",
2269
+ "neighbor_epi_sum_np",
2270
+ "neighbor_vf_sum_np",
2271
+ "neighbor_count_np",
2272
+ "neighbor_deg_sum_np",
2273
+ "neighbor_inv_count_np",
2274
+ "neighbor_cos_avg_np",
2275
+ "neighbor_sin_avg_np",
2276
+ "neighbor_mean_tmp_np",
2277
+ "neighbor_mean_length_np",
2278
+ "neighbor_accum_np",
2279
+ "neighbor_edge_values_np",
2280
+ ):
2281
+ setattr(cache, attr, None)
2282
+ cache.neighbor_accum_signature = None
602
2283
 
603
2284
 
604
2285
  def set_delta_nfr_hook(
605
- G, func, *, name: str | None = None, note: str | None = None
2286
+ G: TNFRGraph,
2287
+ func: DeltaNFRHook,
2288
+ *,
2289
+ name: str | None = None,
2290
+ note: str | None = None,
606
2291
  ) -> None:
607
2292
  """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.
2293
+
2294
+ The callable should accept ``(G, *[, n_jobs])`` and is responsible for
2295
+ writing ``ALIAS_DNFR`` in each node. ``n_jobs`` is optional and ignored by
2296
+ hooks that do not support parallel execution. Basic metadata in
2297
+ ``G.graph`` is updated accordingly.
610
2298
  """
611
- G.graph["compute_delta_nfr"] = func
612
- G.graph["_dnfr_hook_name"] = str(
613
- name or getattr(func, "__name__", "custom_dnfr")
614
- )
2299
+
2300
+ def _wrapped(graph: TNFRGraph, *args: Any, **kwargs: Any) -> None:
2301
+ if "n_jobs" in kwargs:
2302
+ try:
2303
+ func(graph, *args, **kwargs)
2304
+ return
2305
+ except TypeError as exc:
2306
+ if "n_jobs" not in str(exc):
2307
+ raise
2308
+ kwargs = dict(kwargs)
2309
+ kwargs.pop("n_jobs", None)
2310
+ func(graph, *args, **kwargs)
2311
+ return
2312
+ func(graph, *args, **kwargs)
2313
+
2314
+ _wrapped.__name__ = getattr(func, "__name__", "custom_dnfr")
2315
+ _wrapped.__doc__ = getattr(func, "__doc__", _wrapped.__doc__)
2316
+
2317
+ G.graph["compute_delta_nfr"] = _wrapped
2318
+ G.graph["_dnfr_hook_name"] = str(name or getattr(func, "__name__", "custom_dnfr"))
615
2319
  if "_dnfr_weights" not in G.graph:
616
2320
  _configure_dnfr_weights(G)
617
2321
  if note:
@@ -620,114 +2324,293 @@ def set_delta_nfr_hook(
620
2324
  G.graph["_DNFR_META"] = meta
621
2325
 
622
2326
 
2327
+ def _dnfr_hook_chunk_worker(
2328
+ G: TNFRGraph,
2329
+ node_ids: Sequence[NodeId],
2330
+ grad_items: tuple[
2331
+ tuple[str, Callable[[TNFRGraph, NodeId, Mapping[str, Any]], float]],
2332
+ ...,
2333
+ ],
2334
+ weights: Mapping[str, float],
2335
+ ) -> list[tuple[NodeId, float]]:
2336
+ """Compute weighted gradients for ``node_ids``.
2337
+
2338
+ The helper is defined at module level so it can be pickled by
2339
+ :class:`concurrent.futures.ProcessPoolExecutor`.
2340
+ """
2341
+
2342
+ results: list[tuple[NodeId, float]] = []
2343
+ for node in node_ids:
2344
+ nd = G.nodes[node]
2345
+ total = 0.0
2346
+ for name, func in grad_items:
2347
+ w = weights.get(name, 0.0)
2348
+ if w:
2349
+ total += w * float(func(G, node, nd))
2350
+ results.append((node, total))
2351
+ return results
2352
+
2353
+
623
2354
  def _apply_dnfr_hook(
624
- G,
625
- grads: dict[str, Callable[[Any, Any], float]],
2355
+ G: TNFRGraph,
2356
+ grads: Mapping[str, Callable[[TNFRGraph, NodeId, Mapping[str, Any]], float]],
626
2357
  *,
627
- weights: dict[str, float],
2358
+ weights: Mapping[str, float],
628
2359
  hook_name: str,
629
2360
  note: str | None = None,
2361
+ n_jobs: int | None = None,
630
2362
  ) -> None:
631
- """Generic helper to compute and store ΔNFR using ``grads``.
2363
+ """Compute and store ΔNFR using ``grads``.
632
2364
 
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`.
2365
+ Parameters
2366
+ ----------
2367
+ G : nx.Graph
2368
+ Graph whose nodes will receive the ΔNFR update.
2369
+ grads : dict
2370
+ Mapping from component names to callables with signature
2371
+ ``(G, node, data) -> float`` returning the gradient contribution.
2372
+ weights : dict
2373
+ Weight per component; missing entries default to ``0``.
2374
+ hook_name : str
2375
+ Friendly identifier stored in ``G.graph`` metadata.
2376
+ note : str | None, optional
2377
+ Additional documentation recorded next to the hook metadata.
2378
+ n_jobs : int | None, optional
2379
+ Optional worker count for the pure-Python execution path. When NumPy
2380
+ is available the helper always prefers the vectorised implementation
2381
+ and ignores ``n_jobs`` because the computation already happens in
2382
+ bulk.
636
2383
  """
637
2384
 
638
- for n, nd in G.nodes(data=True):
639
- total = 0.0
2385
+ nodes_data: list[tuple[NodeId, Mapping[str, Any]]] = list(G.nodes(data=True))
2386
+ if not nodes_data:
2387
+ _write_dnfr_metadata(G, weights=weights, hook_name=hook_name, note=note)
2388
+ return
2389
+
2390
+ np_module = cast(ModuleType | None, get_numpy())
2391
+ if np_module is not None:
2392
+ totals = np_module.zeros(len(nodes_data), dtype=float)
640
2393
  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)
2394
+ w = float(weights.get(name, 0.0))
2395
+ if w == 0.0:
2396
+ continue
2397
+ values = np_module.fromiter(
2398
+ (float(func(G, n, nd)) for n, nd in nodes_data),
2399
+ dtype=float,
2400
+ count=len(nodes_data),
2401
+ )
2402
+ if w == 1.0:
2403
+ np_module.add(totals, values, out=totals)
2404
+ else:
2405
+ np_module.add(totals, values * w, out=totals)
2406
+ for idx, (n, _) in enumerate(nodes_data):
2407
+ set_dnfr(G, n, float(totals[idx]))
2408
+ _write_dnfr_metadata(G, weights=weights, hook_name=hook_name, note=note)
2409
+ return
2410
+
2411
+ effective_jobs = _resolve_parallel_jobs(n_jobs, len(nodes_data))
2412
+ results: list[tuple[NodeId, float]] | None = None
2413
+ if effective_jobs:
2414
+ grad_items = tuple(grads.items())
2415
+ try:
2416
+ import pickle
2417
+
2418
+ pickle.dumps((grad_items, weights, G), protocol=pickle.HIGHEST_PROTOCOL)
2419
+ except Exception:
2420
+ effective_jobs = None
2421
+ else:
2422
+ chunk_results: list[tuple[NodeId, float]] = []
2423
+ with ProcessPoolExecutor(max_workers=effective_jobs) as executor:
2424
+ futures = []
2425
+ node_ids: list[NodeId] = [n for n, _ in nodes_data]
2426
+ for start, end in _iter_chunk_offsets(len(node_ids), effective_jobs):
2427
+ if start == end:
2428
+ continue
2429
+ futures.append(
2430
+ executor.submit(
2431
+ _dnfr_hook_chunk_worker,
2432
+ G,
2433
+ node_ids[start:end],
2434
+ grad_items,
2435
+ weights,
2436
+ )
2437
+ )
2438
+ for future in futures:
2439
+ chunk_results.extend(future.result())
2440
+ results = chunk_results
2441
+
2442
+ if results is None:
2443
+ results = []
2444
+ for n, nd in nodes_data:
2445
+ total = 0.0
2446
+ for name, func in grads.items():
2447
+ w = weights.get(name, 0.0)
2448
+ if w:
2449
+ total += w * float(func(G, n, nd))
2450
+ results.append((n, total))
2451
+
2452
+ for node, value in results:
2453
+ set_dnfr(G, node, float(value))
645
2454
 
646
2455
  _write_dnfr_metadata(G, weights=weights, hook_name=hook_name, note=note)
647
2456
 
648
2457
 
649
- # --- Hooks de ejemplo (opcionales) ---
650
- def dnfr_phase_only(G) -> None:
651
- """Example: ΔNFR from phase only (Kuramoto-like)."""
2458
+ # --- Example hooks (optional) ---
2459
+
652
2460
 
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)
2461
+ class _PhaseGradient:
2462
+ """Callable computing the phase contribution using cached trig values."""
2463
+
2464
+ __slots__ = ("cos", "sin")
2465
+
2466
+ def __init__(
2467
+ self,
2468
+ cos_map: Mapping[NodeId, float],
2469
+ sin_map: Mapping[NodeId, float],
2470
+ ) -> None:
2471
+ self.cos: Mapping[NodeId, float] = cos_map
2472
+ self.sin: Mapping[NodeId, float] = sin_map
2473
+
2474
+ def __call__(
2475
+ self,
2476
+ G: TNFRGraph,
2477
+ n: NodeId,
2478
+ nd: Mapping[str, Any],
2479
+ ) -> float:
2480
+ theta_val = get_theta_attr(nd, 0.0)
2481
+ th_i = float(theta_val if theta_val is not None else 0.0)
2482
+ neighbors = list(G.neighbors(n))
2483
+ if neighbors:
2484
+ th_bar = neighbor_phase_mean_list(
2485
+ neighbors,
2486
+ cos_th=self.cos,
2487
+ sin_th=self.sin,
2488
+ fallback=th_i,
2489
+ )
2490
+ else:
2491
+ th_bar = th_i
656
2492
  return -angle_diff(th_i, th_bar) / math.pi
657
2493
 
2494
+
2495
+ class _NeighborAverageGradient:
2496
+ """Callable computing neighbour averages for scalar attributes."""
2497
+
2498
+ __slots__ = ("alias", "values")
2499
+
2500
+ def __init__(
2501
+ self,
2502
+ alias: tuple[str, ...],
2503
+ values: MutableMapping[NodeId, float],
2504
+ ) -> None:
2505
+ self.alias: tuple[str, ...] = alias
2506
+ self.values: MutableMapping[NodeId, float] = values
2507
+
2508
+ def __call__(
2509
+ self,
2510
+ G: TNFRGraph,
2511
+ n: NodeId,
2512
+ nd: Mapping[str, Any],
2513
+ ) -> float:
2514
+ val = self.values.get(n)
2515
+ if val is None:
2516
+ val = float(get_attr(nd, self.alias, 0.0))
2517
+ self.values[n] = val
2518
+ neighbors = list(G.neighbors(n))
2519
+ if not neighbors:
2520
+ return 0.0
2521
+ total = 0.0
2522
+ for neigh in neighbors:
2523
+ neigh_val = self.values.get(neigh)
2524
+ if neigh_val is None:
2525
+ neigh_val = float(get_attr(G.nodes[neigh], self.alias, val))
2526
+ self.values[neigh] = neigh_val
2527
+ total += neigh_val
2528
+ return total / len(neighbors) - val
2529
+
2530
+
2531
+ def dnfr_phase_only(G: TNFRGraph, *, n_jobs: int | None = None) -> None:
2532
+ """Compute ΔNFR from phase only (Kuramoto-like).
2533
+
2534
+ Parameters
2535
+ ----------
2536
+ G : nx.Graph
2537
+ Graph whose nodes receive the ΔNFR assignment.
2538
+ n_jobs : int | None, optional
2539
+ Parallel worker hint used when NumPy is unavailable. Defaults to
2540
+ serial execution.
2541
+ """
2542
+
2543
+ trig = compute_theta_trig(G.nodes(data=True))
2544
+ g_phase = _PhaseGradient(trig.cos, trig.sin)
658
2545
  _apply_dnfr_hook(
659
2546
  G,
660
2547
  {"phase": g_phase},
661
2548
  weights={"phase": 1.0},
662
2549
  hook_name="dnfr_phase_only",
663
- note="Hook de ejemplo.",
2550
+ note="Example hook.",
2551
+ n_jobs=n_jobs,
664
2552
  )
665
2553
 
666
2554
 
667
- def dnfr_epi_vf_mixed(G) -> None:
668
- """Example: ΔNFR without phase, mixing EPI and νf."""
2555
+ def dnfr_epi_vf_mixed(G: TNFRGraph, *, n_jobs: int | None = None) -> None:
2556
+ """Compute ΔNFR without phase, mixing EPI and νf.
669
2557
 
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
2558
+ Parameters
2559
+ ----------
2560
+ G : nx.Graph
2561
+ Graph whose nodes receive the ΔNFR assignment.
2562
+ n_jobs : int | None, optional
2563
+ Parallel worker hint used when NumPy is unavailable. Defaults to
2564
+ serial execution.
2565
+ """
693
2566
 
2567
+ epi_values = {
2568
+ n: float(get_attr(nd, ALIAS_EPI, 0.0)) for n, nd in G.nodes(data=True)
2569
+ }
2570
+ vf_values = {n: float(get_attr(nd, ALIAS_VF, 0.0)) for n, nd in G.nodes(data=True)}
2571
+ grads = {
2572
+ "epi": _NeighborAverageGradient(ALIAS_EPI, epi_values),
2573
+ "vf": _NeighborAverageGradient(ALIAS_VF, vf_values),
2574
+ }
694
2575
  _apply_dnfr_hook(
695
2576
  G,
696
- {"epi": g_epi, "vf": g_vf},
2577
+ grads,
697
2578
  weights={"phase": 0.0, "epi": 0.5, "vf": 0.5},
698
2579
  hook_name="dnfr_epi_vf_mixed",
699
- note="Hook de ejemplo.",
2580
+ note="Example hook.",
2581
+ n_jobs=n_jobs,
700
2582
  )
701
2583
 
702
2584
 
703
- def dnfr_laplacian(G) -> None:
704
- """Explicit topological gradient using Laplacian over EPI and νf."""
2585
+ def dnfr_laplacian(G: TNFRGraph, *, n_jobs: int | None = None) -> None:
2586
+ """Explicit topological gradient using Laplacian over EPI and νf.
2587
+
2588
+ Parameters
2589
+ ----------
2590
+ G : nx.Graph
2591
+ Graph whose nodes receive the ΔNFR assignment.
2592
+ n_jobs : int | None, optional
2593
+ Parallel worker hint used when NumPy is unavailable. Defaults to
2594
+ serial execution.
2595
+ """
2596
+
705
2597
  weights_cfg = get_param(G, "DNFR_WEIGHTS")
706
2598
  wE = float(weights_cfg.get("epi", DEFAULTS["DNFR_WEIGHTS"]["epi"]))
707
2599
  wV = float(weights_cfg.get("vf", DEFAULTS["DNFR_WEIGHTS"]["vf"]))
708
2600
 
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
-
2601
+ epi_values = {
2602
+ n: float(get_attr(nd, ALIAS_EPI, 0.0)) for n, nd in G.nodes(data=True)
2603
+ }
2604
+ vf_values = {n: float(get_attr(nd, ALIAS_VF, 0.0)) for n, nd in G.nodes(data=True)}
2605
+ grads = {
2606
+ "epi": _NeighborAverageGradient(ALIAS_EPI, epi_values),
2607
+ "vf": _NeighborAverageGradient(ALIAS_VF, vf_values),
2608
+ }
725
2609
  _apply_dnfr_hook(
726
2610
  G,
727
- {"epi": g_epi, "vf": g_vf},
2611
+ grads,
728
2612
  weights={"epi": wE, "vf": wV},
729
2613
  hook_name="dnfr_laplacian",
730
- note="Gradiente topológico",
2614
+ note="Topological gradient",
2615
+ n_jobs=n_jobs,
731
2616
  )
732
-
733
-