mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 20:27:17 +01:00
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
235 lines
8.9 KiB
Python
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
|