gdk9-cli 0.3.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.
Files changed (82) hide show
  1. examples/01_analyze.py +28 -0
  2. examples/02_profile.py +24 -0
  3. examples/03_assign.py +19 -0
  4. examples/04_dcg.py +41 -0
  5. examples/05_tokenize.py +22 -0
  6. examples/06_synthesize.py +20 -0
  7. examples/07_api.py +57 -0
  8. examples/08_kernel.py +35 -0
  9. examples/09_kernel_search_walkthrough.py +102 -0
  10. examples/10_conserve_vs_naive.py +100 -0
  11. gdk9/__init__.py +5 -0
  12. gdk9/ansi.py +57 -0
  13. gdk9/cli.py +1186 -0
  14. gdk9/crdt.py +88 -0
  15. gdk9/crypto.py +132 -0
  16. gdk9/data/ninefold.json +40 -0
  17. gdk9/data/official.example.json +16 -0
  18. gdk9/data/official.json +132 -0
  19. gdk9/data/subs.json +436 -0
  20. gdk9/dcg.py +356 -0
  21. gdk9/egglog_bridge/__init__.py +6 -0
  22. gdk9/egglog_bridge/dr_egglog.py +107 -0
  23. gdk9/egglog_bridge/fallback.py +34 -0
  24. gdk9/energy.py +211 -0
  25. gdk9/errors.py +15 -0
  26. gdk9/fmt.py +212 -0
  27. gdk9/imply.py +83 -0
  28. gdk9/io_utils.py +24 -0
  29. gdk9/kernel/__init__.py +24 -0
  30. gdk9/kernel/engine.py +123 -0
  31. gdk9/kernel/errors.py +17 -0
  32. gdk9/kernel/expression.py +43 -0
  33. gdk9/kernel/principle.py +43 -0
  34. gdk9/kernel/proof.py +24 -0
  35. gdk9/kernel/rule.py +30 -0
  36. gdk9/kernel/symbol.py +19 -0
  37. gdk9/kernel_cli.py +195 -0
  38. gdk9/log.py +30 -0
  39. gdk9/optimize.py +288 -0
  40. gdk9/parser.py +35 -0
  41. gdk9/plugins/__init__.py +9 -0
  42. gdk9/plugins/loader.py +346 -0
  43. gdk9/plugins/registry.py +42 -0
  44. gdk9/principles.py +131 -0
  45. gdk9/saturate.py +89 -0
  46. gdk9/state.py +138 -0
  47. gdk9/subs.py +108 -0
  48. gdk9/tokenize.py +386 -0
  49. gdk9/tui.py +115 -0
  50. gdk9/utilization.py +15 -0
  51. gdk9_cli-0.3.0.dist-info/METADATA +295 -0
  52. gdk9_cli-0.3.0.dist-info/RECORD +82 -0
  53. gdk9_cli-0.3.0.dist-info/WHEEL +5 -0
  54. gdk9_cli-0.3.0.dist-info/entry_points.txt +3 -0
  55. gdk9_cli-0.3.0.dist-info/licenses/LICENSE +661 -0
  56. gdk9_cli-0.3.0.dist-info/top_level.txt +4 -0
  57. scripts/batch_analyze.py +109 -0
  58. scripts/export_json.py +125 -0
  59. scripts/make_zip.py +57 -0
  60. scripts/profile_compare.py +165 -0
  61. scripts/watch_file.py +124 -0
  62. tests/conftest.py +26 -0
  63. tests/experiment/__init__.py +1 -0
  64. tests/experiment/test_conserve_vs_naive.py +287 -0
  65. tests/kernel/test_kernel_energy.py +18 -0
  66. tests/kernel/test_kernel_rules.py +58 -0
  67. tests/kernel/test_kernel_search_bound.py +34 -0
  68. tests/test_crypto.py +36 -0
  69. tests/test_crypto_secure.py +26 -0
  70. tests/test_dcg.py +318 -0
  71. tests/test_dcg_golden.py +67 -0
  72. tests/test_egglog_bridge.py +41 -0
  73. tests/test_energy.py +34 -0
  74. tests/test_kernel_cli_handbook.py +101 -0
  75. tests/test_optimize.py +21 -0
  76. tests/test_plugins.py +64 -0
  77. tests/test_rules.py +43 -0
  78. tests/test_rules_commit.py +40 -0
  79. tests/test_rules_reversibility.py +50 -0
  80. tests/test_state_crdt.py +87 -0
  81. tests/test_tokenize.py +38 -0
  82. tests/test_tokenize_metrics.py +33 -0
gdk9/dcg.py ADDED
@@ -0,0 +1,356 @@
1
+ """Directed Cognition Graph (DCG) for GDk9.
2
+
3
+ Nodes: all 52 alphabetic characters (A–Z, a–z), each labelled with its
4
+ symmetry class, SymPhi rest-energy, and 4D vector.
5
+
6
+ Edge types and costs
7
+ --------------------
8
+ Uppercase ↔ lowercase DC ↔ AC form conversion cost 1 (→) / 2 (←)
9
+ Within-class all-pairs same symmetry class, same case cost 1
10
+ Cross-class cycle asym → biph → idemp → invol
11
+ → asym (both cases) cost 2
12
+
13
+ The cross-class cycle formalises the energy-flow circuit from the whitepaper.
14
+ F → E (asymmetric → biphasic) is the canonical DC→AC choice-point.
15
+
16
+ Energy model
17
+ ------------
18
+ SymPhi rest-energy uses positional algebra (not digital root):
19
+ idempotent E = pos (1–26)
20
+ biphasic E = sin(pos)
21
+ involutive E = 1 / pos
22
+ asymmetric E = pos + 1
23
+
24
+ 4D vector per character: [pos, type_id, √|E|, sin(pos·π/26)]
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import heapq
29
+ import math
30
+ from typing import Dict, List, Optional, Tuple
31
+
32
+ # ── Symmetry sets (whitepaper §2.2) ──────────────────────────────────────────
33
+
34
+ _IDEMP = frozenset('AHIMOTUVWXY') # idempotent: x²=x (11 letters)
35
+ _BIPH = frozenset('BCDEK') # biphasic: x²=f(x) (5)
36
+ _INVOL = frozenset('NSZ') # involutive: x²=1 (3)
37
+ _ASYM = frozenset('FGJLPQR') # asymmetric: x²≠x,1 (7)
38
+
39
+ _CLASS_UPPER: Dict[str, List[str]] = {
40
+ 'idempotent': sorted(_IDEMP),
41
+ 'biphasic': sorted(_BIPH),
42
+ 'involutive': sorted(_INVOL),
43
+ 'asymmetric': sorted(_ASYM),
44
+ }
45
+ _CLASS_LOWER: Dict[str, List[str]] = {
46
+ sc: [c.lower() for c in letters]
47
+ for sc, letters in _CLASS_UPPER.items()
48
+ }
49
+
50
+ _SYM_ID: Dict[str, int] = {
51
+ 'idempotent': 1,
52
+ 'biphasic': 2,
53
+ 'involutive': 3,
54
+ 'asymmetric': 4,
55
+ }
56
+
57
+ # ── Per-character functions ───────────────────────────────────────────────────
58
+
59
+ def symmetry_class(char: str) -> str:
60
+ """Return the symmetry class for any alphabetic character.
61
+
62
+ Classification is determined by the uppercase form, so 'f' and 'F'
63
+ both return 'asymmetric'. Non-alpha characters fall back to 'asymmetric'.
64
+ """
65
+ u = char.upper()
66
+ if u in _IDEMP: return 'idempotent'
67
+ if u in _BIPH: return 'biphasic'
68
+ if u in _INVOL: return 'involutive'
69
+ if u in _ASYM: return 'asymmetric'
70
+ return 'asymmetric'
71
+
72
+
73
+ def sym_energy(char: str) -> float:
74
+ """SymPhi rest-energy for a single alphabetic character.
75
+
76
+ Uses positional algebra rather than digital root so that energy values
77
+ reflect topological shape and continuous symmetry properties.
78
+ """
79
+ if not char.isalpha():
80
+ return 0.0
81
+ pos = ord(char.upper()) - ord('A') + 1 # 1–26
82
+ sc = symmetry_class(char)
83
+ if sc == 'idempotent': return float(pos)
84
+ if sc == 'biphasic': return math.sin(pos)
85
+ if sc == 'involutive': return 1.0 / pos
86
+ return float(pos + 1) # asymmetric
87
+
88
+
89
+ def vectorize(char: str) -> Tuple[float, float, float, float]:
90
+ """4D vector [pos, type_id, √|E|, sin(pos·π/26)] per the whitepaper."""
91
+ if not char.isalpha():
92
+ return (0.0, 0.0, 0.0, 0.0)
93
+ pos = ord(char.upper()) - ord('A') + 1
94
+ sc = symmetry_class(char)
95
+ tid = float(_SYM_ID.get(sc, 0))
96
+ e = sym_energy(char)
97
+ theta = pos * math.pi / 26.0
98
+ return (float(pos), tid, math.sqrt(abs(e)), math.sin(theta))
99
+
100
+
101
+ def word_sym_energy(word: str) -> float:
102
+ """Sum of SymPhi rest-energies for all alphabetic characters in *word*."""
103
+ return sum(sym_energy(c) for c in word if c.isalpha())
104
+
105
+
106
+ def word_vector(word: str) -> Tuple[float, float, float, float]:
107
+ """Sum of 4D character vectors for all alphabetic characters in *word*."""
108
+ v = [0.0, 0.0, 0.0, 0.0]
109
+ for c in word:
110
+ if c.isalpha():
111
+ cv = vectorize(c)
112
+ for i in range(4):
113
+ v[i] += cv[i]
114
+ return (v[0], v[1], v[2], v[3])
115
+
116
+
117
+ # ── Graph ─────────────────────────────────────────────────────────────────────
118
+
119
+ class DCG:
120
+ """Directed Cognition Graph over the 52-letter alphabet.
121
+
122
+ Internal representation: adjacency dict ``{src: {dst: weight}}``.
123
+ All public mutation happens through :func:`build_dcg` / :func:`get_dcg`.
124
+ """
125
+
126
+ __slots__ = ('_adj', '_nodes')
127
+
128
+ def __init__(self) -> None:
129
+ self._adj: Dict[str, Dict[str, float]] = {}
130
+ self._nodes: Dict[str, Dict] = {}
131
+
132
+ # ── Internal builders ─────────────────────────────────────────────────────
133
+
134
+ def _ensure_node(self, char: str) -> None:
135
+ if char not in self._nodes:
136
+ self._nodes[char] = {
137
+ 'class': symmetry_class(char),
138
+ 'energy': sym_energy(char),
139
+ 'vector': vectorize(char),
140
+ 'form': 'upper' if char.isupper() else 'lower',
141
+ }
142
+ self._adj[char] = {}
143
+
144
+ def _add_edge(self, src: str, dst: str, weight: float = 1.0) -> None:
145
+ self._ensure_node(src)
146
+ self._ensure_node(dst)
147
+ if weight < self._adj[src].get(dst, float('inf')):
148
+ self._adj[src][dst] = weight
149
+
150
+ # ── Inspection ────────────────────────────────────────────────────────────
151
+
152
+ def node_data(self, char: str) -> Dict:
153
+ """Return a copy of the metadata dict for *char*."""
154
+ return dict(self._nodes.get(char, {}))
155
+
156
+ def has_edge(self, src: str, dst: str) -> bool:
157
+ return dst in self._adj.get(src, {})
158
+
159
+ def edge_weight(self, src: str, dst: str) -> Optional[float]:
160
+ return self._adj.get(src, {}).get(dst)
161
+
162
+ def successors(self, node: str) -> List[str]:
163
+ return list(self._adj.get(node, {}).keys())
164
+
165
+ def predecessors(self, node: str) -> List[str]:
166
+ return [n for n, dsts in self._adj.items() if node in dsts]
167
+
168
+ def node_count(self) -> int:
169
+ return len(self._nodes)
170
+
171
+ def edge_count(self) -> int:
172
+ return sum(len(dsts) for dsts in self._adj.values())
173
+
174
+ def edge_count_by_type(self) -> Dict[str, int]:
175
+ """Count edges grouped by source-class → dest-class label."""
176
+ counts: Dict[str, int] = {}
177
+ for src, dsts in self._adj.items():
178
+ sc = self._nodes[src]['class']
179
+ sf = self._nodes[src]['form']
180
+ for dst in dsts:
181
+ dc = self._nodes[dst]['class']
182
+ df = self._nodes[dst]['form']
183
+ if sf != df:
184
+ label = 'dc_ac'
185
+ elif sc == dc:
186
+ label = f'within_{sc}'
187
+ else:
188
+ label = f'{sc}_to_{dc}'
189
+ counts[label] = counts.get(label, 0) + 1
190
+ return counts
191
+
192
+ # ── Algorithms ────────────────────────────────────────────────────────────
193
+
194
+ def shortest_path(self, src: str, dst: str) -> Optional[List[str]]:
195
+ """Dijkstra shortest path from *src* to *dst*.
196
+
197
+ Returns the ordered node list, or ``None`` if unreachable.
198
+ """
199
+ if src not in self._nodes or dst not in self._nodes:
200
+ return None
201
+ dist: Dict[str, float] = {n: float('inf') for n in self._nodes}
202
+ prev: Dict[str, Optional[str]] = {n: None for n in self._nodes}
203
+ dist[src] = 0.0
204
+ heap: List[Tuple[float, str]] = [(0.0, src)]
205
+ visited: set = set()
206
+
207
+ while heap:
208
+ d, u = heapq.heappop(heap)
209
+ if u in visited:
210
+ continue
211
+ visited.add(u)
212
+ if u == dst:
213
+ break
214
+ for v, w in self._adj.get(u, {}).items():
215
+ nd = d + w
216
+ if nd < dist[v]:
217
+ dist[v] = nd
218
+ prev[v] = u
219
+ heapq.heappush(heap, (nd, v))
220
+
221
+ if dist[dst] == float('inf'):
222
+ return None
223
+
224
+ path: List[str] = []
225
+ cur: Optional[str] = dst
226
+ while cur is not None:
227
+ path.append(cur)
228
+ cur = prev[cur]
229
+ path.reverse()
230
+ return path
231
+
232
+ def shortest_path_cost(self, src: str, dst: str) -> Optional[float]:
233
+ """Total cost of the shortest path, or ``None`` if unreachable."""
234
+ path = self.shortest_path(src, dst)
235
+ if path is None:
236
+ return None
237
+ cost = 0.0
238
+ for i in range(len(path) - 1):
239
+ cost += self._adj[path[i]][path[i + 1]]
240
+ return cost
241
+
242
+ def word_path_info(self, word: str) -> Dict:
243
+ """Classify each letter in *word* and check DCG-edge validity per step.
244
+
245
+ Steps for non-alpha characters are skipped so the path covers only
246
+ the meaningful symbolic content.
247
+ """
248
+ letters = [c for c in word if c.isalpha()]
249
+ steps = []
250
+ for i, ch in enumerate(letters):
251
+ nd = self.node_data(ch)
252
+ step: Dict = {
253
+ 'char': ch,
254
+ 'class': nd.get('class', '?'),
255
+ 'energy': round(nd.get('energy', 0.0), 6),
256
+ 'vector': [round(x, 6) for x in nd.get('vector', (0.0, 0.0, 0.0, 0.0))],
257
+ }
258
+ if i > 0:
259
+ prev_ch = letters[i - 1]
260
+ step['edge_from'] = prev_ch
261
+ step['valid_step'] = self.has_edge(prev_ch, ch)
262
+ step['edge_weight'] = self.edge_weight(prev_ch, ch)
263
+ steps.append(step)
264
+
265
+ all_valid = len(steps) <= 1 or all(s.get('valid_step', True) for s in steps)
266
+ return {
267
+ 'word': word,
268
+ 'steps': steps,
269
+ 'total_energy': round(word_sym_energy(word), 6),
270
+ 'vector_sum': [round(x, 6) for x in word_vector(word)],
271
+ 'is_valid_path': all_valid,
272
+ }
273
+
274
+
275
+ # ── Module-level lazy singleton ───────────────────────────────────────────────
276
+
277
+ _DCG_INSTANCE: Optional[DCG] = None
278
+
279
+
280
+ def get_dcg() -> DCG:
281
+ """Return the shared DCG instance, building it on first call."""
282
+ global _DCG_INSTANCE
283
+ if _DCG_INSTANCE is None:
284
+ _DCG_INSTANCE = _build_dcg()
285
+ return _DCG_INSTANCE
286
+
287
+
288
+ def _build_dcg() -> DCG:
289
+ """Construct the full 52-node DCG with all edge types."""
290
+ g = DCG()
291
+
292
+ upper_letters = [chr(i) for i in range(ord('A'), ord('Z') + 1)]
293
+
294
+ # 1. Uppercase ↔ lowercase (DC ↔ AC form conversion)
295
+ for u in upper_letters:
296
+ lo = u.lower()
297
+ g._add_edge(u, lo, 1.0) # DC → AC (forward, cheaper)
298
+ g._add_edge(lo, u, 2.0) # AC → DC (convergence, costlier)
299
+
300
+ # 2. Within-class all-pairs (same symmetry class, same case)
301
+ for case_dict in (_CLASS_UPPER, _CLASS_LOWER):
302
+ for letters in case_dict.values():
303
+ for src in letters:
304
+ for dst in letters:
305
+ if src != dst:
306
+ g._add_edge(src, dst, 1.0)
307
+
308
+ # 3. Cross-class directed cycle (energy-flow circuit)
309
+ # asym → biph → idemp → invol → asym (same-case only)
310
+ _cycle = [
311
+ ('asymmetric', 'biphasic'),
312
+ ('biphasic', 'idempotent'),
313
+ ('idempotent', 'involutive'),
314
+ ('involutive', 'asymmetric'),
315
+ ]
316
+ for case_dict in (_CLASS_UPPER, _CLASS_LOWER):
317
+ for src_class, dst_class in _cycle:
318
+ for src in case_dict[src_class]:
319
+ for dst in case_dict[dst_class]:
320
+ g._add_edge(src, dst, 2.0)
321
+
322
+ return g
323
+
324
+
325
+ # ── High-level helpers ────────────────────────────────────────────────────────
326
+
327
+ def homotopy_equivalent(
328
+ word_a: str,
329
+ word_b: str,
330
+ tol: float = 1.0,
331
+ ) -> Tuple[bool, Dict]:
332
+ """Check homotopy equivalence of two words.
333
+
334
+ Two words are homotopy-equivalent when their 4D vector sums are within
335
+ *tol* (Euclidean distance) and their total SymPhi energies differ by at
336
+ most *tol*. This implements the path-homotopy criterion
337
+ H(s,t) = (1−t)p₀(s) + t·p₁(s) from the whitepaper: if the interpolated
338
+ path stays within the tolerance ball, the words are equivalent.
339
+ """
340
+ ea = word_sym_energy(word_a)
341
+ eb = word_sym_energy(word_b)
342
+ va = word_vector(word_a)
343
+ vb = word_vector(word_b)
344
+
345
+ e_diff = abs(ea - eb)
346
+ v_dist = math.sqrt(sum((a - b) ** 2 for a, b in zip(va, vb)))
347
+ equiv = e_diff <= tol and v_dist <= tol
348
+
349
+ return equiv, {
350
+ 'word_a': {'energy': round(ea, 6), 'vector': [round(x, 6) for x in va]},
351
+ 'word_b': {'energy': round(eb, 6), 'vector': [round(x, 6) for x in vb]},
352
+ 'energy_diff': round(e_diff, 6),
353
+ 'vector_distance': round(v_dist, 6),
354
+ 'tol': tol,
355
+ 'equivalent': equiv,
356
+ }
@@ -0,0 +1,6 @@
1
+ """GDk9 ↔ egglog bridge: digital-root rewrite suite via egglog-python.
2
+
3
+ Optional dependency: pip install 'gdk9-cli[egglog]' (egglog>=13).
4
+ Two-phase saturation (sum fold, then DR subsume) — see docs/SPIKE-egglog.md.
5
+ """
6
+ __version__ = "0.1.0"
@@ -0,0 +1,107 @@
1
+ """Encode ONE GDk9 digital-root rewrite suite into egglog-python.
2
+
3
+ Suite (from gdk9.energy.digital_root + additive energy totals):
4
+ R1 Energy(a) + Energy(b) -> Energy(a + b) (const-fold sum)
5
+ R2 Energy(a) | a>0, a%9==0 -> Energy.root(9) (subsume)
6
+ R3 Energy(a) | a>0, a%9!=0 -> Energy.root(a%9) (subsume)
7
+ R4 Energy(0) -> Energy.root(9) (zero_to_nine)
8
+
9
+ Saturation is two-phase so unbounded integer growth cannot explode the e-graph
10
+ (equating DR-equivalent integers while still adding would diverge).
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import List, Sequence
15
+
16
+ from egglog import (
17
+ EGraph,
18
+ Expr,
19
+ eq,
20
+ i64,
21
+ i64Like,
22
+ ne,
23
+ rewrite,
24
+ ruleset,
25
+ vars_,
26
+ )
27
+
28
+
29
+ class Energy(Expr):
30
+ def __init__(self, value: i64Like) -> None: ...
31
+
32
+ def __add__(self, other: Energy) -> Energy: ...
33
+
34
+ @classmethod
35
+ def root(cls, value: i64Like) -> Energy:
36
+ """Collapsed digital-root form (preferred extract)."""
37
+ ...
38
+
39
+
40
+ def build_dr_rules():
41
+ a, b = vars_("a b", i64)
42
+ rs_sum = ruleset(
43
+ rewrite(Energy(a) + Energy(b)).to(Energy(a + b)),
44
+ )
45
+ rs_dr = ruleset(
46
+ rewrite(Energy(a), subsume=True).to(
47
+ Energy.root(9),
48
+ a > 0,
49
+ eq(a % 9).to(i64(0)),
50
+ ),
51
+ rewrite(Energy(a), subsume=True).to(
52
+ Energy.root(a % 9),
53
+ a > 0,
54
+ ne(a % 9).to(i64(0)),
55
+ ),
56
+ rewrite(Energy(0), subsume=True).to(Energy.root(9)),
57
+ )
58
+ return rs_sum, rs_dr
59
+
60
+
61
+ def _parse_root(extracted: object) -> int | None:
62
+ text = str(extracted)
63
+ # Energy.root(N)
64
+ if "root(" in text:
65
+ inner = text.split("root(", 1)[1].rstrip(")")
66
+ try:
67
+ return int(inner)
68
+ except ValueError:
69
+ return None
70
+ # Energy(N) fallback
71
+ if text.startswith("Energy(") and text.endswith(")"):
72
+ try:
73
+ return int(text[len("Energy(") : -1])
74
+ except ValueError:
75
+ return None
76
+ return None
77
+
78
+
79
+ def saturate_egglog(values: Sequence[int], sum_iters: int = 8, dr_iters: int = 8) -> dict:
80
+ vals = [int(v) for v in values]
81
+ if not vals:
82
+ raise ValueError("need at least one value")
83
+
84
+ egraph = EGraph()
85
+ rs_sum, rs_dr = build_dr_rules()
86
+
87
+ expr = Energy(vals[0])
88
+ for v in vals[1:]:
89
+ expr = expr + Energy(v)
90
+ named = egraph.let("fixture", expr)
91
+
92
+ before = egraph.extract(named)
93
+ egraph.run(rs_sum * sum_iters)
94
+ after_sum = egraph.extract(named)
95
+ egraph.run(rs_dr * dr_iters)
96
+ after_dr = egraph.extract(named)
97
+ best = _parse_root(after_dr)
98
+
99
+ return {
100
+ "engine": "egglog",
101
+ "inputs": vals,
102
+ "before": str(before),
103
+ "after_sum": str(after_sum),
104
+ "after_dr": str(after_dr),
105
+ "best_dr": best,
106
+ "ok": best is not None and 1 <= best <= 9,
107
+ }
@@ -0,0 +1,34 @@
1
+ """Legacy GDk9 digital-root rewriter (kept as fallback)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Iterable, List, Sequence, Tuple
5
+
6
+ from gdk9.energy import digital_root
7
+
8
+
9
+ def collapse_totals(values: Sequence[int], zero_to_nine: bool = True) -> Tuple[int, int]:
10
+ """Sum values then digital-root (classic GDk9 path)."""
11
+ total = sum(int(v) for v in values)
12
+ return total, digital_root(total, zero_to_nine=zero_to_nine)
13
+
14
+
15
+ def collapse_each_then_sum(values: Sequence[int], zero_to_nine: bool = True) -> Tuple[List[int], int]:
16
+ """DR each term, sum, DR again — congruence check path."""
17
+ folded = [digital_root(int(v), zero_to_nine=zero_to_nine) for v in values]
18
+ _total, dr = collapse_totals(folded, zero_to_nine=zero_to_nine)
19
+ return folded, dr
20
+
21
+
22
+ def saturate_fallback(values: Iterable[int]) -> dict:
23
+ vals = [int(v) for v in values]
24
+ total, dr_direct = collapse_totals(vals)
25
+ folded, dr_via = collapse_each_then_sum(vals)
26
+ return {
27
+ "engine": "gdk9.energy.digital_root (fallback)",
28
+ "inputs": vals,
29
+ "total": total,
30
+ "folded_terms": folded,
31
+ "best_dr": dr_direct,
32
+ "congruence_dr": dr_via,
33
+ "ok": dr_direct == dr_via,
34
+ }