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

468 lines
15 KiB
Python

"""Pure heuristics turning session records into ranked automation candidates.
No LLM anywhere: the compile-worthiness score comes from turn composition and
cross-invocation command similarity, savings from measured token usage.
"""
from __future__ import annotations
from dataclasses import dataclass, field
import hashlib
from typing import TYPE_CHECKING
from python_pkg.session_autopsy import stats
if TYPE_CHECKING:
from datetime import datetime
from python_pkg.session_autopsy.records import SessionRecord, SkillInvocation
MIN_SKILL_INVOCATIONS = 3
NGRAM_SIZES = range(3, 7)
NGRAM_MIN_OCCURRENCES = 8
NGRAM_MIN_SESSIONS = 4
NGRAM_MIN_DISTINCT_RATIO = 0.5
ERROR_MIN_OCCURRENCES = 10
ERROR_MIN_SESSIONS = 3
PROMPT_MIN_OCCURRENCES = 4
PROMPT_MIN_WORDS = 3
MAX_EVIDENCE_SESSIONS = 10
TREND_TOP_SESSIONS = 3
@dataclass(frozen=True)
class _CounterSpec:
"""Which signature-counter field to mine, and the thresholds to mine it at."""
field_name: str
"""Attribute on ``record.obs`` holding the ``sig -> count`` map."""
kind: str
"""Candidate kind tag."""
min_occurrences: int
"""Total-count threshold."""
min_sessions: int
"""Distinct-session threshold (ORed with the occurrence threshold)."""
action: str
"""Suggested action text."""
_ERROR_SPEC = _CounterSpec(
field_name="error_signatures",
kind="err",
min_occurrences=ERROR_MIN_OCCURRENCES,
min_sessions=ERROR_MIN_SESSIONS,
action="fix once: eliminate the failure's root cause",
)
_PROMPT_SPEC = _CounterSpec(
field_name="typed_prompt_signatures",
kind="prompt",
min_occurrences=PROMPT_MIN_OCCURRENCES,
min_sessions=1,
action="promote to slash command or script",
)
@dataclass
class Candidate:
"""One ranked automation opportunity."""
id: str
kind: str
title: str
sig: str
sessions: int
occurrences: int
avg_tokens: int
per_week: float
est_weekly_savings: int
score: float
action: str
session_ids: list[str] = field(default_factory=list)
def to_dict(self) -> dict[str, object]:
"""Serialize for ``candidates --json``.
Returns:
A JSON-compatible dict of all fields.
"""
return {
"id": self.id,
"kind": self.kind,
"title": self.title,
"sig": self.sig,
"sessions": self.sessions,
"occurrences": self.occurrences,
"avg_tokens": self.avg_tokens,
"per_week": round(self.per_week, 2),
"est_weekly_savings": self.est_weekly_savings,
"score": round(self.score, 3),
"action": self.action,
"session_ids": self.session_ids,
}
@dataclass
class TrendInfo:
"""Weekly cache-read-per-turn averages and their direction."""
weekly: list[tuple[str, int]] = field(default_factory=list)
slope_per_week: float = 0.0
top_sessions: list[tuple[str, int]] = field(default_factory=list)
@dataclass
class AnalysisResult:
"""Everything the report renders: ranked candidates and the trend."""
candidates: list[Candidate] = field(default_factory=list)
trend: TrendInfo = field(default_factory=TrendInfo)
def analyze(records: list[SessionRecord], now: datetime) -> AnalysisResult:
"""Run every detector and rank the merged candidates.
Args:
records: All stored session records.
now: Current UTC time (injected for determinism in tests).
Returns:
Ranked candidates and the context trend.
"""
per_turn_avg = stats.per_turn_avg(records)
candidates = [
*_skill_candidates(records, now),
*_ngram_candidates(records, now, per_turn_avg),
*_error_candidates(records, now, per_turn_avg),
*_prompt_candidates(records, now, per_turn_avg),
]
candidates.sort(key=lambda cand: (-cand.est_weekly_savings, cand.id))
return AnalysisResult(candidates=candidates, trend=_trend(records))
def _hex_id(text: str) -> str:
"""Derive the stable 8-hex candidate id suffix for a signature.
Args:
text: The normalized signature.
Returns:
First 8 hex chars of its SHA-256.
"""
return hashlib.sha256(text.encode("utf-8")).hexdigest()[:8]
def _skill_candidates(records: list[SessionRecord], now: datetime) -> list[Candidate]:
"""Rank every skill with enough invocations by measured spend.
No compile-vs-keep verdict is attempted here: real-corpus probing showed no
cheap scalar separates mechanical skills from judgment-heavy ones (a giant
Write turn hides among mechanical read turns). The score is published as
evidence; the verdict belongs to /compile-candidate reading real traces.
Args:
records: All session records.
now: Current UTC time.
Returns:
One candidate per skill with at least MIN_SKILL_INVOCATIONS uses.
"""
by_skill: dict[str, list[tuple[SessionRecord, SkillInvocation]]] = {}
for record in records:
for invocation in record.obs.skill_invocations:
by_skill.setdefault(invocation.name, []).append((record, invocation))
return [
_score_skill(name, pairs, now)
for name, pairs in sorted(by_skill.items())
if len(pairs) >= MIN_SKILL_INVOCATIONS
]
def _score_skill(
name: str,
pairs: list[tuple[SessionRecord, SkillInvocation]],
now: datetime,
) -> Candidate:
"""Build the candidate for one skill from all its invocations.
Args:
name: Skill name.
pairs: (session record, invocation) for every invocation.
now: Current UTC time.
Returns:
The scored candidate (score = determinism * similarity).
"""
determinism = stats.mean(
[
invocation.tool_only_turns / total
for _, invocation in pairs
if (total := invocation.tool_only_turns + invocation.text_turns) > 0
],
)
similarity = stats.mean_pairwise_similarity(
[invocation.bash_sig for _, invocation in pairs]
)
score = determinism * similarity
avg_tokens = int(
stats.mean(
[float(inv.tokens_output + inv.tokens_cache_write) for _, inv in pairs]
)
)
per_week = stats.per_week(
[stats.parse_timestamp(record.meta.started_at) for record, _ in pairs], now
)
session_ids = _recent_session_ids([record for record, _ in pairs])
action = f"review for compilation: /compile-candidate skill-{name}"
return Candidate(
id=f"skill-{name}",
kind="skill",
title=f"Skill '{name}' ({len(pairs)} invocations)",
sig=name,
sessions=len({record.session_id for record, _ in pairs}),
occurrences=len(pairs),
avg_tokens=avg_tokens,
per_week=per_week,
est_weekly_savings=int(avg_tokens * per_week),
score=score,
action=action,
session_ids=session_ids,
)
def _recent_session_ids(records: list[SessionRecord]) -> list[str]:
"""Most recent contributing session ids for the traces subcommand.
Args:
records: Contributing session records (may repeat).
Returns:
Up to :data:`MAX_EVIDENCE_SESSIONS` unique ids, newest first.
"""
unique: dict[str, str] = {}
for record in records:
unique[record.session_id] = record.meta.started_at or ""
ranked = sorted(unique.items(), key=lambda item: item[1], reverse=True)
return [session_id for session_id, _ in ranked[:MAX_EVIDENCE_SESSIONS]]
def _counter_candidates(
records: list[SessionRecord],
now: datetime,
per_turn_avg: int,
spec: _CounterSpec,
) -> list[Candidate]:
"""Shared aggregation for signature-counter fields (errors, prompts).
Args:
records: All session records.
now: Current UTC time.
per_turn_avg: Corpus-wide tokens per assistant turn.
spec: Which counter field to mine, and at what thresholds.
Returns:
Candidates for every signature crossing either threshold.
"""
totals: dict[str, int] = {}
contributors: dict[str, list[SessionRecord]] = {}
for record in records:
counter: dict[str, int] = getattr(record.obs, spec.field_name)
for sig, count in counter.items():
totals[sig] = totals.get(sig, 0) + count
contributors.setdefault(sig, []).append(record)
candidates = []
for sig, total in sorted(totals.items()):
sessions = contributors[sig]
if total < spec.min_occurrences and len(sessions) < spec.min_sessions:
continue
timestamps = [
stats.parse_timestamp(record.meta.started_at) for record in sessions
]
per_week = stats.per_week(timestamps, now) * (total / len(sessions))
candidates.append(
Candidate(
id=f"{spec.kind}-{_hex_id(sig)}",
kind=spec.kind,
title=sig[:70],
sig=sig,
sessions=len({record.session_id for record in sessions}),
occurrences=total,
avg_tokens=per_turn_avg,
per_week=per_week,
est_weekly_savings=int(per_turn_avg * per_week),
score=1.0,
action=spec.action,
session_ids=_recent_session_ids(sessions),
),
)
return candidates
def _error_candidates(
records: list[SessionRecord], now: datetime, per_turn_avg: int
) -> list[Candidate]:
"""Repeated error signatures worth a fix-once script.
Args:
records: All session records.
now: Current UTC time.
per_turn_avg: Corpus-wide tokens per assistant turn (crude cost proxy).
Returns:
Fix-once candidates.
"""
return _counter_candidates(records, now, per_turn_avg, _ERROR_SPEC)
def _prompt_candidates(
records: list[SessionRecord], now: datetime, per_turn_avg: int
) -> list[Candidate]:
"""Repeated typed prompts worth promoting to a command or script.
Args:
records: All session records.
now: Current UTC time.
per_turn_avg: Corpus-wide tokens per assistant turn.
Returns:
Prompt-promotion candidates (short generic prompts filtered out).
"""
raw = _counter_candidates(records, now, per_turn_avg, _PROMPT_SPEC)
return [
cand
for cand in raw
if len(cand.sig.split()) >= PROMPT_MIN_WORDS
and cand.occurrences >= PROMPT_MIN_OCCURRENCES
]
def _ngram_candidates(
records: list[SessionRecord], now: datetime, per_turn_avg: int
) -> list[Candidate]:
"""Repeated cross-session Bash command sequences outside skill spans.
Args:
records: All session records.
now: Current UTC time.
per_turn_avg: Corpus-wide tokens per assistant turn.
Returns:
Sequence candidates, longest surviving grams only.
"""
counts: dict[tuple[str, ...], int] = {}
contributors: dict[tuple[str, ...], list[SessionRecord]] = {}
for record in records:
seen_here: set[tuple[str, ...]] = set()
for sequence in record.obs.bash_sequences:
for size in NGRAM_SIZES:
for start in range(len(sequence) - size + 1):
gram = tuple(sequence[start : start + size])
counts[gram] = counts.get(gram, 0) + 1
if gram not in seen_here:
seen_here.add(gram)
contributors.setdefault(gram, []).append(record)
surviving = {
gram: count
for gram, count in counts.items()
if count >= NGRAM_MIN_OCCURRENCES
and len(contributors[gram]) >= NGRAM_MIN_SESSIONS
and len(set(gram)) / len(gram) >= NGRAM_MIN_DISTINCT_RATIO
}
top = _drop_subgrams(surviving)
candidates = []
for gram in sorted(top):
sessions = contributors[gram]
joined = " → ".join(gram)
occurrences = counts[gram]
per_week = stats.per_week(
[stats.parse_timestamp(record.meta.started_at) for record in sessions], now
)
candidates.append(
Candidate(
id=f"ngram-{_hex_id(joined)}",
kind="ngram",
title=joined[:70],
sig=joined,
sessions=len({record.session_id for record in sessions}),
occurrences=occurrences,
avg_tokens=per_turn_avg * len(gram),
per_week=per_week,
est_weekly_savings=int(per_turn_avg * len(gram) * per_week),
score=1.0,
action="script this sequence",
session_ids=_recent_session_ids(sessions),
),
)
return candidates
def _drop_subgrams(surviving: dict[tuple[str, ...], int]) -> set[tuple[str, ...]]:
"""Remove grams contained in a longer surviving gram.
Args:
surviving: Grams that crossed the frequency thresholds.
Returns:
The maximal grams only.
"""
grams = sorted(surviving, key=len, reverse=True)
kept: set[tuple[str, ...]] = set()
for gram in grams:
if not any(_contains(longer, gram) for longer in kept):
kept.add(gram)
return kept
def _contains(longer: tuple[str, ...], shorter: tuple[str, ...]) -> bool:
"""Whether ``shorter`` appears contiguously inside ``longer``.
Args:
longer: The candidate container gram.
shorter: The gram to look for.
Returns:
True when contained.
"""
if len(shorter) >= len(longer):
return False
return any(
longer[i : i + len(shorter)] == shorter
for i in range(len(longer) - len(shorter) + 1)
)
def _trend(records: list[SessionRecord]) -> TrendInfo:
"""Weekly cache-read-per-turn trend plus the heaviest sessions.
Args:
records: All session records.
Returns:
The populated trend info (empty when timestamps are missing).
"""
weekly_turns: dict[str, int] = {}
weekly_reads: dict[str, int] = {}
heaviest: list[tuple[str, int]] = []
for record in records:
stamp = stats.parse_timestamp(record.meta.started_at)
if stamp is None or record.counts.assistant_msgs == 0:
continue
week = f"{stamp.isocalendar().year}-W{stamp.isocalendar().week:02d}"
weekly_turns[week] = weekly_turns.get(week, 0) + record.counts.assistant_msgs
weekly_reads[week] = weekly_reads.get(week, 0) + record.tokens.cache_read
heaviest.append(
(
record.meta.slug or record.session_id,
record.tokens.cache_read // record.counts.assistant_msgs,
)
)
weekly = [
(week, weekly_reads[week] // weekly_turns[week])
for week in sorted(weekly_turns)
]
heaviest.sort(key=lambda item: -item[1])
return TrendInfo(
weekly=weekly,
slope_per_week=stats.slope([float(avg) for _, avg in weekly]),
top_sessions=heaviest[:TREND_TOP_SESSIONS],
)