The battery job ran the ci tier and the wrong-model null in one step with one exit status, so a red job did not say which of the two failed. battery.py nullmodel runs the null on its own, in a run directory whose report is just that section, and exits 1 when a pair failed; run --no-nullmodel keeps it out of a --tier ci run. The workflow runs `run $SUBSET --no-nullmodel`, then (for a ci-tier run) `nullmodel` as a separate step that runs whatever the first did, and keeps both reports. The ci tier's own rows are unchanged; a plain `run --tier ci` still includes the null as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EizBKhTqrAYago9KJG3dA3
151 lines
7.6 KiB
Python
151 lines
7.6 KiB
Python
"""The wrong-model null: a wrong --model must change nothing and must be recognised as wrong.
|
|
|
|
nullmodel.json lists sets of the open arm, each with five wrong models - deposited coordinates of
|
|
other open-arm entries (README, 'The wrong-model null', says how they were chosen). The runner
|
|
(battery.py) runs each set once without --model and once with each wrong model; `score` compares a
|
|
wrong-model run with the no-model run of the same set:
|
|
|
|
(a) data p.hkl byte for byte; p.mtz reflection by reflection (group, cell, columns, every
|
|
value - the file itself differs by the command line in its history); p.cif with its
|
|
command line left out
|
|
(b) decisions every KEY= line of p_report.txt outside the MODEL VALIDATION section (space group,
|
|
SOHNCKE group and SPACE_GROUP_ENANTIOMORPH, cell, resolution cut, twinning,
|
|
warnings, ...), provenance lines aside
|
|
(c) null the model is recognised as wrong: MODEL_FIT is not ACCEPTED, MODEL_DECISIONS_TAKEN is
|
|
NONE, no enantiomorph adopted, CC_MODEL_CONFIRMED_TO_D_MIN is NONE (no shell's
|
|
CC(model, data) clears its empirical null), and R_FREE
|
|
is where a random structure's is: 0.586 for acentric reflections, 0.828 for centric
|
|
(Wilson 1950); the scale, anisotropic B, bulk solvent and rigid body fitted to the
|
|
working set can only pull it a few points below that, so R_FREE >= RANDOM_R_FREE_MIN.
|
|
"""
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
|
|
import gemmi
|
|
import numpy
|
|
|
|
import score
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
MANIFEST = os.path.join(HERE, "nullmodel.json")
|
|
|
|
# Wilson's random-structure R is 0.586 (acentric); a fit of scale, anisotropic B, bulk solvent and a
|
|
# rigid body on the working set takes a few points off it, never fifteen
|
|
RANDOM_R_FREE_MIN = 0.50
|
|
# lines of p_report.txt that say how and when the run was made, not what it decided
|
|
PROVENANCE = {"DATE", "COMMAND_LINE", "WALL_TIME"}
|
|
MODEL_SECTION = "5. MODEL VALIDATION"
|
|
|
|
|
|
def f(x, fmt):
|
|
return "-" if x is None else fmt.format(x)
|
|
|
|
|
|
def load():
|
|
return json.load(open(MANIFEST))["sets"]
|
|
|
|
|
|
def sha256(path):
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as fh:
|
|
for chunk in iter(lambda: fh.read(1 << 20), b""):
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def decision_keys(path):
|
|
"""KEY= lines of a report outside the model section, provenance left out."""
|
|
out, in_model = {}, False
|
|
for line in open(path, errors="replace"):
|
|
if line.startswith("# ") and line[3:].strip()[:1].isdigit():
|
|
in_model = line[3:].strip() == MODEL_SECTION
|
|
m = score.REPORT_LINE.match(line.rstrip("\n"))
|
|
if m and not in_model and m.group(1) not in PROVENANCE:
|
|
out[m.group(1)] = m.group(2).strip()
|
|
return out
|
|
|
|
|
|
def same_mtz(a, b):
|
|
ma, mb = gemmi.read_mtz_file(a), gemmi.read_mtz_file(b)
|
|
if (ma.spacegroup.number != mb.spacegroup.number or ma.cell.parameters != mb.cell.parameters
|
|
or ma.column_labels() != mb.column_labels()):
|
|
return False
|
|
return numpy.array_equal(numpy.asarray(ma), numpy.asarray(mb), equal_nan=True)
|
|
|
|
|
|
def same_cif(a, b):
|
|
def text(path):
|
|
return re.sub(r"_jfjoch_provenance\.command_line\s*\n;.*?\n;", "", open(path).read(), flags=re.S)
|
|
return text(a) == text(b)
|
|
|
|
|
|
def score_pair(none_wd, wd):
|
|
"""The (a)(b)(c) verdict of one wrong-model run in wd against the no-model run in none_wd."""
|
|
rep = score.read_report(os.path.join(wd, "p_report.txt"))
|
|
files = {}
|
|
for name, same in (("p.hkl", lambda a, b: sha256(a) == sha256(b)), ("p.mtz", same_mtz),
|
|
("p.cif", same_cif)):
|
|
a, b = os.path.join(none_wd, name), os.path.join(wd, name)
|
|
files[name] = os.path.exists(a) and os.path.exists(b) and same(a, b)
|
|
ka, kb = (decision_keys(os.path.join(d, "p_report.txt")) for d in (none_wd, wd))
|
|
moved = sorted(k for k in set(ka) | set(kb) if ka.get(k) != kb.get(k))
|
|
r_free = score.fnum(rep.get("R_FREE"))
|
|
row = {
|
|
"data_same": all(files.values()), "files_differ": [n for n, s in files.items() if not s],
|
|
"decisions_moved": moved,
|
|
"sg": kb.get("SPACE_GROUP_NAME"), "enantiomorph": kb.get("SPACE_GROUP_ENANTIOMORPH"),
|
|
"model_validation": rep.get("MODEL_VALIDATION"), "model_fit": rep.get("MODEL_FIT"),
|
|
"model_fit_sigma": score.fnum(rep.get("MODEL_FIT_SIGMA")),
|
|
"model_decisions": rep.get("MODEL_DECISIONS_TAKEN"),
|
|
"enantiomorph_adopted": rep.get("MODEL_ENANTIOMORPH_ADOPTED"),
|
|
"indexing_margin_sigma": score.fnum(rep.get("MODEL_INDEXING_MARGIN_SIGMA")),
|
|
"change_of_basis": rep.get("MODEL_CHANGE_OF_BASIS"),
|
|
"r_free": r_free, "r_work": score.fnum(rep.get("R_WORK")),
|
|
"cc_model": score.fnum(rep.get("CC_MODEL_OVERALL")),
|
|
"cc_confirmed": rep.get("CC_MODEL_CONFIRMED_TO_D_MIN"),
|
|
}
|
|
why = []
|
|
if not row["data_same"]:
|
|
why.append("data differ: " + ", ".join(row["files_differ"]))
|
|
if moved:
|
|
why.append("decisions moved: " + ", ".join(moved[:6]) + (" ..." if len(moved) > 6 else ""))
|
|
if row["model_validation"] != "PERFORMED":
|
|
why.append(f"model validation {row['model_validation'] or 'missing'}")
|
|
else:
|
|
if row["model_fit"] == "ACCEPTED":
|
|
why.append(f"MODEL_FIT ACCEPTED ({row['model_fit_sigma']:+.1f} sigma)")
|
|
if row["model_decisions"] != "NONE" or row["enantiomorph_adopted"] != "FALSE":
|
|
why.append(f"model decided {row['model_decisions']}")
|
|
if r_free is None or r_free < RANDOM_R_FREE_MIN:
|
|
why.append(f"R_free {r_free} below a random structure's")
|
|
if row["cc_confirmed"] not in (None, "NONE"):
|
|
why.append(f"CC_MODEL_CONFIRMED_TO_D_MIN {row['cc_confirmed']} (a significant CC with a wrong model)")
|
|
row["verdict"] = "fail" if why else "pass"
|
|
row["reason"] = "; ".join(why)
|
|
return row
|
|
|
|
|
|
def section(doc, rows):
|
|
"""The report's own pass/fail section for the wrong-model null (nullmodel_results.json in a run directory)."""
|
|
bad = [r for r in rows if r["verdict"] != "pass"]
|
|
doc.h(2, f"Wrong-model null: {len(rows) - len(bad)} of {len(rows)} pass")
|
|
doc.p("Each set run without --model and with five wrong models (nullmodel.json). A pair passes when "
|
|
"(a) p.hkl, the p.mtz reflections and p.cif are identical to the no-model run's, (b) no "
|
|
"KEY= line outside MODEL VALIDATION moved, and (c) the model is recognised as wrong: "
|
|
"MODEL_FIT not ACCEPTED, no decision taken, CC_MODEL_CONFIRMED_TO_D_MIN NONE and R_free "
|
|
f">= {RANDOM_R_FREE_MIN} (a random structure: 0.586 acentric, 0.828 centric). "
|
|
"NOT_TESTED means the model claimed no hand, indexing or setting, so no null was needed.")
|
|
head = ["set", "wrong model", "(a) data", "(b) decisions", "space group", "hand", "MODEL_FIT",
|
|
"sigma", "decided", "R_free", "CC model", "CC confirmed to", "verdict", "why"]
|
|
doc.table(head, [[r["set"], r["model"], "same" if r["data_same"] else "DIFFER",
|
|
"same" if not r["decisions_moved"] else f"{len(r['decisions_moved'])} moved",
|
|
r["sg"] or "-", r["enantiomorph"] or "-", r["model_fit"] or "-",
|
|
f(r["model_fit_sigma"], "{:+.1f}"), r["model_decisions"] or "-",
|
|
f(r["r_free"], "{:.3f}"), f(r["cc_model"], "{:.3f}"),
|
|
r["cc_confirmed"] or "-", r["verdict"], r["reason"]] for r in rows],
|
|
num=(7, 9, 10),
|
|
row_class=lambda row: "fail" if row[12] != "pass" else "",
|
|
cell_class=lambda i, x: {"pass": "pass", "fail": "fail", "crash": "fail"}.get(x) if i == 12 else None)
|