testsAndMisc/python_pkg/session_autopsy/tests/test_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

241 lines
8.4 KiB
Python

"""Tests for the pure candidate detectors."""
from __future__ import annotations
from datetime import datetime, timezone
from python_pkg.session_autopsy import detectors, stats
from python_pkg.session_autopsy.detectors import analyze
from python_pkg.session_autopsy.records import (
ActivityCounts,
Observations,
TokenTotals,
)
from python_pkg.session_autopsy.stats import parse_timestamp
from python_pkg.session_autopsy.tests.conftest import (
invocation,
record,
session_meta,
)
NOW = datetime(2026, 7, 24, 12, 0, tzinfo=timezone.utc)
OLD = "2026-01-01T00:00:00.000Z"
def test_parse_timestamp() -> None:
"""None, empty, junk, and Z-suffixed ISO forms."""
assert parse_timestamp(None) is None
assert parse_timestamp("") is None
assert parse_timestamp("junk") is None
stamp = parse_timestamp("2026-07-20T10:00:00.000Z")
assert stamp is not None
assert stamp.tzinfo is not None
def test_analyze_empty_corpus() -> None:
"""No records → no candidates, empty trend, zero per-turn average."""
result = analyze([], NOW)
assert result.candidates == []
assert result.trend.weekly == []
assert result.trend.slope_per_week == 0.0
def test_skill_candidates_threshold_and_score() -> None:
"""Skills need >=3 invocations; zero-turn spans don't skew determinism."""
records = [
record(
"a",
obs=Observations(
skill_invocations=[invocation(), invocation(tool_only=0, text=0)]
),
),
record("b", obs=Observations(skill_invocations=[invocation()])),
record("c", obs=Observations(skill_invocations=[invocation(name="rare")])),
]
result = analyze(records, NOW)
skills = [cand for cand in result.candidates if cand.kind == "skill"]
assert [cand.id for cand in skills] == ["skill-finish"]
finish = skills[0]
assert finish.occurrences == 3
assert finish.sessions == 2
assert finish.score == 0.6
assert finish.avg_tokens == 150_000
assert finish.per_week == 3 / 8
assert finish.est_weekly_savings == int(150_000 * 3 / 8)
assert "compile-candidate skill-finish" in finish.action
def test_skill_similarity_zero_without_sequences() -> None:
"""All-empty bash signatures give zero similarity evidence."""
records = [
record("a", obs=Observations(skill_invocations=[invocation(bash_sig=[])])),
record("b", obs=Observations(skill_invocations=[invocation(bash_sig=[])])),
record("c", obs=Observations(skill_invocations=[invocation(bash_sig=[])])),
]
result = analyze(records, NOW)
assert result.candidates[0].score == 0.0
def test_old_invocations_have_zero_weekly_rate() -> None:
"""Occurrences outside the trailing window contribute no weekly rate."""
records = [
record(
"a",
meta=session_meta(started_at=OLD),
obs=Observations(skill_invocations=[invocation()]),
),
record(
"b",
meta=session_meta(started_at=OLD),
obs=Observations(skill_invocations=[invocation()]),
),
record(
"c",
meta=session_meta(started_at=None),
obs=Observations(skill_invocations=[invocation()]),
),
]
result = analyze(records, NOW)
assert result.candidates[0].per_week == 0.0
assert result.candidates[0].est_weekly_savings == 0
def test_error_candidates_thresholds() -> None:
"""Errors qualify by total occurrences OR by session spread."""
records = [
record("a", obs=Observations(error_signatures={"boom: <PATH>": 10, "meh": 2})),
record("b", obs=Observations(error_signatures={"spread: x": 1})),
record("c", obs=Observations(error_signatures={"spread: x": 1})),
record("d", obs=Observations(error_signatures={"spread: x": 1})),
]
result = analyze(records, NOW)
errs = {cand.sig: cand for cand in result.candidates if cand.kind == "err"}
assert set(errs) == {"boom: <PATH>", "spread: x"}
assert errs["boom: <PATH>"].occurrences == 10
assert errs["spread: x"].sessions == 3
def test_prompt_candidates_filters() -> None:
"""Prompts need >=4 occurrences and >=3 words."""
records = [
record(
"a",
obs=Observations(
typed_prompt_signatures={
"do the big thing": 4,
"hi there friend": 3,
"short one": 9,
}
),
),
]
result = analyze(records, NOW)
prompts = [cand.sig for cand in result.candidates if cand.kind == "prompt"]
assert prompts == ["do the big thing"]
def test_ngram_candidates_and_subgram_drop() -> None:
"""Frequent distinct sequences survive; subgrams and degenerates drop."""
seq = ["a", "b", "c", "d"]
degenerate = ["x", "x", "x", "x", "x", "x"]
records = [
record(sid, obs=Observations(bash_sequences=[seq, seq, degenerate]))
for sid in ("s1", "s2", "s3", "s4")
]
result = analyze(records, NOW)
ngrams = [cand for cand in result.candidates if cand.kind == "ngram"]
assert [cand.sig for cand in ngrams] == ["a → b → c → d"]
assert ngrams[0].occurrences == 8
assert ngrams[0].sessions == 4
def test_ngram_below_session_threshold() -> None:
"""Many occurrences in too few sessions do not qualify."""
records = [record("only", obs=Observations(bash_sequences=[["a", "b", "c"]] * 20))]
result = analyze(records, NOW)
assert [cand for cand in result.candidates if cand.kind == "ngram"] == []
def test_contains_and_drop_subgrams() -> None:
"""Containment handles equal-length and absent cases."""
assert detectors._contains(("a", "b", "c"), ("b", "c"))
assert not detectors._contains(("a", "b"), ("a", "b"))
assert not detectors._contains(("a", "b", "c"), ("c", "d"))
kept = detectors._drop_subgrams({("a", "b", "c"): 9, ("a", "b"): 9, ("z", "q"): 9})
assert kept == {("a", "b", "c"), ("z", "q")}
def test_trend_weekly_slope_and_offenders() -> None:
"""Weekly averages, slope, and cache-heaviest sessions."""
records = [
record(
"w1",
tokens=TokenTotals(cache_read=1000000),
meta=session_meta(started_at="2026-07-06T10:00:00Z"),
counts=ActivityCounts(assistant_msgs=10),
),
record(
"w2",
tokens=TokenTotals(cache_read=2000000),
meta=session_meta(started_at="2026-07-13T10:00:00Z"),
counts=ActivityCounts(assistant_msgs=10),
),
record(
"w3",
tokens=TokenTotals(cache_read=3000000),
meta=session_meta(started_at="2026-07-20T10:00:00Z", slug="heavy-slug"),
counts=ActivityCounts(assistant_msgs=10),
),
record(
"w4",
tokens=TokenTotals(cache_read=100),
meta=session_meta(started_at="2026-07-21T10:00:00Z"),
counts=ActivityCounts(assistant_msgs=10),
),
record("undated", meta=session_meta(started_at=None)),
record("silent", counts=ActivityCounts(assistant_msgs=0)),
]
result = analyze(records, NOW)
trend = result.trend
assert len(trend.weekly) == 3
assert trend.slope_per_week > 0
assert trend.top_sessions[0] == ("heavy-slug", 300_000)
assert len(trend.top_sessions) == 3
def test_slope_needs_two_points() -> None:
"""A single weekly sample yields slope 0."""
assert stats.slope([5.0]) == 0.0
def test_candidate_to_dict() -> None:
"""Serialization exposes rounded score and per_week."""
records = [record("a", obs=Observations(skill_invocations=[invocation()] * 3))]
cand = analyze(records, NOW).candidates[0]
data = cand.to_dict()
assert data["id"] == "skill-finish"
assert isinstance(data["per_week"], float)
assert isinstance(data["session_ids"], list)
def test_recent_session_ids_cap_and_order() -> None:
"""Evidence session ids are newest-first, capped at 10, deduplicated."""
many = [
record(
f"s{index:02d}",
meta=session_meta(started_at=f"2026-07-{index + 1:02d}T00:00:00Z"),
obs=Observations(skill_invocations=[invocation()]),
)
for index in range(12)
]
many.append(
record(
"s00",
meta=session_meta(started_at="2026-07-01T00:00:00Z"),
obs=Observations(skill_invocations=[invocation()]),
)
)
cand = analyze(many, NOW).candidates[0]
assert len(cand.session_ids) == 10
assert cand.session_ids[0] == "s11"