tnfr 3.5.0__py3-none-any.whl → 4.1.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/grammar.py ADDED
@@ -0,0 +1,155 @@
1
+ from __future__ import annotations
2
+ from typing import Dict, Any, Set
3
+
4
+ from .constants import (
5
+ DEFAULTS,
6
+ ALIAS_SI, ALIAS_DNFR, ALIAS_EPI,
7
+ )
8
+ from .helpers import _get_attr, clamp01, reciente_glifo
9
+ from collections import deque
10
+
11
+ # Glifos nominales (para evitar typos)
12
+ AL = "A’L"; EN = "E’N"; IL = "I’L"; OZ = "O’Z"; UM = "U’M"; RA = "R’A"; SHA = "SH’A"; VAL = "VA’L"; NUL = "NU’L"; THOL = "T’HOL"; ZHIR = "Z’HIR"; NAV = "NA’V"; REMESH = "RE’MESH"
13
+
14
+ # -------------------------
15
+ # Estado de gramática por nodo
16
+ # -------------------------
17
+
18
+ def _gram_state(nd: Dict[str, Any]) -> Dict[str, Any]:
19
+ """Crea/retorna el estado de gramática nodal.
20
+ Campos:
21
+ - thol_open (bool)
22
+ - thol_len (int)
23
+ """
24
+ st = nd.setdefault("_GRAM", {"thol_open": False, "thol_len": 0})
25
+ st.setdefault("thol_open", False)
26
+ st.setdefault("thol_len", 0)
27
+ return st
28
+
29
+ # -------------------------
30
+ # Compatibilidades canónicas (siguiente permitido)
31
+ # -------------------------
32
+ CANON_COMPAT: Dict[str, Set[str]] = {
33
+ # Inicio / apertura
34
+ AL: {EN, RA, NAV, VAL, UM},
35
+ EN: {IL, UM, RA, NAV},
36
+ # Estabilización / difusión / acople
37
+ IL: {RA, VAL, UM, SHA},
38
+ UM: {RA, IL, VAL, NAV},
39
+ RA: {IL, VAL, UM, NAV},
40
+ VAL: {UM, RA, IL, NAV},
41
+ # Disonancia → transición → mutación
42
+ OZ: {ZHIR, NAV},
43
+ ZHIR: {IL, NAV},
44
+ NAV: {OZ, ZHIR, RA, IL, UM},
45
+ # Cierres / latencias
46
+ SHA: {AL, EN},
47
+ NUL: {AL, IL},
48
+ # Bloques autoorganizativos
49
+ THOL: {OZ, ZHIR, NAV, RA, IL, UM, SHA, NUL},
50
+ }
51
+
52
+ # Fallbacks canónicos si una transición no está permitida
53
+ CANON_FALLBACK: Dict[str, str] = {
54
+ AL: EN, EN: IL, IL: RA, UM: RA, RA: IL, VAL: RA, OZ: ZHIR, ZHIR: IL, NAV: RA, SHA: AL, NUL: AL, THOL: NAV,
55
+ }
56
+
57
+ # -------------------------
58
+ # Cierres T’HOL y precondiciones Z’HIR
59
+ # -------------------------
60
+
61
+ def _dnfr_norm(G, nd) -> float:
62
+ # Normalizador robusto: usa historial de |ΔNFR| máx guardado por dynamics (si existe)
63
+ norms = G.graph.get("_sel_norms") or {}
64
+ dmax = float(norms.get("dnfr_max", 1.0)) or 1.0
65
+ return clamp01(abs(_get_attr(nd, ALIAS_DNFR, 0.0)) / dmax)
66
+
67
+
68
+ def _si(G, nd) -> float:
69
+ return clamp01(_get_attr(nd, ALIAS_SI, 0.5))
70
+
71
+ # -------------------------
72
+ # Núcleo: forzar gramática sobre un candidato
73
+ # -------------------------
74
+
75
+ def enforce_canonical_grammar(G, n, cand: str) -> str:
76
+ """Valida/ajusta el glifo candidato según la gramática canónica.
77
+
78
+ Reglas clave:
79
+ - Compatibilidades de transición glífica (recorrido TNFR).
80
+ - O’Z→Z’HIR: la mutación requiere disonancia reciente o |ΔNFR| alto.
81
+ - T’HOL[...]: obliga cierre con SH’A o NU’L cuando el campo se estabiliza
82
+ o se alcanza el largo del bloque; mantiene estado por nodo.
83
+
84
+ Devuelve el glifo efectivo a aplicar.
85
+ """
86
+ nd = G.nodes[n]
87
+ st = _gram_state(nd)
88
+ cfg = G.graph.get("GRAMMAR_CANON", DEFAULTS.get("GRAMMAR_CANON", {}))
89
+
90
+ # 0) Si vienen glifos fuera del alfabeto, no tocamos
91
+ if cand not in CANON_COMPAT:
92
+ return cand
93
+
94
+ # 1) Precondición O’Z→Z’HIR: mutación requiere disonancia reciente o campo fuerte
95
+ if cand == ZHIR:
96
+ win = int(cfg.get("zhir_requires_oz_window", 3))
97
+ dn_min = float(cfg.get("zhir_dnfr_min", 0.05))
98
+ if not reciente_glifo(nd, OZ, win) and _dnfr_norm(G, nd) < dn_min:
99
+ cand = OZ # forzamos paso por O’Z
100
+
101
+ # 2) Si estamos dentro de T’HOL, control de cierre obligado
102
+ if st.get("thol_open", False):
103
+ st["thol_len"] = int(st.get("thol_len", 0))
104
+ st["thol_len"] += 1
105
+ minlen = int(cfg.get("thol_min_len", 2))
106
+ maxlen = int(cfg.get("thol_max_len", 6))
107
+ close_dn = float(cfg.get("thol_close_dnfr", 0.15))
108
+ if st["thol_len"] >= maxlen or (st["thol_len"] >= minlen and _dnfr_norm(G, nd) <= close_dn):
109
+ cand = NUL if _si(G, nd) >= float(cfg.get("si_high", 0.66)) else SHA
110
+
111
+ # 3) Compatibilidades: si el anterior restringe el siguiente
112
+ prev = None
113
+ hist = nd.get("hist_glifos")
114
+ if hist:
115
+ try:
116
+ prev = list(hist)[-1]
117
+ except Exception:
118
+ prev = None
119
+ if prev in CANON_COMPAT and cand not in CANON_COMPAT[prev]:
120
+ cand = CANON_FALLBACK.get(prev, cand)
121
+
122
+ return cand
123
+
124
+ # -------------------------
125
+ # Post-selección: actualizar estado de gramática
126
+ # -------------------------
127
+
128
+ def on_applied_glifo(G, n, applied: str) -> None:
129
+ nd = G.nodes[n]
130
+ st = _gram_state(nd)
131
+ if applied == THOL:
132
+ st["thol_open"] = True
133
+ st["thol_len"] = 0
134
+ elif applied in (SHA, NUL):
135
+ st["thol_open"] = False
136
+ st["thol_len"] = 0
137
+ else:
138
+ pass
139
+
140
+ # -------------------------
141
+ # Integración con dynamics.step: helper de selección+aplicación
142
+ # -------------------------
143
+
144
+ def select_and_apply_with_grammar(G, n, selector, window: int) -> None:
145
+ """Aplica gramática canónica sobre la propuesta del selector.
146
+
147
+ El selector puede incluir una gramática **suave** (pre–filtro) como
148
+ `parametric_glyph_selector`; la presente función garantiza que la
149
+ gramática canónica tenga precedencia final.
150
+ """
151
+ from .operators import aplicar_glifo
152
+ cand = selector(G, n)
153
+ cand = enforce_canonical_grammar(G, n, cand)
154
+ aplicar_glifo(G, n, cand, window=window)
155
+ on_applied_glifo(G, n, cand)
tnfr/helpers.py CHANGED
@@ -4,7 +4,7 @@ helpers.py — TNFR canónica
4
4
  Utilidades transversales + cálculo de Índice de sentido (Si).
5
5
  """
6
6
  from __future__ import annotations
7
- from typing import Iterable, Dict, Any, Tuple, List
7
+ from typing import Iterable, Dict, Any
8
8
  import math
9
9
  from collections import deque
10
10
  from statistics import fmean, StatisticsError
@@ -21,19 +21,23 @@ from .constants import DEFAULTS, ALIAS_VF, ALIAS_THETA, ALIAS_DNFR, ALIAS_EPI, A
21
21
  # -------------------------
22
22
 
23
23
  def clamp(x: float, a: float, b: float) -> float:
24
+ """Constriñe ``x`` al intervalo cerrado [a, b]."""
24
25
  return a if x < a else b if x > b else x
25
26
 
26
27
 
27
28
  def clamp_abs(x: float, m: float) -> float:
29
+ """Limita ``x`` al rango simétrico [-m, m] usando ``abs(m)``."""
28
30
  m = abs(m)
29
31
  return clamp(x, -m, m)
30
32
 
31
33
 
32
34
  def clamp01(x: float) -> float:
35
+ """Ataja ``x`` a la banda [0, 1]."""
33
36
  return clamp(x, 0.0, 1.0)
34
37
 
35
38
 
36
39
  def list_mean(xs: Iterable[float], default: float = 0.0) -> float:
40
+ """Promedio aritmético o ``default`` si ``xs`` está vacío."""
37
41
  try:
38
42
  return fmean(xs)
39
43
  except StatisticsError:
@@ -77,16 +81,14 @@ def _set_attr(d, aliases, value: float) -> None:
77
81
  # -------------------------
78
82
 
79
83
  def media_vecinal(G, n, aliases: Iterable[str], default: float = 0.0) -> float:
80
- vals: List[float] = []
81
- for v in G.neighbors(n):
82
- vals.append(_get_attr(G.nodes[v], aliases, default))
84
+ """Media del atributo indicado por ``aliases`` en los vecinos de ``n``."""
85
+ vals = (_get_attr(G.nodes[v], aliases, default) for v in G.neighbors(n))
83
86
  return list_mean(vals, default)
84
87
 
85
88
 
86
89
  def fase_media(G, n) -> float:
87
90
  """Promedio circular de las fases de los vecinos."""
88
- x = 0.0
89
- y = 0.0
91
+ x = y = 0.0
90
92
  count = 0
91
93
  for v in G.neighbors(n):
92
94
  th = _get_attr(G.nodes[v], ALIAS_THETA, 0.0)
@@ -95,7 +97,7 @@ def fase_media(G, n) -> float:
95
97
  count += 1
96
98
  if count == 0:
97
99
  return _get_attr(G.nodes[n], ALIAS_THETA, 0.0)
98
- return math.atan2(y / max(1, count), x / max(1, count))
100
+ return math.atan2(y / count, x / count)
99
101
 
100
102
 
101
103
  # -------------------------
@@ -103,16 +105,24 @@ def fase_media(G, n) -> float:
103
105
  # -------------------------
104
106
 
105
107
  def push_glifo(nd: Dict[str, Any], glifo: str, window: int) -> None:
108
+ """Añade ``glifo`` al historial del nodo con tamaño máximo ``window``."""
106
109
  hist = nd.setdefault("hist_glifos", deque(maxlen=window))
107
110
  hist.append(str(glifo))
108
111
 
109
112
 
110
113
  def reciente_glifo(nd: Dict[str, Any], glifo: str, ventana: int) -> bool:
114
+ """Indica si ``glifo`` apareció en las últimas ``ventana`` emisiones."""
111
115
  hist = nd.get("hist_glifos")
112
116
  if not hist:
113
117
  return False
114
- last = list(hist)[-ventana:]
115
- return str(glifo) in last
118
+ 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
116
126
 
117
127
  # -------------------------
118
128
  # Callbacks Γ(R)
@@ -130,10 +140,26 @@ def _ensure_callbacks(G):
130
140
  cbs.setdefault(k, [])
131
141
  return cbs
132
142
 
133
- def register_callback(G, event: str, func):
134
- """Registra un callback en G.graph['callbacks'][event]. Firma: func(G, ctx) -> None"""
143
+ def register_callback(
144
+ G,
145
+ event: str | None = None,
146
+ func=None,
147
+ *,
148
+ when: str | None = None,
149
+ name: str | None = None,
150
+ ):
151
+ """Registra ``func`` como callback del ``event`` indicado.
152
+
153
+ Permite tanto la forma posicional ``register_callback(G, "after_step", fn)``
154
+ como la forma con palabras clave ``register_callback(G, when="after_step", func=fn)``.
155
+ El parámetro ``name`` se acepta por compatibilidad pero actualmente no se
156
+ utiliza.
157
+ """
158
+ event = event or when
135
159
  if event not in ("before_step", "after_step", "on_remesh"):
136
160
  raise ValueError(f"Evento desconocido: {event}")
161
+ if func is None:
162
+ raise TypeError("func es obligatorio")
137
163
  cbs = _ensure_callbacks(G)
138
164
  cbs[event].append(func)
139
165
  return func
@@ -160,7 +186,10 @@ def invoke_callbacks(G, event: str, ctx: dict | None = None):
160
186
  def compute_Si(G, *, inplace: bool = True) -> Dict[Any, float]:
161
187
  """Calcula Si por nodo y lo escribe en G.nodes[n]["Si"].
162
188
 
163
- Si = α·νf_norm + β·(1 - disp_fase_local) + γ·(1 - |ΔNFR|/max|ΔNFR|)
189
+ Fórmula:
190
+ Si = α·νf_norm + β·(1 - disp_fase_local) + γ·(1 - |ΔNFR|/max|ΔNFR|)
191
+ También guarda en ``G.graph`` los pesos normalizados y la
192
+ sensibilidad parcial (∂Si/∂componente).
164
193
  """
165
194
  alpha = float(G.graph.get("SI_WEIGHTS", DEFAULTS["SI_WEIGHTS"]).get("alpha", 0.34))
166
195
  beta = float(G.graph.get("SI_WEIGHTS", DEFAULTS["SI_WEIGHTS"]).get("beta", 0.33))
@@ -170,6 +199,8 @@ def compute_Si(G, *, inplace: bool = True) -> Dict[Any, float]:
170
199
  alpha = beta = gamma = 1/3
171
200
  else:
172
201
  alpha, beta, gamma = alpha/s, beta/s, gamma/s
202
+ G.graph["_Si_weights"] = {"alpha": alpha, "beta": beta, "gamma": gamma}
203
+ G.graph["_Si_sensitivity"] = {"dSi_dvf_norm": alpha, "dSi_ddisp_fase": -beta, "dSi_ddnfr_norm": -gamma}
173
204
 
174
205
  # Normalización de νf en red
175
206
  vfs = [abs(_get_attr(G.nodes[n], ALIAS_VF, 0.0)) for n in G.nodes()]
tnfr/main.py CHANGED
@@ -1,7 +1,11 @@
1
1
  from __future__ import annotations
2
- import argparse, sys
3
- import networkx as nx
4
- from . import preparar_red, run, __version__
2
+ import argparse, sys
3
+ import networkx as nx
4
+ from . import preparar_red, run, __version__
5
+ from .constants import merge_overrides, attach_defaults
6
+ from .sense import register_sigma_callback
7
+ from .metrics import register_metrics_callbacks
8
+ from .trace import register_trace
5
9
 
6
10
  def main(argv: list[str] | None = None) -> None:
7
11
  p = argparse.ArgumentParser(
@@ -15,13 +19,19 @@ def main(argv: list[str] | None = None) -> None:
15
19
  p.add_argument("--observer", action="store_true", help="adjunta observador estándar")
16
20
  args = p.parse_args(argv)
17
21
 
18
- if args.version:
19
- print(__version__)
20
- return
21
-
22
- G = nx.erdos_renyi_graph(args.n, args.p)
23
- preparar_red(G, ATTACH_STD_OBSERVER=bool(args.observer))
24
- run(G, args.steps)
22
+ if args.version:
23
+ print(__version__)
24
+ return
25
+
26
+ G = nx.erdos_renyi_graph(args.n, args.p)
27
+ preparar_red(G, ATTACH_STD_OBSERVER=bool(args.observer))
28
+ attach_defaults(G)
29
+ register_sigma_callback(G)
30
+ register_metrics_callbacks(G)
31
+ register_trace(G)
32
+ # Ejemplo: activar Γi(R) lineal con β=0.2 y R0=0.5
33
+ merge_overrides(G, GAMMA={"type": "kuramoto_linear", "beta": 0.2, "R0": 0.5})
34
+ run(G, args.steps)
25
35
 
26
36
  h = G.graph.get("history", {})
27
37
  C = h.get("C_steps", [])[-1] if h.get("C_steps") else None
tnfr/metrics.py ADDED
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+ from typing import Dict, Any, List, Tuple
3
+ from collections import defaultdict, Counter
4
+ import statistics
5
+
6
+ from .constants import DEFAULTS
7
+ from .helpers import _get_attr, clamp01, register_callback
8
+ from .sense import GLYPHS_CANONICAL
9
+
10
+ # -------------
11
+ # DEFAULTS
12
+ # -------------
13
+ DEFAULTS.setdefault("METRICS", {
14
+ "enabled": True,
15
+ "save_by_node": True, # guarda Tg por nodo (más pesado)
16
+ "normalize_series": False # glifograma normalizado a fracción por paso
17
+ })
18
+
19
+ # -------------
20
+ # Utilidades internas
21
+ # -------------
22
+
23
+ def _ensure_history(G):
24
+ if "history" not in G.graph:
25
+ G.graph["history"] = {}
26
+ return G.graph["history"]
27
+
28
+
29
+ def _last_glifo(nd: Dict[str, Any]) -> str | None:
30
+ hist = nd.get("hist_glifos")
31
+ if not hist:
32
+ return None
33
+ try:
34
+ return list(hist)[-1]
35
+ except Exception:
36
+ return None
37
+
38
+
39
+ # -------------
40
+ # Estado nodal para Tg
41
+ # -------------
42
+
43
+ def _tg_state(nd: Dict[str, Any]) -> Dict[str, Any]:
44
+ """Estructura interna por nodo para acumular tiempos de corrida por glifo.
45
+ Campos: curr (glifo actual), run (tiempo acumulado en el glifo actual)
46
+ """
47
+ st = nd.setdefault("_Tg", {"curr": None, "run": 0.0})
48
+ st.setdefault("curr", None)
49
+ st.setdefault("run", 0.0)
50
+ return st
51
+
52
+
53
+ # -------------
54
+ # Callback principal: actualizar métricas por paso
55
+ # -------------
56
+
57
+ def _metrics_step(G, *args, **kwargs):
58
+ """Actualiza métricas operativas TNFR por paso.
59
+
60
+ - Tg (tiempo glífico): sumatoria de corridas por glifo (global y por nodo).
61
+ - Índice de latencia: fracción de nodos en SH’A.
62
+ - Glifograma: conteo o fracción por glifo en el paso.
63
+
64
+ Todos los resultados se guardan en G.graph['history'].
65
+ """
66
+ if not G.graph.get("METRICS", DEFAULTS.get("METRICS", {})).get("enabled", True):
67
+ return
68
+
69
+ hist = _ensure_history(G)
70
+ dt = float(G.graph.get("DT", 1.0))
71
+ t = float(G.graph.get("_t", 0.0))
72
+
73
+ # --- Glifograma (conteos por glifo este paso) ---
74
+ counts = Counter()
75
+
76
+ # --- Índice de latencia: proporción de nodos en SH’A ---
77
+ n_total = 0
78
+ n_latent = 0
79
+
80
+ # --- Tg: acumular corridas por nodo ---
81
+ save_by_node = bool(G.graph.get("METRICS", DEFAULTS["METRICS"]).get("save_by_node", True))
82
+ tg_total = hist.setdefault("Tg_total", defaultdict(float)) # tiempo total por glifo (global)
83
+ tg_by_node = hist.setdefault("Tg_by_node", {}) # nodo → {glifo: [runs,...]}
84
+
85
+ for n in G.nodes():
86
+ nd = G.nodes[n]
87
+ g = _last_glifo(nd)
88
+ if not g:
89
+ continue
90
+
91
+ n_total += 1
92
+ if g == "SH’A":
93
+ n_latent += 1
94
+
95
+ counts[g] += 1
96
+
97
+ st = _tg_state(nd)
98
+ # Si seguimos en el mismo glifo, acumulamos; si cambiamos, cerramos corrida
99
+ if st["curr"] is None:
100
+ st["curr"] = g
101
+ st["run"] = dt
102
+ elif g == st["curr"]:
103
+ st["run"] += dt
104
+ else:
105
+ # cerramos corrida anterior
106
+ prev = st["curr"]
107
+ dur = float(st["run"])
108
+ tg_total[prev] += dur
109
+ if save_by_node:
110
+ rec = tg_by_node.setdefault(n, defaultdict(list))
111
+ rec[prev].append(dur)
112
+ # reiniciamos corrida
113
+ st["curr"] = g
114
+ st["run"] = dt
115
+
116
+ # Al final del paso, no cerramos la corrida actual: se cerrará cuando cambie.
117
+
118
+ # Guardar glifograma (conteos crudos y normalizados)
119
+ norm = bool(G.graph.get("METRICS", DEFAULTS["METRICS"]).get("normalize_series", False))
120
+ row = {"t": t}
121
+ total = max(1, sum(counts.values()))
122
+ for g in GLYPHS_CANONICAL:
123
+ c = counts.get(g, 0)
124
+ row[g] = (c / total) if norm else c
125
+ hist.setdefault("glifogram", []).append(row)
126
+
127
+ # Guardar índice de latencia
128
+ li = (n_latent / max(1, n_total)) if n_total else 0.0
129
+ hist.setdefault("latency_index", []).append({"t": t, "value": li})
130
+
131
+
132
+ # -------------
133
+ # Registro del callback
134
+ # -------------
135
+
136
+ def register_metrics_callbacks(G) -> None:
137
+ register_callback(G, when="after_step", func=_metrics_step, name="metrics_step")
138
+
139
+
140
+ # -------------
141
+ # Consultas / reportes
142
+ # -------------
143
+
144
+ def Tg_global(G, normalize: bool = True) -> Dict[str, float]:
145
+ """Tiempo glífico total por clase. Si normalize=True, devuelve fracciones del total."""
146
+ hist = _ensure_history(G)
147
+ tg_total: Dict[str, float] = hist.get("Tg_total", {})
148
+ total = sum(tg_total.values()) or 1.0
149
+ if normalize:
150
+ return {g: float(tg_total.get(g, 0.0)) / total for g in GLYPHS_CANONICAL}
151
+ return {g: float(tg_total.get(g, 0.0)) for g in GLYPHS_CANONICAL}
152
+
153
+
154
+ def Tg_by_node(G, n, normalize: bool = False) -> Dict[str, float | List[float]]:
155
+ """Resumen por nodo: si normalize, devuelve medias por glifo; si no, lista de corridas."""
156
+ hist = _ensure_history(G)
157
+ rec = hist.get("Tg_by_node", {}).get(n, {})
158
+ if not normalize:
159
+ # convertir default dict → list para serializar
160
+ return {g: list(rec.get(g, [])) for g in GLYPHS_CANONICAL}
161
+ out = {}
162
+ for g in GLYPHS_CANONICAL:
163
+ runs = rec.get(g, [])
164
+ out[g] = float(statistics.mean(runs)) if runs else 0.0
165
+
166
+ return out
167
+
168
+
169
+ def latency_series(G) -> Dict[str, List[float]]:
170
+ hist = _ensure_history(G)
171
+ xs = hist.get("latency_index", [])
172
+ return {
173
+ "t": [float(x.get("t", i)) for i, x in enumerate(xs)],
174
+ "value": [float(x.get("value", 0.0)) for x in xs],
175
+ }
176
+
177
+
178
+ def glifogram_series(G) -> Dict[str, List[float]]:
179
+ hist = _ensure_history(G)
180
+ xs = hist.get("glifogram", [])
181
+ if not xs:
182
+ return {"t": []}
183
+ out = {"t": [float(x.get("t", i)) for i, x in enumerate(xs)]}
184
+ for g in GLYPHS_CANONICAL:
185
+ out[g] = [float(x.get(g, 0.0)) for x in xs]
186
+ return out
187
+
188
+
189
+ def glyph_top(G, k: int = 3) -> List[Tuple[str, float]]:
190
+ """Top-k glifos por Tg_global (fracción)."""
191
+ tg = Tg_global(G, normalize=True)
192
+ return sorted(tg.items(), key=lambda kv: kv[1], reverse=True)[:max(1, int(k))]
193
+
194
+
195
+ def glyph_dwell_stats(G, n) -> Dict[str, Dict[str, float]]:
196
+ """Estadísticos por nodo: mean/median/max de corridas por glifo."""
197
+ hist = _ensure_history(G)
198
+ rec = hist.get("Tg_by_node", {}).get(n, {})
199
+ out = {}
200
+ for g in GLYPHS_CANONICAL:
201
+ runs = list(rec.get(g, []))
202
+ if not runs:
203
+ out[g] = {"mean": 0.0, "median": 0.0, "max": 0.0, "count": 0}
204
+ else:
205
+ out[g] = {
206
+ "mean": float(statistics.mean(runs)),
207
+ "median": float(statistics.median(runs)),
208
+ "max": float(max(runs)),
209
+ "count": int(len(runs)),
210
+ }
211
+ return out
tnfr/operators.py CHANGED
@@ -21,6 +21,15 @@ Nota sobre α (alpha) de RE’MESH: se toma por prioridad de
21
21
  3) DEFAULTS["REMESH_ALPHA"]
22
22
  """
23
23
 
24
+
25
+ def _node_offset(G, n) -> int:
26
+ """Deterministic node index used for jitter seeds."""
27
+ mapping = G.graph.get("_node_offset_map")
28
+ if mapping is None or len(mapping) != G.number_of_nodes():
29
+ mapping = {node: idx for idx, node in enumerate(sorted(G.nodes(), key=lambda x: str(x)))}
30
+ G.graph["_node_offset_map"] = mapping
31
+ return int(mapping.get(n, 0))
32
+
24
33
  # -------------------------
25
34
  # Glifos (operadores locales)
26
35
  # -------------------------
@@ -55,7 +64,7 @@ def op_OZ(G, n): # O’Z — Disonancia (aumenta ΔNFR o añade ruido)
55
64
  if bool(G.graph.get("OZ_NOISE_MODE", False)):
56
65
  base_seed = int(G.graph.get("RANDOM_SEED", 0))
57
66
  step_idx = len(G.graph.get("history", {}).get("C_steps", []))
58
- rnd = random.Random(base_seed + step_idx*1000003 + hash(("OZ", n)) % 1009)
67
+ rnd = random.Random(base_seed + step_idx*1000003 + _node_offset(G, n) % 1009)
59
68
  sigma = float(G.graph.get("OZ_SIGMA", 0.1))
60
69
  noise = sigma * (2.0 * rnd.random() - 1.0)
61
70
  _set_attr(nd, ALIAS_DNFR, dnfr + noise)
@@ -124,7 +133,7 @@ def op_NAV(G, n): # NA’V — Transición (jitter suave de ΔNFR)
124
133
  base_seed = int(G.graph.get("RANDOM_SEED", 0))
125
134
  # opcional: pequeño offset para evitar misma secuencia en todos los nodos/pasos
126
135
  step_idx = len(G.graph.get("history", {}).get("C_steps", []))
127
- rnd = random.Random(base_seed + step_idx*1000003 + hash(n) % 1009)
136
+ rnd = random.Random(base_seed + step_idx*1000003 + _node_offset(G, n) % 1009)
128
137
  jitter = j * (2.0 * rnd.random() - 1.0)
129
138
  else:
130
139
  # comportamiento determinista (compatibilidad previa)
@@ -154,6 +163,18 @@ _NAME_TO_OP = {
154
163
 
155
164
 
156
165
  def aplicar_glifo(G, n, glifo: str, *, window: Optional[int] = None) -> None:
166
+ """Aplica un glifo TNFR al nodo `n` con histéresis `window`.
167
+
168
+ Los 13 glifos implementan reorganizadores canónicos:
169
+ A’L (emisión), E’N (recepción), I’L (coherencia), O’Z (disonancia),
170
+ U’M (acoplamiento), R’A (resonancia), SH’A (silencio), VA’L (expansión),
171
+ NU’L (contracción), T’HOL (autoorganización), Z’HIR (mutación),
172
+ NA’V (transición), RE’MESH (recursividad).
173
+
174
+ Relación con la gramática: la selección previa debe pasar por
175
+ `enforce_canonical_grammar` (grammar.py) para respetar compatibilidades,
176
+ precondición O’Z→Z’HIR y cierres T’HOL[...].
177
+ """
157
178
  glifo = str(glifo)
158
179
  op = _NAME_TO_OP.get(glifo)
159
180
  if not op:
@@ -169,13 +190,15 @@ def aplicar_glifo(G, n, glifo: str, *, window: Optional[int] = None) -> None:
169
190
  # -------------------------
170
191
 
171
192
  def _remesh_alpha_info(G):
172
- """Devuelve (alpha, source) con precedencia explícita:
173
- 1) GLYPH_FACTORS["REMESH_alpha"] 2) G.graph["REMESH_ALPHA"] 3) DEFAULTS["REMESH_ALPHA"]"""
193
+ """Devuelve (alpha, source) con precedencia explícita."""
194
+ hard = bool(G.graph.get("REMESH_ALPHA_HARD", DEFAULTS.get("REMESH_ALPHA_HARD", False)))
174
195
  gf = G.graph.get("GLYPH_FACTORS", DEFAULTS["GLYPH_FACTORS"])
175
- if "REMESH_alpha" in gf:
196
+ if not hard and "REMESH_alpha" in gf:
176
197
  return float(gf["REMESH_alpha"]), "GLYPH_FACTORS"
177
198
  if "REMESH_ALPHA" in G.graph:
178
199
  return float(G.graph["REMESH_ALPHA"]), "G.graph"
200
+ if "REMESH_alpha" in gf:
201
+ return float(gf["REMESH_alpha"]), "GLYPH_FACTORS"
179
202
  return float(DEFAULTS["REMESH_ALPHA"]), "DEFAULTS"
180
203
 
181
204
 
@@ -296,6 +319,12 @@ def aplicar_remesh_si_estabilizacion_global(G, pasos_estables_consecutivos: Opti
296
319
  cooldown = int(G.graph.get("REMESH_COOLDOWN_VENTANA", DEFAULTS["REMESH_COOLDOWN_VENTANA"]))
297
320
  if step_idx - last < cooldown:
298
321
  return
322
+ t_now = float(G.graph.get("_t", 0.0))
323
+ last_ts = float(G.graph.get("_last_remesh_ts", -1e12))
324
+ cooldown_ts = float(G.graph.get("REMESH_COOLDOWN_TS", DEFAULTS.get("REMESH_COOLDOWN_TS", 0.0)))
325
+ if cooldown_ts > 0 and (t_now - last_ts) < cooldown_ts:
326
+ return
299
327
  # 4) Aplicar y registrar
300
328
  aplicar_remesh_red(G)
301
329
  G.graph["_last_remesh_step"] = step_idx
330
+ G.graph["_last_remesh_ts"] = t_now
tnfr/presets.py ADDED
@@ -0,0 +1,25 @@
1
+ from __future__ import annotations
2
+ from .program import seq, block, wait, ejemplo_canonico_basico
3
+
4
+
5
+ _PRESETS = {
6
+ "arranque_resonante": seq("A’L", "E’N", "I’L", "R’A", "VA’L", "U’M", wait(3), "SH’A"),
7
+ "mutacion_contenida": seq("A’L", "E’N", block("O’Z", "Z’HIR", "I’L", repeat=2), "R’A", "SH’A"),
8
+ "exploracion_acople": seq(
9
+ "A’L",
10
+ "E’N",
11
+ "I’L",
12
+ "VA’L",
13
+ "U’M",
14
+ block("O’Z", "NA’V", "I’L", repeat=1),
15
+ "R’A",
16
+ "SH’A",
17
+ ),
18
+ "ejemplo_canonico": ejemplo_canonico_basico(),
19
+ }
20
+
21
+
22
+ def get_preset(name: str):
23
+ if name not in _PRESETS:
24
+ raise KeyError(f"Preset no encontrado: {name}")
25
+ return _PRESETS[name]