tnfr 4.1.0__py3-none-any.whl → 4.5.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.

tnfr/dynamics.py CHANGED
@@ -11,19 +11,29 @@ Incluye:
11
11
  - default_glyph_selector, step, run
12
12
  """
13
13
  from __future__ import annotations
14
- from typing import Dict, Any, Iterable
14
+ from typing import Dict, Any, Iterable, Literal
15
15
  import math
16
16
  from collections import deque
17
17
  import networkx as nx
18
18
 
19
19
  from .observers import sincronía_fase, carga_glifica, orden_kuramoto, sigma_vector
20
20
  from .operators import aplicar_remesh_si_estabilizacion_global
21
- from .grammar import select_and_apply_with_grammar
22
- from .constants import DEFAULTS, ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI, ALIAS_dEPI, ALIAS_D2EPI
21
+ from .grammar import (
22
+ enforce_canonical_grammar,
23
+ on_applied_glifo,
24
+ AL,
25
+ EN,
26
+ )
27
+ from .constants import (
28
+ DEFAULTS,
29
+ ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI,
30
+ ALIAS_dEPI, ALIAS_D2EPI, ALIAS_dVF, ALIAS_D2VF, ALIAS_dSI,
31
+ ALIAS_EPI_KIND,
32
+ )
23
33
  from .gamma import eval_gamma
24
34
  from .helpers import (
25
35
  clamp, clamp01, list_mean, phase_distance,
26
- _get_attr, _set_attr, media_vecinal, fase_media,
36
+ _get_attr, _set_attr, _get_attr_str, _set_attr_str, media_vecinal, fase_media,
27
37
  invoke_callbacks, reciente_glifo
28
38
  )
29
39
 
@@ -32,20 +42,21 @@ from .helpers import (
32
42
  # -------------------------
33
43
 
34
44
  def _write_dnfr_metadata(G, *, weights: dict, hook_name: str, note: str | None = None) -> None:
35
- """Escribe en G.graph un bloque _DNFR_META con la mezcla y el nombre del hook."""
36
- w_phase = float(weights.get("phase", 0.0))
37
- w_epi = float(weights.get("epi", 0.0))
38
- w_vf = float(weights.get("vf", 0.0))
39
- s = w_phase + w_epi + w_vf
40
- if s <= 0:
41
- w_phase = w_epi = w_vf = 1/3
42
- s = 1.0
45
+ """Escribe en G.graph un bloque _DNFR_META con la mezcla y el nombre del hook.
46
+
47
+ `weights` puede incluir componentes arbitrarias (phase/epi/vf/topo/etc.)."""
48
+ total = sum(float(v) for v in weights.values())
49
+ if total <= 0:
50
+ # si no hay pesos, normalizamos a componentes iguales
51
+ n = max(1, len(weights))
52
+ weights = {k: 1.0 / n for k in weights}
53
+ total = 1.0
43
54
  meta = {
44
55
  "hook": hook_name,
45
56
  "weights_raw": dict(weights),
46
- "weights_norm": {"phase": w_phase/s, "epi": w_epi/s, "vf": w_vf/s},
47
- "components": [k for k, v in {"phase":w_phase, "epi":w_epi, "vf":w_vf}.items() if v != 0],
48
- "doc": "ΔNFR = w_phase·g_phase + w_epi·g_epi + w_vf·g_vf",
57
+ "weights_norm": {k: float(v) / total for k, v in weights.items()},
58
+ "components": [k for k, v in weights.items() if float(v) != 0.0],
59
+ "doc": "ΔNFR = Σ w_i·g_i",
49
60
  }
50
61
  if note:
51
62
  meta["note"] = str(note)
@@ -54,26 +65,35 @@ def _write_dnfr_metadata(G, *, weights: dict, hook_name: str, note: str | None =
54
65
 
55
66
 
56
67
  def default_compute_delta_nfr(G) -> None:
57
- """Calcula ΔNFR mezclando gradientes de fase, EPI y νf según pesos."""
68
+ """Calcula ΔNFR mezclando gradientes de fase, EPI, νf y un término topológico."""
58
69
  w = G.graph.get("DNFR_WEIGHTS", DEFAULTS["DNFR_WEIGHTS"]) # dict
59
70
  w_phase = float(w.get("phase", 0.34))
60
71
  w_epi = float(w.get("epi", 0.33))
61
72
  w_vf = float(w.get("vf", 0.33))
62
- s = w_phase + w_epi + w_vf
73
+ w_topo = float(w.get("topo", 0.0))
74
+ s = w_phase + w_epi + w_vf + w_topo
63
75
  if s <= 0:
64
76
  w_phase = w_epi = w_vf = 1/3
77
+ w_topo = 0.0
78
+ s = 1.0
65
79
  else:
66
- w_phase, w_epi, w_vf = w_phase/s, w_epi/s, w_vf/s
80
+ w_phase, w_epi, w_vf, w_topo = (w_phase/s, w_epi/s, w_vf/s, w_topo/s)
67
81
 
68
82
  # Documentar mezcla y hook activo
69
- _write_dnfr_metadata(G, weights={"phase":w_phase, "epi":w_epi, "vf":w_vf}, hook_name="default_compute_delta_nfr")
70
-
83
+ _write_dnfr_metadata(
84
+ G,
85
+ weights={"phase": w_phase, "epi": w_epi, "vf": w_vf, "topo": w_topo},
86
+ hook_name="default_compute_delta_nfr",
87
+ )
88
+
89
+ degs = dict(G.degree()) if w_topo != 0 else None
90
+
71
91
  for n in G.nodes():
72
92
  nd = G.nodes[n]
73
93
  th_i = _get_attr(nd, ALIAS_THETA, 0.0)
74
94
  th_bar = fase_media(G, n)
75
95
  # Gradiente de fase: empuja hacia la fase media (signo envuelto)
76
- g_phase = - ( (th_i - th_bar + math.pi) % (2*math.pi) - math.pi ) / math.pi # ~[-1,1]
96
+ g_phase = -((th_i - th_bar + math.pi) % (2 * math.pi) - math.pi) / math.pi # ~[-1,1]
77
97
 
78
98
  epi_i = _get_attr(nd, ALIAS_EPI, 0.0)
79
99
  epi_bar = media_vecinal(G, n, ALIAS_EPI, default=epi_i)
@@ -83,7 +103,14 @@ def default_compute_delta_nfr(G) -> None:
83
103
  vf_bar = media_vecinal(G, n, ALIAS_VF, default=vf_i)
84
104
  g_vf = (vf_bar - vf_i)
85
105
 
86
- dnfr = w_phase*g_phase + w_epi*g_epi + w_vf*g_vf
106
+ if w_topo != 0 and degs is not None:
107
+ deg_i = float(degs.get(n, 0))
108
+ deg_bar = list_mean(degs.get(v, deg_i) for v in G.neighbors(n)) if G.degree(n) else deg_i
109
+ g_topo = deg_bar - deg_i
110
+ else:
111
+ g_topo = 0.0
112
+
113
+ dnfr = w_phase * g_phase + w_epi * g_epi + w_vf * g_vf + w_topo * g_topo
87
114
  _set_attr(nd, ALIAS_DNFR, dnfr)
88
115
 
89
116
  def set_delta_nfr_hook(G, func, *, name: str | None = None, note: str | None = None) -> None:
@@ -103,9 +130,9 @@ def dnfr_phase_only(G) -> None:
103
130
  nd = G.nodes[n]
104
131
  th_i = _get_attr(nd, ALIAS_THETA, 0.0)
105
132
  th_bar = fase_media(G, n)
106
- g_phase = - ( (th_i - th_bar + math.pi) % (2*math.pi) - math.pi ) / math.pi
133
+ g_phase = -((th_i - th_bar + math.pi) % (2 * math.pi) - math.pi) / math.pi
107
134
  _set_attr(nd, ALIAS_DNFR, g_phase)
108
- _write_dnfr_metadata(G, weights={"phase":1.0, "epi":0.0, "vf":0.0}, hook_name="dnfr_phase_only", note="Hook de ejemplo.")
135
+ _write_dnfr_metadata(G, weights={"phase": 1.0}, hook_name="dnfr_phase_only", note="Hook de ejemplo.")
109
136
 
110
137
  def dnfr_epi_vf_mixed(G) -> None:
111
138
  """Ejemplo: ΔNFR sin fase, mezclando EPI y νf."""
@@ -120,11 +147,40 @@ def dnfr_epi_vf_mixed(G) -> None:
120
147
  _set_attr(nd, ALIAS_DNFR, 0.5*g_epi + 0.5*g_vf)
121
148
  _write_dnfr_metadata(G, weights={"phase":0.0, "epi":0.5, "vf":0.5}, hook_name="dnfr_epi_vf_mixed", note="Hook de ejemplo.")
122
149
 
150
+
151
+ def dnfr_laplacian(G) -> None:
152
+ """Gradiente topológico explícito usando Laplaciano sobre EPI y νf."""
153
+ wE = float(G.graph.get("DNFR_WEIGHTS", {}).get("epi", 0.33))
154
+ wV = float(G.graph.get("DNFR_WEIGHTS", {}).get("vf", 0.33))
155
+ for n in G.nodes():
156
+ nd = G.nodes[n]
157
+ epi = _get_attr(nd, ALIAS_EPI, 0.0)
158
+ vf = _get_attr(nd, ALIAS_VF, 0.0)
159
+ neigh = list(G.neighbors(n))
160
+ deg = len(neigh) or 1
161
+ epi_bar = sum(_get_attr(G.nodes[v], ALIAS_EPI, epi) for v in neigh) / deg
162
+ vf_bar = sum(_get_attr(G.nodes[v], ALIAS_VF, vf) for v in neigh) / deg
163
+ g_epi = epi_bar - epi
164
+ g_vf = vf_bar - vf
165
+ _set_attr(nd, ALIAS_DNFR, wE * g_epi + wV * g_vf)
166
+ _write_dnfr_metadata(
167
+ G,
168
+ weights={"epi": wE, "vf": wV},
169
+ hook_name="dnfr_laplacian",
170
+ note="Gradiente topológico",
171
+ )
172
+
123
173
  # -------------------------
124
174
  # Ecuación nodal
125
175
  # -------------------------
126
176
 
127
- def update_epi_via_nodal_equation(G, *, dt: float = None, t: float | None = None) -> None:
177
+ def update_epi_via_nodal_equation(
178
+ G,
179
+ *,
180
+ dt: float = None,
181
+ t: float | None = None,
182
+ method: Literal["euler", "rk4"] | None = None,
183
+ ) -> None:
128
184
  """Ecuación nodal TNFR.
129
185
 
130
186
  Implementa la forma extendida de la ecuación nodal:
@@ -155,18 +211,50 @@ def update_epi_via_nodal_equation(G, *, dt: float = None, t: float | None = None
155
211
  t = float(G.graph.get("_t", 0.0))
156
212
  else:
157
213
  t = float(t)
158
- for n in G.nodes():
159
- nd = G.nodes[n]
160
- vf = _get_attr(nd, ALIAS_VF, 0.0)
161
- dnfr = _get_attr(nd, ALIAS_DNFR, 0.0)
162
- dEPI_dt_prev = _get_attr(nd, ALIAS_dEPI, 0.0)
163
- dEPI_dt = vf * dnfr
164
- dEPI_dt += eval_gamma(G, n, t)
165
- epi = _get_attr(nd, ALIAS_EPI, 0.0) + dt * dEPI_dt
166
- _set_attr(nd, ALIAS_EPI, epi)
167
- _set_attr(nd, ALIAS_dEPI, dEPI_dt)
168
- _set_attr(nd, ALIAS_D2EPI, (dEPI_dt - dEPI_dt_prev) / dt if dt != 0 else 0.0)
169
- G.graph["_t"] = t + dt
214
+
215
+ method = (method or G.graph.get("INTEGRATOR_METHOD", DEFAULTS.get("INTEGRATOR_METHOD", "euler"))).lower()
216
+ dt_min = float(G.graph.get("DT_MIN", DEFAULTS.get("DT_MIN", 0.0)))
217
+ if dt_min > 0 and dt > dt_min:
218
+ steps = int(math.ceil(dt / dt_min))
219
+ else:
220
+ steps = 1
221
+ dt_step = dt / steps if steps else 0.0
222
+
223
+ t_local = t
224
+ for _ in range(steps):
225
+ for n in G.nodes():
226
+ nd = G.nodes[n]
227
+ vf = _get_attr(nd, ALIAS_VF, 0.0)
228
+ dnfr = _get_attr(nd, ALIAS_DNFR, 0.0)
229
+ dEPI_dt_prev = _get_attr(nd, ALIAS_dEPI, 0.0)
230
+ epi_i = _get_attr(nd, ALIAS_EPI, 0.0)
231
+
232
+ def _f(time: float) -> float:
233
+ return vf * dnfr + eval_gamma(G, n, time)
234
+
235
+ if method == "rk4":
236
+ k1 = _f(t_local)
237
+ k2 = _f(t_local + dt_step / 2.0)
238
+ k3 = _f(t_local + dt_step / 2.0)
239
+ k4 = _f(t_local + dt_step)
240
+ epi = epi_i + (dt_step / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
241
+ dEPI_dt = k4
242
+ else:
243
+ if method != "euler":
244
+ raise ValueError("method must be 'euler' or 'rk4'")
245
+ dEPI_dt = _f(t_local)
246
+ epi = epi_i + dt_step * dEPI_dt
247
+
248
+ epi_kind = _get_attr_str(nd, ALIAS_EPI_KIND, "")
249
+ _set_attr(nd, ALIAS_EPI, epi)
250
+ if epi_kind:
251
+ _set_attr_str(nd, ALIAS_EPI_KIND, epi_kind)
252
+ _set_attr(nd, ALIAS_dEPI, dEPI_dt)
253
+ _set_attr(nd, ALIAS_D2EPI, (dEPI_dt - dEPI_dt_prev) / dt_step if dt_step != 0 else 0.0)
254
+
255
+ t_local += dt_step
256
+
257
+ G.graph["_t"] = t_local
170
258
 
171
259
 
172
260
  # -------------------------
@@ -184,7 +272,7 @@ def aplicar_dnfr_campo(G, w_theta=None, w_epi=None, w_vf=None) -> None:
184
272
 
185
273
 
186
274
  def integrar_epi_euler(G, dt: float | None = None) -> None:
187
- update_epi_via_nodal_equation(G, dt=dt)
275
+ update_epi_via_nodal_equation(G, dt=dt, method="euler")
188
276
 
189
277
 
190
278
  def aplicar_clamps_canonicos(nd: Dict[str, Any], G=None, node=None) -> None:
@@ -212,6 +300,17 @@ def aplicar_clamps_canonicos(nd: Dict[str, Any], G=None, node=None) -> None:
212
300
  _set_attr(nd, ALIAS_THETA, ((th + math.pi) % (2*math.pi) - math.pi))
213
301
 
214
302
 
303
+ def validate_canon(G) -> None:
304
+ """Aplica clamps canónicos a todos los nodos de ``G``.
305
+
306
+ Envuelve fase y restringe ``EPI`` y ``νf`` a los rangos en ``G.graph``.
307
+ Si ``VALIDATORS_STRICT`` está activo, registra alertas en ``history``.
308
+ """
309
+ for n in G.nodes():
310
+ aplicar_clamps_canonicos(G.nodes[n], G, n)
311
+ return G
312
+
313
+
215
314
  def coordinar_fase_global_vecinal(G, fuerza_global: float | None = None, fuerza_vecinal: float | None = None) -> None:
216
315
  """
217
316
  Ajusta fase con mezcla GLOBAL+VECINAL.
@@ -311,6 +410,40 @@ def coordinar_fase_global_vecinal(G, fuerza_global: float | None = None, fuerza_
311
410
  dL = ((thL - th + math.pi) % (2*math.pi) - math.pi)
312
411
  _set_attr(nd, ALIAS_THETA, th + kG*dG + kL*dL)
313
412
 
413
+ # -------------------------
414
+ # Adaptación de νf por coherencia
415
+ # -------------------------
416
+
417
+ def adaptar_vf_por_coherencia(G) -> None:
418
+ """Ajusta νf hacia la media vecinal en nodos con estabilidad sostenida."""
419
+ tau = int(G.graph.get("VF_ADAPT_TAU", DEFAULTS.get("VF_ADAPT_TAU", 5)))
420
+ mu = float(G.graph.get("VF_ADAPT_MU", DEFAULTS.get("VF_ADAPT_MU", 0.1)))
421
+ eps_dnfr = float(G.graph.get("EPS_DNFR_STABLE", DEFAULTS["EPS_DNFR_STABLE"]))
422
+ thr_sel = G.graph.get("SELECTOR_THRESHOLDS", DEFAULTS.get("SELECTOR_THRESHOLDS", {}))
423
+ thr_def = G.graph.get("GLYPH_THRESHOLDS", DEFAULTS.get("GLYPH_THRESHOLDS", {"hi": 0.66}))
424
+ si_hi = float(thr_sel.get("si_hi", thr_def.get("hi", 0.66)))
425
+ vf_min = float(G.graph.get("VF_MIN", DEFAULTS["VF_MIN"]))
426
+ vf_max = float(G.graph.get("VF_MAX", DEFAULTS["VF_MAX"]))
427
+
428
+ updates = {}
429
+ for n in G.nodes():
430
+ nd = G.nodes[n]
431
+ Si = _get_attr(nd, ALIAS_SI, 0.0)
432
+ dnfr = abs(_get_attr(nd, ALIAS_DNFR, 0.0))
433
+ if Si >= si_hi and dnfr <= eps_dnfr:
434
+ nd["stable_count"] = nd.get("stable_count", 0) + 1
435
+ else:
436
+ nd["stable_count"] = 0
437
+ continue
438
+
439
+ if nd["stable_count"] >= tau:
440
+ vf = _get_attr(nd, ALIAS_VF, 0.0)
441
+ vf_bar = media_vecinal(G, n, ALIAS_VF, default=vf)
442
+ updates[n] = vf + mu * (vf_bar - vf)
443
+
444
+ for n, vf_new in updates.items():
445
+ _set_attr(G.nodes[n], ALIAS_VF, clamp(vf_new, vf_min, vf_max))
446
+
314
447
  # -------------------------
315
448
  # Selector glífico por defecto
316
449
  # -------------------------
@@ -425,6 +558,19 @@ def parametric_glyph_selector(G, n) -> str:
425
558
  prev = list(hist)[-1]
426
559
  if isinstance(prev, str) and prev in ("I’L","O’Z","Z’HIR","T’HOL","NA’V","R’A"):
427
560
  return prev
561
+
562
+ # Penalización por falta de avance en σ/Si si se repite glifo
563
+ prev = None
564
+ hist_prev = nd.get("hist_glifos")
565
+ if hist_prev:
566
+ prev = list(hist_prev)[-1]
567
+ if prev == cand:
568
+ delta_si = _get_attr(nd, ALIAS_dSI, 0.0)
569
+ h = G.graph.get("history", {})
570
+ sig = h.get("sense_sigma_mag", [])
571
+ delta_sigma = sig[-1] - sig[-2] if len(sig) >= 2 else 0.0
572
+ if delta_si <= 0.0 and delta_sigma <= 0.0:
573
+ score -= 0.05
428
574
 
429
575
  # Override suave guiado por score (solo si NO cayó la histéresis arriba)
430
576
  # Regla: score>=0.66 inclina a I’L; score<=0.33 inclina a O’Z/Z’HIR
@@ -461,21 +607,45 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
461
607
  # 2b) Normalizadores para selector paramétrico (por paso)
462
608
  _norms_para_selector(G) # no molesta si luego se usa el selector por defecto
463
609
 
464
- # 3) Selección glífica + aplicación
610
+ # 3) Selección glífica + aplicación (con lags obligatorios A’L/E’N)
465
611
  if apply_glyphs:
466
612
  selector = G.graph.get("glyph_selector", default_glyph_selector)
467
613
  from .operators import aplicar_glifo
468
614
  window = int(G.graph.get("GLYPH_HYSTERESIS_WINDOW", DEFAULTS["GLYPH_HYSTERESIS_WINDOW"]))
469
615
  use_canon = bool(G.graph.get("GRAMMAR_CANON", DEFAULTS.get("GRAMMAR_CANON", {})).get("enabled", False))
616
+
617
+ al_max = int(G.graph.get("AL_MAX_LAG", DEFAULTS["AL_MAX_LAG"]))
618
+ en_max = int(G.graph.get("EN_MAX_LAG", DEFAULTS["EN_MAX_LAG"]))
619
+ h_al = _hist0.setdefault("since_AL", {})
620
+ h_en = _hist0.setdefault("since_EN", {})
621
+
470
622
  for n in G.nodes():
471
- if use_canon:
472
- select_and_apply_with_grammar(G, n, selector, window)
623
+ h_al[n] = int(h_al.get(n, 0)) + 1
624
+ h_en[n] = int(h_en.get(n, 0)) + 1
625
+
626
+ if h_al[n] > al_max:
627
+ g = AL
628
+ elif h_en[n] > en_max:
629
+ g = EN
473
630
  else:
474
631
  g = selector(G, n)
475
- aplicar_glifo(G, n, g, window=window)
632
+ if use_canon:
633
+ g = enforce_canonical_grammar(G, n, g)
634
+
635
+ aplicar_glifo(G, n, g, window=window)
636
+ if use_canon:
637
+ on_applied_glifo(G, n, g)
638
+
639
+ if g == AL:
640
+ h_al[n] = 0
641
+ h_en[n] = min(h_en[n], en_max)
642
+ elif g == EN:
643
+ h_en[n] = 0
476
644
 
477
645
  # 4) Ecuación nodal
478
- update_epi_via_nodal_equation(G, dt=dt)
646
+ _dt = float(G.graph.get("DT", DEFAULTS["DT"])) if dt is None else float(dt)
647
+ method = G.graph.get("INTEGRATOR_METHOD", DEFAULTS.get("INTEGRATOR_METHOD", "euler"))
648
+ update_epi_via_nodal_equation(G, dt=_dt, method=method)
479
649
 
480
650
  # 5) Clamps
481
651
  for n in G.nodes():
@@ -484,10 +654,15 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
484
654
  # 6) Coordinación de fase
485
655
  coordinar_fase_global_vecinal(G, None, None)
486
656
 
657
+ # 6b) Adaptación de νf por coherencia
658
+ adaptar_vf_por_coherencia(G)
659
+
487
660
  # 7) Observadores ligeros
488
661
  _update_history(G)
489
662
  # dynamics.py — dentro de step(), justo antes del punto 8)
490
- tau = int(G.graph.get("REMESH_TAU", DEFAULTS["REMESH_TAU"]))
663
+ tau_g = int(G.graph.get("REMESH_TAU_GLOBAL", G.graph.get("REMESH_TAU", DEFAULTS["REMESH_TAU_GLOBAL"])))
664
+ tau_l = int(G.graph.get("REMESH_TAU_LOCAL", G.graph.get("REMESH_TAU", DEFAULTS["REMESH_TAU_LOCAL"])))
665
+ tau = max(tau_g, tau_l)
491
666
  maxlen = max(2 * tau + 5, 64)
492
667
  epi_hist = G.graph.get("_epi_hist")
493
668
  if not isinstance(epi_hist, deque) or epi_hist.maxlen != maxlen:
@@ -498,6 +673,10 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
498
673
  # 8) RE’MESH condicionado
499
674
  aplicar_remesh_si_estabilizacion_global(G)
500
675
 
676
+ # 8b) Validadores de invariantes
677
+ from .validators import run_validators
678
+ run_validators(G)
679
+
501
680
  # Contexto final (últimas métricas del paso)
502
681
  h = G.graph.get("history", {})
503
682
  ctx = {"step": step_idx}
@@ -528,11 +707,12 @@ def run(G, steps: int, *, dt: float | None = None, use_Si: bool = True, apply_gl
528
707
  # -------------------------
529
708
 
530
709
  def _update_history(G) -> None:
531
- hist = G.graph.setdefault("history", {
532
- "C_steps": [], "stable_frac": [],
533
- "phase_sync": [], "glyph_load_estab": [], "glyph_load_disr": [],
534
- "Si_mean": [], "Si_hi_frac": [], "Si_lo_frac": []
535
- })
710
+ hist = G.graph.setdefault("history", {})
711
+ for k in (
712
+ "C_steps", "stable_frac", "phase_sync", "glyph_load_estab", "glyph_load_disr",
713
+ "Si_mean", "Si_hi_frac", "Si_lo_frac", "delta_Si", "B"
714
+ ):
715
+ hist.setdefault(k, [])
536
716
 
537
717
  # Proxy de coherencia C(t)
538
718
  dnfr_mean = list_mean(abs(_get_attr(G.nodes[n], ALIAS_DNFR, 0.0)) for n in G.nodes())
@@ -552,11 +732,37 @@ def _update_history(G) -> None:
552
732
  eps_depi = float(G.graph.get("EPS_DEPI_STABLE", DEFAULTS["EPS_DEPI_STABLE"]))
553
733
  stables = 0
554
734
  total = max(1, G.number_of_nodes())
735
+ dt = float(G.graph.get("DT", DEFAULTS.get("DT", 1.0))) or 1.0
736
+ delta_si_acc = []
737
+ B_acc = []
555
738
  for n in G.nodes():
556
739
  nd = G.nodes[n]
557
740
  if abs(_get_attr(nd, ALIAS_DNFR, 0.0)) <= eps_dnfr and abs(_get_attr(nd, ALIAS_dEPI, 0.0)) <= eps_depi:
558
741
  stables += 1
742
+
743
+ # δSi por nodo
744
+ Si_curr = _get_attr(nd, ALIAS_SI, 0.0)
745
+ Si_prev = nd.get("_prev_Si", Si_curr)
746
+ dSi = Si_curr - Si_prev
747
+ nd["_prev_Si"] = Si_curr
748
+ _set_attr(nd, ALIAS_dSI, dSi)
749
+ delta_si_acc.append(dSi)
750
+
751
+ # Bifurcación B = ∂²νf/∂t²
752
+ vf_curr = _get_attr(nd, ALIAS_VF, 0.0)
753
+ vf_prev = nd.get("_prev_vf", vf_curr)
754
+ dvf_dt = (vf_curr - vf_prev) / dt
755
+ dvf_prev = nd.get("_prev_dvf", dvf_dt)
756
+ B = (dvf_dt - dvf_prev) / dt
757
+ nd["_prev_vf"] = vf_curr
758
+ nd["_prev_dvf"] = dvf_dt
759
+ _set_attr(nd, ALIAS_dVF, dvf_dt)
760
+ _set_attr(nd, ALIAS_D2VF, B)
761
+ B_acc.append(B)
762
+
559
763
  hist["stable_frac"].append(stables/total)
764
+ hist["delta_Si"].append(list_mean(delta_si_acc, 0.0))
765
+ hist["B"].append(list_mean(B_acc, 0.0))
560
766
  # --- nuevas series: sincronía de fase y carga glífica ---
561
767
  try:
562
768
  ps = sincronía_fase(G) # [0,1], más alto = más en fase
tnfr/gamma.py CHANGED
@@ -23,14 +23,7 @@ import math
23
23
  import cmath
24
24
 
25
25
  from .constants import ALIAS_THETA
26
-
27
-
28
- def _get_attr(nd: Dict[str, Any], aliases, default: float = 0.0) -> float:
29
- """Obtiene el primer atributo presente en nd según aliases."""
30
- for k in aliases:
31
- if k in nd:
32
- return nd[k]
33
- return default
26
+ from .helpers import _get_attr
34
27
 
35
28
 
36
29
  def kuramoto_R_psi(G) -> Tuple[float, float]:
@@ -83,10 +76,44 @@ def gamma_kuramoto_bandpass(G, node, t, cfg: Dict[str, Any]) -> float:
83
76
  return beta * R * (1.0 - R) * sgn
84
77
 
85
78
 
79
+ def gamma_kuramoto_tanh(G, node, t, cfg: Dict[str, Any]) -> float:
80
+ """Acoplamiento saturante tipo tanh para Γi(R).
81
+
82
+ Fórmula: Γ = β · tanh(k·(R - R0)) · cos(θ_i - ψ)
83
+ - β: ganancia del acoplamiento
84
+ - k: pendiente de la tanh (cuán rápido satura)
85
+ - R0: umbral de activación
86
+ """
87
+ beta = float(cfg.get("beta", 0.0))
88
+ k = float(cfg.get("k", 1.0))
89
+ R0 = float(cfg.get("R0", 0.0))
90
+ R, psi = kuramoto_R_psi(G)
91
+ th_i = _get_attr(G.nodes[node], ALIAS_THETA, 0.0)
92
+ return beta * math.tanh(k * (R - R0)) * math.cos(th_i - psi)
93
+
94
+
95
+ def gamma_harmonic(G, node, t, cfg: Dict[str, Any]) -> float:
96
+ """Forzamiento armónico coherente con el campo global de fase.
97
+
98
+ Fórmula: Γ = β · sin(ω·t + φ) · cos(θ_i - ψ)
99
+ - β: ganancia del acoplamiento
100
+ - ω: frecuencia angular del forzante
101
+ - φ: fase inicial del forzante
102
+ """
103
+ beta = float(cfg.get("beta", 0.0))
104
+ omega = float(cfg.get("omega", 1.0))
105
+ phi = float(cfg.get("phi", 0.0))
106
+ R, psi = kuramoto_R_psi(G)
107
+ th = _get_attr(G.nodes[node], ALIAS_THETA, 0.0)
108
+ return beta * math.sin(omega * t + phi) * math.cos(th - psi)
109
+
110
+
86
111
  GAMMA_REGISTRY = {
87
112
  "none": gamma_none,
88
113
  "kuramoto_linear": gamma_kuramoto_linear,
89
114
  "kuramoto_bandpass": gamma_kuramoto_bandpass,
115
+ "kuramoto_tanh": gamma_kuramoto_tanh,
116
+ "harmonic": gamma_harmonic,
90
117
  }
91
118
 
92
119
 
tnfr/helpers.py CHANGED
@@ -14,7 +14,7 @@ try:
14
14
  except Exception: # pragma: no cover
15
15
  nx = None # type: ignore
16
16
 
17
- from .constants import DEFAULTS, ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI
17
+ from .constants import DEFAULTS, ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI, ALIAS_EPI_KIND
18
18
 
19
19
  # -------------------------
20
20
  # Utilidades numéricas
@@ -76,6 +76,24 @@ def _set_attr(d, aliases, value: float) -> None:
76
76
  return
77
77
  d[next(iter(aliases))] = float(value)
78
78
 
79
+
80
+ def _get_attr_str(d: Dict[str, Any], aliases: Iterable[str], default: str = "") -> str:
81
+ for k in aliases:
82
+ if k in d:
83
+ try:
84
+ return str(d[k])
85
+ except Exception:
86
+ continue
87
+ return str(default)
88
+
89
+
90
+ def _set_attr_str(d, aliases, value: str) -> None:
91
+ for k in aliases:
92
+ if k in d:
93
+ d[k] = str(value)
94
+ return
95
+ d[next(iter(aliases))] = str(value)
96
+
79
97
  # -------------------------
80
98
  # Estadísticos vecinales
81
99
  # -------------------------
@@ -111,18 +129,36 @@ def push_glifo(nd: Dict[str, Any], glifo: str, window: int) -> None:
111
129
 
112
130
 
113
131
  def reciente_glifo(nd: Dict[str, Any], glifo: str, ventana: int) -> bool:
114
- """Indica si ``glifo`` apareció en las últimas ``ventana`` emisiones."""
132
+ """Indica si ``glifo`` apareció en las últimas ``ventana`` emisiones"""
115
133
  hist = nd.get("hist_glifos")
116
- if not hist:
117
- return False
118
134
  gl = str(glifo)
119
- for g in reversed(hist):
120
- if g == gl:
121
- return True
122
- ventana -= 1
123
- if ventana <= 0:
124
- break
125
- return False
135
+ from itertools import islice
136
+ if hist and any(g == gl for g in islice(reversed(hist), ventana)):
137
+ return True
138
+ # fallback al glifo dominante actual
139
+ return _get_attr_str(nd, ALIAS_EPI_KIND, "") == gl
140
+
141
+ # -------------------------
142
+ # Utilidades de historial global
143
+ # -------------------------
144
+
145
+ def ensure_history(G) -> Dict[str, Any]:
146
+ """Garantiza G.graph['history'] y la devuelve."""
147
+ return G.graph.setdefault("history", {})
148
+
149
+
150
+ def last_glifo(nd: Dict[str, Any]) -> str | None:
151
+ """Retorna el glifo más reciente del nodo o ``None``."""
152
+ kind = _get_attr_str(nd, ALIAS_EPI_KIND, "")
153
+ if kind:
154
+ return kind
155
+ hist = nd.get("hist_glifos")
156
+ if not hist:
157
+ return None
158
+ try:
159
+ return hist[-1]
160
+ except Exception:
161
+ return None
126
162
 
127
163
  # -------------------------
128
164
  # Callbacks Γ(R)
@@ -202,12 +238,9 @@ def compute_Si(G, *, inplace: bool = True) -> Dict[Any, float]:
202
238
  G.graph["_Si_weights"] = {"alpha": alpha, "beta": beta, "gamma": gamma}
203
239
  G.graph["_Si_sensitivity"] = {"dSi_dvf_norm": alpha, "dSi_ddisp_fase": -beta, "dSi_ddnfr_norm": -gamma}
204
240
 
205
- # Normalización de νf en red
206
- vfs = [abs(_get_attr(G.nodes[n], ALIAS_VF, 0.0)) for n in G.nodes()]
207
- vfmax = max(vfs) if vfs else 1.0
208
- # Normalización de ΔNFR
209
- dnfrs = [abs(_get_attr(G.nodes[n], ALIAS_DNFR, 0.0)) for n in G.nodes()]
210
- dnfrmax = max(dnfrs) if dnfrs else 1.0
241
+ # Normalización de νf y ΔNFR en red
242
+ vfmax = max((abs(_get_attr(G.nodes[n], ALIAS_VF, 0.0)) for n in G.nodes()), default=1.0)
243
+ dnfrmax = max((abs(_get_attr(G.nodes[n], ALIAS_DNFR, 0.0)) for n in G.nodes()), default=1.0)
211
244
 
212
245
  out: Dict[Any, float] = {}
213
246
  for n in G.nodes():