signal-bot/signal_bot/versioning.py
Krzysztof kuhy Rudnicki 405541be9b
Some checks are pending
CI / check (push) Waiting to run
CI / web (push) Waiting to run
release: 0.2.6 «Stephanus I» — dodano !analityka
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
2026-08-31 23:19:51 +02:00

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)