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/__init__.py +41 -12
- tnfr/cli.py +53 -1
- tnfr/config.py +41 -0
- tnfr/constants.py +82 -25
- tnfr/dynamics.py +191 -42
- tnfr/gamma.py +17 -0
- tnfr/helpers.py +33 -21
- tnfr/metrics.py +368 -5
- tnfr/node.py +202 -0
- tnfr/observers.py +9 -1
- tnfr/operators.py +298 -125
- tnfr/structural.py +201 -0
- tnfr/types.py +2 -1
- tnfr/validators.py +38 -0
- tnfr-4.5.1.dist-info/METADATA +221 -0
- tnfr-4.5.1.dist-info/RECORD +28 -0
- tnfr-4.3.0.dist-info/METADATA +0 -109
- tnfr-4.3.0.dist-info/RECORD +0 -24
- {tnfr-4.3.0.dist-info → tnfr-4.5.1.dist-info}/WHEEL +0 -0
- {tnfr-4.3.0.dist-info → tnfr-4.5.1.dist-info}/entry_points.txt +0 -0
- {tnfr-4.3.0.dist-info → tnfr-4.5.1.dist-info}/licenses/LICENSE.md +0 -0
- {tnfr-4.3.0.dist-info → tnfr-4.5.1.dist-info}/top_level.txt +0 -0
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
|
-
|
|
9
|
-
from .
|
|
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
|
|
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
|
|