signal-bot/signal_bot/stats/backfill.py
Krzysztof kuhy Rudnicki 8354d9bcde 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 Mithra and for Michał Faron, 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 --
Mithra 19:00, Michał Faron 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
2026-08-30 20:15:20 +02:00

95 lines
3.8 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
"""Carrying the pre-0.1.0 counter into the event log, once.
Before 0.1.0 the bot kept one integer per person and deleted it after each
nightly post. When the event log arrived there were 319 messages sitting in
that counter, accumulated since the last successful post. Throwing them away
would have been tidier, but they are real messages and they are the group's
only pre-history.
What the counter cannot say is *when*. It has no timestamp column, and its
window straddles two calendar days -- from the previous night's post to
whenever the migration runs. So a backfilled day is filed under the day that
holds most of that window, and marked, because a merged 22-hour day pretending
to be a normal one would hand somebody a personal record they never set and
would drag every "average day" figure with it.
Marked days keep their totals and stay out of records and per-hour statistics.
``group_meta`` carries the mark, so this needs no schema change.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Final
from signal_bot.stats.events import MessageEvent
if TYPE_CHECKING:
from signal_bot.store import Store
BACKFILL_KEY: Final = "backfill_day"
# -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:
"""True when this database predates 0.1.0 and still holds the old table."""
row = store.events.connection.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'message_counts'"
).fetchone()
return row is not None
def backfilled_day(store: Store, group_id: str) -> str | None:
"""The one day whose totals came from the old counter, if any."""
return store.meta(group_id, BACKFILL_KEY)
def seed_from_counter(store: Store, day: str) -> dict[str, int]:
"""Copy every surviving tally into the event log as one marked day.
Args:
store: The open database, which must still hold ``message_counts``.
day: The Warsaw day to file the counts under. Choose the day covering
most of the counter's window.
Returns:
Messages seeded per group id, for reporting.
"""
rows = store.events.connection.execute(
"SELECT group_id, uuid, display_name, count FROM message_counts WHERE count > 0"
).fetchall()
seeded: dict[str, int] = {}
for row in rows:
group_id = row["group_id"]
for index in range(row["count"]):
# Synthetic, strictly increasing, and unique per person per group,
# because (group, uuid, sent_at_ms) is the primary key. They are
# not timestamps anybody should read as one, which is what the
# day marking is for.
store.events.record(
MessageEvent(
group_id=group_id,
uuid=row["uuid"],
display_name=row["display_name"],
sent_at_ms=index + 1,
day=day,
local_hour=UNKNOWN_HOUR,
)
)
store.events.note_person(group_id, row["uuid"], row["display_name"], "", day)
seeded[group_id] = seeded.get(group_id, 0) + row["count"]
for group_id in seeded:
store.set_meta(group_id, BACKFILL_KEY, day)
return seeded