#!/usr/bin/env python3
"""qqww-t4-sec481-chain.py -- verify the chain: the first-order factorisation of the sector-481 eps^0 block of row 35 (the
nonplanar T4 qqbar -> WW family; rows 54-59 of the served connection, n = 6), exact over Q(var).  For a first-order system
Y' = A(var) Y with A in M_n(Q(var)), search for a hyperexponential solution vector
    Y = prod_i f_i(var)^{e_i} * P(var),   e_i in Q, P a polynomial vector,
with the candidate exponents e_i read from the residue eigenvalues at every singular factor f_i (the rational eigenvalues of the
residue matrix in the residue field Q[var]/(f_i), all conjugate roots carrying the same e_i), the degree of P bounded by the
exponents at infinity (N = rho - e_inf, rho a rational eigenvalue of lim var*A, e_inf = sum e_i deg f_i), the coefficients of P
from the exact nullspace (FLINT fmpq_mat rref) of  D P' - (D A - D u'/u) P = 0;  quotient the solution out by the rational gauge
T = [P | e_j (j != i0)] (det T = +-P_{i0}) and iterate on the (n-1)x(n-1) block.  Complete reduction to size 0 <=> a chain of
first-order factors with algebraic solutions (the criterion of the audited external computation's report, report_C3.md L135-142 and
L508-511, the method this script names in its receipt's recipe field); a Fuchsian remainder block of rank k >= 2 with NO
hyperexponential solution over the complete candidate set = NO_FIRST_ORDER_FACTOR (a block with an elliptic maximal-cut curve
whose solutions are not d'Alembertian is such a remainder).
Completeness: at a simple pole the residue eigenvalues are necessary local exponents (rigorous); a pole of order >= 2 (finite or at
infinity) is NOT Fuchsian -- the candidate set is then a declared bounded set (half-integers |e| <= EB) and the degree bound NB, and
the negative verdict is labelled BOUNDED.  A REDUCIBLE verdict is exact whatever the candidate bounds: every factor in the chain is an
exact hyperexponential solution (the quotient asserts A P - P' = c P exactly); the bounds only weaken a NEGATIVE verdict.

THE OBJECT FACT this script re-establishes (the record: vendor_row35_sec481/lines/, /rays/): on both served lines and along two
mass-scaling rays the sector-481 eps^0 block is REDUCIBLE_TO_FIRST_ORDER -- a chain of six exact hyperexponential factors with
exponents in (1/2)Z at the sector's letters (the two conic quadratics carrying exponent -3/2, the rest integer), remainder rank 0,
two rational solutions (the first two factors' rates are 0) -- while the sector's maximal-cut curve is elliptic (j non-constant in
z5).  The certified thimble column of the record lies in the chain's solution space (vendor_row35_sec481/thimble/, 39.89 digits).

Usage (from the bundle directory or with --served-dir naming it):
  qqww-t4-sec481-chain.py --controls [--out DIR] [--tag TAG]
      the four built-in controls C1-C4 (C1 a planted reducible 3x3 chain conjugated by a polynomial gauge: size 0, the exponents
      recovered; C2 the Legendre Picard-Fuchs companion system, irreducible with SL2 monodromy: NO_FIRST_ORDER_FACTOR rank 2;
      C3 a 2x2 triangular block with half-integer exponents: size 0; C4 a direct sum Legendre (+) hyperexponential 1x1: the 1x1
      factor found and the rank-2 remainder left); exit 0 iff all four read as expected, else 3.  Seconds-class (the record's
      rehearsal: 2.9 s).
  qqww-t4-sec481-chain.py --line L1|L2 [--served-dir DIR] [--reference RECEIPT.json] [--out DIR] [--tag TAG]
      the served line's sector-481 block (rows 54-59 of row35_data.json on L1, (x, z) = (17/2, 5/3); of row35_L2/connection_L2.json
      on L2, (x, z) = (17/2, 7/4)) at d = 4 in the variable y, both files pinned by sha256 (a mismatch refuses by name, rc 2).
      Measured walls of the record (one core): 164.5 s on L1, 159.9 s on L2 (the bounds EB 3 / NB 12 / MAXCOMBO 20000).
  qqww-t4-sec481-chain.py --block RAY_BLOCK.json [--reference RECEIPT.json] [--out DIR] [--tag TAG]
      a ray block receipt (key A_eps0_block_rows, the variable lam): the two vendored ray objects vendor_row35_sec481/rays/
      P2_SEC481_BLOCK_p2live_S_*.json (the ray P) and P2_SEC481_BLOCK_p2liveE_S_*.json (the ray E), pinned by sha256 (another
      file refuses by name unless --allow-unpinned-block).  Measured walls of the record: 72.9 s (P), 70.3 s (E).
  --reference RECEIPT.json: a receipt of the same form (the vendored chain receipts under vendor_row35_sec481/lines/ and /rays/,
      or one this script wrote); the run's chain is compared factor by factor -- the exponent lists and the rates u'/u decide
      (exit 3 on any difference), the partial fractions, degree bounds, P vectors and solution counts are reported beside.
  --planted-served: NOT offered from this bundle; refuses by name (rc 2).  The served-block (+) Legendre planted control of the
      record ran 1773.1 s under NB 8 / MAXCOMBO 400 on an 1800-s clock and returned NO_FIRST_ORDER_FACTOR_BOUNDED_REMAINDER_RANK_5 after 3
      factors (its exit code 7: the expected rank-2 remainder after six factors was not reached inside the truncated candidate
      set); the seconds-class planted control is C1 of --controls.
  --out DIR: the receipt directory (default: a fresh directory sec481-chain-run_<stamp>/ under the current working directory;
      a directory inside the bundle refuses by name, rc 2); --tag TAG names the receipt qqww-t4-sec481-chain-run_<TAG>_<stamp>.json
      (default TAG = the mode: controls, L1, L2, or block-<name>).
  env: EB (default 3), NB (default 12), MAXCOMBO (default 20000) -- the candidate bounds of the search.
Exit codes: 0 = the verdict is REDUCIBLE_TO_FIRST_ORDER (and, with --reference, the chain equals the reference factor by factor) /
--controls all as expected; 2 = refused by name (usage, a pin mismatch, a missing file, --out inside the bundle, --planted-served);
3 = a negative verdict, a chain differing from the reference, or a control not as expected.  The receipt names this script and every
input by basename + sha256; it is written O_EXCL and never overwritten."""
import sys, os, re, json, hashlib, subprocess, time, itertools
import argparse
import sympy as sp
from fractions import Fraction
import flint
R = sp.Rational
EB = int(os.environ.get('EB', '3')); NB = int(os.environ.get('NB', '12')); MAXCOMBO = int(os.environ.get('MAXCOMBO', '20000'))
def stamp(f='+%Y-%m-%dT%H:%M:%SZ'):
    return subprocess.run(['date', '-u', f], capture_output=True, text=True, check=True).stdout.strip()
def sha(p): return hashlib.sha256(open(p, 'rb').read()).hexdigest()

SCHEMA = 'P2_REDUCIBILITY_v1'
RECIPE = 'report_C3.md L135-142 (sys_factor): hyperexponential vector solutions prod f_i^{e_i} P, quotient by a rational gauge, iterate; L508-511 (blocktri)'
HERE = os.path.dirname(os.path.abspath(__file__))
SERVED_PINS = {'row35_data.json': '6acf455def67385491ef9be8734c223129c811854503d810abbd4b71a8dea9fc',
               'row35_L2/connection_L2.json': 'fd83c63b685adb69364b936f26672543581ad0aa45d2f6f8d85d31458596d9bb'}
LINE_FILE = {'L1': 'row35_data.json', 'L2': 'row35_L2/connection_L2.json'}
LINE_XZ = {'L1': '(17/2, 5/3)', 'L2': '(17/2, 7/4)'}
RAY_PINS = {'P2_SEC481_BLOCK_p2live_S_20260910T011946Z.json': 'e316f787c2012917e65f139df7c8e854089da5b835b93bb7ba268bfed9345f79',
            'P2_SEC481_BLOCK_p2liveE_S_20260910T012441Z.json': '08f55706a9db2ba5c0d51bcfeda4e49230c6522efd7ce758b809f5f8ff724b1b'}
SEC481_MASTERS = ['wpairT4[1,0,0,0,0,1,1,1,1]', 'wpairT4[1,-1,0,0,0,1,1,1,1]', 'wpairT4[1,0,-1,0,0,1,1,1,1]',
                  'wpairT4[1,0,0,-1,0,1,1,1,1]', 'wpairT4[1,0,0,0,-1,1,1,1,1]', 'wpairT4[1,-2,0,0,0,1,1,1,1]']
CHAIN_KEYS_DECIDING = ('exponents', 'rate_u_prime_over_u')
CHAIN_KEYS_REPORTED = ('rate_partial_fractions', 'degree_bound', 'P', 'n_solutions_at_this_exponent')
PLANTED_SERVED = {'wall_s': 1773.1, 'bounds': {'EB': 3, 'NB': 8, 'MAXCOMBO': 400}, 'clock_s': 1800, 'verdict': 'NO_FIRST_ORDER_FACTOR_BOUNDED_REMAINDER_RANK_5', 'chain_len': 3, 'exit_code': 7}


def refuse(msg, rc=2):
    print(f'qqww-t4-sec481-chain.py REFUSED: {msg}', file=sys.stderr)
    sys.exit(rc)


def check_pin(path, want, label):
    if not os.path.isfile(path):
        refuse(f'{label}: {os.path.basename(path)} is not at {os.path.dirname(path) or "."} -- restore the bundle')
    got = sha(path)
    if got != want:
        refuse(f'{label}: {os.path.basename(path)} sha256 {got[:16]}... != pinned {want[:16]}... -- refusing to run on altered bytes')
    return got


def load_reference(path):
    ref = json.load(open(path))
    if ref.get('schema') != SCHEMA or 'result' not in ref or 'chain' not in ref['result']:
        refuse(f'--reference {os.path.basename(path)}: not a {SCHEMA} receipt with a result.chain')
    return ref


def compare_chain(result, ref_result):
    """factor by factor: the deciding keys (exponent lists, rates) must be equal; the reported keys are listed either way.
    Both sides are normalised through a JSON round trip first (the run's chain carries tuples where a receipt read from disk
    carries lists)."""
    norm = lambda o: json.loads(json.dumps(o, default=str))
    result, ref_result = norm(result), norm(ref_result)
    a, b = result['chain'], ref_result['chain']
    rep = {'verdict_equal': result['verdict'] == ref_result['verdict'], 'size_equal': result['size'] == ref_result['size'],
           'remainder_rank_equal': result['remainder_rank'] == ref_result['remainder_rank'], 'chain_len': [len(a), len(b)],
           'factors_deciding_equal': [], 'factors_reported_equal': [], 'differing': []}
    for i in range(max(len(a), len(b))):
        if i >= len(a) or i >= len(b):
            rep['differing'].append(f'factor {i + 1}: present in one chain only'); continue
        dec = {k: a[i].get(k) == b[i].get(k) for k in CHAIN_KEYS_DECIDING}
        repd = {k: a[i].get(k) == b[i].get(k) for k in CHAIN_KEYS_REPORTED}
        rep['factors_deciding_equal'].append(dec); rep['factors_reported_equal'].append(repd)
        for k, v in dec.items():
            if not v: rep['differing'].append(f'factor {i + 1}: {k}')
    rep['equal'] = bool(rep['verdict_equal'] and rep['size_equal'] and rep['remainder_rank_equal'] and len(a) == len(b) and not rep['differing'])
    return rep


def write_receipt(rec, out_dir, tag, fs):
    os.makedirs(out_dir, exist_ok=True)
    rp = os.path.join(out_dir, f'qqww-t4-sec481-chain-run_{tag}_{fs}.json')
    fd = os.open(rp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
    with os.fdopen(fd, 'w') as f: json.dump(rec, f, indent=1, default=str)
    return rp


def singular_factors(A, x):
    n = A.rows; L = sp.Integer(1)
    for i in range(n):
        for j in range(n):
            if A[i, j] != 0: L = sp.lcm(L, sp.fraction(sp.cancel(A[i, j]))[1])
    L = sp.Poly(L, x)
    facs = []
    for f, m in sp.factor_list(L.as_expr(), x)[1]:
        fp = sp.Poly(f, x)
        if fp.degree() == 0: continue
        fp = fp.monic()
        order = 0
        for i in range(n):
            for j in range(n):
                if A[i, j] == 0: continue
                dd = sp.Poly(sp.fraction(sp.cancel(A[i, j]))[1], x); o = 0
                while dd.degree() > 0 and sp.rem(dd, fp).is_zero:
                    dd = sp.quo(dd, fp); o += 1
                order = max(order, o)
        facs.append((fp, order))
    return facs, L

def inf_order(A, x):
    o = -99
    for i in range(A.rows):
        for j in range(A.cols):
            if A[i, j] == 0: continue
            nn, dd = sp.fraction(sp.cancel(A[i, j]))
            o = max(o, int(sp.Poly(nn, x).degree() - sp.Poly(dd, x).degree() + 2))
    return o   # <= 1 : regular (Fuchsian) at infinity

def rational_roots(poly_x, xx):
    """rational roots of a polynomial in xx over Q (exact), as sympy Rationals"""
    P = sp.Poly(poly_x, xx)
    if P.degree() <= 0: return []
    out = []
    for f, m in sp.factor_list(P.as_expr(), xx)[1]:
        fp = sp.Poly(f, xx)
        if fp.degree() == 1:
            a, b = fp.all_coeffs(); out.append(sp.Rational(-b, a))
    return sorted(set(out))

def residue_exponents(A, x, fp):
    """rational eigenvalues e of the residue matrix at the roots of the irreducible factor fp (simple pole), the same e at every root:
    M = (f*A mod f) * (f')^{-1} mod f in Q[x]/(f); chi(t) = det(t - M) mod f; N(t) = Res_x(chi, f); candidates = rational roots of N
    with chi(e) == 0 mod f verified."""
    n = A.rows; f = fp.as_expr(); fd = sp.diff(f, x)
    g = sp.invert(fd, f, x)                      # (f')^{-1} mod f
    M = sp.zeros(n, n)
    for i in range(n):
        for j in range(n):
            if A[i, j] == 0: continue
            nn, dd = sp.fraction(sp.cancel(A[i, j]))
            q_den, r_ = sp.div(sp.Poly(dd, x), fp)    # dd = f * q_den (simple pole) or dd coprime to f
            if not r_.is_zero:
                continue                             # no pole at f in this entry: contributes 0 to the residue
            inv_den = sp.invert(sp.rem(q_den.as_expr(), f, x), f, x)   # the other factors are units mod f
            M[i, j] = sp.rem(sp.expand(nn * inv_den * g), f, x)
    t = sp.Symbol('t_chi')
    chi = (t * sp.eye(n) - M).det(method='berkowitz')
    chi = sp.Poly(sp.expand(chi), t)
    # reduce the coefficients mod f
    chi_red = sum(sp.rem(sp.expand(c), f, x) * t ** k for k, c in zip(range(chi.degree(), -1, -1), chi.all_coeffs()))
    if fp.degree() == 1:
        r0 = sp.solve(f, x)[0]
        Npoly = sp.expand(chi_red.subs(x, r0))
    else:
        Npoly = sp.resultant(sp.expand(chi_red), f, x)
    cands = rational_roots(Npoly, t)
    ok = []
    for e in cands:
        if sp.rem(sp.expand(chi_red.subs(t, e)), f, x) == 0: ok.append(e)
    return ok

def inf_exponents(A, x):
    """rational eigenvalues rho of A_1 = lim x*A(x) (regular at infinity): solutions ~ x^rho"""
    A1 = (A * x).applyfunc(lambda e: sp.limit(sp.cancel(e), x, sp.oo))
    t = sp.Symbol('t_chi')
    chi = (t * sp.eye(A.rows) - A1).det(method='berkowitz')
    return rational_roots(sp.expand(chi), t)

def poly_solutions(A, x, L, facs, exps, N):
    """nullspace of D P' - (D A - D u'/u) P over Q with deg P <= N; D = L (the lcm denominator); u'/u = sum e_i f_i'/f_i"""
    n = A.rows; D = L.as_expr()
    ul = sum(e * sp.diff(f.as_expr(), x) / f.as_expr() for (f, o), e in zip(facs, exps))
    B = (D * A - D * ul * sp.eye(n)).applyfunc(sp.cancel)
    for i in range(n):
        for j in range(n):
            assert sp.fraction(B[i, j])[1].is_number, 'D A not polynomial'
    # unknowns c[i][k], P_i = sum_k c[i][k] x^k, k = 0..N
    # equation row i: D * P_i' - sum_j B_ij P_j = 0, polynomial identity in x
    degD = sp.Poly(D, x).degree(); degB = max([sp.Poly(sp.expand(B[i, j]), x).degree() for i in range(n) for j in range(n) if B[i, j] != 0] + [0])
    maxdeg = max(degD + N, degB + N) + 1
    nun = n * (N + 1)
    rows = []
    Dp = sp.Poly(D, x)
    Bp = [[sp.Poly(sp.expand(B[i, j]), x) if B[i, j] != 0 else None for j in range(n)] for i in range(n)]
    for i in range(n):
        # coefficient of x^m in D*P_i' : sum_k c[i][k] k * [x^m] (D x^(k-1))
        for m in range(maxdeg + 1):
            row = [Fraction(0)] * nun
            for k in range(1, N + 1):
                # D * k x^(k-1): coefficient of x^m = k * coeff of x^(m-k+1) in D
                mm = m - k + 1
                if 0 <= mm <= degD: row[i * (N + 1) + k] += Fraction(int(sp.Rational(Dp.coeff_monomial(x ** mm)).p), int(sp.Rational(Dp.coeff_monomial(x ** mm)).q)) * k
            for j in range(n):
                if Bp[i][j] is None: continue
                for k in range(N + 1):
                    mm = m - k
                    if 0 <= mm <= Bp[i][j].degree():
                        c = sp.Rational(Bp[i][j].coeff_monomial(x ** mm))
                        row[j * (N + 1) + k] -= Fraction(int(c.p), int(c.q))
            if any(v != 0 for v in row): rows.append(row)
    if not rows:
        # no constraint at all (D A - D u'/u == 0 and N == 0): every constant vector is a solution -- return the unit vectors
        return [[sp.Integer(1) if i == ii else sp.Integer(0) for i in range(n)] for ii in range(n)]
    M = flint.fmpq_mat(len(rows), nun, [flint.fmpq(v.numerator, v.denominator) for r in rows for v in r])
    rr, rank = M.rref()
    if rank == nun: return []
    # nullspace basis from the rref
    pivots = []
    for r in range(rank):
        for c in range(nun):
            if rr[r, c] != 0: pivots.append(c); break
    free = [c for c in range(nun) if c not in pivots]
    sols = []
    for fcol in free:
        v = [Fraction(0)] * nun; v[fcol] = Fraction(1)
        for r, pc in enumerate(pivots):
            q = rr[r, fcol]; v[pc] = -Fraction(int(q.p), int(q.q))
        P = [sum(sp.Rational(v[i * (N + 1) + k].numerator, v[i * (N + 1) + k].denominator) * x ** k for k in range(N + 1)) for i in range(n)]
        sols.append(P)
    return sols

def find_hyperexp(A, x, log):
    n = A.rows
    facs, L = singular_factors(A, x)
    io = inf_order(A, x)
    fuchsian = all(o <= 1 for f, o in facs) and io <= 1
    cand = []
    for fp, o in facs:
        if o == 1: cand.append(residue_exponents(A, x, fp))
        else: cand.append([sp.Rational(k, 2) for k in range(-2 * EB, 2 * EB + 1)])
    if io <= 1: rhos = inf_exponents(A, x)
    else: rhos = None
    log.append({'size': n, 'singular_factors': [(str(f.as_expr()), o) for f, o in facs], 'pole_order_at_infinity': io, 'fuchsian': fuchsian,
                'candidate_exponents': [[str(e) for e in c] for c in cand], 'infinity_rational_exponents': ([str(r) for r in rhos] if rhos is not None else 'irregular at infinity: degree bound NB'),
                'n_combinations': int(sp.prod([len(c) for c in cand])) if cand else 1})
    combos = list(itertools.product(*cand)) if cand else [()]
    if len(combos) > MAXCOMBO:
        log[-1]['note'] = f'combinations {len(combos)} > MAXCOMBO {MAXCOMBO}: truncated (BOUNDED search)'
        combos = combos[:MAXCOMBO]
    tried = 0
    for exps in combos:
        e_inf = sum(e * f.degree() for (f, o), e in zip(facs, exps))
        if rhos is not None:
            Ns = [int(r - e_inf) for r in rhos if (r - e_inf).is_integer and r - e_inf >= 0]
            if not Ns: continue
            N = max(Ns)
        else:
            N = NB
        tried += 1
        sols = poly_solutions(A, x, L, facs, list(exps), N)
        if sols:
            P = sols[0]
            g = sp.Integer(0)
            for p in P: g = sp.gcd(g, sp.Poly(p, x).as_expr()) if p != 0 else g
            P = [sp.cancel(p / g) for p in P] if g != 0 else P
            log[-1]['combinations_tried'] = tried
            return {'exponents': [(str(f.as_expr()), str(e)) for (f, o), e in zip(facs, exps)], 'degree_bound': N, 'P': [str(p) for p in P], 'n_solutions_at_this_exponent': len(sols)}, P, list(exps), facs, fuchsian
    log[-1]['combinations_tried'] = tried
    return None, None, None, facs, fuchsian

def quotient(A, x, P):
    n = A.rows
    degs = [sp.Poly(p, x).degree() if p != 0 else 10 ** 9 for p in P]
    i0 = degs.index(min(degs))
    cols = [sp.Matrix(P)] + [sp.eye(n)[:, j] for j in range(n) if j != i0]
    T = sp.Matrix.hstack(*cols)
    assert sp.cancel(T.det()) != 0
    Ti = T.inv().applyfunc(sp.cancel)
    At = (Ti * (A * T - T.diff(x))).applyfunc(sp.cancel)
    first = [sp.cancel(At[i, 0]) for i in range(1, n)]
    assert all(v == 0 for v in first), 'the first column of the transformed system is not (u/u) e1'
    return At[1:, 1:], str(sp.cancel(At[0, 0]))

def reduce_chain(A, x, label):
    log = []; chain = []; t0 = time.time()
    B = A.applyfunc(sp.cancel); bounded = False
    while B.rows > 0:
        found, P, exps, facs, fuchsian = find_hyperexp(B, x, log)
        if not fuchsian: bounded = True
        if found is None:
            verdict = ('NO_FIRST_ORDER_FACTOR' if fuchsian else 'NO_FIRST_ORDER_FACTOR_BOUNDED') + f'_REMAINDER_RANK_{B.rows}'
            return {'label': label, 'size': A.rows, 'verdict': verdict, 'chain': chain, 'remainder_rank': B.rows, 'log': log, 'bounded_search_used': bounded, 'wall_s': round(time.time() - t0, 3),
                    'remainder_block': [[str(sp.cancel(B[i, j])) for j in range(B.rows)] for i in range(B.rows)]}
        B, rate = quotient(B, x, P)
        found['rate_u_prime_over_u'] = rate
        # the canonical exponents: the search's e_i are defined only up to integers absorbed into P (P is made primitive in the
        # quotient); the rate u'/u of the found solution is the invariant object -- its partial fractions give the exponents
        try:
            found['rate_partial_fractions'] = str(sp.apart(sp.sympify(rate, locals={str(x): x}), x))
        except Exception as ex:
            found['rate_partial_fractions'] = f'apart failed: {ex}'
        found['exponents_note'] = 'search labels; the canonical exponents are the residues of rate_u_prime_over_u (rate_partial_fractions)'
        chain.append(found)
    # a REDUCIBLE verdict is exact whatever the candidate bounds: every factor in the chain is an exact hyperexponential solution
    # (the quotient asserts A P - P' = c P exactly); the bounds only weaken a NEGATIVE verdict
    return {'label': label, 'size': A.rows, 'verdict': 'REDUCIBLE_TO_FIRST_ORDER', 'chain': chain, 'remainder_rank': 0, 'log': log, 'bounded_search_used': bounded, 'wall_s': round(time.time() - t0, 3)}

def controls(x):
    out = {}
    # C1: planted reducible 3x3 chain (exponents 1/2, -1/2, 1 at 0, 1, 2), conjugated by a polynomial gauge with a non-constant det
    Dg = sp.diag(R(1, 2) / x - R(1, 2) / (x - 1), R(1, 3) / (x - 2), -1 / x + R(3, 2) / (x - 1))
    Dg[0, 1] = 1 / (x - 1); Dg[1, 2] = x / (x - 2)
    T = sp.Matrix([[1, x, 0], [0, 1, x + 1], [x - 3, 0, 1]])
    A1 = (T * Dg * T.inv() + T.diff(x) * T.inv()).applyfunc(sp.cancel)
    out['C1_planted_reducible_3x3'] = reduce_chain(A1, x, 'C1 planted reducible 3x3, gauge det ' + str(sp.factor(T.det())))
    out['C1_planted_reducible_3x3']['expected'] = 'REDUCIBLE_TO_FIRST_ORDER'
    # C2: Legendre Picard-Fuchs companion: x(1-x) y'' + (1-2x) y' - y/4 = 0
    A2 = sp.Matrix([[0, 1], [R(1, 4) / (x * (1 - x)), -(1 - 2 * x) / (x * (1 - x))]])
    out['C2_legendre_irreducible_2x2'] = reduce_chain(A2, x, 'C2 Legendre PF companion (K(x), K(1-x))')
    out['C2_legendre_irreducible_2x2']['expected'] = 'NO_FIRST_ORDER_FACTOR_REMAINDER_RANK_2'
    # C3: 2x2 triangular with half-integer exponents
    A3 = sp.Matrix([[R(1, 2) / x - R(1, 2) / (x - 1), 1 / (x * (x - 1))], [0, R(-3, 2) / (x - 1)]])
    out['C3_halfinteger_triangular_2x2'] = reduce_chain(A3, x, 'C3 half-integer triangular 2x2')
    out['C3_halfinteger_triangular_2x2']['expected'] = 'REDUCIBLE_TO_FIRST_ORDER'
    # C4: Legendre (+) a 1x1 hyperexponential factor, conjugated by a constant gauge
    A4 = sp.diag(A2, sp.Matrix([[R(1, 2) / (x - 1)]]))
    C = sp.Matrix([[1, 2, 0], [0, 1, 1], [1, 0, 1]])
    A4 = (C * A4 * C.inv()).applyfunc(sp.cancel)
    out['C4_legendre_plus_1x1'] = reduce_chain(A4, x, 'C4 Legendre (+) 1x1 half-integer, constant gauge')
    out['C4_legendre_plus_1x1']['expected'] = 'NO_FIRST_ORDER_FACTOR_REMAINDER_RANK_2 after one factor'
    def cls(v): return v.replace('_BOUNDED', '')   # the verdict class; the BOUNDED label (a non-Fuchsian point in the companion/gauged form) is recorded beside
    ok = (cls(out['C1_planted_reducible_3x3']['verdict']) == 'REDUCIBLE_TO_FIRST_ORDER' and cls(out['C2_legendre_irreducible_2x2']['verdict']) == 'NO_FIRST_ORDER_FACTOR_REMAINDER_RANK_2'
          and cls(out['C3_halfinteger_triangular_2x2']['verdict']) == 'REDUCIBLE_TO_FIRST_ORDER'
          and cls(out['C4_legendre_plus_1x1']['verdict']) == 'NO_FIRST_ORDER_FACTOR_REMAINDER_RANK_2' and len(out['C4_legendre_plus_1x1']['chain']) == 1)
    return out, ok

def served_L1_block(path, rows=(54, 55, 56, 57, 58, 59)):
    y, d = sp.symbols('y d')
    raw = json.load(open(path))
    def ev(nd):
        return sum(R(c) * d ** k for k, c in enumerate(nd['num'])) / sum(R(c) * d ** k for k, c in enumerate(nd['den']))
    idx = {r: i for i, r in enumerate(rows)}
    B = sp.zeros(len(rows), len(rows))
    for key, e in raw['A_entries'].items():
        i, j = (int(t) for t in key.split(','))
        if i in idx and j in idx:
            N = sum(ev(nd) * y ** k for k, nd in enumerate(e['N'])); Q = sum(ev(qd) * y ** k for k, qd in enumerate(e['Q']))
            B[idx[i], idx[j]] = sp.cancel(N / Q)
    masters = [raw['masters'][r] for r in rows]
    return B.subs(d, 4).applyfunc(sp.cancel), masters, y

def main():
    ap = argparse.ArgumentParser(prog='qqww-t4-sec481-chain.py', description='verify the chain: the first-order factorisation of the sector-481 eps^0 block of row 35 (see the module docstring)')
    mode = ap.add_mutually_exclusive_group(required=True)
    mode.add_argument('--controls', action='store_true', help='the four built-in controls C1-C4 (seconds-class); exit 0 iff all as expected')
    mode.add_argument('--line', choices=('L1', 'L2'), help='the served line block (rows 54-59 at d = 4, variable y); the connection pinned by sha256')
    mode.add_argument('--block', metavar='RAY_BLOCK.json', help='a ray block receipt (key A_eps0_block_rows, variable lam): the vendored ray objects, pinned by sha256')
    mode.add_argument('--planted-served', action='store_true', help='not offered from this bundle (refuses by name; see the module docstring)')
    ap.add_argument('--served-dir', default=HERE, help='the bundle directory holding row35_data.json and row35_L2/connection_L2.json (default: the directory of this script)')
    ap.add_argument('--reference', metavar='RECEIPT.json', help='a receipt of the same form; the chain must equal it factor by factor (exponent lists, rates) for exit 0')
    ap.add_argument('--allow-unpinned-block', action='store_true', help='with --block: run on a receipt that is not one of the two vendored ray objects (stated in the receipt)')
    ap.add_argument('--out', metavar='DIR', help='the receipt directory (default: a fresh sec481-chain-run_<stamp>/ under the working directory; never inside the bundle)')
    ap.add_argument('--tag', help='the receipt name qqww-t4-sec481-chain-run_<TAG>_<stamp>.json (default: the mode)')
    args = ap.parse_args()
    st = stamp(); fs = stamp('+%Y%m%dT%H%M%SZ'); t0 = time.time()
    if args.planted_served:
        refuse(f"--planted-served is not offered from this bundle: the served-block (+) Legendre planted control of the record ran {PLANTED_SERVED['wall_s']} s under "
               f"NB {PLANTED_SERVED['bounds']['NB']} / MAXCOMBO {PLANTED_SERVED['bounds']['MAXCOMBO']} on a {PLANTED_SERVED['clock_s']}-s clock and returned {PLANTED_SERVED['verdict']} after "
               f"{PLANTED_SERVED['chain_len']} factors (exit code {PLANTED_SERVED['exit_code']}: the expected rank-2 remainder after six factors was not reached inside the truncated candidate set); "
               f"the seconds-class planted control is C1 of --controls")
    out_dir = os.path.abspath(args.out) if args.out else os.path.join(os.getcwd(), f'sec481-chain-run_{fs}')
    bundle = os.path.realpath(HERE)
    if os.path.realpath(out_dir) == bundle or os.path.realpath(out_dir).startswith(bundle + os.sep):
        refuse(f'--out {out_dir} is inside the bundle directory {bundle}; the receipt directory must lie outside it')
    rec = {'schema': SCHEMA, 'stamp_utc': st, 'tag': None, 'mode': None, 'line': None, 'producer': os.path.basename(__file__), 'producer_sha256': sha(os.path.abspath(__file__)),
           'recipe': RECIPE, 'bounds': {'EB': EB, 'NB': NB, 'MAXCOMBO': MAXCOMBO}, 'sympy': sp.__version__, 'python_flint': flint.__version__}
    rc = 0
    if args.controls:
        rec['mode'] = '--controls'; rec['tag'] = args.tag or 'controls'
        x = sp.Symbol('lam')
        rec['controls'], ok = controls(x); rec['controls_all_pass'] = ok; rc = 0 if ok else 3
    elif args.block:
        src = os.path.abspath(args.block)
        if not os.path.isfile(src):
            refuse(f'--block {src}: no such file')
        got = sha(src); base = os.path.basename(src)
        pinned = RAY_PINS.get(base)
        if pinned is None or pinned != got:
            if not args.allow_unpinned_block:
                refuse(f'--block {base} sha256 {got[:16]}... is not one of the two vendored ray objects ({", ".join(k[:24] + "... " + v[:16] + "..." for k, v in RAY_PINS.items())}); pass --allow-unpinned-block to run on it anyway')
        blk = json.load(open(src)); x = sp.Symbol('lam')
        rows = blk['A_eps0_block_rows']; n = len(rows)
        A = sp.Matrix(n, n, lambda i, j: sp.sympify(rows[i][j], locals={'lam': x}))
        rec['mode'] = '--block'; rec['tag'] = args.tag or f'block-{blk["config_receipt"]["ray"]["name"]}'
        rec['input'] = {'file': base, 'sha256': got, 'pinned': pinned == got, 'block_size': n, 'masters': blk['config_receipt']['masters_served_order'], 'ray': blk['config_receipt']['ray']}
        rec['result'] = reduce_chain(A, x, f'sector {blk["config_receipt"]["sector"]} eps^0 block on the ray {blk["config_receipt"]["ray"]["name"]}')
    else:
        line = args.line
        served = os.path.abspath(args.served_dir)
        rel = LINE_FILE[line]; src = os.path.join(served, rel)
        got = check_pin(src, SERVED_PINS[rel], f'--line {line}')
        A, masters, y = served_L1_block(src)
        if [m.replace(' ', '') for m in masters] != SEC481_MASTERS:
            refuse(f'--line {line}: rows 54-59 of {rel} are not the six sector-481 masters {SEC481_MASTERS}: {masters}')
        rec['mode'] = '--line'; rec['tag'] = args.tag or line; rec['line'] = line
        rec['input'] = {'file': rel, 'sha256': got, 'served_dir': os.path.basename(served), 'rows': [54, 55, 56, 57, 58, 59], 'masters': masters,
                        'note': f'the SERVED {line} line block in y at d = 4 ((x, z) = {LINE_XZ[line]}); NOT the ray object'}
        rec['result'] = reduce_chain(A, y, f'served {line} sector-481 block, d = 4, variable y')
    if 'result' in rec:
        if rec['result']['verdict'] != 'REDUCIBLE_TO_FIRST_ORDER':
            rc = 3
        if args.reference:
            rp = os.path.abspath(args.reference)
            if not os.path.isfile(rp):
                refuse(f'--reference {rp}: no such file')
            ref = load_reference(rp)
            cmp_ = compare_chain(rec['result'], ref['result'])
            rec['reference'] = {'file': os.path.basename(rp), 'sha256': sha(rp), 'verdict': ref['result']['verdict'], 'compare': cmp_}
            if not cmp_['equal']:
                rc = 3
    rec['wall_s'] = round(time.time() - t0, 3); rec['stamp_end_utc'] = stamp(); rec['exit_code'] = rc
    rp = write_receipt(rec, out_dir, rec['tag'], fs)
    summ = {'receipt': os.path.basename(rp), 'receipt_dir': out_dir, 'sha256': sha(rp), 'wall_s': rec['wall_s'], 'rc': rc}
    if 'controls' in rec: summ['controls'] = {k: (v['verdict'], v['expected'], v['wall_s']) for k, v in rec['controls'].items()}; summ['controls_all_pass'] = rec['controls_all_pass']
    if 'result' in rec: summ['verdict'] = rec['result']['verdict']; summ['chain_len'] = len(rec['result']['chain']); summ['remainder_rank'] = rec['result']['remainder_rank']; summ['bounded'] = rec['result']['bounded_search_used']
    if 'reference' in rec: summ['reference_equal_factor_by_factor'] = rec['reference']['compare']['equal']; summ['reference_differing'] = rec['reference']['compare']['differing']
    print(json.dumps(summ, indent=1, default=str))
    sys.exit(rc)
if __name__ == '__main__':
    main()
