diff --git a/signal_bot/stats/backfill.py b/signal_bot/stats/backfill.py index 866cf24..d801023 100644 --- a/signal_bot/stats/backfill.py +++ b/signal_bot/stats/backfill.py @@ -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) diff --git a/signal_bot/stats/curiosities.py b/signal_bot/stats/curiosities.py index 3201314..bfef950 100644 --- a/signal_bot/stats/curiosities.py +++ b/signal_bot/stats/curiosities.py @@ -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), diff --git a/signal_bot/stats/render_extra.py b/signal_bot/stats/render_extra.py index d3a1a22..8037ba4 100644 --- a/signal_bot/stats/render_extra.py +++ b/signal_bot/stats/render_extra.py @@ -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 diff --git a/tests/test_stats_backfill.py b/tests/test_stats_backfill.py index 36b3d96..9fa6292 100644 --- a/tests/test_stats_backfill.py +++ b/tests/test_stats_backfill.py @@ -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) diff --git a/tests/test_stats_commands.py b/tests/test_stats_commands.py index 9a25ce4..5cdde18 100644 --- a/tests/test_stats_commands.py +++ b/tests/test_stats_commands.py @@ -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")