tnfr 6.0.0__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 (176) hide show
  1. tnfr/__init__.py +50 -5
  2. tnfr/__init__.pyi +0 -7
  3. tnfr/_compat.py +0 -1
  4. tnfr/_generated_version.py +34 -0
  5. tnfr/_version.py +44 -2
  6. tnfr/alias.py +14 -13
  7. tnfr/alias.pyi +5 -37
  8. tnfr/cache.py +9 -729
  9. tnfr/cache.pyi +8 -224
  10. tnfr/callback_utils.py +16 -31
  11. tnfr/callback_utils.pyi +3 -29
  12. tnfr/cli/__init__.py +17 -11
  13. tnfr/cli/__init__.pyi +0 -21
  14. tnfr/cli/arguments.py +175 -14
  15. tnfr/cli/arguments.pyi +5 -11
  16. tnfr/cli/execution.py +434 -48
  17. tnfr/cli/execution.pyi +14 -24
  18. tnfr/cli/utils.py +20 -3
  19. tnfr/cli/utils.pyi +5 -5
  20. tnfr/config/__init__.py +2 -1
  21. tnfr/config/__init__.pyi +2 -0
  22. tnfr/config/feature_flags.py +83 -0
  23. tnfr/config/init.py +1 -1
  24. tnfr/config/operator_names.py +1 -14
  25. tnfr/config/presets.py +6 -26
  26. tnfr/constants/__init__.py +10 -13
  27. tnfr/constants/__init__.pyi +10 -22
  28. tnfr/constants/aliases.py +31 -0
  29. tnfr/constants/core.py +4 -3
  30. tnfr/constants/init.py +1 -1
  31. tnfr/constants/metric.py +3 -3
  32. tnfr/dynamics/__init__.py +64 -10
  33. tnfr/dynamics/__init__.pyi +3 -4
  34. tnfr/dynamics/adaptation.py +79 -13
  35. tnfr/dynamics/aliases.py +10 -9
  36. tnfr/dynamics/coordination.py +77 -35
  37. tnfr/dynamics/dnfr.py +575 -274
  38. tnfr/dynamics/dnfr.pyi +1 -10
  39. tnfr/dynamics/integrators.py +47 -33
  40. tnfr/dynamics/integrators.pyi +0 -1
  41. tnfr/dynamics/runtime.py +489 -129
  42. tnfr/dynamics/sampling.py +2 -0
  43. tnfr/dynamics/selectors.py +101 -62
  44. tnfr/execution.py +15 -8
  45. tnfr/execution.pyi +5 -25
  46. tnfr/flatten.py +7 -3
  47. tnfr/flatten.pyi +1 -8
  48. tnfr/gamma.py +22 -26
  49. tnfr/gamma.pyi +0 -6
  50. tnfr/glyph_history.py +37 -26
  51. tnfr/glyph_history.pyi +1 -19
  52. tnfr/glyph_runtime.py +16 -0
  53. tnfr/glyph_runtime.pyi +9 -0
  54. tnfr/immutable.py +20 -15
  55. tnfr/immutable.pyi +4 -7
  56. tnfr/initialization.py +5 -7
  57. tnfr/initialization.pyi +1 -9
  58. tnfr/io.py +6 -305
  59. tnfr/io.pyi +13 -8
  60. tnfr/mathematics/__init__.py +81 -0
  61. tnfr/mathematics/backend.py +426 -0
  62. tnfr/mathematics/dynamics.py +398 -0
  63. tnfr/mathematics/epi.py +254 -0
  64. tnfr/mathematics/generators.py +222 -0
  65. tnfr/mathematics/metrics.py +119 -0
  66. tnfr/mathematics/operators.py +233 -0
  67. tnfr/mathematics/operators_factory.py +71 -0
  68. tnfr/mathematics/projection.py +78 -0
  69. tnfr/mathematics/runtime.py +173 -0
  70. tnfr/mathematics/spaces.py +247 -0
  71. tnfr/mathematics/transforms.py +292 -0
  72. tnfr/metrics/__init__.py +10 -10
  73. tnfr/metrics/coherence.py +123 -94
  74. tnfr/metrics/common.py +22 -13
  75. tnfr/metrics/common.pyi +42 -11
  76. tnfr/metrics/core.py +72 -14
  77. tnfr/metrics/diagnosis.py +48 -57
  78. tnfr/metrics/diagnosis.pyi +3 -7
  79. tnfr/metrics/export.py +3 -5
  80. tnfr/metrics/glyph_timing.py +41 -31
  81. tnfr/metrics/reporting.py +13 -6
  82. tnfr/metrics/sense_index.py +884 -114
  83. tnfr/metrics/trig.py +167 -11
  84. tnfr/metrics/trig.pyi +1 -0
  85. tnfr/metrics/trig_cache.py +112 -15
  86. tnfr/node.py +400 -17
  87. tnfr/node.pyi +55 -38
  88. tnfr/observers.py +111 -8
  89. tnfr/observers.pyi +0 -15
  90. tnfr/ontosim.py +9 -6
  91. tnfr/ontosim.pyi +0 -5
  92. tnfr/operators/__init__.py +529 -42
  93. tnfr/operators/__init__.pyi +14 -0
  94. tnfr/operators/definitions.py +350 -18
  95. tnfr/operators/definitions.pyi +0 -14
  96. tnfr/operators/grammar.py +760 -0
  97. tnfr/operators/jitter.py +28 -22
  98. tnfr/operators/registry.py +7 -12
  99. tnfr/operators/registry.pyi +0 -2
  100. tnfr/operators/remesh.py +38 -61
  101. tnfr/rng.py +17 -300
  102. tnfr/schemas/__init__.py +8 -0
  103. tnfr/schemas/grammar.json +94 -0
  104. tnfr/selector.py +3 -4
  105. tnfr/selector.pyi +1 -1
  106. tnfr/sense.py +22 -24
  107. tnfr/sense.pyi +0 -7
  108. tnfr/structural.py +504 -21
  109. tnfr/structural.pyi +41 -18
  110. tnfr/telemetry/__init__.py +23 -1
  111. tnfr/telemetry/cache_metrics.py +226 -0
  112. tnfr/telemetry/nu_f.py +423 -0
  113. tnfr/telemetry/nu_f.pyi +123 -0
  114. tnfr/tokens.py +1 -4
  115. tnfr/tokens.pyi +1 -6
  116. tnfr/trace.py +20 -53
  117. tnfr/trace.pyi +9 -37
  118. tnfr/types.py +244 -15
  119. tnfr/types.pyi +200 -14
  120. tnfr/units.py +69 -0
  121. tnfr/units.pyi +16 -0
  122. tnfr/utils/__init__.py +107 -48
  123. tnfr/utils/__init__.pyi +80 -11
  124. tnfr/utils/cache.py +1705 -65
  125. tnfr/utils/cache.pyi +370 -58
  126. tnfr/utils/chunks.py +104 -0
  127. tnfr/utils/chunks.pyi +21 -0
  128. tnfr/utils/data.py +95 -5
  129. tnfr/utils/data.pyi +8 -17
  130. tnfr/utils/graph.py +2 -4
  131. tnfr/utils/init.py +31 -7
  132. tnfr/utils/init.pyi +4 -11
  133. tnfr/utils/io.py +313 -14
  134. tnfr/{helpers → utils}/numeric.py +50 -24
  135. tnfr/utils/numeric.pyi +21 -0
  136. tnfr/validation/__init__.py +92 -4
  137. tnfr/validation/__init__.pyi +77 -17
  138. tnfr/validation/compatibility.py +79 -43
  139. tnfr/validation/compatibility.pyi +4 -6
  140. tnfr/validation/grammar.py +55 -133
  141. tnfr/validation/grammar.pyi +37 -8
  142. tnfr/validation/graph.py +138 -0
  143. tnfr/validation/graph.pyi +17 -0
  144. tnfr/validation/rules.py +161 -74
  145. tnfr/validation/rules.pyi +55 -18
  146. tnfr/validation/runtime.py +263 -0
  147. tnfr/validation/runtime.pyi +31 -0
  148. tnfr/validation/soft_filters.py +170 -0
  149. tnfr/validation/soft_filters.pyi +37 -0
  150. tnfr/validation/spectral.py +159 -0
  151. tnfr/validation/spectral.pyi +46 -0
  152. tnfr/validation/syntax.py +28 -139
  153. tnfr/validation/syntax.pyi +7 -4
  154. tnfr/validation/window.py +39 -0
  155. tnfr/validation/window.pyi +1 -0
  156. tnfr/viz/__init__.py +9 -0
  157. tnfr/viz/matplotlib.py +246 -0
  158. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/METADATA +63 -19
  159. tnfr-7.0.0.dist-info/RECORD +185 -0
  160. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/licenses/LICENSE.md +1 -1
  161. tnfr/constants_glyphs.py +0 -16
  162. tnfr/constants_glyphs.pyi +0 -12
  163. tnfr/grammar.py +0 -25
  164. tnfr/grammar.pyi +0 -13
  165. tnfr/helpers/__init__.py +0 -151
  166. tnfr/helpers/__init__.pyi +0 -66
  167. tnfr/helpers/numeric.pyi +0 -12
  168. tnfr/presets.py +0 -15
  169. tnfr/presets.pyi +0 -7
  170. tnfr/utils/io.pyi +0 -10
  171. tnfr/utils/validators.py +0 -130
  172. tnfr/utils/validators.pyi +0 -19
  173. tnfr-6.0.0.dist-info/RECORD +0 -157
  174. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/WHEEL +0 -0
  175. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/entry_points.txt +0 -0
  176. {tnfr-6.0.0.dist-info → tnfr-7.0.0.dist-info}/top_level.txt +0 -0
@@ -8,7 +8,7 @@ from typing import Any, cast
8
8
 
9
9
  from ..alias import collect_attr, set_vf
10
10
  from ..constants import get_graph_param
11
- from ..helpers.numeric import clamp
11
+ from ..utils import clamp, resolve_chunk_size
12
12
  from ..metrics.common import ensure_neighbors_map
13
13
  from ..types import CoherenceMetric, DeltaNFR, NodeId, TNFRGraph
14
14
  from ..utils import get_numpy
@@ -18,7 +18,7 @@ __all__ = ("adapt_vf_by_coherence",)
18
18
 
19
19
 
20
20
  def _vf_adapt_chunk(
21
- args: tuple[list[tuple[Any, int, tuple[int, ...]]], tuple[float, ...], float]
21
+ args: tuple[list[tuple[Any, int, tuple[int, ...]]], tuple[float, ...], float],
22
22
  ) -> list[tuple[Any, float]]:
23
23
  """Return proposed νf updates for ``chunk`` of stable nodes."""
24
24
 
@@ -35,7 +35,71 @@ def _vf_adapt_chunk(
35
35
 
36
36
 
37
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."""
38
+ """Synchronise νf to the neighbour mean once ΔNFR and Si stay coherent.
39
+
40
+ Parameters
41
+ ----------
42
+ G : TNFRGraph
43
+ Graph that stores the TNFR nodes and configuration required for
44
+ adaptation. The routine reads ``VF_ADAPT_TAU`` (τ) to decide how many
45
+ consecutive stable steps a node must accumulate in ``stable_count``
46
+ before updating. The adaptation weight ``VF_ADAPT_MU`` (μ) controls how
47
+ quickly νf moves toward the neighbour mean. Stability is detected when
48
+ the absolute ΔNFR stays below ``EPS_DNFR_STABLE`` and the sense index Si
49
+ exceeds the selector threshold ``SELECTOR_THRESHOLDS['si_hi']`` (falling
50
+ back to ``GLYPH_THRESHOLDS['hi']``). Only nodes that satisfy both
51
+ thresholds for τ successive evaluations are eligible for μ-weighted
52
+ averaging.
53
+ n_jobs : int or None, optional
54
+ Number of worker processes used for eligible nodes. ``None`` (the
55
+ default) keeps the adaptation serial, ``1`` disables parallelism, and
56
+ any value greater than one dispatches chunks of nodes to a
57
+ :class:`~concurrent.futures.ProcessPoolExecutor` so large graphs can
58
+ adjust νf without blocking the main dynamic loop.
59
+
60
+ Returns
61
+ -------
62
+ None
63
+ The graph is updated in place; no value is returned.
64
+
65
+ Raises
66
+ ------
67
+ KeyError
68
+ Raised when ``G.graph`` lacks the canonical adaptation parameters and
69
+ defaults have not been injected.
70
+
71
+ Examples
72
+ --------
73
+ >>> from tnfr.constants import inject_defaults
74
+ >>> from tnfr.dynamics import adapt_vf_by_coherence
75
+ >>> from tnfr.structural import create_nfr
76
+ >>> G, seed = create_nfr("seed", vf=0.2)
77
+ >>> _, anchor = create_nfr("anchor", graph=G, vf=1.0)
78
+ >>> G.add_edge(seed, anchor)
79
+ >>> inject_defaults(G)
80
+ >>> G.graph["VF_ADAPT_TAU"] = 2 # τ: consecutive stable steps
81
+ >>> G.graph["VF_ADAPT_MU"] = 0.5 # μ: neighbour coupling strength
82
+ >>> G.graph["SELECTOR_THRESHOLDS"] = {"si_hi": 0.8}
83
+ >>> for node in G.nodes:
84
+ ... G.nodes[node]["Si"] = 0.9 # above ΔSi threshold
85
+ ... G.nodes[node]["ΔNFR"] = 0.0 # within |ΔNFR| ≤ eps guard
86
+ ... G.nodes[node]["stable_count"] = 1
87
+ >>> adapt_vf_by_coherence(G)
88
+ >>> round(G.nodes[seed]["νf"], 2), round(G.nodes[anchor]["νf"], 2)
89
+ (0.6, 0.6)
90
+ >>> G.nodes[seed]["stable_count"], G.nodes[anchor]["stable_count"] >= 2
91
+ (2, True)
92
+ """
93
+
94
+ required_keys = ("VF_ADAPT_TAU", "VF_ADAPT_MU")
95
+ missing_keys = [key for key in required_keys if key not in G.graph]
96
+ if missing_keys:
97
+ missing_list = ", ".join(sorted(missing_keys))
98
+ raise KeyError(
99
+ "adapt_vf_by_coherence requires graph parameters "
100
+ f"{missing_list}; call tnfr.constants.inject_defaults(G) "
101
+ "before adaptation."
102
+ )
39
103
 
40
104
  tau = get_graph_param(G, "VF_ADAPT_TAU", int)
41
105
  mu = float(get_graph_param(G, "VF_ADAPT_MU"))
@@ -141,10 +205,11 @@ def adapt_vf_by_coherence(G: TNFRGraph, n_jobs: int | None = None) -> None:
141
205
 
142
206
  prev_counts = [int(G.nodes[node].get("stable_count", 0)) for node in nodes]
143
207
  stable_flags = [
144
- si >= si_hi and dnfr <= eps_dnfr
145
- for si, dnfr in zip(si_list, dnfr_list)
208
+ si >= si_hi and dnfr <= eps_dnfr for si, dnfr in zip(si_list, dnfr_list)
209
+ ]
210
+ new_counts = [
211
+ prev + 1 if flag else 0 for prev, flag in zip(prev_counts, stable_flags)
146
212
  ]
147
- new_counts = [prev + 1 if flag else 0 for prev, flag in zip(prev_counts, stable_flags)]
148
213
 
149
214
  for node, count in zip(nodes, new_counts):
150
215
  G.nodes[node]["stable_count"] = int(count)
@@ -174,16 +239,18 @@ def adapt_vf_by_coherence(G: TNFRGraph, n_jobs: int | None = None) -> None:
174
239
  for node in eligible_nodes:
175
240
  idx = node_index[node]
176
241
  neigh_indices = tuple(
177
- node_index[nbr]
178
- for nbr in neighbors_map.get(node, ())
179
- if nbr in node_index
242
+ node_index[nbr] for nbr in neighbors_map.get(node, ()) if nbr in node_index
180
243
  )
181
244
  work_items.append((node, idx, neigh_indices))
182
245
 
183
- chunk_size = max(1, math.ceil(len(work_items) / jobs))
246
+ approx_chunk = math.ceil(len(work_items) / jobs) if jobs else None
247
+ chunk_size = resolve_chunk_size(
248
+ approx_chunk,
249
+ len(work_items),
250
+ minimum=1,
251
+ )
184
252
  chunks = [
185
- work_items[i : i + chunk_size]
186
- for i in range(0, len(work_items), chunk_size)
253
+ work_items[i : i + chunk_size] for i in range(0, len(work_items), chunk_size)
187
254
  ]
188
255
  vf_tuple = tuple(vf_list)
189
256
  updates: dict[Any, float] = {}
@@ -198,4 +265,3 @@ def adapt_vf_by_coherence(G: TNFRGraph, n_jobs: int | None = None) -> None:
198
265
  if vf_new is None:
199
266
  continue
200
267
  set_vf(G, node, clamp(float(vf_new), vf_min, vf_max))
201
-
tnfr/dynamics/aliases.py CHANGED
@@ -2,21 +2,22 @@
2
2
 
3
3
  from __future__ import annotations
4
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")
5
+ from ..constants.aliases import (
6
+ ALIAS_D2EPI,
7
+ ALIAS_DNFR,
8
+ ALIAS_DSI,
9
+ ALIAS_EPI,
10
+ ALIAS_SI,
11
+ ALIAS_THETA,
12
+ ALIAS_VF,
13
+ )
13
14
 
14
15
  __all__ = (
15
16
  "ALIAS_VF",
16
17
  "ALIAS_DNFR",
17
18
  "ALIAS_EPI",
18
19
  "ALIAS_SI",
20
+ "ALIAS_THETA",
19
21
  "ALIAS_D2EPI",
20
22
  "ALIAS_DSI",
21
23
  )
22
-
@@ -6,8 +6,7 @@ import math
6
6
  from collections import deque
7
7
  from collections.abc import Mapping, MutableMapping, Sequence
8
8
  from concurrent.futures import ProcessPoolExecutor
9
- from typing import TYPE_CHECKING, Any, TypeVar, cast
10
-
9
+ from typing import Any, TypeVar, cast
11
10
  from ..alias import get_theta_attr, set_theta
12
11
  from ..constants import (
13
12
  DEFAULTS,
@@ -18,25 +17,13 @@ from ..constants import (
18
17
  normalise_state_token,
19
18
  )
20
19
  from ..glyph_history import append_metric
21
- from ..helpers.numeric import angle_diff
20
+ from ..utils import angle_diff, resolve_chunk_size
22
21
  from ..metrics.common import ensure_neighbors_map
23
22
  from ..metrics.trig import neighbor_phase_mean_list
24
23
  from ..metrics.trig_cache import get_trig_cache
25
24
  from ..observers import DEFAULT_GLYPH_LOAD_SPAN, glyph_load, kuramoto_order
26
- from ..types import NodeId, Phase, TNFRGraph
25
+ from ..types import FloatArray, NodeId, Phase, TNFRGraph
27
26
  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
27
 
41
28
  _DequeT = TypeVar("_DequeT")
42
29
 
@@ -111,9 +98,7 @@ def _smooth_adjust_k(
111
98
 
112
99
  if state == STATE_DISSONANT:
113
100
  kG_t = kG_max
114
- kL_t = 0.5 * (
115
- kL_min + kL_max
116
- ) # local medio para no perder plasticidad
101
+ kL_t = 0.5 * (kL_min + kL_max) # keep kL mid-range to preserve local plasticity
117
102
  elif state == STATE_STABLE:
118
103
  kG_t = kG_min
119
104
  kL_t = kL_min
@@ -172,13 +157,71 @@ def coordinate_global_local_phase(
172
157
  *,
173
158
  n_jobs: int | None = None,
174
159
  ) -> None:
175
- """Coordinate phase using a blend of global and neighbour coupling."""
160
+ """Coordinate phase using a blend of global and neighbour coupling.
161
+
162
+ This operator harmonises a TNFR graph by iteratively nudging each node's
163
+ phase toward the global Kuramoto mean while respecting the local
164
+ neighbourhood attractor. The global (``kG``) and local (``kL``) coupling
165
+ gains reshape phase coherence by modulating how strongly nodes follow the
166
+ network-wide synchrony versus immediate neighbours. When explicit coupling
167
+ overrides are not supplied, the gains adapt based on current ΔNFR telemetry
168
+ and the structural state recorded in the graph history. Adaptive updates
169
+ mutate the ``history`` buffers for phase state, order parameter, disruptor
170
+ load, and the stored coupling gains.
171
+
172
+ Parameters
173
+ ----------
174
+ G : TNFRGraph
175
+ Graph whose nodes expose TNFR phase attributes and ΔNFR telemetry. The
176
+ graph's ``history`` mapping is updated in-place when adaptive gain
177
+ smoothing is active.
178
+ global_force : float, optional
179
+ Override for the global coupling gain ``kG``. When provided, adaptive
180
+ gain estimation is skipped and the global history buffers are left
181
+ untouched.
182
+ local_force : float, optional
183
+ Override for the local coupling gain ``kL``. Analogous to
184
+ ``global_force``, the adaptive pathway is bypassed when supplied.
185
+ n_jobs : int, optional
186
+ Maximum number of worker processes for distributing local updates.
187
+ Values of ``None`` or ``<=1`` perform updates sequentially. NumPy
188
+ availability forces sequential execution because vectorised updates are
189
+ faster than multiprocess handoffs.
190
+
191
+ Returns
192
+ -------
193
+ None
194
+ This operator updates node phases in-place and does not allocate a new
195
+ graph structure.
196
+
197
+ Examples
198
+ --------
199
+ Coordinate phase on a minimal TNFR network while inspecting ΔNFR telemetry
200
+ and history traces::
201
+
202
+ >>> import networkx as nx
203
+ >>> from tnfr.dynamics.coordination import coordinate_global_local_phase
204
+ >>> G = nx.Graph()
205
+ >>> G.add_nodes_from(("a", {"theta": 0.0, "ΔNFR": 0.08}),
206
+ ... ("b", {"theta": 1.2, "ΔNFR": -0.05}))
207
+ >>> G.add_edge("a", "b")
208
+ >>> G.graph["history"] = {}
209
+ >>> coordinate_global_local_phase(G)
210
+ >>> list(round(G.nodes[n]["theta"], 3) for n in G)
211
+ [0.578, 0.622]
212
+ >>> history = G.graph["history"]
213
+ >>> sorted(history)
214
+ ['phase_R', 'phase_disr', 'phase_kG', 'phase_kL', 'phase_state']
215
+ >>> history["phase_kG"][-1] <= history["phase_kL"][-1]
216
+ True
217
+
218
+ The resulting history buffers allow downstream observers to correlate
219
+ ΔNFR adjustments with phase telemetry snapshots.
220
+ """
176
221
 
177
222
  g = cast(dict[str, Any], G.graph)
178
223
  hist = cast(dict[str, Any], g.setdefault("history", {}))
179
- maxlen = int(
180
- g.get("PHASE_HISTORY_MAXLEN", METRIC_DEFAULTS["PHASE_HISTORY_MAXLEN"])
181
- )
224
+ maxlen = int(g.get("PHASE_HISTORY_MAXLEN", METRIC_DEFAULTS["PHASE_HISTORY_MAXLEN"]))
182
225
  hist_state = cast(deque[str], _ensure_hist_deque(hist, "phase_state", maxlen))
183
226
  if hist_state:
184
227
  normalised_states = [normalise_state_token(item) for item in hist_state]
@@ -254,12 +297,10 @@ def coordinate_global_local_phase(
254
297
 
255
298
  theta_vals = [_theta_value(n) for n in nodes]
256
299
  cos_vals = [
257
- float(cos_map.get(n, math.cos(theta_vals[idx])))
258
- for idx, n in enumerate(nodes)
300
+ float(cos_map.get(n, math.cos(theta_vals[idx]))) for idx, n in enumerate(nodes)
259
301
  ]
260
302
  sin_vals = [
261
- float(sin_map.get(n, math.sin(theta_vals[idx])))
262
- for idx, n in enumerate(nodes)
303
+ float(sin_map.get(n, math.sin(theta_vals[idx]))) for idx, n in enumerate(nodes)
263
304
  ]
264
305
 
265
306
  if np is not None:
@@ -283,8 +324,8 @@ def coordinate_global_local_phase(
283
324
  for idx, n in enumerate(nodes)
284
325
  ]
285
326
  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
327
+ theta_updates = (
328
+ theta_arr + kG * (thG - theta_arr) + kL * (neighbor_arr - theta_arr)
288
329
  )
289
330
  for idx, node in enumerate(nodes):
290
331
  set_theta(G, node, float(theta_updates[int(idx)]))
@@ -313,11 +354,13 @@ def coordinate_global_local_phase(
313
354
  set_theta(G, node, float(th + kG * dG + kL * dL))
314
355
  return
315
356
 
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
- ]
357
+ approx_chunk = math.ceil(len(nodes) / jobs) if jobs else None
358
+ chunk_size = resolve_chunk_size(
359
+ approx_chunk,
360
+ len(nodes),
361
+ minimum=1,
362
+ )
363
+ chunks = [nodes[idx : idx + chunk_size] for idx in range(0, len(nodes), chunk_size)]
321
364
  args: list[ChunkArgs] = [
322
365
  (
323
366
  chunk,
@@ -340,4 +383,3 @@ def coordinate_global_local_phase(
340
383
  new_theta = results.get(node)
341
384
  base_theta = theta_map.get(node, 0.0)
342
385
  set_theta(G, node, float(new_theta if new_theta is not None else base_theta))
343
-