tnfr 4.3.0__py3-none-any.whl → 4.5.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/metrics.py CHANGED
@@ -4,9 +4,18 @@ from collections import defaultdict, Counter
4
4
  import statistics
5
5
  import csv
6
6
  import json
7
-
8
- from .constants import DEFAULTS
9
- from .helpers import register_callback, ensure_history, last_glifo
7
+ from math import cos
8
+
9
+ from .constants import DEFAULTS, ALIAS_EPI, ALIAS_THETA, ALIAS_DNFR
10
+ from .helpers import (
11
+ register_callback,
12
+ ensure_history,
13
+ last_glifo,
14
+ _get_attr,
15
+ clamp01,
16
+ list_mean,
17
+ fmean,
18
+ )
10
19
  from .sense import GLYPHS_CANONICAL
11
20
 
12
21
  # -------------
@@ -114,6 +123,23 @@ def _metrics_step(G, *args, **kwargs):
114
123
  li = (n_latent / max(1, n_total)) if n_total else 0.0
115
124
  hist.setdefault("latency_index", []).append({"t": t, "value": li})
116
125
 
126
+ # --- Soporte y norma de la EPI ---
127
+ thr = float(G.graph.get("EPI_SUPPORT_THR", DEFAULTS.get("EPI_SUPPORT_THR", 0.0)))
128
+ supp_nodes = [n for n in G.nodes() if abs(_get_attr(G.nodes[n], ALIAS_EPI, 0.0)) >= thr]
129
+ norm = (
130
+ sum(abs(_get_attr(G.nodes[n], ALIAS_EPI, 0.0)) for n in supp_nodes) / len(supp_nodes)
131
+ if supp_nodes else 0.0
132
+ )
133
+ hist.setdefault("EPI_support", []).append({"t": t, "size": len(supp_nodes), "norm": float(norm)})
134
+
135
+ # --- Métricas morfosintácticas ---
136
+ total = max(1, sum(counts.values()))
137
+ id_val = counts.get("O’Z", 0) / total
138
+ cm_val = (counts.get("Z’HIR", 0) + counts.get("NA’V", 0)) / total
139
+ ne_val = (counts.get("I’L", 0) + counts.get("T’HOL", 0)) / total
140
+ pp_val = counts.get("SH’A", 0) / max(1, counts.get("RE’MESH", 0))
141
+ hist.setdefault("morph", []).append({"t": t, "ID": id_val, "CM": cm_val, "NE": ne_val, "PP": pp_val})
142
+
117
143
 
118
144
  # -------------
119
145
  # Registro del callback
@@ -121,6 +147,9 @@ def _metrics_step(G, *args, **kwargs):
121
147
 
122
148
  def register_metrics_callbacks(G) -> None:
123
149
  register_callback(G, when="after_step", func=_metrics_step, name="metrics_step")
150
+ # Nuevas funcionalidades canónicas
151
+ register_coherence_callbacks(G)
152
+ register_diagnosis_callbacks(G)
124
153
 
125
154
 
126
155
  # -------------
@@ -173,7 +202,7 @@ def glifogram_series(G) -> Dict[str, List[float]]:
173
202
 
174
203
 
175
204
  def glyph_top(G, k: int = 3) -> List[Tuple[str, float]]:
176
- """Top-k glifos por Tg_global (fracción)."""
205
+ """Top-k operadores estructurales por Tg_global (fracción)."""
177
206
  tg = Tg_global(G, normalize=True)
178
207
  return sorted(tg.items(), key=lambda kv: kv[1], reverse=True)[:max(1, int(k))]
179
208
 
@@ -213,6 +242,8 @@ def export_history(G, base_path: str, fmt: str = "csv") -> None:
213
242
  "mag": sigma_mag,
214
243
  "angle": hist.get("sense_sigma_angle", []),
215
244
  }
245
+ morph = hist.get("morph", [])
246
+ epi_supp = hist.get("EPI_support", [])
216
247
  fmt = fmt.lower()
217
248
  if fmt == "csv":
218
249
  with open(base_path + "_glifogram.csv", "w", newline="") as f:
@@ -228,7 +259,339 @@ def export_history(G, base_path: str, fmt: str = "csv") -> None:
228
259
  writer.writerow(["t", "x", "y", "mag", "angle"])
229
260
  for i, t in enumerate(sigma["t"]):
230
261
  writer.writerow([t, sigma["sigma_x"][i], sigma["sigma_y"][i], sigma["mag"][i], sigma["angle"][i]])
262
+ if morph:
263
+ with open(base_path + "_morph.csv", "w", newline="") as f:
264
+ writer = csv.writer(f)
265
+ writer.writerow(["t", "ID", "CM", "NE", "PP"])
266
+ for row in morph:
267
+ writer.writerow([row.get("t"), row.get("ID"), row.get("CM"), row.get("NE"), row.get("PP")])
268
+ if epi_supp:
269
+ with open(base_path + "_epi_support.csv", "w", newline="") as f:
270
+ writer = csv.writer(f)
271
+ writer.writerow(["t", "size", "norm"])
272
+ for row in epi_supp:
273
+ writer.writerow([row.get("t"), row.get("size"), row.get("norm")])
231
274
  else:
232
- data = {"glifogram": glifo, "sigma": sigma}
275
+ data = {"glifogram": glifo, "sigma": sigma, "morph": morph, "epi_support": epi_supp}
233
276
  with open(base_path + ".json", "w") as f:
234
277
  json.dump(data, f)
278
+
279
+
280
+ # =========================
281
+ # COHERENCIA W_ij^t (TNFR)
282
+ # =========================
283
+
284
+
285
+ def _norm01(x, lo, hi):
286
+ if hi <= lo:
287
+ return 0.0
288
+ v = (float(x) - float(lo)) / (float(hi) - float(lo))
289
+ return 0.0 if v < 0 else (1.0 if v > 1.0 else v)
290
+
291
+
292
+ def _similarity_abs(a, b, lo, hi):
293
+ return 1.0 - _norm01(abs(float(a) - float(b)), 0.0, float(hi - lo) if hi > lo else 1.0)
294
+
295
+
296
+ def _coherence_components(G, ni, nj, epi_min, epi_max, vf_min, vf_max):
297
+ ndi = G.nodes[ni]
298
+ ndj = G.nodes[nj]
299
+ th_i = _get_attr(ndi, ALIAS_THETA, 0.0)
300
+ th_j = _get_attr(ndj, ALIAS_THETA, 0.0)
301
+ s_phase = 0.5 * (1.0 + cos(th_i - th_j))
302
+ epi_i = _get_attr(ndi, ALIAS_EPI, 0.0)
303
+ epi_j = _get_attr(ndj, ALIAS_EPI, 0.0)
304
+ s_epi = _similarity_abs(epi_i, epi_j, epi_min, epi_max)
305
+ vf_i = float(_get_attr(ndi, "νf", 0.0))
306
+ vf_j = float(_get_attr(ndj, "νf", 0.0))
307
+ s_vf = _similarity_abs(vf_i, vf_j, vf_min, vf_max)
308
+ si_i = clamp01(float(_get_attr(ndi, "Si", 0.0)))
309
+ si_j = clamp01(float(_get_attr(ndj, "Si", 0.0)))
310
+ s_si = 1.0 - abs(si_i - si_j)
311
+ return s_phase, s_epi, s_vf, s_si
312
+
313
+
314
+ def coherence_matrix(G):
315
+ cfg = G.graph.get("COHERENCE", DEFAULTS["COHERENCE"])
316
+ if not cfg.get("enabled", True):
317
+ return None, None
318
+
319
+ nodes = list(G.nodes())
320
+ n = len(nodes)
321
+ if n == 0:
322
+ return nodes, []
323
+
324
+ # Precompute indices to avoid repeated list.index calls within loops
325
+ node_to_index = {node: idx for idx, node in enumerate(nodes)}
326
+
327
+ epi_vals = [float(_get_attr(G.nodes[v], ALIAS_EPI, 0.0)) for v in nodes]
328
+ vf_vals = [float(_get_attr(G.nodes[v], "νf", 0.0)) for v in nodes]
329
+ epi_min, epi_max = min(epi_vals), max(epi_vals)
330
+ vf_min, vf_max = min(vf_vals), max(vf_vals)
331
+
332
+ wdict = dict(cfg.get("weights", {}))
333
+ for k in ("phase", "epi", "vf", "si"):
334
+ wdict.setdefault(k, 0.0)
335
+ wsum = sum(float(v) for v in wdict.values()) or 1.0
336
+ wnorm = {k: float(v) / wsum for k, v in wdict.items()}
337
+
338
+ scope = str(cfg.get("scope", "neighbors")).lower()
339
+ neighbors_only = scope != "all"
340
+ self_diag = bool(cfg.get("self_on_diag", True))
341
+ mode = str(cfg.get("store_mode", "sparse")).lower()
342
+ thr = float(cfg.get("threshold", 0.0))
343
+ if mode not in ("sparse", "dense"):
344
+ mode = "sparse"
345
+
346
+ if mode == "dense":
347
+ W = [[0.0] * n for _ in range(n)]
348
+ else:
349
+ W = []
350
+
351
+ row_sum = [0.0] * n
352
+ row_count = [0] * n
353
+
354
+ for i, ni in enumerate(nodes):
355
+ if self_diag:
356
+ if mode == "dense":
357
+ W[i][i] = 1.0
358
+ else:
359
+ W.append((i, i, 1.0))
360
+ row_sum[i] += 1.0
361
+ row_count[i] += 1
362
+
363
+ neighs = G.neighbors(ni) if neighbors_only else nodes
364
+ for nj in neighs:
365
+ if nj == ni:
366
+ continue
367
+ j = node_to_index[nj]
368
+ s_phase, s_epi, s_vf, s_si = _coherence_components(
369
+ G, ni, nj, epi_min, epi_max, vf_min, vf_max
370
+ )
371
+ wij = (
372
+ wnorm["phase"] * s_phase
373
+ + wnorm["epi"] * s_epi
374
+ + wnorm["vf"] * s_vf
375
+ + wnorm["si"] * s_si
376
+ )
377
+ wij = clamp01(wij)
378
+ if mode == "dense":
379
+ W[i][j] = wij
380
+ else:
381
+ if wij >= thr:
382
+ W.append((i, j, wij))
383
+ row_sum[i] += wij
384
+ row_count[i] += 1
385
+
386
+ Wi = [row_sum[i] / max(1, row_count[i]) for i in range(n)]
387
+ vals = []
388
+ if mode == "dense":
389
+ for i in range(n):
390
+ for j in range(n):
391
+ if i == j:
392
+ continue
393
+ vals.append(W[i][j])
394
+ else:
395
+ for (i, j, w) in W:
396
+ if i == j:
397
+ continue
398
+ vals.append(w)
399
+
400
+ stats = {
401
+ "min": min(vals) if vals else 0.0,
402
+ "max": max(vals) if vals else 0.0,
403
+ "mean": (sum(vals) / len(vals)) if vals else 0.0,
404
+ "n_edges": len(vals),
405
+ "mode": mode,
406
+ "scope": scope,
407
+ }
408
+
409
+ hist = ensure_history(G)
410
+ hist.setdefault(cfg.get("history_key", "W_sparse"), []).append(W)
411
+ hist.setdefault(cfg.get("Wi_history_key", "W_i"), []).append(Wi)
412
+ hist.setdefault(cfg.get("stats_history_key", "W_stats"), []).append(stats)
413
+
414
+ return nodes, W
415
+
416
+
417
+ def local_phase_sync_weighted(G, n, nodes_order=None, W_row=None):
418
+ import cmath
419
+
420
+ cfg = G.graph.get("COHERENCE", DEFAULTS["COHERENCE"])
421
+ scope = str(cfg.get("scope", "neighbors")).lower()
422
+ neighbors_only = scope != "all"
423
+
424
+ if W_row is None or nodes_order is None:
425
+ vec = [
426
+ cmath.exp(1j * float(_get_attr(G.nodes[v], ALIAS_THETA, 0.0)))
427
+ for v in (G.neighbors(n) if neighbors_only else (set(G.nodes()) - {n}))
428
+ ]
429
+ if not vec:
430
+ return 0.0
431
+ mean = sum(vec) / len(vec)
432
+ return abs(mean)
433
+
434
+ i = nodes_order.index(n)
435
+ if isinstance(W_row, list) and W_row and isinstance(W_row[0], (int, float)):
436
+ weights = W_row
437
+ else:
438
+ weights = [0.0] * len(nodes_order)
439
+ for (ii, jj, w) in W_row:
440
+ if ii == i:
441
+ weights[jj] = w
442
+
443
+ num = 0 + 0j
444
+ den = 0.0
445
+ for j, nj in enumerate(nodes_order):
446
+ if nj == n:
447
+ continue
448
+ w = weights[j]
449
+ den += w
450
+ th_j = float(_get_attr(G.nodes[nj], ALIAS_THETA, 0.0))
451
+ num += w * cmath.exp(1j * th_j)
452
+ return abs(num / den) if den else 0.0
453
+
454
+
455
+ def _coherence_step(G, ctx=None):
456
+ if not G.graph.get("COHERENCE", DEFAULTS["COHERENCE"]).get("enabled", True):
457
+ return
458
+ coherence_matrix(G)
459
+
460
+
461
+ def register_coherence_callbacks(G) -> None:
462
+ register_callback(G, when="after_step", func=_coherence_step, name="coherence_step")
463
+
464
+
465
+ # =========================
466
+ # DIAGNÓSTICO NODAL (TNFR)
467
+ # =========================
468
+
469
+
470
+ def _dnfr_norm(nd, dnfr_max):
471
+ val = abs(float(_get_attr(nd, ALIAS_DNFR, 0.0)))
472
+ if dnfr_max <= 0:
473
+ return 0.0
474
+ x = val / dnfr_max
475
+ return 1.0 if x > 1 else x
476
+
477
+
478
+ def _symmetry_index(G, n, k=3, epi_min=None, epi_max=None):
479
+ nd = G.nodes[n]
480
+ epi_i = float(_get_attr(nd, ALIAS_EPI, 0.0))
481
+ vec = list(G.neighbors(n))
482
+ if not vec:
483
+ return 1.0
484
+ epi_bar = fmean(float(_get_attr(G.nodes[v], ALIAS_EPI, epi_i)) for v in vec)
485
+ if epi_min is None or epi_max is None:
486
+ epis = [float(_get_attr(G.nodes[v], ALIAS_EPI, 0.0)) for v in G.nodes()]
487
+ epi_min, epi_max = min(epis), max(epis)
488
+ return _similarity_abs(epi_i, epi_bar, epi_min, epi_max)
489
+
490
+
491
+ def _state_from_thresholds(Rloc, dnfr_n, cfg):
492
+ stb = cfg.get("stable", {"Rloc_hi": 0.8, "dnfr_lo": 0.2, "persist": 3})
493
+ dsr = cfg.get("dissonance", {"Rloc_lo": 0.4, "dnfr_hi": 0.5, "persist": 3})
494
+ if (Rloc >= float(stb["Rloc_hi"])) and (dnfr_n <= float(stb["dnfr_lo"])):
495
+ return "estable"
496
+ if (Rloc <= float(dsr["Rloc_lo"])) and (dnfr_n >= float(dsr["dnfr_hi"])):
497
+ return "disonante"
498
+ return "transicion"
499
+
500
+
501
+ def _recommendation(state, cfg):
502
+ adv = cfg.get("advice", {})
503
+ key = {"estable": "stable", "transicion": "transition", "disonante": "dissonant"}[state]
504
+ return list(adv.get(key, []))
505
+
506
+
507
+ def _diagnosis_step(G, ctx=None):
508
+ dcfg = G.graph.get("DIAGNOSIS", DEFAULTS["DIAGNOSIS"])
509
+ if not dcfg.get("enabled", True):
510
+ return
511
+
512
+ hist = ensure_history(G)
513
+ key = dcfg.get("history_key", "nodal_diag")
514
+
515
+ dnfr_vals = [abs(float(_get_attr(G.nodes[v], ALIAS_DNFR, 0.0))) for v in G.nodes()]
516
+ dnfr_max = max(dnfr_vals) if dnfr_vals else 1.0
517
+ epi_vals = [float(_get_attr(G.nodes[v], ALIAS_EPI, 0.0)) for v in G.nodes()]
518
+ epi_min, epi_max = (min(epi_vals) if epi_vals else 0.0), (max(epi_vals) if epi_vals else 1.0)
519
+
520
+ CfgW = G.graph.get("COHERENCE", DEFAULTS["COHERENCE"])
521
+ Wkey = CfgW.get("Wi_history_key", "W_i")
522
+ Wm_key = CfgW.get("history_key", "W_sparse")
523
+ Wi_series = hist.get(Wkey, [])
524
+ Wi_last = Wi_series[-1] if Wi_series else None
525
+ Wm_series = hist.get(Wm_key, [])
526
+ Wm_last = Wm_series[-1] if Wm_series else None
527
+
528
+ nodes = list(G.nodes())
529
+ diag = {}
530
+ for i, n in enumerate(nodes):
531
+ nd = G.nodes[n]
532
+ Si = clamp01(float(_get_attr(nd, "Si", 0.0)))
533
+ EPI = float(_get_attr(nd, ALIAS_EPI, 0.0))
534
+ vf = float(_get_attr(nd, "νf", 0.0))
535
+ dnfr_n = _dnfr_norm(nd, dnfr_max)
536
+
537
+ Rloc = 0.0
538
+ if Wm_last is not None:
539
+ if Wm_last and isinstance(Wm_last[0], list):
540
+ row = Wm_last[i]
541
+ else:
542
+ row = Wm_last
543
+ Rloc = local_phase_sync_weighted(G, n, nodes_order=nodes, W_row=row)
544
+ else:
545
+ Rloc = local_phase_sync_weighted(G, n)
546
+
547
+ symm = _symmetry_index(G, n, epi_min=epi_min, epi_max=epi_max) if dcfg.get("compute_symmetry", True) else None
548
+ state = _state_from_thresholds(Rloc, dnfr_n, dcfg)
549
+
550
+ alerts = []
551
+ if state == "disonante" and dnfr_n >= float(dcfg.get("dissonance", {}).get("dnfr_hi", 0.5)):
552
+ alerts.append("tensión estructural alta")
553
+
554
+ advice = _recommendation(state, dcfg)
555
+
556
+ rec = {
557
+ "node": n,
558
+ "Si": Si,
559
+ "EPI": EPI,
560
+ "νf": vf,
561
+ "dnfr_norm": dnfr_n,
562
+ "W_i": (Wi_last[i] if (Wi_last and i < len(Wi_last)) else None),
563
+ "R_local": Rloc,
564
+ "symmetry": symm,
565
+ "state": state,
566
+ "advice": advice,
567
+ "alerts": alerts,
568
+ }
569
+ diag[n] = rec
570
+
571
+ hist.setdefault(key, []).append(diag)
572
+
573
+
574
+ def dissonance_events(G, ctx=None):
575
+ """Emite eventos de inicio/fin de disonancia estructural por nodo."""
576
+ hist = ensure_history(G)
577
+ evs = hist.setdefault("events", [])
578
+ norms = G.graph.get("_sel_norms", {})
579
+ dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
580
+ step_idx = len(hist.get("C_steps", []))
581
+ for n in G.nodes():
582
+ nd = G.nodes[n]
583
+ dn = abs(_get_attr(nd, ALIAS_DNFR, 0.0)) / dnfr_max
584
+ Rloc = local_phase_sync_weighted(G, n)
585
+ st = bool(nd.get("_disr_state", False))
586
+ if (not st) and dn >= 0.5 and Rloc <= 0.4:
587
+ nd["_disr_state"] = True
588
+ evs.append(("disonance_start", {"node": n, "step": step_idx}))
589
+ elif st and dn <= 0.2 and Rloc >= 0.7:
590
+ nd["_disr_state"] = False
591
+ evs.append(("disonance_end", {"node": n, "step": step_idx}))
592
+
593
+
594
+ def register_diagnosis_callbacks(G) -> None:
595
+ register_callback(G, when="after_step", func=_diagnosis_step, name="diagnosis_step")
596
+ register_callback(G, when="after_step", func=dissonance_events, name="dissonance_events")
597
+
tnfr/node.py ADDED
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+ from dataclasses import dataclass, field
3
+ from typing import Deque, Dict, Iterable, List, Optional, Protocol
4
+ from collections import deque
5
+
6
+ from .constants import DEFAULTS
7
+ from .helpers import push_glifo
8
+
9
+
10
+ class NodoProtocol(Protocol):
11
+ """Protocolo mínimo para nodos TNFR."""
12
+
13
+ EPI: float
14
+ vf: float
15
+ theta: float
16
+ Si: float
17
+ epi_kind: str
18
+ dnfr: float
19
+ d2EPI: float
20
+ graph: Dict[str, object]
21
+
22
+ def neighbors(self) -> Iterable["NodoProtocol"]:
23
+ ...
24
+
25
+ def push_glifo(self, glifo: str, window: int) -> None:
26
+ ...
27
+
28
+ def has_edge(self, other: "NodoProtocol") -> bool:
29
+ ...
30
+
31
+ def add_edge(self, other: "NodoProtocol", weight: float) -> None:
32
+ ...
33
+
34
+ def offset(self) -> int:
35
+ ...
36
+
37
+ def all_nodes(self) -> Iterable["NodoProtocol"]:
38
+ ...
39
+
40
+
41
+ @dataclass
42
+ class NodoTNFR:
43
+ """Representa un nodo TNFR autónomo."""
44
+
45
+ EPI: float = 0.0
46
+ vf: float = 0.0
47
+ theta: float = 0.0
48
+ Si: float = 0.0
49
+ epi_kind: str = ""
50
+ dnfr: float = 0.0
51
+ d2EPI: float = 0.0
52
+ graph: Dict[str, object] = field(default_factory=dict)
53
+ _neighbors: List["NodoTNFR"] = field(default_factory=list)
54
+ _hist_glifos: Deque[str] = field(default_factory=lambda: deque(maxlen=DEFAULTS.get("GLYPH_HYSTERESIS_WINDOW", 7)))
55
+
56
+ def neighbors(self) -> Iterable["NodoTNFR"]:
57
+ return list(self._neighbors)
58
+
59
+ def has_edge(self, other: "NodoTNFR") -> bool:
60
+ return other in self._neighbors
61
+
62
+ def add_edge(self, other: "NodoTNFR", weight: float = 1.0) -> None:
63
+ if other not in self._neighbors:
64
+ self._neighbors.append(other)
65
+ other._neighbors.append(self)
66
+
67
+ def push_glifo(self, glifo: str, window: int) -> None:
68
+ self._hist_glifos.append(glifo)
69
+ while len(self._hist_glifos) > window:
70
+ self._hist_glifos.popleft()
71
+ self.epi_kind = glifo
72
+
73
+ def offset(self) -> int:
74
+ return 0
75
+
76
+ def all_nodes(self) -> Iterable["NodoTNFR"]:
77
+ return list(getattr(self.graph, "_all_nodes", [self]))
78
+
79
+ def aplicar_glifo(self, glifo: str, window: Optional[int] = None) -> None:
80
+ from .operators import aplicar_glifo_obj
81
+ aplicar_glifo_obj(self, glifo, window=window)
82
+
83
+ def integrar(self, dt: float) -> None:
84
+ self.EPI += self.dnfr * dt
85
+
86
+
87
+ class NodoNX(NodoProtocol):
88
+ """Adaptador para nodos ``networkx``."""
89
+
90
+ def __init__(self, G, n):
91
+ self.G = G
92
+ self.n = n
93
+ self.graph = G.graph
94
+
95
+ @property
96
+ def EPI(self) -> float:
97
+ from .helpers import _get_attr
98
+ from .constants import ALIAS_EPI
99
+ return float(_get_attr(self.G.nodes[self.n], ALIAS_EPI, 0.0))
100
+
101
+ @EPI.setter
102
+ def EPI(self, v: float) -> None:
103
+ from .helpers import _set_attr
104
+ from .constants import ALIAS_EPI
105
+ _set_attr(self.G.nodes[self.n], ALIAS_EPI, float(v))
106
+
107
+ @property
108
+ def vf(self) -> float:
109
+ from .helpers import _get_attr
110
+ from .constants import ALIAS_VF
111
+ return float(_get_attr(self.G.nodes[self.n], ALIAS_VF, 0.0))
112
+
113
+ @vf.setter
114
+ def vf(self, v: float) -> None:
115
+ from .helpers import _set_attr
116
+ from .constants import ALIAS_VF
117
+ _set_attr(self.G.nodes[self.n], ALIAS_VF, float(v))
118
+
119
+ @property
120
+ def theta(self) -> float:
121
+ from .helpers import _get_attr
122
+ from .constants import ALIAS_THETA
123
+ return float(_get_attr(self.G.nodes[self.n], ALIAS_THETA, 0.0))
124
+
125
+ @theta.setter
126
+ def theta(self, v: float) -> None:
127
+ from .helpers import _set_attr
128
+ from .constants import ALIAS_THETA
129
+ _set_attr(self.G.nodes[self.n], ALIAS_THETA, float(v))
130
+
131
+ @property
132
+ def Si(self) -> float:
133
+ from .helpers import _get_attr
134
+ from .constants import ALIAS_SI
135
+ return float(_get_attr(self.G.nodes[self.n], ALIAS_SI, 0.0))
136
+
137
+ @Si.setter
138
+ def Si(self, v: float) -> None:
139
+ from .helpers import _set_attr
140
+ from .constants import ALIAS_SI
141
+ _set_attr(self.G.nodes[self.n], ALIAS_SI, float(v))
142
+
143
+ @property
144
+ def epi_kind(self) -> str:
145
+ from .helpers import _get_attr_str
146
+ from .constants import ALIAS_EPI_KIND
147
+ return _get_attr_str(self.G.nodes[self.n], ALIAS_EPI_KIND, "")
148
+
149
+ @epi_kind.setter
150
+ def epi_kind(self, v: str) -> None:
151
+ from .helpers import _set_attr_str
152
+ from .constants import ALIAS_EPI_KIND
153
+ _set_attr_str(self.G.nodes[self.n], ALIAS_EPI_KIND, str(v))
154
+
155
+ @property
156
+ def dnfr(self) -> float:
157
+ from .helpers import _get_attr
158
+ from .constants import ALIAS_DNFR
159
+ return float(_get_attr(self.G.nodes[self.n], ALIAS_DNFR, 0.0))
160
+
161
+ @dnfr.setter
162
+ def dnfr(self, v: float) -> None:
163
+ from .helpers import _set_attr
164
+ from .constants import ALIAS_DNFR
165
+ _set_attr(self.G.nodes[self.n], ALIAS_DNFR, float(v))
166
+
167
+ @property
168
+ def d2EPI(self) -> float:
169
+ from .helpers import _get_attr
170
+ from .constants import ALIAS_D2EPI
171
+ return float(_get_attr(self.G.nodes[self.n], ALIAS_D2EPI, 0.0))
172
+
173
+ @d2EPI.setter
174
+ def d2EPI(self, v: float) -> None:
175
+ from .helpers import _set_attr
176
+ from .constants import ALIAS_D2EPI
177
+ _set_attr(self.G.nodes[self.n], ALIAS_D2EPI, float(v))
178
+
179
+ def neighbors(self) -> Iterable[NodoProtocol]:
180
+ return [NodoNX(self.G, v) for v in self.G.neighbors(self.n)]
181
+
182
+ def push_glifo(self, glifo: str, window: int) -> None:
183
+ push_glifo(self.G.nodes[self.n], glifo, window)
184
+ self.epi_kind = glifo
185
+
186
+ def has_edge(self, other: NodoProtocol) -> bool:
187
+ if isinstance(other, NodoNX):
188
+ return self.G.has_edge(self.n, other.n)
189
+ raise NotImplementedError
190
+
191
+ def add_edge(self, other: NodoProtocol, weight: float) -> None:
192
+ if isinstance(other, NodoNX):
193
+ self.G.add_edge(self.n, other.n, weight=float(weight))
194
+ else:
195
+ raise NotImplementedError
196
+
197
+ def offset(self) -> int:
198
+ from .operators import _node_offset
199
+ return _node_offset(self.G, self.n)
200
+
201
+ def all_nodes(self) -> Iterable[NodoProtocol]:
202
+ return [NodoNX(self.G, v) for v in self.G.nodes()]
tnfr/observers.py CHANGED
@@ -101,9 +101,17 @@ def carga_glifica(G, window: int | None = None) -> dict:
101
101
  # Proporciones por glifo
102
102
  dist = {k: v / count for k, v in total.items()}
103
103
 
104
-
105
104
  # Agregados conceptuales (puedes ajustar categorías)
105
+ # Glifos que consolidan la coherencia nodal: I’L estabiliza el flujo (cap. 6),
106
+ # R’A propaga la resonancia (cap. 9), U’M acopla nodos en fase (cap. 8)
107
+ # y SH’A ofrece silencio regenerativo (cap. 10). Véase manual TNFR,
108
+ # sec. 18.19 "Análisis morfosintáctico" para la taxonomía funcional.
106
109
  estabilizadores = ["I’L", "R’A", "U’M", "SH’A"]
110
+
111
+ # Glifos que perturban o reconfiguran la red: O’Z introduce disonancia
112
+ # evolutiva (cap. 7), Z’HIR muta la estructura (cap. 14), NA’V marca
113
+ # el tránsito entre estados (cap. 15) y T’HOL autoorganiza un nuevo
114
+ # orden (cap. 13). Véase manual TNFR, sec. 18.19 para esta clasificación.
107
115
  disruptivos = ["O’Z", "Z’HIR", "NA’V", "T’HOL"]
108
116
 
109
117