Jump to content

The Principia Machina/TPC-PY/diff biblio.py

From The Invariance Itself
Revision as of 15:43, 27 September 2026 by BFP Rauch (talk | contribs) (TPC-PY source diff_biblio.py (0.1.0), SHA-256 066959936e73c18e; conformed TPS 0.3.0; PROPOSED, not declared (CO-8))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
"""diff_biblio 0.1.0: random differential run of tpc_biblio, Python against
PHP. Usage: python3 diff_biblio.py <seed> [count]. Writes its cases to a
temporary file outside the tree (kept where TPC_DIFF_CASES names it)
(count cases per function: doi, shape, arxiv, isbn, issn, pmid, pmcid,
key, dupe, check, canon, preimage, url, compare, withdrawn, view,
holdlist), runs diff_biblio.php over it and compares every render byte
for byte. The alphabet holds code points assigned at Unicode 13.0 or
earlier, so Python 3.10's unicodedata, Python 3.13's and ICU classify
them alike (the key form's classes come from the pinned table in any
case; NFC, NFD and the zone's text safety come from the interpreter)."""
import json
import os
import random
import subprocess
import sys
import tempfile

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from tpc_biblio import run_case, CSL_KEYS, CSL_TYPES, NAME_KEYS, RESULTS, DECL_KINDS

seed = int(sys.argv[1]) if len(sys.argv) > 1 else 1
count = int(sys.argv[2]) if len(sys.argv) > 2 else 3000
rnd = random.Random(seed)

TEXT = ["a", "b", "Z", "x", " ", "  ", "\t", "\n", "\r\n", "e\u0301", "\u00e9",
        "\u00c9", "\u212b", "\u00c5", "\u1100\u1161", "\uac00", "\u00a0",
        "\u2003", "\u3000", "|", "\\", "\"", "'", "-", "\u2010", "\u2014",
        "\u2019", ".", ",", ";", ":", "(", ")", "<", ">", "&", "#", "%",
        "$", "+", "=", "^", "~", "_", "/", "\u00fc", "u\u0308", "\u00d8",
        "\u00df", "\u03a9", "\u03c9", "\u0301", "\ufb01", "\u91cf", "\u0663",
        "5", "0", "1", "\u200b", "\u00ad", "\u202e", "\u0001", "\u007f",
        "\u2028", "\u00b0", "\u2030", "\u00d7", "\u00bd", "\u2044",
        "<i>", "</i>", "<sup>", "&amp;", "&lt;", "&#8217;", "&#x2019;",
        "&nbsp;", "&#0;", "RETRACTED:", "Retracted: ", "WITHDRAWN ARTICLE:",
        "withdrawn", "WITHDRAWN", "Withdrawn.", "unwithdrawn"]
SAFE = ["a", "b", "Z", "x", " ", "e\u0301", "\u00e9", "\u00c9", "\u212b",
        "\u1100\u1161", "\u00fc", "\u00d8", "\u03a9", "\u91cf", "5", "-",
        ".", ",", "(", ")", "<", ">", "&", "%", "'", "\"", "\\", "|",
        "\t", "\n", "<i>", "&amp;", "&#8217;", "RETRACTED: "]
DOI_SUF = ["a", "B", "z", "0", "9", ".", "-", "_", "(", ")", "<", ">", ";",
           ":", "/", "%", "%3C", "%3E", "%zz", "%C0%AE", "%25", " ", "\t",
           "\u00e9", "e\u0301", "\u200b", "\u202e", "\u00a0", "\u03a9", "~",
           "#", "?", "&", "=", "+"]


def pick(xs):
    return xs[rnd.randrange(len(xs))]


def r_text(alpha=TEXT, lo=0, hi=8, bad=0.04):
    if rnd.random() < bad:
        return pick([5, None, True, 1.5, ["a"], {"a": 1}])
    return "".join(pick(alpha) for _ in range(rnd.randint(lo, hi)))


def r_safe(lo=1, hi=6):
    return "".join(pick(SAFE) for _ in range(rnd.randint(lo, hi)))


def r_digits(lo, hi):
    return "".join(pick("0123456789") for _ in range(rnd.randint(lo, hi)))


def r_yymm():
    r = rnd.random()
    if r < 0.5:
        return pick(["0704", "0705", "1412", "1501", "2601", "9912", "0703",
                     "1500", "1413", "0700", "2613", "9108", "0000"])
    return r_digits(4, 4)


def r_version():
    return pick(["", "", "", "v1", "v2", "v10", "v0", "v01", "V1", "v", "v1v2"])


def r_arxiv_id():
    r = rnd.random()
    if r < 0.45:
        return r_yymm() + "." + r_digits(3, 6) + r_version()
    if r < 0.9:
        arch = pick(["hep-th", "math", "cond-mat", "astro-ph", "cs", "q-bio",
                     "a", "HEP-TH", "hep_th", "physics", "x" * 16, "x" * 17])
        sc = pick(["", "", ".GT", ".AG", ".mes-hall", ".G", ".GTX", ".gt"])
        return arch + sc + "/" + r_yymm() + r_digits(2, 4) + r_version()
    return r_text(lo=0, hi=12, bad=0)


def r_arxiv():
    if rnd.random() < 0.03:
        return r_text()
    v = r_arxiv_id()
    return pick(["", "", "arXiv:", "ARXIV:", "https://arxiv.org/abs/",
                 "http://ARXIV.org/abs/", "arxiv.org/abs/", "arXiv: "]) + v


def r_doi_core():
    r = rnd.random()
    if r < 0.15:
        return "10.48550/" + pick(["arXiv.", "arxiv.", "ARXIV.", "other.", ""]) + r_arxiv_id()
    pre = pick(["10.", "10.", "10.", "11.", "10", "1.", "10..", "10.1000.10"]) + \
        (r_digits(0, 5) if rnd.random() < 0.8 else "")
    if rnd.random() < 0.9:
        pre += "/"
    suf = "".join(pick(DOI_SUF) for _ in range(rnd.randint(0, 8)))
    return pre + suf


def r_doi():
    if rnd.random() < 0.05:
        return r_text()
    core = r_doi_core()
    return pick(["", "", "", "doi:", "DOI:", "Doi:", "https://doi.org/",
                 "http://dx.doi.org/", "HTTPS://DOI.ORG/", "https://example.org/",
                 "doi: "]) + core


def isbn10_check(nine):
    t = sum((10 - i) * int(nine[i]) for i in range(9))
    c = (11 - t % 11) % 11
    return "X" if c == 10 else str(c)


def isbn13_check(twelve):
    t = sum((1 if i % 2 == 0 else 3) * int(twelve[i]) for i in range(12))
    return str((10 - t % 10) % 10)


def hyphenate(s):
    out = ""
    for ch in s:
        out += ch
        if rnd.random() < 0.15:
            out += pick(["-", " ", "\u2010", ""])
    return out


def r_isbn():
    if rnd.random() < 0.05:
        return r_text()
    r = rnd.random()
    if r < 0.4:
        nine = r_digits(9, 9)
        c = isbn10_check(nine) if rnd.random() < 0.7 else pick("0123456789Xx")
        if rnd.random() < 0.3 and c == "X":
            c = "x"
        return hyphenate(nine + c)
    if r < 0.8:
        tw = (pick(["978", "979", "977", "97"]) + r_digits(9, 10))[:12]
        c = isbn13_check(tw) if len(tw) == 12 and rnd.random() < 0.7 else pick("0123456789X")
        return hyphenate(tw + c)
    return hyphenate(r_digits(8, 14))


def r_issn():
    if rnd.random() < 0.05:
        return r_text()
    seven = r_digits(7, 7)
    t = sum((8 - i) * int(seven[i]) for i in range(7))
    r = t % 11
    c = 0 if r == 0 else 11 - r
    good = "X" if c == 10 else str(c)
    c = good if rnd.random() < 0.7 else pick("0123456789Xx")
    if rnd.random() < 0.3 and c == "X":
        c = "x"
    s = seven[:4] + pick(["-", "-", "-", "", " "]) + seven[4:] + c
    return pick(["", "", "ISSN "]) + s


def r_pmid():
    if rnd.random() < 0.05:
        return r_text()
    return pick(["", "", "0", "PMID:", "-", " "]) + r_digits(0, 12) + pick(["", "", " ", "\u200b"])


def r_pmcid():
    if rnd.random() < 0.05:
        return r_text()
    return pick(["PMC", "PMC", "pmc", "PmC", "", "PMCID:"]) + r_digits(0, 9) + pick(["", "", "v1", " "])


GOOD_DOI = ["10.1016/S0140-6736(97)11096-0", "10.1016/s0140-6736(97)11096-0", "10.1000/x", "10.1000/X",
            "10.1002/(SICI)1097-0266(199708)18:7<509::AID-SMJ882>3.0.CO;2-Z", "10.5281/zenodo.1234567",
            "10.1103/FixRevD.113.012345", "10.1000/\u00e9", "10.1000.10/abc"]
GOOD_ARXIV = ["2601.12345", "2601.12345v2", "2601.12345v3", "hep-th/0701999", "math.GT/0309136v1", "0704.0001"]
GOOD_ISBN = ["0-306-40615-2", "9780306406157", "978-0-306-40615-7", "080442957X", "979-10-90636-07-1"]
GOOD_ISSN = ["0140-6736", "1474-547X", "0378-5955", "2470-0010", "1097-0266"]
GOOD_PMID = ["9500320", "1", "20137807"]
GOOD_PMCID = ["PMC1", "PMC8243230", "pmc1"]


def gv(good, wild, p=0.85):
    return pick(good) if rnd.random() < p else wild()


def r_key():
    return {"text": r_text(lo=0, hi=14), "kind": pick(["title", "title", "name", "TITLE", 5])}


def r_ids(valid=False):
    o = {}
    for k, gd, wl in (("DOI", GOOD_DOI, r_doi), ("ARXIV", GOOD_ARXIV, r_arxiv), ("ISBN", GOOD_ISBN, r_isbn),
                      ("ISSN", GOOD_ISSN, r_issn), ("PMID", GOOD_PMID, r_pmid), ("PMCID", GOOD_PMCID, r_pmcid)):
        if rnd.random() < 0.35:
            o[k] = gv(gd, wl, 0.9 if valid else 0.5)
    if rnd.random() < 0.05:
        o[pick(["ORCID", "doi", "URL"])] = "x"
    if rnd.random() < 0.02:
        return pick([[], "x", None])
    return o


def r_name():
    if rnd.random() < 0.02:
        return pick(["x", 5, {}, {"given": "A"}, {"family": ""}, {"family": "A", "comma-suffix": True}])
    o = {"family": pick(["Wakefield", "Santos", "M\u00fcller", "Muller", "\u00d8st", "Editor", r_safe(1, 4)])}
    if rnd.random() < 0.15:
        del o["family"]
        o["literal"] = pick(["Fixture Collaboration", r_safe(1, 4)])
    for k in ("given", "dropping-particle", "non-dropping-particle", "suffix"):
        if rnd.random() < (0.5 if k == "given" else 0.08):
            o[k] = r_safe(1, 4) if rnd.random() < 0.98 else r_text(lo=0, hi=3)
    return o


def r_date():
    if rnd.random() < 0.02:
        return pick([{"raw": "1998"}, {"date-parts": [["1998"]]}, {"date-parts": []}, "1998",
                     {"date-parts": [[1998]], "circa": "yes"}, {"date-parts": [[1998, 13]]},
                     {"date-parts": [[True]]}, {"date-parts": [[1998], [1999], [2000]]}])
    parts = []
    for _ in range(pick([1, 1, 1, 2])):
        p = [pick([1998, 2026, 2022, 2020, 2026, 1998, 2019, 2001, 1998, 2026, 2022, 2020, -500, 0, 9999] * 6 + [10000, -10000])]
        if rnd.random() < 0.6:
            p.append(pick([1, 2, 12, 6, 8, 2, 1, 12, 3, 4, 5, 7, 9, 10, 11, 6, 8, 2] * 3 + [13, 0]))
            if rnd.random() < 0.5:
                p.append(pick([1, 28, 31, 26, 28, 1, 31, 26, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] * 3 + [32]))
        parts.append(p)
    o = {"date-parts": parts}
    if rnd.random() < 0.2:
        o["circa"] = pick([True, False])
    return o


def r_item(good=0.7):
    o = {}
    for k in CSL_KEYS:
        base = 0.97 if k in ("type", "title", "author", "issued") else 0.15
        if rnd.random() >= base:
            continue
        if k == "type":
            o[k] = pick(CSL_TYPES) if rnd.random() < 0.97 else pick(["preprint", 5, ""])
        elif k in ("author", "editor", "translator", "container-author", "collection-editor"):
            o[k] = [r_name() for _ in range(rnd.randint(1 if k == "author" else 0, 3))] if rnd.random() < 0.98 else {"family": "x"}
        elif k in ("issued", "accessed", "original-date"):
            o[k] = r_date()
        elif k == "DOI":
            o[k] = gv(GOOD_DOI + ["10.48550/arXiv.2601.12345", "https://doi.org/10.1000/x", "doi:10.1000/X"], r_doi)
        elif k == "ISBN":
            o[k] = gv(GOOD_ISBN, r_isbn)
        elif k == "ISSN":
            o[k] = gv(GOOD_ISSN, r_issn)
        elif k == "PMID":
            o[k] = gv(GOOD_PMID, r_pmid)
        elif k == "PMCID":
            o[k] = gv(GOOD_PMCID, r_pmcid)
        elif k == "URL":
            o[k] = "https://example.org/" + r_safe(0, 4)
        elif k == "title":
            o[k] = pick(["Ileal-lymphoid-nodular hyperplasia, non-specific colitis, and pervasive developmental disorder in children",
                         "Vacuum structure of a fixture field", "Fixture Handbook", "Dynamic capabilities: and strategic management",
                         "The E. coli genome & its structure", r_safe(1, 12), r_safe(1, 12)])
        else:
            o[k] = r_safe(1, 12) if rnd.random() < 0.97 else r_text(lo=0, hi=4)
    if rnd.random() < 0.03:
        o[pick(["abstract", "note", "custom", "keyword", "references"])] = "x"
    if rnd.random() < 0.01:
        return pick([[], "x", None])
    return o


def r_caps():
    if rnd.random() < 0.02:
        return pick([{}, {"entry_max": 1}, {"entry_max": 16384, "title": 0, "text": 500, "name": 200, "url": 2000, "names": 100},
                     {"entry_max": 16384, "title": 1000, "text": 500, "name": 200, "url": 2000, "names": 100, "x": 1}])
    return {"entry_max": pick([16384] * 30 + [300, 100]), "title": pick([1000] * 30 + [8, 3]),
            "text": pick([500] * 30 + [8, 3]), "name": pick([200] * 30 + [4, 1]), "url": pick([2000] * 30 + [30]),
            "names": pick([100] * 30 + [2, 1])}


def r_existing():
    out = []
    for _ in range(rnd.randint(0, 3)):
        out.append([pick(["DOI", "ARXIV", "ISBN"]), pick(["10.1000/x", "10.1016/s0140-6736(97)11096-0",
                                                          "2601.12345", "9780306406157", r_safe()]),
                    pick(["RR-BIB1", "RR-BIB2", "RR-BIB3"])])
    if rnd.random() < 0.015:
        out.append(pick([["DOI", "x"], ["ISSN", "x", "RR-BIB1"], "x"]))
    return out


def r_entry_ids(item):
    """identifiers likely to match the item's fields."""
    if type(item) is not dict or rnd.random() < 0.15:
        return r_ids(True)
    o = {}
    for k in ("DOI", "ISBN", "ISSN", "PMID", "PMCID"):
        if k in item and rnd.random() < 0.9:
            o[k] = item[k]
            if k == "DOI" and type(o[k]) is str and rnd.random() < 0.3:
                o[k] = o[k].lower()
    if "DOI" in o and type(o["DOI"]) is str and o["DOI"][:8] == "10.48550":
        del o["DOI"]
        o["ARXIV"] = pick(["2601.12345", "2601.12345v2", "2601.12345v3", "2601.99999"])
    if rnd.random() < 0.3:
        o["ARXIV"] = gv(GOOD_ARXIV, r_arxiv)
    if rnd.random() < 0.05:
        o[pick(["DOI", "ISSN", "ISBN"])] = pick(["10.1000/x", "0140-6736", "0-306-40615-2"])
    return o


def gen_check():
    item = r_item()
    sup = pick([None] * 30 + ["RR-BIB1", "RR-BIB3", "RR-BIB1", "RR-BIB3", "", 5])
    e = {"item": item, "identifiers": r_entry_ids(item), "supersedes": sup}
    if rnd.random() < 0.015:
        e = pick([{"item": item, "identifiers": {}}, {"item": item, "identifiers": {}, "supersedes": None, "x": 1}, "x"])
    return {"entry": e, "existing": r_existing(), "caps": r_caps()}


def gen_canon():
    item = r_item()
    if type(item) is dict and rnd.random() < 0.2:
        item["id"] = pick(["RR-BIB1", "RR-BIB2"])
    return {"bib_id": pick(["RR-BIB1"] * 20 + ["RR-BIB2"] * 10 + ["", 5, "x\u200b"]),
            "proposed_by": pick(["DeltaS Zero"] * 20 + ["acct"] * 10 + ["", None]),
            "landed_by": pick(["Prime AI"] * 20 + ["acct"] * 10 + [5]),
            "landed": pick(["2026-09-26T12:00:00Z"] * 40 + ["now", "", 1]),
            "supersedes": pick([None] * 30 + ["RR-BIB1"] * 10 + [5, ""]),
            "identifiers": r_entry_ids(item), "item": item}


def gen_url():
    tmpl = pick(["https://doi.org/ra/{ID}", "https://api.crossref.org/v1/works/{ID}",
                 "https://api.datacite.org/dois/{ID}", "https://export.arxiv.org/api/query?id_list={ID}",
                 "https://eutils.ncbi.nlm.nih.gov/entrez/eutils/esummary.fcgi?db=pubmed&retmode=json&id={ID}",
                 "https://pmc.ncbi.nlm.nih.gov/tools/idconv/api/v1/articles/?format=json&ids={ID}",
                 "http://doi.org/ra/{ID}", "https://doi.org/ra/", "https://doi.org/{ID}/{ID}",
                 "https://doi.org/ra/{ID}#x", "https://{ID}.doi.org/", "https://doi.org/{id}/{ID}",
                 "https://doi.org:8443/ra/{ID}", "https://doi.org/ra /{ID}", "https://doi.org{ID}", 5,
                 "https://d\u00f6i.org/ra/{ID}", "https://-doi.org/ra/{ID}"])
    d = {"kind": pick(DECL_KINDS + ("OPENALEX", "DOI")), "template": tmpl}
    if rnd.random() < 0.2:
        d["name"] = pick(["lookup", "", 5])
    if rnd.random() < 0.05:
        d = pick([{"kind": "DOI-RA"}, {"template": tmpl}, dict(d, pacing=3), "x"])
    k = d.get("kind") if type(d) is dict else None
    ident = (r_doi() if k in ("DOI-RA", "DOI-CROSSREF", "DOI-DATACITE") else r_arxiv() if k == "ARXIV"
             else r_pmid() if k == "PMID" else r_pmcid())
    if rnd.random() < 0.15:
        ident = pick([r_doi(), r_arxiv(), r_pmid(), r_pmcid()])
    if rnd.random() < 0.4:
        ident = pick(GOOD_DOI if k in ("DOI-RA", "DOI-CROSSREF", "DOI-DATACITE") else GOOD_ARXIV if k == "ARXIV"
                     else GOOD_PMID if k == "PMID" else GOOD_PMCID)
    return {"declaration": d, "identifier": ident}


def r_fields(item, ids, kind):
    f = {"year": None, "title": None, "subtitle": None, "family": None, "name_form": None, "doi": None,
         "issn": [], "isbn": [], "arxiv_doi": None, "arxiv_id": None}
    if type(item) is dict:
        if rnd.random() < 0.8 and type(item.get("issued")) is dict:
            try:
                f["year"] = item["issued"]["date-parts"][0][0] + pick([0, 0, 0, 1])
            except (KeyError, IndexError, TypeError):
                f["year"] = 1998
        if rnd.random() < 0.85 and type(item.get("title")) is str:
            t = item["title"]
            f["title"] = pick(["", "", "RETRACTED: ", "<i>"]) + t + pick(["", "", ".", " x"])
            if rnd.random() < 0.2 and ":" in t:
                a, b = t.split(":", 1)
                f["title"], f["subtitle"] = a, b
        names = item.get("author") or item.get("editor")
        if rnd.random() < 0.85 and type(names) is list and names and type(names[0]) is dict:
            nm = names[0]
            fam = nm.get("family") or nm.get("literal") or ""
            form = pick(["FAMILY", "GIVEN FAMILY", "FAMILY INITIALS"])
            if kind == "ARXIV":
                form = "GIVEN FAMILY"
            if kind in ("PMID", "PMCID"):
                form = "FAMILY INITIALS"
            fam = fam if type(fam) is str else "x"
            f["family"] = (fam if form == "FAMILY" else
                           pick(["Sara ", "", "x"]) + fam if form == "GIVEN FAMILY" else
                           fam + pick([" AJ", "", "x"]))
            f["name_form"] = form
    if rnd.random() < 0.7 and "DOI" in ids:
        f["doi"] = ids["DOI"] if rnd.random() < 0.8 else r_doi()
        if type(f["doi"]) is str and rnd.random() < 0.5:
            f["doi"] = f["doi"].lower()
    if rnd.random() < 0.3 and "ISSN" in ids:
        f["issn"] = [ids["ISSN"]] + ([r_issn()] if rnd.random() < 0.5 else [])
    elif rnd.random() < 0.2:
        f["issn"] = [r_issn()]
    if rnd.random() < 0.3 and "ISBN" in ids:
        f["isbn"] = [ids["ISBN"]]
    if kind == "ARXIV" and rnd.random() < 0.5:
        v = ids.get("DOI") if "DOI" in ids and rnd.random() < 0.7 else r_doi()
        f["arxiv_doi"] = (pick(["https://doi.org/", ""]) + v) if type(v) is str else v
    if kind == "DOI" and rnd.random() < 0.4:
        f["arxiv_id"] = ids.get("ARXIV", pick(GOOD_ARXIV)) if rnd.random() < 0.7 else r_arxiv()
    for k in ("issn", "isbn"):
        f[k] = [x for x in f[k] if type(x) is str and x != ""]
    if rnd.random() < 0.02:
        f[pick(["year", "title", "family", "name_form"])] = pick([5, "x", None, True])
    if rnd.random() < 0.01:
        del f[pick(list(f))]
    return f


def r_notice():
    if rnd.random() < 0.02:
        return pick([{"type": "x"}, {"type": "", "identifier": None, "date": None, "source": "x"}, "x",
                     {"type": "retraction", "identifier": "a b", "date": None, "source": "x"},
                     {"type": "retraction", "identifier": "-", "date": None, "source": "x"},
                     {"type": "retraction", "identifier": None, "date": "2010-02-30", "source": "x"}])
    return {"type": pick(["retraction", "correction", "expression_of_concern", "Reinstatement",
                          "Retracted Publication", "Retraction in", "withdrawn", "Partial-Retraction", r_safe()]),
            "identifier": pick([None, "10.1016/s0140-6736(10)60175-4", "20137807", "10.1/X"]),
            "date": pick([None, "2010-02-06", "2026-03-01", "2024-02-29"]),
            "source": pick(["retraction-watch", "publisher", "pubmed", "arxiv"])}


def r_record(kind, item, ids):
    ident = ids.get(kind, "x")
    if rnd.random() < 0.05:
        ident = pick([r_doi(), r_arxiv(), r_pmid(), r_pmcid(), ident.lower() if type(ident) is str else ident])
    st = pick(["FOUND"] * 8 + ["NOT FOUND", "NOT FOUND", "FAILED", "FAILED", None, "OK"])
    r = {"kind": kind, "identifier": ident}
    if kind == "DOI":
        ra = pick([{"status": "FOUND", "ra": "Crossref"}] * 6 + [{"status": "FOUND", "ra": "DataCite"}] * 3
                  + [{"status": "FOUND", "ra": "mEDRA"}] * 2 + [{"status": "NOT FOUND", "ra": None}] * 2
                  + [{"status": "FAILED", "ra": None}, {"status": "FOUND", "ra": None},
                     {"status": "NOT FOUND", "ra": "Crossref"}, "x"])
        r["ra"] = ra
        reg = None
        if type(ra) is dict and ra["status"] == "FOUND":
            reg = {"Crossref": "crossref", "DataCite": "datacite"}.get(ra["ra"], pick([None, "generic"]))
        if rnd.random() < 0.02:
            reg = pick(["crossref", "datacite", "generic", None, "arxiv"])
        r["registry"] = reg
        r["status"] = st if reg is not None else (None if rnd.random() < 0.9 else st)
    else:
        r["registry"] = ("arxiv" if kind == "ARXIV" else "pubmed") if rnd.random() < 0.98 else pick(["crossref", None])
        r["status"] = st
    if r["status"] == "FOUND":
        r["fields"] = r_fields(item, ids, kind)
        r["notices"] = [r_notice() for _ in range(rnd.randint(0, 3))]
    else:
        r["fields"] = None if rnd.random() < 0.95 else r_fields(item, ids, kind)
        r["notices"] = [] if rnd.random() < 0.95 else [r_notice()]
    if rnd.random() < 0.01:
        del r[pick(list(r))]
    return r


def gen_compare():
    item = r_item()
    ids = r_entry_ids(item)
    recs = []
    if type(ids) is dict:
        for k in ("DOI", "ARXIV", "PMID", "PMCID"):
            if k in ids and rnd.random() < 0.85:
                recs.append(r_record(k, item, ids))
        if rnd.random() < 0.05:
            recs.append(r_record(pick(["DOI", "ARXIV", "PMID", "PMCID"]), item, ids))
    e = {"item": item, "identifiers": ids}
    if rnd.random() < 0.01:
        e = pick([{"item": item, "identifiers": ids, "supersedes": None}, "x"])
    if rnd.random() < 0.01:
        recs = pick(["x", None])
    return {"entry": e, "records": recs,
            "date": pick(["2026-09-26"] * 40 + ["2024-02-29"] * 4 + ["2023-02-29", "2026-13-01", "x", 5])}


def gen_withdrawn():
    return {"comment": pick([r_text(lo=0, hi=12), None, "withdrawn", "This paper has been Withdrawn",
                             "unwithdrawn", "withdrawn5", "\u00e9withdrawn"])}


BIBS = ["RR-BIB1", "RR-BIB10", "RR-BIB2", "RR-BIB3", "RR-BIB4", "RR-BIB5"]


def gen_view():
    ents = []
    for b in BIBS:
        if rnd.random() < 0.8:
            ents.append({"bib_id": b, "check": pick(RESULTS * 8 + ("OK",)) if rnd.random() < 0.995 else 5,
                         "notices": pick([0, 0, 0, 0, 1, 2, 2, 0, 0, 0, 1, 2, 0, 0, 0, 0, 1] * 4 + [-1]), "primary": pick([True, True, False])})
    if rnd.random() < 0.015:
        ents.append(dict(ents[0]) if ents else {"bib_id": "RR-BIB1"})
    finds = []
    have = [e["bib_id"] for e in ents if type(e) is dict and "bib_id" in e] or ["RR-BIB1"]
    for _ in range(rnd.randint(0, 4)):
        finds.append({"bib_id": pick(have * 20 + ["RR-BIB9"]),
                      "result": pick((["SOURCE AGREES", "SOURCE DIFFERS"] + ["NOT FOUND"] * 12) * 3 + ["x"]),
                      "agreed": pick([True, True, False]) if rnd.random() < 0.99 else "yes"})
    recs = []
    for i in range(rnd.randint(0, 6)):
        have = [e["bib_id"] for e in ents if type(e) is dict and "bib_id" in e]
        lit = [b for b in (have if rnd.random() < 0.9 else BIBS) if rnd.random() < 0.4] + (["RR-BIB9"] if rnd.random() < 0.02 else [])
        if rnd.random() < 0.015 and lit:
            lit.append(lit[0])
        recs.append({"id": pick(["RR-%d" % i, "RR-%d" % (i + 20), "RR-%d" % (i + 30)] * 4 + ["RR-1", "RR-10", "RR-2", "RR-7"]),
                     "status": pick(["PROVISIONAL"] * 6 + ["ESTABLISHED"] * 3 + ["STRUCK", "HELD", "REFUTED", ""]),
                     "literature": lit})
    if rnd.random() < 0.015:
        recs.append(pick([{"id": "RR-1", "literature": []}, "x"]))
    args = {"records": recs, "entries": ents, "findings": finds}
    if rnd.random() < 0.015:
        args[pick(list(args))] = pick(["x", None, {}])
    return args


def gen_holdlist():
    ents = []
    for b in BIBS:
        if rnd.random() < 0.8:
            ents.append({"bib_id": b, "proposed_by": pick(["acct", "other", "lead"]),
                         "landed_by": pick(["acct", "other", "lead"]), "block_id": pick(["B1", "B2", "B3", "B4"])})
    if rnd.random() < 0.03:
        ents.append(pick([dict(ents[0]) if ents else {"bib_id": "x"}, {"bib_id": "RR-BIB1", "proposed_by": "acct", "block_id": "B2"}, "x"]))
    checks = {}
    for b in BIBS:
        if rnd.random() < 0.97:
            checks[b] = pick(RESULTS * 12 + ("OK",)) if rnd.random() < 0.995 else 5
    args = {"entries": ents, "account": pick(["acct"] * 30 + ["other", "other", "nobody", "", 5]),
            "blocks": [b for b in ["B1", "B2", "B3", "B4"] if rnd.random() < 0.6], "checks": checks}
    if rnd.random() < 0.03:
        args[pick(list(args))] = pick(["x", None, [], {}])
    return args


GEN = [("doi", lambda: {"text": r_doi()}), ("shape", lambda: {"text": r_doi_core() if rnd.random() < 0.9 else r_text()}),
       ("arxiv", lambda: {"text": gv(GOOD_ARXIV, r_arxiv, 0.3)}), ("isbn", lambda: {"text": r_isbn()}),
       ("issn", lambda: {"text": r_issn()}), ("pmid", lambda: {"text": r_pmid()}),
       ("pmcid", lambda: {"text": r_pmcid()}), ("key", r_key), ("dupe", lambda: {"identifiers": r_ids()}),
       ("check", gen_check), ("canon", gen_canon), ("preimage", gen_canon), ("url", gen_url),
       ("compare", gen_compare), ("withdrawn", gen_withdrawn), ("view", gen_view), ("holdlist", gen_holdlist)]
cases = []
for fn, g in GEN:
    for _ in range(count):
        cases.append({"fn": fn, "args": g()})
CASES_PATH = os.environ.get("TPC_DIFF_CASES")  # named: kept, for crosspy.py
if not CASES_PATH:
    fd, CASES_PATH = tempfile.mkstemp(prefix="tpc_diff_", suffix=".json")  # outside the tree
    os.close(fd)
with open(CASES_PATH, "w", encoding="utf-8") as f:
    json.dump(cases, f)
py = [run_case(c["fn"], c["args"]) for c in cases]
php = json.loads(subprocess.run(["php", os.path.join(HERE, "diff_biblio.php"), CASES_PATH], capture_output=True, text=True,
                                check=True).stdout)
if not os.environ.get("TPC_DIFF_CASES"):
    os.remove(CASES_PATH)
assert len(php) == len(py)
first = None
total = 0
for fn, _ in GEN:
    idx = [i for i, c in enumerate(cases) if c["fn"] == fn]
    diffs = [i for i in idx if py[i] != php[i]]
    total += len(diffs)
    cover = {}
    for i in idx:
        out = py[i]
        if out.startswith("ERROR"):
            keys = [out[6:].split(":")[0] if fn in ("check", "canon", "preimage", "dupe") else "ERROR"]
        elif fn == "doi":
            keys = [out.split("|")[1]]
        elif fn in ("shape", "withdrawn"):
            keys = [out.split("|")[1]]
        elif fn == "arxiv":
            keys = [out.split("|")[4] + ("+v" if out.split("|")[2] != "0" else "")]
        elif fn == "isbn":
            keys = ["given" + out.split("|")[2]]
        elif fn == "dupe":
            keys = [out.split("|")[1]]
        elif fn == "check":
            keys = ["OK:" + out.split("\n")[0].split("|")[2]]
        elif fn == "compare":
            keys = [out.split("\n")[0][8:]]
            keys += sorted({ln.split("|")[2] for ln in out.split("\n")[1:] if ln[:1] in "DAPI"})
            keys += ["NOTICES" if "\nnotice|" in out else "no-notice"]
        elif fn == "view":
            keys = ["n" + out.split("\n")[0][5:]]
        elif fn == "holdlist":
            keys = ["n" + out.split("\n")[0][9:]]
        else:
            keys = ["OK"]
        for k in keys:
            cover[k] = cover.get(k, 0) + 1
    print("seed %d %-9s cases %d differences %d" % (seed, fn, len(idx), len(diffs)))
    print("    " + ", ".join("%s %d" % (k, cover[k]) for k in sorted(cover)))
    if diffs and first is None:
        first = diffs[0]
print("seed %d total differences %d" % (seed, total))
if first is not None:
    print(json.dumps(cases[first], ensure_ascii=True))
    print(py[first])
    print("---")
    print(php[first])