#!/usr/bin/env python3 """Partition a GroupV2 run: how many branches it ended in, when each split off, and which members are on it. The load-bearing line is `epoch reached`, which libchat writes once per epoch a member applies, carrying that member's MLS epoch authenticator. Two members at the same epoch holding different authenticators have forked; a member at a lower epoch has only fallen behind. Nothing else separates those two, because both drop everything the group sends as undecryptable. Input is either one log per member, or a single log holding several members that is split on the `user_id` span field. A capture from a build without the `epoch reached` line is described rather than partitioned, and the report says so instead of guessing. Usage: forks.py RUNDIR [--json OUT] forks.py --shared LOG [--json OUT] """ import argparse import collections import os import re import sys ANSI = re.compile(r"\x1b\[[0-9;]*m") # `2026-08-21T20:57:08.112841Z INFO span{..}: target: message field=value ...` LINE = re.compile(r"^(?P\d{4}-\d\d-\d\dT[\d:.]+Z)\s+(?P\w+)\s+(?P.*)$") EPOCH_REACHED = re.compile(r"epoch reached .*?\bepoch=(\d+).*?\bauthenticator=([0-9a-f]+)") JOINED = re.compile(r"joined conversation") MINTED = re.compile(r"commit candidate created .*?\bepoch=(\d+)\s+proposals=(\d+)(?:\s+hash=([0-9a-f]+))?") # Every way de-mls names a candidate that arrived. Two ingest paths reach # `receive_commit_candidate`, one straight from the plaintext envelope and one # from the steward branch, and only the second announces itself, so matching a # single message undercounts, on the member that creates the group to zero. CANDIDATE = re.compile( r"candidate (received from peer steward|stashed|ignored)[^\s]*" r"(?:.*?\bepoch=(\d+))?(?:.*?\bhash=([0-9a-f]+))?" ) PHASE = re.compile(r'state transition(?: \(recovery retry\))? .*?\bstate="(\w+)"') DROPPED = re.compile(r"app message ignored \(wrong epoch/conversation\)") # `user_id=