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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. tnfr/__init__.py +228 -49
  2. tnfr/__init__.pyi +40 -0
  3. tnfr/_compat.py +11 -0
  4. tnfr/_version.py +7 -0
  5. tnfr/_version.pyi +7 -0
  6. tnfr/alias.py +106 -21
  7. tnfr/alias.pyi +140 -0
  8. tnfr/cache.py +666 -512
  9. tnfr/cache.pyi +232 -0
  10. tnfr/callback_utils.py +2 -9
  11. tnfr/callback_utils.pyi +105 -0
  12. tnfr/cli/__init__.py +21 -7
  13. tnfr/cli/__init__.pyi +47 -0
  14. tnfr/cli/arguments.py +42 -20
  15. tnfr/cli/arguments.pyi +33 -0
  16. tnfr/cli/execution.py +54 -20
  17. tnfr/cli/execution.pyi +80 -0
  18. tnfr/cli/utils.py +0 -2
  19. tnfr/cli/utils.pyi +8 -0
  20. tnfr/config/__init__.py +12 -0
  21. tnfr/config/__init__.pyi +8 -0
  22. tnfr/config/constants.py +104 -0
  23. tnfr/config/constants.pyi +12 -0
  24. tnfr/{config.py → config/init.py} +11 -7
  25. tnfr/config/init.pyi +8 -0
  26. tnfr/config/operator_names.py +106 -0
  27. tnfr/config/operator_names.pyi +28 -0
  28. tnfr/config/presets.py +104 -0
  29. tnfr/config/presets.pyi +7 -0
  30. tnfr/constants/__init__.py +78 -24
  31. tnfr/constants/__init__.pyi +104 -0
  32. tnfr/constants/core.py +1 -2
  33. tnfr/constants/core.pyi +17 -0
  34. tnfr/constants/init.pyi +12 -0
  35. tnfr/constants/metric.py +4 -12
  36. tnfr/constants/metric.pyi +19 -0
  37. tnfr/constants_glyphs.py +9 -91
  38. tnfr/constants_glyphs.pyi +12 -0
  39. tnfr/dynamics/__init__.py +112 -634
  40. tnfr/dynamics/__init__.pyi +83 -0
  41. tnfr/dynamics/adaptation.py +201 -0
  42. tnfr/dynamics/aliases.py +22 -0
  43. tnfr/dynamics/coordination.py +343 -0
  44. tnfr/dynamics/dnfr.py +1936 -354
  45. tnfr/dynamics/dnfr.pyi +33 -0
  46. tnfr/dynamics/integrators.py +369 -75
  47. tnfr/dynamics/integrators.pyi +35 -0
  48. tnfr/dynamics/runtime.py +521 -0
  49. tnfr/dynamics/sampling.py +8 -5
  50. tnfr/dynamics/sampling.pyi +7 -0
  51. tnfr/dynamics/selectors.py +680 -0
  52. tnfr/execution.py +56 -41
  53. tnfr/execution.pyi +65 -0
  54. tnfr/flatten.py +7 -7
  55. tnfr/flatten.pyi +28 -0
  56. tnfr/gamma.py +54 -37
  57. tnfr/gamma.pyi +40 -0
  58. tnfr/glyph_history.py +85 -38
  59. tnfr/glyph_history.pyi +53 -0
  60. tnfr/grammar.py +19 -338
  61. tnfr/grammar.pyi +13 -0
  62. tnfr/helpers/__init__.py +110 -30
  63. tnfr/helpers/__init__.pyi +66 -0
  64. tnfr/helpers/numeric.py +1 -0
  65. tnfr/helpers/numeric.pyi +12 -0
  66. tnfr/immutable.py +55 -19
  67. tnfr/immutable.pyi +37 -0
  68. tnfr/initialization.py +12 -10
  69. tnfr/initialization.pyi +73 -0
  70. tnfr/io.py +99 -34
  71. tnfr/io.pyi +11 -0
  72. tnfr/locking.pyi +7 -0
  73. tnfr/metrics/__init__.pyi +20 -0
  74. tnfr/metrics/coherence.py +934 -294
  75. tnfr/metrics/common.py +1 -3
  76. tnfr/metrics/common.pyi +15 -0
  77. tnfr/metrics/core.py +192 -34
  78. tnfr/metrics/core.pyi +13 -0
  79. tnfr/metrics/diagnosis.py +707 -101
  80. tnfr/metrics/diagnosis.pyi +89 -0
  81. tnfr/metrics/export.py +27 -13
  82. tnfr/metrics/glyph_timing.py +218 -38
  83. tnfr/metrics/reporting.py +22 -18
  84. tnfr/metrics/reporting.pyi +12 -0
  85. tnfr/metrics/sense_index.py +199 -25
  86. tnfr/metrics/sense_index.pyi +9 -0
  87. tnfr/metrics/trig.py +53 -18
  88. tnfr/metrics/trig.pyi +12 -0
  89. tnfr/metrics/trig_cache.py +3 -7
  90. tnfr/metrics/trig_cache.pyi +10 -0
  91. tnfr/node.py +148 -125
  92. tnfr/node.pyi +161 -0
  93. tnfr/observers.py +44 -30
  94. tnfr/observers.pyi +46 -0
  95. tnfr/ontosim.py +14 -13
  96. tnfr/ontosim.pyi +33 -0
  97. tnfr/operators/__init__.py +84 -52
  98. tnfr/operators/__init__.pyi +31 -0
  99. tnfr/operators/definitions.py +181 -0
  100. tnfr/operators/definitions.pyi +92 -0
  101. tnfr/operators/jitter.py +86 -23
  102. tnfr/operators/jitter.pyi +11 -0
  103. tnfr/operators/registry.py +80 -0
  104. tnfr/operators/registry.pyi +15 -0
  105. tnfr/operators/remesh.py +141 -57
  106. tnfr/presets.py +9 -54
  107. tnfr/presets.pyi +7 -0
  108. tnfr/py.typed +0 -0
  109. tnfr/rng.py +259 -73
  110. tnfr/rng.pyi +14 -0
  111. tnfr/selector.py +24 -17
  112. tnfr/selector.pyi +19 -0
  113. tnfr/sense.py +55 -43
  114. tnfr/sense.pyi +30 -0
  115. tnfr/structural.py +44 -267
  116. tnfr/structural.pyi +46 -0
  117. tnfr/telemetry/__init__.py +13 -0
  118. tnfr/telemetry/verbosity.py +37 -0
  119. tnfr/tokens.py +3 -2
  120. tnfr/tokens.pyi +41 -0
  121. tnfr/trace.py +272 -82
  122. tnfr/trace.pyi +68 -0
  123. tnfr/types.py +345 -6
  124. tnfr/types.pyi +145 -0
  125. tnfr/utils/__init__.py +158 -0
  126. tnfr/utils/__init__.pyi +133 -0
  127. tnfr/utils/cache.py +755 -0
  128. tnfr/utils/cache.pyi +156 -0
  129. tnfr/{collections_utils.py → utils/data.py} +57 -90
  130. tnfr/utils/data.pyi +73 -0
  131. tnfr/utils/graph.py +87 -0
  132. tnfr/utils/graph.pyi +10 -0
  133. tnfr/utils/init.py +746 -0
  134. tnfr/utils/init.pyi +85 -0
  135. tnfr/{json_utils.py → utils/io.py} +13 -18
  136. tnfr/utils/io.pyi +10 -0
  137. tnfr/utils/validators.py +130 -0
  138. tnfr/utils/validators.pyi +19 -0
  139. tnfr/validation/__init__.py +25 -0
  140. tnfr/validation/__init__.pyi +17 -0
  141. tnfr/validation/compatibility.py +59 -0
  142. tnfr/validation/compatibility.pyi +8 -0
  143. tnfr/validation/grammar.py +149 -0
  144. tnfr/validation/grammar.pyi +11 -0
  145. tnfr/validation/rules.py +194 -0
  146. tnfr/validation/rules.pyi +18 -0
  147. tnfr/validation/syntax.py +151 -0
  148. tnfr/validation/syntax.pyi +7 -0
  149. tnfr-6.0.0.dist-info/METADATA +135 -0
  150. tnfr-6.0.0.dist-info/RECORD +157 -0
  151. tnfr/graph_utils.py +0 -84
  152. tnfr/import_utils.py +0 -228
  153. tnfr/logging_utils.py +0 -116
  154. tnfr/validators.py +0 -84
  155. tnfr/value_utils.py +0 -59
  156. tnfr-4.5.2.dist-info/METADATA +0 -379
  157. tnfr-4.5.2.dist-info/RECORD +0 -67
  158. {tnfr-4.5.2.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
  159. {tnfr-4.5.2.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
  160. {tnfr-4.5.2.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
  161. {tnfr-4.5.2.dist-info → tnfr-6.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,83 @@
1
+ from typing import Any, Literal, Sequence
2
+
3
+ from tnfr.types import TNFRGraph
4
+
5
+ __all__: tuple[str, ...]
6
+
7
+ dnfr: Any
8
+ integrators: Any
9
+
10
+ ALIAS_D2EPI: Sequence[str]
11
+ ALIAS_DNFR: Sequence[str]
12
+ ALIAS_DSI: Sequence[str]
13
+ ALIAS_EPI: Sequence[str]
14
+ ALIAS_SI: Sequence[str]
15
+ ALIAS_VF: Sequence[str]
16
+
17
+ AbstractSelector: Any
18
+ DefaultGlyphSelector: Any
19
+ GlyphCode: Any
20
+ ParametricGlyphSelector: Any
21
+ _SelectorPreselection: Any
22
+ _apply_glyphs: Any
23
+ _apply_selector: Any
24
+ _choose_glyph: Any
25
+ _configure_selector_weights: Any
26
+ ProcessPoolExecutor: Any
27
+ _maybe_remesh: Any
28
+ _normalize_job_overrides: Any
29
+ _prepare_dnfr: Any
30
+ _prepare_dnfr_data: Any
31
+ _prepare_selector_preselection: Any
32
+ _resolve_jobs_override: Any
33
+ _resolve_preselected_glyph: Any
34
+ _run_after_callbacks: Any
35
+ _run_before_callbacks: Any
36
+ _run_validators: Any
37
+ _selector_parallel_jobs: Any
38
+ _update_epi_hist: Any
39
+ _update_node_sample: Any
40
+ _update_nodes: Any
41
+ _compute_dnfr: Any
42
+ _compute_neighbor_means: Any
43
+ _init_dnfr_cache: Any
44
+ _refresh_dnfr_vectors: Any
45
+ adapt_vf_by_coherence: Any
46
+ apply_canonical_clamps: Any
47
+ coordinate_global_local_phase: Any
48
+ default_compute_delta_nfr: Any
49
+ default_glyph_selector: Any
50
+ dnfr_epi_vf_mixed: Any
51
+ dnfr_laplacian: Any
52
+ dnfr_phase_only: Any
53
+ enforce_canonical_grammar: Any
54
+ get_numpy: Any
55
+ on_applied_glyph: Any
56
+ apply_glyph: Any
57
+ parametric_glyph_selector: Any
58
+
59
+ AbstractIntegrator: Any
60
+ DefaultIntegrator: Any
61
+
62
+ def prepare_integration_params(
63
+ G: TNFRGraph,
64
+ dt: float | None = ...,
65
+ t: float | None = ...,
66
+ method: Literal["euler", "rk4"] | None = ...,
67
+ ) -> tuple[float, int, float, Literal["euler", "rk4"]]: ...
68
+
69
+ run: Any
70
+ set_delta_nfr_hook: Any
71
+ step: Any
72
+
73
+ def update_epi_via_nodal_equation(
74
+ G: TNFRGraph,
75
+ *,
76
+ dt: float | None = ...,
77
+ t: float | None = ...,
78
+ method: Literal["euler", "rk4"] | None = ...,
79
+ n_jobs: int | None = ...,
80
+ ) -> None: ...
81
+
82
+ validate_canon: Any
83
+
@@ -0,0 +1,201 @@
1
+ """νf adaptation routines for TNFR dynamics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from concurrent.futures import ProcessPoolExecutor
7
+ from typing import Any, cast
8
+
9
+ from ..alias import collect_attr, set_vf
10
+ from ..constants import get_graph_param
11
+ from ..helpers.numeric import clamp
12
+ from ..metrics.common import ensure_neighbors_map
13
+ from ..types import CoherenceMetric, DeltaNFR, NodeId, TNFRGraph
14
+ from ..utils import get_numpy
15
+ from .aliases import ALIAS_DNFR, ALIAS_SI, ALIAS_VF
16
+
17
+ __all__ = ("adapt_vf_by_coherence",)
18
+
19
+
20
+ def _vf_adapt_chunk(
21
+ args: tuple[list[tuple[Any, int, tuple[int, ...]]], tuple[float, ...], float]
22
+ ) -> list[tuple[Any, float]]:
23
+ """Return proposed νf updates for ``chunk`` of stable nodes."""
24
+
25
+ chunk, vf_values, mu = args
26
+ updates: list[tuple[Any, float]] = []
27
+ for node, idx, neighbor_idx in chunk:
28
+ vf = vf_values[idx]
29
+ if neighbor_idx:
30
+ mean = math.fsum(vf_values[j] for j in neighbor_idx) / len(neighbor_idx)
31
+ else:
32
+ mean = vf
33
+ updates.append((node, vf + mu * (mean - vf)))
34
+ return updates
35
+
36
+
37
+ def adapt_vf_by_coherence(G: TNFRGraph, n_jobs: int | None = None) -> None:
38
+ """Adjust νf toward neighbour mean in nodes with sustained stability."""
39
+
40
+ tau = get_graph_param(G, "VF_ADAPT_TAU", int)
41
+ mu = float(get_graph_param(G, "VF_ADAPT_MU"))
42
+ eps_dnfr = cast(DeltaNFR, get_graph_param(G, "EPS_DNFR_STABLE"))
43
+ thr_sel = get_graph_param(G, "SELECTOR_THRESHOLDS", dict)
44
+ thr_def = get_graph_param(G, "GLYPH_THRESHOLDS", dict)
45
+ si_hi = cast(
46
+ CoherenceMetric,
47
+ float(thr_sel.get("si_hi", thr_def.get("hi", 0.66))),
48
+ )
49
+ vf_min = float(get_graph_param(G, "VF_MIN"))
50
+ vf_max = float(get_graph_param(G, "VF_MAX"))
51
+
52
+ nodes = list(G.nodes)
53
+ if not nodes:
54
+ return
55
+
56
+ neighbors_map = ensure_neighbors_map(G)
57
+ node_count = len(nodes)
58
+ node_index = {node: idx for idx, node in enumerate(nodes)}
59
+
60
+ jobs: int | None
61
+ if n_jobs is None:
62
+ jobs = None
63
+ else:
64
+ try:
65
+ jobs = int(n_jobs)
66
+ except (TypeError, ValueError):
67
+ jobs = None
68
+ else:
69
+ if jobs <= 1:
70
+ jobs = None
71
+
72
+ np_mod = get_numpy()
73
+ use_np = np_mod is not None
74
+
75
+ si_values = collect_attr(G, nodes, ALIAS_SI, 0.0, np=np_mod if use_np else None)
76
+ dnfr_values = collect_attr(G, nodes, ALIAS_DNFR, 0.0, np=np_mod if use_np else None)
77
+ vf_values = collect_attr(G, nodes, ALIAS_VF, 0.0, np=np_mod if use_np else None)
78
+
79
+ if use_np:
80
+ np = np_mod # type: ignore[assignment]
81
+ assert np is not None
82
+ si_arr = si_values.astype(float, copy=False)
83
+ dnfr_arr = np.abs(dnfr_values.astype(float, copy=False))
84
+ vf_arr = vf_values.astype(float, copy=False)
85
+
86
+ prev_counts = np.fromiter(
87
+ (int(G.nodes[node].get("stable_count", 0)) for node in nodes),
88
+ dtype=int,
89
+ count=node_count,
90
+ )
91
+ stable_mask = (si_arr >= si_hi) & (dnfr_arr <= eps_dnfr)
92
+ new_counts = np.where(stable_mask, prev_counts + 1, 0)
93
+
94
+ for node, count in zip(nodes, new_counts.tolist()):
95
+ G.nodes[node]["stable_count"] = int(count)
96
+
97
+ eligible_mask = new_counts >= tau
98
+ if not bool(eligible_mask.any()):
99
+ return
100
+
101
+ max_degree = 0
102
+ if node_count:
103
+ degree_counts = np.fromiter(
104
+ (len(neighbors_map.get(node, ())) for node in nodes),
105
+ dtype=int,
106
+ count=node_count,
107
+ )
108
+ if degree_counts.size:
109
+ max_degree = int(degree_counts.max())
110
+
111
+ if max_degree > 0:
112
+ neighbor_indices = np.zeros((node_count, max_degree), dtype=int)
113
+ mask = np.zeros((node_count, max_degree), dtype=bool)
114
+ for idx, node in enumerate(nodes):
115
+ neigh = neighbors_map.get(node, ())
116
+ if not neigh:
117
+ continue
118
+ idxs = [node_index[nbr] for nbr in neigh if nbr in node_index]
119
+ if not idxs:
120
+ continue
121
+ length = len(idxs)
122
+ neighbor_indices[idx, :length] = idxs
123
+ mask[idx, :length] = True
124
+ neighbor_values = vf_arr[neighbor_indices]
125
+ sums = (neighbor_values * mask).sum(axis=1)
126
+ counts = mask.sum(axis=1)
127
+ neighbor_means = np.where(counts > 0, sums / counts, vf_arr)
128
+ else:
129
+ neighbor_means = vf_arr
130
+
131
+ vf_updates = vf_arr + mu * (neighbor_means - vf_arr)
132
+ for idx in np.nonzero(eligible_mask)[0]:
133
+ node = nodes[int(idx)]
134
+ vf_new = clamp(float(vf_updates[int(idx)]), vf_min, vf_max)
135
+ set_vf(G, node, vf_new)
136
+ return
137
+
138
+ si_list = [float(val) for val in si_values]
139
+ dnfr_list = [abs(float(val)) for val in dnfr_values]
140
+ vf_list = [float(val) for val in vf_values]
141
+
142
+ prev_counts = [int(G.nodes[node].get("stable_count", 0)) for node in nodes]
143
+ stable_flags = [
144
+ si >= si_hi and dnfr <= eps_dnfr
145
+ for si, dnfr in zip(si_list, dnfr_list)
146
+ ]
147
+ new_counts = [prev + 1 if flag else 0 for prev, flag in zip(prev_counts, stable_flags)]
148
+
149
+ for node, count in zip(nodes, new_counts):
150
+ G.nodes[node]["stable_count"] = int(count)
151
+
152
+ eligible_nodes = [node for node, count in zip(nodes, new_counts) if count >= tau]
153
+ if not eligible_nodes:
154
+ return
155
+
156
+ if jobs is None:
157
+ for node in eligible_nodes:
158
+ idx = node_index[node]
159
+ neigh_indices = [
160
+ node_index[nbr]
161
+ for nbr in neighbors_map.get(node, ())
162
+ if nbr in node_index
163
+ ]
164
+ if neigh_indices:
165
+ total = math.fsum(vf_list[i] for i in neigh_indices)
166
+ mean = total / len(neigh_indices)
167
+ else:
168
+ mean = vf_list[idx]
169
+ vf_new = vf_list[idx] + mu * (mean - vf_list[idx])
170
+ set_vf(G, node, clamp(float(vf_new), vf_min, vf_max))
171
+ return
172
+
173
+ work_items: list[tuple[Any, int, tuple[int, ...]]] = []
174
+ for node in eligible_nodes:
175
+ idx = node_index[node]
176
+ neigh_indices = tuple(
177
+ node_index[nbr]
178
+ for nbr in neighbors_map.get(node, ())
179
+ if nbr in node_index
180
+ )
181
+ work_items.append((node, idx, neigh_indices))
182
+
183
+ chunk_size = max(1, math.ceil(len(work_items) / jobs))
184
+ chunks = [
185
+ work_items[i : i + chunk_size]
186
+ for i in range(0, len(work_items), chunk_size)
187
+ ]
188
+ vf_tuple = tuple(vf_list)
189
+ updates: dict[Any, float] = {}
190
+ with ProcessPoolExecutor(max_workers=jobs) as executor:
191
+ args = ((chunk, vf_tuple, mu) for chunk in chunks)
192
+ for chunk_updates in executor.map(_vf_adapt_chunk, args):
193
+ for node, value in chunk_updates:
194
+ updates[node] = float(value)
195
+
196
+ for node in eligible_nodes:
197
+ vf_new = updates.get(node)
198
+ if vf_new is None:
199
+ continue
200
+ set_vf(G, node, clamp(float(vf_new), vf_min, vf_max))
201
+
@@ -0,0 +1,22 @@
1
+ """Shared alias tokens used across TNFR dynamics submodules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..constants import get_aliases
6
+
7
+ ALIAS_VF = get_aliases("VF")
8
+ ALIAS_DNFR = get_aliases("DNFR")
9
+ ALIAS_EPI = get_aliases("EPI")
10
+ ALIAS_SI = get_aliases("SI")
11
+ ALIAS_D2EPI = get_aliases("D2EPI")
12
+ ALIAS_DSI = get_aliases("DSI")
13
+
14
+ __all__ = (
15
+ "ALIAS_VF",
16
+ "ALIAS_DNFR",
17
+ "ALIAS_EPI",
18
+ "ALIAS_SI",
19
+ "ALIAS_D2EPI",
20
+ "ALIAS_DSI",
21
+ )
22
+
@@ -0,0 +1,343 @@
1
+ """Phase coordination helpers for TNFR dynamics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections import deque
7
+ from collections.abc import Mapping, MutableMapping, Sequence
8
+ from concurrent.futures import ProcessPoolExecutor
9
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
10
+
11
+ from ..alias import get_theta_attr, set_theta
12
+ from ..constants import (
13
+ DEFAULTS,
14
+ METRIC_DEFAULTS,
15
+ STATE_DISSONANT,
16
+ STATE_STABLE,
17
+ STATE_TRANSITION,
18
+ normalise_state_token,
19
+ )
20
+ from ..glyph_history import append_metric
21
+ from ..helpers.numeric import angle_diff
22
+ from ..metrics.common import ensure_neighbors_map
23
+ from ..metrics.trig import neighbor_phase_mean_list
24
+ from ..metrics.trig_cache import get_trig_cache
25
+ from ..observers import DEFAULT_GLYPH_LOAD_SPAN, glyph_load, kuramoto_order
26
+ from ..types import NodeId, Phase, TNFRGraph
27
+ from ..utils import get_numpy
28
+ from .._compat import TypeAlias
29
+
30
+ if TYPE_CHECKING: # pragma: no cover - typing imports only
31
+ try:
32
+ import numpy as np_typing
33
+ import numpy.typing as npt
34
+ except ImportError: # pragma: no cover - optional typing dependency
35
+ FloatArray: TypeAlias = Any
36
+ else:
37
+ FloatArray: TypeAlias = npt.NDArray[np_typing.float_]
38
+ else: # pragma: no cover - runtime without numpy typing
39
+ FloatArray: TypeAlias = Any
40
+
41
+ _DequeT = TypeVar("_DequeT")
42
+
43
+ ChunkArgs = tuple[
44
+ Sequence[NodeId],
45
+ Mapping[NodeId, Phase],
46
+ Mapping[NodeId, float],
47
+ Mapping[NodeId, float],
48
+ Mapping[NodeId, Sequence[NodeId]],
49
+ float,
50
+ float,
51
+ float,
52
+ ]
53
+
54
+ __all__ = ("coordinate_global_local_phase",)
55
+
56
+
57
+ def _ensure_hist_deque(
58
+ hist: MutableMapping[str, Any], key: str, maxlen: int
59
+ ) -> deque[_DequeT]:
60
+ """Ensure history entry ``key`` is a deque with ``maxlen``."""
61
+
62
+ dq = hist.setdefault(key, deque(maxlen=maxlen))
63
+ if not isinstance(dq, deque):
64
+ dq = deque(dq, maxlen=maxlen)
65
+ hist[key] = dq
66
+ return cast("deque[_DequeT]", dq)
67
+
68
+
69
+ def _read_adaptive_params(
70
+ g: Mapping[str, Any],
71
+ ) -> tuple[Mapping[str, Any], float, float]:
72
+ """Obtain configuration and current values for phase adaptation."""
73
+
74
+ cfg = g.get("PHASE_ADAPT", DEFAULTS.get("PHASE_ADAPT", {}))
75
+ kG = float(g.get("PHASE_K_GLOBAL", DEFAULTS["PHASE_K_GLOBAL"]))
76
+ kL = float(g.get("PHASE_K_LOCAL", DEFAULTS["PHASE_K_LOCAL"]))
77
+ return cast(Mapping[str, Any], cfg), kG, kL
78
+
79
+
80
+ def _compute_state(G: TNFRGraph, cfg: Mapping[str, Any]) -> tuple[str, float, float]:
81
+ """Return the canonical network state and supporting metrics."""
82
+
83
+ R = kuramoto_order(G)
84
+ dist = glyph_load(G, window=DEFAULT_GLYPH_LOAD_SPAN)
85
+ disr = float(dist.get("_disruptors", 0.0)) if dist else 0.0
86
+
87
+ R_hi = float(cfg.get("R_hi", 0.90))
88
+ R_lo = float(cfg.get("R_lo", 0.60))
89
+ disr_hi = float(cfg.get("disr_hi", 0.50))
90
+ disr_lo = float(cfg.get("disr_lo", 0.25))
91
+ if (R >= R_hi) and (disr <= disr_lo):
92
+ state = STATE_STABLE
93
+ elif (R <= R_lo) or (disr >= disr_hi):
94
+ state = STATE_DISSONANT
95
+ else:
96
+ state = STATE_TRANSITION
97
+ return state, float(R), disr
98
+
99
+
100
+ def _smooth_adjust_k(
101
+ kG: float, kL: float, state: str, cfg: Mapping[str, Any]
102
+ ) -> tuple[float, float]:
103
+ """Smoothly update kG/kL toward targets according to state."""
104
+
105
+ kG_min = float(cfg.get("kG_min", 0.01))
106
+ kG_max = float(cfg.get("kG_max", 0.20))
107
+ kL_min = float(cfg.get("kL_min", 0.05))
108
+ kL_max = float(cfg.get("kL_max", 0.25))
109
+
110
+ state = normalise_state_token(state)
111
+
112
+ if state == STATE_DISSONANT:
113
+ kG_t = kG_max
114
+ kL_t = 0.5 * (
115
+ kL_min + kL_max
116
+ ) # local medio para no perder plasticidad
117
+ elif state == STATE_STABLE:
118
+ kG_t = kG_min
119
+ kL_t = kL_min
120
+ else:
121
+ kG_t = 0.5 * (kG_min + kG_max)
122
+ kL_t = 0.5 * (kL_min + kL_max)
123
+
124
+ up = float(cfg.get("up", 0.10))
125
+ down = float(cfg.get("down", 0.07))
126
+
127
+ def _step(curr: float, target: float, mn: float, mx: float) -> float:
128
+ gain = up if target > curr else down
129
+ nxt = curr + gain * (target - curr)
130
+ return max(mn, min(mx, nxt))
131
+
132
+ return _step(kG, kG_t, kG_min, kG_max), _step(kL, kL_t, kL_min, kL_max)
133
+
134
+
135
+ def _phase_adjust_chunk(args: ChunkArgs) -> list[tuple[NodeId, Phase]]:
136
+ """Return coordinated phase updates for the provided chunk."""
137
+
138
+ (
139
+ nodes,
140
+ theta_map,
141
+ cos_map,
142
+ sin_map,
143
+ neighbors_map,
144
+ thG,
145
+ kG,
146
+ kL,
147
+ ) = args
148
+ updates: list[tuple[NodeId, Phase]] = []
149
+ for node in nodes:
150
+ th = float(theta_map.get(node, 0.0))
151
+ neigh = neighbors_map.get(node, ())
152
+ if neigh:
153
+ thL = neighbor_phase_mean_list(
154
+ neigh,
155
+ cos_map,
156
+ sin_map,
157
+ np=None,
158
+ fallback=th,
159
+ )
160
+ else:
161
+ thL = th
162
+ dG = angle_diff(thG, th)
163
+ dL = angle_diff(thL, th)
164
+ updates.append((node, cast(Phase, th + kG * dG + kL * dL)))
165
+ return updates
166
+
167
+
168
+ def coordinate_global_local_phase(
169
+ G: TNFRGraph,
170
+ global_force: float | None = None,
171
+ local_force: float | None = None,
172
+ *,
173
+ n_jobs: int | None = None,
174
+ ) -> None:
175
+ """Coordinate phase using a blend of global and neighbour coupling."""
176
+
177
+ g = cast(dict[str, Any], G.graph)
178
+ hist = cast(dict[str, Any], g.setdefault("history", {}))
179
+ maxlen = int(
180
+ g.get("PHASE_HISTORY_MAXLEN", METRIC_DEFAULTS["PHASE_HISTORY_MAXLEN"])
181
+ )
182
+ hist_state = cast(deque[str], _ensure_hist_deque(hist, "phase_state", maxlen))
183
+ if hist_state:
184
+ normalised_states = [normalise_state_token(item) for item in hist_state]
185
+ if normalised_states != list(hist_state):
186
+ hist_state.clear()
187
+ hist_state.extend(normalised_states)
188
+ hist_R = cast(deque[float], _ensure_hist_deque(hist, "phase_R", maxlen))
189
+ hist_disr = cast(deque[float], _ensure_hist_deque(hist, "phase_disr", maxlen))
190
+
191
+ if (global_force is not None) or (local_force is not None):
192
+ kG = float(
193
+ global_force
194
+ if global_force is not None
195
+ else g.get("PHASE_K_GLOBAL", DEFAULTS["PHASE_K_GLOBAL"])
196
+ )
197
+ kL = float(
198
+ local_force
199
+ if local_force is not None
200
+ else g.get("PHASE_K_LOCAL", DEFAULTS["PHASE_K_LOCAL"])
201
+ )
202
+ else:
203
+ cfg, kG, kL = _read_adaptive_params(g)
204
+
205
+ if bool(cfg.get("enabled", False)):
206
+ state, R, disr = _compute_state(G, cfg)
207
+ kG, kL = _smooth_adjust_k(kG, kL, state, cfg)
208
+
209
+ hist_state.append(state)
210
+ hist_R.append(float(R))
211
+ hist_disr.append(float(disr))
212
+
213
+ g["PHASE_K_GLOBAL"] = kG
214
+ g["PHASE_K_LOCAL"] = kL
215
+ append_metric(hist, "phase_kG", float(kG))
216
+ append_metric(hist, "phase_kL", float(kL))
217
+
218
+ jobs: int | None
219
+ try:
220
+ jobs = None if n_jobs is None else int(n_jobs)
221
+ except (TypeError, ValueError):
222
+ jobs = None
223
+ if jobs is not None and jobs <= 1:
224
+ jobs = None
225
+
226
+ np = get_numpy()
227
+ if np is not None:
228
+ jobs = None
229
+
230
+ nodes: list[NodeId] = [cast(NodeId, node) for node in G.nodes()]
231
+ num_nodes = len(nodes)
232
+ if not num_nodes:
233
+ return
234
+
235
+ trig = get_trig_cache(G, np=np)
236
+ theta_map = cast(dict[NodeId, Phase], trig.theta)
237
+ cos_map = cast(dict[NodeId, float], trig.cos)
238
+ sin_map = cast(dict[NodeId, float], trig.sin)
239
+
240
+ neighbors_proxy = ensure_neighbors_map(G)
241
+ neighbors_map: dict[NodeId, tuple[NodeId, ...]] = {}
242
+ for n in nodes:
243
+ try:
244
+ neighbors_map[n] = tuple(cast(Sequence[NodeId], neighbors_proxy[n]))
245
+ except KeyError:
246
+ neighbors_map[n] = ()
247
+
248
+ def _theta_value(node: NodeId) -> float:
249
+ cached = theta_map.get(node)
250
+ if cached is not None:
251
+ return float(cached)
252
+ attr_val = get_theta_attr(G.nodes[node], 0.0)
253
+ return float(attr_val if attr_val is not None else 0.0)
254
+
255
+ theta_vals = [_theta_value(n) for n in nodes]
256
+ cos_vals = [
257
+ float(cos_map.get(n, math.cos(theta_vals[idx])))
258
+ for idx, n in enumerate(nodes)
259
+ ]
260
+ sin_vals = [
261
+ float(sin_map.get(n, math.sin(theta_vals[idx])))
262
+ for idx, n in enumerate(nodes)
263
+ ]
264
+
265
+ if np is not None:
266
+ theta_arr = cast(FloatArray, np.fromiter(theta_vals, dtype=float))
267
+ cos_arr = cast(FloatArray, np.fromiter(cos_vals, dtype=float))
268
+ sin_arr = cast(FloatArray, np.fromiter(sin_vals, dtype=float))
269
+ if cos_arr.size:
270
+ mean_cos = float(np.mean(cos_arr))
271
+ mean_sin = float(np.mean(sin_arr))
272
+ thG = float(np.arctan2(mean_sin, mean_cos))
273
+ else:
274
+ thG = 0.0
275
+ neighbor_means = [
276
+ neighbor_phase_mean_list(
277
+ neighbors_map.get(n, ()),
278
+ cos_map,
279
+ sin_map,
280
+ np=np,
281
+ fallback=theta_vals[idx],
282
+ )
283
+ for idx, n in enumerate(nodes)
284
+ ]
285
+ neighbor_arr = cast(FloatArray, np.fromiter(neighbor_means, dtype=float))
286
+ theta_updates = theta_arr + kG * (thG - theta_arr) + kL * (
287
+ neighbor_arr - theta_arr
288
+ )
289
+ for idx, node in enumerate(nodes):
290
+ set_theta(G, node, float(theta_updates[int(idx)]))
291
+ return
292
+
293
+ mean_cos = math.fsum(cos_vals) / num_nodes
294
+ mean_sin = math.fsum(sin_vals) / num_nodes
295
+ thG = math.atan2(mean_sin, mean_cos)
296
+
297
+ if jobs is None:
298
+ for node in nodes:
299
+ th = float(theta_map.get(node, 0.0))
300
+ neigh = neighbors_map.get(node, ())
301
+ if neigh:
302
+ thL = neighbor_phase_mean_list(
303
+ neigh,
304
+ cos_map,
305
+ sin_map,
306
+ np=None,
307
+ fallback=th,
308
+ )
309
+ else:
310
+ thL = th
311
+ dG = angle_diff(thG, th)
312
+ dL = angle_diff(thL, th)
313
+ set_theta(G, node, float(th + kG * dG + kL * dL))
314
+ return
315
+
316
+ chunk_size = max(1, math.ceil(len(nodes) / jobs))
317
+ chunks = [
318
+ nodes[idx : idx + chunk_size]
319
+ for idx in range(0, len(nodes), chunk_size)
320
+ ]
321
+ args: list[ChunkArgs] = [
322
+ (
323
+ chunk,
324
+ theta_map,
325
+ cos_map,
326
+ sin_map,
327
+ neighbors_map,
328
+ thG,
329
+ kG,
330
+ kL,
331
+ )
332
+ for chunk in chunks
333
+ ]
334
+ results: dict[NodeId, Phase] = {}
335
+ with ProcessPoolExecutor(max_workers=jobs) as executor:
336
+ for res in executor.map(_phase_adjust_chunk, args):
337
+ for node, value in res:
338
+ results[node] = value
339
+ for node in nodes:
340
+ new_theta = results.get(node)
341
+ base_theta = theta_map.get(node, 0.0)
342
+ set_theta(G, node, float(new_theta if new_theta is not None else base_theta))
343
+