tnfr 4.5.1__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 (170) hide show
  1. tnfr/__init__.py +270 -90
  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 +631 -0
  7. tnfr/alias.pyi +140 -0
  8. tnfr/cache.py +732 -0
  9. tnfr/cache.pyi +232 -0
  10. tnfr/callback_utils.py +381 -0
  11. tnfr/callback_utils.pyi +105 -0
  12. tnfr/cli/__init__.py +89 -0
  13. tnfr/cli/__init__.pyi +47 -0
  14. tnfr/cli/arguments.py +199 -0
  15. tnfr/cli/arguments.pyi +33 -0
  16. tnfr/cli/execution.py +322 -0
  17. tnfr/cli/execution.pyi +80 -0
  18. tnfr/cli/utils.py +34 -0
  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/init.py +36 -0
  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 +228 -0
  31. tnfr/constants/__init__.pyi +104 -0
  32. tnfr/constants/core.py +158 -0
  33. tnfr/constants/core.pyi +17 -0
  34. tnfr/constants/init.py +31 -0
  35. tnfr/constants/init.pyi +12 -0
  36. tnfr/constants/metric.py +102 -0
  37. tnfr/constants/metric.pyi +19 -0
  38. tnfr/constants_glyphs.py +16 -0
  39. tnfr/constants_glyphs.pyi +12 -0
  40. tnfr/dynamics/__init__.py +136 -0
  41. tnfr/dynamics/__init__.pyi +83 -0
  42. tnfr/dynamics/adaptation.py +201 -0
  43. tnfr/dynamics/aliases.py +22 -0
  44. tnfr/dynamics/coordination.py +343 -0
  45. tnfr/dynamics/dnfr.py +2315 -0
  46. tnfr/dynamics/dnfr.pyi +33 -0
  47. tnfr/dynamics/integrators.py +561 -0
  48. tnfr/dynamics/integrators.pyi +35 -0
  49. tnfr/dynamics/runtime.py +521 -0
  50. tnfr/dynamics/sampling.py +34 -0
  51. tnfr/dynamics/sampling.pyi +7 -0
  52. tnfr/dynamics/selectors.py +680 -0
  53. tnfr/execution.py +216 -0
  54. tnfr/execution.pyi +65 -0
  55. tnfr/flatten.py +283 -0
  56. tnfr/flatten.pyi +28 -0
  57. tnfr/gamma.py +320 -89
  58. tnfr/gamma.pyi +40 -0
  59. tnfr/glyph_history.py +337 -0
  60. tnfr/glyph_history.pyi +53 -0
  61. tnfr/grammar.py +23 -153
  62. tnfr/grammar.pyi +13 -0
  63. tnfr/helpers/__init__.py +151 -0
  64. tnfr/helpers/__init__.pyi +66 -0
  65. tnfr/helpers/numeric.py +88 -0
  66. tnfr/helpers/numeric.pyi +12 -0
  67. tnfr/immutable.py +214 -0
  68. tnfr/immutable.pyi +37 -0
  69. tnfr/initialization.py +199 -0
  70. tnfr/initialization.pyi +73 -0
  71. tnfr/io.py +311 -0
  72. tnfr/io.pyi +11 -0
  73. tnfr/locking.py +37 -0
  74. tnfr/locking.pyi +7 -0
  75. tnfr/metrics/__init__.py +41 -0
  76. tnfr/metrics/__init__.pyi +20 -0
  77. tnfr/metrics/coherence.py +1469 -0
  78. tnfr/metrics/common.py +149 -0
  79. tnfr/metrics/common.pyi +15 -0
  80. tnfr/metrics/core.py +259 -0
  81. tnfr/metrics/core.pyi +13 -0
  82. tnfr/metrics/diagnosis.py +840 -0
  83. tnfr/metrics/diagnosis.pyi +89 -0
  84. tnfr/metrics/export.py +151 -0
  85. tnfr/metrics/glyph_timing.py +369 -0
  86. tnfr/metrics/reporting.py +152 -0
  87. tnfr/metrics/reporting.pyi +12 -0
  88. tnfr/metrics/sense_index.py +294 -0
  89. tnfr/metrics/sense_index.pyi +9 -0
  90. tnfr/metrics/trig.py +216 -0
  91. tnfr/metrics/trig.pyi +12 -0
  92. tnfr/metrics/trig_cache.py +105 -0
  93. tnfr/metrics/trig_cache.pyi +10 -0
  94. tnfr/node.py +255 -177
  95. tnfr/node.pyi +161 -0
  96. tnfr/observers.py +154 -150
  97. tnfr/observers.pyi +46 -0
  98. tnfr/ontosim.py +135 -134
  99. tnfr/ontosim.pyi +33 -0
  100. tnfr/operators/__init__.py +452 -0
  101. tnfr/operators/__init__.pyi +31 -0
  102. tnfr/operators/definitions.py +181 -0
  103. tnfr/operators/definitions.pyi +92 -0
  104. tnfr/operators/jitter.py +266 -0
  105. tnfr/operators/jitter.pyi +11 -0
  106. tnfr/operators/registry.py +80 -0
  107. tnfr/operators/registry.pyi +15 -0
  108. tnfr/operators/remesh.py +569 -0
  109. tnfr/presets.py +10 -23
  110. tnfr/presets.pyi +7 -0
  111. tnfr/py.typed +0 -0
  112. tnfr/rng.py +440 -0
  113. tnfr/rng.pyi +14 -0
  114. tnfr/selector.py +217 -0
  115. tnfr/selector.pyi +19 -0
  116. tnfr/sense.py +307 -142
  117. tnfr/sense.pyi +30 -0
  118. tnfr/structural.py +69 -164
  119. tnfr/structural.pyi +46 -0
  120. tnfr/telemetry/__init__.py +13 -0
  121. tnfr/telemetry/verbosity.py +37 -0
  122. tnfr/tokens.py +61 -0
  123. tnfr/tokens.pyi +41 -0
  124. tnfr/trace.py +520 -95
  125. tnfr/trace.pyi +68 -0
  126. tnfr/types.py +382 -17
  127. tnfr/types.pyi +145 -0
  128. tnfr/utils/__init__.py +158 -0
  129. tnfr/utils/__init__.pyi +133 -0
  130. tnfr/utils/cache.py +755 -0
  131. tnfr/utils/cache.pyi +156 -0
  132. tnfr/utils/data.py +267 -0
  133. tnfr/utils/data.pyi +73 -0
  134. tnfr/utils/graph.py +87 -0
  135. tnfr/utils/graph.pyi +10 -0
  136. tnfr/utils/init.py +746 -0
  137. tnfr/utils/init.pyi +85 -0
  138. tnfr/utils/io.py +157 -0
  139. tnfr/utils/io.pyi +10 -0
  140. tnfr/utils/validators.py +130 -0
  141. tnfr/utils/validators.pyi +19 -0
  142. tnfr/validation/__init__.py +25 -0
  143. tnfr/validation/__init__.pyi +17 -0
  144. tnfr/validation/compatibility.py +59 -0
  145. tnfr/validation/compatibility.pyi +8 -0
  146. tnfr/validation/grammar.py +149 -0
  147. tnfr/validation/grammar.pyi +11 -0
  148. tnfr/validation/rules.py +194 -0
  149. tnfr/validation/rules.pyi +18 -0
  150. tnfr/validation/syntax.py +151 -0
  151. tnfr/validation/syntax.pyi +7 -0
  152. tnfr-6.0.0.dist-info/METADATA +135 -0
  153. tnfr-6.0.0.dist-info/RECORD +157 -0
  154. tnfr/cli.py +0 -322
  155. tnfr/config.py +0 -41
  156. tnfr/constants.py +0 -277
  157. tnfr/dynamics.py +0 -814
  158. tnfr/helpers.py +0 -264
  159. tnfr/main.py +0 -47
  160. tnfr/metrics.py +0 -597
  161. tnfr/operators.py +0 -525
  162. tnfr/program.py +0 -176
  163. tnfr/scenarios.py +0 -34
  164. tnfr/validators.py +0 -38
  165. tnfr-4.5.1.dist-info/METADATA +0 -221
  166. tnfr-4.5.1.dist-info/RECORD +0 -28
  167. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/WHEEL +0 -0
  168. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/entry_points.txt +0 -0
  169. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/licenses/LICENSE.md +0 -0
  170. {tnfr-4.5.1.dist-info → tnfr-6.0.0.dist-info}/top_level.txt +0 -0
tnfr/sense.py CHANGED
@@ -1,200 +1,365 @@
1
+ """Sense calculations."""
2
+
1
3
  from __future__ import annotations
2
- from typing import Dict, Any, List, Tuple
4
+ from typing import Any, Callable, TypeVar
5
+ from collections.abc import Iterable, Iterator, Mapping
3
6
  import math
4
7
  from collections import Counter
5
-
6
- from .constants import DEFAULTS, ALIAS_SI, ALIAS_EPI
7
- from .helpers import _get_attr, clamp01, register_callback, ensure_history, last_glifo
8
-
8
+ from itertools import tee
9
+
10
+ import networkx as nx
11
+
12
+ from .constants import get_aliases, get_graph_param
13
+ from .alias import get_attr
14
+ from .helpers.numeric import clamp01, kahan_sum_nd
15
+ from .utils import get_numpy
16
+ from .callback_utils import CallbackEvent, callback_manager
17
+ from .glyph_history import (
18
+ ensure_history,
19
+ last_glyph,
20
+ count_glyphs,
21
+ append_metric,
22
+ )
23
+ from .config.constants import (
24
+ ANGLE_MAP,
25
+ GLYPHS_CANONICAL,
26
+ )
27
+ from .types import NodeId, SigmaVector, TNFRGraph
9
28
  # -------------------------
10
- # Canon: orden circular de glifos y ángulos
29
+ # Canon: circular glyph order and angles
11
30
  # -------------------------
12
- GLYPHS_CANONICAL: List[str] = [
13
- "A’L", # 0
14
- "E’N", # 1
15
- "I’L", # 2
16
- "U’M", # 3
17
- "R’A", # 4
18
- "VA’L", # 5
19
- "O’Z", # 6
20
- "Z’HIR",# 7
21
- "NA’V", # 8
22
- "T’HOL",# 9
23
- "NU’L", #10
24
- "SH’A", #11
25
- "RE’MESH" #12
26
- ]
27
-
28
- _SIGMA_ANGLES: Dict[str, float] = {g: (2.0*math.pi * i / len(GLYPHS_CANONICAL)) for i, g in enumerate(GLYPHS_CANONICAL)}
29
31
 
30
- # -------------------------
31
- # Config por defecto
32
- # -------------------------
33
- DEFAULTS.setdefault("SIGMA", {
34
- "enabled": True,
35
- "weight": "Si", # "Si" | "EPI" | "1"
36
- "smooth": 0.0, # EMA sobre el vector global (0=off)
37
- "history_key": "sigma_global", # dónde guardar en G.graph['history']
38
- "per_node": False, # si True, guarda trayectoria σ por nodo (más pesado)
39
- })
32
+ GLYPH_UNITS: dict[str, complex] = {
33
+ g: complex(math.cos(a), math.sin(a)) for g, a in ANGLE_MAP.items()
34
+ }
35
+
36
+ __all__ = (
37
+ "GLYPH_UNITS",
38
+ "glyph_angle",
39
+ "glyph_unit",
40
+ "sigma_vector_node",
41
+ "sigma_vector",
42
+ "sigma_vector_from_graph",
43
+ "push_sigma_snapshot",
44
+ "register_sigma_callback",
45
+ "sigma_rose",
46
+ )
40
47
 
41
48
  # -------------------------
42
- # Utilidades básicas
49
+ # Basic utilities
43
50
  # -------------------------
44
51
 
52
+
53
+ T = TypeVar("T")
54
+
55
+
56
+ def _resolve_glyph(g: str, mapping: Mapping[str, T]) -> T:
57
+ """Return ``mapping[g]`` or raise ``KeyError`` with a standard message."""
58
+
59
+ try:
60
+ return mapping[g]
61
+ except KeyError as e: # pragma: no cover - small helper
62
+ raise KeyError(f"Unknown glyph: {g}") from e
63
+
64
+
45
65
  def glyph_angle(g: str) -> float:
46
- return float(_SIGMA_ANGLES.get(g, 0.0))
66
+ """Return angle for glyph ``g``."""
67
+
68
+ return float(_resolve_glyph(g, ANGLE_MAP))
47
69
 
48
70
 
49
71
  def glyph_unit(g: str) -> complex:
50
- a = glyph_angle(g)
51
- return complex(math.cos(a), math.sin(a))
72
+ """Return unit vector for glyph ``g``."""
52
73
 
74
+ return _resolve_glyph(g, GLYPH_UNITS)
53
75
 
54
- def _weight(G, n, mode: str) -> float:
55
- nd = G.nodes[n]
56
- if mode == "Si":
57
- return clamp01(_get_attr(nd, ALIAS_SI, 0.5))
58
- if mode == "EPI":
59
- return max(0.0, float(_get_attr(nd, ALIAS_EPI, 0.0)))
60
- return 1.0
61
76
 
77
+ ALIAS_SI = get_aliases("SI")
78
+ ALIAS_EPI = get_aliases("EPI")
62
79
 
63
-
64
- # -------------------------
65
- # σ por nodo y σ global
66
- # -------------------------
80
+ MODE_FUNCS: dict[str, Callable[[Mapping[str, Any]], float]] = {
81
+ "Si": lambda nd: clamp01(get_attr(nd, ALIAS_SI, 0.5)),
82
+ "EPI": lambda nd: max(0.0, get_attr(nd, ALIAS_EPI, 0.0)),
83
+ }
67
84
 
68
- def sigma_vector_node(G, n, weight_mode: str | None = None) -> Dict[str, float] | None:
69
- nd = G.nodes[n]
70
- g = last_glifo(nd)
71
- if g is None:
85
+
86
+ def _weight(nd: Mapping[str, Any], mode: str) -> float:
87
+ return MODE_FUNCS.get(mode, lambda _: 1.0)(nd)
88
+
89
+
90
+ def _node_weight(
91
+ nd: Mapping[str, Any], weight_mode: str
92
+ ) -> tuple[str, float, complex] | None:
93
+ """Return ``(glyph, weight, weighted_unit)`` or ``None`` if no glyph."""
94
+ g = last_glyph(nd)
95
+ if not g:
72
96
  return None
73
- w = _weight(G, n, weight_mode or G.graph.get("SIGMA", DEFAULTS["SIGMA"]).get("weight", "Si"))
74
- z = glyph_unit(g) * w
75
- x, y = z.real, z.imag
76
- mag = math.hypot(x, y)
77
- ang = math.atan2(y, x) if mag > 0 else glyph_angle(g)
78
- return {"x": float(x), "y": float(y), "mag": float(mag), "angle": float(ang), "glifo": g, "w": float(w)}
97
+ w = _weight(nd, weight_mode)
98
+ z = glyph_unit(g) * w # precompute weighted unit vector
99
+ return g, w, z
100
+
79
101
 
102
+ def _sigma_cfg(G: TNFRGraph) -> dict[str, Any]:
103
+ return get_graph_param(G, "SIGMA", dict)
80
104
 
81
- def sigma_vector_global(G, weight_mode: str | None = None) -> Dict[str, float]:
82
- """Vector global del plano del sentido σ.
83
105
 
84
- Mapea el último glifo de cada nodo a un vector unitario en S¹, ponderado
85
- por `Si` (o `EPI`/1), y promedia para obtener:
86
- - componentes (x, y), magnitud |σ| y ángulo arg(σ).
106
+ def _to_complex(val: complex | float | int) -> complex:
107
+ """Return ``val`` as complex, promoting real numbers."""
87
108
 
88
- Interpretación TNFR: |σ| mide cuán alineada está la red en su
89
- **recorrido glífico**; arg(σ) indica la **dirección funcional** dominante
90
- (p. ej., torno a I’L/RA para consolidación/distribución, O’Z/Z’HIR para cambio).
109
+ if isinstance(val, complex):
110
+ return val
111
+ if isinstance(val, (int, float)):
112
+ return complex(val, 0.0)
113
+ raise TypeError("values must be an iterable of real or complex numbers")
114
+
115
+
116
+ def _empty_sigma(fallback_angle: float) -> SigmaVector:
117
+ """Return an empty σ-vector with ``fallback_angle``.
118
+
119
+ Helps centralise the default structure returned when no values are
120
+ available for σ calculations.
91
121
  """
92
- cfg = G.graph.get("SIGMA", DEFAULTS["SIGMA"])
93
- weight_mode = weight_mode or cfg.get("weight", "Si")
94
- acc = complex(0.0, 0.0)
122
+
123
+ return {
124
+ "x": 0.0,
125
+ "y": 0.0,
126
+ "mag": 0.0,
127
+ "angle": float(fallback_angle),
128
+ "n": 0,
129
+ }
130
+
131
+
132
+ # -------------------------
133
+ # σ per node and global σ
134
+ # -------------------------
135
+
136
+
137
+ def _sigma_from_iterable(
138
+ values: Iterable[complex | float | int] | complex | float | int,
139
+ fallback_angle: float = 0.0,
140
+ ) -> SigmaVector:
141
+ """Normalise vectors in the σ-plane.
142
+
143
+ ``values`` may contain complex or real numbers; real inputs are promoted to
144
+ complex with zero imaginary part. The returned dictionary includes the
145
+ number of processed values under the ``"n"`` key.
146
+ """
147
+
148
+ if isinstance(values, Iterable) and not isinstance(values, (str, bytes, bytearray, Mapping)):
149
+ iterator = iter(values)
150
+ else:
151
+ iterator = iter((values,))
152
+
153
+ np = get_numpy()
154
+ if np is not None:
155
+ iterator, np_iter = tee(iterator)
156
+ arr = np.fromiter((_to_complex(v) for v in np_iter), dtype=np.complex128)
157
+ cnt = int(arr.size)
158
+ if cnt == 0:
159
+ return _empty_sigma(fallback_angle)
160
+ x = float(np.mean(arr.real))
161
+ y = float(np.mean(arr.imag))
162
+ mag = float(np.hypot(x, y))
163
+ ang = float(np.arctan2(y, x)) if mag > 0 else float(fallback_angle)
164
+ return {
165
+ "x": float(x),
166
+ "y": float(y),
167
+ "mag": float(mag),
168
+ "angle": float(ang),
169
+ "n": int(cnt),
170
+ }
95
171
  cnt = 0
96
- for n in G.nodes():
97
- v = sigma_vector_node(G, n, weight_mode)
98
- if v is None:
99
- continue
100
- acc += complex(v["x"], v["y"])
101
- cnt += 1
172
+
173
+ def pair_iter() -> Iterator[tuple[float, float]]:
174
+ nonlocal cnt
175
+ for val in iterator:
176
+ z = _to_complex(val)
177
+ cnt += 1
178
+ yield (z.real, z.imag)
179
+
180
+ sum_x, sum_y = kahan_sum_nd(pair_iter(), dims=2)
181
+
102
182
  if cnt == 0:
103
- return {"x": 1.0, "y": 0.0, "mag": 1.0, "angle": 0.0, "n": 0}
104
- x, y = acc.real / max(1, cnt), acc.imag / max(1, cnt)
183
+ return _empty_sigma(fallback_angle)
184
+
185
+ x = sum_x / cnt
186
+ y = sum_y / cnt
187
+ mag = math.hypot(x, y)
188
+ ang = math.atan2(y, x) if mag > 0 else float(fallback_angle)
189
+ return {
190
+ "x": float(x),
191
+ "y": float(y),
192
+ "mag": float(mag),
193
+ "angle": float(ang),
194
+ "n": int(cnt),
195
+ }
196
+
197
+
198
+ def _ema_update(
199
+ prev: SigmaVector, current: SigmaVector, alpha: float
200
+ ) -> SigmaVector:
201
+ """Exponential moving average update for σ vectors."""
202
+ x = (1 - alpha) * prev["x"] + alpha * current["x"]
203
+ y = (1 - alpha) * prev["y"] + alpha * current["y"]
105
204
  mag = math.hypot(x, y)
106
205
  ang = math.atan2(y, x)
107
- return {"x": float(x), "y": float(y), "mag": float(mag), "angle": float(ang), "n": cnt}
206
+ return {
207
+ "x": float(x),
208
+ "y": float(y),
209
+ "mag": float(mag),
210
+ "angle": float(ang),
211
+ "n": int(current["n"]),
212
+ }
213
+
214
+
215
+ def _sigma_from_nodes(
216
+ nodes: Iterable[Mapping[str, Any]],
217
+ weight_mode: str,
218
+ fallback_angle: float = 0.0,
219
+ ) -> tuple[SigmaVector, list[tuple[str, float, complex]]]:
220
+ """Aggregate weighted glyph vectors for ``nodes``.
221
+
222
+ Returns the aggregated σ vector and the list of ``(glyph, weight, vector)``
223
+ triples used in the calculation.
224
+ """
225
+
226
+ nws = [nw for nd in nodes if (nw := _node_weight(nd, weight_mode))]
227
+ sv = _sigma_from_iterable((nw[2] for nw in nws), fallback_angle)
228
+ return sv, nws
229
+
230
+
231
+ def sigma_vector_node(
232
+ G: TNFRGraph, n: NodeId, weight_mode: str | None = None
233
+ ) -> SigmaVector | None:
234
+ cfg = _sigma_cfg(G)
235
+ nd = G.nodes[n]
236
+ weight_mode = weight_mode or cfg.get("weight", "Si")
237
+ sv, nws = _sigma_from_nodes([nd], weight_mode)
238
+ if not nws:
239
+ return None
240
+ g, w, _ = nws[0]
241
+ if sv["mag"] == 0:
242
+ sv["angle"] = glyph_angle(g)
243
+ sv["glyph"] = g
244
+ sv["w"] = float(w)
245
+ return sv
246
+
247
+
248
+ def sigma_vector(dist: Mapping[str, float]) -> SigmaVector:
249
+ """Compute Σ⃗ from a glyph distribution.
250
+
251
+ ``dist`` may contain raw counts or proportions. All ``(glyph, weight)``
252
+ pairs are converted to vectors and passed to :func:`_sigma_from_iterable`.
253
+ The resulting vector includes the number of processed pairs under ``n``.
254
+ """
255
+
256
+ vectors = (glyph_unit(g) * float(w) for g, w in dist.items())
257
+ return _sigma_from_iterable(vectors)
258
+
259
+
260
+ def sigma_vector_from_graph(
261
+ G: TNFRGraph, weight_mode: str | None = None
262
+ ) -> SigmaVector:
263
+ """Global vector in the σ sense plane for a graph.
264
+
265
+ Parameters
266
+ ----------
267
+ G:
268
+ NetworkX graph with per-node states.
269
+ weight_mode:
270
+ How to weight each node ("Si", "EPI" or ``None`` for unit weight).
271
+
272
+ Returns
273
+ -------
274
+ dict[str, float]
275
+ Cartesian components, magnitude and angle of the average vector.
276
+ """
277
+
278
+ if not isinstance(G, nx.Graph):
279
+ raise TypeError("sigma_vector_from_graph requires a networkx.Graph")
280
+
281
+ cfg = _sigma_cfg(G)
282
+ weight_mode = weight_mode or cfg.get("weight", "Si")
283
+ sv, _ = _sigma_from_nodes(
284
+ (nd for _, nd in G.nodes(data=True)), weight_mode
285
+ )
286
+ return sv
108
287
 
109
288
 
110
289
  # -------------------------
111
- # Historia / series
290
+ # History / series
112
291
  # -------------------------
113
292
 
114
- def push_sigma_snapshot(G, t: float | None = None) -> None:
115
- cfg = G.graph.get("SIGMA", DEFAULTS["SIGMA"])
293
+
294
+ def push_sigma_snapshot(G: TNFRGraph, t: float | None = None) -> None:
295
+ cfg = _sigma_cfg(G)
116
296
  if not cfg.get("enabled", True):
117
297
  return
298
+
299
+ # Local history cache to avoid repeated lookups
118
300
  hist = ensure_history(G)
119
301
  key = cfg.get("history_key", "sigma_global")
120
302
 
121
- # Global
122
- sv = sigma_vector_global(G, cfg.get("weight", "Si"))
303
+ weight_mode = cfg.get("weight", "Si")
304
+ sv = sigma_vector_from_graph(G, weight_mode)
123
305
 
124
- # Suavizado exponencial (EMA) opcional
306
+ # Optional exponential smoothing (EMA)
125
307
  alpha = float(cfg.get("smooth", 0.0))
126
308
  if alpha > 0 and hist.get(key):
127
- prev = hist[key][-1]
128
- x = (1-alpha)*prev["x"] + alpha*sv["x"]
129
- y = (1-alpha)*prev["y"] + alpha*sv["y"]
130
- mag = math.hypot(x, y)
131
- ang = math.atan2(y, x)
132
- sv = {"x": x, "y": y, "mag": mag, "angle": ang, "n": sv.get("n", 0)}
133
-
134
- sv["t"] = float(G.graph.get("_t", 0.0) if t is None else t)
135
-
136
- hist.setdefault(key, []).append(sv)
137
-
138
- # Conteo de glifos por paso (útil para rosa glífica)
139
- counts = Counter()
140
- for n in G.nodes():
141
- g = last_glifo(G.nodes[n])
142
- if g:
143
- counts[g] += 1
144
- hist.setdefault("sigma_counts", []).append({"t": sv["t"], **counts})
145
-
146
- # Trayectoria por nodo (opcional)
309
+ sv = _ema_update(hist[key][-1], sv, alpha)
310
+
311
+ current_t = float(G.graph.get("_t", 0.0) if t is None else t)
312
+ sv["t"] = current_t
313
+
314
+ append_metric(hist, key, sv)
315
+
316
+ # Glyph count per step (useful for the glyph rose)
317
+ counts = count_glyphs(G, last_only=True)
318
+ append_metric(hist, "sigma_counts", {"t": current_t, **counts})
319
+
320
+ # Optional per-node trajectory
147
321
  if cfg.get("per_node", False):
148
322
  per = hist.setdefault("sigma_per_node", {})
149
- for n in G.nodes():
150
- nd = G.nodes[n]
151
- g = last_glifo(nd)
323
+ for n, nd in G.nodes(data=True):
324
+ g = last_glyph(nd)
152
325
  if not g:
153
326
  continue
154
- a = glyph_angle(g)
155
327
  d = per.setdefault(n, [])
156
- d.append({"t": sv["t"], "g": g, "angle": a})
328
+ d.append({"t": current_t, "g": g, "angle": glyph_angle(g)})
157
329
 
158
330
 
159
331
  # -------------------------
160
- # Registro como callback automático (after_step)
332
+ # Register as an automatic callback (after_step)
161
333
  # -------------------------
162
334
 
163
- def register_sigma_callback(G) -> None:
164
- register_callback(G, when="after_step", func=push_sigma_snapshot, name="sigma_snapshot")
165
-
166
-
167
- # -------------------------
168
- # Series de utilidad
169
- # -------------------------
170
335
 
171
- def sigma_series(G, key: str | None = None) -> Dict[str, List[float]]:
172
- cfg = G.graph.get("SIGMA", DEFAULTS["SIGMA"])
173
- key = key or cfg.get("history_key", "sigma_global")
174
- hist = G.graph.get("history", {})
175
- xs = hist.get(key, [])
176
- if not xs:
177
- return {"t": [], "angle": [], "mag": []}
178
- return {
179
- "t": [float(x.get("t", i)) for i, x in enumerate(xs)],
180
- "angle": [float(x["angle"]) for x in xs],
181
- "mag": [float(x["mag"]) for x in xs],
182
- }
336
+ def register_sigma_callback(G: TNFRGraph) -> None:
337
+ callback_manager.register_callback(
338
+ G,
339
+ event=CallbackEvent.AFTER_STEP.value,
340
+ func=push_sigma_snapshot,
341
+ name="sigma_snapshot",
342
+ )
183
343
 
184
344
 
185
- def sigma_rose(G, steps: int | None = None) -> Dict[str, int]:
186
- """Histograma de glifos en los últimos `steps` pasos (o todos)."""
187
- hist = G.graph.get("history", {})
345
+ def sigma_rose(G: TNFRGraph, steps: int | None = None) -> dict[str, int]:
346
+ """Histogram of glyphs in the last ``steps`` steps (or all)."""
347
+ hist = ensure_history(G)
188
348
  counts = hist.get("sigma_counts", [])
189
349
  if not counts:
190
350
  return {g: 0 for g in GLYPHS_CANONICAL}
191
- if steps is None or steps >= len(counts):
192
- agg = Counter()
193
- for row in counts:
194
- agg.update({k: v for k, v in row.items() if k != "t"})
195
- out = {g: int(agg.get(g, 0)) for g in GLYPHS_CANONICAL}
196
- return out
197
- agg = Counter()
198
- for row in counts[-int(steps):]:
199
- agg.update({k: v for k, v in row.items() if k != "t"})
200
- return {g: int(agg.get(g, 0)) for g in GLYPHS_CANONICAL}
351
+ if steps is not None:
352
+ steps = int(steps)
353
+ if steps < 0:
354
+ raise ValueError("steps must be non-negative")
355
+ rows = (
356
+ counts if steps >= len(counts) else counts[-steps:]
357
+ ) # noqa: E203
358
+ else:
359
+ rows = counts
360
+ counter = Counter()
361
+ for row in rows:
362
+ for k, v in row.items():
363
+ if k != "t":
364
+ counter[k] += int(v)
365
+ return {g: int(counter.get(g, 0)) for g in GLYPHS_CANONICAL}
tnfr/sense.pyi ADDED
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from typing import Optional
5
+
6
+ from .types import NodeId, SigmaVector, TNFRGraph
7
+
8
+ __all__: tuple[str, ...]
9
+
10
+ GLYPH_UNITS: dict[str, complex]
11
+
12
+ def glyph_angle(g: str) -> float: ...
13
+
14
+ def glyph_unit(g: str) -> complex: ...
15
+
16
+ def push_sigma_snapshot(G: TNFRGraph, t: Optional[float] = None) -> None: ...
17
+
18
+ def register_sigma_callback(G: TNFRGraph) -> None: ...
19
+
20
+ def sigma_rose(G: TNFRGraph, steps: Optional[int] = None) -> dict[str, int]: ...
21
+
22
+ def sigma_vector(dist: Mapping[str, float]) -> SigmaVector: ...
23
+
24
+ def sigma_vector_from_graph(
25
+ G: TNFRGraph, weight_mode: Optional[str] = None
26
+ ) -> SigmaVector: ...
27
+
28
+ def sigma_vector_node(
29
+ G: TNFRGraph, n: NodeId, weight_mode: Optional[str] = None
30
+ ) -> Optional[SigmaVector]: ...