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:
Krzysztof Rudnicki 2026-08-30 20:15:20 +02:00
parent ad93bbfdf7
commit 40538b89a1
5 changed files with 65 additions and 11 deletions

View File

@ -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)

View File

@ -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),

View File

@ -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

View File

@ -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)

View File

@ -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")