P7: differential-testing harness (simurg compare) and nightly run on the Pi

- compare/core: EXACT/NEAR/DISCREPANCY/UNKNOWN with the plan §16.3 tolerances (abs, float32 ULP), NaN = NaN,
  locus classes (assay, variant type, chromosome group, intensity-only); fields absent on both sides are
  not applicable
- compare/records: GTC vs GTC (per-locus arrays, transforms per bin, controls, sample statistics; metadata
  listed, not counted) and VCF vs VCF (records keyed incl. ID; SNV/MNV/indel, merged, multi-allelic classes)
- simurg compare A B (files or folders paired by name): summary.json, discrepancies.tsv.gz (never truncated),
  self-contained dashboard.html; --tolerance, --fail-on-discrepancy (exit 3)
- scripts/nightly.py + install_nightly.sh (crontab 02:30): unit tests, GSA vs gtc2vcf (GTC + VCF), VCF
  regression vs golden, Omni2.5 subset vs vendor GTCs; first run PASS on every step
- tests/unit/test_compare.py; docs/validation/P7.md, README
This commit is contained in:
kemal committed 2026-09-28 17:00:37 +01:00
1 parent 51747674ba
commit 3c33ae5cb1
10 files changed
+1268 -1

No files matched your search

View File
Whitespace-only changes.
+223
View File
@@ -0,0 +1,223 @@
"""Field-level comparison with tolerance categories (spec CLI-038; plan §16.3).
Every compared value falls in exactly one category:
* **EXACT** — bitwise equal (NaN equals NaN whatever its sign bit, K-014);
* **NEAR** — within the field's tolerance;
* **DISCREPANCY** — outside it (or NaN on one side only);
* **UNKNOWN** — one side lacks the field or the record.
Counts are kept per locus class (assay, variant type, chromosome group, intensity-only), and every discrepancy is
kept with its identifiers — nothing is hidden (plan §16.3).
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from dataclasses import field as dc_field
from typing import Any
import numpy as np
import numpy.typing as npt
EXACT, NEAR, DISCREPANCY, UNKNOWN = 0, 1, 2, 3
CATEGORY_NAMES = ("EXACT", "NEAR", "DISCREPANCY", "UNKNOWN")
@dataclass(frozen=True)
class Tolerance:
abs: float | None = None # absolute difference allowed
ulp: int | None = None # float32 units in the last place allowed
@property
def exact(self) -> bool:
return self.abs is None and self.ulp is None
EXACT_TOL = Tolerance()
# plan §16.3 (bit-exact mode targets)
GTC_TOLERANCES: dict[str, Tolerance] = {
"raw_x": EXACT_TOL,
"raw_y": EXACT_TOL,
"controls_x": EXACT_TOL,
"controls_y": EXACT_TOL,
"genotypes": EXACT_TOL,
"base_calls": EXACT_TOL,
"normalization_transforms": Tolerance(ulp=2),
"genotype_scores": Tolerance(abs=1e-5),
"b_allele_freqs": Tolerance(abs=1e-5),
"logr_ratios": Tolerance(abs=1e-5),
"call_rate": Tolerance(abs=1e-6),
"logr_dev": Tolerance(abs=1e-6),
"gc10": Tolerance(abs=1e-6),
"gc50": Tolerance(abs=1e-6),
"gender": EXACT_TOL,
"num_calls": EXACT_TOL,
"num_no_calls": EXACT_TOL,
"num_intensity_only": EXACT_TOL,
"percentiles_x": EXACT_TOL,
"percentiles_y": EXACT_TOL,
}
VCF_FLOAT_TOL = Tolerance(abs=1e-5) # GS, BAF, LRR, NORMX, NORMY, THETA, R
VCF_EXACT_FIELDS = {"GT", "X", "Y"}
def _float_ulps(a: npt.NDArray[np.float32], b: npt.NDArray[np.float32]) -> npt.NDArray[np.int64]:
"""Distance in float32 ULPs (monotone integer mapping of the bit patterns)."""
def key(x: npt.NDArray[np.float32]) -> npt.NDArray[np.int64]:
i = x.astype(np.float32).view(np.int32).astype(np.int64)
return np.where(i < 0, -(i & 0x7FFFFFFF), i)
return np.abs(key(a) - key(b))
def categorize(a: npt.ArrayLike, b: npt.ArrayLike, tol: Tolerance) -> npt.NDArray[np.int8]:
"""Element-wise category of two equally shaped arrays (numbers or strings)."""
a, b = np.asarray(a), np.asarray(b)
if a.shape != b.shape:
raise ValueError(f"shape {a.shape} vs {b.shape}")
out = np.full(a.shape, DISCREPANCY, np.int8)
if a.dtype.kind in "fc" or b.dtype.kind in "fc":
fa, fb = a.astype(np.float64), b.astype(np.float64)
na, nb = np.isnan(fa), np.isnan(fb)
same = (fa == fb) | (na & nb)
if a.dtype == np.float32 and b.dtype == np.float32:
same &= (a.view(np.uint32) == b.view(np.uint32)) | (na & nb) | ((fa == 0) & (fb == 0))
out[same] = EXACT
near = np.zeros(a.shape, np.bool_)
valid = ~(na | nb) & ~same
if tol.abs is not None:
near |= valid & (np.abs(fa - fb) <= tol.abs)
if tol.ulp is not None:
near |= valid & (_float_ulps(a.astype(np.float32), b.astype(np.float32)) <= tol.ulp)
out[near] = NEAR
return out
out[a == b] = EXACT
return out
@dataclass
class FieldResult:
"""Category counts of one field: overall and per locus class, plus the discrepancies themselves."""
field: str
tolerance: str
counts: dict[str, list[int]] = dc_field(default_factory=dict) # class -> [EXACT, NEAR, DISCREPANCY, UNKNOWN]
discrepancies: list[dict[str, Any]] = dc_field(default_factory=list)
def add(self, cls: str, cats: npt.NDArray[np.int8]) -> None:
c = self.counts.setdefault(cls, [0, 0, 0, 0])
binc = np.bincount(cats.ravel().astype(np.int64), minlength=4)
for k in range(4):
c[k] += int(binc[k])
@property
def total(self) -> list[int]:
return self.counts.get("all", [0, 0, 0, 0])
def tol_text(t: Tolerance) -> str:
if t.exact:
return "exact"
parts = []
if t.abs is not None:
parts.append(f"abs<={t.abs:g}")
if t.ulp is not None:
parts.append(f"ulp<={t.ulp}")
return ",".join(parts)
def locus_classes(
chroms: Sequence[str], snps: Sequence[str], assay_types: npt.ArrayLike, intensity_only: npt.ArrayLike
) -> dict[str, npt.NDArray[np.bool_]]:
"""Locus class masks (plan §16.3): assay, variant type, chromosome group, intensity-only."""
at = np.asarray(assay_types)
io = np.asarray(intensity_only, dtype=np.bool_)
def group(c: str) -> str:
c = c[3:] if c.lower().startswith("chr") else c
if c in ("0", ""):
return "unmapped"
if c == "XY":
return "PAR"
if c in ("X", "Y"):
return c
if c in ("MT", "M"):
return "MT"
return "autosome" if c.isdigit() else "other"
groups = np.array([group(c) for c in chroms])
indel = np.array([("D" in s or "I" in s) for s in snps])
out = {"all": np.ones(len(groups), np.bool_), "assay=InfII": at == 0, "assay=InfI": at != 0,
"type=SNV": ~indel, "type=indel": indel, "intensity_only": io} # fmt: skip
for gname in ("autosome", "X", "Y", "PAR", "MT", "unmapped", "other"):
m = groups == gname
if m.any():
out[f"chrom={gname}"] = m
return out
def compare_locus_field(
name: str,
a: npt.ArrayLike | None,
b: npt.ArrayLike | None,
tol: Tolerance,
classes: Mapping[str, npt.NDArray[np.bool_]],
ids: Sequence[str],
context: Mapping[str, str],
) -> FieldResult:
"""Per-locus arrays (first axis = locus). Missing or empty on one side → UNKNOWN for every locus."""
res = FieldResult(name, tol_text(tol))
n = len(classes["all"])
a_ok = a is not None and len(np.asarray(a)) == n
b_ok = b is not None and len(np.asarray(b)) == n
if not (a_ok and b_ok):
cats = np.full(n, UNKNOWN, np.int8)
else:
el = categorize(np.asarray(a), np.asarray(b), tol)
cats = el.max(axis=tuple(range(1, el.ndim))) if el.ndim > 1 else el # worst element of the locus
for i in np.flatnonzero(cats == DISCREPANCY).tolist():
res.discrepancies.append(
{**context, "field": name, "locus": ids[i], "a": _fmt(np.asarray(a)[i]), "b": _fmt(np.asarray(b)[i])}
)
for cls, mask in classes.items():
res.add(cls, cats[mask])
return res
def compare_scalar(name: str, a: Any, b: Any, tol: Tolerance, context: Mapping[str, str]) -> FieldResult:
res = FieldResult(name, tol_text(tol))
if a is None or b is None:
cat = np.array([UNKNOWN], np.int8)
else:
aa, bb = np.asarray([a]), np.asarray([b])
if aa.dtype.kind == "f" or bb.dtype.kind == "f":
aa, bb = aa.astype(np.float64), bb.astype(np.float64)
cat = categorize(aa, bb, tol)
res.add("sample", cat)
if cat[0] == DISCREPANCY:
res.discrepancies.append({**context, "field": name, "locus": "", "a": _fmt(a), "b": _fmt(b)})
return res
def _fmt(v: Any) -> str:
arr = np.asarray(v)
if arr.ndim == 0:
x = arr.item()
return repr(x) if isinstance(x, float) else str(x)
return "[" + ",".join(_fmt(x) for x in arr.ravel().tolist()) + "]"
def merge_results(results: Sequence[FieldResult]) -> dict[str, FieldResult]:
"""Sum results of the same field (e.g. over samples)."""
out: dict[str, FieldResult] = {}
for r in results:
m = out.setdefault(r.field, FieldResult(r.field, r.tolerance))
for cls, c in r.counts.items():
cur = m.counts.setdefault(cls, [0, 0, 0, 0])
for k in range(4):
cur[k] += c[k]
m.discrepancies.extend(r.discrepancies)
return out
+223
View File
@@ -0,0 +1,223 @@
"""GTC-vs-GTC and VCF-vs-VCF comparison on parsed data (spec CLI-038; plan §16.3)."""
from __future__ import annotations
import math
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import Any
import numpy as np
import numpy.typing as npt
from numpy.lib import recfunctions as rfn
from simurg.compare.core import (
DISCREPANCY,
EXACT,
GTC_TOLERANCES,
UNKNOWN,
VCF_EXACT_FIELDS,
VCF_FLOAT_TOL,
FieldResult,
Tolerance,
categorize,
compare_locus_field,
compare_scalar,
tol_text,
)
GTC_LOCUS_FIELDS = ("raw_x", "raw_y", "genotypes", "base_calls", "genotype_scores", "b_allele_freqs", "logr_ratios")
GTC_SCALARS = ("call_rate", "logr_dev", "gc10", "gc50", "gender", "num_calls", "num_no_calls", "num_intensity_only")
GTC_METADATA = ("sample_name", "sample_plate", "sample_well", "cluster_file", "snp_manifest", "imaging_date",
"autocall_date", "autocall_version", "slide_identifier") # fmt: skip
@dataclass
class Comparison:
"""Results of one pair (one sample)."""
label: str
kind: str # "gtc" | "vcf"
fields: list[FieldResult] = field(default_factory=list)
metadata: dict[str, list[str]] = field(default_factory=dict) # field -> [a, b] when they differ
notes: list[str] = field(default_factory=list)
def compare_gtc(
a: Any,
b: Any,
label: str,
classes: Mapping[str, npt.NDArray[np.bool_]],
names: Sequence[str],
tolerances: Mapping[str, Tolerance] = GTC_TOLERANCES,
) -> Comparison:
"""Two parsed GTCs of the same manifest."""
res = Comparison(label, "gtc")
ctx = {"pair": label}
if a.num_snps != b.num_snps:
res.notes.append(f"locus count differs: {a.num_snps} vs {b.num_snps}")
return res
for f in GTC_LOCUS_FIELDS:
va, vb = getattr(a, f, None), getattr(b, f, None)
if (va is None or len(va) == 0) and (vb is None or len(vb) == 0):
continue # absent on both sides (e.g. BAF/LRR in GTC v3): not applicable
res.fields.append(compare_locus_field(f, va, vb, tolerances[f], classes, names, ctx))
# normalization transforms: one row per bin
ta, tb = a.normalization_transforms, b.normalization_transforms
fr = FieldResult("normalization_transforms", tol_text(tolerances["normalization_transforms"]))
if len(ta) != len(tb) or not len(ta):
fr.add("bins", np.full(max(len(ta), len(tb), 1), UNKNOWN, np.int8))
else:
ua = rfn.structured_to_unstructured(ta).astype(np.float32)
ub = rfn.structured_to_unstructured(tb).astype(np.float32)
cats = categorize(ua, ub, tolerances["normalization_transforms"]).max(axis=1)
fr.add("bins", cats)
for i in np.flatnonzero(cats == DISCREPANCY).tolist():
fr.discrepancies.append({**ctx, "field": "normalization_transforms", "locus": f"bin{i}",
"a": str(ua[i].tolist()), "b": str(ub[i].tolist())}) # fmt: skip
res.fields.append(fr)
# controls: one row per control bead
for f in ("controls_x", "controls_y"):
ca, cb = getattr(a, f), getattr(b, f)
fr = FieldResult(f, tol_text(tolerances[f]))
fr.add("controls", categorize(ca, cb, tolerances[f]) if len(ca) == len(cb) else np.array([UNKNOWN], np.int8))
res.fields.append(fr)
for f in GTC_SCALARS:
res.fields.append(compare_scalar(f, _scalar(a, f), _scalar(b, f), tolerances[f], ctx))
for f in ("percentiles_x", "percentiles_y"):
pa, pb = getattr(a, f), getattr(b, f)
fr = FieldResult(f, tol_text(tolerances[f]))
fr.add("sample", categorize(pa, pb, tolerances[f]) if len(pa) == len(pb) else np.array([UNKNOWN], np.int8))
res.fields.append(fr)
for f in GTC_METADATA:
va, vb = str(getattr(a, f, "")), str(getattr(b, f, ""))
if va != vb:
res.metadata[f] = [va, vb]
if a.version != b.version:
res.notes.append(f"GTC versions differ: {a.version} vs {b.version} (fields absent in one are UNKNOWN)")
return res
def _scalar(g: Any, f: str) -> Any:
if f == "gender":
return g.gender_char
v = getattr(g, f, None)
if isinstance(v, float) and math.isnan(v):
return v
return v
# ------------------------------------------------------------------------------------------------ VCF
@dataclass
class VcfRecord:
chrom: str
pos: int
vid: str
ref: str
alt: str
fields: dict[str, str] # FORMAT key -> value of the compared sample
def vcf_classes(r: VcfRecord) -> list[str]:
c = r.chrom[3:] if r.chrom.lower().startswith("chr") else r.chrom
grp = "autosome" if c.isdigit() else {"X": "X", "Y": "Y", "MT": "MT", "M": "MT"}.get(c, "other")
alts = r.alt.split(",")
if all(len(x) == len(r.ref) == 1 for x in alts):
vtype = "SNV"
elif all(len(x) == len(r.ref) for x in alts):
vtype = "MNV"
else:
vtype = "indel"
out = ["all", f"chrom={grp}", f"type={vtype}"]
if "," in r.vid or ";" in r.vid:
out.append("merged_site")
if len(alts) > 1:
out.append("multiallelic")
return out
def _gt_norm(gt: str) -> str:
parts = gt.replace("|", "/").split("/")
if any(p == "." for p in parts):
return "." if len(parts) == 1 else "./."
return "/".join(sorted(parts, key=int))
def _cat_scalar(va: str, vb: str, tol: Tolerance) -> int:
if va == vb:
return EXACT
x, y = _num(va), _num(vb)
if math.isnan(x) and math.isnan(y):
return EXACT
if math.isnan(x) or math.isnan(y):
return DISCREPANCY
if x == y:
return EXACT
if tol.abs is not None and abs(x - y) <= tol.abs:
return 1 # NEAR
return DISCREPANCY
def compare_vcf_records(
a: Mapping[tuple[str, int, str, str, str], VcfRecord],
b: Mapping[tuple[str, int, str, str, str], VcfRecord],
label: str,
float_tol: Tolerance = VCF_FLOAT_TOL,
) -> Comparison:
"""Records keyed by (CHROM, POS, REF, ALT, ID); FORMAT fields of one sample on each side."""
res = Comparison(label, "vcf")
ctx = {"pair": label}
keys = list(a) + [k for k in b if k not in a]
fields = sorted({f for r in a.values() for f in r.fields} | {f for r in b.values() for f in r.fields})
counts: dict[str, dict[str, list[int]]] = {f: {} for f in ["record", *fields]}
disc: dict[str, list[dict[str, Any]]] = {f: [] for f in ["record", *fields]}
tols = {f: (Tolerance() if f in VCF_EXACT_FIELDS else float_tol) for f in fields}
def bump(f: str, classes: list[str], cat: int) -> None:
per = counts[f]
for cls in classes:
c = per.get(cls)
if c is None:
c = per[cls] = [0, 0, 0, 0]
c[cat] += 1
for k in keys:
ra, rb = a.get(k), b.get(k)
r = ra or rb
assert r is not None
classes = vcf_classes(r)
where = f"{r.chrom}:{r.pos}:{r.ref}>{r.alt} {r.vid}"
if ra is None or rb is None:
bump("record", classes, UNKNOWN)
disc["record"].append({**ctx, "field": "record", "locus": where,
"a": "present" if ra else "absent", "b": "present" if rb else "absent"}) # fmt: skip
continue
bump("record", classes, EXACT)
for f in fields:
va, vb = ra.fields.get(f), rb.fields.get(f)
if va is None and vb is None:
continue # the field does not apply to this record on either side (e.g. BAF of merged sites)
if va is None or vb is None:
cat = UNKNOWN
elif f == "GT":
cat = EXACT if _gt_norm(va) == _gt_norm(vb) else DISCREPANCY
else:
cat = _cat_scalar(va, vb, tols[f])
bump(f, classes, cat)
if cat == DISCREPANCY:
disc[f].append({**ctx, "field": f, "locus": where, "a": va, "b": vb})
for f in ["record", *fields]:
fr = FieldResult(
f, "present in both" if f == "record" else ("exact" if f in VCF_EXACT_FIELDS else tol_text(float_tol))
)
fr.counts = counts[f]
fr.discrepancies = disc[f]
res.fields.append(fr)
return res
def _num(v: str) -> float:
try:
return float(v)
except ValueError:
return math.nan