signal-bot/tests/test_analytics_behaviour.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
One row per command attempt, not two aggregates written only on success.

command_uses holds last-use only and daily_uses counts successes; both are
rate-limiter state and both are written by store.record_use, which the
dispatcher calls only when a handler returns cleanly. A gate refusal and a
crash returned before it, so a command that failed every time looked exactly
like one nobody ran, and nothing knew which command a counted message was.

command_events now takes every attempt with its outcome, its duration and
the item it served; upstream_calls takes every outbound request made while
serving one. The two writes stay separate on purpose -- recording a refusal
as a use is what slid the cooldown window forward every time somebody was
told to wait -- and test_dispatcher_outcomes asserts last_used does not move.

Backfilled from journald joined against message_events, with daily_uses
filling the residual so the two sources cannot double-count. Two things were
wrong on the first attempt and both were found by measuring rather than by
reasoning: the window has to be symmetric, because sent_at_ms comes from the
sender's phone and the journal stamp from this host (194 of 299 lines landed
*before* their own message, by up to 3.2 s); and matching has to be a global
closest-pair assignment, because per-row greedy cannot separate two commands
seconds apart. Together those took the dropped rows from 137 to zero, and
recovered 418 invocations back to 29 August.

Also: !analityka in the group, twelve Polish lines, naming at most three
people and only ones whose display name has been seen; an Analytics tab with
Nivo charts; and continuous latency measurement, which answers the open
question in DOCS-latency.md -- a text send is 235 ms and an image send about
2.2 s, so the upload really is where the time goes.

EPHEMERAL_TABLES was doing two unrelated jobs and is now two tuples: what a
schema bump rebuilds, and what leaving a group clears. daily_uses left the
first (it is a backfill source now) and stayed in the second, and
_drop_reshaped took over the repair a bump used to do for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4QTp3h5Q3MvfrwGV8xqPf
2026-08-31 23:19:51 +02:00

235 lines
8.9 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Families D, E and F: demand, sittings, adoption and pool exhaustion."""
from __future__ import annotations
from typing import TYPE_CHECKING
import pytest
from signal_bot.analytics import adoption, content, demand, sessions
from signal_bot.analytics.scope import SESSION_GAP_MS, Scope
from signal_bot.stats.events import MessageEvent
from tests.analytics_seed import ALA, BASIA, CELINA, GROUP, Shape, backfilled, ran
if TYPE_CHECKING:
import sqlite3
from signal_bot.store import Store
DAY = "2026-08-29"
NEXT = "2026-08-30"
TRIGGERS = ("!kot", "!pies", "!pulapka", "!papiez")
def conn_of(store: Store) -> sqlite3.Connection:
return store.commands.connection
# -- family D ----------------------------------------------------------------
def test_near_misses_are_grouped_by_the_command_they_nearly_hit(store: Store) -> None:
"""Seventeen typos of one trigger mean it wants another alias.
Ungrouped they read as seventeen unrelated mistakes, which is exactly
the list the Ideas tab already shows and the reason this adds something.
"""
for at_ms, word in enumerate(("!pulapla", "!pulapak", "!pulakpa", "!kto")):
store.suggestions.record_unknown(GROUP, word, word, at_ms)
found = demand.collect(conn_of(store), Scope(), TRIGGERS)
assert [(miss.target, miss.hits) for miss in found.near_misses] == [
("!pulapka", 3),
("!kot", 1),
]
def test_the_same_typo_twice_counts_twice(store: Store) -> None:
store.suggestions.record_unknown(GROUP, "!kto", "!kto", 1)
store.suggestions.record_unknown(GROUP, "!kto", "!kto raz jeszcze", 2)
found = demand.collect(conn_of(store), Scope(), TRIGGERS)
assert found.near_misses[0].hits == 2
assert found.near_misses[0].variants == 1
def test_a_word_close_to_nothing_is_a_new_idea_not_a_typo(store: Store) -> None:
"""Filing a real request as noise is the failure worth avoiding."""
store.suggestions.record_unknown(GROUP, "!pogodynka", "!pogodynka", 1)
found = demand.collect(conn_of(store), Scope(), TRIGGERS)
assert found.near_misses == ()
assert (found.unclaimed, found.unclaimed_words) == (1, 1)
def test_deliberate_ideas_are_counted_beside_the_typos(store: Store) -> None:
store.suggestions.record_suggestion(GROUP, "!pogoda", "na jutro", 1)
assert demand.collect(conn_of(store), Scope(), TRIGGERS).filed == 1
def test_an_empty_wish_list_reads_as_empty(store: Store) -> None:
assert demand.collect(conn_of(store), Scope(), TRIGGERS) == demand.Demand()
# -- family E ----------------------------------------------------------------
def test_commands_close_together_are_one_sitting(store: Store) -> None:
ran(store, day=DAY, hour=12, minute=0, command="cat")
ran(store, day=DAY, hour=12, minute=1, command="dog")
found = sessions.collect(conn_of(store), Scope())
assert (found.count, found.commands, found.longest) == (1, 2, 2)
assert found.average_length == 2.0
def test_a_long_gap_starts_a_new_sitting(store: Store) -> None:
gap_minutes = SESSION_GAP_MS // 60_000 + 1
ran(store, day=DAY, hour=12, minute=0, command="cat")
ran(store, day=DAY, hour=12, minute=gap_minutes, command="dog")
assert sessions.collect(conn_of(store), Scope()).count == 2
def test_two_people_never_share_a_sitting(store: Store) -> None:
ran(store, day=DAY, hour=12, minute=0, who=ALA, command="cat")
ran(store, day=DAY, hour=12, minute=1, who=BASIA, command="dog")
found = sessions.collect(conn_of(store), Scope())
assert found.count == 2
assert found.pairs == ()
def test_commands_used_together_are_paired_once(store: Store) -> None:
"""(cat, dog) and (dog, cat) are one fact, not two."""
ran(store, day=DAY, hour=12, minute=0, command="dog")
ran(store, day=DAY, hour=12, minute=1, command="cat")
assert sessions.collect(conn_of(store), Scope()).pairs == (("cat", "dog", 1),)
def test_re_rolling_the_same_command_is_counted_as_repeats(store: Store) -> None:
"""Three !papież in a row is a fact about the pool, not about the person."""
for minute in range(3):
ran(store, day=DAY, hour=12, minute=minute, command="pope")
found = sessions.collect(conn_of(store), Scope())
assert found.repeats == (("pope", 2),)
assert found.pairs == ()
def test_rows_with_no_real_time_never_form_a_sitting(store: Store) -> None:
"""A daily_uses row's timestamp is built from midnight, not observed.
Treating those as a sequence invents a sitting in which somebody ran
forty commands in forty milliseconds.
"""
for _ in range(3):
backfilled(store, day=DAY, command="cat")
assert sessions.collect(conn_of(store), Scope()) == sessions.Sessions()
def test_share_of_chat_is_commands_over_messages(store: Store) -> None:
for at_ms in range(4):
store.events.record(
MessageEvent(
group_id=GROUP,
uuid=ALA,
display_name="ala",
sent_at_ms=at_ms,
day=DAY,
local_hour=12,
is_command=at_ms < 1,
)
)
found = adoption.share_of_chat(conn_of(store), Scope())
assert (found.messages, found.commands) == (4, 1)
assert found.fraction == 0.25
def test_share_of_an_empty_group_is_zero(store: Store) -> None:
assert adoption.share_of_chat(conn_of(store), Scope()).fraction == 0.0
assert adoption.Share().fraction == 0.0
def test_newcomers_are_dated_by_their_first_use(store: Store) -> None:
ran(store, day=DAY, who=ALA)
ran(store, day=NEXT, who=ALA, minute=1)
ran(store, day=NEXT, who=BASIA, minute=2)
assert adoption.newcomers(conn_of(store), Scope()) == [(DAY, 1), (NEXT, 1)]
def test_retention_counts_who_came_back(store: Store) -> None:
"""Of the people who started, how many were still there N days later."""
ran(store, day=DAY, who=ALA)
ran(store, day=NEXT, who=ALA, minute=1)
ran(store, day=DAY, who=BASIA, minute=2)
curve = adoption.retention(conn_of(store), Scope(), 2)
assert curve == [(0, 2, 2), (1, 1, 2), (2, 0, 2)]
def test_retention_of_nobody_is_empty(store: Store) -> None:
assert adoption.retention(conn_of(store), Scope(), 3) == []
def test_a_release_is_compared_against_equal_windows(store: Store) -> None:
"""A release on the first recorded day gets a before-window of zero.
Unequal windows would read that as a spike when it is an edge.
"""
series = [
("2026-08-28", 2),
("2026-08-29", 3),
("2026-08-30", 10),
("2026-08-31", 12),
]
found = adoption.around_releases(series, [("0.2.5", "2026-08-30")], 1)
assert found == [("0.2.5", "2026-08-30", 22, 3)]
# -- family F ----------------------------------------------------------------
def test_a_pool_that_never_repeats_reads_as_varied(store: Store) -> None:
for minute, item in enumerate(("Q1", "Q2", "Q3")):
ran(store, day=DAY, minute=minute, command="pope", shape=Shape(item_id=item))
(pool,) = content.pools(conn_of(store), Scope())
assert (pool.draws, pool.distinct) == (3, 3)
assert pool.repeat_rate == 0.0
def test_a_repeating_pool_is_measurable(store: Store) -> None:
"""The bug this exists to catch reached a live group chat once.
iNaturalist accepted order_by=random and silently ignored it, so one
identical photograph came back forever. A repeat rate would have said so.
"""
for minute in range(4):
ran(store, day=DAY, minute=minute, command="pope", shape=Shape(item_id="Q1"))
(pool,) = content.pools(conn_of(store), Scope())
assert (pool.draws, pool.distinct) == (4, 1)
assert pool.repeat_rate == 0.75
assert content.repeats(conn_of(store), Scope()) == [("pope", "Q1", 4)]
def test_a_command_that_names_nothing_has_no_pool(store: Store) -> None:
ran(store, day=DAY, command="stats_today")
assert content.pools(conn_of(store), Scope()) == []
def test_an_undrawn_pool_has_no_repeat_rate() -> None:
assert content.Pool("pope", 0, 0).repeat_rate == 0.0
def test_expected_distinct_says_what_uniform_would_have_looked_like() -> None:
"""The comparison that turns a repeat rate into a verdict."""
assert content.expected_distinct(0, 10) == 0.0
assert content.expected_distinct(266, 0) == 0.0
# 266 traps drawn 19 times should give about 18.4 distinct ones.
assert 18.0 < content.expected_distinct(266, 19) < 18.9
# Drawing once always gives exactly one -- approx because the identity
# holds in real arithmetic and not in binary floating point.
assert content.expected_distinct(10, 1) == pytest.approx(1.0)
def test_everything_is_scoped_to_one_group(store: Store) -> None:
ran(store, day=DAY, command="pope", who=CELINA, shape=Shape(group_id=GROUP, item_id="Q1"))
ran(store, day=DAY, command="pope", who=CELINA, shape=Shape(group_id="group-b==", item_id="Q2"))
assert content.pools(conn_of(store), Scope(GROUP))[0].draws == 1
assert content.pools(conn_of(store), Scope())[0].draws == 2