testsAndMisc/python_pkg/session_autopsy/turn_tracker.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

77 lines
2.5 KiB
Python

"""Turn-efficiency accounting for a single transcript.
Split out of ``parse.py``: the tracker is self-contained — it knows only about
turn totals and the classification of the commands in a turn — and lifting it
out keeps the parser inside the repo's 500-line-per-file limit.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from python_pkg.session_autopsy.records import TurnEfficiency
from python_pkg.session_autopsy.turns import LINT_TEST, POLL, VCS_CHECK, classify_turn
@dataclass
class OpenTurn:
"""The API turn currently being accumulated, keyed by requestId."""
request_id: str | None = None
tools: int = 0
commands: list[str] = field(default_factory=list)
class TurnTracker:
"""Running turn-efficiency totals plus the turn currently open.
The two belong together: the open turn only exists to be folded into the
totals, and keeping them as one object is also what keeps the parser's
accumulator inside its instance-attribute budget.
"""
def __init__(self) -> None:
"""Start with empty totals and no open turn."""
self.totals = TurnEfficiency()
self._open = OpenTurn()
def begin(self, request_id: object) -> None:
"""Open a new turn if this line belongs to a different API response.
Args:
request_id: The line's ``requestId`` (or message-id fallback).
"""
if request_id != self._open.request_id:
self.flush()
self._open.request_id = request_id if isinstance(request_id, str) else None
def add_tool(self) -> None:
"""Count one tool_use block in the open turn."""
self._open.tools += 1
def add_command(self, command: str) -> None:
"""Record one Bash command issued in the open turn.
Args:
command: The raw Bash tool input.
"""
self._open.commands.append(command)
def flush(self) -> None:
"""Fold the open turn into the totals and start a fresh one."""
open_turn = self._open
self._open = OpenTurn()
if open_turn.tools <= 0:
return
self.totals.api_turns += 1
self.totals.tool_calls += open_turn.tools
if open_turn.tools > 1:
self.totals.batched_turns += 1
label = classify_turn(open_turn.commands)
if label == POLL:
self.totals.poll_turns += 1
elif label == LINT_TEST:
self.totals.lint_test_turns += 1
elif label == VCS_CHECK:
self.totals.vcs_check_turns += 1