mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-10 19:17:02 +01:00
fix: !ja told everybody their favourite hour was noon
Reported from the group: the same "Najczęściej piszesz między 12:00 a 13:00" line came back for Ala and for Jan Nowak, which is too much of a coincidence to be a statistic. It was the backfill. The 342 messages carried over from the pre-0.1.0 counter have no timestamps, so the seeded rows got a placeholder hour of 12 -- and favourite_hour was the one query that never excluded the backfilled day. For every person those synthetic noons outnumbered every real message they had sent since, so all seven got the same answer. The genuine hours are 19-21: this group talks in the evening. The comment above that placeholder said "the day is marked anyway, so nothing that cares about hours will read these", which is exactly the kind of reasoning that stops being true the moment somebody adds a query. So the fix is not another `exclude` argument to remember: the absent hour is now -1, which no real hour can be, and hour queries filter `local_hour >= 0`. Forgetting the exclusion can no longer invent an answer, and night_owl gets the same guard even though its 0..2 window happened to miss the old placeholder. Live database migrated with the service stopped and a backup taken: 342 rows moved from noon to unknown. Verified afterwards on the real data -- Ala 19:00, Jan Nowak 19:00, kuhy 20:00, and the two people whose only messages are backfilled correctly get no hour line at all. Not released. Sitting at 0.1.1 until kuhy says otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015d48t4qsQLBJnDwDeo2Nom
This commit is contained in:
parent
ad93bbfdf7
commit
40538b89a1
@ -28,10 +28,16 @@ if TYPE_CHECKING:
|
||||
from signal_bot.store import Store
|
||||
|
||||
BACKFILL_KEY: Final = "backfill_day"
|
||||
# Noon: a backfilled event has no real hour, and midnight would look like a
|
||||
# deliberate reading rather than an absent one. The day is marked anyway, so
|
||||
# nothing that cares about hours will read these.
|
||||
BACKFILL_HOUR: Final = 12
|
||||
# -1, because a backfilled event has no hour at all and any value in 0..23 is
|
||||
# a lie that reads as data. This started as 12, on the reasoning that the day
|
||||
# is marked so "nothing that cares about hours will read these" -- and then
|
||||
# favourite_hour did, and told all seven people their favourite hour was
|
||||
# 12:00, because 319 synthetic noons outweighed every real evening.
|
||||
#
|
||||
# The lesson is in the type, not in a comment: an absent value must not be
|
||||
# spellable as a present one. Hour queries filter `local_hour >= 0`, so
|
||||
# forgetting to exclude the backfilled day can no longer invent an answer.
|
||||
UNKNOWN_HOUR: Final = -1
|
||||
|
||||
|
||||
def has_legacy_counter(store: Store) -> bool:
|
||||
@ -77,7 +83,7 @@ def seed_from_counter(store: Store, day: str) -> dict[str, int]:
|
||||
display_name=row["display_name"],
|
||||
sent_at_ms=index + 1,
|
||||
day=day,
|
||||
local_hour=BACKFILL_HOUR,
|
||||
local_hour=UNKNOWN_HOUR,
|
||||
)
|
||||
)
|
||||
store.events.note_person(group_id, row["uuid"], row["display_name"], "", day)
|
||||
|
||||
@ -198,6 +198,7 @@ def night_owl(facts: DayFacts) -> Highlight | None:
|
||||
SELECT display_name, local_hour
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND day = ? AND local_hour BETWEEN ? AND ?
|
||||
AND local_hour >= 0
|
||||
ORDER BY local_hour DESC, sent_at_ms DESC LIMIT 1
|
||||
""",
|
||||
(facts.group_id, facts.day, 0, NIGHT_OWL_HOUR),
|
||||
|
||||
@ -174,12 +174,17 @@ def render_me(
|
||||
|
||||
|
||||
def favourite_hour(events: EventStore, group_id: str, uuid: str) -> int | None:
|
||||
"""The hour somebody writes most in, or None if they never have."""
|
||||
"""The hour somebody writes most in, or None when nothing is known.
|
||||
|
||||
Skips rows whose hour is UNKNOWN_HOUR. Those are backfilled from a counter
|
||||
that had no timestamps, and counting them told every member of the group
|
||||
that they write most at noon.
|
||||
"""
|
||||
row = events.connection.execute(
|
||||
"""
|
||||
SELECT local_hour, COUNT(*) AS messages
|
||||
FROM message_events
|
||||
WHERE group_id = ? AND uuid = ?
|
||||
WHERE group_id = ? AND uuid = ? AND local_hour >= 0
|
||||
GROUP BY local_hour
|
||||
ORDER BY messages DESC, local_hour ASC
|
||||
LIMIT 1
|
||||
|
||||
@ -6,7 +6,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from signal_bot.stats.backfill import (
|
||||
BACKFILL_HOUR,
|
||||
UNKNOWN_HOUR,
|
||||
backfilled_day,
|
||||
has_legacy_counter,
|
||||
seed_from_counter,
|
||||
@ -94,7 +94,12 @@ def test_seeded_people_are_known_to_the_roster(store: Store) -> None:
|
||||
|
||||
|
||||
def test_seeded_events_carry_no_invented_detail(store: Store) -> None:
|
||||
"""No hours, no lengths, no media -- the counter knew none of them."""
|
||||
"""No hours, no lengths, no media -- the counter knew none of them.
|
||||
|
||||
The hour is -1 rather than any real hour. It was noon once, and
|
||||
favourite_hour then told all seven people they wrote most at midday: 319
|
||||
synthetic noons outweighed every genuine evening.
|
||||
"""
|
||||
give_legacy_counter(store, [(GROUP, "u1", "Ala", 2)])
|
||||
seed_from_counter(store, DAY)
|
||||
rows = store.events.connection.execute(
|
||||
@ -102,7 +107,7 @@ def test_seeded_events_carry_no_invented_detail(store: Store) -> None:
|
||||
).fetchall()
|
||||
assert len(rows) == 2
|
||||
for row in rows:
|
||||
assert row["local_hour"] == BACKFILL_HOUR
|
||||
assert row["local_hour"] == UNKNOWN_HOUR
|
||||
assert (row["char_len"], row["media_count"], row["is_command"]) == (0, 0, 0)
|
||||
|
||||
|
||||
|
||||
@ -12,9 +12,10 @@ from signal_bot.animals import load_aliases
|
||||
from signal_bot.folding import fold
|
||||
from signal_bot.registry import CommandRegistry
|
||||
from signal_bot.stats import periods
|
||||
from signal_bot.stats.backfill import BACKFILL_KEY
|
||||
from signal_bot.stats.backfill import BACKFILL_KEY, seed_from_counter
|
||||
from signal_bot.stats.commands import HANDLERS, register_stats_commands
|
||||
from tests.stats_seed import GROUP, IGA, KUHY, MITHRA, Shape, wrote
|
||||
from tests.test_stats_backfill import LEGACY as LEGACY_COUNTER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from signal_bot.store import Store
|
||||
@ -44,6 +45,17 @@ class FakeContext:
|
||||
self.replies.append(text)
|
||||
|
||||
|
||||
def seed_from_counter_rows(store: Store, uuid: str, name: str, count: int) -> None:
|
||||
"""Seed a pre-0.1.0 counter and carry it over, as the migration did."""
|
||||
store.events.connection.executescript(LEGACY_COUNTER)
|
||||
store.events.connection.execute(
|
||||
"INSERT INTO message_counts (group_id, uuid, display_name, count) VALUES (?, ?, ?, ?)",
|
||||
(GROUP, uuid, name, count),
|
||||
)
|
||||
store.events.connection.commit()
|
||||
seed_from_counter(store, "2026-08-01")
|
||||
|
||||
|
||||
async def run(store: Store, name: str, uuid: str = KUHY[0]) -> str:
|
||||
"""Run one command and return what it said."""
|
||||
ctx = FakeContext(store=store, message=Sender(source_uuid=uuid))
|
||||
@ -172,6 +184,31 @@ async def test_records_skips_somebody_with_no_days_left_after_exclusions(
|
||||
assert "Basia" not in rendered
|
||||
|
||||
|
||||
async def test_the_backfilled_day_cannot_invent_a_favourite_hour(
|
||||
store: Store,
|
||||
) -> None:
|
||||
"""The 0.1.1 bug: everybody's favourite hour came out as 12:00.
|
||||
|
||||
The backfilled counter had no hours, and the placeholder was noon -- so
|
||||
for every person the 319 seeded rows outvoted every real evening, and
|
||||
!ja told each of them the same thing.
|
||||
"""
|
||||
seed_from_counter_rows(store, KUHY[0], KUHY[1], 300)
|
||||
wrote(store, "2026-08-15", KUHY, 3, Shape(hour=21))
|
||||
answer = await run(store, "stats_me")
|
||||
assert "między 21:00 a 22:00" in answer
|
||||
assert "12:00" not in answer
|
||||
|
||||
|
||||
async def test_somebody_with_only_backfilled_messages_has_no_hour(
|
||||
store: Store,
|
||||
) -> None:
|
||||
"""Nothing is known about when they wrote, so the line is omitted."""
|
||||
seed_from_counter_rows(store, KUHY[0], KUHY[1], 5)
|
||||
answer = await run(store, "stats_me")
|
||||
assert "Najczęściej piszesz" not in answer
|
||||
|
||||
|
||||
async def test_weekdays_marks_today(store: Store) -> None:
|
||||
wrote(store, TODAY, MITHRA, 5)
|
||||
assert "← dziś" in await run(store, "stats_weekdays")
|
||||
|
||||
Loading…
Reference in New Issue
Block a user