"""p08_check.py: the Phase 2 check script.

Runs every assertion below, then renders the final report (outputs/phase2_report.md) with the result
in its first lines, then re-reads that file and checks it. A phase stop may not be requested unless this
script prints PASS.

Groups of assertions
  A  values file: schema, finite numbers, interval estimands equal to their point estimands, lo <= point <= hi
  B  provenance of external numbers: each journal and Kaggle number appears in the verified text it was parsed from
  C  independent recomputation from the raw files (not through the analysis scripts)
  D  claims the prose makes in words ("every", "all", "none", "exactly") that rest on values
  E  rendering: every token resolves, every interval sits next to its own point estimate, and the skeleton
     render contains no digit outside a listed identifier pattern (no number typed by hand)
  F  language lint (p07_lint.py): zero errors
  G  the final report file on disk carries this check's result and matches a fresh render
"""
import csv, datetime, glob, hashlib, json, math, os, re, sys

import numpy as np
import pandas as pd

sys.path.insert(0, "/home/claude/mill/code")
from vals import Values, TOKEN, fmt_value
from p06_report import render, qlog_counts, load
from p07_lint import lint, load_allow

ROOT = "/home/claude/mill"
RAW, INP, OUT, CODE = f"{ROOT}/raw", f"{ROOT}/inputs", f"{ROOT}/outputs", f"{ROOT}/code"
MACH = ("imi_vm20i", "imi_vmx30ui", "tmf_vf10")

N = 0
FAIL = []


def check(cond, what):
    global N
    N += 1
    if not bool(cond):
        FAIL.append(what)
    return bool(cond)


V = json.load(open(f"{OUT}/values.json"))
val = lambda k: V[k]["value"]

# ============================================================== A. values file
KINDS = {"value", "judgment", "external", "text", "interval"}
scripts = {os.path.basename(p) for p in glob.glob(f"{CODE}/*.py")}
for k, r in V.items():
    check(re.fullmatch(r"[a-z0-9_]+", k), f"A name {k}")
    check(all(f in r for f in ("value", "unit", "estimand", "source", "kind")), f"A fields {k}")
    check(r.get("kind") in KINDS, f"A kind {k}")
    check(r.get("source") in scripts, f"A source script exists for {k}: {r.get('source')}")
    check(bool(r.get("estimand")), f"A estimand present {k}")
    if r.get("kind") == "interval":
        p = r.get("point")
        check(p in V, f"A interval {k} has point {p}")
        if p in V:
            check(V[p]["estimand"] == r["estimand"], f"A interval {k} estimand equals that of {p}")
            pt = V[p]["value"]
            lo, hi = r["lo"], r["hi"]
            check(all(isinstance(x, (int, float)) and math.isfinite(x) for x in (lo, hi)), f"A interval {k} finite")
            check(lo <= pt + 1e-9 * max(1, abs(pt)) and pt <= hi + 1e-9 * max(1, abs(pt)), f"A interval {k}: lo <= point <= hi ({lo}, {pt}, {hi})")
            check(abs(r["level"] - val("ci_level")) < 1e-12, f"A interval {k} level equals ci_level")
    elif isinstance(r.get("value"), float):
        check(math.isfinite(r["value"]), f"A finite {k}")

# ============================================================== B. provenance of external numbers
JR = json.load(open(f"{INP}/journals.json"))
jtext = {}
for j in JR["journals"]:
    parts = [j.get("metrics_text", ""), json.dumps(j.get("timeline_text", {})), " ".join(j.get("fee_quotes", [])),
             j.get("article_types", ""), j.get("review_rule", ""), j.get("if_release_quote", "")]
    jtext[j["id"].lower()] = " ".join(parts)
for k, r in V.items():
    m = re.match(r"j_([a-z]+)_", k)
    if not m or r.get("kind") != "external":
        continue
    t = jtext.get(m.group(1), "")
    v = r["value"]
    if isinstance(v, str):
        ok = v in t
    elif r["unit"] == "fraction":
        ok = f"{round(100 * v)}%" in t
    elif isinstance(v, float):
        ok = (f"{v:.1f}" in t) or (f"{v:g}" in t)
    else:
        ok = re.search(rf"(?<![\d.]){v}(?![\d])", t) is not None or f"({v})" in t
    check(ok, f"B {k}={v} appears in the verified journal text it was parsed from")
SRC = json.load(open(f"{INP}/sources.json"))
S = {s["id"]: s for s in SRC["sources"]}
kf = S["S_kaggle_api"]["fields"]
check(val("kaggle_total_bytes") == kf["totalBytes"], "B kaggle_total_bytes equals the API record")
check(val("kaggle_version") == kf["currentVersionNumber"], "B kaggle_version equals the API record")
check(kf["licenseName"] == "Attribution 4.0 International (CC BY 4.0)", "B Kaggle licence is CC BY 4.0")
QC = json.load(open(f"{INP}/quote_checks.json"))
check(len(QC["results"]) == len(S["S_article"]["quotes"]), "B every article quote was checked against the page text")
for q, ok in QC["results"].items():
    check(ok is True, f"B article quote {q} found verbatim on the page")
check("particularly the sound recordings" in S["S_article"]["quotes"]["q_refine"], "B quoted phrase in finding 1 is verbatim from q_refine")
check("synchronized to the same NIST time server" in S["S_article"]["quotes"]["q_ntp"], "B NIST claim rests on q_ntp")
check("wet cutting conditions" in S["S_article"]["quotes"]["q_wet"] and "dry cutting" in S["S_article"]["quotes"]["q_wet"], "B wet and dry claim rests on q_wet")
check("long vs. short setup" in S["S_article"]["quotes"]["q_clamp"], "B tool setup claim rests on q_clamp")
check("does not denote a single vibration mechanism" in S["S_article"]["quotes"]["q_abn_mech"], "B broader-than-chatter claim rests on q_abn_mech")
check("the same ap was maintained throughout that pass" in S["S_article"]["quotes"]["q_pass"], "B fixed-ap claim rests on q_pass")
t4 = {r[0]: [float(x) / 100 for x in r[1:]] for r in S["S_table4"]["table"]["rows"]}
for m in MACH:
    for l in (0, 1, 2):
        check(abs(val(f"doc_t4_{m}_l{l}") - t4[m][l]) < 1e-12, f"B doc_t4_{m}_l{l} equals the table cell")

# ============================================================== C. independent recomputation from raw files
inv = [r for r in json.load(open(f"{RAW}/Phase2/derived/file_inventory.json")) if not r["path"].startswith("Phase2/")]
check(len(inv) == val("n_files"), "C file count")
check(sum(r["bytes"] for r in inv) == val("bytes_total"), "C byte total")
check(val("bytes_total") == kf["totalBytes"], "C byte total equals Kaggle totalBytes")
labs = []
for m in MACH:
    for d in glob.glob(f"{RAW}/{m}/dataset*"):
        L = pd.read_csv(f"{d}/label.csv")
        L["machine"], L["run"] = m, int(d.split("dataset")[-1])
        labs.append(L)
L = pd.concat(labs, ignore_index=True)
L["dur"] = L.end - L.start
check(L.groupby("machine").run.nunique().sum() == val("n_runs"), "C run count")
for m in MACH:
    check(L[L.machine == m].run.nunique() == val(f"n_runs_{m}"), f"C runs {m}")
    check((L.machine == m).sum() == val(f"n_paths_{m}"), f"C paths {m}")
    for l in (0, 1, 2):
        check(((L.machine == m) & (L.label == l)).sum() == val(f"n_paths_{m}_l{l}"), f"C paths {m} label {l}")
        share = L[(L.machine == m) & (L.label == l)].dur.sum() / L[L.machine == m].dur.sum()
        check(abs(share - val(f"time_share_{m}_l{l}")) < 1e-9, f"C time share {m} label {l}")
        check(abs(round(100 * share, 2) - 100 * t4[m][l]) < 1e-9, f"C Table 4 cell {m} label {l} reproduced as printed")
check(len(L) == val("n_paths"), "C path total")
check(abs(L.dur.sum() - val("cut_total_s")) < 1e-6, "C cutting time")
check(abs(round(L.dur.sum()) - val("doc_cut_time_s")) <= 1, "C cutting time equals the article's within a second")
zg = []
for (m, r), g in L.groupby(["machine", "run"]):
    g = g.sort_values("start")
    gap = g.start.values[1:] - g.end.values[:-1]
    for i in np.where(np.abs(gap) < 1e-9)[0]:
        zg.append((m, g.label.values[i] != g.label.values[i + 1]))
check(len(zg) == val("n_zero_gaps"), "C zero-gap pairs")
check(all(m == "tmf_vf10" for m, _ in zg), "C every zero-gap pair is on tmf_vf10")
check(all(c for _, c in zg), "C every zero-gap pair changes label")
lab01 = L[L.machine.isin(["imi_vm20i", "imi_vmx30ui"]) & L.label.isin([0, 1])]
both = [g for _, g in lab01.groupby(["machine", "run"]) if g.label.nunique() == 2]
check(len(both) == val("d2_within_runs"), "C runs holding both labels")
check(all(g.ap.nunique() == 1 for g in both), "C ap is constant inside every run that holds both labels")
lab = L[L.machine != "tmf_vf10"]
for wp, key in (("AA 6061", "aa6061"), ("AISI 1018", "aisi1018"), ("AISI 4140", "aisi4140")):
    s = lab[lab.Workpiece == wp]
    check(abs((s.label == 1).mean() - val(f"lab_l1_path_share_{key}")) < 1e-9, f"C label 1 share on {wp}")
rl = L.groupby(["machine", "run"]).label.apply(lambda x: (x == 1).all())
check(int(rl.sum()) == val("n_runs_all_l1"), "C runs abnormal from first path to last")
check(set(L.Workpiece.unique()) >= {"AA 6061", "AISI 1018", "AISI 4140"} and L.Workpiece.nunique() == val("n_materials"), "C material count")
ps = open(f"{OUT}/prespec_descriptive_questions.md", "rb").read()
check(hashlib.sha256(ps).hexdigest() == val("prespec_sha256"), "C pre-specification hash matches the frozen file")
check(open(f"{OUT}/prespec_sha256.txt").read().split()[0] == val("prespec_sha256"), "C pre-specification hash matches the hash record")
pub = datetime.datetime.strptime(val("doc_article_published"), "%d %B %Y").date()
ret = datetime.date.fromisoformat(val("sources_retrieved"))
check((ret - pub).days == val("days_since_pub"), "C days since publication")
check(math.ceil((ret - pub).days / 30.44) == val("months_since_pub"), "C months since publication")
rows = list(csv.DictReader(open(f"{INP}/query_log.tsv"), delimiter="\t"))
check(len(rows) == val("q_rows"), "C query log rows")
check(len({r["database"] for r in rows}) == val("q_databases"), "C query log databases")
check(all(r[c] is not None for r in rows for c in r), "C query log rows are complete")

# ============================================================== D. claims made in words
eq = lambda a, b: check(val(a) == val(b), f"D {a} equals {b}")
check(val("bytes_match_kaggle") == 1, "D bytes match Kaggle")
check(val("n_availability_mismatch") == 0, "D no modality mismatch")
check(val("summary_matches_folders") == 1, "D summary workbook lists the folders")
check(val("n_runs_tool_mismatch_summary") == 0 and val("n_runs_wp_mismatch_summary") == 0, "D summary tool and workpiece agree")
check(val("wav_all_48k_16bit_mono") == 1 and val("doc_fs_audio_hz") == 48000 and val("doc_audio_bits") == 16, "D WAV headers as documented")
check(round(val("acc_fs_est_min")) == round(val("acc_fs_est_max")) == 51200, "D every accelerometer file at the documented rate")
check(val("ct_dt_median_min") == val("ct_dt_median_max") == val("doc_ct_period_s"), "D CT period in every run as documented")
eq("label_cols_min", "label_cols_max"); eq("ct_cols_min", "ct_cols_max"); eq("acc_cols_min", "acc_cols_max")
for m in MACH:
    eq(f"mtc_cols_{m}_min", f"mtc_cols_{m}_max")
eq("n_runs_one_stem", "n_runs")
eq("n_zero_gaps", "n_zero_gaps_tmf_vf10"); eq("n_zero_gaps", "n_zero_gaps_label_change")
check(val("d9_paths_gt1pct_imi_vm20i_s0_l1") == 1, "D every label 1 path on imi_vm20i above the clipping threshold")
check(val("n_csv_crlf") == val("n_files_label") + val("n_files_mtc") + val("n_files_ct") == val("n_csv_small"), "D all small CSV files use CRLF")
check(val("dur_s0_minus_doc_maxabs_s") < 1, "D durations within a fraction of a second")
check(val("doc_durations_match_s0") == 1 and val("doc_durations_match_mtc") == 0, "D durations are sound-file durations")
check(val("vf10_runs_multiple_f") == val("n_runs_tmf_vf10"), "D every industrial run mixes feed values")
check(val("n_paths_z_mismatch_table2") == 0 and val("n_paths_ae_gt_d") == 0, "D flutes and widths consistent")
check(val("n_path_overlaps") == 0 and val("n_path_duplicates") == 0 and val("n_runs_unsorted") == 0 and val("n_nonpositive_dur") == 0, "D path timing clean")
check(val("n_label_end_beyond_s0") == 0, "D no label ends after the sound file")
check(val("d7_runs_imi_vm20i_aisi1018") == 1 and V["d7_u_imi_vm20i_aisi1018_ci"]["lo"] == V["d7_u_imi_vm20i_aisi1018_ci"]["hi"], "D AISI 1018 rests on one run, interval collapsed")
check(V["d7_u_imi_vm20i_aa6061_ci"]["lo"] < 0 < V["d7_u_imi_vm20i_aa6061_ci"]["hi"], "D aluminium interval spans zero")
check(val("d2_within_auc_ap") == 0.5, "D ap AUC exactly one half")
check(val("d2_disc_down_imi_vmx30ui") == 0, "D imi_vmx30ui has no down-only abnormal pair (reason for the correction)")
check(val("s1_auc_imi_vm20i_clip_run") > 0.95 and abs(val("s1_auc_imi_vmx30ui_clip_run") - 0.5) < 0.05, "D clipping AUC high on imi_vm20i and at chance on imi_vmx30ui")
check(val("s1_gap_imi_vm20i_highrate") < val("s1_gap_imi_vmx30ui_highrate"), "D leakage gap larger on imi_vmx30ui")
check(val("n_users_published") + val("n_users_repo") == val("n_dataset_users"), "D dataset users add up")
check(val("n_mtc_unavailable_other") == 0, "D no missing MTConnect cell outside the asset columns")
labruns = L[L.machine != "tmf_vf10"].groupby(["machine", "run"])
check(labruns.Tool.nunique().max() == 1 == val("lab_tools_per_run_max"), "D every laboratory run uses a single tool")
check(labruns.Workpiece.nunique().max() == 1, "D every laboratory run uses a single workpiece")
check(val("vf10_tools_per_run_min") == val("vf10_tools_per_run_max"), "D every industrial run uses the same number of tools")
check(val("n_runs_all_l1") == val("n_runs_all_l1_lab"), "D every all-label-1 run is a laboratory run")
check(all(val(f"d1_l1_share_tool_{t}") < 0.02 for t in ("b", "bstar", "c1", "c2", "d", "dstar", "f1", "f2")), "D tools other than A and E carry label 1 at a trace at most")
diam = {}
for row in S["S_table2"]["table"]["rows"]:
    m_ = re.match(r"([\d.]+) mm diameter .*end mill", row[1])
    if m_:
        diam[row[0].replace("*", "")] = float(m_.group(1))
check(diam and min(diam, key=diam.get) == "A", "D tool A is the smallest end mill in the article's tool table")
d10r = {ch: val(f"d10_runs_{ch}") for ch in ("acc0", "acc1", "s0", "s1", "s2")}
check(min(d10r.values()) == val("d10_runs_acc0") and max(d10r.values()) == val("d10_runs_s0") < val("min_runs_interpretable"), "D every D10 comparison rests on too few runs")
check(val("d7_runs_imi_vm20i_aisi4140") >= val("min_runs_interpretable"), "D AISI 4140 on imi_vm20i rests on enough runs")
check(val("kaggle_downloads") > val("kaggle_views"), "D downloads exceed views")
for m in MACH:
    dd = val(f"dur_s0_{m}_s") - val(f"doc_duration_{m}_s")
    check(0 <= dd < 1, f"D the article's duration for {m} is the Sound0 total without its fraction of a second")
check(max(abs(val(f"path_share_{m}_l{l}") - t4[m][l]) for m in MACH for l in (0, 1, 2)) > 0.01, "D path shares do not reproduce Table 4")
check(val("n_runs_with_acc_imi_vmx30ui") == 0 and val("n_runs_with_acc_tmf_vf10") == 0, "D only imi_vm20i has accelerometers")
PROF = json.load(open(f"{OUT}/profile.json"))
neg = [x["lag_s"] for x in PROF["d8_load_sound_lags"] if x["machine"] == "imi_vm20i" and x["lag_s"] < 0]
check(len(neg) == val("d8_load_n_neg_imi_vm20i") and max(neg) <= -2, "D negative load lags on imi_vm20i are several seconds long")
cit = [r_ for r_ in rows if r_["query"] == "works citing the data descriptor"]
check(len(cit) == 1 and int(cit[0]["count"]) == val("doc_article_citations"), "D OpenAlex citing-work count equals the article page's")
gs = [r_ for r_ in rows if r_["database"] == "Google Scholar" and "cited by" in r_["top_ids_seen"]]
check(gs and int(re.search(r"descriptor \(cited by (\d+)\)", gs[0]["top_ids_seen"]).group(1)) == val("doc_article_citations"), "D Google Scholar citation count equals the article page's")
check(V["d3_auc_imi_vmx30ui_load_ci"]["hi"] < 0.5, "D spindle load on imi_vmx30ui runs against the pre-specified direction")
check(all(0.5 < val(f"d3_auc_imi_vm20i_{c}") < 0.75 for c in ("load", "watt")), "D spindle load and power separate the labels weakly on imi_vm20i")
check(val("d1_l1_paths_aa6061") == val("d1_l1_paths_aa6061_toola") + val("d1_l1_paths_aa6061_other"), "D aluminium label 1 paths add up")
check(((L.machine == "imi_vmx30ui") & (L.run == 19)).sum() == val("ovr_imi_vmx30ui_run19_paths"), "D the override affects all paths of imi_vmx30ui dataset19")
check(abs(val("ovr_imi_vmx30ui_run19_len_ratio") - 100 / val("ovr_imi_vmx30ui_run19_pct")) < 0.01, "D path length ratio matches the reciprocal of the override")
# scores and survivors, recomputed
SC = json.load(open(f"{INP}/scores.json")); IS = json.load(open(f"{OUT}/idea_scores.json"))
w = SC["rubric"]["weights"]; thr = SC["rubric"]["threshold"]
tot = {i: sum(w[c] * s[c] for c in w) for i, s in SC["scores"].items()}
check(abs(sum(w.values()) - 1) < 1e-12, "D weights sum to one")
check(sorted(i for i, t in tot.items() if t >= thr - 1e-12) == sorted(IS["survivors"]), "D survivors recomputed")
check(sorted(i for i, t in tot.items() if thr - val("near_band") - 1e-12 <= t < thr - 1e-12) == sorted(IS["near"]), "D near ideas recomputed")
for i, t in tot.items():
    check(abs(t - val(f"tot_{i.lower()}")) < 1e-9, f"D weighted total {i}")
vet = json.load(open(f"{INP}/vet.json"))["components"]
check(abs(np.mean([c["score"] for c in vet.values()]) - val("vet_mean")) < 1e-12, "D validity mean")
check(len(json.load(open(f"{INP}/ideas.json"))) >= 12, "D at least twelve ideas")
for p, ids in JR["assignment"].items():
    check(2 <= len(ids) <= 3, f"D two or three journals for {p}")
    for jid in ids:
        j = next(x for x in JR["journals"] if x["id"] == jid)
        check(j["tag"] in ("VERIFIED", "PARTIAL"), f"D proposed journal {jid} is not UNVERIFIED")
        check(all(j.get(f) for f in ("fee_quotes", "scope_quote", "ai_policy_quote", "preprint_quote", "data_quote", "authorship_quote", "predatory_signals")), f"D journal {jid} has every required field")
        check(j["urls"].get("fees", "").startswith("https://"), f"D journal {jid} links its own fee page")
N_CLOSE = {p: len(v) for p, v in json.load(open(f"{INP}/novelty.json"))["closest"].items()}
for p in ("S1", "S2"):
    check(5 <= N_CLOSE[p] <= 10, f"D five to ten closest works for {p}")
allow = load_allow()
check(all(a.get("reason") for a in allow), "D every lint allow-list entry has a reason")

# ============================================================== E. rendering
extra = qlog_counts(V)
extra["check_line"] = {"value": "placeholder", "unit": "text", "estimand": "p08_check.py", "source": "p08_check.py", "kind": "text"}
normal, rn = render(V, "normal", extra)
skeleton, rs = render(V, "skeleton", extra)
check(not rn.missing, f"E every token resolves (missing: {sorted(set(rn.missing))[:5]})")
check("{{" not in normal and "}}" not in normal, "E no unresolved marker in the report")
check("§" not in normal and "¤" not in normal, "E no skeleton marker in the normal render")
tpl = open(f"{CODE}/report_template.md").read()
for m in re.finditer(r"\{\{\s*([a-z0-9_]+_ci)\s*\|", tpl):
    before = tpl[max(0, m.start() - 80):m.start()]
    prev = TOKEN.findall(before)
    check(prev and prev[-1][0] == V[m.group(1)]["point"], f"E interval {m.group(1)} shown next to its own point estimate")
judg = sorted(k for k in rn.used if V.get(k, {}).get("kind") == "judgment")
check(all(V[k]["estimand"]["population"] == "analyst judgment" for k in judg), "E every judgment used is labelled as a judgment")
# skeleton digit check: after removing listed identifier patterns, no digit may remain
PATTERNS = [
    (r"imi_vm20i|imi_vmx30ui|tmf_vf10", "machine names"),
    (r"AA 6061|AISI 1018|AISI 4140|ASTM A36", "material designations"),
    (r"\b(?:D10|D[1-9]|I0[1-9]|I1[0-6]|S[12]|U[1-4]|C[12]|F[12]|C2[0-3])\b", "identifiers of questions, ideas, packages, users, tools and pipeline changes"),
    (r"\bdataset\d+\b", "run folder names"),
    (r"\b(?:Sound|Acc|acc|s)[0-2]\b", "channel names"),
    (r"(?i)\blabels? [0-2](?: and [0-2])?\b", "label values"),
    (r"\bMP4\b", "file format name"),
    (r"\bPhase [1-5]\b|\bTable [24]\b|\bS_table[24]\b", "phase and table names"),
    (r"\bsections? \d+(?:\.\d+)?(?: and \d+(?:\.\d+)?)?", "cross-references to sections"),
    (r"(?m)^#{1,4} \d+(?:\.\d+)?\.?", "heading numbers"),
    (r"(?m)^\d+\. ", "numbered list markers"),
    (r"\bUTF-8\b|\bfloat32\b|\bsha256\b|\bHTTP \d{3}\b|\bIO-Link\b", "encoding and protocol names"),
    (r"CC BY(?:-NC-ND)? 4\.0", "licence names"),
    (r"mm/s2|J/mm3", "units with exponents"),
    (r"\bW\d{7,10}\b", "OpenAlex work identifiers"),
    (r"archive \(1\)", "folder name as received"),
    (r"\b[\w.-]+\.(?:md|csv|json|tsv|py|xlsx)\b", "file names of the deliverable package"),
]
sk = re.sub(r"`[^`]*`", " ", skeleton)
for pat, _ in PATTERNS:
    sk = re.sub(pat, " ", sk)
left = [(sk.count("\n", 0, m.start()) + 1, sk[max(0, m.start() - 50):m.end() + 30].replace("\n", " ")) for m in re.finditer(r"\d", sk)]
check(not left, f"E no hand-typed digit in the report ({len(left)} found; first: {left[:10]})")

# ============================================================== F. language lint
lerr, lwarn, lstats = lint(normal, skeleton)
check(not lerr, f"F language lint clean ({len(lerr)} errors; first: {lerr[:3]})")

# ============================================================== result, final render, G
N_POST = 4  # assertions in group G, counted in advance so the number printed in the report is the total
n_total = N + N_POST
status = "PASS" if not FAIL else "FAIL"
stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
line = f"{status}, {n_total:,} assertions run by p08_check.py at {stamp}" + ("" if not FAIL else f"; {len(FAIL)} failed")
extra["check_line"] = {"value": line, "unit": "text", "estimand": "p08_check.py", "source": "p08_check.py", "kind": "text"}
final, rf = render(V, "normal", extra)
open(f"{OUT}/phase2_report.md", "w").write(final)
disk = open(f"{OUT}/phase2_report.md").read()
check(f"**Check: {line}**" in disk, "G the report on disk leads with this check result")
check(disk == final and not rf.missing, "G the report on disk equals a fresh render with no missing token")
check("check not yet run" not in disk, "G no placeholder check line left")
check(disk.index("**Check:") < disk.index("## 1. Negative findings first"), "G the check result comes before the findings")
assert N == n_total, "the advance count of group G assertions is wrong"
status = "PASS" if not FAIL else "FAIL"
if status == "FAIL" and "FAIL" not in line:  # a group G failure: rewrite the line
    line = f"FAIL, {N:,} assertions run by p08_check.py at {stamp}; {len(FAIL)} failed"
    extra["check_line"]["value"] = line
    open(f"{OUT}/phase2_report.md", "w").write(render(V, "normal", extra)[0])
res = {"status": status, "assertions": N, "failed": FAIL, "time_utc": stamp, "check_line": line,
       "judgments_used_in_report": judg, "lint_warnings": [list(w) for w in lwarn], "sentence_stats": lstats,
       "values_used_in_report": len(rn.used), "values_in_file": len(V)}
json.dump(res, open(f"{OUT}/check_result.json", "w"), indent=1)
print(line)
for f in FAIL:
    print("FAILED:", f)
print(f"judgments used in the report ({len(judg)}): {', '.join(judg)}")
print(f"lint warnings for a human pass: {len(lwarn)}")
sys.exit(0 if status == "PASS" else 1)
