#!/usr/bin/env python3
"""Resolvers of the thirty registered predictions F1-F30 of "Sealed Before the Open" (Appendix D).

Run:  python3 -I resolve.py [--data DIR] [--as-of YYYY-MM-DD] [--only F1,F7] [--json] [--dev]
Rules version: osf-rules-1 (2026-10-21). Standard library only.

Each resolver returns {status: came_true | refuted | unresolved, reason, numbers}. The rules of
Appendix D apply to every row unless the row names another window:
  * window: sessions 11 (2 Nov 2026) .. the last session on or before the check date;
  * rows: counted run of the session, cells sealed in the answer line, answer lines sealed strictly
    before the reference price, each seat's latest such line (common.select); late lines never pool;
  * Brier one-day, all rows, a strict rise = 1, draws included; "ranked" = 30 resolved forecasts on at
    least 10 sessions (30/10 rule) counted inside the prediction's window unless the row says otherwise;
  * intervals 95% with the session as the cluster (session-cluster bootstrap, 10,000, seed 20261102)
    unless the row names a block bootstrap;
  * a minimum sample not reached, a missing file or a field without a documented equivalent ->
    unresolved, never a success; before its check date a prediction is unresolved ("not yet due") and
    the provisional numbers are shown only as a test of the code.
Set A rows read the named published field; where the field also covers sessions before the window, the
row's definition is applied to the window from the raw rows and both values are printed.
--dev: windows start at session 1 and check dates are ignored. It tests the code on development data;
its output is never a resolution.
"""
import argparse, json, math, os, statistics as st, sys
from datetime import date
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import common as C

NOV, DEC, APR = C.CHECK["nov"], C.CHECK["dec"], C.CHECK["apr"]
U = "unresolved"
NOT_PUBLISHED = "data not yet published"

def R(status, reason="", **numbers): return dict(status=status, reason=reason, numbers=numbers)
def verdict(cond): return "came_true" if cond else "refuted"

class Ctx:
    def __init__(self, data, as_of, dev):
        self.d, self.as_of, self.dev = data, as_of, dev
        self.fam, self.order = C.families(data)
        self._rows = {}
    def window(self, check, first=None):
        f = 1 if self.dev else (first or C.WINDOW_START)
        last = C.last_session_on_or_before(min(check, self.as_of) if not self.dev else self.as_of)
        return f, last
    def rows(self, h, first, last):
        k = (h, first, last)
        if k not in self._rows: self._rows[k] = C.select(self.d, h, first, last)
        return self._rows[k]
    def resolved(self, h, first, last):
        rows, _ = self.rows(h, first, last)
        return None if rows is None else [r for r in rows if r["y"] is not None]
    def events(self, h, first, last):
        rows = self.resolved(h, first, last)
        return None if rows is None else [e for e in C.consensus_events(rows, self.fam, self.order) if e["y"] is not None]

def run_date(run_id):
    try: return date.fromisoformat(run_id[:10])
    except ValueError: return None

# ============================================================ Set A: published fields
def F1(x):
    """mean of n_eff.counted.per_run[*].n_eff over runs dated 2-30 Nov 2026 (council-stats.json); >=10 runs; < 2.0"""
    return _neff_field(x, ("n_eff", "counted", "per_run"), date(2026, 11, 2), NOV, 10, 2.0, drop=())

def F2(x):
    """mean of n_eff.counted.one_per_group.per_run[*].n_eff, runs dated 2 Nov - 31 Dec 2026; >=20 runs; < 2.5"""
    return _neff_field(x, ("n_eff", "counted", "one_per_group", "per_run"), date(2026, 11, 2), DEC, 20, 2.5, drop=C.GROUP_DROP)

def _neff_field(x, path, d0, d1, nmin, thr, drop):
    s = x.d.json("council-stats.json")
    if s is None: return R(U, f"{NOT_PUBLISHED}: council-stats.json")
    node = s
    for p in path:
        node = node.get(p) if isinstance(node, dict) else None
        if node is None: return R(U, f"field {'.'.join(path)} absent (no documented equivalent found)")
    lo = date(2026, 10, 19) if x.dev else d0
    vals = [(r["run"], r["n_eff"]) for r in node if run_date(r.get("run", "")) and lo <= run_date(r["run"]) <= d1
            and r.get("n_eff") is not None]
    # recomputation by the filed rule, as a cross-check (not the definition of a Set A row)
    f, l = x.window(d1, 1 if x.dev else C.session_number(d0))
    rows, _ = x.rows("1d", f, l)
    recomp = {}
    for run, rs in C.by_run(rows or []).items():
        v = C.n_eff_picture(C.pictures(rs), drop)
        if v: recomp[run] = round(v["n_eff"], 2)
    m = st.mean(v for _, v in vals) if vals else None
    nums = dict(runs=len(vals), mean=m, per_run=vals, recomputed=recomp)
    if len(vals) < nmin: return R(U, f"fewer than {nmin} counted runs", **nums)
    return R(verdict(m < thr), f"mean {m:.3f} vs {thr}", **nums)

ACTION_CLASS = {"buy_strong": "up", "buy_dips": "up", "hold": "hold", "trim": "down", "sell": "down"}

def shared_link_agreement(fc, keep_session, reps=10000, seed=C.SEED):
    """The field's definition (council-stats.json sources.method): answers of every run, 1d; each seat's
    latest line in the run, cells of correction lines out, answers without links out; pairs of seats in the
    same run and indicator; a link is shared when both cite it; agreement = the same action class
    (up / hold / down); strata = run x indicator with both kinds of pair; diff_within_strata = mean over strata
    of (agree_sharing - agree_not_sharing) weighted by the stratum's sharing pairs (reproduces 0.129 on the
    files of 21 October 2026); p = two-sided permutation of the shared labels within strata."""
    import itertools, random
    g = {}
    for r in fc:
        if r.get("horizon") != "1d" or r.get("action") not in ACTION_CLASS or r.get("line_kind") == "correction": continue
        if not keep_session(r["session"]): continue
        k = (r["run_id"], r["asset"]); cur = g.setdefault(k, {})
        if r["seat"] in cur and int(cur[r["seat"]]["line"]) > int(r["line"]): continue
        cur[r["seat"]] = r
    strata = {}
    for k, dd in g.items():
        for a, b in itertools.combinations(sorted(dd), 2):
            ua = {u.strip() for u in dd[a]["source_urls"].split(";") if u.strip()}
            ub = {u.strip() for u in dd[b]["source_urls"].split(";") if u.strip()}
            if not ua or not ub: continue
            strata.setdefault(k, []).append((bool(ua & ub), ACTION_CLASS[dd[a]["action"]] == ACTION_CLASS[dd[b]["action"]]))
    def diff(st_):
        num = den = 0.0
        for ps in st_.values():
            s = [ag for sh, ag in ps if sh]; n = [ag for sh, ag in ps if not sh]
            if s and n: num += len(s) * (sum(s) / len(s) - sum(n) / len(n)); den += len(s)
        return num / den if den else None
    d0 = diff(strata)
    pairs = sum(len(v) for v in strata.values()); sharing = sum(sh for v in strata.values() for sh, _ in v)
    if d0 is None: return dict(pairs=pairs, pairs_sharing=sharing, diff=None, p=None)
    rng = random.Random(seed); hit = 0
    for _ in range(reps):
        perm = {}
        for k, ps in strata.items():
            lab = [sh for sh, _ in ps]; rng.shuffle(lab); perm[k] = [(l, ag) for l, (_, ag) in zip(lab, ps)]
        dp = diff(perm)
        if dp is not None and abs(dp) >= abs(d0) - 1e-12: hit += 1
    return dict(pairs=pairs, pairs_sharing=sharing, diff=d0, p=(hit + 1) / (reps + 1))

def F5(x):
    """sources.agreement_by_shared_link.diff_within_strata >= 0.05 and p_permutation < 0.05 (council-stats.json);
    the field covers every run since the start, so the field's definition is applied to the window."""
    s = x.d.json("council-stats.json")
    a = ((s or {}).get("sources") or {}).get("agreement_by_shared_link")
    if not a or a.get("diff_within_strata") is None or a.get("p_permutation") is None:
        return R(U, f"{NOT_PUBLISHED}: sources.agreement_by_shared_link")
    fc = x.d.forecasts()
    if fc is None: return R(U, f"{NOT_PUBLISHED}: council-forecasts.csv")
    f, l = x.window(DEC)
    w = shared_link_agreement(fc, lambda sess: (C.session_number(sess) or 0) >= f and (C.session_number(sess) or 10 ** 6) <= l)
    nums = dict(window=[f, l], window_value=w, published_field_all_runs=dict(diff=a["diff_within_strata"], p=a["p_permutation"],
                pairs=a.get("pairs"), pairs_sharing=a.get("pairs_sharing")))
    if w["diff"] is None: return R(U, "no stratum with both sharing and non-sharing pairs in the window", **nums)
    return R(verdict(w["diff"] >= 0.05 and w["p"] < 0.05), f"diff {w['diff']:.4f}, p {w['p']:.4f}", **nums)

def _together_window(x, first, last):
    """all_wrong (>=4 answering, a stated 50 not answering), minority_right (consensus wrong, >=1 seat right), rows."""
    ev = x.events("1d", first, last)
    if ev is None: return None
    aw = mr = n = 0; per = []
    for e in ev:
        ans = {s: p for s, p in e["probs"].items() if C.side(p) is not None}
        n += 1
        errs = {s: C.wrong(p, e["y"]) for s, p in ans.items()}
        allw = len(ans) >= 4 and all(errs.values())
        cw = C.wrong(e["p"], e["y"])
        minr = cw == 1 and any(v == 0 for v in errs.values())
        aw += allw; mr += minr; per.append((e, ans, errs, allw))
    return dict(all_wrong=aw, minority_right=mr, rows=n, _per=per)

def F7(x):
    """minority_right > all_wrong: the `together` definition recomputed on the window by the rule of the paper
    (lines sealed before the reference, all rows, draws included); the published field, which also counts late
    lines and leaves draws out, is shown beside it"""
    f, l = x.window(DEC)
    L = x.d.league()
    field = (L or {}).get("together")
    t = _together_window(x, f, l)
    if t is None: return R(U, f"{NOT_PUBLISHED}: council-forecasts.csv / council-book.jsonl")
    nums = dict(window=[f, l], all_wrong=t["all_wrong"], minority_right=t["minority_right"], rows=t["rows"],
                published_field_all_sessions=field)
    if t["rows"] == 0: return R(U, "no resolved one-day market-sessions in the window", **nums)
    return R(verdict(t["minority_right"] > t["all_wrong"]), "", **nums)

def F8(x):
    """after session 30, >= 9 of the Council seats ranked at 1d (`ranked`, council-league.json by_horizon.1d)"""
    L, ro = x.d.league(), x.d.json("league-roster.json")
    if L is None or ro is None: return R(U, f"{NOT_PUBLISHED}: council-league.json / league-roster.json")
    council = [m["seat"] for m in ro.get("models", []) if m.get("place") == "council"]
    bh = (L.get("by_horizon") or {}).get("1d")
    if bh is None: return R(U, "field by_horizon.1d absent")
    ranked = sorted(m["seat"] for m in bh if m.get("ranked") and m["seat"] in council)
    nums = dict(council_seats=len(council), ranked=ranked, n_ranked=len(ranked),
                league_last_session=L.get("days_collected"))
    return R(verdict(len(ranked) >= 9), f"{len(ranked)} ranked of {len(council)} Council seats", **nums)

def F20(x):
    """Closed Book heat below open heat: summary.heat.closed_minus_open < 0 (arena-cb-experiment.json), on the window"""
    cb = x.d.json("arena-cb-experiment.json")
    if cb is None: return R(U, f"{NOT_PUBLISHED}: arena-cb-experiment.json")
    lo = date(2026, 10, 19) if x.dev else C.session_date(C.WINDOW_START)
    runs = [r for r in cb.get("runs", []) if run_date(r["run_id"]) and lo <= run_date(r["run_id"]) <= NOV
            and r.get("heat_open") is not None and r.get("heat_closed") is not None]
    field = ((cb.get("summary") or {}).get("heat") or {})
    if not runs: return R(U, "no Closed Book runs in the window", published_summary=field)
    d = st.mean(r["heat_closed"] - r["heat_open"] for r in runs)
    return R(verdict(d < 0), f"closed - open heat {d:.2f} over {len(runs)} runs", closed_minus_open=d,
             runs=len(runs), published_summary=field)

def F22(x):
    """>= 55% of resolved critiques confirmed (status, council-talk-critiques.csv; recomputed from expect and
    move_pct where the field differs); >= 20 resolved critiques; critiques whose window starts from session 11"""
    rows = x.d.csv("council-talk-critiques.csv")
    if rows is None: return R(U, f"{NOT_PUBLISHED}: council-talk-critiques.csv")
    f, l = x.window(DEC)
    res, diffs = [], []
    for r in rows:
        s0, s1 = C.session_number(r.get("from_session", "")), C.session_number(r.get("to_session", ""))
        if s0 is None or s0 < f or (s1 or 10 ** 6) > l: continue
        if r.get("status") not in ("confirmed", "not_confirmed"): continue
        try: mv = float(r["move_pct"])
        except (ValueError, KeyError): continue
        ok = (mv < 0) if r["expect"] == "down" else (mv > 0) if r["expect"] == "up" else None
        if ok is None: continue
        if ok != (r["status"] == "confirmed"): diffs.append(r["id"])
        res.append(ok)
    nums = dict(window=[f, l], resolved=len(res), confirmed=sum(res), recomputed_differs=diffs)
    if len(res) < 20: return R(U, "fewer than 20 resolved critiques", **nums)
    sh = sum(res) / len(res); nums["share"] = sh
    return R(verdict(sh >= 0.55), f"share {sh:.3f}", **nums)

def F23(x):
    """mean heed_minus_keep_pp > 0 (council-talk.json), weeks whose sessions start from session 11"""
    t = x.d.json("council-talk.json")
    if t is None: return R(U, f"{NOT_PUBLISHED}: council-talk.json")
    f, l = x.window(DEC)
    vals = []
    for w in t.get("weeks", []):
        ss = [C.session_number(s) for s in w.get("sessions", []) if C.session_number(s)]
        if not ss or min(ss) < f or max(ss) > l: continue
        vals += [c["heed_minus_keep_pp"] for c in w.get("criticised", []) if c.get("heed_minus_keep_pp") is not None]
    field = (t.get("summary") or {}).get("heed_minus_keep_pp")
    if not vals: return R(U, "no criticised portfolio with heed_minus_keep_pp in the window", published_summary=field)
    m = st.mean(vals)
    return R(verdict(m > 0), f"mean {m:.4f} pp", mean=m, n=len(vals), published_summary=field)

def F24(x):
    """heat_self >= 0.5 x heat_between_models with heat_between_models > 0 (council-repeats.json); weeks of the window"""
    r = x.d.json("council-repeats.json")
    if r is None: return R(U, f"{NOT_PUBLISHED}: council-repeats.json")
    lo = date(2026, 10, 1) if x.dev else date(2026, 11, 1)   # the radar of Sunday 1 Nov opens the week of session 11
    hs, hb = [], []
    for w in r.get("weeks", []):
        d = run_date(w.get("week_run", ""))
        nf = ((w.get("summary") or {}).get("noise_floor") or {})
        if d is None or not (lo <= d <= NOV): continue
        if nf.get("heat_self") is not None and nf.get("heat_between_models") is not None:
            hs.append(nf["heat_self"]); hb.append(nf["heat_between_models"])
    field = {k: (r.get("summary") or {}).get(k) for k in ("heat_self", "heat_between_models", "weeks")}
    if not hs: return R(U, "no repeat week in the window", published_summary=field)
    a, b = st.mean(hs), st.mean(hb)
    if a == 0 and b == 0: return R(U, "both zero", heat_self=a, heat_between_models=b)
    if b <= 0: return R(verdict(False), "heat_between_models not > 0", heat_self=a, heat_between_models=b)
    return R(verdict(a >= 0.5 * b), f"{a:.2f} vs 0.5 x {b:.2f}", heat_self=a, heat_between_models=b, weeks=len(hs),
             published_summary=field)

def F28(x):
    """g0 Brier - g1 Brier < 0.01 with >= 200 first forecasts per group (experiment, council-league.json)"""
    L = x.d.league()
    e = (L or {}).get("experiment")
    if not e or "g0" not in e or "g1" not in e: return R(U, f"{NOT_PUBLISHED}: experiment.g0/g1")
    g0, g1 = e["g0"], e["g1"]
    nums = dict(g0_n=g0.get("n"), g1_n=g1.get("n"), g0_brier=g0.get("brier"), g1_brier=g1.get("brier"),
                matched=e.get("matched"), note="`matched` is published as a count, not as Brier scores; "
                "the Brier scores are read from g0.brier and g1.brier (see РАСХОЖДЕНИЯ.md)")
    if (g0.get("n") or 0) < 200 or (g1.get("n") or 0) < 200: return R(U, "fewer than 200 first forecasts per group", **nums)
    if g0.get("brier") is None or g1.get("brier") is None: return R(U, "Brier of a group not published", **nums)
    d = g0["brier"] - g1["brier"]; nums["difference"] = d
    return R(verdict(d < 0.01), f"g0 - g1 = {d:.4f}", **nums)

# ============================================================ Set B: computations over the raw files
def F3(x):
    """N_eff of the seats' one-day error indicators per scored session (rule of Table D1, F3), averaged; >=30 sessions; < 4.0"""
    f, l = x.window(DEC)
    rows = x.resolved("1d", f, l)
    if rows is None: return R(U, NOT_PUBLISHED)
    # Appendix D, F3: respondents = seats with an eligible line in the session, n = their number; rho = mean
    # pairwise Pearson of the 0/1 error indicators across markets both answered (stated 50 left out); a pair with
    # a constant indicator is left out, a session with no computable pair is left out; sessions weigh equally.
    per, resp = {}, {}
    for r in rows:
        resp.setdefault(r["snum"], set()).add(r["seat"])
        w = C.wrong(r["p"], r["y"])
        if w is not None: per.setdefault(r["snum"], {}).setdefault(r["seat"], {})[r["asset"]] = w
    vals = []
    for s in sorted(resp):
        d = per.get(s, {}); rs = []
        for a, b in __import__("itertools").combinations(sorted(d), 2):
            com = sorted(set(d[a]) & set(d[b]))
            c = C.pearson([d[a][k] for k in com], [d[b][k] for k in com])
            if c is not None: rs.append(c)
        if not rs: continue
        n = len(resp[s]); rho = st.mean(rs)
        vals.append((s, round(n / (1 + (n - 1) * rho), 3), n))
    nums = dict(window=[f, l], sessions=len(vals), per_session=vals)
    if len(vals) < 30: return R(U, "fewer than 30 scored sessions", **nums)
    m = st.mean(v for _, v, _ in vals); nums["mean"] = m
    return R(verdict(m < 4.0), f"mean {m:.3f}", **nums)

def _crowd_contracts(x, first, last, vmin=1000):
    """One contract per market-session: the contract the models answered (Polymarket where it lists the market,
    otherwise Kalshi), admitted if its sealed vol >= vmin (Appendix D preamble, v4.1). Returns list of dicts and the seats' eligible crowd_up per (run, asset)."""
    book = x.d.book()
    rows, _ = x.rows("1d", first, last)
    if book is None or rows is None: return None, None
    elig = {}
    for r in rows: elig.setdefault(r["run_id"], {})[r["seat"]] = int(r["line"])
    resol = {}
    for o in book.values():
        if o.get("kind") == "crowd": resol[o["run_id"]] = o.get("rows") or {}
    out, cups = [], {}
    for o in book.values():
        if o.get("kind") != "run" or o["run_id"] not in elig: continue
        cr = o.get("crowd") or {}
        sn = min(r["snum"] for r in rows if r["run_id"] == o["run_id"])
        venues = {"polymarket": cr.get("odds") or {}, "kalshi": cr.get("kalshi") or {}}
        assets = set(venues["polymarket"]) | set(venues["kalshi"])
        for a in sorted(assets):
            # the contract the models answered with crowd_up: Polymarket where it lists the market, else Kalshi;
            # the sealed volume of THAT contract must reach vmin (admission threshold; no fallback to the other venue)
            v = "polymarket" if a in venues["polymarket"] else "kalshi"
            c = venues[v][a]
            if (c.get("vol") or 0) < vmin or c.get("p_up") is None: continue
            key = a if v == "polymarket" else f"{a}@kalshi"
            up = (resol.get(o["run_id"], {}).get(key) or {}).get("up")
            out.append(dict(run_id=o["run_id"], snum=sn, asset=a, venue=v, vol=c["vol"], p=float(c["p_up"]), up=up))
        for seat, ln in elig[o["run_id"]].items():
            items = (book.get(ln) or {}).get("items") or {}
            for a, hs in items.items():
                cu = ((hs or {}).get("1d") or {}).get("crowd_up")
                if cu is not None: cups.setdefault((o["run_id"], a), {})[seat] = float(cu)
    return out, cups

def F4(x):
    """seats' mean |crowd_up - 50| < 0.5 x crowd's mean |p - 50| on >= 100 contract-sessions (vol >= $1,000)"""
    f, l = x.window(DEC)
    cons, cups = _crowd_contracts(x, f, l)
    if cons is None: return R(U, NOT_PUBLISHED)
    use = [c for c in cons if cups.get((c["run_id"], c["asset"]))]
    seat_d = {}
    for c in use:
        for s, cu in cups[(c["run_id"], c["asset"])].items(): seat_d.setdefault(s, []).append(abs(cu - 50))
    nums = dict(window=[f, l], contract_sessions=len(use))
    if len(use) < 100: return R(U, "fewer than 100 contract-sessions", **nums)
    seats = st.mean(st.mean(v) for v in seat_d.values()); crowd = st.mean(abs(c["p"] - 50) for c in use)
    nums.update(seats_mean_distance=seats, crowd_mean_distance=crowd)
    return R(verdict(seats < 0.5 * crowd), "", **nums)

def F6(x):
    """observed all-wrong share >= 2 x expected (mean product of the answering seats' error rates on the same rows)"""
    f, l = x.window(DEC)
    t = _together_window(x, f, l)
    if t is None: return R(U, NOT_PUBLISHED)
    q = [(e, ans, errs) for e, ans, errs, _ in t["_per"] if len(ans) >= 4]
    if not q: return R(U, "no resolved one-day market-session with at least 4 answering seats", window=[f, l])
    rate = {}
    for _, _, errs in q:
        for s, w in errs.items(): rate.setdefault(s, []).append(w)
    rate = {s: st.mean(v) for s, v in rate.items()}
    obs = sum(all(errs.values()) for _, _, errs in q) / len(q)
    exp = st.mean(math.prod(rate[s] for s in errs) for _, _, errs in q)
    L = x.d.league()
    nums = dict(window=[f, l], rows=len(q), observed=obs, expected=exp, published_together=(L or {}).get("together"))
    return R(verdict(obs >= 2 * exp), f"observed {obs:.4f} vs 2 x expected {2 * exp:.4f}", **nums)

def F9(x):
    """run-by-run rho(1d) - rho(1m) > 0 (more independent voices at the month), 95% moving-block bootstrap (blocks of 5
    runs) wholly above 0; >=20 runs. Read on rho, which is linear: N_eff is convex in rho, and the noise of rho estimated
    from twelve markets would lift the month (low rho) more than the day (21 Oct 2026, external critique 3)."""
    f, l = x.window(DEC)
    r1, _ = x.rows("1d", f, l); rm, _ = x.rows("1m", f, l)
    if r1 is None: return R(U, NOT_PUBLISHED)
    a, b = C.by_run(r1), C.by_run(rm)
    diffs = []
    for run in sorted(set(a) & set(b), key=lambda k: min(r["snum"] for r in a[k])):
        v1, vm = C.n_eff_picture(C.pictures(a[run])), C.n_eff_picture(C.pictures(b[run]))
        if v1 and vm: diffs.append((run, v1["rho"] - vm["rho"]))
    nums = dict(window=[f, l], runs=len(diffs), per_run=[(r, round(d, 3)) for r, d in diffs])
    if len(diffs) < 20: return R(U, "fewer than 20 counted runs", **nums)
    vals = [d for _, d in diffs]
    ci = C.percentile_interval(C.moving_block_means(vals, 5), 0.95)
    nums.update(mean=st.mean(vals), ci95=ci)
    return R(verdict(ci[0] > 0), "", **nums)

def F10(x):
    """consensus 1d Brier (sessions 31-52) < leader's (ranked Council model, lowest 1d Brier over sessions 11-30),
    on the December market-sessions both answered"""
    ro = x.d.json("league-roster.json") or {}
    council = {m["seat"] for m in ro.get("models", []) if m.get("place") == "council"}
    nov = x.resolved("1d", 1 if x.dev else 11, C.last_session_on_or_before(min(NOV, x.as_of)) if not x.dev else 30)
    if nov is None: return R(U, NOT_PUBLISHED)
    ranked = [s for s in C.ranked_seats(nov) if s in council]
    if not ranked: return R(U, "no ranked Council model over sessions 11 to 30")
    leader = min(ranked, key=lambda s: (C.seat_brier(nov, s)[0], s))
    f, l = (31, C.last_session_on_or_before(min(DEC, x.as_of)))
    dec = x.resolved("1d", f, l)
    ev = {(e["snum"], e["asset"]): e for e in C.consensus_events(dec, x.fam, x.order) if e["y"] is not None}
    lk = {(r["snum"], r["asset"]) for r in dec if r["seat"] == leader}
    keys = sorted(set(ev) & lk)
    nums = dict(leader=leader, leader_nov_brier=C.seat_brier(nov, leader)[0], ranked_nov=ranked, december_rows=len(keys))
    if not keys: return R(U, "no December market-session answered by both", **nums)
    bc = st.mean(C.brier(ev[k]["p"], ev[k]["y"]) for k in keys); bl = C.seat_brier(dec, leader, set(keys))[0]
    nums.update(consensus_brier=bc, leader_brier=bl)
    return R(verdict(bc < bl), "", **nums)

def _cluster_ci(rows_by_session):
    return C.percentile_interval(C.cluster_means(list(rows_by_session.values())), 0.95)

def F11(x):
    """no ranked Council model's 1d Brier has a 95% session-cluster interval wholly below 0.25"""
    f, l = x.window(DEC)
    rows = x.resolved("1d", f, l)
    if rows is None: return R(U, NOT_PUBLISHED)
    ro = x.d.json("league-roster.json") or {}
    council = {m["seat"] for m in ro.get("models", []) if m.get("place") == "council"}
    ranked = [s for s in C.ranked_seats(rows) if s in council]
    if not ranked: return R(U, "no ranked Council model in the window", window=[f, l])
    out = {}
    for s in ranked:
        g = {}
        for r in rows:
            if r["seat"] == s: g.setdefault(r["snum"], []).append(C.brier(r["p"], r["y"]))
        out[s] = dict(brier=C.seat_brier(rows, s)[0], ci95=_cluster_ci(g))
    below = [s for s, v in out.items() if v["ci95"] and v["ci95"][1] < 0.25]
    return R(verdict(not below), f"upper bound below 0.25: {below}", window=[f, l], seats=out)

def _f12_rows(x):
    f, l = x.window(DEC)
    cons, cups = _crowd_contracts(x, f, l)
    if cons is None: return None, None
    rows = []
    for c in cons:
        p = cups.get((c["run_id"], c["asset"]))
        if not p or c["up"] is None: continue
        rows.append(dict(c, council=C.consensus(p, x.fam, x.order)))
    return rows, (f, l)

def F12(x):
    """crowd Brier < council's trimmed consensus of crowd_up, >= 100 market-sessions, vol >= $1,000"""
    rows, w = _f12_rows(x)
    if rows is None: return R(U, NOT_PUBLISHED)
    nums = dict(window=list(w), market_sessions=len(rows))
    if len(rows) < 100: return R(U, "fewer than 100 market-sessions", **nums)
    bc = st.mean(C.brier(r["p"], r["up"]) for r in rows); ba = st.mean(C.brier(r["council"], r["up"]) for r in rows)
    nums.update(crowd_brier=bc, council_brier=ba)
    return R(verdict(bc < ba), "", **nums)

def F13(x):
    """mean of crowd and council has lower Brier than the crowd, same contracts as F12"""
    rows, w = _f12_rows(x)
    if rows is None: return R(U, NOT_PUBLISHED)
    nums = dict(window=list(w), market_sessions=len(rows))
    if len(rows) < 100: return R(U, "fewer than 100 market-sessions", **nums)
    bc = st.mean(C.brier(r["p"], r["up"]) for r in rows)
    bm = st.mean(C.brier((r["p"] + r["council"]) / 2, r["up"]) for r in rows)
    nums.update(crowd_brier=bc, combination_brier=bm)
    return R(verdict(bm < bc), "", **nums)

def F14(x):
    """pooled [60,70) bucket on the backed side, n >= 100: stated - observed >= 5 points"""
    f, l = x.window(DEC)
    rows = x.resolved("1d", f, l)
    if rows is None: return R(U, NOT_PUBLISHED)
    b = []
    for r in rows:
        if r["p"] == 50: continue
        q, o = (r["p"], r["y"]) if r["p"] > 50 else (100 - r["p"], 1 - r["y"])
        if 60 <= q < 70: b.append((q, o))
    nums = dict(window=[f, l], n=len(b))
    if len(b) < 100: return R(U, "fewer than 100 forecasts in the bucket", **nums)
    said = st.mean(q for q, _ in b); came = 100 * st.mean(o for _, o in b)
    nums.update(stated=said, observed=came, gap=said - came)
    return R(verdict(said - came >= 5), "", **nums)

def _momentum(x, f, l):
    """momentum: yesterday's direction of the same indicator (the previous session's league outcome,
    reference to close) at a fixed 55%; no forecast where yesterday's move is 0 or unknown."""
    allr, _ = x.rows("1d", max(1, f - 5), l)
    prev = {}
    for r in allr or []:
        if r.get("actual_pct") not in ("", None): prev[(r["snum"], r["asset"])] = float(r["actual_pct"])
    return prev

def F15(x):
    """B(consensus) - B(momentum) at 1d on common sessions > -0.01"""
    f, l = x.window(DEC)
    ev = x.events("1d", f, l)
    if ev is None: return R(U, NOT_PUBLISHED)
    prev = _momentum(x, f, l)
    pairs = []
    for e in ev:
        y0 = prev.get((e["snum"] - 1, e["asset"]))
        if y0 is None or y0 == 0: continue
        pm = 55.0 if y0 > 0 else 45.0
        pairs.append((C.brier(e["p"], e["y"]), C.brier(pm, e["y"])))
    nums = dict(window=[f, l], rows=len(pairs))
    if not pairs: return R(U, "no common rows of consensus and momentum", **nums)
    d = st.mean(a for a, _ in pairs) - st.mean(b for _, b in pairs); nums["difference"] = d
    return R(verdict(d > -0.01), f"B(cons) - B(mom) = {d:.4f}", **nums)

def F16(x):
    """for none of 1d, 1w, 1m does the consensus Brier have a 95% interval wholly below 0.25
    (1d: session clusters; h: moving-block bootstrap with blocks of h sessions over session means)"""
    f, l = x.window(APR)
    out = {}
    for h in ("1d", "1w", "1m"):
        ev = x.events(h, f, l)
        if ev is None: return R(U, NOT_PUBLISHED)
        g = {}
        for e in ev: g.setdefault(e["snum"], []).append(C.brier(e["p"], e["y"]))
        if len(g) < 2: out[h] = None; continue
        if h == "1d": ci = _cluster_ci(g)
        else:
            means = [st.mean(v) for _, v in sorted(g.items())]
            ci = C.percentile_interval(C.moving_block_means(means, C.H_SESSIONS[h]), 0.95)
        out[h] = dict(brier=st.mean(b for v in g.values() for b in v), sessions=len(g), ci95=ci)
    if any(v is None for v in out.values()): return R(U, "a horizon has fewer than 2 resolved sessions", window=[f, l], horizons=out)
    below = [h for h, v in out.items() if v["ci95"][1] < 0.25]
    return R(verdict(not below), f"upper bound below 0.25: {below}", window=[f, l], horizons=out)

def F17(x):
    """consensus BSS against climatology (2016-2025, climatology_fixed p_up) is lowest at 1d among 1d/1w/1m"""
    clim, meta = C.load_climatology(x.d, expected_sha=C.CLIM_SHA256)
    if clim is None: return R(U, meta)
    f, l = x.window(APR)
    bss = {}
    for h in ("1d", "1w", "1m"):
        ev = x.events(h, f, l)
        if ev is None: return R(U, NOT_PUBLISHED)
        ev = [e for e in ev if (e["asset"], h) in clim]
        if not ev: bss[h] = None; continue
        b = st.mean(C.brier(e["p"], e["y"]) for e in ev)
        bc = st.mean((clim[(e["asset"], h)] - e["y"]) ** 2 for e in ev)
        bss[h] = dict(bss=1 - b / bc if bc > 0 else None, n=len(ev))
    if any(v is None or v["bss"] is None for v in bss.values()):
        return R(U, "a horizon has no resolved consensus events", window=[f, l], bss=bss, climatology=meta)
    low = min(bss, key=lambda h: bss[h]["bss"])
    return R(verdict(low == "1d"), f"lowest BSS at {low}", window=[f, l], bss=bss, climatology=meta)

def _sides(x, f, l):
    L, ro = x.d.league(), x.d.json("league-roster.json")
    rows = x.resolved("1d", f, l)
    if rows is None or ro is None: return None
    side = {m["seat"]: m.get("side") for m in ro.get("models", []) if m.get("place") == "council"}
    ranked = [s for s in C.ranked_seats(rows) if s in side]
    sess = {}
    for r in rows: sess.setdefault(r["snum"], set()).add(r["seat"])
    common = {s for s, seats in sess.items() if set(ranked) <= seats}
    keep = [r for r in rows if r["snum"] in common]
    b = {s: C.seat_brier(keep, s)[0] for s in ranked}
    us = {s: v for s, v in b.items() if side[s] == "US" and v is not None}
    cn = {s: v for s, v in b.items() if side[s] == "CN" and v is not None}
    return dict(us=us, cn=cn, common_sessions=len(common), window=[f, l])

def F18(x):
    """|US - CN| (mean of ranked models' 1d Brier on common sessions) < range inside each side"""
    f, l = x.window(DEC)
    s = _sides(x, f, l)
    if s is None: return R(U, NOT_PUBLISHED)
    if len(s["us"]) < 2 or len(s["cn"]) < 2: return R(U, "fewer than two ranked models on a side", **s)
    gap = abs(st.mean(s["us"].values()) - st.mean(s["cn"].values()))
    ru = max(s["us"].values()) - min(s["us"].values()); rc = max(s["cn"].values()) - min(s["cn"].values())
    return R(verdict(gap < ru and gap < rc), "", gap=gap, range_us=ru, range_cn=rc, **s)

def F19(x):
    """US side's 1d Brier (mean of ranked models, common sessions) < China side's, to 30 Apr 2027"""
    f, l = x.window(APR)
    s = _sides(x, f, l)
    if s is None: return R(U, NOT_PUBLISHED)
    if len(s["us"]) < 2 or len(s["cn"]) < 2: return R(U, "fewer than two ranked models on a side", **s)
    u, c = st.mean(s["us"].values()), st.mean(s["cn"].values())
    return R(verdict(u < c), "", us_brier=u, cn_brier=c, **s)

def _own_outcome(v):
    """y of a closed call on its own reference: field y / up, else close > ref; None if not published."""
    if not isinstance(v, dict): return None
    for k in ("y", "up"):
        if v.get(k) in (0, 1, True, False): return int(v[k])
    if v.get("ref") is not None and v.get("close") is not None: return 1 if float(v["close"]) > float(v["ref"]) else 0
    return None

def F21(x):
    """|Brier(closed book) - Brier(open)| <= 0.01, same models and sessions, next-day calls"""
    book = x.d.book()
    if book is None: return R(U, NOT_PUBLISHED)
    f, l = x.window(DEC)
    rows = x.resolved("1d", f, l) or []
    openp = {(r["session"], r["seat"], r["asset"]): r for r in rows}
    pairs, skipped = [], 0
    for o in book.values():
        if o.get("kind") != "closed_book": continue
        sn = C.session_number(o.get("horizon", ""))
        if sn is None or not (f <= sn <= l): continue
        own = o.get("score") or o.get("outcome")        # an own-reference outcome, if the league publishes one
        for a, c in (o.get("calls") or {}).items():
            r = openp.get((o["horizon"], o["seat"], a))
            if r is None or c.get("prob_up") is None: continue
            if o.get("asked_after_open"):
                y = _own_outcome((own or {}).get(a) if isinstance(own, dict) else None)
                if y is None: skipped += 1; continue      # sealed after the common reference: own reference needed
                # the pair is scored on the closed call's own event: both the closed and the open probability
                pairs.append((C.brier(c["prob_up"], y), C.brier(r["p"], y)))
            else:
                pairs.append((C.brier(c["prob_up"], r["y"]), C.brier(r["p"], r["y"])))
    full = [(a, b) for a, b in pairs if b is not None]
    nums = dict(window=[f, l], pairs=len(full), skipped_after_open_without_own_reference=skipped)
    if not full:
        return R(U, f"{NOT_PUBLISHED}: no comparable pairs — Closed Book calls sealed before the common reference, or "
                    "each closed call's own reference price, close and outcome (e.g. `score` per market)", **nums)
    d = abs(st.mean(a for a, _ in full) - st.mean(b for _, b in full)); nums["abs_difference"] = d
    return R(verdict(d <= 0.01), "", **nums)

def _quarter_rank(x, f, l, council):
    """Council seats (place `council`) meeting 30/10 within the quarter, by 1d Brier; exact ties by seat name.
    Rows are keyed by seat, so a seat stays one seat across a dated change of model version (Section 5.7)."""
    rows = x.resolved("1d", f, l) or []
    rk = [s for s in C.ranked_seats(rows) if s in council]
    return sorted(rk, key=lambda s: (C.seat_brier(rows, s)[0], s))

def F25(x):
    """of the top three ranked Council models by 1d Brier over Q4 2026 (sessions 11-52), at most one in the
    top three over Q1 2027 (sessions of 4 Jan - 31 Mar 2027)"""
    ro = x.d.json("league-roster.json")
    if ro is None or x.d.forecasts() is None: return R(U, NOT_PUBLISHED)
    council = {m["seat"] for m in ro.get("models", []) if m.get("place") == "council"}
    q4 = _quarter_rank(x, 1 if x.dev else 11, 52, council)
    if not x.dev and x.as_of < date(2027, 3, 31): return R(U, "Q1 2027 not complete", q4_top3=q4[:3])
    q1 = _quarter_rank(x, 53, C.session_number(date(2027, 3, 31)), council)
    if len(q4) < 3 or len(q1) < 3: return R(U, "fewer than three ranked Council models in a quarter", q4=q4, q1=q1)
    stay = set(q4[:3]) & set(q1[:3])
    return R(verdict(len(stay) <= 1), f"stayed: {sorted(stay)}", q4_top3=q4[:3], q1_top3=q1[:3])

def _accounts_last(x):
    acc, ro = x.d.csv("arena-accounts.csv"), x.d.json("league-roster.json")
    if acc is None or ro is None: return None
    end = C.session_date(126)
    sess = sorted({r["session"] for r in acc if r["session"] <= str(end)})
    if not sess: return None
    last = sess[-1]
    vals = {r["account"]: float(r["value_usd"]) for r in acc if r["session"] == last}
    seats = [m["seat"] for m in ro.get("models", [])]
    return dict(session=last, season_end=str(end), values=vals, seats=seats)

def F26(x):
    """at the last session of the season, <= 6 of the 14 main-league model accounts above `_spy`"""
    a = _accounts_last(x)
    if a is None: return R(U, f"{NOT_PUBLISHED}: arena-accounts.csv / league-roster.json")
    if not x.dev and a["session"] < a["season_end"]: return R(U, "season not finished", last_session=a["session"])
    spy = a["values"].get("_spy")
    models = {s: a["values"][s] for s in a["seats"] if s in a["values"]}
    if spy is None or len(models) != 14: return R(U, "the _spy account or one of the 14 model accounts missing",
                                                  models=len(models), spy=spy)
    above = sorted(s for s, v in models.items() if v > spy)
    return R(verdict(len(above) <= 6), f"{len(above)} above _spy", session=a["session"], spy=spy, above=above)

def _spearman(a, b):
    def ranks(v):
        o = sorted(range(len(v)), key=lambda i: v[i]); r = [0.0] * len(v); i = 0
        while i < len(o):
            j = i
            while j + 1 < len(o) and v[o[j + 1]] == v[o[i]]: j += 1
            for k in range(i, j + 1): r[o[k]] = (i + j) / 2 + 1
            i = j + 1
        return r
    ra, rb = ranks(a), ranks(b)
    return C.pearson(ra, rb)

def F27(x):
    """Spearman(1d Brier rank, main-league return rank) < 0.5 over the Council seats ranked (30/10) over sessions
    11-126 that hold a main-league account; >= 6 seats; tied values take the mean of their ranks;
    return from value_usd at the last session of the season"""
    f, l = x.window(APR)
    rows = x.resolved("1d", f, l)
    a = _accounts_last(x)
    if rows is None or a is None: return R(U, NOT_PUBLISHED)
    if not x.dev and a["session"] < a["season_end"]: return R(U, "season not finished", last_session=a["session"])
    ro = x.d.json("league-roster.json") or {}
    council = {m["seat"] for m in ro.get("models", []) if m.get("place") == "council"}
    seats = [s for s in C.ranked_seats(rows) if s in council and s in a["values"]]
    if len(seats) < 6: return R(U, "fewer than 6 ranked Council seats with an account", seats=seats)
    b = [C.seat_brier(rows, s)[0] for s in seats]
    ret = [-(a["values"][s] / 100000.0 - 1) for s in seats]   # rank 1 = highest return, like rank 1 = lowest Brier
    rho = _spearman(b, ret)
    return R(verdict(rho is not None and rho < 0.5), f"rho {rho}", rho=rho, seats=seats)

def F29(x):
    """agreement with the consensus, shown-group minus not-shown group >= 10 points, >= 200 first forecasts per group"""
    book = x.d.book()
    if book is None: return R(U, NOT_PUBLISHED)
    ids = [o for o in book.values() if o.get("kind") == "experiment" and o.get("by_id")]
    if not ids:
        return R(U, f"{NOT_PUBLISHED}: per-reader first forecasts of the experiment (book lines `experiment`, "
                    "`by_id`) are empty; the agreement of each reader's first forecast with the consensus is not published")
    return R(U, f"{NOT_PUBLISHED}: the schema of `experiment.by_id` is not documented in arena-dataset.json; "
                "resolver to be bound to the documented fields", lines=len(ids))

def F30(x):
    """blind season: fixed guesser top-1 accuracy >= mean of 3/k"""
    return R(U, f"{NOT_PUBLISHED}: blind-season answer texts, the post-season reveal and the guesser's outputs are not "
                "in the open files; the guesser (model, version, prompt SHA-256) is named in a filed addendum before "
                "2 November 2026")

ALL = {f"F{i}": globals()[f"F{i}"] for i in range(1, 31)}
SET_A = ("F1", "F2", "F5", "F7", "F8", "F20", "F22", "F23", "F24", "F28")
CHECK_DATES = {**{k: NOV for k in ("F1", "F8", "F20", "F24")},
               **{k: APR for k in ("F16", "F17", "F19", "F25", "F26", "F27", "F30")}}

def resolve(ctx, ids=None):
    out = {}
    for k in (ids or ALL):
        chk = CHECK_DATES.get(k, DEC)
        try: r = ALL[k](ctx)
        except Exception as e:  # a resolver error must never become a success
            r = R(U, f"resolver error: {type(e).__name__}: {e}")
        r.update(id=k, set="A" if k in SET_A else "B", check_date=str(chk))
        if not ctx.dev and ctx.as_of < chk and r["status"] != U:
            r["provisional"] = r["status"]; r["status"] = U
            r["reason"] = f"not yet due (check date {chk}); provisional: {r['provisional']}. " + r["reason"]
        elif not ctx.dev and ctx.as_of < chk:
            r["reason"] = f"not yet due (check date {chk}). " + r["reason"]
        out[k] = r
    return out

def main():
    ap = C.add_common_args(argparse.ArgumentParser(description=__doc__.split("\n")[0]))
    ap.add_argument("--only", help="comma-separated ids, e.g. F1,F7")
    ap.add_argument("--json", action="store_true")
    a = ap.parse_args()
    ctx = Ctx(C.Data(a.data, a.base_url), C.as_of_date(a), a.dev)
    res = resolve(ctx, a.only.split(",") if a.only else None)
    if a.json: print(json.dumps(res, indent=1, default=str)); return
    print(f"RESOLVERS F1-F30  rules={C.RULES_VERSION}  as_of={ctx.as_of}  "
          f"{'DEVELOPMENT CHECK (windows from session 1, check dates ignored; not a resolution)' if a.dev else ''}")
    for k, r in res.items():
        print(f"{k} [{r['set']}] {r['status']}: {r['reason']}")
        print("     " + json.dumps(r["numbers"], default=str)[:600])
    for l in ctx.d.log: print("[load]", l)

if __name__ == "__main__":
    main()
