#!/usr/bin/env python3
"""Merge every poidh contract into one per-wallet record, then type it.

Inputs (all already on disk, all originally pulled from chain):
  /home/agent/work/poidhv1/events.json          v1  (Arbitrum, 2023-2024)
  /home/agent/work/poidhv1/poidh-v1-wallets.csv v1  per-wallet ETH ledger
  /home/agent/work/poidh/payrate/data/*.jsonl   v2+v3 (arb/base/degen/mainnet)
  /home/agent/work/poidh/payrate/out/bounty_outcomes.csv  v2+v3 outcomes

Output: index.json  -- one record per wallet, plus the corpus-level cut points
the four axes are measured against.

Two counting rules, stated once here because everything downstream inherits
them:

  * A "claim" is a *bounty you claimed on*, not a claim NFT. The site API's
    claimants[] is deduped per bounty (502 of 3,084 bounties have
    len(claimants) < nClaims) so per-wallet claim-NFT counts are not
    recoverable from this dataset. Distinct bounties is the honest unit.
  * Value is USD at bounty creation. v2/v3 carry priceUsd from the site.
    v1 is ETH on Arbitrum and carries no USD, so it is converted at the
    ETH/USD daily close (prices.json, DefiLlama). A wallet whose whole
    history is v1 still gets a real USD number.
"""
import csv, json, glob, math, os, statistics, sys
from bisect import bisect_left
from collections import defaultdict, Counter
from datetime import datetime, timezone

V1 = "/home/agent/work/poidhv1"
PR = "/home/agent/work/poidh/payrate"
HERE = os.path.dirname(os.path.abspath(__file__))

WEI = 10 ** 18


def day(ts):
    return datetime.fromtimestamp(ts, timezone.utc).strftime("%Y-%m-%d")


# ---------------------------------------------------------------- blank record
def blank():
    return {
        "created": 0, "created_paid": 0, "created_cancelled": 0,
        "created_open": 0, "claimed": 0, "claimed_won": 0,
        "usd_posted": 0.0, "usd_won": 0.0,
        "chains": set(), "gens": set(),
        "first_ts": None, "last_ts": None,
        "titles_posted": [], "titles_won": [],
        "wait_days": [],          # days their own bounties waited for a claim
        "n_rivals": [],           # claimers on bounties they claimed
        "sizes": [],              # USD of every bounty they posted or claimed on
    }


W = defaultdict(blank)


def touch(a, ts):
    r = W[a]
    if ts:
        r["first_ts"] = ts if r["first_ts"] is None else min(r["first_ts"], ts)
        r["last_ts"] = ts if r["last_ts"] is None else max(r["last_ts"], ts)


# ------------------------------------------------------------------ eth prices
prices = json.load(open(f"{HERE}/prices.json"))


def eth_usd(ts):
    d = day(ts)
    if d in prices:
        return prices[d]
    # nearest earlier day we have; the series is dense so this is a 1-2 day gap
    keys = [k for k in prices if k <= d]
    return prices[max(keys)] if keys else 0.0


# ------------------------------------------------------------------------- v1
v1 = json.load(open(f"{V1}/events.json"))
v1b = {b["id"]: b for b in v1["bounties"]}
v1_accepted = {a["bountyId"]: a for a in v1["accepted"]}
v1_cancelled = {c["bountyId"] for c in v1["cancelled"]}

# who won each v1 bounty
v1_claim = {c["id"]: c for c in v1["claims"]}
v1_winner = {}
for a in v1["accepted"]:
    v1_winner[a["bountyId"]] = a["claimIssuer"].lower()

for b in v1["bounties"]:
    a = b["issuer"].lower()
    ts = b["createdAt"]
    usd = int(b["amount"]) / WEI * eth_usd(ts)
    r = W[a]
    r["created"] += 1
    r["usd_posted"] += usd
    r["chains"].add("arbitrum")
    r["gens"].add("v1")
    r["titles_posted"].append(b["name"])
    if usd > 0:
        r["sizes"].append(usd)
    if b["id"] in v1_winner:
        r["created_paid"] += 1
    elif b["id"] in v1_cancelled:
        r["created_cancelled"] += 1
    else:
        r["created_open"] += 1
    touch(a, ts)

# Per-wallet claim-NFT counters, filled by v1 here and by v2/v3 below. `claimed`
# counts bounties claimed on; these count the claims themselves, which is a
# different number whenever somebody files twice on one bounty.
n_claim_nfts = defaultdict(int)
multi = defaultdict(int)                 # bounties they filed >1 claim on
handle_votes = defaultdict(Counter)

v1_claims_by = defaultdict(set)          # addr -> set(bountyId)
v1_per = defaultdict(Counter)            # bountyId -> addr -> claims filed
V1_CLAIMS = 0
for c in v1["claims"]:
    a = c["issuer"].lower()
    v1_claims_by[a].add(c["bountyId"])
    v1_per[c["bountyId"]][a] += 1
    n_claim_nfts[a] += 1
    V1_CLAIMS += 1
    touch(a, c["createdAt"])
for bid, per in v1_per.items():
    for a, k in per.items():
        if k > 1:
            multi[a] += 1

for a, bids in v1_claims_by.items():
    r = W[a]
    r["chains"].add("arbitrum")
    r["gens"].add("v1")
    r["claimed"] += len(bids)
    for bid in bids:
        b = v1b.get(bid)
        if b:
            u = int(b["amount"]) / WEI * eth_usd(b["createdAt"])
            if u > 0:
                r["sizes"].append(u)
        if v1_winner.get(bid) == a:
            r["claimed_won"] += 1
            if b:
                r["usd_won"] += int(b["amount"]) / WEI * eth_usd(b["createdAt"])
                r["titles_won"].append(b["name"])

# v1 first-claim wait, per issuer
v1_first_claim = {}
for c in v1["claims"]:
    bid = c["bountyId"]
    if bid not in v1_first_claim or c["createdAt"] < v1_first_claim[bid]:
        v1_first_claim[bid] = c["createdAt"]
for bid, t in v1_first_claim.items():
    b = v1b.get(bid)
    if b:
        W[b["issuer"].lower()]["wait_days"].append(
            (t - b["createdAt"]) / 86400.0)

# v1 rivals
v1_claimers = defaultdict(set)
for c in v1["claims"]:
    v1_claimers[c["bountyId"]].add(c["issuer"].lower())
for bid, s in v1_claimers.items():
    for a in s:
        W[a]["n_rivals"].append(len(s))

# ---------------------------------------------------------------------- v2/v3
# Outcome per (chain, site_id). site_id is the only 1:1 key: v2 and v3 both
# number their bounties from 1 on the same chain, so (chain, on_chain_id) is
# ambiguous, and a gen-guessing join silently attached base site 1339 -- a
# bounty pulled after the outcomes CSV was frozen -- to somebody else's row.
# The tell was a total of 3,580 attributed bounties against 3,579 real ones.
outcome = {}
for row in csv.DictReader(open(f"{PR}/out/bounty_outcomes.csv")):
    outcome[(row["chain"], int(row["site_id"]))] = row
UNMATCHED = []

# ---------------------------------------------- the fresh per-claim pull
# claims_raw.jsonl is poidh's own /{chain}/bounty/{id}/data endpoint, one line
# per site id. It carries the full claims array -- the chain-derived pull only
# kept claimants[], deduped per bounty -- and each claim's farcasterHandle,
# which is where the username map comes from.
#
# It is loaded HERE, before the merge, because the two sources disagree and the
# disagreement has to be resolved per bounty rather than counted twice. Where
# the endpoint answers, it is authoritative: it is fresher and it is per-claim.
# Where it 404s, the chain-derived pull is all there is.
RAW = f"{HERE}/claims_raw.jsonl"
raw = {}
if os.path.exists(RAW):
    for line in open(RAW):
        d = json.loads(line)
        if d.get("missing") or d.get("failed"):
            continue
        raw[(d["chain"], d["site_id"])] = d
    print(f"per-claim pull: {len(raw)} bounties answered")
else:
    print("note: no claims_raw.jsonl -- claim-NFT counts and handles are absent")

grew = shrank = 0                  # vs the chain-derived pull
grew_resolved = []                 # growth on an already-resolved bounty
no_detail = 0                      # corpus bounties the endpoint would not return
unattributed = 0                   # their claims that cannot be pinned to a wallet
V23_CLAIMS = 0                     # v2+v3 only; v1 is counted above as V1_CLAIMS

for f in sorted(glob.glob(f"{PR}/data/*.jsonl")):
    for line in open(f):
        d = json.loads(line)
        if "issuer" not in d:
            continue                      # 3 sentinel rows: site fetch failed
        chain = d["chain"]
        row = outcome.get((chain, d["siteId"]))
        if row is None:
            UNMATCHED.append((chain, d["siteId"]))
            continue
        gen = row["gen"]
        usd = float(row["usd_at_creation"] or 0)
        ts = d["createdAt"]
        iss = d["issuer"].lower()
        winner = (row["winner"] or "").lower() or None

        r = W[iss]
        r["created"] += 1
        r["usd_posted"] += usd
        r["chains"].add(chain)
        r["gens"].add(gen)
        r["titles_posted"].append(row["title"])
        if usd > 0:
            r["sizes"].append(usd)
        r["created_paid" if row["outcome"] == "paid" else
          "created_cancelled" if row["outcome"] == "cancelled" else
          "created_open"] += 1
        touch(iss, ts)
        if row["days_first_claim_waiting"]:
            r["wait_days"].append(float(row["days_first_claim_waiting"]))

        frozen = {c.lower() for c in (d.get("claimants") or [])}
        n_frozen = int(d.get("nClaims") or 0)
        fresh = raw.get((chain, d["siteId"]))

        if fresh is not None:
            per = Counter(c["a"] for c in fresh["claims"] if c["a"])
            n_fresh = sum(per.values())
            # Claims cannot vanish. If the endpoint reports fewer than the chain
            # did, the two sources disagree about the past and neither can be
            # trusted for per-wallet counts.
            if n_fresh < n_frozen:
                sys.exit(f"HALT: {chain} site {d['siteId']} has {n_fresh} claims "
                         f"on the endpoint but {n_frozen} on chain. Claims do not "
                         f"disappear; one of the two pulls is wrong.")
            if n_fresh > n_frozen:
                grew += 1
                # Growth is only explicable as snapshot skew: the chain pull was
                # frozen earlier, and people kept claiming. That can only happen
                # on a bounty that was still open. Growth on a resolved one would
                # mean something else, so it halts below.
                if row["outcome"] != "open":
                    grew_resolved.append((chain, d["siteId"], n_frozen, n_fresh,
                                          row["outcome"]))
            for a, k in per.items():
                n_claim_nfts[a] += k
                if k > 1:
                    multi[a] += 1
            for c in fresh["claims"]:
                if c["a"] and c.get("fc"):
                    handle_votes[c["a"]][c["fc"].lower()] += 1
            claimants = set(per)
            V23_CLAIMS += n_fresh
        else:
            # The endpoint 404s on 51 real bounties (mostly early Arbitrum).
            # claimants[] is deduped, so the most that can be attributed is one
            # claim each; the rest is counted and reported, not quietly dropped.
            no_detail += 1
            for a in frozen:
                n_claim_nfts[a] += 1
            claimants = frozen
            V23_CLAIMS += len(frozen)
            unattributed += max(0, n_frozen - len(frozen))

        for a in claimants:
            c = W[a]
            c["claimed"] += 1
            c["chains"].add(chain)
            c["gens"].add(gen)
            c["n_rivals"].append(len(claimants))
            if usd > 0:
                c["sizes"].append(usd)
            touch(a, ts)
            if winner and a == winner:
                c["claimed_won"] += 1
                c["usd_won"] += usd
                c["titles_won"].append(row["title"])

if UNMATCHED:
    print(f"note: {len(UNMATCHED)} bounties in the raw pull have no outcome row "
          f"and are excluded: {UNMATCHED}")

attributed = sum(r["created"] for r in W.values())
expect = len(v1["bounties"]) + len(outcome)
if attributed != expect:
    sys.exit(f"HALT: attributed {attributed} bounties, expected {expect}")

# ------------------------------------------------- what the merge cost, checked
# Pre-registered before the pull landed, and enforced here so it fires rather
# than being something I merely intended to look at.
#
#  1. No bounty may shrink -- checked inline above, since claims cannot vanish.
#  2. Any bounty that grew must still be open. The chain pull was frozen before
#     the endpoint pull, so growth is snapshot skew and skew can only touch a
#     bounty people could still claim on. Growth on a resolved bounty would mean
#     the two sources disagree about settled history, which is a different and
#     much worse problem.
#  3. The endpoint 404s on some real bounties, and for those the deduped
#     claimants list is the ceiling on what can be attributed to a wallet. That
#     shortfall must stay under 1% of all claims. 1% is the number to beat
#     because it is genuinely in doubt: the hole is ~290 claims wide against a
#     corpus near 11,600, and how much of it is recoverable depends entirely on
#     how many of those claims were duplicates by one wallet -- which I could
#     not know before running it. A looser bound would have passed no matter
#     what the data said, which is not a check.
if grew_resolved:
    sys.exit(f"HALT: {len(grew_resolved)} resolved bounties gained claims since "
             f"the chain pull was frozen: {grew_resolved[:5]}. Snapshot skew "
             f"cannot explain that.")
#     The denominator is the v2+v3 claim count, not every claim: v1 comes out of
#     the chain pull per-claim and is fully attributed, so folding it in would
#     dilute the very rate the bound is meant to catch.
UNATTRIB_MAX = 0.01
if V23_CLAIMS and unattributed / V23_CLAIMS > UNATTRIB_MAX:
    sys.exit(f"HALT: {unattributed} of {V23_CLAIMS} claims "
             f"({unattributed/V23_CLAIMS:.2%}) sit on bounties the endpoint "
             f"will not return and cannot be pinned to a wallet -- past the "
             f"pre-registered {UNATTRIB_MAX:.0%}. Per-wallet claim counts would "
             f"be understated by more than the page could honestly caveat.")
print(f"merge: {len(raw)} bounties from the endpoint, {no_detail} it would not "
      f"return; {grew} grew vs the chain pull (all still open), 0 shrank")
print(f"       {V1_CLAIMS} v1 + {V23_CLAIMS} v2/v3 = {V1_CLAIMS + V23_CLAIMS} claims; "
      f"{unattributed} v2/v3 claims unattributable "
      f"({unattributed/max(V23_CLAIMS,1):.2%}), "
      f"{len(handle_votes)} wallets with a Farcaster handle")

# ------------------------------------------------------- corpus-level cutpoints
# The whale axis is the size of the bounties you *engage with*, not the value
# you extracted. An earlier cut on (posted + won) collapsed: 2,883 of 3,452
# wallets are claimers who never won and never posted, so their value is 0,
# the population median was 0, and "usd >= median" was true for every single
# wallet. An axis that gives everyone the same letter is not an axis.
def wsize(r):
    return statistics.median(r["sizes"]) if r["sizes"] else None

sizes = sorted(x for x in (wsize(r) for r in W.values()) if x is not None)
MED_USD = statistics.median(sizes)

vals = sorted(r["usd_posted"] + r["usd_won"] for r in W.values())
P99_USD = vals[int(len(vals) * 0.99)]

acts = sorted(r["created"] + r["claimed"] for r in W.values())
MED_ACT = statistics.median(acts)

# ------------------------------------------------------------------ the axes
AXES = [
    # pos, neg, pos name, neg name, what the page says to a wallet on each
    # side, and the rule itself. The rule text lives here and nowhere else --
    # gen_page.py reads it out of index.json rather than restating it, because
    # a rule written down twice drifts on whichever copy the check misses.
    ("M", "T", "Maker", "Taker",
     "You post bounties more often than you chase them.",
     "You chase bounties more often than you post them.",
     "You have posted more bounties than you have claimed on."),
    ("W", "S", "Whale", "Shrimp",
     "You play for the bigger bounties.",
     "You play for the small ones.",
     "The median bounty you touch is worth at least the median across all "
     "wallets."),
    ("R", "H", "Roamer", "Homebody",
     "You are active on more than one chain.",
     "You stick to a single chain.",
     "You have been active on more than one of the four chains."),
    ("C", "G", "Closer", "Ghost",
     "Most of what you touch resolves.",
     "Most of what you touch never resolves.",
     "At least half of everything you touched resolved: your bounties paid "
     "out, or your claims were accepted."),
]

TYPES = {
    "MWRC": ("The Patron", "Big money, many chains, and the bounties actually pay. poidh's closest thing to an institution."),
    "MWRG": ("The Vaporware Baron", "Posts large, posts everywhere, and very little of it ever closes. The board is full of your ghosts."),
    "MWHC": ("The Local Sponsor", "One chain, real money, reliable payouts. Everyone on your chain knows your name."),
    "MWHG": ("The Ghost Whale", "Deep pockets on a single chain, but your bounties mostly die of old age or get cancelled."),
    "MSRC": ("The Micro-Patron", "Small bounties scattered across chains, and you close them out. Cheap, prolific, dependable."),
    "MSRG": ("The Idea Fountain", "You post more ideas than the network can absorb, everywhere at once, and most go unclaimed."),
    "MSHC": ("The Neighbourhood Poster", "Modest bounties on your home chain that reliably find a claimer. The backbone."),
    "MSHG": ("The Dust Poster", "A handful of tiny bounties on one chain that nobody ever came for."),
    "TWRC": ("The Mercenary", "You hunt across chains, you go where the money is, and you win. The most feared line on a bounty page."),
    "TWRG": ("The Long Shot", "You swing at the big ones on every chain. Mostly you miss, but the upside is why you're here."),
    "TWHC": ("The Specialist", "One chain, big bounties, high hit rate. You know your patch better than anyone."),
    "TWHG": ("The Whale Chaser", "You only bother for the large ones on your chain, and they mostly go to someone else."),
    "TSRC": ("The Journeyman", "Small jobs, several chains, and you land them. Steady, unglamorous, effective."),
    "TSRG": ("The Wanderer", "You drift between chains claiming small bounties and rarely get picked."),
    "TSHC": ("The Regular", "You show up on your chain, claim modest bounties, and get paid. A poidh local."),
    "TSHG": ("The Lurker", "A few small claims on one chain, none of them accepted. Everyone starts here."),
}


def type_of(r):
    created, claimed = r["created"], r["claimed"]
    usd = r["usd_posted"] + r["usd_won"]
    resolved = r["created_paid"] + r["claimed_won"]
    acted = created + claimed
    a1 = "M" if created > claimed else "T" if claimed > created else \
         ("M" if created else "T")
    ws = wsize(r)
    a2 = "W" if ws is not None and ws >= MED_USD else "S"
    a3 = "R" if len(r["chains"]) > 1 else "H"
    a4 = "C" if acted and resolved / acted >= 0.5 else "G"
    return a1 + a2 + a3 + a4


# --------------------------------------------------------------------- badges
def badges(a, r):
    out = []
    acted = r["created"] + r["claimed"]
    usd = r["usd_posted"] + r["usd_won"]
    if "v1" in r["gens"]:
        out.append(("OG", "Active on poidh v1, the 2023 Arbitrum original"))
    if len(r["chains"]) == 4:
        out.append(("Four Chains", "Active on Arbitrum, Base, Degen and Ethereum"))
    if r["created"] and r["claimed"]:
        out.append(("Both Sides", "You have both posted a bounty and claimed one"))
    if usd >= P99_USD:
        out.append(("Top 1%", "Top 1% of all poidh wallets by USD moved"))
    if r["claimed"] >= 3 and r["claimed_won"] == r["claimed"]:
        out.append(("Perfect Record", "Every bounty you claimed on, you won"))
    if r["claimed"] >= 5 and r["claimed_won"] == 0:
        out.append(("Still Trying", "Five or more claims, none accepted yet"))
    if r["created"] >= 3 and r["created_paid"] == 0:
        out.append(("Never Paid Out", "Three or more bounties posted, none ever paid"))
    if r["created_cancelled"] >= 3 and r["created_cancelled"] > r["created_paid"]:
        out.append(("Serial Canceller", "You cancel more bounties than you pay"))
    if acted == 1:
        out.append(("One and Done", "Exactly one action on poidh, ever"))
    if r["wait_days"] and statistics.median(r["wait_days"]) < 1:
        out.append(("Fast Board", "Your bounties get their first claim within a day"))
    if r["n_rivals"] and statistics.median(r["n_rivals"]) >= 5:
        out.append(("Crowd Fighter", "You claim on the busy bounties, 5+ rivals typical"))
    if r["n_rivals"] and max(r["n_rivals"]) == 1 and r["claimed"] >= 3:
        out.append(("Uncontested", "Nobody has ever claimed against you"))
    if multi.get(a, 0) >= 3:
        out.append(("Persistent", "You have filed more than one claim on the "
                                  "same bounty, three times or more"))
    return out


# ------------------------------------------------------------------- assemble
recs = {}
for a, r in W.items():
    t = type_of(r)
    usd = r["usd_posted"] + r["usd_won"]
    recs[a] = {
        "a": a,
        "t": t,
        "cr": r["created"], "crp": r["created_paid"],
        "crc": r["created_cancelled"], "cro": r["created_open"],
        "cl": r["claimed"], "clw": r["claimed_won"],
        "up": round(r["usd_posted"], 2), "uw": round(r["usd_won"], 2),
        "ch": sorted(r["chains"]), "gn": sorted(r["gens"]),
        "f": r["first_ts"], "l": r["last_ts"],
        "b": badges(a, r),
        "tp": r["titles_posted"][:3], "tw": r["titles_won"][:3],
        "wd": round(statistics.median(r["wait_days"]), 2) if r["wait_days"] else None,
        "nr": round(statistics.median(r["n_rivals"]), 1) if r["n_rivals"] else None,
        "ms": round(wsize(r), 2) if r["sizes"] else None,
        "cn": n_claim_nfts.get(a, 0),
        "fc": (handle_votes[a].most_common(1)[0][0] if handle_votes.get(a) else None),
    }

# Percentile ranks over the whole population, as the share of wallets strictly
# below you. Not sort position: activity is a small integer, so thousands of
# wallets tie on it, and ranking by position would hand two identical wallets
# percentiles hundreds of places apart on nothing but list order. It also read
# wrong at the top -- position/(n-1) gives the single busiest wallet 100.0, and
# the page then says "more active than 100% of poidh wallets", counting itself.
# Strictly-below makes ties equal and caps the top below 100 by construction.
def pct_map(score):
    vals = sorted(score(a) for a in recs)
    n = len(vals)
    # Truncate rather than round: the busiest wallet has 3,455 of 3,456 below it,
    # which is 99.97, and rounding to one place turns that back into the "100% of
    # wallets, including me" the strictly-below rule was there to prevent. Every
    # figure the page prints is therefore a share the wallet really does beat.
    return {a: math.floor(1000.0 * bisect_left(vals, score(a)) / n) / 10.0
            for a in recs}

pv = pct_map(lambda a: recs[a]["up"] + recs[a]["uw"])
pa = pct_map(lambda a: recs[a]["cr"] + recs[a]["cl"])
assert max(pv.values()) < 100 and max(pa.values()) < 100
for a, r in recs.items():
    r["pv"] = pv[a]      # percentile by USD moved
    r["pa"] = pa[a]      # percentile by activity

counts = defaultdict(int)
for r in recs.values():
    counts[r["t"]] += 1

# Pre-registered bound, checked here so it actually fires. poidh is genuinely
# lopsided (far more claimers than posters) so this is NOT a balance
# requirement -- it is a floor that catches an axis which computed nothing,
# the way the first whale cut did at 3452/0.
FLOOR = 0.02
splits = {}
for i, (pos, neg, pn, nn, _, _, rule) in enumerate(AXES):
    n_pos = sum(1 for r in recs.values() if r["t"][i] == pos)
    share = n_pos / len(recs)
    splits[pos + neg] = {"pos": pos, "neg": neg, "pos_name": pn,
                         "neg_name": nn, "n_pos": n_pos,
                         "n_neg": len(recs) - n_pos, "rule": rule,
                         "share_pos": round(share, 4)}
    if not (FLOOR <= share <= 1 - FLOOR):
        sys.exit(f"HALT: axis {pos}/{neg} is {n_pos}/{len(recs)-n_pos} "
                 f"({share:.1%}) -- past the pre-registered {FLOOR:.0%} floor. "
                 f"An axis this lopsided is a broken rule, not a finding.")

# handle -> every wallet that has claimed under it, busiest first. One in ten
# handles has more than one, because people change wallets and keep the account.
# Picking the busiest and dropping the rest would be wrong for a quarter of
# those claims, so the whole list ships and the page offers the alternatives
# instead of quietly choosing.
by_handle = defaultdict(list)
for a, r in recs.items():
    if r["fc"]:
        by_handle[r["fc"]].append(a)
H = {h: sorted(v, key=lambda a: -(recs[a]["cn"] + recs[a]["cr"]))
     for h, v in by_handle.items()}
handle_collisions = sum(1 for v in H.values() if len(v) > 1)
print(f"handle map: {len(H)} usernames, {handle_collisions} of them on >1 wallet")

#  4. The published claim total must equal the sum of the per-wallet counts. It is
#     the same quantity computed two ways -- once as a running total over bounties,
#     once as a sum over wallets -- and they were not equal the first time: the
#     total was v2/v3 only while the per-wallet counts already carried v1, so the
#     page would have said "12,556 claims across v1, v2 and v3" while reporting
#     11,834. A headline figure and the records under it must be the same number.
_sum_cn = sum(r["cn"] for r in recs.values())
if _sum_cn != V1_CLAIMS + V23_CLAIMS:
    sys.exit(f"HALT: {V1_CLAIMS + V23_CLAIMS} claims counted over bounties but "
             f"{_sum_cn} summed over wallets. One of the two walks is wrong.")

out = {
    "generated_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
    "n_wallets": len(recs),
    "n_bounties": attributed,
    "n_claims": V1_CLAIMS + V23_CLAIMS,
    "n_claims_v1": V1_CLAIMS,
    "n_claims_v23": V23_CLAIMS,
    "n_handles": len(H),
    "handle_collisions": handle_collisions,
    "n_bounties_no_claim_detail": no_detail,
    "n_claims_unattributed": unattributed,
    "n_bounties_grew_since_chain_pull": grew,
    "h": H,
    "cut_usd": round(MED_USD, 2),
    "p99_usd": round(P99_USD, 2),
    "types": {k: {"name": v[0], "blurb": v[1], "n": counts[k]}
              for k, v in TYPES.items()},
    "axes": AXES,
    "splits": splits,
    "w": recs,
}
json.dump(out, open(f"{HERE}/index.json", "w"), separators=(",", ":"))
print(f"{len(recs)} wallets  median bounty-size cut ${MED_USD:.2f}  p99 ${P99_USD:.2f}")
for k, v in splits.items():
    print(f"  axis {k}: {v['n_pos']:5d} {v['pos_name']:10s} / "
          f"{v['n_neg']:5d} {v['neg_name']}")
print(f"index.json {os.path.getsize(f'{HERE}/index.json'):,} bytes")
for k in sorted(counts, key=lambda k: -counts[k]):
    print(f"  {k} {TYPES[k][0]:26s} {counts[k]:5d}")
