# -*- coding: utf-8 -*-
"""
register_gate.py — the confidences on the page must be the ones in the register.

Round-six red team edited figures.py's CONF dict, rebuilt, and shipped a page whose
calibration interval came from one set of confidences while its printed mean came
from another. The reason: the stated confidences existed in three uncrossed copies —
the register .md documents, figures.py::CONF, and make_pages.py::P — and nothing
compared them. figures.py's own comment had already named the gap:

    "the round-two review confirmed all 25 matched v3, but matching by hand is not
     a mechanism."

This is the mechanism. It parses the confidences out of the registered documents —
which are frozen and timestamped, so they cannot be quietly moved — and compares
them against both copies in the code. A confidence is the one number this project
says may never change after registration; nothing else in the build checked it.

Run: python3 scripts/register_gate.py
"""
import os, re, sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.normpath(os.path.join(HERE, ".."))
sys.path.insert(0, HERE)
import figures  # noqa: E402

PRIME = {"′": "p", "″": "pp", "'": "p"}


def key(x):
    for a, b in PRIME.items():
        x = x.replace(a, b)
    return x


def from_register():
    """The 25 stated confidences, read out of the registered documents."""
    # repo layout has it at the root; the published bundle is flat
    for base in (ROOT, HERE):
        cand = os.path.join(base, "PREDICTIONS_v3_2026-08-23.md")
        if os.path.exists(cand):
            break
    txt = open(cand, encoding="utf-8").read()
    conf = {}
    # the 19 carried forward from v2 sit in a table
    for pid, pct in re.findall(r"^\|\s*([A-G]\d[a-z]?[′″']?)\s*\|[^|]*\|\s*(\d{1,3})%\s*\|",
                               txt, re.M):
        conf[key(pid)] = int(pct)
    # the six registered on 23 August sit in headed prose sections
    for m in re.finditer(r"\*\*([A-G]\d[a-z]?[′″']?)\s*[—-]", txt):
        pid = key(m.group(1))
        nxt = re.search(r"\*\*Confidence:\s*(\d{1,3})%\*\*", txt[m.end():m.end() + 1400])
        if nxt and pid not in conf:
            conf[pid] = int(nxt.group(1))
    return conf


def from_page():
    """The confidences make_pages.py prints on the cards.

    NOT anchored to the start of a line any more: one leading space used to hide a card
    from this parser, and main() then read the absence as agreement."""
    src = open(os.path.join(HERE, "make_pages.py"), encoding="utf-8").read()
    out = {}
    for pid, pct in re.findall(r'\("([A-Z]\d[a-z]?[′″’\']?)",\s*"[A-Z]",\s*"[a-z]+",\s*(\d{1,3}),',
                               src):
        out[key(pid)] = int(pct)
    return out


# The threshold and the fail condition are the other two things a registered prediction
# must not lose. They are prose, so they cannot be compared token-for-token to the
# register -- but the NUMBERS in them can be, and a threshold is a number. Every numeric
# literal in a card's "registered as" and "fails if" text must appear in that
# prediction's section of the registered document.
NUM = re.compile(r"(?<![\w.])(\d+\.\d+|\d+)(?!\.?\d)(?!\w)")
# Numbers that are not thresholds: years, counts of things, and the placeholder syntax.
IGNORE = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "2024", "2026", "435",
          "115", "59", "25", "31"}


# The pid class here is [A-Z], not [A-G]. A red-teamer added a 26th card with pid "H1" \u2014
# outside A-G \u2014 and it was invisible to every parser anchored to [A-G]: it did not count
# toward the 25-card invariant, and its attacker-chosen threshold was never checked. The
# predictions only run A-G, so [A-Z] still matches all of them and additionally makes any
# out-of-range card VISIBLE, which is what the count invariant needs to reject it.
CARD_RE = (r'\("([A-Z]\d[a-z]?[\u2032\u2033\']?)",\s*"[A-Z]",\s*"[a-z]+",\s*\d{1,3},\s*\n'
           r'\s*"(?:[^"\\]|\\.)*",\s*\n'          # plain
           r'\s*"((?:[^"\\]|\\.)*)",\s*\n'        # formal  <- threshold
           r'\s*"((?:[^"\\]|\\.)*)",')            # fails-if


def card_pids():
    """Every pid in the card list, WITH duplicates, in source order.

    from_page/card_numbers key by pid, so a duplicated pid is silently collapsed to its
    last occurrence \u2014 a red-teamer inserted a corrupt second copy of a card BEFORE the real
    one and the dict kept only the (clean) last, hiding the corrupt copy from every check
    while it still rendered. This returns the raw list so main() can reject any duplicate."""
    src = open(os.path.join(HERE, "make_pages.py"), encoding="utf-8").read()
    return [key(m.group(1)) for m in re.finditer(CARD_RE, src)]


def card_numbers():
    """{pid: set of numeric literals in the card's registered-as and fails-if text}"""
    src = open(os.path.join(HERE, "make_pages.py"), encoding="utf-8").read()
    out = {}
    for m in re.finditer(CARD_RE, src):
        text = m.group(2) + " " + m.group(3)
        text = re.sub(r"\d{4}-\d{2}-\d{2}", " ", text)    # ISO dates are not thresholds
        text = re.sub(r"\[\[[^\]]+\]\]", " ", text)       # computed figures are not literals
        text = re.sub(r"<[^>]+>", " ", text)
        text = re.sub(r"\b[A-Z]{2}-\d{2}\b", " ", text)   # district codes are not thresholds
        nums = {n for n in NUM.findall(text) if n not in IGNORE}
        out[key(m.group(1))] = nums
    return out


# A threshold is registered if it appears in ANY of the frozen register versions -- the
# five are all covered by timestamp/COMMITMENT.txt, none may be edited, and a card may
# legitimately carry a number first written in v1 and carried forward. Checking against
# v3 alone reported five false failures on the first run (the 0.05 significance level
# lives in v1, v2 and v4). Checking against the union is the honest scope: it proves a
# number was registered SOMEWHERE frozen, not that it was registered on this card, and
# saying so here is better than implying the stronger check.
REGISTERS = ("PREDICTIONS_registered_2026-08-22.md", "PREDICTIONS_v2_2026-08-22.md",
             "PREDICTIONS_v3_2026-08-23.md", "PREDICTIONS_v4_2026-08-23.md",
             "PREDICTIONS_v5_2026-08-24.md")


def register_sections():
    """The full text of every frozen register version, concatenated."""
    out, found = [], []
    for n in REGISTERS:
        for base in (ROOT, HERE, os.path.join(ROOT, "public_data")):
            c = os.path.join(base, n)
            if os.path.exists(c):
                out.append(open(c, encoding="utf-8").read()); found.append(n); break
    if len(found) != len(REGISTERS):
        raise SystemExit("REGISTER GATE FAILED — only %d of %d frozen register versions "
                         "found (%s). The threshold check is only as complete as the set "
                         "it reads." % (len(found), len(REGISTERS),
                                        sorted(set(REGISTERS) - set(found))))
    return "\n".join(out)


PID_TOKEN = re.compile(r"(?<![A-Za-z0-9])([A-G]\d[a-z]?[′″']?)(?![A-Za-z0-9])")


def _lines_by_pid(reg):
    """Assign every register line to a prediction, by carrying the 'current prediction'
    forward and switching whenever a line names a new one.

    Two red-teamers, two windows too wide. A global corpus scope let any registered value
    pass for any card; a ±400-char window let a neighbour four lines away bleed in. This
    carries the current pid line by line — a '### A4 · A4′' section hands its lines to A4
    until '- **A4′ scoring map:**' switches to A4′, and a '### B3' section keeps its
    unlabelled scoring-map bullet under B3 — so each threshold is bound to exactly the
    prediction it sits under, and no neighbour, prime sibling included, can lend it a value.
    The last pid NAMED on a line wins (headings that name two hand off to the bullets below)."""
    out, cur = {}, None
    for line in reg.splitlines():
        named = PID_TOKEN.findall(line)
        if named:
            cur = key(named[-1])
        if cur:
            out.setdefault(cur, []).append(line)
    return {k: "\n".join(v) for k, v in out.items()}


def check_thresholds():
    """Every threshold number on a card must appear in ITS OWN prediction's register text."""
    reg = register_sections()
    by_pid = _lines_by_pid(reg)
    bad = []
    for pid, nums in sorted(card_numbers().items()):
        wnums = set(NUM.findall(by_pid.get(pid, "")))
        missing = sorted(n for n in nums if n not in wnums)
        if missing:
            bad.append((pid, missing))
    return bad


# NUM above deliberately ignores the small integers 0-10 (years, race counts, ordinals in
# prose are too common to compare token-for-token). But some registered thresholds ARE small
# integers, and three of them are written as ENGLISH WORDS: D1's "at least two", E1's "between
# two and four of the seven", E3's "the other four ... two or more". NUM never matches a word,
# and even the digit forms sit in IGNORE — so a red-teamer rewrote D1's "≥ two" to "≥ four",
# rebuilt, and shipped it: the one threshold on the card, entirely unchecked. This closes that
# class. It reads every card's small-number thresholds in BOTH forms (word and digit, 1-12),
# and requires each to appear — in either form — in that prediction's own register lines, so a
# word cannot be swapped for a different word OR a digit and slip past. Residual, stated plainly:
# a rewrite to a DIFFERENT small number that happens to occur elsewhere in the same prediction's
# prose (D1's basis mentions "three races") would still pass; the union/prose scope cannot tell
# a threshold token from an incidental one. The landed attack ("two"->"four") is caught.
WORDNUM = {"one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6,
           "seven": 7, "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12}
_INV = {v: k for k, v in WORDNUM.items()}


def _strip_nonthreshold(text):
    text = re.sub(r"\d{4}-\d{2}-\d{2}", " ", text)     # ISO dates are not thresholds
    text = re.sub(r"\[\[[^\]]+\]\]", " ", text)        # computed figures are not literals
    text = re.sub(r"<[^>]+>", " ", text)
    text = re.sub(r"\b[A-Z]{2}-\d{2}\b", " ", text)    # district codes are not thresholds
    return text


def _small_ints(text):
    """Small-integer thresholds (1-12) present in text, as words or digits, normalised to int."""
    text = _strip_nonthreshold(text)
    low = text.lower()
    out = set()
    for w, n in WORDNUM.items():
        if re.search(r"(?<![a-z-])" + w + r"(?![a-z-])", low):
            out.add(n)
    for d in re.findall(r"(?<![\w.])(\d+)(?![\w.])", text):
        if 1 <= int(d) <= 12:
            out.add(int(d))
    return out


def card_small_ints():
    """{pid: set of small-integer thresholds (1-12) in the card's registered-as and fails-if}"""
    src = open(os.path.join(HERE, "make_pages.py"), encoding="utf-8").read()
    out = {}
    for m in re.finditer(CARD_RE, src):
        nums = _small_ints(m.group(2) + " " + m.group(3))
        if nums:
            out[key(m.group(1))] = nums
    return out


# ── The registered thresholds, FROZEN ────────────────────────────────────────────────
# The checks above prove a threshold appears SOMEWHERE in its prediction's frozen register
# text. Two red-teamers showed that is not enough on its own: a decimal could be rewritten
# to a different value that happens to occur elsewhere in the same prediction's prose
# (A1a 0.60→0.70, where 0.70 is A1a's own basis figure), and a word threshold could be
# reworded to a number-word outside one..twelve ("two"→"fifteen") or to a non-numeric
# phrase ("a pair"), vanishing from extraction entirely. Both leave the register-membership
# check satisfied or silent. The durable fix is to pin the exact threshold literals each
# card carried at freeze, and require an EXACT match: any value changed, added, dropped, or
# reworded out of recognition breaks equality. These sets were captured from the known-good
# build that matches the register; a legitimate threshold change means a NEW prediction id,
# so this dict changing is itself the signal that a registration was altered.
EXPECT_NUMS = {
    "A1a": ["0.60"], "A1ap": ["0.20"], "A1b": ["0.70", "0.90"], "A1bp": ["0.20"],
    "A2": ["0.20"], "A3": ["0.50"], "A3p": ["0.10", "0.45"], "A4": ["0.50", "0.85"],
    "A4p": ["0.10", "0.55"], "A5": ["0.20"], "B1": ["0.05", "1.0"], "B2": ["100"],
    "B3": ["0.05", "1.5", "55"], "B4": ["2.5", "3.5"], "C1": ["15"], "C2": ["1.30"],
    "G1": ["0.05"], "G2": ["0.05"],
}
EXPECT_SMALL = {
    "B2": [1], "D1": [2], "E1": [2, 4, 7], "E3": [2, 4], "F1": [1], "G1": [2],
}


def check_frozen():
    """Card thresholds must EXACTLY match the frozen registration, not merely be registered
    somewhere. Returns a list of (pid, 'kind', expected, got) mismatches."""
    bad = []
    got_n = {p: sorted(v) for p, v in card_numbers().items() if v}
    for pid in sorted(set(got_n) | set(EXPECT_NUMS)):
        if got_n.get(pid, []) != EXPECT_NUMS.get(pid, []):
            bad.append((pid, "decimal/large threshold", EXPECT_NUMS.get(pid, []),
                        got_n.get(pid, [])))
    got_s = {p: sorted(v) for p, v in card_small_ints().items()}
    for pid in sorted(set(got_s) | set(EXPECT_SMALL)):
        if got_s.get(pid, []) != EXPECT_SMALL.get(pid, []):
            bad.append((pid, "word/small-int threshold", EXPECT_SMALL.get(pid, []),
                        got_s.get(pid, [])))
    return bad


def _present(n, low):
    """True if the small integer n appears in text `low` (already lowercased) as word or digit."""
    return (re.search(r"(?<![a-z])" + _INV[n] + r"(?![a-z])", low) is not None
            or re.search(r"(?<![\w.])" + str(n) + r"(?![\w.])", low) is not None)


def check_small_ints():
    """Every small-integer threshold on a card must appear in ITS OWN prediction's register text."""
    by_pid = _lines_by_pid(register_sections())
    bad = []
    for pid, nums in sorted(card_small_ints().items()):
        low = by_pid.get(pid, "").lower()
        missing = sorted(n for n in nums if not _present(n, low))
        if missing:
            bad.append((pid, missing))
    return bad


def main():
    pids = card_pids()
    dupes = sorted({p for p in pids if pids.count(p) > 1})
    if dupes:
        raise SystemExit("REGISTER GATE FAILED — duplicate prediction id(s) in the card list: "
                         "%s. from_page and card_numbers key by pid and keep only the last "
                         "occurrence, so a duplicated card hides its other copy from every "
                         "check while it still renders. One card, one id." % dupes)
    reg, page, code = from_register(), from_page(), dict(figures.CONF)
    if len(reg) != len(code):
        raise SystemExit(f"register gate: parsed {len(reg)} confidences from the register, "
                         f"code holds {len(code)} — {sorted(set(code) - set(reg))} unmatched")
    bad = []
    for pid in sorted(code):
        r, c, p = reg.get(pid), code[pid], page.get(pid)
        if r != c:
            bad.append((pid, "register", r, "figures.py CONF", c))
        if p is None:
            bad.append((pid, "the register", r, "make_pages.py", "NO CARD PARSED — a "
                        "prediction this gate cannot see is not a prediction it checks"))
        elif p != c:
            bad.append((pid, "figures.py CONF", c, "make_pages.py card", p))
    if bad:
        print("REGISTER GATE FAILED — a stated confidence does not match the register:\n")
        for pid, a, av, b, bv in bad:
            print(f"  {pid}: {a} says {av}, {b} says {bv}")
        print("\nA confidence is the one number this project says never changes after")
        print("registration. Restore it, or the register is not what the page claims.")
        raise SystemExit(1)
    cards = card_numbers()
    if len(cards) != len(code):
        raise SystemExit("REGISTER GATE FAILED — parsed %d cards' thresholds but there are "
                         "%d predictions: %s. A card this gate cannot read is a card nobody "
                         "checks." % (len(cards), len(code), sorted(set(code) - set(cards))))
    drift = check_thresholds()
    if drift:
        print("REGISTER GATE FAILED — a threshold on a card is not in the register:\n")
        for pid, missing in drift:
            print(f"  {pid}: the card states {', '.join(missing)}, which appears nowhere in "
                  f"{pid}'s own registered text (checked per-prediction, not against the "
                  f"corpus — a value registered for a DIFFERENT prediction does not count). "
                  f"A threshold is registered for this prediction or it is not.")
        print("\nRewriting a threshold after the fact is the attack this project exists to")
        print("make impossible. Restore it, or register a NEW prediction with a new id.")
        raise SystemExit(1)
    words = check_small_ints()
    if words:
        print("REGISTER GATE FAILED — a word/small-integer threshold on a card is not in the "
              "register:\n")
        for pid, missing in words:
            named = ", ".join(f"{n} ({_INV[n]})" for n in missing)
            print(f"  {pid}: the card states the threshold {named}, which appears in neither "
                  f"word nor digit form anywhere in {pid}'s own registered text (checked "
                  f"per-prediction). D1's 'at least two', E1's 'between two and four', E3's "
                  f"'the other four' are registered as words; a word cannot be swapped for "
                  f"another word or a digit.")
        print("\nRewriting a threshold after the fact is the attack this project exists to")
        print("make impossible. Restore it, or register a NEW prediction with a new id.")
        raise SystemExit(1)
    frozen = check_frozen()
    if frozen:
        print("REGISTER GATE FAILED — a card's threshold does not match the FROZEN "
              "registration:\n")
        for pid, kind, exp, got in frozen:
            print(f"  {pid}: {kind} was registered as {exp or '(none)'}, the card now "
                  f"carries {got or '(none)'}. A value that merely appears elsewhere in the "
                  f"register, a number-word outside one..twelve, or a non-numeric rewording "
                  f"all land here — the exact registered literal must be present, and only it.")
        print("\nThe registered thresholds are pinned at freeze precisely so a later rewrite")
        print("cannot pass by coincidence. Restore the value, or register a NEW prediction.")
        raise SystemExit(1)
    print(f"    register gate: {len(code)} confidences, {len(cards)} threshold sets and "
          f"{len(card_small_ints())} word/small-int thresholds agree across the register, "
          f"figures.py and the cards, and every threshold matches its frozen registration")


if __name__ == "__main__":
    main()
