evals/postmortem/ turns the one-off audit behind tracking issue #103563 into something anyone with a Hermes state.db copy (and optionally rotated agent.log*) can run on their own fan-out: forensics/ common.py discovers the run tree (root = most descendants, compression-rollover children excluded so cost buckets stay disjoint), fits pricing from estimated_cost_usd, and five lanes recompute the OBSERVED figures: tokens (buckets, depth/duration shares, context reconstruction, excess-cache-write proxy, cap replay), logcalls (per-call cache behaviour from agent.log with coverage printed first; strict and loose plateau definitions reported separately), delegation (timeouts, orphaned children, polling hours, batch-join withheld child-hours, truncated summaries), tools (hardline blocks, foreground refusals, whole-file rewrites), goal_loop (nudges, parked barrier), rework (public-surface drop at PR open + post-open commit inventory). Every figure is labeled OBSERVED or MODELED. live_ab/ the per-PR A/Bs (real code paths, fake providers, temp HERMES_HOME), paths from argv. review_probes/ the independent /review's probes, credited and adapted; each reproduced a round-1 defect and the fixed head must pass it. run.py runs the offline probes against one or two checkouts and prints PASS/FAIL side by side (--live adds the ones that spend cents). tests/ synthetic-DB smoke test for the lanes and runner. On the run's DB the lanes reproduce the tracking issue's population exactly (1,394 sessions, 93,284 calls, $19,302.59; cache_write $11,159.76) and on main vs an integration checkout of the 13 PRs the runner shows every probe FAIL -> PASS (two guard-only probes pass on both, noted in run.py). The trajectories are deliberately not shipped: the DB holds 51,956 home paths, 5,341 e-mails, private IPs, chat ids and real-shaped credentials in tool output. The lane reports and recomputed JSON are in a secret gist linked from #103563.
187 lines
9.1 KiB
Python
187 lines
9.1 KiB
Python
"""Shared loaders for the post-mortem forensics: point at ANY Hermes ``state.db`` (a copy, never the live
|
|
file) and get the run tree, the in-run session set, fitted pricing and message iterators.
|
|
|
|
Nothing here knows about a particular run. The root is discovered as the session with the most
|
|
descendants unless ``--root`` is given; compression-rollover children (a child whose ``id`` the parent's
|
|
``compaction`` metadata names as its continuation) are excluded from the tree so cost populations stay
|
|
disjoint. Pricing is fitted by least squares from ``sessions`` usage columns to ``estimated_cost_usd``, so
|
|
the recomputed dollars match what THAT Hermes recorded, not an invoice.
|
|
|
|
Usage from a lane script::
|
|
|
|
from evals.postmortem.forensics.common import Run
|
|
run = Run.from_args() # --db, --root, --out
|
|
for sid in run.in_run: # ordered session ids
|
|
...
|
|
run.write("q1_cost.json", data)
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import collections
|
|
import json
|
|
import sqlite3
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from typing import Any, Dict, Iterable, Iterator, List, Optional
|
|
|
|
USAGE_COLS = ("input_tokens", "cache_read_tokens", "cache_write_tokens", "output_tokens")
|
|
|
|
|
|
def _lstsq(rows: List[List[float]], y: List[float]) -> List[float]:
|
|
"""Ordinary least squares without numpy (4 unknowns): normal equations solved by Gaussian elimination."""
|
|
n = len(rows[0])
|
|
ata = [[sum(r[i] * r[j] for r in rows) for j in range(n)] for i in range(n)]
|
|
atb = [sum(r[i] * yy for r, yy in zip(rows, y)) for i in range(n)]
|
|
m = [row[:] + [b] for row, b in zip(ata, atb)]
|
|
for c in range(n):
|
|
piv = max(range(c, n), key=lambda r: abs(m[r][c]))
|
|
m[c], m[piv] = m[piv], m[c]
|
|
if abs(m[c][c]) < 1e-12:
|
|
continue
|
|
for r in range(n):
|
|
if r != c:
|
|
f = m[r][c] / m[c][c]
|
|
m[r] = [a - f * b for a, b in zip(m[r], m[c])]
|
|
return [m[i][n] / m[i][i] if abs(m[i][i]) > 1e-12 else 0.0 for i in range(n)]
|
|
|
|
|
|
@dataclass
|
|
class Run:
|
|
db_path: Path
|
|
out_dir: Path
|
|
root: str
|
|
sessions: Dict[str, Dict[str, Any]]
|
|
depth: Dict[str, int]
|
|
in_run: List[str] # root + descendants, rollover excluded, dispatch order
|
|
price_per_token: Dict[str, float] # fitted: USD per token for each USAGE_COLS entry
|
|
_conn: sqlite3.Connection = field(repr=False)
|
|
|
|
# ── construction ──────────────────────────────────────────────────────────────────────────
|
|
@classmethod
|
|
def parser(cls, description: str = "") -> argparse.ArgumentParser:
|
|
ap = argparse.ArgumentParser(description=description)
|
|
ap.add_argument("--db", required=True, help="path to a COPY of ~/.hermes/state.db")
|
|
ap.add_argument("--root", default=None, help="root session id (default: the session with the most descendants)")
|
|
ap.add_argument("--out", default="postmortem_out", help="directory for JSON/markdown outputs")
|
|
return ap
|
|
|
|
@classmethod
|
|
def from_args(cls, argv: Optional[List[str]] = None, description: str = "") -> "Run":
|
|
a = cls.parser(description).parse_args(argv)
|
|
return cls.open(a.db, root=a.root, out=a.out)
|
|
|
|
@classmethod
|
|
def open(cls, db: str, *, root: Optional[str] = None, out: str = "postmortem_out") -> "Run": # noqa: C901
|
|
conn = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
|
|
conn.row_factory = sqlite3.Row
|
|
sessions = {r["id"]: dict(r) for r in conn.execute("SELECT * FROM sessions")}
|
|
children: Dict[Optional[str], List[str]] = collections.defaultdict(list)
|
|
for sid, s in sessions.items():
|
|
children[s.get("parent_session_id")].append(sid)
|
|
rollover = cls._rollover_ids(conn, sessions)
|
|
if root is None:
|
|
def size(sid: str) -> int:
|
|
n, stack = 0, [sid]
|
|
while stack:
|
|
cur = stack.pop()
|
|
for c in children[cur]:
|
|
if c not in rollover:
|
|
n += 1; stack.append(c)
|
|
return n
|
|
root = str(max((sid for sid in sessions if sessions[sid].get("parent_session_id") is None), key=size))
|
|
depth: Dict[str, int] = {}
|
|
order: List[str] = []
|
|
stack = [(root, 0)]
|
|
while stack:
|
|
sid, d = stack.pop()
|
|
depth[sid] = d; order.append(sid)
|
|
stack.extend((c, d + 1) for c in sorted(children[sid], key=lambda x: sessions[x].get("started_at") or 0, reverse=True) if c not in rollover)
|
|
order.sort(key=lambda s: sessions[s].get("started_at") or 0)
|
|
price = cls._fit_pricing([sessions[s] for s in order])
|
|
out_dir = Path(out); out_dir.mkdir(parents=True, exist_ok=True)
|
|
return cls(Path(db), out_dir, root, sessions, depth, order, price, conn)
|
|
|
|
@staticmethod
|
|
def _rollover_ids(conn: sqlite3.Connection, sessions: Dict[str, Dict[str, Any]]) -> set:
|
|
"""Children created by in-place compression rollover, not by delegation: a child whose ``source``
|
|
is a top-level surface (cli/tui/telegram/...), not ``subagent``, whose parent shares that source,
|
|
and which started within a few seconds of the parent ending. Their whole later lifetime belongs to
|
|
the continuing conversation, not to the fan-out, so they are kept out of the run population."""
|
|
out = set()
|
|
for sid, s in sessions.items():
|
|
pid = s.get("parent_session_id")
|
|
if not pid or pid not in sessions or (s.get("source") or "") == "subagent":
|
|
continue
|
|
parent = sessions[pid]
|
|
if (s.get("source") or "") != (parent.get("source") or ""):
|
|
continue
|
|
try:
|
|
gap = float(s.get("started_at") or 0) - float(parent.get("ended_at") or 0)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if -5.0 <= gap <= 5.0:
|
|
out.add(sid)
|
|
return out
|
|
|
|
@staticmethod
|
|
def _fit_pricing(rows: Iterable[Dict[str, Any]]) -> Dict[str, float]:
|
|
X, y = [], []
|
|
for s in rows:
|
|
cost = s.get("estimated_cost_usd")
|
|
if not cost:
|
|
continue
|
|
X.append([float(s.get(c) or 0) for c in USAGE_COLS]); y.append(float(cost))
|
|
if len(X) < 8:
|
|
return {c: 0.0 for c in USAGE_COLS}
|
|
# Columns with negligible mass (e.g. input_tokens on cache-heavy Anthropic routes) make the
|
|
# normal equations ill-conditioned; fit only columns carrying >0.1% of all tokens.
|
|
mass = [sum(r[i] for r in X) for i in range(len(USAGE_COLS))]
|
|
keep = [i for i, m in enumerate(mass) if m > 0.001 * sum(mass)]
|
|
coef = _lstsq([[r[i] for i in keep] for r in X], y)
|
|
price = {c: 0.0 for c in USAGE_COLS}
|
|
for i, k in enumerate(keep):
|
|
price[USAGE_COLS[k]] = max(0.0, coef[i])
|
|
return price
|
|
|
|
# ── accessors ─────────────────────────────────────────────────────────────────────────────
|
|
def cost(self, sid: str) -> float:
|
|
return float(self.sessions[sid].get("estimated_cost_usd") or 0.0)
|
|
|
|
def messages(self, sid: str, cols: str = "*") -> List[Dict[str, Any]]:
|
|
return [dict(r) for r in self._conn.execute(f"SELECT {cols} FROM messages WHERE session_id=? ORDER BY id", (sid,))]
|
|
|
|
def iter_messages(self, sids: Iterable[str], cols: str = "*") -> Iterator[Dict[str, Any]]:
|
|
for sid in sids:
|
|
yield from self.messages(sid, cols)
|
|
|
|
def system_prompt_len(self, sid: str) -> int:
|
|
h = self.sessions[sid].get("system_prompt_hash")
|
|
if not h:
|
|
return 0
|
|
r = self._conn.execute("SELECT length(prompt) AS n FROM system_prompts WHERE hash=?", (h,)).fetchone()
|
|
return int(r["n"]) if r else 0
|
|
|
|
def by_depth(self) -> Dict[int, List[str]]:
|
|
out: Dict[int, List[str]] = collections.defaultdict(list)
|
|
for sid in self.in_run:
|
|
out[self.depth[sid]].append(sid)
|
|
return dict(out)
|
|
|
|
def write(self, name: str, data: Any) -> Path:
|
|
p = self.out_dir / name
|
|
p.write_text(json.dumps(data, indent=1, default=str) if not name.endswith(".md") else str(data), encoding="utf-8")
|
|
return p
|
|
|
|
def summary(self) -> Dict[str, Any]:
|
|
tot = {c: sum(float(self.sessions[s].get(c) or 0) for s in self.in_run) for c in USAGE_COLS}
|
|
return {
|
|
"root": self.root, "sessions": len(self.in_run), "children": len(self.in_run) - 1,
|
|
"by_depth": {d: len(v) for d, v in sorted(self.by_depth().items())},
|
|
"api_calls": sum(int(self.sessions[s].get("api_call_count") or 0) for s in self.in_run),
|
|
"cost_usd": round(sum(self.cost(s) for s in self.in_run), 2),
|
|
"usage_tokens": tot,
|
|
"fitted_price_per_million": {c: round(p * 1e6, 4) for c, p in self.price_per_token.items()},
|
|
"cost_by_bucket_usd": {c: round(tot[c] * self.price_per_token[c], 2) for c in USAGE_COLS},
|
|
}
|