testsAndMisc/python_pkg/session_autopsy/report.py
Krzysztof kuhy Rudnicki 9d7751cebb
Some checks are pending
Pre-commit checks / pre-commit (push) Waiting to run
Python tests / test (push) Waiting to run
Shell tests / shell-tests (push) Waiting to run
Shell tests / arch-tests (push) Waiting to run
kcd2 dice solver: serve it at dice.kuhy.duckdns.org, fit all 43 dice, move inventories between devices
The solver only ran behind ./run.sh, had one media query in the whole
stylesheet, and kept the inventory in a 46vh scroll box that showed maybe a
third of the 43 dice. Setting it up on the PC did nothing for the phone.

Deployment
  dice.kuhy.duckdns.org, behind the existing gitea-caddy edge. DuckDNS already
  wildcards *.kuhy.duckdns.org (checked against 1.1.1.1 and 8.8.8.8), so no
  dashboard step. deploy_build.sh builds to dist.staging and rsyncs into dist/
  with --delete-after --delay-updates; plain --delete removes stale assets
  before writing the new ones, which 404s a client mid-load. A post-commit hook
  starts a MemorySwapMax=0 systemd user unit, so a rebuild can neither slow a
  commit nor thrash zram.

  Three traps found by driving the live URL in a browser rather than curling it:
  a site address of "127.0.0.1:8089" reads as a HOSTNAME, so Caddy enabled
  auto-HTTPS and started an HTTP->HTTPS redirect listener that bound *:80 next
  to the edge -- SO_REUSEPORT would then split inbound HTTP and intermittently
  break ACME for every domain here. The same address also made a Host matcher,
  so the edge's forwarded Host matched nothing and served an empty 200 while the
  local health check passed. And watching dist/'s own inode could never detect a
  detached bind mount: rsync into an existing directory never changes it. Each
  now has a guard that asserts positively rather than failing open.

Dice list
  All 43 on screen at every width, no inner scrollbar. Column count and size
  tier are chosen together from a container query on the panel -- not the
  viewport, which mispredicts by 2x at the point the columns split. Two
  invariants govern it: the list fits above the fold, and the name column stays
  wide enough to tell Painter's die B, G and R apart. The left column holds the
  grid alone, so it is sized to fill the height it owns: one --die-row value
  drives the type, buttons and bar strip, giving a 52px row and 44px buttons on
  a 1296px-tall screen and 20px rows on a phone.

  Three safe-design rules are broken inside .die-list deliberately: sub-16px
  body text, square icon buttons, sub-8px intra-cell gaps. 43 items with a name,
  a chart and a counter in one viewport is arithmetically incompatible with 16px.

Import / export
  A file, the clipboard, or a link carrying the inventory in the URL fragment.
  One validator behind all four entry points including localStorage; it keeps
  what it can and reports what it dropped, so a die added in a later game patch
  costs you that entry, not the inventory. A link never silently overwrites what
  is saved here -- it offers Keep or Discard, and only a link that actually
  carries dice is applied at all, because one encoding a count of zero parsed
  cleanly, blanked the screen, disabled Clear and left Keep writing the emptiness
  over the visitor's own loadout.

session_autopsy
  parse.py (551) and report.py (504) were over the repo's 500-line limit, so
  test_file_length has been failing on main since 5ffd035. Extracted TurnTracker
  and the two candidate-cell helpers. Report output is byte-identical on real
  data apart from its timestamp, checked against HEAD in a worktree.

Verified: 288 TS tests and 911 Python tests, 100% coverage both, lint and
pre-commit clean, 8 viewports measured on the live HTTPS site, solver confirmed
through the real module worker.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 20:01:21 +02:00

477 lines
15 KiB
Python

"""Render REPORT.md, maintain state.json, and measure compiled skills."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from python_pkg.session_autopsy.report_cells import action_cell, verdict_label
from python_pkg.session_autopsy.stats import parse_timestamp
if TYPE_CHECKING:
from datetime import datetime
from pathlib import Path
from python_pkg.session_autopsy.detectors import AnalysisResult, Candidate
from python_pkg.session_autopsy.records import SessionRecord
REPORT_FILE = "REPORT.md"
STATE_FILE = "state.json"
COMPILED_FILE = "compiled.json"
TRACES_HINT = "PYTHONPATH=~/testsAndMisc python3 -m python_pkg.session_autopsy traces"
THOUSAND = 1_000
MILLION = 1_000_000
TABLE_TOP = 40
DETAIL_TOP = 25
# The ⚙ badge / SessionStart nudge count only candidates at least this big —
# nobody reviews 180 weak n-grams, so the count must mean "worth your time".
SIGNIFICANT_WEEKLY_TOKENS = 100_000
def fmt_tokens(count: int) -> str:
"""Format a token count for humans.
Args:
count: Token count.
Returns:
``"842"``, ``"74k"``, or ``"1.2M"``.
"""
if count >= MILLION:
return f"{count / MILLION:.1f}M"
if count >= THOUSAND:
return f"{count // THOUSAND}k"
return str(count)
def handled_ids(compiled: list[dict[str, object]]) -> set[str]:
"""Candidate ids that already have a verdict (compiled, keep-llm, dropped).
A handled candidate never counts toward the unreviewed nudge and its
table row stops suggesting a review — its story lives in the Reviewed
section and the scoreboard instead.
Args:
compiled: Entries from compiled.json.
Returns:
The candidate ids with any recorded verdict.
"""
return {
str(entry.get("candidate_id"))
for entry in compiled
if entry.get("candidate_id")
}
def significant_ids(candidates: list[Candidate]) -> list[str]:
"""Ids of the candidates big enough to surface in the badge.
Args:
candidates: Ranked candidates.
Returns:
Ids with at least :data:`SIGNIFICANT_WEEKLY_TOKENS` estimated weekly
tokens at stake, in rank order.
"""
return [
cand.id
for cand in candidates
if cand.est_weekly_savings >= SIGNIFICANT_WEEKLY_TOKENS
]
def load_state(home: Path) -> dict[str, object]:
"""Read state.json, tolerating absence and corruption.
Args:
home: The autopsy home directory.
Returns:
The stored state dict, or an empty dict.
"""
path = home / STATE_FILE
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return {}
if isinstance(data, dict):
return data
return {}
def load_compiled(home: Path) -> list[dict[str, object]]:
"""Read compiled.json entries, tolerating absence and corruption.
Args:
home: The autopsy home directory.
Returns:
The list of ``{candidate_id, skill, script, compiled_at}`` entries.
"""
path = home / COMPILED_FILE
if not path.is_file():
return []
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError:
return []
if isinstance(data, list):
return [entry for entry in data if isinstance(entry, dict)]
return []
def write_state(
home: Path, candidate_ids: list[str], now: datetime, *, mark_reviewed: bool = False
) -> int:
"""Update state.json and return the unreviewed-candidate count.
Args:
home: The autopsy home directory.
candidate_ids: Every current candidate id, ranked.
now: Current UTC time.
mark_reviewed: When True, every current candidate becomes reviewed.
Returns:
The number of candidates not yet marked reviewed.
"""
state = load_state(home)
stored = state.get("reviewed_ids")
reviewed = (
{item for item in stored if isinstance(item, str)}
if isinstance(stored, list)
else set()
)
if mark_reviewed:
reviewed = set(candidate_ids)
reviewed &= set(candidate_ids)
unreviewed = len([cid for cid in candidate_ids if cid not in reviewed])
payload = {
"unreviewed_count": unreviewed,
"candidate_ids": candidate_ids,
"reviewed_ids": sorted(reviewed),
"last_generated": now.isoformat(timespec="seconds"),
}
home.mkdir(parents=True, exist_ok=True)
(home / STATE_FILE).write_text(
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
)
return unreviewed
def measure_lines(
records: list[SessionRecord],
compiled: list[dict[str, object]],
only_skill: str | None,
) -> list[str]:
"""Compare tokens/invocation before vs after each compilation.
Args:
records: All session records.
compiled: Entries from compiled.json.
only_skill: Restrict output to one skill name, when given.
Returns:
One human-readable line per compiled skill.
"""
lines = []
for entry in compiled:
skill = str(entry.get("skill") or "")
if (
not skill
or not entry.get("script")
or (only_skill is not None and skill != only_skill)
):
continue
compiled_at = parse_timestamp(str(entry.get("compiled_at") or "") or None)
before: list[int] = []
after: list[int] = []
for record in records:
stamp = parse_timestamp(record.meta.started_at)
for invocation in record.obs.skill_invocations:
if invocation.name != skill:
continue
spent = invocation.tokens_output + invocation.tokens_cache_write
if (
compiled_at is not None
and stamp is not None
and stamp >= compiled_at
):
after.append(spent)
else:
before.append(spent)
lines.append(_measure_line(skill, before, after))
if not lines:
lines.append(
"nothing compiled yet — run /compile-candidate on a ranked candidate"
)
return lines
def _measure_line(skill: str, before: list[int], after: list[int]) -> str:
"""Render one scoreboard line.
Args:
skill: Skill name.
before: Tokens/invocation samples before compilation.
after: Samples after compilation.
Returns:
The formatted line, with a delta when both sides have data.
"""
avg_before = sum(before) // len(before) if before else 0
if not after:
return (
f"- {skill}: before {fmt_tokens(avg_before)}/inv "
f"(n={len(before)}) — no post-compile invocations yet"
)
avg_after = sum(after) // len(after)
delta = ""
if avg_before > 0:
delta = f" — {100 * (avg_after - avg_before) // avg_before:+d}%"
return (
f"- {skill}: before {fmt_tokens(avg_before)}/inv (n={len(before)}) "
f"→ after {fmt_tokens(avg_after)}/inv (n={len(after)}){delta}"
)
def render_report(
records: list[SessionRecord],
result: AnalysisResult,
compiled: list[dict[str, object]],
now: datetime,
) -> str:
"""Render the full REPORT.md content.
Args:
records: All session records.
result: Detector output.
compiled: Entries from compiled.json.
now: Current UTC time.
Returns:
The Markdown document.
"""
handled = handled_ids(compiled)
parts = [
_header(records, now),
_turn_efficiency(records),
_candidate_table(result.candidates, handled),
]
parts.extend(
_candidate_detail(candidate) for candidate in result.candidates[:DETAIL_TOP]
)
parts.append(_reviewed_keep_llm(compiled))
parts.append(_trend_section(result))
parts.append(
"## Compiled scoreboard\n\n"
+ "\n".join(measure_lines(records, compiled, None))
+ "\n"
)
return "\n".join(parts)
def _header(records: list[SessionRecord], now: datetime) -> str:
"""Render the totals header.
Args:
records: All session records.
now: Current UTC time.
Returns:
The header block.
"""
turns = sum(record.counts.assistant_msgs for record in records)
output = sum(record.tokens.output for record in records)
cache_write = sum(record.tokens.cache_write for record in records)
cache_read = sum(record.tokens.cache_read for record in records)
per_turn = cache_read // turns if turns else 0
return (
"# Session Autopsy Report\n\n"
f"Generated: {now.isoformat(timespec='seconds')} | Sessions: {len(records)} | "
f"Assistant msgs: {turns}\n"
f"Tokens: {fmt_tokens(output)} output | "
f"{fmt_tokens(cache_write)} cache-write | "
f"{fmt_tokens(cache_read)} cache-read (avg {fmt_tokens(per_turn)}/turn)\n"
)
def _turn_efficiency(records: list[SessionRecord]) -> str:
"""Render the turn-efficiency block.
Cache read is charged per API round-trip on the whole conversation so far, so
turns the model did not need to take are the single largest recoverable cost —
far larger than message length. This block prices them.
Args:
records: All session records.
Returns:
The turn-efficiency section.
"""
api = sum(r.turns.api_turns for r in records)
if not api:
return "## Turn efficiency\n\nno tool turns recorded yet\n"
batched = sum(r.turns.batched_turns for r in records)
poll = sum(r.turns.poll_turns for r in records)
lint = sum(r.turns.lint_test_turns for r in records)
vcs = sum(r.turns.vcs_check_turns for r in records)
calls = sum(r.turns.tool_calls for r in records)
cache_read = sum(r.tokens.cache_read for r in records)
mechanical = poll + lint + vcs
per_turn = cache_read // api if api else 0
return (
"## Turn efficiency\n\n"
f"{api} tool turns carrying {calls} calls | "
f"batched: {batched} ({batched / api:.0%}) | "
f"avg context re-read: {fmt_tokens(per_turn)}/turn\n\n"
"| mechanical turn kind | turns | est. cache-read |\n"
"| --- | ---: | ---: |\n"
f"| polling (progress, liveness, clocks) | {poll} "
f"| {fmt_tokens(poll * per_turn)} |\n"
f"| lint / test (exit-code adjudicated) | {lint} "
f"| {fmt_tokens(lint * per_turn)} |\n"
f"| read-only VCS checks | {vcs} | {fmt_tokens(vcs * per_turn)} |\n"
f"| **total** | **{mechanical}** ({mechanical / api:.0%}) | "
f"**{fmt_tokens(mechanical * per_turn)}** |\n\n"
"These turns decided nothing: a script could have taken them and reported "
"once. Batching independent calls cuts the rest.\n"
)
def _candidate_table(candidates: list[Candidate], handled: set[str]) -> str:
"""Render the ranked candidate table.
Args:
candidates: Ranked candidates.
handled: Ids with a recorded verdict — their action column stops
suggesting a review.
Returns:
The Markdown table (or a placeholder when empty).
"""
if not candidates:
return "## Ranked automation candidates\n\nnone yet — need more sessions\n"
rows = [
f"| {rank} | {cand.id} | {cand.kind} | "
f"{cand.occurrences}x / {cand.sessions} sessions "
f"| {fmt_tokens(cand.est_weekly_savings)}/wk | "
f"{action_cell(cand, handled)} |"
for rank, cand in enumerate(candidates[:TABLE_TOP], start=1)
]
overflow = ""
if len(candidates) > TABLE_TOP:
overflow = (
f"\n… plus {len(candidates) - TABLE_TOP} below the fold — "
"see `candidates --json` for all.\n"
)
return (
"## Ranked automation candidates\n\n"
"| # | id | kind | evidence | est. weekly tokens | action |\n"
"|---|----|------|----------|--------------------|--------|\n"
+ "\n".join(rows)
+ "\n"
+ overflow
)
def _candidate_detail(candidate: Candidate) -> str:
"""Render one candidate's detail block.
Args:
candidate: The candidate.
Returns:
The detail block with the exact traces command.
"""
return (
f"### {candidate.id} — {candidate.title}\n\n"
f"- score {candidate.score:.2f}, avg "
f"{fmt_tokens(candidate.avg_tokens)} tokens/occurrence, "
f"{candidate.per_week:.1f}/week\n"
f"- signature: `{candidate.sig}`\n"
f"- traces: `{TRACES_HINT} {candidate.id}`\n"
)
def _reviewed_keep_llm(compiled: list[dict[str, object]]) -> str:
"""Render candidates already reviewed and ruled judgment-heavy.
These come from /compile-candidate verdicts in compiled.json, not from a
heuristic: real-trace review decided the workflow needs the LLM.
Args:
compiled: Entries from compiled.json.
Returns:
The section text.
"""
kept = [
entry for entry in compiled if entry.get("verdict") in ("keep-llm", "dropped")
]
if not kept:
return "## Reviewed — keeping LLM\n\nnone reviewed yet\n"
lines = [
f"- {entry.get('candidate_id')} — "
f"reviewed {entry.get('compiled_at')} — {verdict_label(entry)}"
for entry in kept
]
return "## Reviewed — keeping LLM\n\n" + "\n".join(lines) + "\n"
def _trend_section(result: AnalysisResult) -> str:
"""Render the context-bloat trend section.
Args:
result: Detector output holding the trend.
Returns:
The section text.
"""
trend = result.trend
if not trend.weekly:
return "## Context-bloat trend\n\nno dated sessions yet\n"
weeks = "\n".join(
f"- {week}: {fmt_tokens(avg)}/turn" for week, avg in trend.weekly[-8:]
)
offenders = ", ".join(
f"{name} ({fmt_tokens(avg)}/turn)" for name, avg in trend.top_sessions
)
return (
"## Context-bloat trend\n\n"
f"slope: {fmt_tokens(int(trend.slope_per_week))}/turn per week\n"
f"heaviest sessions: {offenders}\n\n{weeks}\n"
)
def write_report(
home: Path,
records: list[SessionRecord],
result: AnalysisResult,
now: datetime,
) -> int:
"""Write REPORT.md and refresh state.json.
Args:
home: The autopsy home directory.
records: All session records.
result: Detector output.
now: Current UTC time.
Returns:
The unreviewed-candidate count (for the caller's summary line).
"""
home.mkdir(parents=True, exist_ok=True)
compiled = load_compiled(home)
(home / REPORT_FILE).write_text(
render_report(records, result, compiled, now), encoding="utf-8"
)
pending = [
cid
for cid in significant_ids(result.candidates)
if cid not in handled_ids(compiled)
]
return write_state(home, pending, now)