File size: 6,631 Bytes
f76c374
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
#!/usr/bin/env python3
"""Summarize micro-compaction telemetry from Hermes logs.

Reads the content-free JSON lines emitted by
``ContextCompressor._emit_micro_compaction_telemetry`` and reports what the
feature actually bought you.

Usage:
  python scripts/micro_compaction_report.py [LOGFILE ...]
  python scripts/micro_compaction_report.py --per-session

With no LOGFILE, reads ``$HERMES_HOME/logs/agent.log`` (default ~/.hermes).

What to look at
---------------
The point of micro-compaction is not saving tokens or time. It is:

  (a) amortizing the one long batch-compaction pause across many turns, and
  (b) keeping the context window low enough that a session runs much further
      before it needs a hard compaction at all.

So the headline numbers here are OCCUPANCY (how full the window is kept, as a
percentage of the compaction threshold) and BATCH COMPACTIONS (how often the
long pause actually fired). Net tokens saved is reported too, but it is the
least interesting figure -- a session can save nothing on paper and still be a
clear win because the stalls disappeared and the window never filled.

Caveat: running the test suite writes telemetry into the same log. Test lines
cluster inside a sub-second window and carry an empty session_id (they group
as "(unknown)"). Use --per-session to spot them.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
from collections import defaultdict
from pathlib import Path

MICRO_MARKER = "micro compaction telemetry: "
BATCH_MARKER = "context compression attempt telemetry: "


def default_log() -> Path:
    home = os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")
    return Path(home) / "logs" / "agent.log"


def load(paths: list[Path]) -> tuple[list[dict], list[dict]]:
    micro: list[dict] = []
    batch: list[dict] = []
    for path in paths:
        try:
            text = path.read_text(encoding="utf-8", errors="replace")
        except OSError as exc:
            print(f"warning: cannot read {path}: {exc}", file=sys.stderr)
            continue
        for line in text.splitlines():
            for marker, sink in ((MICRO_MARKER, micro), (BATCH_MARKER, batch)):
                idx = line.find(marker)
                if idx == -1:
                    continue
                try:
                    sink.append(json.loads(line[idx + len(marker):]))
                except ValueError:
                    pass
                break
    return micro, batch


def pct(values: list[float]) -> tuple[float, float, float] | None:
    if not values:
        return None
    ordered = sorted(values)
    return ordered[0], ordered[len(ordered) // 2], ordered[-1]


def fmt(n) -> str:
    return "-" if n is None else f"{n:,}"


def report(micro: list[dict], batch: list[dict], per_session: bool) -> int:
    if not micro:
        print("No micro-compaction telemetry found.")
        print("It may be disabled (compression.micro_compact), or no session")
        print("has run long enough to trigger a pass yet.")
        return 1

    by_session: dict[str, list[dict]] = defaultdict(list)
    for e in micro:
        by_session[e.get("session_id") or "(unknown)"].append(e)

    outcomes: dict[str, int] = defaultdict(int)
    for e in micro:
        outcomes[e.get("outcome", "?")] += 1

    occupancies = [e["occupancy_pct"] for e in micro if e.get("occupancy_pct") is not None]
    saved = sum(-(e.get("tokens_delta") or 0) for e in micro)
    absorbed = [e for e in micro if e.get("outcome") == "absorbed"]
    durations = [e.get("duration_ms") or 0 for e in micro]

    if per_session:
        print(f"{'session':<26} {'passes':>6} {'occupancy%':>18} {'batch':>6} {'saved':>10}")
        print("-" * 72)
        batch_by_session: dict[str, int] = defaultdict(int)
        for b in batch:
            batch_by_session[b.get("session_id") or "(unknown)"] += 1
        for sid, evs in sorted(by_session.items(), key=lambda kv: -len(kv[1])):
            occ = [e["occupancy_pct"] for e in evs if e.get("occupancy_pct") is not None]
            spread = pct(occ)
            occ_s = f"{spread[0]:.0f}-{spread[2]:.0f} (med {spread[1]:.0f})" if spread else "-"
            s = sum(-(e.get("tokens_delta") or 0) for e in evs)
            print(f"{sid[:26]:<26} {len(evs):>6} {occ_s:>18} "
                  f"{batch_by_session.get(sid, 0):>6} {s:>+10,}")
        print()

    print("-- headroom ----------------------------------")
    spread = pct(occupancies)
    if spread:
        print(f"context occupancy       min {spread[0]:.0f}%  median {spread[1]:.0f}%  max {spread[2]:.0f}%")
        print("                        (% of the batch-compaction threshold)")
    else:
        print("context occupancy       unavailable (window not resolved when logged)")
    print(f"batch compactions       {len(batch):,}")
    if batch:
        print(f"  micro passes each     {len(micro) / len(batch):.1f}")
    else:
        print("  none fired -- the long pause never happened in this log")

    print()
    print("-- activity ----------------------------------")
    print(f"sessions                {len(by_session):,}")
    print(f"passes                  {len(micro):,}")
    for name, count in sorted(outcomes.items(), key=lambda kv: -kv[1]):
        print(f"  {name:<20}  {count:,}")
    if durations:
        ordered = sorted(durations)
        print(f"pass duration           median {ordered[len(ordered) // 2]:,} ms  "
              f"max {ordered[-1]:,} ms")

    print()
    print("-- tokens (least interesting) ----------------")
    print(f"net tokens saved        {saved:+,}")
    if absorbed:
        sizes = [e.get("exchange_tokens") or 0 for e in absorbed]
        print(f"exchanges absorbed      {len(absorbed):,}  "
              f"(mean {sum(sizes) // len(absorbed):,} tokens each)")
    print("note: the first pass in a session costs ~400 tokens of marker")
    print("scaffolding; it pays back from the second pass on.")
    return 0


def main() -> int:
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("logs", nargs="*", type=Path, help="log files (default: agent.log)")
    ap.add_argument("--per-session", action="store_true", help="break down by session")
    args = ap.parse_args()

    paths = args.logs or [default_log()]
    for p in paths:
        if not p.exists():
            print(f"warning: {p} does not exist", file=sys.stderr)
    micro, batch = load([p for p in paths if p.exists()])
    return report(micro, batch, args.per_session)


if __name__ == "__main__":
    sys.exit(main())