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/state.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ import json
5
+ import math
6
+ import os
7
+ import socket
8
+ from pathlib import Path
9
+ from typing import Dict, Any, Optional, Tuple
10
+
11
+ from .crdt import merge_maps, stamp
12
+ from .errors import InputError
13
+
14
+
15
+ DEFAULT_STATE_PATH = os.path.expanduser("~/.gdk9/state.json")
16
+ LEGACY_ACTOR = "legacy"
17
+ _MISSING = object()
18
+
19
+
20
+ def _ensure_parent(path: Path) -> None:
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+
23
+
24
+ def load_state(path: Optional[str] = None) -> Dict[str, Any]:
25
+ p = Path(path or DEFAULT_STATE_PATH)
26
+ if not p.exists():
27
+ return {"symbols": {}, "rules": {}, "crdt": {"symbols": {}, "rules": {}}}
28
+ try:
29
+ state = json.loads(p.read_text(encoding="utf-8"))
30
+ return _ensure_state_shape(state)
31
+ except Exception as exc:
32
+ raise InputError(f"Failed to read state from {p}: {exc}")
33
+
34
+
35
+ def save_state(state: Dict[str, Any], path: Optional[str] = None) -> None:
36
+ p = Path(path or DEFAULT_STATE_PATH)
37
+ _ensure_parent(p)
38
+ tmp = p.with_suffix(p.suffix + ".tmp")
39
+ tmp.write_text(json.dumps(_ensure_state_shape(state), ensure_ascii=False, indent=2), encoding="utf-8")
40
+ tmp.replace(p)
41
+
42
+
43
+ def _ensure_state_shape(state: Dict[str, Any]) -> Dict[str, Any]:
44
+ state.setdefault("symbols", {})
45
+ state.setdefault("rules", {})
46
+ crdt = state.setdefault("crdt", {})
47
+ crdt.setdefault("symbols", {})
48
+ crdt.setdefault("rules", {})
49
+ for name in list(state.get("symbols", {}).keys()):
50
+ crdt["symbols"].setdefault(name, stamp(state["symbols"].get(name), LEGACY_ACTOR, 0.0))
51
+ for name in list(state.get("rules", {}).keys()):
52
+ crdt["rules"].setdefault(name, stamp(state["rules"].get(name), LEGACY_ACTOR, 0.0))
53
+ return state
54
+
55
+
56
+ def _default_actor() -> str:
57
+ return os.getenv("GDK9_ACTOR_ID") or socket.gethostname() or "anonymous"
58
+
59
+
60
+ def get_symbol(state: Dict[str, Any], name: str) -> Optional[float]:
61
+ return state.get("symbols", {}).get(name)
62
+
63
+
64
+ def set_symbol(state: Dict[str, Any], name: str, energy: float, actor: Optional[str] = None, timestamp: Optional[float] = None) -> None:
65
+ if not math.isfinite(energy):
66
+ raise InputError("Energy must be a finite float")
67
+ st = _ensure_state_shape(state)
68
+ st.setdefault("symbols", {})[name] = float(energy)
69
+ st.setdefault("crdt", {}).setdefault("symbols", {})[name] = stamp(float(energy), actor or _default_actor(), timestamp)
70
+
71
+
72
+ def list_symbols(state: Dict[str, Any]) -> Dict[str, float]:
73
+ return dict(sorted(state.get("symbols", {}).items()))
74
+
75
+
76
+ def set_rule(state: Dict[str, Any], name: str, data: Dict[str, Any], actor: Optional[str] = None, timestamp: Optional[float] = None) -> None:
77
+ st = _ensure_state_shape(state)
78
+ st.setdefault("rules", {})[name] = data
79
+ st.setdefault("crdt", {}).setdefault("rules", {})[name] = stamp(data, actor or _default_actor(), timestamp)
80
+
81
+
82
+ def merge_state(left: Dict[str, Any], right: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Dict[str, int]]]:
83
+ lstate = _ensure_state_shape(copy.deepcopy(left))
84
+ rstate = _ensure_state_shape(copy.deepcopy(right))
85
+ merged: Dict[str, Any] = {"symbols": {}, "rules": {}, "crdt": {"symbols": {}, "rules": {}}}
86
+ sym_meta, sym_stats, sym_winners = merge_maps(
87
+ lstate.get("crdt", {}).get("symbols", {}),
88
+ rstate.get("crdt", {}).get("symbols", {}),
89
+ LEGACY_ACTOR,
90
+ 0.0,
91
+ )
92
+ merged["crdt"]["symbols"] = sym_meta
93
+ for name in sym_meta.keys():
94
+ merged["symbols"][name] = _resolve_value(
95
+ name,
96
+ sym_winners.get(name),
97
+ sym_meta,
98
+ lstate.get("symbols", {}),
99
+ rstate.get("symbols", {}),
100
+ )
101
+ rule_meta, rule_stats, rule_winners = merge_maps(
102
+ lstate.get("crdt", {}).get("rules", {}),
103
+ rstate.get("crdt", {}).get("rules", {}),
104
+ LEGACY_ACTOR,
105
+ 0.0,
106
+ )
107
+ merged["crdt"]["rules"] = rule_meta
108
+ for name in rule_meta.keys():
109
+ merged["rules"][name] = _resolve_value(
110
+ name,
111
+ rule_winners.get(name),
112
+ rule_meta,
113
+ lstate.get("rules", {}),
114
+ rstate.get("rules", {}),
115
+ )
116
+ return merged, {"symbols": sym_stats, "rules": rule_stats}
117
+
118
+
119
+ def _resolve_value(
120
+ name: str,
121
+ winner: Optional[str],
122
+ meta: Dict[str, Dict[str, Any]],
123
+ left_data: Dict[str, Any],
124
+ right_data: Dict[str, Any],
125
+ ) -> Any:
126
+ """Resolve the value for a CRDT key using winner metadata and payload fallbacks."""
127
+ side = winner if winner in {"left", "right"} else ("left" if name in left_data else "right")
128
+ primary = left_data if side == "left" else right_data
129
+ secondary = right_data if side == "left" else left_data
130
+ value = primary.get(name, _MISSING)
131
+ if value is _MISSING:
132
+ meta_value = meta.get(name, {}).get("value", _MISSING)
133
+ if meta_value is not _MISSING:
134
+ value = meta_value
135
+ if value is _MISSING:
136
+ value = secondary.get(name, _MISSING)
137
+ return None if value is _MISSING else value
138
+
gdk9/subs.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Dict, List
5
+
6
+ from .principles import Principle
7
+ from .energy import char_energy, digital_root
8
+
9
+
10
+ ALL_LETTERS = [chr(c) for c in range(ord('A'), ord('Z') + 1)] + [chr(c) for c in range(ord('a'), ord('z') + 1)]
11
+ ALL_DIGITS = [str(i) for i in range(0, 10)]
12
+
13
+
14
+ def _build_energy_bins(pr: Principle) -> Dict[int, List[str]]:
15
+ bins: Dict[int, List[str]] = {i: [] for i in range(1, 10)}
16
+ for sym in pr.symbol_energy.keys():
17
+ e = char_energy(sym, pr)
18
+ bins[e].append(sym)
19
+ # Ensure some diversity by sorting
20
+ for k in bins:
21
+ bins[k] = sorted(set(bins[k]))
22
+ return bins
23
+
24
+
25
+ def generate_subs(principle: Principle, limit: int = 3, include_digits: bool = True) -> Dict[str, List[str]]:
26
+ subs: Dict[str, List[str]] = {}
27
+ bins = _build_energy_bins(principle)
28
+ # helper to choose candidates by same energy then triad neighbors
29
+ def candidates_for_energy(e: int) -> List[str]:
30
+ same = list(bins.get(e, []))
31
+ if len(same) >= limit:
32
+ return same[:limit]
33
+ # triad groups
34
+ r = e % 9
35
+ triad = []
36
+ if r in (1, 4, 7):
37
+ triad = [4, 7, 1]
38
+ elif r in (2, 5, 8):
39
+ triad = [5, 8, 2]
40
+ else:
41
+ triad = [6, 9, 3]
42
+ out = same
43
+ for t in triad:
44
+ for s in bins.get(t, []):
45
+ if s not in out:
46
+ out.append(s)
47
+ if len(out) >= limit:
48
+ return out[:limit]
49
+ return out[:limit]
50
+
51
+ # Build for letters
52
+ for ch in ALL_LETTERS:
53
+ e = char_energy(ch, principle)
54
+ cands = candidates_for_energy(e)
55
+ if include_digits:
56
+ # add digits that match energy
57
+ for d in ALL_DIGITS:
58
+ if char_energy(d, principle) == e and d not in cands:
59
+ cands.append(d)
60
+ if len(cands) >= limit:
61
+ break
62
+ if cands:
63
+ subs[ch] = cands[:limit]
64
+
65
+ # Build for digits
66
+ for ch in ALL_DIGITS:
67
+ e = char_energy(ch, principle)
68
+ cands = candidates_for_energy(e)
69
+ if cands:
70
+ subs[ch] = cands[:limit]
71
+
72
+ # Include common symbols mapping to alternatives (neighbors in bins)
73
+ for e, sym_list in bins.items():
74
+ for ch in sym_list:
75
+ # pick neighbors by rotating list to add variety
76
+ alts = [s for s in sym_list if s != ch][:limit]
77
+ if alts:
78
+ subs[ch] = alts
79
+
80
+ return subs
81
+
82
+
83
+ def generate_allowed_inserts(principle: Principle) -> str:
84
+ # Cover all residues 0..8 using available symbols
85
+ bins = _build_energy_bins(principle)
86
+ chosen: List[str] = []
87
+ seen_residues = set()
88
+ # Map energy to residue (9 -> 0)
89
+ for e in range(1, 10):
90
+ res = e % 9
91
+ if res in seen_residues:
92
+ continue
93
+ syms = bins.get(e, [])
94
+ if syms:
95
+ chosen.append(syms[0])
96
+ seen_residues.add(res)
97
+ if len(seen_residues) == 9:
98
+ break
99
+ return "".join(chosen)
100
+
101
+
102
+ def build_subs_json(principle: Principle, limit: int = 3) -> str:
103
+ data = {
104
+ "subs": generate_subs(principle, limit=limit),
105
+ "allowed_inserts": generate_allowed_inserts(principle),
106
+ }
107
+ return json.dumps(data, ensure_ascii=False, indent=2)
108
+
gdk9/tokenize.py ADDED
@@ -0,0 +1,386 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from dataclasses import dataclass
6
+ from typing import Dict, Iterable, List, Tuple
7
+
8
+ from .ansi import colorize
9
+ from .energy import string_energy, char_energy
10
+ from .principles import Principle
11
+
12
+
13
+ def delimiter_set(principle: Principle, energy: int = 1, extra: str | None = None) -> str:
14
+ """Return a string of delimiter characters whose symbol energy equals `energy`.
15
+
16
+ - Includes any characters in `extra`.
17
+ - Sorted for stable UI.
18
+ """
19
+ ds = {s for s, v in principle.symbol_energy.items() if int(v) == int(energy)}
20
+ if extra:
21
+ for ch in extra:
22
+ ds.add(ch)
23
+ return "".join(sorted(ds))
24
+
25
+
26
+ @dataclass
27
+ class Token:
28
+ kind: str # 'token' or 'delim'
29
+ text: str
30
+ total: int
31
+ dr: int
32
+ letters: int = 0
33
+ digits: int = 0
34
+ symbols: int = 0
35
+ e_letters: int = 0
36
+ e_digits: int = 0
37
+ e_symbols: int = 0
38
+ dominant: str = "mixed" # one of: letters, digits, symbols, mixed
39
+ r_letters: float = 0.0
40
+ r_digits: float = 0.0
41
+ r_symbols: float = 0.0
42
+
43
+
44
+ def _compile_pattern(delims: str) -> re.Pattern[str]:
45
+ if not delims:
46
+ # No explicit delimiters -> treat whole text as one token
47
+ return re.compile(r".+", flags=re.DOTALL)
48
+ cls = re.escape(delims)
49
+ # Keep groups of non-delims or groups of delimiters as separate tokens
50
+ return re.compile(rf"[^{cls}]+|[{cls}]+")
51
+
52
+
53
+ def tokenize(
54
+ text: str,
55
+ principle: Principle,
56
+ *,
57
+ energy: int = 1,
58
+ delims: str | None = None,
59
+ keep_delims: bool = True,
60
+ strip_tokens: bool = True,
61
+ ) -> List[str]:
62
+ """Split `text` using delimiters derived from principle's symbol energy.
63
+
64
+ - If `delims` is provided, it overrides derived delimiters.
65
+ - If `keep_delims` is True, delimiter runs are emitted as standalone tokens.
66
+ - If `strip_tokens` is True, non-delim tokens are stripped and empties dropped.
67
+ """
68
+ ds = delims if delims is not None else delimiter_set(principle, energy=energy)
69
+ pat = _compile_pattern(ds)
70
+ raw = pat.findall(text)
71
+ out: List[str] = []
72
+ for piece in raw:
73
+ is_delim = all(ch in ds for ch in piece)
74
+ if is_delim and not keep_delims:
75
+ continue
76
+ if not is_delim and strip_tokens:
77
+ piece = piece.strip()
78
+ if not piece:
79
+ continue
80
+ out.append(piece)
81
+ return out
82
+
83
+
84
+ def _classify(piece: str, principle: Principle) -> Tuple[int, int, int, int, int, int]:
85
+ l = d = s = 0
86
+ el = ed = es = 0
87
+ for ch in piece:
88
+ if ch.isspace():
89
+ continue
90
+ if ch.isalpha():
91
+ l += 1
92
+ el += char_energy(ch, principle)
93
+ elif ch.isdigit():
94
+ d += 1
95
+ ed += char_energy(ch, principle)
96
+ else:
97
+ s += 1
98
+ es += char_energy(ch, principle)
99
+ return l, d, s, el, ed, es
100
+
101
+
102
+ def tokens_with_energy(
103
+ text: str,
104
+ principle: Principle,
105
+ *,
106
+ energy: int = 1,
107
+ delims: str | None = None,
108
+ keep_delims: bool = True,
109
+ strip_tokens: bool = True,
110
+ ) -> List[Token]:
111
+ """Tokenize and annotate each token with (total, dr)."""
112
+ ds = delims if delims is not None else delimiter_set(principle, energy=energy)
113
+ pat = _compile_pattern(ds)
114
+ toks: List[Token] = []
115
+ for piece in pat.findall(text):
116
+ is_delim = all(ch in ds for ch in piece)
117
+ if is_delim and not keep_delims:
118
+ continue
119
+ if not is_delim and strip_tokens:
120
+ piece = piece.strip()
121
+ if not piece:
122
+ continue
123
+ total, dr = string_energy(piece, principle)
124
+ letters, digits, symbols, e_letters, e_digits, e_symbols = _classify(piece, principle)
125
+ # Dominant class by energy, then by count
126
+ energy_triple = [(e_letters, 'letters'), (e_digits, 'digits'), (e_symbols, 'symbols')]
127
+ max_e = max(e for e, _ in energy_triple)
128
+ winners = [name for e, name in energy_triple if e == max_e]
129
+ if len(winners) == 1 and max_e > 0:
130
+ dominant = winners[0]
131
+ else:
132
+ count_triple = [(letters, 'letters'), (digits, 'digits'), (symbols, 'symbols')]
133
+ max_c = max(c for c, _ in count_triple)
134
+ winners_c = [name for c, name in count_triple if c == max_c and c > 0]
135
+ dominant = winners_c[0] if len(winners_c) == 1 else 'mixed'
136
+ # Ratios by count
137
+ denom = max(1, letters + digits + symbols)
138
+ r_letters = letters / denom
139
+ r_digits = digits / denom
140
+ r_symbols = symbols / denom
141
+ toks.append(Token(
142
+ "delim" if is_delim else "token",
143
+ piece,
144
+ total,
145
+ dr,
146
+ letters=letters,
147
+ digits=digits,
148
+ symbols=symbols,
149
+ e_letters=e_letters,
150
+ e_digits=e_digits,
151
+ e_symbols=e_symbols,
152
+ dominant=dominant,
153
+ r_letters=r_letters,
154
+ r_digits=r_digits,
155
+ r_symbols=r_symbols,
156
+ ))
157
+ return toks
158
+
159
+
160
+ def render_table(rows: List[List[str]], use_color: bool = False) -> str:
161
+ """Render a simple table string without printing (for UX-friendly CLI)."""
162
+ widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
163
+ out_lines: List[str] = []
164
+ header = rows[0]
165
+ out_lines.append(" ".join(val.ljust(widths[i]) for i, val in enumerate(header)))
166
+ for r in rows[1:]:
167
+ out_lines.append(" ".join(r[i].ljust(widths[i]) for i in range(len(widths))))
168
+ return "\n".join(out_lines)
169
+
170
+
171
+ def summarize_tokens_table(
172
+ text: str,
173
+ principle: Principle,
174
+ *,
175
+ energy: int = 1,
176
+ delims: str | None = None,
177
+ keep_delims: bool = True,
178
+ strip_tokens: bool = True,
179
+ use_color: bool = False,
180
+ ) -> List[List[str]]:
181
+ toks = tokens_with_energy(
182
+ text,
183
+ principle,
184
+ energy=energy,
185
+ delims=delims,
186
+ keep_delims=keep_delims,
187
+ strip_tokens=strip_tokens,
188
+ )
189
+ rows: List[List[str]] = [["#", "kind", "token", "total", "dr"]]
190
+ for i, t in enumerate(toks):
191
+ drs = str(t.dr)
192
+ # Gentle color cue on dr for UX readability
193
+ color_map = {"1": "blue", "2": "blue", "3": "cyan", "4": "green", "5": "yellow", "6": "magenta", "7": "red", "8": "red", "9": "bold"}
194
+ rows.append([
195
+ str(i),
196
+ t.kind,
197
+ t.text if len(t.text) <= 40 else (t.text[:37] + "…"),
198
+ str(t.total),
199
+ colorize(drs, color_map.get(drs, None), use_color),
200
+ ])
201
+ return rows
202
+
203
+
204
+ def annotate_text(
205
+ text: str,
206
+ principle: Principle,
207
+ *,
208
+ energy: int = 1,
209
+ delims: str | None = None,
210
+ keep_delims: bool = True,
211
+ strip_tokens: bool = True,
212
+ gap: str = " ",
213
+ ) -> str:
214
+ """Return a human-friendly annotated string: tokens separated for visibility.
215
+
216
+ Delimiters are preserved inline; tokens are joined by `gap`.
217
+ """
218
+ toks = tokens_with_energy(
219
+ text,
220
+ principle,
221
+ energy=energy,
222
+ delims=delims,
223
+ keep_delims=keep_delims,
224
+ strip_tokens=strip_tokens,
225
+ )
226
+ return gap.join(t.text for t in toks)
227
+
228
+
229
+ def to_json_payload(
230
+ text: str,
231
+ principle: Principle,
232
+ *,
233
+ energy: int = 1,
234
+ delims: str | None = None,
235
+ keep_delims: bool = True,
236
+ strip_tokens: bool = True,
237
+ ) -> str:
238
+ toks = tokens_with_energy(
239
+ text,
240
+ principle,
241
+ energy=energy,
242
+ delims=delims,
243
+ keep_delims=keep_delims,
244
+ strip_tokens=strip_tokens,
245
+ )
246
+ metrics = compute_metrics(text, toks, principle, delims if delims is not None else delimiter_set(principle, energy=energy))
247
+ return json.dumps(
248
+ {
249
+ "delims": delims if delims is not None else delimiter_set(principle, energy=energy),
250
+ "tokens": [t.__dict__ for t in toks],
251
+ "metrics": metrics,
252
+ },
253
+ ensure_ascii=False,
254
+ indent=2,
255
+ )
256
+
257
+
258
+ def compute_metrics(text: str, toks: List[Token], principle: Principle, dset: str) -> Dict[str, object]:
259
+ """Compute rich token/energy metrics for UX and downstream analysis."""
260
+ # Partition
261
+ content = [t for t in toks if t.kind == 'token']
262
+ delim = [t for t in toks if t.kind == 'delim']
263
+ # Counts
264
+ counts = {
265
+ "total_tokens": len(toks),
266
+ "content_tokens": len(content),
267
+ "delimiter_tokens": len(delim),
268
+ }
269
+ # Energies (sum of totals) and document-level
270
+ from .energy import string_energy
271
+ doc_total, doc_dr = string_energy(text, principle)
272
+ sums = {
273
+ "content_total": int(sum(t.total for t in content)),
274
+ "delimiter_total": int(sum(t.total for t in delim)),
275
+ "document_total": int(doc_total),
276
+ "document_dr": int(doc_dr),
277
+ }
278
+ # DR histogram for content and delimiter tokens
279
+ def hist(items: List[Token]) -> Dict[str, int]:
280
+ h: Dict[str, int] = {str(i): 0 for i in range(1, 10)}
281
+ for t in items:
282
+ k = str(9 if t.dr == 0 else t.dr)
283
+ h[k] += 1
284
+ return h
285
+ histograms = {
286
+ "content_dr": hist(content),
287
+ "delimiter_dr": hist(delim),
288
+ }
289
+ # Length stats for content tokens
290
+ lengths = [len(t.text) for t in content]
291
+ if lengths:
292
+ avg_len = sum(lengths) / len(lengths)
293
+ min_len = min(lengths)
294
+ max_len = max(lengths)
295
+ else:
296
+ avg_len = 0.0
297
+ min_len = 0
298
+ max_len = 0
299
+ length_stats = {"avg": avg_len, "min": min_len, "max": max_len}
300
+ # Top tokens by energy (content only)
301
+ top_content = sorted(content, key=lambda t: (t.total, t.dr, len(t.text)), reverse=True)[:5]
302
+ top_tokens = [{"text": t.text, "total": t.total, "dr": t.dr} for t in top_content]
303
+ # Delimiter character counts
304
+ delim_counts: Dict[str, int] = {}
305
+ for t in delim:
306
+ for ch in t.text:
307
+ delim_counts[ch] = delim_counts.get(ch, 0) + 1
308
+ top_delims = sorted(delim_counts.items(), key=lambda kv: kv[1], reverse=True)[:8]
309
+ # Class aggregates (content and delimiters)
310
+ def class_aggr(items: List[Token]) -> Dict[str, int]:
311
+ return {
312
+ "letters": int(sum(t.letters for t in items)),
313
+ "digits": int(sum(t.digits for t in items)),
314
+ "symbols": int(sum(t.symbols for t in items)),
315
+ }
316
+ def class_energy_aggr(items: List[Token]) -> Dict[str, int]:
317
+ return {
318
+ "letters": int(sum(t.e_letters for t in items)),
319
+ "digits": int(sum(t.e_digits for t in items)),
320
+ "symbols": int(sum(t.e_symbols for t in items)),
321
+ }
322
+ classes = {
323
+ "content": {
324
+ "counts": class_aggr(content),
325
+ "energy": class_energy_aggr(content),
326
+ },
327
+ "delimiters": {
328
+ "counts": class_aggr(delim),
329
+ "energy": class_energy_aggr(delim),
330
+ }
331
+ }
332
+ return {
333
+ "counts": counts,
334
+ "sums": sums,
335
+ "histograms": histograms,
336
+ "lengths": length_stats,
337
+ "top_tokens": top_tokens,
338
+ "delimiters": {
339
+ "set": dset,
340
+ "char_counts": dict(top_delims),
341
+ },
342
+ "classes": classes,
343
+ }
344
+
345
+
346
+ def summarize_metrics_lines(text: str, toks: List[Token], principle: Principle, dset: str, use_color: bool = False) -> List[str]:
347
+ """Return human-readable summary lines for CLI footer."""
348
+ from .ansi import colorize as _c
349
+ def _kv(k: str, v: str) -> str:
350
+ return _c(k, "dim", use_color) + "=" + _c(v, "bright_white", use_color)
351
+ def _dr(n: int) -> str:
352
+ _dr_col = {1:"blue",2:"blue",3:"cyan",4:"green",5:"yellow",6:"magenta",7:"red",8:"bright_red",9:"bright_white"}
353
+ return _c(str(n), _dr_col.get(n), use_color)
354
+
355
+ metrics = compute_metrics(text, toks, principle, dset)
356
+ out: List[str] = []
357
+ c = metrics["counts"] # type: ignore[index]
358
+ out.append(" " + " ".join([
359
+ _kv("tokens", str(c["total_tokens"])),
360
+ _kv("content", str(c["content_tokens"])),
361
+ _kv("delims", str(c["delimiter_tokens"])),
362
+ ]))
363
+ s = metrics["sums"] # type: ignore[index]
364
+ out.append(" " + " ".join([
365
+ _kv("doc_total", str(s["document_total"])),
366
+ _kv("doc_dr", _dr(s["document_dr"])),
367
+ _kv("content∑", str(s["content_total"])),
368
+ _kv("delim∑", str(s["delimiter_total"])),
369
+ ]))
370
+ h = metrics["histograms"]["content_dr"] # type: ignore[index]
371
+ parts = [_dr(k) + _c(":", "dim", use_color) + _c(str(h[str(k)]), "white", use_color)
372
+ for k in range(1, 10)]
373
+ out.append(" " + _c("dr ", "dim", use_color) + " ".join(parts))
374
+ tops = metrics["top_tokens"] # type: ignore[index]
375
+ if tops:
376
+ top_parts = [_c(t["text"], "bold", use_color) + _c(f"[{t['total']}/{t['dr']}]", "dim", use_color)
377
+ for t in tops]
378
+ out.append(" " + _c("top ", "dim", use_color) + _c(" ", "dim", use_color).join(top_parts))
379
+ cl = metrics["classes"]["content"]["energy"] # type: ignore[index]
380
+ out.append(" " + " ".join([
381
+ _kv("letters", _c(str(cl["letters"]), "cyan", use_color)),
382
+ _kv("digits", _c(str(cl["digits"]), "yellow", use_color)),
383
+ _kv("symbols", _c(str(cl["symbols"]), "magenta", use_color)),
384
+ ]))
385
+ out.append(" " + _kv("delims", _c(repr(dset), "dim", use_color)))
386
+ return out