tnfr 4.3.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,23 +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
21
+ from .grammar import (
22
+ enforce_canonical_grammar,
23
+ on_applied_glifo,
24
+ AL,
25
+ EN,
26
+ )
22
27
  from .constants import (
23
28
  DEFAULTS,
24
29
  ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI,
25
30
  ALIAS_dEPI, ALIAS_D2EPI, ALIAS_dVF, ALIAS_D2VF, ALIAS_dSI,
31
+ ALIAS_EPI_KIND,
26
32
  )
27
33
  from .gamma import eval_gamma
28
34
  from .helpers import (
29
35
  clamp, clamp01, list_mean, phase_distance,
30
- _get_attr, _set_attr, media_vecinal, fase_media,
36
+ _get_attr, _set_attr, _get_attr_str, _set_attr_str, media_vecinal, fase_media,
31
37
  invoke_callbacks, reciente_glifo
32
38
  )
33
39
 
@@ -36,20 +42,21 @@ from .helpers import (
36
42
  # -------------------------
37
43
 
38
44
  def _write_dnfr_metadata(G, *, weights: dict, hook_name: str, note: str | None = None) -> None:
39
- """Escribe en G.graph un bloque _DNFR_META con la mezcla y el nombre del hook."""
40
- w_phase = float(weights.get("phase", 0.0))
41
- w_epi = float(weights.get("epi", 0.0))
42
- w_vf = float(weights.get("vf", 0.0))
43
- s = w_phase + w_epi + w_vf
44
- if s <= 0:
45
- w_phase = w_epi = w_vf = 1/3
46
- 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
47
54
  meta = {
48
55
  "hook": hook_name,
49
56
  "weights_raw": dict(weights),
50
- "weights_norm": {"phase": w_phase/s, "epi": w_epi/s, "vf": w_vf/s},
51
- "components": [k for k, v in {"phase":w_phase, "epi":w_epi, "vf":w_vf}.items() if v != 0],
52
- "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",
53
60
  }
54
61
  if note:
55
62
  meta["note"] = str(note)
@@ -58,26 +65,35 @@ def _write_dnfr_metadata(G, *, weights: dict, hook_name: str, note: str | None =
58
65
 
59
66
 
60
67
  def default_compute_delta_nfr(G) -> None:
61
- """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."""
62
69
  w = G.graph.get("DNFR_WEIGHTS", DEFAULTS["DNFR_WEIGHTS"]) # dict
63
70
  w_phase = float(w.get("phase", 0.34))
64
71
  w_epi = float(w.get("epi", 0.33))
65
72
  w_vf = float(w.get("vf", 0.33))
66
- 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
67
75
  if s <= 0:
68
76
  w_phase = w_epi = w_vf = 1/3
77
+ w_topo = 0.0
78
+ s = 1.0
69
79
  else:
70
- 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)
71
81
 
72
82
  # Documentar mezcla y hook activo
73
- _write_dnfr_metadata(G, weights={"phase":w_phase, "epi":w_epi, "vf":w_vf}, hook_name="default_compute_delta_nfr")
74
-
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
+
75
91
  for n in G.nodes():
76
92
  nd = G.nodes[n]
77
93
  th_i = _get_attr(nd, ALIAS_THETA, 0.0)
78
94
  th_bar = fase_media(G, n)
79
95
  # Gradiente de fase: empuja hacia la fase media (signo envuelto)
80
- 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]
81
97
 
82
98
  epi_i = _get_attr(nd, ALIAS_EPI, 0.0)
83
99
  epi_bar = media_vecinal(G, n, ALIAS_EPI, default=epi_i)
@@ -87,7 +103,14 @@ def default_compute_delta_nfr(G) -> None:
87
103
  vf_bar = media_vecinal(G, n, ALIAS_VF, default=vf_i)
88
104
  g_vf = (vf_bar - vf_i)
89
105
 
90
- 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
91
114
  _set_attr(nd, ALIAS_DNFR, dnfr)
92
115
 
93
116
  def set_delta_nfr_hook(G, func, *, name: str | None = None, note: str | None = None) -> None:
@@ -107,9 +130,9 @@ def dnfr_phase_only(G) -> None:
107
130
  nd = G.nodes[n]
108
131
  th_i = _get_attr(nd, ALIAS_THETA, 0.0)
109
132
  th_bar = fase_media(G, n)
110
- 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
111
134
  _set_attr(nd, ALIAS_DNFR, g_phase)
112
- _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.")
113
136
 
114
137
  def dnfr_epi_vf_mixed(G) -> None:
115
138
  """Ejemplo: ΔNFR sin fase, mezclando EPI y νf."""
@@ -124,11 +147,40 @@ def dnfr_epi_vf_mixed(G) -> None:
124
147
  _set_attr(nd, ALIAS_DNFR, 0.5*g_epi + 0.5*g_vf)
125
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.")
126
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
+
127
173
  # -------------------------
128
174
  # Ecuación nodal
129
175
  # -------------------------
130
176
 
131
- 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:
132
184
  """Ecuación nodal TNFR.
133
185
 
134
186
  Implementa la forma extendida de la ecuación nodal:
@@ -159,18 +211,50 @@ def update_epi_via_nodal_equation(G, *, dt: float = None, t: float | None = None
159
211
  t = float(G.graph.get("_t", 0.0))
160
212
  else:
161
213
  t = float(t)
162
- for n in G.nodes():
163
- nd = G.nodes[n]
164
- vf = _get_attr(nd, ALIAS_VF, 0.0)
165
- dnfr = _get_attr(nd, ALIAS_DNFR, 0.0)
166
- dEPI_dt_prev = _get_attr(nd, ALIAS_dEPI, 0.0)
167
- dEPI_dt = vf * dnfr
168
- dEPI_dt += eval_gamma(G, n, t)
169
- epi = _get_attr(nd, ALIAS_EPI, 0.0) + dt * dEPI_dt
170
- _set_attr(nd, ALIAS_EPI, epi)
171
- _set_attr(nd, ALIAS_dEPI, dEPI_dt)
172
- _set_attr(nd, ALIAS_D2EPI, (dEPI_dt - dEPI_dt_prev) / dt if dt != 0 else 0.0)
173
- 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
174
258
 
175
259
 
176
260
  # -------------------------
@@ -188,7 +272,7 @@ def aplicar_dnfr_campo(G, w_theta=None, w_epi=None, w_vf=None) -> None:
188
272
 
189
273
 
190
274
  def integrar_epi_euler(G, dt: float | None = None) -> None:
191
- update_epi_via_nodal_equation(G, dt=dt)
275
+ update_epi_via_nodal_equation(G, dt=dt, method="euler")
192
276
 
193
277
 
194
278
  def aplicar_clamps_canonicos(nd: Dict[str, Any], G=None, node=None) -> None:
@@ -326,6 +410,40 @@ def coordinar_fase_global_vecinal(G, fuerza_global: float | None = None, fuerza_
326
410
  dL = ((thL - th + math.pi) % (2*math.pi) - math.pi)
327
411
  _set_attr(nd, ALIAS_THETA, th + kG*dG + kL*dL)
328
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
+
329
447
  # -------------------------
330
448
  # Selector glífico por defecto
331
449
  # -------------------------
@@ -489,21 +607,45 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
489
607
  # 2b) Normalizadores para selector paramétrico (por paso)
490
608
  _norms_para_selector(G) # no molesta si luego se usa el selector por defecto
491
609
 
492
- # 3) Selección glífica + aplicación
610
+ # 3) Selección glífica + aplicación (con lags obligatorios A’L/E’N)
493
611
  if apply_glyphs:
494
612
  selector = G.graph.get("glyph_selector", default_glyph_selector)
495
613
  from .operators import aplicar_glifo
496
614
  window = int(G.graph.get("GLYPH_HYSTERESIS_WINDOW", DEFAULTS["GLYPH_HYSTERESIS_WINDOW"]))
497
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
+
498
622
  for n in G.nodes():
499
- if use_canon:
500
- 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
501
630
  else:
502
631
  g = selector(G, n)
503
- 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
504
644
 
505
645
  # 4) Ecuación nodal
506
- 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)
507
649
 
508
650
  # 5) Clamps
509
651
  for n in G.nodes():
@@ -512,6 +654,9 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
512
654
  # 6) Coordinación de fase
513
655
  coordinar_fase_global_vecinal(G, None, None)
514
656
 
657
+ # 6b) Adaptación de νf por coherencia
658
+ adaptar_vf_por_coherencia(G)
659
+
515
660
  # 7) Observadores ligeros
516
661
  _update_history(G)
517
662
  # dynamics.py — dentro de step(), justo antes del punto 8)
@@ -528,6 +673,10 @@ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool
528
673
  # 8) RE’MESH condicionado
529
674
  aplicar_remesh_si_estabilizacion_global(G)
530
675
 
676
+ # 8b) Validadores de invariantes
677
+ from .validators import run_validators
678
+ run_validators(G)
679
+
531
680
  # Contexto final (últimas métricas del paso)
532
681
  h = G.graph.get("history", {})
533
682
  ctx = {"step": step_idx}
tnfr/gamma.py CHANGED
@@ -92,11 +92,28 @@ def gamma_kuramoto_tanh(G, node, t, cfg: Dict[str, Any]) -> float:
92
92
  return beta * math.tanh(k * (R - R0)) * math.cos(th_i - psi)
93
93
 
94
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
+
95
111
  GAMMA_REGISTRY = {
96
112
  "none": gamma_none,
97
113
  "kuramoto_linear": gamma_kuramoto_linear,
98
114
  "kuramoto_bandpass": gamma_kuramoto_bandpass,
99
115
  "kuramoto_tanh": gamma_kuramoto_tanh,
116
+ "harmonic": gamma_harmonic,
100
117
  }
101
118
 
102
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,14 @@ 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
126
140
 
127
141
  # -------------------------
128
142
  # Utilidades de historial global
@@ -130,18 +144,19 @@ def reciente_glifo(nd: Dict[str, Any], glifo: str, ventana: int) -> bool:
130
144
 
131
145
  def ensure_history(G) -> Dict[str, Any]:
132
146
  """Garantiza G.graph['history'] y la devuelve."""
133
- if "history" not in G.graph:
134
- G.graph["history"] = {}
135
- return G.graph["history"]
147
+ return G.graph.setdefault("history", {})
136
148
 
137
149
 
138
150
  def last_glifo(nd: Dict[str, Any]) -> str | None:
139
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
140
155
  hist = nd.get("hist_glifos")
141
156
  if not hist:
142
157
  return None
143
158
  try:
144
- return list(hist)[-1]
159
+ return hist[-1]
145
160
  except Exception:
146
161
  return None
147
162
 
@@ -223,12 +238,9 @@ def compute_Si(G, *, inplace: bool = True) -> Dict[Any, float]:
223
238
  G.graph["_Si_weights"] = {"alpha": alpha, "beta": beta, "gamma": gamma}
224
239
  G.graph["_Si_sensitivity"] = {"dSi_dvf_norm": alpha, "dSi_ddisp_fase": -beta, "dSi_ddnfr_norm": -gamma}
225
240
 
226
- # Normalización de νf en red
227
- vfs = [abs(_get_attr(G.nodes[n], ALIAS_VF, 0.0)) for n in G.nodes()]
228
- vfmax = max(vfs) if vfs else 1.0
229
- # Normalización de ΔNFR
230
- dnfrs = [abs(_get_attr(G.nodes[n], ALIAS_DNFR, 0.0)) for n in G.nodes()]
231
- 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)
232
244
 
233
245
  out: Dict[Any, float] = {}
234
246
  for n in G.nodes():