tnfr 4.5.0__py3-none-any.whl → 4.5.2__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.

Files changed (78) hide show
  1. tnfr/__init__.py +91 -89
  2. tnfr/alias.py +546 -0
  3. tnfr/cache.py +578 -0
  4. tnfr/callback_utils.py +388 -0
  5. tnfr/cli/__init__.py +75 -0
  6. tnfr/cli/arguments.py +177 -0
  7. tnfr/cli/execution.py +288 -0
  8. tnfr/cli/utils.py +36 -0
  9. tnfr/collections_utils.py +300 -0
  10. tnfr/config.py +19 -28
  11. tnfr/constants/__init__.py +174 -0
  12. tnfr/constants/core.py +159 -0
  13. tnfr/constants/init.py +31 -0
  14. tnfr/constants/metric.py +110 -0
  15. tnfr/constants_glyphs.py +98 -0
  16. tnfr/dynamics/__init__.py +658 -0
  17. tnfr/dynamics/dnfr.py +733 -0
  18. tnfr/dynamics/integrators.py +267 -0
  19. tnfr/dynamics/sampling.py +31 -0
  20. tnfr/execution.py +201 -0
  21. tnfr/flatten.py +283 -0
  22. tnfr/gamma.py +302 -88
  23. tnfr/glyph_history.py +290 -0
  24. tnfr/grammar.py +285 -96
  25. tnfr/graph_utils.py +84 -0
  26. tnfr/helpers/__init__.py +71 -0
  27. tnfr/helpers/numeric.py +87 -0
  28. tnfr/immutable.py +178 -0
  29. tnfr/import_utils.py +228 -0
  30. tnfr/initialization.py +197 -0
  31. tnfr/io.py +246 -0
  32. tnfr/json_utils.py +162 -0
  33. tnfr/locking.py +37 -0
  34. tnfr/logging_utils.py +116 -0
  35. tnfr/metrics/__init__.py +41 -0
  36. tnfr/metrics/coherence.py +829 -0
  37. tnfr/metrics/common.py +151 -0
  38. tnfr/metrics/core.py +101 -0
  39. tnfr/metrics/diagnosis.py +234 -0
  40. tnfr/metrics/export.py +137 -0
  41. tnfr/metrics/glyph_timing.py +189 -0
  42. tnfr/metrics/reporting.py +148 -0
  43. tnfr/metrics/sense_index.py +120 -0
  44. tnfr/metrics/trig.py +181 -0
  45. tnfr/metrics/trig_cache.py +109 -0
  46. tnfr/node.py +214 -159
  47. tnfr/observers.py +126 -128
  48. tnfr/ontosim.py +134 -134
  49. tnfr/operators/__init__.py +420 -0
  50. tnfr/operators/jitter.py +203 -0
  51. tnfr/operators/remesh.py +485 -0
  52. tnfr/presets.py +46 -14
  53. tnfr/rng.py +254 -0
  54. tnfr/selector.py +210 -0
  55. tnfr/sense.py +284 -131
  56. tnfr/structural.py +207 -79
  57. tnfr/tokens.py +60 -0
  58. tnfr/trace.py +329 -94
  59. tnfr/types.py +43 -17
  60. tnfr/validators.py +70 -24
  61. tnfr/value_utils.py +59 -0
  62. tnfr-4.5.2.dist-info/METADATA +379 -0
  63. tnfr-4.5.2.dist-info/RECORD +67 -0
  64. tnfr/cli.py +0 -322
  65. tnfr/constants.py +0 -277
  66. tnfr/dynamics.py +0 -814
  67. tnfr/helpers.py +0 -264
  68. tnfr/main.py +0 -47
  69. tnfr/metrics.py +0 -597
  70. tnfr/operators.py +0 -525
  71. tnfr/program.py +0 -176
  72. tnfr/scenarios.py +0 -34
  73. tnfr-4.5.0.dist-info/METADATA +0 -109
  74. tnfr-4.5.0.dist-info/RECORD +0 -28
  75. {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/WHEEL +0 -0
  76. {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/entry_points.txt +0 -0
  77. {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/licenses/LICENSE.md +0 -0
  78. {tnfr-4.5.0.dist-info → tnfr-4.5.2.dist-info}/top_level.txt +0 -0
tnfr/sense.py CHANGED
@@ -1,200 +1,353 @@
1
+ """Sense calculations."""
2
+
1
3
  from __future__ import annotations
2
- from typing import Dict, Any, List, Tuple
4
+ from typing import TypeVar
5
+ from collections.abc import Iterable, Mapping
3
6
  import math
4
7
  from collections import Counter
5
-
6
- from .constants import DEFAULTS, ALIAS_SI, ALIAS_EPI
7
- from .helpers import _get_attr, clamp01, register_callback, ensure_history, last_glifo
8
-
8
+ from itertools import tee
9
+
10
+ import networkx as nx # type: ignore[import-untyped]
11
+
12
+ from .constants import get_aliases, get_graph_param
13
+ from .alias import get_attr
14
+ from .helpers.numeric import clamp01, kahan_sum_nd
15
+ from .import_utils import get_numpy
16
+ from .callback_utils import CallbackEvent, callback_manager
17
+ from .glyph_history import (
18
+ ensure_history,
19
+ last_glyph,
20
+ count_glyphs,
21
+ append_metric,
22
+ )
23
+ from .constants_glyphs import (
24
+ ANGLE_MAP,
25
+ GLYPHS_CANONICAL,
26
+ )
9
27
  # -------------------------
10
- # Canon: orden circular de glifos y ángulos
28
+ # Canon: orden circular de glyphs y ángulos
11
29
  # -------------------------
12
- GLYPHS_CANONICAL: List[str] = [
13
- "A’L", # 0
14
- "E’N", # 1
15
- "I’L", # 2
16
- "U’M", # 3
17
- "R’A", # 4
18
- "VA’L", # 5
19
- "O’Z", # 6
20
- "Z’HIR",# 7
21
- "NA’V", # 8
22
- "T’HOL",# 9
23
- "NU’L", #10
24
- "SH’A", #11
25
- "RE’MESH" #12
26
- ]
27
-
28
- _SIGMA_ANGLES: Dict[str, float] = {g: (2.0*math.pi * i / len(GLYPHS_CANONICAL)) for i, g in enumerate(GLYPHS_CANONICAL)}
29
30
 
30
- # -------------------------
31
- # Config por defecto
32
- # -------------------------
33
- DEFAULTS.setdefault("SIGMA", {
34
- "enabled": True,
35
- "weight": "Si", # "Si" | "EPI" | "1"
36
- "smooth": 0.0, # EMA sobre el vector global (0=off)
37
- "history_key": "sigma_global", # dónde guardar en G.graph['history']
38
- "per_node": False, # si True, guarda trayectoria σ por nodo (más pesado)
39
- })
31
+ GLYPH_UNITS: dict[str, complex] = {
32
+ g: complex(math.cos(a), math.sin(a)) for g, a in ANGLE_MAP.items()
33
+ }
34
+
35
+ __all__ = (
36
+ "GLYPH_UNITS",
37
+ "glyph_angle",
38
+ "glyph_unit",
39
+ "sigma_vector_node",
40
+ "sigma_vector",
41
+ "sigma_vector_from_graph",
42
+ "push_sigma_snapshot",
43
+ "register_sigma_callback",
44
+ "sigma_rose",
45
+ )
40
46
 
41
47
  # -------------------------
42
48
  # Utilidades básicas
43
49
  # -------------------------
44
50
 
51
+
52
+ T = TypeVar("T")
53
+
54
+
55
+ def _resolve_glyph(g: str, mapping: Mapping[str, T]) -> T:
56
+ """Return ``mapping[g]`` or raise ``KeyError`` with a standard message."""
57
+
58
+ try:
59
+ return mapping[g]
60
+ except KeyError as e: # pragma: no cover - small helper
61
+ raise KeyError(f"Glyph desconocido: {g}") from e
62
+
63
+
45
64
  def glyph_angle(g: str) -> float:
46
- return float(_SIGMA_ANGLES.get(g, 0.0))
65
+ """Return angle for glyph ``g``."""
66
+
67
+ return float(_resolve_glyph(g, ANGLE_MAP))
47
68
 
48
69
 
49
70
  def glyph_unit(g: str) -> complex:
50
- a = glyph_angle(g)
51
- return complex(math.cos(a), math.sin(a))
71
+ """Return unit vector for glyph ``g``."""
52
72
 
73
+ return _resolve_glyph(g, GLYPH_UNITS)
53
74
 
54
- def _weight(G, n, mode: str) -> float:
55
- nd = G.nodes[n]
56
- if mode == "Si":
57
- return clamp01(_get_attr(nd, ALIAS_SI, 0.5))
58
- if mode == "EPI":
59
- return max(0.0, float(_get_attr(nd, ALIAS_EPI, 0.0)))
60
- return 1.0
75
+
76
+ ALIAS_SI = get_aliases("SI")
77
+ ALIAS_EPI = get_aliases("EPI")
78
+
79
+ MODE_FUNCS = {
80
+ "Si": lambda nd: clamp01(get_attr(nd, ALIAS_SI, 0.5)),
81
+ "EPI": lambda nd: max(0.0, get_attr(nd, ALIAS_EPI, 0.0)),
82
+ }
83
+
84
+
85
+ def _weight(nd, mode: str) -> float:
86
+ return MODE_FUNCS.get(mode, lambda _: 1.0)(nd)
87
+
88
+
89
+ def _node_weight(nd, weight_mode: str) -> tuple[str, float, complex] | None:
90
+ """Return ``(glyph, weight, weighted_unit)`` or ``None`` if no glyph."""
91
+ g = last_glyph(nd)
92
+ if not g:
93
+ return None
94
+ w = _weight(nd, weight_mode)
95
+ z = glyph_unit(g) * w # precompute weighted unit vector
96
+ return g, w, z
97
+
98
+
99
+ def _sigma_cfg(G):
100
+ return get_graph_param(G, "SIGMA", dict)
101
+
102
+
103
+ def _to_complex(val: complex | float | int) -> complex:
104
+ """Return ``val`` as complex, promoting real numbers."""
105
+
106
+ if isinstance(val, complex):
107
+ return val
108
+ if isinstance(val, (int, float)):
109
+ return complex(val, 0.0)
110
+ raise TypeError("values must be an iterable of real or complex numbers")
111
+
112
+
113
+ def _empty_sigma(fallback_angle: float) -> dict[str, float]:
114
+ """Return an empty σ-vector with ``fallback_angle``.
115
+
116
+ Helps centralise the default structure returned when no values are
117
+ available for σ calculations.
118
+ """
119
+
120
+ return {
121
+ "x": 0.0,
122
+ "y": 0.0,
123
+ "mag": 0.0,
124
+ "angle": float(fallback_angle),
125
+ "n": 0,
126
+ }
61
127
 
62
128
 
63
-
64
129
  # -------------------------
65
130
  # σ por nodo y σ global
66
131
  # -------------------------
67
132
 
68
- def sigma_vector_node(G, n, weight_mode: str | None = None) -> Dict[str, float] | None:
133
+
134
+ def _sigma_from_iterable(
135
+ values: Iterable[complex | float | int] | complex | float | int,
136
+ fallback_angle: float = 0.0,
137
+ ) -> dict[str, float]:
138
+ """Normalise vectors in the σ-plane.
139
+
140
+ ``values`` may contain complex or real numbers; real inputs are promoted to
141
+ complex with zero imaginary part. The returned dictionary includes the
142
+ number of processed values under the ``"n"`` key.
143
+ """
144
+
145
+ if isinstance(values, Iterable) and not isinstance(values, (str, bytes, bytearray, Mapping)):
146
+ iterator = iter(values)
147
+ else:
148
+ iterator = iter((values,))
149
+
150
+ np = get_numpy()
151
+ if np is not None:
152
+ iterator, np_iter = tee(iterator)
153
+ arr = np.fromiter((_to_complex(v) for v in np_iter), dtype=np.complex128)
154
+ cnt = int(arr.size)
155
+ if cnt == 0:
156
+ return _empty_sigma(fallback_angle)
157
+ x = float(np.mean(arr.real))
158
+ y = float(np.mean(arr.imag))
159
+ mag = float(np.hypot(x, y))
160
+ ang = float(np.arctan2(y, x)) if mag > 0 else float(fallback_angle)
161
+ return {
162
+ "x": x,
163
+ "y": y,
164
+ "mag": mag,
165
+ "angle": ang,
166
+ "n": cnt,
167
+ }
168
+ cnt = 0
169
+
170
+ def pair_iter():
171
+ nonlocal cnt
172
+ for val in iterator:
173
+ z = _to_complex(val)
174
+ cnt += 1
175
+ yield (z.real, z.imag)
176
+
177
+ sum_x, sum_y = kahan_sum_nd(pair_iter(), dims=2)
178
+
179
+ if cnt == 0:
180
+ return _empty_sigma(fallback_angle)
181
+
182
+ x = sum_x / cnt
183
+ y = sum_y / cnt
184
+ mag = math.hypot(x, y)
185
+ ang = math.atan2(y, x) if mag > 0 else float(fallback_angle)
186
+ return {
187
+ "x": float(x),
188
+ "y": float(y),
189
+ "mag": float(mag),
190
+ "angle": float(ang),
191
+ "n": cnt,
192
+ }
193
+
194
+
195
+ def _ema_update(
196
+ prev: dict[str, float], current: dict[str, float], alpha: float
197
+ ) -> dict[str, float]:
198
+ """Exponential moving average update for σ vectors."""
199
+ x = (1 - alpha) * prev["x"] + alpha * current["x"]
200
+ y = (1 - alpha) * prev["y"] + alpha * current["y"]
201
+ mag = math.hypot(x, y)
202
+ ang = math.atan2(y, x)
203
+ return {"x": x, "y": y, "mag": mag, "angle": ang, "n": current.get("n", 0)}
204
+
205
+
206
+ def _sigma_from_nodes(
207
+ nodes: Iterable[dict], weight_mode: str, fallback_angle: float = 0.0
208
+ ) -> tuple[dict[str, float], list[tuple[str, float, complex]]]:
209
+ """Aggregate weighted glyph vectors for ``nodes``.
210
+
211
+ Returns the aggregated σ vector and the list of ``(glyph, weight, vector)``
212
+ triples used in the calculation.
213
+ """
214
+
215
+ nws = [nw for nd in nodes if (nw := _node_weight(nd, weight_mode))]
216
+ sv = _sigma_from_iterable((nw[2] for nw in nws), fallback_angle)
217
+ return sv, nws
218
+
219
+
220
+ def sigma_vector_node(
221
+ G, n, weight_mode: str | None = None
222
+ ) -> dict[str, float] | None:
223
+ cfg = _sigma_cfg(G)
69
224
  nd = G.nodes[n]
70
- g = last_glifo(nd)
71
- if g is None:
225
+ weight_mode = weight_mode or cfg.get("weight", "Si")
226
+ sv, nws = _sigma_from_nodes([nd], weight_mode)
227
+ if not nws:
72
228
  return None
73
- w = _weight(G, n, weight_mode or G.graph.get("SIGMA", DEFAULTS["SIGMA"]).get("weight", "Si"))
74
- z = glyph_unit(g) * w
75
- x, y = z.real, z.imag
76
- mag = math.hypot(x, y)
77
- ang = math.atan2(y, x) if mag > 0 else glyph_angle(g)
78
- return {"x": float(x), "y": float(y), "mag": float(mag), "angle": float(ang), "glifo": g, "w": float(w)}
229
+ g, w, _ = nws[0]
230
+ if sv["mag"] == 0:
231
+ sv["angle"] = glyph_angle(g)
232
+ sv.update({"glyph": g, "w": float(w)})
233
+ return sv
234
+
235
+
236
+ def sigma_vector(dist: dict[str, float]) -> dict[str, float]:
237
+ """Compute Σ⃗ from a glyph distribution.
238
+
239
+ ``dist`` may contain raw counts or proportions. All ``(glyph, weight)``
240
+ pairs are converted to vectors and passed to :func:`_sigma_from_iterable`.
241
+ The resulting vector includes the number of processed pairs under ``n``.
242
+ """
243
+
244
+ vectors = (glyph_unit(g) * float(w) for g, w in dist.items())
245
+ return _sigma_from_iterable(vectors)
79
246
 
80
247
 
81
- def sigma_vector_global(G, weight_mode: str | None = None) -> Dict[str, float]:
82
- """Vector global del plano del sentido σ.
248
+ def sigma_vector_from_graph(
249
+ G: nx.Graph, weight_mode: str | None = None
250
+ ) -> dict[str, float]:
251
+ """Global vector in the σ sense plane for a graph.
83
252
 
84
- Mapea el último glifo de cada nodo a un vector unitario en S¹, ponderado
85
- por `Si` (o `EPI`/1), y promedia para obtener:
86
- - componentes (x, y), magnitud |σ| y ángulo arg(σ).
253
+ Parameters
254
+ ----------
255
+ G:
256
+ NetworkX graph with per-node states.
257
+ weight_mode:
258
+ How to weight each node ("Si", "EPI" or ``None`` for unit weight).
87
259
 
88
- Interpretación TNFR: |σ| mide cuán alineada está la red en su
89
- **recorrido glífico**; arg(σ) indica la **dirección funcional** dominante
90
- (p. ej., torno a I’L/RA para consolidación/distribución, O’Z/Z’HIR para cambio).
260
+ Returns
261
+ -------
262
+ dict[str, float]
263
+ Cartesian components, magnitude and angle of the average vector.
91
264
  """
92
- cfg = G.graph.get("SIGMA", DEFAULTS["SIGMA"])
265
+
266
+ if not isinstance(G, nx.Graph):
267
+ raise TypeError("sigma_vector_from_graph requiere un networkx.Graph")
268
+
269
+ cfg = _sigma_cfg(G)
93
270
  weight_mode = weight_mode or cfg.get("weight", "Si")
94
- acc = complex(0.0, 0.0)
95
- cnt = 0
96
- for n in G.nodes():
97
- v = sigma_vector_node(G, n, weight_mode)
98
- if v is None:
99
- continue
100
- acc += complex(v["x"], v["y"])
101
- cnt += 1
102
- if cnt == 0:
103
- return {"x": 1.0, "y": 0.0, "mag": 1.0, "angle": 0.0, "n": 0}
104
- x, y = acc.real / max(1, cnt), acc.imag / max(1, cnt)
105
- mag = math.hypot(x, y)
106
- ang = math.atan2(y, x)
107
- return {"x": float(x), "y": float(y), "mag": float(mag), "angle": float(ang), "n": cnt}
271
+ sv, _ = _sigma_from_nodes(
272
+ (nd for _, nd in G.nodes(data=True)), weight_mode
273
+ )
274
+ return sv
108
275
 
109
276
 
110
277
  # -------------------------
111
278
  # Historia / series
112
279
  # -------------------------
113
280
 
281
+
114
282
  def push_sigma_snapshot(G, t: float | None = None) -> None:
115
- cfg = G.graph.get("SIGMA", DEFAULTS["SIGMA"])
283
+ cfg = _sigma_cfg(G)
116
284
  if not cfg.get("enabled", True):
117
285
  return
286
+
287
+ # Cache local de la historia para evitar llamadas repetidas
118
288
  hist = ensure_history(G)
119
289
  key = cfg.get("history_key", "sigma_global")
120
290
 
121
- # Global
122
- sv = sigma_vector_global(G, cfg.get("weight", "Si"))
291
+ weight_mode = cfg.get("weight", "Si")
292
+ sv = sigma_vector_from_graph(G, weight_mode)
123
293
 
124
294
  # Suavizado exponencial (EMA) opcional
125
295
  alpha = float(cfg.get("smooth", 0.0))
126
296
  if alpha > 0 and hist.get(key):
127
- prev = hist[key][-1]
128
- x = (1-alpha)*prev["x"] + alpha*sv["x"]
129
- y = (1-alpha)*prev["y"] + alpha*sv["y"]
130
- mag = math.hypot(x, y)
131
- ang = math.atan2(y, x)
132
- sv = {"x": x, "y": y, "mag": mag, "angle": ang, "n": sv.get("n", 0)}
297
+ sv = _ema_update(hist[key][-1], sv, alpha)
133
298
 
134
- sv["t"] = float(G.graph.get("_t", 0.0) if t is None else t)
299
+ current_t = float(G.graph.get("_t", 0.0) if t is None else t)
300
+ sv["t"] = current_t
135
301
 
136
- hist.setdefault(key, []).append(sv)
302
+ append_metric(hist, key, sv)
137
303
 
138
- # Conteo de glifos por paso (útil para rosa glífica)
139
- counts = Counter()
140
- for n in G.nodes():
141
- g = last_glifo(G.nodes[n])
142
- if g:
143
- counts[g] += 1
144
- hist.setdefault("sigma_counts", []).append({"t": sv["t"], **counts})
304
+ # Conteo de glyphs por paso (útil para rosa glífica)
305
+ counts = count_glyphs(G, last_only=True)
306
+ append_metric(hist, "sigma_counts", {"t": current_t, **counts})
145
307
 
146
308
  # Trayectoria por nodo (opcional)
147
309
  if cfg.get("per_node", False):
148
310
  per = hist.setdefault("sigma_per_node", {})
149
- for n in G.nodes():
150
- nd = G.nodes[n]
151
- g = last_glifo(nd)
311
+ for n, nd in G.nodes(data=True):
312
+ g = last_glyph(nd)
152
313
  if not g:
153
314
  continue
154
- a = glyph_angle(g)
155
315
  d = per.setdefault(n, [])
156
- d.append({"t": sv["t"], "g": g, "angle": a})
316
+ d.append({"t": current_t, "g": g, "angle": glyph_angle(g)})
157
317
 
158
318
 
159
319
  # -------------------------
160
320
  # Registro como callback automático (after_step)
161
321
  # -------------------------
162
322
 
163
- def register_sigma_callback(G) -> None:
164
- register_callback(G, when="after_step", func=push_sigma_snapshot, name="sigma_snapshot")
165
-
166
-
167
- # -------------------------
168
- # Series de utilidad
169
- # -------------------------
170
323
 
171
- def sigma_series(G, key: str | None = None) -> Dict[str, List[float]]:
172
- cfg = G.graph.get("SIGMA", DEFAULTS["SIGMA"])
173
- key = key or cfg.get("history_key", "sigma_global")
174
- hist = G.graph.get("history", {})
175
- xs = hist.get(key, [])
176
- if not xs:
177
- return {"t": [], "angle": [], "mag": []}
178
- return {
179
- "t": [float(x.get("t", i)) for i, x in enumerate(xs)],
180
- "angle": [float(x["angle"]) for x in xs],
181
- "mag": [float(x["mag"]) for x in xs],
182
- }
324
+ def register_sigma_callback(G) -> None:
325
+ callback_manager.register_callback(
326
+ G,
327
+ event=CallbackEvent.AFTER_STEP.value,
328
+ func=push_sigma_snapshot,
329
+ name="sigma_snapshot",
330
+ )
183
331
 
184
332
 
185
- def sigma_rose(G, steps: int | None = None) -> Dict[str, int]:
186
- """Histograma de glifos en los últimos `steps` pasos (o todos)."""
187
- hist = G.graph.get("history", {})
333
+ def sigma_rose(G, steps: int | None = None) -> dict[str, int]:
334
+ """Histogram of glyphs in the last ``steps`` steps (or all)."""
335
+ hist = ensure_history(G)
188
336
  counts = hist.get("sigma_counts", [])
189
337
  if not counts:
190
338
  return {g: 0 for g in GLYPHS_CANONICAL}
191
- if steps is None or steps >= len(counts):
192
- agg = Counter()
193
- for row in counts:
194
- agg.update({k: v for k, v in row.items() if k != "t"})
195
- out = {g: int(agg.get(g, 0)) for g in GLYPHS_CANONICAL}
196
- return out
197
- agg = Counter()
198
- for row in counts[-int(steps):]:
199
- agg.update({k: v for k, v in row.items() if k != "t"})
200
- return {g: int(agg.get(g, 0)) for g in GLYPHS_CANONICAL}
339
+ if steps is not None:
340
+ steps = int(steps)
341
+ if steps < 0:
342
+ raise ValueError("steps must be non-negative")
343
+ rows = (
344
+ counts if steps >= len(counts) else counts[-steps:]
345
+ ) # noqa: E203
346
+ else:
347
+ rows = counts
348
+ counter = Counter()
349
+ for row in rows:
350
+ for k, v in row.items():
351
+ if k != "t":
352
+ counter[k] += int(v)
353
+ return {g: int(counter.get(g, 0)) for g in GLYPHS_CANONICAL}