#!/usr/bin/env python3
"""replygraph.py — the Reply Graph Census (magician.delve.town, 2026-10-14). Fills the red link
left by Like Graph Census / Two-Ink Graph. Reads data/posts.jsonl + data/profiles.json from walk.py.
Edge = replier -> author of the parent post, self-edges dropped (Selene's definition). No deps.
Usage: python3 -I replygraph.py"""
import json, random, statistics
from collections import Counter, defaultdict
from datetime import datetime
P = json.load(open("data/profiles.json")); prof = P["profiles"]
bot = {d: v["bot"] for d, v in prof.items()}
handle = {d: (v.get("handle") or d).replace(".delve.town", "") for d, v in prof.items()}
posts = {}
for line in open("data/posts.jsonl"):
    r = json.loads(line); posts[r["uri"]] = r["value"]
def did(uri): return uri.split("/")[2]
def ts(v):
    try: return datetime.fromisoformat(v["createdAt"].replace("Z", "+00:00"))
    except Exception: return None
replies = {u: v for u, v in posts.items() if v.get("reply")}
tops = {u: v for u, v in posts.items() if not v.get("reply")}
print(f"snapshot {P['at']}  repos {P['repos']}  bot-labelled {sum(bot.values())}")
print(f"posts {len(posts)}  top-level {len(tops)}  replies {len(replies)}")
edges, missing, selfrep = [], 0, 0
for u, v in replies.items():
    p = v["reply"]["parent"]["uri"]
    if p not in posts: missing += 1; continue
    a, b = did(u), did(p)
    if a == b: selfrep += 1; continue
    edges.append((a, b))
print(f"reply edges {len(edges)}  self-replies {selfrep}  parent not on this PDS (outside-hosted or deleted) {missing}")
def components(E):
    adj = defaultdict(set)
    for a, b in E: adj[a].add(b); adj[b].add(a)
    seen, sizes = set(), []
    for n in adj:
        if n in seen: continue
        st, c = [n], 0; seen.add(n)
        while st:
            x = st.pop(); c += 1
            for y in adj[x]:
                if y not in seen: seen.add(y); st.append(y)
        sizes.append(c)
    return len(adj), sorted(sizes, reverse=True)
def rep(name, E):
    n, s = components(E); print(f"{name}: {len(E)} edges / {n} nodes / largest component {s[0]} / components {len(s)}"); return n, s
print("\n== connectivity (Selene's test) ==")
rep("all", edges)
unl = [e for e in edges if not bot[e[0]]]; rep("unlabelled sources only", unl)
lab = [e for e in edges if bot[e[0]]]; rep("labelled sources only", lab)
random.seed(1); L = []
for _ in range(200): L.append(components(random.sample(edges, len(unl)))[1][0])
print(f"null (random {len(unl)} of all, 200 trials): largest {statistics.median(L):.0f} ({min(L)}–{max(L)})")
print("\n== ink crossing (who replies to whom) ==")
c = Counter((bot[a], bot[b]) for a, b in edges)
for (x, y), n in sorted(c.items()): print(f"{'labelled' if x else 'unlabelled':10} -> {'labelled' if y else 'unlabelled':10} {n:6} ({100*n/len(edges):.0f}%)")
print("\n== reciprocity ==")
pairs = defaultdict(lambda: [0, 0])
for a, b in edges:
    k = tuple(sorted((a, b))); pairs[k][0 if k[0] == a else 1] += 1
both = sum(1 for v in pairs.values() if v[0] and v[1])
print(f"pairs {len(pairs)}  both directions {both} ({100*both/len(pairs):.0f}%)  one-way {len(pairs)-both}")
w = sorted(pairs.items(), key=lambda kv: -sum(kv[1]))[:5]
print("heaviest pairs:", ", ".join(f"{handle[k[0]]}<->{handle[k[1]]} {sum(v)}" for k, v in w))
print("\n== concentration of received replies ==")
indeg = Counter(b for a, b in edges); tot = sum(indeg.values()); top = indeg.most_common(10)
print(f"receivers {len(indeg)}; top 10 receive {100*sum(n for _, n in top)/tot:.0f}%: " + ", ".join(f"{handle[d]} {n}" for d, n in top))
outdeg = Counter(a for a, b in edges); top = outdeg.most_common(10)
print(f"senders {len(outdeg)}; top 10 send {100*sum(n for _, n in top)/len(edges):.0f}%: " + ", ".join(f"{handle[d]} {n}" for d, n in top))
print("\n== answered rate (top-level posts with >=1 reply from someone else) ==")
got = defaultdict(set)
for u, v in replies.items():
    r = v["reply"]["root"]["uri"]
    if r in tops and did(u) != did(r): got[r].add(did(u))
for name, f in (("all", lambda d: True), ("labelled authors", lambda d: bot[d]), ("unlabelled authors", lambda d: not bot[d])):
    T = [u for u in tops if f(did(u))]; a = sum(1 for u in T if got.get(u))
    print(f"{name}: {a}/{len(T)} answered ({100*a/max(1,len(T)):.0f}%)")
print("\n== time to first reply (top-level posts that got one) ==")
first = {}
for u, v in replies.items():
    r = v["reply"]["root"]["uri"]
    if r in tops and did(u) != did(r):
        t = ts(v)
        if t and (r not in first or t < first[r]): first[r] = t
d = [(first[r] - ts(tops[r])).total_seconds()/60 for r in first if ts(tops[r]) and first[r] > ts(tops[r])]
d.sort(); q = lambda p: d[int(p*len(d))]
print(f"n {len(d)}  median {q(.5):.0f} min  p25 {q(.25):.0f}  p75 {q(.75):.0f}  p90 {q(.9)/60:.1f} h")
print("\n== thread shape ==")
size = Counter(v["reply"]["root"]["uri"] for v in replies.values())
depth = {}
def dep(u, seen=()):
    if u in depth: return depth[u]
    v = posts.get(u)
    if not v or not v.get("reply") or u in seen: depth[u] = 0; return 0
    depth[u] = 1 + dep(v["reply"]["parent"]["uri"], seen + (u,)); return depth[u]
D = [dep(u) for u in replies]
print(f"threads with replies {len(size)}; replies per thread median {statistics.median(size.values()):.0f}, max {max(size.values())}")
print(f"reply depth: median {statistics.median(D):.0f}, p90 {sorted(D)[int(.9*len(D))]}, max {max(D)}; depth>=5: {100*sum(1 for x in D if x>=5)/len(D):.0f}% of replies")
print("\n== per-day volume (last 10 days, replies / top-level) ==")
day = Counter(); dayt = Counter()
for u, v in posts.items():
    t = ts(v)
    if t: (day if v.get("reply") else dayt)[t.date().isoformat()] += 1
for k in sorted(set(day) | set(dayt))[-10:]: print(f"{k} {day[k]:5} / {dayt[k]:4}")
