mirror of
https://github.com/kuhyx/signal-bot.git
synced 2026-11-11 13:07:51 +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
209 lines
6.8 KiB
Python
209 lines
6.8 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki. MIT licence.
|
|
"""Release versions named after the popes, in order.
|
|
|
|
Release *n* is pope *n*: 0.0.0 is Święty Piotr Apostoł, and the version is a
|
|
pure function of the ordinal rather than a judgement about the size of a
|
|
change. The one fixed point is Jan Paweł II at 2.13.7, which is what picks
|
|
the two constants below -- eight patches per minor, and majors opening at
|
|
ordinals 33 and 153:
|
|
|
|
(264 - 153) = 111 = 13 * 8 + 7 -> 2.13.7
|
|
|
|
Both earlier majors happen to divide evenly by eight as well (32 = 4 * 8,
|
|
120 = 15 * 8), so no bracket needs a fudge factor. The anchors are not
|
|
arbitrary either: 33 is Sylwester I, pope through the Edict of Milan and
|
|
Nicaea, and 153 is Wiktor II, the first pope elected after the 1054 schism.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Final
|
|
from urllib.parse import quote
|
|
|
|
# Ordinals at which a new major version opens. Ascending, first entry 1.
|
|
MAJOR_STARTS: Final = (1, 33, 153)
|
|
PATCHES_PER_MINOR: Final = 8
|
|
|
|
# Which release this build is. Bump by one per release; everything else,
|
|
# including the version string in pyproject.toml, follows from it.
|
|
RELEASE_ORDINAL: Final = 23
|
|
|
|
_POPES_FILE: Final = Path(__file__).with_name("popes.json")
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Pope:
|
|
"""One entry of the papal list, as the version ladder needs it."""
|
|
|
|
ordinal: int
|
|
name: str
|
|
# The pl.wikipedia article title, which is name plus any "(papież)"
|
|
# disambiguator -- so it is the URL slug, and name is not.
|
|
title: str
|
|
reign: str
|
|
portrait: str
|
|
latin: str
|
|
|
|
|
|
def _load() -> tuple[Pope, ...]:
|
|
"""Read popes.json once, at import."""
|
|
document: dict[str, Any] = json.loads(_POPES_FILE.read_text(encoding="utf-8"))
|
|
return tuple(
|
|
Pope(
|
|
ordinal=int(entry["ordinal"]),
|
|
name=str(entry["name"]),
|
|
title=str(entry["title"]),
|
|
reign=str(entry["reign"]),
|
|
portrait=str(entry["portrait"]),
|
|
latin=str(entry["latin"]),
|
|
)
|
|
for entry in document["popes"]
|
|
)
|
|
|
|
|
|
POPES: Final = _load()
|
|
|
|
|
|
def version_for(ordinal: int) -> str:
|
|
"""Return the dotted version string for the given papal ordinal.
|
|
|
|
Raises:
|
|
ValueError: if the ordinal is outside the known list.
|
|
"""
|
|
if not 1 <= ordinal <= len(POPES):
|
|
message = f"no pope with ordinal {ordinal}; the list has {len(POPES)}"
|
|
raise ValueError(message)
|
|
major = max(index for index, start in enumerate(MAJOR_STARTS) if ordinal >= start)
|
|
offset = ordinal - MAJOR_STARTS[major]
|
|
return f"{major}.{offset // PATCHES_PER_MINOR}.{offset % PATCHES_PER_MINOR}"
|
|
|
|
|
|
def pope_for(ordinal: int) -> Pope:
|
|
"""Return the pope with the given ordinal.
|
|
|
|
Raises:
|
|
ValueError: if the ordinal is outside the known list.
|
|
"""
|
|
if not 1 <= ordinal <= len(POPES):
|
|
message = f"no pope with ordinal {ordinal}; the list has {len(POPES)}"
|
|
raise ValueError(message)
|
|
return POPES[ordinal - 1]
|
|
|
|
|
|
def ordinal_for(version: str) -> int:
|
|
"""Return the papal ordinal a version string denotes.
|
|
|
|
Raises:
|
|
ValueError: if no release carries that version.
|
|
"""
|
|
for pope in POPES:
|
|
if version_for(pope.ordinal) == version:
|
|
return pope.ordinal
|
|
message = f"no release carries version {version!r}"
|
|
raise ValueError(message)
|
|
|
|
|
|
# Latin ordinals exactly as popes.json spells them, and the number each
|
|
# carries. The vocabulary is closed: these fifteen words are every trailing
|
|
# token in the file's `latin` column apart from four epithets, and a compound
|
|
# reads as a sum -- "Leo Decimus Quartus" is Leo 10+4.
|
|
LATIN_ORDINALS: Final = {
|
|
"Primus": 1,
|
|
"Secundus": 2,
|
|
"Tertius": 3,
|
|
"Quartus": 4,
|
|
"Quintus": 5,
|
|
"Sextus": 6,
|
|
"Septimus": 7,
|
|
"Octavus": 8,
|
|
"Nonus": 9,
|
|
"Decimus": 10,
|
|
"Undecimus": 11,
|
|
"Duodecimus": 12,
|
|
"Duodevicesimus": 18,
|
|
"Undevicesimus": 19,
|
|
"Vicesimus": 20,
|
|
}
|
|
|
|
# The five popes whose Latin trails an epithet or an artifact rather than an
|
|
# ordinal, so stripping cannot find the stem. Enumerated rather than pattern
|
|
# matched: the list is closed at 267 and a rule broad enough to drop "Magnus"
|
|
# would also eat a legitimate second name like "Ioannes Paulus".
|
|
_STEM_OVERRIDES: Final = {
|
|
4: "Clemens", # Clemens Romanus
|
|
45: "Leo", # Leo Magnus
|
|
64: "Gregorius", # Gregorius Magnus
|
|
68: "Adeodatus", # Adeodatus lub Deusdedit -- Polish "or" in a Latin field
|
|
105: "Nicolaus", # Nicolaus Magnus
|
|
}
|
|
|
|
_ROMAN: Final = re.compile(r"^[IVXLC]+$")
|
|
|
|
WIKI_BASE: Final = "https://pl.wikipedia.org/wiki/"
|
|
|
|
|
|
def latin_stem(pope: Pope) -> str:
|
|
"""Return the Latin name with any spelled-out ordinal removed."""
|
|
override = _STEM_OVERRIDES.get(pope.ordinal)
|
|
if override is not None:
|
|
return override
|
|
words = pope.latin.split()
|
|
while words and words[-1] in LATIN_ORDINALS:
|
|
words.pop()
|
|
return " ".join(words)
|
|
|
|
|
|
def roman_numeral(pope: Pope) -> str:
|
|
"""Return the pope's Roman numeral, or "" for the 44 who carry none.
|
|
|
|
Read off the Polish name rather than the Latin, because the Latin is the
|
|
field that omits it: Pius I is stored as plain "Pius". No name holds two
|
|
numeral-shaped tokens, so the first hit is the answer.
|
|
"""
|
|
for token in pope.name.split():
|
|
if _ROMAN.match(token):
|
|
return token
|
|
return ""
|
|
|
|
|
|
def latin_name(pope: Pope) -> str:
|
|
"""Return the release name for a pope: Latin stem plus Roman numeral.
|
|
|
|
This is what a version is called everywhere -- in the group, in
|
|
DOCS-changelog.md, in /healthz and in the release commit subject. Not the
|
|
Polish name, and not the raw `latin` field, which spells the ordinal out
|
|
("Leo Decimus Quartus") where a version wants "Leo XIV".
|
|
"""
|
|
numeral = roman_numeral(pope)
|
|
stem = latin_stem(pope)
|
|
return f"{stem} {numeral}" if numeral else stem
|
|
|
|
|
|
def pope_line(pope: Pope) -> str:
|
|
"""Where a release's pope sits in the list, and when he reigned.
|
|
|
|
Derived from popes.json on both counts -- the ordinal, and the `reign`
|
|
string exactly as the table spells it, which is why a single-year
|
|
pontificate reads "pontyfikat 2025" rather than being padded into a
|
|
range it never had. Typing either by hand is how a changelog ends up
|
|
claiming a pope reigned in the wrong century.
|
|
"""
|
|
return f"papież nr {pope.ordinal} z {len(POPES)}, pontyfikat {pope.reign}"
|
|
|
|
|
|
def article_url(pope: Pope) -> str:
|
|
"""Return the pl.wikipedia URL for a pope.
|
|
|
|
Built from title, not name: half the early popes live at "Ewaryst
|
|
(papież)" and only the title carries the disambiguator.
|
|
"""
|
|
return WIKI_BASE + quote(pope.title.replace(" ", "_"))
|
|
|
|
|
|
__version__: Final = version_for(RELEASE_ORDINAL)
|
|
POPE: Final = pope_for(RELEASE_ORDINAL)
|