Files
Teknium a8ca904922 feat(evals): post-mortem harness — forensics lanes + live A/B + review probes for the #102117 run fixes
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.
2026-09-05 09:13:10 -07:00

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},
}