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

Files changed (195) hide show
  1. tnfr/__init__.py +275 -51
  2. tnfr/__init__.pyi +33 -0
  3. tnfr/_compat.py +10 -0
  4. tnfr/_generated_version.py +34 -0
  5. tnfr/_version.py +49 -0
  6. tnfr/_version.pyi +7 -0
  7. tnfr/alias.py +117 -31
  8. tnfr/alias.pyi +108 -0
  9. tnfr/cache.py +6 -572
  10. tnfr/cache.pyi +16 -0
  11. tnfr/callback_utils.py +16 -38
  12. tnfr/callback_utils.pyi +79 -0
  13. tnfr/cli/__init__.py +34 -14
  14. tnfr/cli/__init__.pyi +26 -0
  15. tnfr/cli/arguments.py +211 -28
  16. tnfr/cli/arguments.pyi +27 -0
  17. tnfr/cli/execution.py +470 -50
  18. tnfr/cli/execution.pyi +70 -0
  19. tnfr/cli/utils.py +18 -3
  20. tnfr/cli/utils.pyi +8 -0
  21. tnfr/config/__init__.py +13 -0
  22. tnfr/config/__init__.pyi +10 -0
  23. tnfr/{constants_glyphs.py → config/constants.py} +26 -20
  24. tnfr/config/constants.pyi +12 -0
  25. tnfr/config/feature_flags.py +83 -0
  26. tnfr/{config.py → config/init.py} +11 -7
  27. tnfr/config/init.pyi +8 -0
  28. tnfr/config/operator_names.py +93 -0
  29. tnfr/config/operator_names.pyi +28 -0
  30. tnfr/config/presets.py +84 -0
  31. tnfr/config/presets.pyi +7 -0
  32. tnfr/constants/__init__.py +80 -29
  33. tnfr/constants/__init__.pyi +92 -0
  34. tnfr/constants/aliases.py +31 -0
  35. tnfr/constants/core.py +4 -4
  36. tnfr/constants/core.pyi +17 -0
  37. tnfr/constants/init.py +1 -1
  38. tnfr/constants/init.pyi +12 -0
  39. tnfr/constants/metric.py +7 -15
  40. tnfr/constants/metric.pyi +19 -0
  41. tnfr/dynamics/__init__.py +165 -633
  42. tnfr/dynamics/__init__.pyi +82 -0
  43. tnfr/dynamics/adaptation.py +267 -0
  44. tnfr/dynamics/aliases.py +23 -0
  45. tnfr/dynamics/coordination.py +385 -0
  46. tnfr/dynamics/dnfr.py +2283 -400
  47. tnfr/dynamics/dnfr.pyi +24 -0
  48. tnfr/dynamics/integrators.py +406 -98
  49. tnfr/dynamics/integrators.pyi +34 -0
  50. tnfr/dynamics/runtime.py +881 -0
  51. tnfr/dynamics/sampling.py +10 -5
  52. tnfr/dynamics/sampling.pyi +7 -0
  53. tnfr/dynamics/selectors.py +719 -0
  54. tnfr/execution.py +70 -48
  55. tnfr/execution.pyi +45 -0
  56. tnfr/flatten.py +13 -9
  57. tnfr/flatten.pyi +21 -0
  58. tnfr/gamma.py +66 -53
  59. tnfr/gamma.pyi +34 -0
  60. tnfr/glyph_history.py +110 -52
  61. tnfr/glyph_history.pyi +35 -0
  62. tnfr/glyph_runtime.py +16 -0
  63. tnfr/glyph_runtime.pyi +9 -0
  64. tnfr/immutable.py +69 -28
  65. tnfr/immutable.pyi +34 -0
  66. tnfr/initialization.py +16 -16
  67. tnfr/initialization.pyi +65 -0
  68. tnfr/io.py +6 -240
  69. tnfr/io.pyi +16 -0
  70. tnfr/locking.pyi +7 -0
  71. tnfr/mathematics/__init__.py +81 -0
  72. tnfr/mathematics/backend.py +426 -0
  73. tnfr/mathematics/dynamics.py +398 -0
  74. tnfr/mathematics/epi.py +254 -0
  75. tnfr/mathematics/generators.py +222 -0
  76. tnfr/mathematics/metrics.py +119 -0
  77. tnfr/mathematics/operators.py +233 -0
  78. tnfr/mathematics/operators_factory.py +71 -0
  79. tnfr/mathematics/projection.py +78 -0
  80. tnfr/mathematics/runtime.py +173 -0
  81. tnfr/mathematics/spaces.py +247 -0
  82. tnfr/mathematics/transforms.py +292 -0
  83. tnfr/metrics/__init__.py +10 -10
  84. tnfr/metrics/__init__.pyi +20 -0
  85. tnfr/metrics/coherence.py +993 -324
  86. tnfr/metrics/common.py +23 -16
  87. tnfr/metrics/common.pyi +46 -0
  88. tnfr/metrics/core.py +251 -35
  89. tnfr/metrics/core.pyi +13 -0
  90. tnfr/metrics/diagnosis.py +708 -111
  91. tnfr/metrics/diagnosis.pyi +85 -0
  92. tnfr/metrics/export.py +27 -15
  93. tnfr/metrics/glyph_timing.py +232 -42
  94. tnfr/metrics/reporting.py +33 -22
  95. tnfr/metrics/reporting.pyi +12 -0
  96. tnfr/metrics/sense_index.py +987 -43
  97. tnfr/metrics/sense_index.pyi +9 -0
  98. tnfr/metrics/trig.py +214 -23
  99. tnfr/metrics/trig.pyi +13 -0
  100. tnfr/metrics/trig_cache.py +115 -22
  101. tnfr/metrics/trig_cache.pyi +10 -0
  102. tnfr/node.py +542 -136
  103. tnfr/node.pyi +178 -0
  104. tnfr/observers.py +152 -35
  105. tnfr/observers.pyi +31 -0
  106. tnfr/ontosim.py +23 -19
  107. tnfr/ontosim.pyi +28 -0
  108. tnfr/operators/__init__.py +601 -82
  109. tnfr/operators/__init__.pyi +45 -0
  110. tnfr/operators/definitions.py +513 -0
  111. tnfr/operators/definitions.pyi +78 -0
  112. tnfr/operators/grammar.py +760 -0
  113. tnfr/operators/jitter.py +107 -38
  114. tnfr/operators/jitter.pyi +11 -0
  115. tnfr/operators/registry.py +75 -0
  116. tnfr/operators/registry.pyi +13 -0
  117. tnfr/operators/remesh.py +149 -88
  118. tnfr/py.typed +0 -0
  119. tnfr/rng.py +46 -143
  120. tnfr/rng.pyi +14 -0
  121. tnfr/schemas/__init__.py +8 -0
  122. tnfr/schemas/grammar.json +94 -0
  123. tnfr/selector.py +25 -19
  124. tnfr/selector.pyi +19 -0
  125. tnfr/sense.py +72 -62
  126. tnfr/sense.pyi +23 -0
  127. tnfr/structural.py +522 -262
  128. tnfr/structural.pyi +69 -0
  129. tnfr/telemetry/__init__.py +35 -0
  130. tnfr/telemetry/cache_metrics.py +226 -0
  131. tnfr/telemetry/nu_f.py +423 -0
  132. tnfr/telemetry/nu_f.pyi +123 -0
  133. tnfr/telemetry/verbosity.py +37 -0
  134. tnfr/tokens.py +1 -3
  135. tnfr/tokens.pyi +36 -0
  136. tnfr/trace.py +270 -113
  137. tnfr/trace.pyi +40 -0
  138. tnfr/types.py +574 -6
  139. tnfr/types.pyi +331 -0
  140. tnfr/units.py +69 -0
  141. tnfr/units.pyi +16 -0
  142. tnfr/utils/__init__.py +217 -0
  143. tnfr/utils/__init__.pyi +202 -0
  144. tnfr/utils/cache.py +2395 -0
  145. tnfr/utils/cache.pyi +468 -0
  146. tnfr/utils/chunks.py +104 -0
  147. tnfr/utils/chunks.pyi +21 -0
  148. tnfr/{collections_utils.py → utils/data.py} +147 -90
  149. tnfr/utils/data.pyi +64 -0
  150. tnfr/utils/graph.py +85 -0
  151. tnfr/utils/graph.pyi +10 -0
  152. tnfr/utils/init.py +770 -0
  153. tnfr/utils/init.pyi +78 -0
  154. tnfr/utils/io.py +456 -0
  155. tnfr/{helpers → utils}/numeric.py +51 -24
  156. tnfr/utils/numeric.pyi +21 -0
  157. tnfr/validation/__init__.py +113 -0
  158. tnfr/validation/__init__.pyi +77 -0
  159. tnfr/validation/compatibility.py +95 -0
  160. tnfr/validation/compatibility.pyi +6 -0
  161. tnfr/validation/grammar.py +71 -0
  162. tnfr/validation/grammar.pyi +40 -0
  163. tnfr/validation/graph.py +138 -0
  164. tnfr/validation/graph.pyi +17 -0
  165. tnfr/validation/rules.py +281 -0
  166. tnfr/validation/rules.pyi +55 -0
  167. tnfr/validation/runtime.py +263 -0
  168. tnfr/validation/runtime.pyi +31 -0
  169. tnfr/validation/soft_filters.py +170 -0
  170. tnfr/validation/soft_filters.pyi +37 -0
  171. tnfr/validation/spectral.py +159 -0
  172. tnfr/validation/spectral.pyi +46 -0
  173. tnfr/validation/syntax.py +40 -0
  174. tnfr/validation/syntax.pyi +10 -0
  175. tnfr/validation/window.py +39 -0
  176. tnfr/validation/window.pyi +1 -0
  177. tnfr/viz/__init__.py +9 -0
  178. tnfr/viz/matplotlib.py +246 -0
  179. tnfr-7.0.0.dist-info/METADATA +179 -0
  180. tnfr-7.0.0.dist-info/RECORD +185 -0
  181. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/licenses/LICENSE.md +1 -1
  182. tnfr/grammar.py +0 -344
  183. tnfr/graph_utils.py +0 -84
  184. tnfr/helpers/__init__.py +0 -71
  185. tnfr/import_utils.py +0 -228
  186. tnfr/json_utils.py +0 -162
  187. tnfr/logging_utils.py +0 -116
  188. tnfr/presets.py +0 -60
  189. tnfr/validators.py +0 -84
  190. tnfr/value_utils.py +0 -59
  191. tnfr-4.5.2.dist-info/METADATA +0 -379
  192. tnfr-4.5.2.dist-info/RECORD +0 -67
  193. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/WHEEL +0 -0
  194. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/entry_points.txt +0 -0
  195. {tnfr-4.5.2.dist-info → tnfr-7.0.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,719 @@
1
+ """Glyph selection helpers for TNFR dynamics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import sys
7
+ from abc import ABC, abstractmethod
8
+ from collections.abc import Mapping, MutableMapping, Sequence
9
+ from concurrent.futures import ProcessPoolExecutor
10
+ from dataclasses import dataclass
11
+ from operator import itemgetter
12
+ from typing import Any, cast
13
+ from ..alias import collect_attr, get_attr
14
+ from ..constants import get_graph_param, get_param
15
+ from ..glyph_history import ensure_history
16
+ from ..utils import clamp01, resolve_chunk_size
17
+ from ..metrics.common import compute_dnfr_accel_max, merge_and_normalize_weights
18
+ from ..operators import apply_glyph
19
+ from ..validation import (
20
+ GrammarContext,
21
+ StructuralGrammarError,
22
+ enforce_canonical_grammar,
23
+ on_applied_glyph,
24
+ record_grammar_violation,
25
+ )
26
+ from ..selector import (
27
+ _apply_selector_hysteresis,
28
+ _calc_selector_score,
29
+ _selector_norms,
30
+ _selector_thresholds,
31
+ )
32
+ from ..types import Glyph, GlyphCode, GlyphSelector, HistoryState, NodeId, TNFRGraph
33
+ from ..utils import get_numpy
34
+ from ..validation import soft_grammar_filters
35
+ from .aliases import ALIAS_D2EPI, ALIAS_DNFR, ALIAS_DSI, ALIAS_SI
36
+
37
+ __all__ = (
38
+ "GlyphCode",
39
+ "AbstractSelector",
40
+ "DefaultGlyphSelector",
41
+ "ParametricGlyphSelector",
42
+ "default_glyph_selector",
43
+ "parametric_glyph_selector",
44
+ "_SelectorPreselection",
45
+ "_configure_selector_weights",
46
+ "_apply_selector",
47
+ "_apply_glyphs",
48
+ "_selector_parallel_jobs",
49
+ "_prepare_selector_preselection",
50
+ "_resolve_preselected_glyph",
51
+ "_choose_glyph",
52
+ )
53
+
54
+
55
+ class AbstractSelector(ABC):
56
+ """Interface describing glyph selector lifecycle hooks."""
57
+
58
+ def prepare(
59
+ self, graph: TNFRGraph, nodes: Sequence[NodeId]
60
+ ) -> None: # pragma: no cover - default no-op
61
+ """Prepare selector state before evaluating a glyph batch."""
62
+
63
+ @abstractmethod
64
+ def select(self, graph: TNFRGraph, node: NodeId) -> GlyphCode:
65
+ """Return the glyph to apply for ``node`` within ``graph``."""
66
+
67
+ def __call__(self, graph: TNFRGraph, node: NodeId) -> GlyphCode:
68
+ """Allow selectors to be used as legacy callables."""
69
+
70
+ return self.select(graph, node)
71
+
72
+
73
+ def _default_selector_logic(G: TNFRGraph, n: NodeId) -> GlyphCode:
74
+ nd = G.nodes[n]
75
+ thr = _selector_thresholds(G)
76
+ hi, lo, dnfr_hi = itemgetter("si_hi", "si_lo", "dnfr_hi")(thr)
77
+
78
+ norms = G.graph.get("_sel_norms")
79
+ if norms is None:
80
+ norms = compute_dnfr_accel_max(G)
81
+ G.graph["_sel_norms"] = norms
82
+ dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
83
+
84
+ Si = clamp01(get_attr(nd, ALIAS_SI, 0.5))
85
+ dnfr = abs(get_attr(nd, ALIAS_DNFR, 0.0)) / dnfr_max
86
+
87
+ if Si >= hi:
88
+ return "IL"
89
+ if Si <= lo:
90
+ return "OZ" if dnfr > dnfr_hi else "ZHIR"
91
+ return "NAV" if dnfr > dnfr_hi else "RA"
92
+
93
+
94
+ def _soft_grammar_prefilter(
95
+ G: TNFRGraph,
96
+ n: NodeId,
97
+ cand: GlyphCode,
98
+ ) -> GlyphCode:
99
+ """Soft grammar: avoid repetitions before the canonical one."""
100
+
101
+ ctx = GrammarContext.from_graph(G)
102
+ filtered = soft_grammar_filters(ctx, n, cand)
103
+ return cast(GlyphCode, filtered)
104
+
105
+
106
+ def _selector_normalized_metrics(
107
+ nd: Mapping[str, Any], norms: Mapping[str, float]
108
+ ) -> tuple[float, float, float]:
109
+ dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
110
+ acc_max = float(norms.get("accel_max", 1.0)) or 1.0
111
+ Si = clamp01(get_attr(nd, ALIAS_SI, 0.5))
112
+ dnfr = abs(get_attr(nd, ALIAS_DNFR, 0.0)) / dnfr_max
113
+ accel = abs(get_attr(nd, ALIAS_D2EPI, 0.0)) / acc_max
114
+ return Si, dnfr, accel
115
+
116
+
117
+ def _selector_base_choice(
118
+ Si: float, dnfr: float, accel: float, thr: Mapping[str, float]
119
+ ) -> GlyphCode:
120
+ si_hi, si_lo, dnfr_hi, acc_hi = itemgetter("si_hi", "si_lo", "dnfr_hi", "accel_hi")(
121
+ thr
122
+ )
123
+ if Si >= si_hi:
124
+ return "IL"
125
+ if Si <= si_lo:
126
+ if accel >= acc_hi:
127
+ return "THOL"
128
+ return "OZ" if dnfr >= dnfr_hi else "ZHIR"
129
+ if dnfr >= dnfr_hi or accel >= acc_hi:
130
+ return "NAV"
131
+ return "RA"
132
+
133
+
134
+ def _configure_selector_weights(G: TNFRGraph) -> Mapping[str, float]:
135
+ """Load and cache selector weight configuration from graph parameters."""
136
+
137
+ weights = merge_and_normalize_weights(
138
+ G, "SELECTOR_WEIGHTS", ("w_si", "w_dnfr", "w_accel")
139
+ )
140
+ cast_weights = cast(Mapping[str, float], weights)
141
+ G.graph["_selector_weights"] = cast_weights
142
+ return cast_weights
143
+
144
+
145
+ def _compute_selector_score(
146
+ G: TNFRGraph,
147
+ nd: Mapping[str, Any],
148
+ Si: float,
149
+ dnfr: float,
150
+ accel: float,
151
+ cand: GlyphCode,
152
+ ) -> float:
153
+ W = G.graph.get("_selector_weights")
154
+ if W is None:
155
+ W = _configure_selector_weights(G)
156
+ score = _calc_selector_score(Si, dnfr, accel, cast(Mapping[str, float], W))
157
+ hist_prev = nd.get("glyph_history")
158
+ if hist_prev and hist_prev[-1] == cand:
159
+ delta_si = get_attr(nd, ALIAS_DSI, 0.0)
160
+ h = ensure_history(G)
161
+ sig = h.get("sense_sigma_mag", [])
162
+ delta_sigma = sig[-1] - sig[-2] if len(sig) >= 2 else 0.0
163
+ if delta_si <= 0.0 and delta_sigma <= 0.0:
164
+ score -= 0.05
165
+ return float(score)
166
+
167
+
168
+ def _apply_score_override(
169
+ cand: GlyphCode, score: float, dnfr: float, dnfr_lo: float
170
+ ) -> GlyphCode:
171
+ cand_key = str(cand)
172
+ if score >= 0.66 and cand_key in ("NAV", "RA", "ZHIR", "OZ"):
173
+ return "IL"
174
+ if score <= 0.33 and cand_key in ("NAV", "RA", "IL"):
175
+ return "OZ" if dnfr >= dnfr_lo else "ZHIR"
176
+ return cand
177
+
178
+
179
+ def _parametric_selector_logic(G: TNFRGraph, n: NodeId) -> GlyphCode:
180
+ nd = G.nodes[n]
181
+ thr = _selector_thresholds(G)
182
+ margin: float | None = get_graph_param(G, "GLYPH_SELECTOR_MARGIN")
183
+
184
+ norms = cast(Mapping[str, float] | None, G.graph.get("_sel_norms"))
185
+ if norms is None:
186
+ norms = _selector_norms(G)
187
+ Si, dnfr, accel = _selector_normalized_metrics(nd, norms)
188
+
189
+ cand = _selector_base_choice(Si, dnfr, accel, thr)
190
+
191
+ hist_cand = _apply_selector_hysteresis(nd, Si, dnfr, accel, thr, margin)
192
+ if hist_cand is not None:
193
+ return hist_cand
194
+
195
+ score = _compute_selector_score(G, nd, Si, dnfr, accel, cand)
196
+
197
+ cand = _apply_score_override(cand, score, dnfr, thr["dnfr_lo"])
198
+
199
+ return _soft_grammar_prefilter(G, n, cand)
200
+
201
+
202
+ @dataclass(slots=True)
203
+ class _SelectorPreselection:
204
+ """Precomputed selector context shared across glyph decisions."""
205
+
206
+ kind: str
207
+ metrics: Mapping[Any, tuple[float, float, float]]
208
+ base_choices: Mapping[Any, GlyphCode]
209
+ thresholds: Mapping[str, float] | None = None
210
+ margin: float | None = None
211
+
212
+
213
+ def _build_default_preselection(
214
+ G: TNFRGraph, nodes: Sequence[NodeId]
215
+ ) -> _SelectorPreselection:
216
+ node_list = list(nodes)
217
+ thresholds = _selector_thresholds(G)
218
+ if not node_list:
219
+ return _SelectorPreselection("default", {}, {}, thresholds=thresholds)
220
+
221
+ norms = G.graph.get("_sel_norms") or _selector_norms(G)
222
+ n_jobs = _selector_parallel_jobs(G)
223
+ metrics = _collect_selector_metrics(G, node_list, norms, n_jobs=n_jobs)
224
+ base_choices = _compute_default_base_choices(metrics, thresholds)
225
+ return _SelectorPreselection(
226
+ "default", metrics, base_choices, thresholds=thresholds
227
+ )
228
+
229
+
230
+ def _build_param_preselection(
231
+ G: TNFRGraph, nodes: Sequence[NodeId]
232
+ ) -> _SelectorPreselection:
233
+ node_list = list(nodes)
234
+ thresholds = _selector_thresholds(G)
235
+ margin: float | None = get_graph_param(G, "GLYPH_SELECTOR_MARGIN")
236
+ if not node_list:
237
+ return _SelectorPreselection(
238
+ "param", {}, {}, thresholds=thresholds, margin=margin
239
+ )
240
+
241
+ norms = G.graph.get("_sel_norms") or _selector_norms(G)
242
+ n_jobs = _selector_parallel_jobs(G)
243
+ metrics = _collect_selector_metrics(G, node_list, norms, n_jobs=n_jobs)
244
+ base_choices = _compute_param_base_choices(metrics, thresholds, n_jobs)
245
+ return _SelectorPreselection(
246
+ "param",
247
+ metrics,
248
+ base_choices,
249
+ thresholds=thresholds,
250
+ margin=margin,
251
+ )
252
+
253
+
254
+ class DefaultGlyphSelector(AbstractSelector):
255
+ """Selector implementing the legacy default glyph heuristic."""
256
+
257
+ __slots__ = ("_preselection", "_prepared_graph_id")
258
+
259
+ def __init__(self) -> None:
260
+ self._preselection: _SelectorPreselection | None = None
261
+ self._prepared_graph_id: int | None = None
262
+
263
+ def prepare(self, graph: TNFRGraph, nodes: Sequence[NodeId]) -> None:
264
+ """Precompute default selector metrics for ``nodes``."""
265
+
266
+ self._preselection = _build_default_preselection(graph, nodes)
267
+ self._prepared_graph_id = id(graph)
268
+
269
+ def select(self, graph: TNFRGraph, node: NodeId) -> GlyphCode:
270
+ """Return the canonical glyph for ``node`` using cached metrics when available."""
271
+
272
+ if self._prepared_graph_id == id(graph):
273
+ preselection = self._preselection
274
+ else:
275
+ preselection = None
276
+ return _resolve_preselected_glyph(
277
+ graph, node, _default_selector_logic, preselection
278
+ )
279
+
280
+
281
+ class ParametricGlyphSelector(AbstractSelector):
282
+ """Selector exposing the parametric scoring pipeline."""
283
+
284
+ __slots__ = ("_preselection", "_prepared_graph_id")
285
+
286
+ def __init__(self) -> None:
287
+ self._preselection: _SelectorPreselection | None = None
288
+ self._prepared_graph_id: int | None = None
289
+
290
+ def prepare(self, graph: TNFRGraph, nodes: Sequence[NodeId]) -> None:
291
+ """Precompute parametric selector metrics and hysteresis thresholds."""
292
+
293
+ _selector_norms(graph)
294
+ _configure_selector_weights(graph)
295
+ self._preselection = _build_param_preselection(graph, nodes)
296
+ self._prepared_graph_id = id(graph)
297
+
298
+ def select(self, graph: TNFRGraph, node: NodeId) -> GlyphCode:
299
+ """Return the parametric glyph decision for ``node``."""
300
+
301
+ if self._prepared_graph_id == id(graph):
302
+ preselection = self._preselection
303
+ else:
304
+ preselection = None
305
+ return _resolve_preselected_glyph(
306
+ graph, node, _parametric_selector_logic, preselection
307
+ )
308
+
309
+
310
+ default_glyph_selector = DefaultGlyphSelector()
311
+ parametric_glyph_selector = ParametricGlyphSelector()
312
+
313
+
314
+ def _choose_glyph(
315
+ G: TNFRGraph,
316
+ n: NodeId,
317
+ selector: GlyphSelector,
318
+ use_canon: bool,
319
+ h_al: MutableMapping[Any, int],
320
+ h_en: MutableMapping[Any, int],
321
+ al_max: int,
322
+ en_max: int,
323
+ ) -> GlyphCode:
324
+ """Return glyph for ``n`` considering forced lags and canonical grammar."""
325
+
326
+ if h_al[n] > al_max:
327
+ return Glyph.AL
328
+ if h_en[n] > en_max:
329
+ return Glyph.EN
330
+ g = selector(G, n)
331
+ if use_canon:
332
+ try:
333
+ g = enforce_canonical_grammar(G, n, g)
334
+ except StructuralGrammarError as err:
335
+ nd = G.nodes[n]
336
+ history = tuple(str(item) for item in nd.get("glyph_history", ()))
337
+ selector_name = getattr(selector, "__name__", selector.__class__.__name__)
338
+ err.attach_context(node=n, selector=selector_name, history=history, stage="selector")
339
+ record_grammar_violation(G, n, err, stage="selector")
340
+ raise
341
+ return g
342
+
343
+
344
+ def _selector_parallel_jobs(G: TNFRGraph) -> int | None:
345
+ """Return worker count for selector helpers when parallelism is enabled."""
346
+
347
+ raw_jobs = G.graph.get("GLYPH_SELECTOR_N_JOBS")
348
+ try:
349
+ n_jobs = None if raw_jobs is None else int(raw_jobs)
350
+ except (TypeError, ValueError):
351
+ return None
352
+ if n_jobs is None or n_jobs <= 1:
353
+ return None
354
+ return n_jobs
355
+
356
+
357
+ def _selector_metrics_chunk(
358
+ args: tuple[list[float], list[float], list[float], float, float],
359
+ ) -> tuple[list[float], list[float], list[float]]:
360
+ """Normalise metric chunk values for multiprocessing execution."""
361
+
362
+ si_values, dnfr_values, accel_values, dnfr_max, accel_max = args
363
+ si_seq = [clamp01(float(v)) for v in si_values]
364
+ dnfr_seq = [abs(float(v)) / dnfr_max for v in dnfr_values]
365
+ accel_seq = [abs(float(v)) / accel_max for v in accel_values]
366
+ return si_seq, dnfr_seq, accel_seq
367
+
368
+
369
+ def _collect_selector_metrics(
370
+ G: TNFRGraph,
371
+ nodes: list[Any],
372
+ norms: Mapping[str, float],
373
+ n_jobs: int | None = None,
374
+ ) -> dict[Any, tuple[float, float, float]]:
375
+ """Return normalised (Si, ΔNFR, acceleration) triples for ``nodes``."""
376
+
377
+ if not nodes:
378
+ return {}
379
+
380
+ dynamics_module = sys.modules.get("tnfr.dynamics")
381
+ get_numpy_fn = get_numpy
382
+ if dynamics_module is not None:
383
+ get_numpy_fn = getattr(dynamics_module, "get_numpy", get_numpy)
384
+
385
+ np_mod = get_numpy_fn()
386
+ dnfr_max = float(norms.get("dnfr_max", 1.0)) or 1.0
387
+ accel_max = float(norms.get("accel_max", 1.0)) or 1.0
388
+
389
+ if np_mod is not None:
390
+ si_seq_np = collect_attr(G, nodes, ALIAS_SI, 0.5, np=np_mod).astype(float)
391
+ si_seq_np = np_mod.clip(si_seq_np, 0.0, 1.0)
392
+ dnfr_seq_np = (
393
+ np_mod.abs(collect_attr(G, nodes, ALIAS_DNFR, 0.0, np=np_mod).astype(float))
394
+ / dnfr_max
395
+ )
396
+ accel_seq_np = (
397
+ np_mod.abs(
398
+ collect_attr(G, nodes, ALIAS_D2EPI, 0.0, np=np_mod).astype(float)
399
+ )
400
+ / accel_max
401
+ )
402
+
403
+ si_seq = si_seq_np.tolist()
404
+ dnfr_seq = dnfr_seq_np.tolist()
405
+ accel_seq = accel_seq_np.tolist()
406
+ else:
407
+ si_values = collect_attr(G, nodes, ALIAS_SI, 0.5)
408
+ dnfr_values = collect_attr(G, nodes, ALIAS_DNFR, 0.0)
409
+ accel_values = collect_attr(G, nodes, ALIAS_D2EPI, 0.0)
410
+
411
+ worker_count = n_jobs if n_jobs is not None and n_jobs > 1 else None
412
+ if worker_count is None:
413
+ si_seq = [clamp01(float(v)) for v in si_values]
414
+ dnfr_seq = [abs(float(v)) / dnfr_max for v in dnfr_values]
415
+ accel_seq = [abs(float(v)) / accel_max for v in accel_values]
416
+ else:
417
+ approx_chunk = math.ceil(len(nodes) / worker_count) if worker_count else None
418
+ chunk_size = resolve_chunk_size(
419
+ approx_chunk,
420
+ len(nodes),
421
+ minimum=1,
422
+ )
423
+ chunk_bounds = [
424
+ (start, min(start + chunk_size, len(nodes)))
425
+ for start in range(0, len(nodes), chunk_size)
426
+ ]
427
+
428
+ si_seq = []
429
+ dnfr_seq = []
430
+ accel_seq = []
431
+
432
+ def _args_iter() -> (
433
+ Sequence[tuple[list[float], list[float], list[float], float, float]]
434
+ ):
435
+ for start, end in chunk_bounds:
436
+ yield (
437
+ si_values[start:end],
438
+ dnfr_values[start:end],
439
+ accel_values[start:end],
440
+ dnfr_max,
441
+ accel_max,
442
+ )
443
+
444
+ executor_cls = ProcessPoolExecutor
445
+ if dynamics_module is not None:
446
+ executor_cls = getattr(
447
+ dynamics_module, "ProcessPoolExecutor", ProcessPoolExecutor
448
+ )
449
+ with executor_cls(max_workers=worker_count) as executor:
450
+ for si_chunk, dnfr_chunk, accel_chunk in executor.map(
451
+ _selector_metrics_chunk, _args_iter()
452
+ ):
453
+ si_seq.extend(si_chunk)
454
+ dnfr_seq.extend(dnfr_chunk)
455
+ accel_seq.extend(accel_chunk)
456
+
457
+ return {
458
+ node: (si_seq[idx], dnfr_seq[idx], accel_seq[idx])
459
+ for idx, node in enumerate(nodes)
460
+ }
461
+
462
+
463
+ def _compute_default_base_choices(
464
+ metrics: Mapping[Any, tuple[float, float, float]],
465
+ thresholds: Mapping[str, float],
466
+ ) -> dict[Any, str]:
467
+ si_hi = float(thresholds.get("si_hi", 0.66))
468
+ si_lo = float(thresholds.get("si_lo", 0.33))
469
+ dnfr_hi = float(thresholds.get("dnfr_hi", 0.50))
470
+
471
+ base: dict[Any, str] = {}
472
+ for node, (Si, dnfr, _) in metrics.items():
473
+ if Si >= si_hi:
474
+ base[node] = "IL"
475
+ elif Si <= si_lo:
476
+ base[node] = "OZ" if dnfr > dnfr_hi else "ZHIR"
477
+ else:
478
+ base[node] = "NAV" if dnfr > dnfr_hi else "RA"
479
+ return base
480
+
481
+
482
+ def _param_base_worker(
483
+ args: tuple[Mapping[str, float], list[tuple[Any, tuple[float, float, float]]]],
484
+ ) -> list[tuple[Any, str]]:
485
+ thresholds, chunk = args
486
+ return [
487
+ (node, _selector_base_choice(Si, dnfr, accel, thresholds))
488
+ for node, (Si, dnfr, accel) in chunk
489
+ ]
490
+
491
+
492
+ def _compute_param_base_choices(
493
+ metrics: Mapping[Any, tuple[float, float, float]],
494
+ thresholds: Mapping[str, float],
495
+ n_jobs: int | None,
496
+ ) -> dict[Any, str]:
497
+ if not metrics:
498
+ return {}
499
+
500
+ items = list(metrics.items())
501
+ if n_jobs is None or n_jobs <= 1:
502
+ return {
503
+ node: _selector_base_choice(Si, dnfr, accel, thresholds)
504
+ for node, (Si, dnfr, accel) in items
505
+ }
506
+
507
+ approx_chunk = math.ceil(len(items) / n_jobs) if n_jobs else None
508
+ chunk_size = resolve_chunk_size(
509
+ approx_chunk,
510
+ len(items),
511
+ minimum=1,
512
+ )
513
+ chunks = [items[i : i + chunk_size] for i in range(0, len(items), chunk_size)]
514
+ base: dict[Any, str] = {}
515
+ args = ((thresholds, chunk) for chunk in chunks)
516
+ executor_cls = ProcessPoolExecutor
517
+ dynamics_module = sys.modules.get("tnfr.dynamics")
518
+ if dynamics_module is not None:
519
+ executor_cls = getattr(
520
+ dynamics_module, "ProcessPoolExecutor", ProcessPoolExecutor
521
+ )
522
+ with executor_cls(max_workers=n_jobs) as executor:
523
+ for result in executor.map(_param_base_worker, args):
524
+ for node, cand in result:
525
+ base[node] = cand
526
+ return base
527
+
528
+
529
+ def _prepare_selector_preselection(
530
+ G: TNFRGraph,
531
+ selector: GlyphSelector,
532
+ nodes: Sequence[NodeId],
533
+ ) -> _SelectorPreselection | None:
534
+ """Build cached selector metrics when ``selector`` supports them."""
535
+
536
+ if selector is default_glyph_selector:
537
+ return _build_default_preselection(G, nodes)
538
+ if selector is parametric_glyph_selector:
539
+ return _build_param_preselection(G, nodes)
540
+ return None
541
+
542
+
543
+ def _resolve_preselected_glyph(
544
+ G: TNFRGraph,
545
+ n: NodeId,
546
+ selector: GlyphSelector,
547
+ preselection: _SelectorPreselection | None,
548
+ ) -> GlyphCode:
549
+ """Return glyph for ``n`` using ``preselection`` shortcuts when possible."""
550
+
551
+ if preselection is None:
552
+ return selector(G, n)
553
+
554
+ metrics = preselection.metrics.get(n)
555
+ if metrics is None:
556
+ return selector(G, n)
557
+
558
+ if preselection.kind == "default":
559
+ cand = preselection.base_choices.get(n)
560
+ return cand if cand is not None else selector(G, n)
561
+
562
+ if preselection.kind == "param":
563
+ Si, dnfr, accel = metrics
564
+ thresholds = preselection.thresholds or _selector_thresholds(G)
565
+ margin: float | None = preselection.margin
566
+ if margin is None:
567
+ margin = get_graph_param(G, "GLYPH_SELECTOR_MARGIN")
568
+
569
+ cand = preselection.base_choices.get(n)
570
+ if cand is None:
571
+ cand = _selector_base_choice(Si, dnfr, accel, thresholds)
572
+
573
+ nd = G.nodes[n]
574
+ hist_cand = _apply_selector_hysteresis(nd, Si, dnfr, accel, thresholds, margin)
575
+ if hist_cand is not None:
576
+ return hist_cand
577
+
578
+ score = _compute_selector_score(G, nd, Si, dnfr, accel, cand)
579
+ cand = _apply_score_override(cand, score, dnfr, thresholds["dnfr_lo"])
580
+ return _soft_grammar_prefilter(G, n, cand)
581
+
582
+ return selector(G, n)
583
+
584
+
585
+ def _glyph_proposal_worker(
586
+ args: tuple[
587
+ list[NodeId],
588
+ TNFRGraph,
589
+ GlyphSelector,
590
+ _SelectorPreselection | None,
591
+ ],
592
+ ) -> list[tuple[NodeId, GlyphCode]]:
593
+ nodes, G, selector, preselection = args
594
+ return [
595
+ (n, _resolve_preselected_glyph(G, n, selector, preselection)) for n in nodes
596
+ ]
597
+
598
+
599
+ def _apply_glyphs(G: TNFRGraph, selector: GlyphSelector, hist: HistoryState) -> None:
600
+ """Apply glyph decisions across the graph updating hysteresis trackers."""
601
+
602
+ window = int(get_param(G, "GLYPH_HYSTERESIS_WINDOW"))
603
+ use_canon = bool(get_graph_param(G, "GRAMMAR_CANON", dict).get("enabled", False))
604
+ al_max = get_graph_param(G, "AL_MAX_LAG", int)
605
+ en_max = get_graph_param(G, "EN_MAX_LAG", int)
606
+
607
+ nodes_data = list(G.nodes(data=True))
608
+ nodes = [n for n, _ in nodes_data]
609
+ if isinstance(selector, AbstractSelector):
610
+ selector.prepare(G, nodes)
611
+ preselection: _SelectorPreselection | None = None
612
+ else:
613
+ preselection = _prepare_selector_preselection(G, selector, nodes)
614
+
615
+ h_al = hist.setdefault("since_AL", {})
616
+ h_en = hist.setdefault("since_EN", {})
617
+ forced: dict[Any, str | Glyph] = {}
618
+ to_select: list[Any] = []
619
+
620
+ for n, _ in nodes_data:
621
+ h_al[n] = int(h_al.get(n, 0)) + 1
622
+ h_en[n] = int(h_en.get(n, 0)) + 1
623
+
624
+ if h_al[n] > al_max:
625
+ forced[n] = Glyph.AL
626
+ elif h_en[n] > en_max:
627
+ forced[n] = Glyph.EN
628
+ else:
629
+ to_select.append(n)
630
+
631
+ decisions: dict[Any, str | Glyph] = dict(forced)
632
+ forced_al_nodes = {n for n, choice in forced.items() if choice == Glyph.AL}
633
+ forced_en_nodes = {n for n, choice in forced.items() if choice == Glyph.EN}
634
+ if to_select:
635
+ n_jobs = _selector_parallel_jobs(G)
636
+ if n_jobs is None:
637
+ for n in to_select:
638
+ decisions[n] = _resolve_preselected_glyph(G, n, selector, preselection)
639
+ else:
640
+ approx_chunk = math.ceil(len(to_select) / n_jobs) if n_jobs else None
641
+ chunk_size = resolve_chunk_size(
642
+ approx_chunk,
643
+ len(to_select),
644
+ minimum=1,
645
+ )
646
+ chunks = [
647
+ to_select[idx : idx + chunk_size]
648
+ for idx in range(0, len(to_select), chunk_size)
649
+ ]
650
+ dynamics_module = sys.modules.get("tnfr.dynamics")
651
+ executor_cls = ProcessPoolExecutor
652
+ if dynamics_module is not None:
653
+ executor_cls = getattr(
654
+ dynamics_module, "ProcessPoolExecutor", ProcessPoolExecutor
655
+ )
656
+ with executor_cls(max_workers=n_jobs) as executor:
657
+ args_iter = ((chunk, G, selector, preselection) for chunk in chunks)
658
+ for results in executor.map(_glyph_proposal_worker, args_iter):
659
+ for node, glyph in results:
660
+ decisions[node] = glyph
661
+
662
+ for n, _ in nodes_data:
663
+ g = decisions.get(n)
664
+ if g is None:
665
+ continue
666
+
667
+ if use_canon:
668
+ g = enforce_canonical_grammar(G, n, g)
669
+
670
+ apply_glyph(G, n, g, window=window)
671
+ if use_canon:
672
+ on_applied_glyph(G, n, g)
673
+
674
+ if n in forced_al_nodes:
675
+ h_al[n] = 0
676
+ h_en[n] = min(h_en[n], en_max)
677
+ continue
678
+ if n in forced_en_nodes:
679
+ h_en[n] = 0
680
+ continue
681
+
682
+ try:
683
+ glyph_enum = g if isinstance(g, Glyph) else Glyph(str(g))
684
+ except ValueError:
685
+ glyph_enum = None
686
+
687
+ if glyph_enum is Glyph.AL:
688
+ h_al[n] = 0
689
+ h_en[n] = min(h_en[n], en_max)
690
+ elif glyph_enum is Glyph.EN:
691
+ h_en[n] = 0
692
+
693
+
694
+ def _apply_selector(G: TNFRGraph) -> GlyphSelector:
695
+ """Resolve the glyph selector callable configured on ``G``."""
696
+
697
+ raw_selector = G.graph.get("glyph_selector")
698
+
699
+ selector: GlyphSelector
700
+ if isinstance(raw_selector, AbstractSelector):
701
+ selector = raw_selector
702
+ elif isinstance(raw_selector, type) and issubclass(raw_selector, AbstractSelector):
703
+ selector_obj = cast(AbstractSelector, raw_selector())
704
+ G.graph["glyph_selector"] = selector_obj
705
+ selector = selector_obj
706
+ elif raw_selector is None:
707
+ selector = default_glyph_selector
708
+ elif callable(raw_selector):
709
+ selector = cast(GlyphSelector, raw_selector)
710
+ else:
711
+ selector = default_glyph_selector
712
+
713
+ if (
714
+ isinstance(selector, ParametricGlyphSelector)
715
+ or selector is parametric_glyph_selector
716
+ ):
717
+ _selector_norms(G)
718
+ _configure_selector_weights(G)
719
+ return selector