tnfr 1.0__py3-none-any.whl → 2.0.1__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 ADDED
@@ -0,0 +1,543 @@
1
+ """
2
+ dynamics.py — TNFR canónica
3
+
4
+ Bucle de dinámica con la ecuación nodal y utilidades:
5
+ ∂EPI/∂t = νf · ΔNFR(t)
6
+ Incluye:
7
+ - default_compute_delta_nfr (mezcla de fase/EPI/νf)
8
+ - update_epi_via_nodal_equation (Euler explícito)
9
+ - aplicar_dnfr_campo, integrar_epi_euler, aplicar_clamps_canonicos
10
+ - coordinar_fase_global_vecinal
11
+ - default_glyph_selector, step, run
12
+ """
13
+ from __future__ import annotations
14
+ from typing import Dict, Any, Iterable
15
+ import math
16
+
17
+ from observers import sincronía_fase, carga_glifica, orden_kuramoto, sigma_vector
18
+ from operators import aplicar_remesh_si_estabilizacion_global
19
+ from constants import DEFAULTS, ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, ALIAS_SI, ALIAS_dEPI, ALIAS_D2EPI
20
+ from helpers import (
21
+ clamp, clamp01, list_mean, phase_distance,
22
+ _get_attr, _set_attr, media_vecinal, fase_media,
23
+ invoke_callbacks, reciente_glifo
24
+ )
25
+
26
+ # -------------------------
27
+ # ΔNFR por defecto (campo) + utilidades de hook/metadata
28
+ # -------------------------
29
+
30
+ def _write_dnfr_metadata(G, *, weights: dict, hook_name: str, note: str | None = None) -> None:
31
+ """Escribe en G.graph un bloque _DNFR_META con la mezcla y el nombre del hook."""
32
+ w_phase = float(weights.get("phase", 0.0))
33
+ w_epi = float(weights.get("epi", 0.0))
34
+ w_vf = float(weights.get("vf", 0.0))
35
+ s = w_phase + w_epi + w_vf
36
+ if s <= 0:
37
+ w_phase = w_epi = w_vf = 1/3
38
+ s = 1.0
39
+ meta = {
40
+ "hook": hook_name,
41
+ "weights_raw": dict(weights),
42
+ "weights_norm": {"phase": w_phase/s, "epi": w_epi/s, "vf": w_vf/s},
43
+ "components": [k for k, v in {"phase":w_phase, "epi":w_epi, "vf":w_vf}.items() if v != 0],
44
+ "doc": "ΔNFR = w_phase·g_phase + w_epi·g_epi + w_vf·g_vf",
45
+ }
46
+ if note:
47
+ meta["note"] = str(note)
48
+ G.graph["_DNFR_META"] = meta
49
+ G.graph["_dnfr_hook_name"] = hook_name # string friendly
50
+
51
+
52
+ def default_compute_delta_nfr(G) -> None:
53
+ w = G.graph.get("DNFR_WEIGHTS", DEFAULTS["DNFR_WEIGHTS"]) # dict
54
+ w_phase = float(w.get("phase", 0.34))
55
+ w_epi = float(w.get("epi", 0.33))
56
+ w_vf = float(w.get("vf", 0.33))
57
+ s = w_phase + w_epi + w_vf
58
+ if s <= 0:
59
+ w_phase = w_epi = w_vf = 1/3
60
+ else:
61
+ w_phase, w_epi, w_vf = w_phase/s, w_epi/s, w_vf/s
62
+
63
+ # Documentar mezcla y hook activo
64
+ _write_dnfr_metadata(G, weights={"phase":w_phase, "epi":w_epi, "vf":w_vf}, hook_name="default_compute_delta_nfr")
65
+
66
+ for n in G.nodes():
67
+ nd = G.nodes[n]
68
+ th_i = _get_attr(nd, ALIAS_THETA, 0.0)
69
+ th_bar = fase_media(G, n)
70
+ # Gradiente de fase: empuja hacia la fase media (signo envuelto)
71
+ g_phase = - ( (th_i - th_bar + math.pi) % (2*math.pi) - math.pi ) / math.pi # ~[-1,1]
72
+
73
+ epi_i = _get_attr(nd, ALIAS_EPI, 0.0)
74
+ epi_bar = media_vecinal(G, n, ALIAS_EPI, default=epi_i)
75
+ g_epi = (epi_bar - epi_i) # gradiente escalar
76
+
77
+ vf_i = _get_attr(nd, ALIAS_VF, 0.0)
78
+ vf_bar = media_vecinal(G, n, ALIAS_VF, default=vf_i)
79
+ g_vf = (vf_bar - vf_i)
80
+
81
+ dnfr = w_phase*g_phase + w_epi*g_epi + w_vf*g_vf
82
+ _set_attr(nd, ALIAS_DNFR, dnfr)
83
+
84
+ def set_delta_nfr_hook(G, func, *, name: str | None = None, note: str | None = None) -> None:
85
+ """Fija un hook estable para calcular ΔNFR. Firma requerida: func(G)->None y debe
86
+ escribir ALIAS_DNFR en cada nodo. Actualiza metadatos básicos en G.graph."""
87
+ G.graph["compute_delta_nfr"] = func
88
+ G.graph["_dnfr_hook_name"] = str(name or getattr(func, "__name__", "custom_dnfr"))
89
+ if note:
90
+ meta = G.graph.get("_DNFR_META", {})
91
+ meta["note"] = str(note)
92
+ G.graph["_DNFR_META"] = meta
93
+
94
+ # --- Hooks de ejemplo (opcionales) ---
95
+ def dnfr_phase_only(G) -> None:
96
+ """Ejemplo: ΔNFR solo desde fase (tipo Kuramoto-like)."""
97
+ for n in G.nodes():
98
+ nd = G.nodes[n]
99
+ th_i = _get_attr(nd, ALIAS_THETA, 0.0)
100
+ th_bar = fase_media(G, n)
101
+ g_phase = - ( (th_i - th_bar + math.pi) % (2*math.pi) - math.pi ) / math.pi
102
+ _set_attr(nd, ALIAS_DNFR, g_phase)
103
+ _write_dnfr_metadata(G, weights={"phase":1.0, "epi":0.0, "vf":0.0}, hook_name="dnfr_phase_only", note="Hook de ejemplo.")
104
+
105
+ def dnfr_epi_vf_mixed(G) -> None:
106
+ """Ejemplo: ΔNFR sin fase, mezclando EPI y νf."""
107
+ for n in G.nodes():
108
+ nd = G.nodes[n]
109
+ epi_i = _get_attr(nd, ALIAS_EPI, 0.0)
110
+ epi_bar = media_vecinal(G, n, ALIAS_EPI, default=epi_i)
111
+ g_epi = (epi_bar - epi_i)
112
+ vf_i = _get_attr(nd, ALIAS_VF, 0.0)
113
+ vf_bar = media_vecinal(G, n, ALIAS_VF, default=vf_i)
114
+ g_vf = (vf_bar - vf_i)
115
+ _set_attr(nd, ALIAS_DNFR, 0.5*g_epi + 0.5*g_vf)
116
+ _write_dnfr_metadata(G, weights={"phase":0.0, "epi":0.5, "vf":0.5}, hook_name="dnfr_epi_vf_mixed", note="Hook de ejemplo.")
117
+
118
+ # -------------------------
119
+ # Ecuación nodal
120
+ # -------------------------
121
+
122
+ def update_epi_via_nodal_equation(G, *, dt: float = None) -> None:
123
+ if dt is None:
124
+ dt = float(G.graph.get("DT", DEFAULTS["DT"]))
125
+ for n in G.nodes():
126
+ nd = G.nodes[n]
127
+ vf = _get_attr(nd, ALIAS_VF, 0.0)
128
+ dnfr = _get_attr(nd, ALIAS_DNFR, 0.0)
129
+ dEPI_dt_prev = _get_attr(nd, ALIAS_dEPI, 0.0)
130
+ dEPI_dt = vf * dnfr
131
+ epi = _get_attr(nd, ALIAS_EPI, 0.0) + dt * dEPI_dt
132
+ _set_attr(nd, ALIAS_EPI, epi)
133
+ _set_attr(nd, ALIAS_dEPI, dEPI_dt)
134
+ _set_attr(nd, ALIAS_D2EPI, (dEPI_dt - dEPI_dt_prev) / dt if dt != 0 else 0.0)
135
+
136
+
137
+ # -------------------------
138
+ # Wrappers nombrados (compatibilidad)
139
+ # -------------------------
140
+
141
+ def aplicar_dnfr_campo(G, w_theta=None, w_epi=None, w_vf=None) -> None:
142
+ if any(v is not None for v in (w_theta, w_epi, w_vf)):
143
+ mix = G.graph.get("DNFR_WEIGHTS", DEFAULTS["DNFR_WEIGHTS"]).copy()
144
+ if w_theta is not None: mix["phase"] = float(w_theta)
145
+ if w_epi is not None: mix["epi"] = float(w_epi)
146
+ if w_vf is not None: mix["vf"] = float(w_vf)
147
+ G.graph["DNFR_WEIGHTS"] = mix
148
+ default_compute_delta_nfr(G)
149
+
150
+
151
+ def integrar_epi_euler(G, dt: float | None = None) -> None:
152
+ update_epi_via_nodal_equation(G, dt=dt)
153
+
154
+
155
+ def aplicar_clamps_canonicos(nd: Dict[str, Any], G=None) -> None:
156
+ eps_min = float((G.graph.get("EPI_MIN") if G is not None else DEFAULTS["EPI_MIN"]))
157
+ eps_max = float((G.graph.get("EPI_MAX") if G is not None else DEFAULTS["EPI_MAX"]))
158
+ vf_min = float((G.graph.get("VF_MIN") if G is not None else DEFAULTS["VF_MIN"]))
159
+ vf_max = float((G.graph.get("VF_MAX") if G is not None else DEFAULTS["VF_MAX"]))
160
+
161
+ epi = _get_attr(nd, ALIAS_EPI, 0.0)
162
+ vf = _get_attr(nd, ALIAS_VF, 0.0)
163
+ th = _get_attr(nd, ALIAS_THETA, 0.0)
164
+
165
+ _set_attr(nd, ALIAS_EPI, clamp(epi, eps_min, eps_max))
166
+ _set_attr(nd, ALIAS_VF, clamp(vf, vf_min, vf_max))
167
+ if (G.graph.get("THETA_WRAP") if G is not None else DEFAULTS["THETA_WRAP"]):
168
+ # envolver fase
169
+ _set_attr(nd, ALIAS_THETA, ((th + math.pi) % (2*math.pi) - math.pi))
170
+
171
+
172
+ def coordinar_fase_global_vecinal(G, fuerza_global: float | None = None, fuerza_vecinal: float | None = None) -> None:
173
+ """
174
+ Ajusta fase con mezcla GLOBAL+VECINAL.
175
+ Si no se pasan fuerzas explícitas, adapta kG/kL según estado (disonante / transición / estable).
176
+ Estado se decide por R (Kuramoto) y carga glífica disruptiva reciente.
177
+ """
178
+ # 0) Si hay fuerzas explícitas, usar y salir del modo adaptativo
179
+ if (fuerza_global is not None) or (fuerza_vecinal is not None):
180
+ kG = float(fuerza_global if fuerza_global is not None else G.graph.get("PHASE_K_GLOBAL", DEFAULTS["PHASE_K_GLOBAL"]))
181
+ kL = float(fuerza_vecinal if fuerza_vecinal is not None else G.graph.get("PHASE_K_LOCAL", DEFAULTS["PHASE_K_LOCAL"]))
182
+ else:
183
+ # 1) Lectura de configuración
184
+ cfg = G.graph.get("PHASE_ADAPT", DEFAULTS.get("PHASE_ADAPT", {}))
185
+ kG = float(G.graph.get("PHASE_K_GLOBAL", DEFAULTS["PHASE_K_GLOBAL"]))
186
+ kL = float(G.graph.get("PHASE_K_LOCAL", DEFAULTS["PHASE_K_LOCAL"]))
187
+
188
+ if bool(cfg.get("enabled", False)):
189
+ # 2) Métricas actuales (no dependemos de history)
190
+ R = orden_kuramoto(G)
191
+ win = int(G.graph.get("GLYPH_LOAD_WINDOW", DEFAULTS["GLYPH_LOAD_WINDOW"]))
192
+ dist = carga_glifica(G, window=win)
193
+ disr = float(dist.get("_disruptivos", 0.0)) if dist else 0.0
194
+
195
+ # 3) Decidir estado
196
+ R_hi = float(cfg.get("R_hi", 0.90)); R_lo = float(cfg.get("R_lo", 0.60))
197
+ disr_hi = float(cfg.get("disr_hi", 0.50)); disr_lo = float(cfg.get("disr_lo", 0.25))
198
+ if (R >= R_hi) and (disr <= disr_lo):
199
+ state = "estable"
200
+ elif (R <= R_lo) or (disr >= disr_hi):
201
+ state = "disonante"
202
+ else:
203
+ state = "transicion"
204
+
205
+ # 4) Objetivos y actualización suave (con saturación)
206
+ kG_min = float(cfg.get("kG_min", 0.01)); kG_max = float(cfg.get("kG_max", 0.20))
207
+ kL_min = float(cfg.get("kL_min", 0.05)); kL_max = float(cfg.get("kL_max", 0.25))
208
+
209
+ if state == "disonante":
210
+ kG_t = kG_max
211
+ kL_t = 0.5 * (kL_min + kL_max) # local medio para no perder plasticidad
212
+ elif state == "estable":
213
+ kG_t = kG_min
214
+ kL_t = kL_min
215
+ else:
216
+ kG_t = 0.5 * (kG_min + kG_max)
217
+ kL_t = 0.5 * (kL_min + kL_max)
218
+
219
+ up = float(cfg.get("up", 0.10)); down = float(cfg.get("down", 0.07))
220
+ def _step(curr, target, mn, mx):
221
+ gain = up if target > curr else down
222
+ nxt = curr + gain * (target - curr)
223
+ return max(mn, min(mx, nxt))
224
+
225
+ kG = _step(kG, kG_t, kG_min, kG_max)
226
+ kL = _step(kL, kL_t, kL_min, kL_max)
227
+
228
+ # 5) Persistir en G.graph y log de serie
229
+ G.graph["PHASE_K_GLOBAL"] = kG
230
+ G.graph["PHASE_K_LOCAL"] = kL
231
+ hist = G.graph.setdefault("history", {})
232
+ hist.setdefault("phase_kG", []).append(float(kG))
233
+ hist.setdefault("phase_kL", []).append(float(kL))
234
+ hist.setdefault("phase_state", []).append(state)
235
+ hist.setdefault("phase_R", []).append(float(R))
236
+ hist.setdefault("phase_disr", []).append(float(disr))
237
+
238
+ # 6) Fase GLOBAL (centroide) para empuje
239
+ X = list(math.cos(_get_attr(G.nodes[n], ALIAS_THETA, 0.0)) for n in G.nodes())
240
+ Y = list(math.sin(_get_attr(G.nodes[n], ALIAS_THETA, 0.0)) for n in G.nodes())
241
+ if X:
242
+ thG = math.atan2(sum(Y)/len(Y), sum(X)/len(X))
243
+ else:
244
+ thG = 0.0
245
+
246
+ # 7) Aplicar corrección global+vecinal
247
+ for n in G.nodes():
248
+ nd = G.nodes[n]
249
+ th = _get_attr(nd, ALIAS_THETA, 0.0)
250
+ thL = fase_media(G, n)
251
+ dG = ((thG - th + math.pi) % (2*math.pi) - math.pi)
252
+ dL = ((thL - th + math.pi) % (2*math.pi) - math.pi)
253
+ _set_attr(nd, ALIAS_THETA, th + kG*dG + kL*dL)
254
+
255
+ # -------------------------
256
+ # Selector glífico por defecto
257
+ # -------------------------
258
+
259
+ def default_glyph_selector(G, n) -> str:
260
+ nd = G.nodes[n]
261
+ # Umbrales desde configuración (fallback a DEFAULTS)
262
+ thr = G.graph.get("GLYPH_THRESHOLDS", DEFAULTS.get("GLYPH_THRESHOLDS", {"hi": 0.66, "lo": 0.33, "dnfr": 1e-3}))
263
+ hi = float(thr.get("hi", 0.66))
264
+ lo = float(thr.get("lo", 0.33))
265
+ tdnfr = float(thr.get("dnfr", 1e-3))
266
+
267
+
268
+ Si = _get_attr(nd, ALIAS_SI, 0.5)
269
+ dnfr = _get_attr(nd, ALIAS_DNFR, 0.0)
270
+
271
+
272
+ if Si >= hi:
273
+ return "I’L" # estabiliza
274
+ if Si <= lo:
275
+ return "O’Z" if abs(dnfr) > tdnfr else "Z’HIR"
276
+ return "NA’V" if abs(dnfr) > tdnfr else "R’A"
277
+
278
+
279
+ # -------------------------
280
+ # Selector glífico multiobjetivo (paramétrico)
281
+ # -------------------------
282
+ def _norms_para_selector(G) -> dict:
283
+ """Calcula y guarda en G.graph los máximos para normalizar |ΔNFR| y |d2EPI/dt2|."""
284
+ dnfr_max = 0.0
285
+ accel_max = 0.0
286
+ for n in G.nodes():
287
+ nd = G.nodes[n]
288
+ dnfr_max = max(dnfr_max, abs(_get_attr(nd, ALIAS_DNFR, 0.0)))
289
+ accel_max = max(accel_max, abs(_get_attr(nd, ALIAS_D2EPI, 0.0)))
290
+ if dnfr_max <= 0: dnfr_max = 1.0
291
+ if accel_max <= 0: accel_max = 1.0
292
+ norms = {"dnfr_max": float(dnfr_max), "accel_max": float(accel_max)}
293
+ G.graph["_sel_norms"] = norms
294
+ return norms
295
+
296
+ def parametric_glyph_selector(G, n) -> str:
297
+ """Multiobjetivo: combina Si, |ΔNFR|_norm y |accel|_norm + histéresis.
298
+ Reglas base:
299
+ - Si alto ⇒ I’L
300
+ - Si bajo ⇒ O’Z si |ΔNFR| alto; Z’HIR si |ΔNFR| bajo; T’HOL si hay mucha aceleración
301
+ - Si medio ⇒ NA’V si |ΔNFR| alto (o accel alta), si no R’A
302
+ """
303
+ nd = G.nodes[n]
304
+ thr = G.graph.get("SELECTOR_THRESHOLDS", DEFAULTS["SELECTOR_THRESHOLDS"])
305
+ si_hi, si_lo = float(thr.get("si_hi", 0.66)), float(thr.get("si_lo", 0.33))
306
+ dnfr_hi, dnfr_lo = float(thr.get("dnfr_hi", 0.5)), float(thr.get("dnfr_lo", 0.1))
307
+ acc_hi, acc_lo = float(thr.get("accel_hi", 0.5)), float(thr.get("accel_lo", 0.1))
308
+ margin = float(G.graph.get("GLYPH_SELECTOR_MARGIN", DEFAULTS["GLYPH_SELECTOR_MARGIN"]))
309
+
310
+ # Normalizadores por paso
311
+ norms = G.graph.get("_sel_norms") or _norms_para_selector(G)
312
+ dnfr_max = float(norms.get("dnfr_max", 1.0))
313
+ acc_max = float(norms.get("accel_max", 1.0))
314
+
315
+ # Lecturas nodales
316
+ Si = clamp01(_get_attr(nd, ALIAS_SI, 0.5))
317
+ dnfr = abs(_get_attr(nd, ALIAS_DNFR, 0.0)) / dnfr_max
318
+ accel = abs(_get_attr(nd, ALIAS_D2EPI, 0.0)) / acc_max
319
+
320
+ W = G.graph.get("SELECTOR_WEIGHTS", DEFAULTS["SELECTOR_WEIGHTS"])
321
+ w_si = float(W.get("w_si", 0.5)); w_dn = float(W.get("w_dnfr", 0.3)); w_ac = float(W.get("w_accel", 0.2))
322
+ s = max(1e-9, w_si + w_dn + w_ac)
323
+ w_si, w_dn, w_ac = w_si/s, w_dn/s, w_ac/s
324
+ score = w_si*Si + w_dn*(1.0 - dnfr) + w_ac*(1.0 - accel)
325
+ # usar score como desempate/override suave: si score>0.66 ⇒ inclinar a I’L; <0.33 ⇒ inclinar a O’Z/Z’HIR
326
+
327
+ # Decisión base
328
+ if Si >= si_hi:
329
+ cand = "I’L"
330
+ elif Si <= si_lo:
331
+ if accel >= acc_hi:
332
+ cand = "T’HOL"
333
+ else:
334
+ cand = "O’Z" if dnfr >= dnfr_hi else "Z’HIR"
335
+ else:
336
+ # Zona intermedia: transición si el campo "pide" reorganizar (dnfr/accel altos)
337
+ if dnfr >= dnfr_hi or accel >= acc_hi:
338
+ cand = "NA’V"
339
+ else:
340
+ cand = "R’A"
341
+
342
+ # --- Histéresis del selector: si está cerca de umbrales, conserva el glifo reciente ---
343
+ # Medimos "certeza" como distancia mínima a los umbrales relevantes
344
+ d_si = min(abs(Si - si_hi), abs(Si - si_lo))
345
+ d_dn = min(abs(dnfr - dnfr_hi), abs(dnfr - dnfr_lo))
346
+ d_ac = min(abs(accel - acc_hi), abs(accel - acc_lo))
347
+ certeza = min(d_si, d_dn, d_ac)
348
+ if certeza < margin:
349
+ hist = nd.get("hist_glifos")
350
+ if hist:
351
+ prev = list(hist)[-1]
352
+ if isinstance(prev, str) and prev in ("I’L","O’Z","Z’HIR","T’HOL","NA’V","R’A"):
353
+ return prev
354
+
355
+ # Override suave guiado por score (solo si NO cayó la histéresis arriba)
356
+ # Regla: score>=0.66 inclina a I’L; score<=0.33 inclina a O’Z/Z’HIR
357
+ try:
358
+ if score >= 0.66 and cand in ("NA’V","R’A","Z’HIR","O’Z"):
359
+ cand = "I’L"
360
+ elif score <= 0.33 and cand in ("NA’V","R’A","I’L"):
361
+ cand = "O’Z" if dnfr >= dnfr_lo else "Z’HIR"
362
+ except NameError:
363
+ # por si 'score' no se definió (robustez), no forzamos nada
364
+ pass
365
+
366
+ # --- Gramática glífica suave: evita repeticiones cercanas salvo que el campo lo pida ---
367
+ gram = G.graph.get("GRAMMAR", DEFAULTS.get("GRAMMAR", {}))
368
+ gwin = int(gram.get("window", 3))
369
+ avoid = set(gram.get("avoid_repeats", []))
370
+ force_dn = float(gram.get("force_dnfr", 0.60))
371
+ force_ac = float(gram.get("force_accel", 0.60))
372
+ fallbacks = gram.get("fallbacks", {})
373
+
374
+ if cand in avoid and reciente_glifo(nd, cand, gwin):
375
+ # Solo permitimos repetir si el campo "insiste": dnfr o accel altos (ya normalizados)
376
+ if not (dnfr >= force_dn or accel >= force_ac):
377
+ cand = fallbacks.get(cand, "R’A")
378
+
379
+ return cand
380
+
381
+ # -------------------------
382
+ # Step / run
383
+ # -------------------------
384
+
385
+ def step(G, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool = True) -> None:
386
+ # Contexto inicial
387
+ _hist0 = G.graph.setdefault("history", {"C_steps": []})
388
+ step_idx = len(_hist0.get("C_steps", []))
389
+ invoke_callbacks(G, "before_step", {"step": step_idx, "dt": dt, "use_Si": use_Si, "apply_glyphs": apply_glyphs})
390
+
391
+ # 1) ΔNFR (campo)
392
+ compute_dnfr_cb = G.graph.get("compute_delta_nfr", default_compute_delta_nfr)
393
+ compute_dnfr_cb(G)
394
+
395
+ # 2) (opcional) Si
396
+ if use_Si:
397
+ from helpers import compute_Si
398
+ compute_Si(G, inplace=True)
399
+
400
+ # 2b) Normalizadores para selector paramétrico (por paso)
401
+ _norms_para_selector(G) # no molesta si luego se usa el selector por defecto
402
+
403
+ # 3) Selección glífica + aplicación
404
+ if apply_glyphs:
405
+ selector = G.graph.get("glyph_selector", default_glyph_selector)
406
+ from operators import aplicar_glifo
407
+ window = int(G.graph.get("GLYPH_HYSTERESIS_WINDOW", DEFAULTS["GLYPH_HYSTERESIS_WINDOW"]))
408
+ for n in G.nodes():
409
+ g = selector(G, n)
410
+ aplicar_glifo(G, n, g, window=window)
411
+
412
+ # 4) Ecuación nodal
413
+ update_epi_via_nodal_equation(G, dt=dt)
414
+
415
+ # 5) Clamps
416
+ for n in G.nodes():
417
+ aplicar_clamps_canonicos(G.nodes[n], G)
418
+
419
+ # 6) Coordinación de fase
420
+ coordinar_fase_global_vecinal(G, None, None)
421
+
422
+ # 7) Observadores ligeros
423
+ _update_history(G)
424
+ # dynamics.py — dentro de step(), justo antes del punto 8)
425
+ epi_hist = G.graph.setdefault("_epi_hist", [])
426
+ epi_hist.append({n: _get_attr(G.nodes[n], ALIAS_EPI, 0.0) for n in G.nodes()})
427
+ # recorta el buffer para que no crezca sin límite
428
+ tau = int(G.graph.get("REMESH_TAU", DEFAULTS["REMESH_TAU"]))
429
+ maxlen = max(2*tau + 5, 64)
430
+ if len(epi_hist) > maxlen:
431
+ del epi_hist[:-maxlen]
432
+
433
+ # 8) RE’MESH condicionado
434
+ aplicar_remesh_si_estabilizacion_global(G)
435
+
436
+ # Contexto final (últimas métricas del paso)
437
+ h = G.graph.get("history", {})
438
+ ctx = {"step": step_idx}
439
+ if h.get("C_steps"): ctx["C"] = h["C_steps"][-1]
440
+ if h.get("stable_frac"): ctx["stable_frac"] = h["stable_frac"][-1]
441
+ if h.get("phase_sync"): ctx["phase_sync"] = h["phase_sync"][-1]
442
+ if h.get("glyph_load_disr"): ctx["glyph_disr"] = h["glyph_load_disr"][-1]
443
+ if h.get("Si_mean"): ctx["Si_mean"] = h["Si_mean"][-1]
444
+ invoke_callbacks(G, "after_step", ctx)
445
+
446
+
447
+ def run(G, steps: int, *, dt: float | None = None, use_Si: bool = True, apply_glyphs: bool = True) -> None:
448
+ for _ in range(int(steps)):
449
+ step(G, dt=dt, use_Si=use_Si, apply_glyphs=apply_glyphs)
450
+ # Early-stop opcional
451
+ stop_cfg = G.graph.get("STOP_EARLY", DEFAULTS.get("STOP_EARLY", {"enabled": False}))
452
+ if stop_cfg and stop_cfg.get("enabled", False):
453
+ w = int(stop_cfg.get("window", 25))
454
+ frac = float(stop_cfg.get("fraction", 0.90))
455
+ hist = G.graph.setdefault("history", {"stable_frac": []})
456
+ series = hist.get("stable_frac", [])
457
+ if len(series) >= w and all(v >= frac for v in series[-w:]):
458
+ break
459
+
460
+
461
+ # -------------------------
462
+ # Historial simple
463
+ # -------------------------
464
+
465
+ def _update_history(G) -> None:
466
+ hist = G.graph.setdefault("history", {
467
+ "C_steps": [], "stable_frac": [],
468
+ "phase_sync": [], "glyph_load_estab": [], "glyph_load_disr": [],
469
+ "Si_mean": [], "Si_hi_frac": [], "Si_lo_frac": []
470
+ })
471
+
472
+ # Proxy de coherencia C(t)
473
+ dnfr_mean = list_mean(abs(_get_attr(G.nodes[n], ALIAS_DNFR, 0.0)) for n in G.nodes())
474
+ dEPI_mean = list_mean(abs(_get_attr(G.nodes[n], ALIAS_dEPI, 0.0)) for n in G.nodes())
475
+ C = 1.0 / (1.0 + dnfr_mean + dEPI_mean)
476
+ hist["C_steps"].append(C)
477
+
478
+ # --- W̄: coherencia promedio en ventana ---
479
+ wbar_w = int(G.graph.get("WBAR_WINDOW", DEFAULTS.get("WBAR_WINDOW", 25)))
480
+ cs = hist["C_steps"]
481
+ if cs:
482
+ w = min(len(cs), max(1, wbar_w))
483
+ wbar = sum(cs[-w:]) / w
484
+ hist.setdefault("W_bar", []).append(wbar)
485
+
486
+ eps_dnfr = float(G.graph.get("EPS_DNFR_STABLE", DEFAULTS["EPS_DNFR_STABLE"]))
487
+ eps_depi = float(G.graph.get("EPS_DEPI_STABLE", DEFAULTS["EPS_DEPI_STABLE"]))
488
+ stables = 0
489
+ total = max(1, G.number_of_nodes())
490
+ for n in G.nodes():
491
+ nd = G.nodes[n]
492
+ if abs(_get_attr(nd, ALIAS_DNFR, 0.0)) <= eps_dnfr and abs(_get_attr(nd, ALIAS_dEPI, 0.0)) <= eps_depi:
493
+ stables += 1
494
+ hist["stable_frac"].append(stables/total)
495
+ # --- nuevas series: sincronía de fase y carga glífica ---
496
+ try:
497
+ ps = sincronía_fase(G) # [0,1], más alto = más en fase
498
+ hist["phase_sync"].append(ps)
499
+ R = orden_kuramoto(G)
500
+ hist.setdefault("kuramoto_R", []).append(R)
501
+ win = int(G.graph.get("GLYPH_LOAD_WINDOW", DEFAULTS["GLYPH_LOAD_WINDOW"]))
502
+ gl = carga_glifica(G, window=win) # proporciones
503
+ hist["glyph_load_estab"].append(gl.get("_estabilizadores", 0.0))
504
+ hist["glyph_load_disr"].append(gl.get("_disruptivos", 0.0))
505
+ # --- Σ⃗(t): vector de sentido a partir de la distribución glífica ---
506
+ sig = sigma_vector(G, window=win)
507
+ hist.setdefault("sense_sigma_x", []).append(sig.get("x", 0.0))
508
+ hist.setdefault("sense_sigma_y", []).append(sig.get("y", 0.0))
509
+ hist.setdefault("sense_sigma_mag", []).append(sig.get("mag", 0.0))
510
+ hist.setdefault("sense_sigma_angle", []).append(sig.get("angle", 0.0))
511
+ # --- ι(t): intensidad de activación coherente (proxy) ---
512
+ # Definición operativa: iota = C(t) * stable_frac(t)
513
+ if hist.get("C_steps") and hist.get("stable_frac"):
514
+ hist.setdefault("iota", []).append(hist["C_steps"][-1] * hist["stable_frac"][-1])
515
+ except Exception:
516
+ # observadores son opcionales; si no están, no rompemos el bucle
517
+ pass
518
+
519
+ # --- nuevas series: Si agregado (media y colas) ---
520
+ try:
521
+ import math
522
+ sis = []
523
+ for n in G.nodes():
524
+ sis.append(_get_attr(G.nodes[n], ALIAS_SI, float("nan")))
525
+ sis = [s for s in sis if not math.isnan(s)]
526
+ if sis:
527
+ si_mean = list_mean(sis, 0.0)
528
+ hist["Si_mean"].append(si_mean)
529
+ # umbrales preferentes del selector paramétrico; fallback a los del selector simple
530
+ thr_sel = G.graph.get("SELECTOR_THRESHOLDS", DEFAULTS.get("SELECTOR_THRESHOLDS", {}))
531
+ thr_def = G.graph.get("GLYPH_THRESHOLDS", DEFAULTS.get("GLYPH_THRESHOLDS", {"hi":0.66,"lo":0.33}))
532
+ si_hi = float(thr_sel.get("si_hi", thr_def.get("hi", 0.66)))
533
+ si_lo = float(thr_sel.get("si_lo", thr_def.get("lo", 0.33)))
534
+ n = len(sis)
535
+ hist["Si_hi_frac"].append(sum(1 for s in sis if s >= si_hi) / n)
536
+ hist["Si_lo_frac"].append(sum(1 for s in sis if s <= si_lo) / n)
537
+ else:
538
+ hist["Si_mean"].append(0.0)
539
+ hist["Si_hi_frac"].append(0.0)
540
+ hist["Si_lo_frac"].append(0.0)
541
+ except Exception:
542
+ # si aún no se calculó Si este paso, no interrumpimos
543
+ pass