testsAndMisc/python_pkg/session_autopsy/stats.py
Krzysztof kuhy Rudnicki a83b5abc27
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
python_pkg: track session_autopsy, finishing its interrupted refactor
The transcript analyzer behind /compile-candidate lived only in the working
tree. Untracked meant unlinted — pre-commit only inspects files handed to it —
so nothing had ever checked it, and it had been abandoned mid-refactor with
every parse raising AttributeError: '_Accumulator' object has no attribute
'first_timestamp'.

The accumulator had been moved onto the grouped records dataclasses, but three
increment sites and parse_session still read flat attributes that no longer
existed. parse_session now hands over acc.meta and acc.counts directly; only
subagent_count comes from elsewhere, since no transcript line carries it.

Two modules broke the 500-line limit, so they were split along seams that were
already there. signatures.py takes the text-signature helpers — traces.py was
importing them from parse.py, making parse an accidental hub, and now neither
imports the other. stats.py takes the pure number and time helpers. parse.py
521 -> 458, detectors.py 559 -> 467.

The structural lint findings were refactored, not configured away.
_counter_candidates took eight arguments; the five naming which counter field to
mine and at what thresholds became a frozen _CounterSpec, so both call sites are
one line. _extract_skill carried in_span and a line budget as loop variables
across thirteen branches; a _SpanBuffer owns that state now, leaving the walk at
four. record() took twelve flat kwargs and now takes one override per record
block, with session_meta() so a partial meta override keeps the standard
timestamp — a bare SessionMeta(slug=...) would have nulled started_at and
quietly changed the per-week arithmetic. All 36 call sites rewritten by an AST
pass. tests/factories.py moved into conftest.py, where every other package here
keeps shared builders and where name-tests-test does not object.

965 tests pass at 100% statement and branch coverage, every hook is green, and
no suppression was added anywhere. `candidates` and `traces skill-finish` both
run against the real corpus.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 16:56:43 +02:00

121 lines
3.4 KiB
Python

"""Small numeric and time helpers shared by the detectors and the report.
These are pure functions over plain numbers, timestamps and token totals — no
knowledge of what a candidate is. Keeping them here leaves
:mod:`python_pkg.session_autopsy.detectors` to the heuristics themselves.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from difflib import SequenceMatcher
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from python_pkg.session_autopsy.records import SessionRecord
TRAILING_WEEKS = 8
MAX_SIMILARITY_INVOCATIONS = 5
MIN_SLOPE_SAMPLES = 2
def parse_timestamp(value: str | None) -> datetime | None:
"""Parse an ISO-8601 transcript timestamp (with trailing ``Z``).
Args:
value: The timestamp string, or None.
Returns:
A timezone-aware datetime, or None when absent/invalid.
"""
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
def per_turn_avg(records: list[SessionRecord]) -> int:
"""Average output+cache-write tokens per assistant turn, corpus-wide.
Args:
records: All session records.
Returns:
The average, or 0 for an empty corpus.
"""
turns = sum(record.counts.assistant_msgs for record in records)
if turns == 0:
return 0
spent = sum(record.tokens.output + record.tokens.cache_write for record in records)
return spent // turns
def per_week(timestamps: list[datetime | None], now: datetime) -> float:
"""Occurrences per week over the trailing window.
Args:
timestamps: One entry per occurrence (None when the session had no
parseable start time; those never count as recent).
now: Current UTC time.
Returns:
Recent occurrences divided by :data:`TRAILING_WEEKS`.
"""
cutoff = now - timedelta(weeks=TRAILING_WEEKS)
recent = sum(1 for stamp in timestamps if stamp is not None and stamp >= cutoff)
return recent / TRAILING_WEEKS
def mean(values: list[float]) -> float:
"""Arithmetic mean, 0.0 for an empty list.
Args:
values: Sample values.
Returns:
The mean.
"""
if not values:
return 0.0
return sum(values) / len(values)
def mean_pairwise_similarity(sequences: list[list[str]]) -> float:
"""Mean SequenceMatcher ratio over the first few invocation pairs.
Args:
sequences: One Bash-signature sequence per invocation.
Returns:
Mean similarity in [0, 1]; 0.0 when fewer than two non-empty
sequences exist (nothing to compare means no evidence of stability).
"""
sample = [seq for seq in sequences if seq][:MAX_SIMILARITY_INVOCATIONS]
ratios = [
SequenceMatcher(None, sample[i], sample[j]).ratio()
for i in range(len(sample))
for j in range(i + 1, len(sample))
]
return mean(ratios)
def slope(values: list[float]) -> float:
"""Least-squares slope of evenly spaced samples.
Args:
values: One sample per week, in order.
Returns:
Tokens-per-turn change per week; 0.0 with fewer than two samples.
"""
count = len(values)
if count < MIN_SLOPE_SAMPLES:
return 0.0
mean_x = (count - 1) / 2
mean_y = sum(values) / count
numerator = sum((i - mean_x) * (val - mean_y) for i, val in enumerate(values))
denominator = sum((i - mean_x) ** 2 for i in range(count))
return numerator / denominator