Back to home page

sPhenix code displayed by LXR

 
 

    


File indexing completed on 2026-08-30 08:17:27

0001 #!/usr/bin/env python3
0002 """
0003 ml_momentum_calibration_v4.py
0004 
0005 Successor to ml_momentum_calibration_reso.py.  Five structural changes:
0006 
0007 TASK 1 -- DIRECT KFPARTICLE NTUPLE INPUT (+ candidate-count option)
0008     --kshort / --lam accept either a KFParticle nTuple ROOT file or a legacy
0009     CSV (detected by extension).  ROOT files are read with uproot; tree name
0010     defaults to "DecayTree" (--tree).  Branch names follow KFParticle_nTuple.cc
0011     (coresoftware): per daughter i in {1, 2}
0012         track_i_pT, track_i_pseudorapidity, track_i_phi, track_i_charge,
0013         track_i_PDG_ID, track_i_pTErr, track_i_Covariance[21]
0014     and per mother (prefix auto-detected, K_S0 / Lambda0)
0015         <res>_mass, <res>_pT, <res>_pseudorapidity, <res>_phi.
0016     --max_candidates N randomly subsamples each species to N candidates after
0017     the selection (0 = use all; seeded by --subsample_seed).
0018     CSV input carries no covariance, so it forces --reso_model absolute.
0019 
0020 TASK 2 -- CURVATURE FEATURE AND CURVATURE-MULTIPLICATIVE, UNCAPPED CORRECTION
0021     Stage-1 input feature is unsigned curvature u = 1/pT, normalised with
0022     ROBUST QUANTILES (median and half the 16-84 span) because with the pT cut
0023     removed (task 4) the u distribution has a long high-u tail that would
0024     dominate a mean/std normalisation.  u_n is clamped to +-U_CLAMP as a
0025     numerical guard only; the clamp makes kappa constant in curvature outside
0026     it, and the fraction of training tracks hitting it is written to
0027     summary.txt.
0028     Output convention:
0029         (1/pT)_corr = (1/pT) * kappa_curv
0030         kappa_curv  = 1 + eps(u, eta, phi) + q * delta(u, eta, phi)
0031     so the pT multiplier applied downstream is 1/kappa_curv.  The even/odd
0032     decomposition is exact in curvature space; q enters only through the
0033     +q*delta output term, never as a network input.  eps and delta are
0034     UNCAPPED (no tanh, no S_CAP): the size of the correction is learned, not
0035     constrained.  The only guard is numerical, kappa_curv >= KAPPA_GUARD,
0036     which prevents a momentum sign flip during early optimisation; its hit
0037     fraction must be 0 at convergence (reported, warned on).
0038     The normalisation constants (u_ref, u_scale, U_CLAMP) are persisted inside
0039     model.pt and stamped into kappa_lookup.csv; --load_stage1 restores them
0040     and FAILS LOUDLY on a checkpoint that predates this convention.
0041 
0042 TASK 3 -- DAUGHTER-TRACK COVARIANCE => STAGE 2 IS A COVARIANCE CALIBRATION
0043     track_i_Covariance is the packed lower triangle of the symmetric
0044     covariance over (x, y, z, px, py, pz), idx(a,b) = a(a+1)/2 + b for a >= b
0045     (momentum block 9/13/14/18/19/20).  Length 21 = 6x6 state; length 28 =
0046     7x7 with E appended -- the momentum-block indices are identical; the
0047     detected layout is printed.  Per track,
0048         sigma^2(pT) = ( px^2 Vxx + 2 px py Vxy + py^2 Vyy ) / pT^2
0049         r_cov       = sigma(pT) / pT
0050     Note sigma(1/pT)/(1/pT) = sigma(pT)/pT to first order, so r_cov is
0051     simultaneously the fractional curvature resolution -- consistent with the
0052     rest of the script working in curvature.  Candidates with non-finite
0053     entries, non-positive Vxx/Vyy, or r_cov outside (1e-5, 1) are dropped and
0054     counted per category.  sqrt(V(px,px)-projection) is cross-checked against
0055     track_i_pTErr; a bulk disagreement beyond ~20% aborts (packing convention
0056     wrong => everything downstream invalid).
0057 
0058     --reso_model {absolute, pull}, default pull.
0059       absolute:  r_i^2 = (a(eta,phi)/beta_i)^2 + (b(eta,phi)*pT_i)^2  (v2)
0060       pull:      r_i   = s(eta_i, phi_i) * pT_i^{u_p} * r_cov,i
0061     with s = S_SCALE*softplus(.) initialised at 1 and u_p a single global
0062     exponent initialised at 0.
0063 
0064     INTERPRETATION CHANGE (explicit, per request; also stamped into
0065     reso_lookup.csv and summary.txt when pull is active):
0066       1. ResoNet no longer measures the momentum resolution.  In `absolute`,
0067          a and b ARE the detector resolution, measured from the peak widths.
0068          In `pull`, the absolute scale of r is inherited from the KFParticle
0069          covariance; what is fitted is s(eta,phi) and u_p, i.e. the
0070          MISCALIBRATION of the reported covariance.  Perfect calibration is
0071          s == 1, u_p == 0.
0072       2. s mixes two effects that cannot be separated here: (i) genuine
0073          resolution the Kalman hit-error/material model misses, and (ii)
0074          systematic mis-estimation of the covariance by the fit itself.
0075       3. The stored covariance is POST-VERTEX-CONSTRAINT
0076          (m_extrapolateTracksToSV_nTuple defaults true): the constraint
0077          shrinks the momentum covariance relative to the raw track fit, so s
0078          is defined relative to CONSTRAINED covariances.  Applying s to raw
0079          SvtxTrack covariances is wrong by the (unmeasured) constraint
0080          shrinkage factor.  The exported field is only valid together with
0081          the same covariance the producer used.
0082       4. s at low pT and the floor c are partially degenerate (rho(s,c) is
0083          reported from the reduced global fit, as rho(a,c) was in absolute).
0084       5. reso_lookup.csv exports (eta, phi, s) plus u_p in the header -- NOT
0085          an evaluated r grid: r depends on the per-track covariance, which a
0086          lookup cannot carry.  The consumer rebuilds
0087          r = s(eta,phi) * pT^{u_p} * r_cov from its own covariance.
0088     summary.txt reports the median and 16/84 quantiles of r_pred/r_cov per
0089     resonance group -- the headline factor by which KFParticle's reported
0090     momentum uncertainty is wrong -- and fig8 gains a raw-covariance pull
0091     overlay (s = 1, u_p = 0, no floor) whose core width says the same thing
0092     before calibration.
0093 
0094 TASK 4 -- NO pT CUT ON DAUGHTER TRACKS
0095     The fiducial selection keeps only |eta| < 1.5, a pT > 1 MeV sanity floor,
0096     finiteness, and the mass windows.  All pT-dependent internals (loss-bin
0097     edges, kappa-map slices, lookup grid, plot ranges) are derived from data
0098     quantiles at run time instead of a hard-coded (0.15, 3) GeV range.
0099 
0100 TASK 5 -- KAPPA MAPS PRESENTED IN 1/pT
0101     fig6 (charge-even eps, charge-odd delta) and fig7 (final per-charge pT
0102     multiplier) are sliced uniformly in curvature 1/pT across the measured
0103     range, each panel labelled "1/pT = X GeV^-1 (pT = Y GeV)".  The lookup
0104     grid is likewise uniform in 1/pT and carries both columns.
0105 
0106 STAGE 2 MODEL (shared machinery, unchanged from the anchor)
0107     sigma_m^2 = J1^2 r1^2 + J2^2 r2^2 + c^2,
0108     J_i = dm/dln(pT_i) = ( E_j |p_i|^2 / E_i - p_i . p_j ) / m  (exact, not
0109     leg-symmetric), window-normalised core+tail Gaussian mixture NLL with
0110     mu fixed to M_PDG, per-group tail (f, k), global floor c (or one per
0111     resonance with --c_per_resonance).  Stage 1 is frozen before stage 2.
0112     Convergence: the global scalars (c, u_p, f, k) get 5x the field learning
0113     rate, and an LBFGS polish with the field frozen follows the Adam epochs
0114     (Adam stalls along the c <-> (s, u_p) soft direction).
0115 
0116 INJECTION CAMPAIGN (mandatory go/no-go, --inject / --inject_only)
0117     Toys are isotropic two-body decays of resonances resampled from the data
0118     (truth mass exactly M_PDG), same eta/mass-window selection, kappa == 1.
0119     pull:      each toy leg draws r_cov from the DATA r_cov distribution of
0120                the matching leg; truth smearing is s0 * pT^{u0} * r_cov plus
0121                the mass floor c0.  Go/no-go: (s0, u0) recovered within the
0122                Hessian errors of the reduced global fit + pull closure.
0123     absolute:  the v2 (a0, b0) recovery test.
0124     A PASS certifies recovery under the model's own assumptions only.
0125 
0126 NOTE ON CHECKPOINTS: model.pt is a dict {state_dict, norm, convention}.
0127 Checkpoints from any earlier version (bare state_dict, log(pT) feature,
0128 tanh-capped output) are rejected by --load_stage1.  Retrain stage 1 and
0129 refit stage 2; a stage-2 refit against an old stage 1 is not a valid
0130 configuration.
0131 """
0132 
0133 import argparse
0134 import math
0135 import os
0136 import sys
0137 import numpy as np
0138 import pandas as pd
0139 import torch
0140 import torch.nn as nn
0141 import torch.nn.functional as F
0142 from scipy.optimize import curve_fit
0143 import matplotlib
0144 matplotlib.use("Agg")
0145 import matplotlib.pyplot as plt
0146 
0147 # ---------------------------------------------------------------- constants
0148 PION_MASS   = 0.13957039     # GeV  (never name this M_PI)
0149 PROTON_MASS = 0.93827209
0150 M_K0S       = 0.497611
0151 M_LAMBDA    = 1.115683
0152 
0153 ETA_RANGE = (-1.5, 1.5)
0154 PT_SANITY = 1e-3             # GeV; reject unphysical/zero pT, NOT a fiducial cut
0155 
0156 KAPPA_GUARD = 0.05           # numerical floor on kappa_curv = 1 + eps + q*delta.
0157                              # NOT a physics cap; hit fraction must be 0.
0158 U_CLAMP = 6.0                # numerical clamp on the normalised curvature
0159                              # feature; outside it kappa is constant in 1/pT.
0160 
0161 # mass windows used for the width NLL (must match the load() selection)
0162 WIN = {"K0s": (0.42, 0.58), "Lambda": (1.09, 1.145), "Lambdabar": (1.09, 1.145)}
0163 MPDG = {"K0s": M_K0S, "Lambda": M_LAMBDA, "Lambdabar": M_LAMBDA}
0164 SIG0 = {"K0s": 0.010, "Lambda": 0.004, "Lambdabar": 0.004}
0165 
0166 # resolution-head parametrisation
0167 A_SCALE = 0.05               # absolute: a = A_SCALE * softplus(.)  dimensionless
0168 B_SCALE = 0.05               # absolute: b = B_SCALE * softplus(.)  [1/GeV]
0169 S_SCALE = 2.0                # pull:     s = S_SCALE * softplus(.)  (init -> 1)
0170 C_INIT  = 0.0015             # GeV, initial mass-width floor
0171 F_MAX   = 0.30               # tail fraction upper bound
0172 K_MIN, K_MAX = 1.2, 4.0      # tail width ratio bounds
0173 RCOV_VALID = (1e-5, 1.0)     # accepted r_cov range
0174 
0175 MOTHER_PREFIXES = ("K_S0", "Lambda0")
0176 
0177 LOG_SQRT_2PI = 0.5 * math.log(2.0 * math.pi)
0178 SQRT2 = math.sqrt(2.0)
0179 
0180 torch.manual_seed(17)
0181 np.random.seed(17)
0182 
0183 
0184 # ---------------------------------------------------------------- covariance
0185 def cov_pack_index(a, b):
0186     """Packed lower-triangle index for a symmetric matrix, a >= b."""
0187     if a < b:
0188         a, b = b, a
0189     return a * (a + 1) // 2 + b
0190 
0191 
0192 def unpack_cov6(arr21):
0193     """Generic (21,) -> (6, 6) symmetric unpack from the index formula."""
0194     M = np.empty((6, 6), dtype=float)
0195     for a in range(6):
0196         for b in range(a + 1):
0197             M[a, b] = M[b, a] = arr21[cov_pack_index(a, b)]
0198     return M
0199 
0200 
0201 def _selftest_cov_packing():
0202     """Round-trip a synthetic symmetric 6x6 through the packing formula."""
0203     rng = np.random.default_rng(7)
0204     S = rng.standard_normal((6, 6))
0205     S = 0.5 * (S + S.T)
0206     packed = np.array([S[a, b] for a in range(6) for b in range(a + 1)])
0207     assert packed.size == 21
0208     assert np.allclose(unpack_cov6(packed), S), "cov packing self-test FAILED"
0209 
0210 
0211 _selftest_cov_packing()
0212 
0213 
0214 def cov_pt_frac_sigma(cov, phi):
0215     """r_cov-numerator: sigma(pT) from the packed covariance, per track.
0216 
0217     sigma^2(pT) = (px^2 Vxx + 2 px py Vxy + py^2 Vyy) / pT^2
0218                 =  cos^2(phi) Vxx + 2 sin cos Vxy + sin^2(phi) Vyy.
0219     Momentum-block indices 9/13/14 hold for both the 21-entry (6x6 state) and
0220     28-entry (7x7 with E) layouts, because px, py stay at positions 3, 4.
0221     Returns (sigma_pT, layout_string).
0222     """
0223     ncol = cov.shape[1]
0224     if ncol == 21:
0225         layout = "6x6 (x,y,z,px,py,pz)"
0226     elif ncol == 28:
0227         layout = "7x7 (x,y,z,px,py,pz,E)"
0228     else:
0229         raise RuntimeError(f"Covariance branch has {ncol} entries; expected 21 or 28")
0230     ixx, ixy, iyy = (cov_pack_index(3, 3), cov_pack_index(4, 3),
0231                      cov_pack_index(4, 4))          # 9, 13, 14 by construction
0232     Vxx, Vxy, Vyy = cov[:, ixx], cov[:, ixy], cov[:, iyy]
0233     c, s = np.cos(phi), np.sin(phi)
0234     var = c * c * Vxx + 2.0 * s * c * Vxy + s * s * Vyy
0235     return np.sqrt(np.maximum(var, 0.0)), layout, Vxx, Vyy
0236 
0237 
0238 # ---------------------------------------------------------------- data loading
0239 def _read_root_species(path, tree_name, need_pdg, max_candidates=0, rng=None, label=""):
0240     """Read one KFParticle nTuple.  Returns a dict of numpy arrays.
0241 
0242     If max_candidates is non-zero, select candidates while iterating over ROOT
0243     chunks instead of materialising the full multi-GB tree before subsampling.
0244     """
0245     import uproot
0246     t = uproot.open(path)[tree_name]
0247     keys = set(t.keys())
0248 
0249     mother = next((p for p in MOTHER_PREFIXES if f"{p}_mass" in keys), None)
0250     if mother is None:
0251         cand = sorted({k[:-5] for k in keys
0252                        if k.endswith("_mass") and not k.startswith("track_")})
0253         if len(cand) != 1:
0254             raise RuntimeError(f"{path}: cannot identify mother prefix among {cand}")
0255         mother = cand[0]
0256 
0257     want = {f"{mother}_{x}": f"c{x}" for x in ("mass", "pT", "pseudorapidity", "phi")}
0258     for i in (1, 2):
0259         for x in ("pT", "pseudorapidity", "phi"):
0260             want[f"track_{i}_{x}"] = f"t{i}_{x}"
0261     opt = {}
0262     for i in (1, 2):
0263         for x in ("charge", "PDG_ID", "pTErr", "Covariance"):
0264             b = f"track_{i}_{x}"
0265             if b in keys:
0266                 opt[b] = f"t{i}_{x}"
0267     missing = [b for b in want if b not in keys]
0268     if missing:
0269         raise RuntimeError(f"{path}: missing branches {missing}")
0270     if need_pdg and "track_2_PDG_ID" not in keys:
0271         raise RuntimeError(f"{path}: Lambda file needs track_i_PDG_ID branches")
0272 
0273     branches = list(want) + list(opt)
0274 
0275     def convert(raw):
0276         out = {"mother": mother}
0277         for b, name in {**want, **opt}.items():
0278             a = raw[b]
0279             if a.dtype == object:                   # jagged-stored fixed array
0280                 a = np.stack(a)
0281             out[name] = np.asarray(a)
0282         return out
0283 
0284     if not max_candidates:
0285         print(f"[{label}] reading {t.num_entries} ROOT entries from {path}", flush=True)
0286         return convert(t.arrays(branches, library="np"))
0287 
0288     if rng is None:
0289         rng = np.random.default_rng(42)
0290     chunks = []
0291     n_keep = 0
0292     n_seen = 0
0293     print(f"[{label}] reading ROOT chunks until {max_candidates} selected candidates", flush=True)
0294     for raw in t.iterate(branches, library="np", step_size="100 MB"):
0295         d = convert(raw)
0296         n_chunk = d["cmass"].size
0297         n_seen += n_chunk
0298 
0299         if label == "K0s":
0300             lo, hi = 0.40, 0.60
0301         else:
0302             lo, hi = 1.08, 1.15
0303         m = np.isfinite(d["cmass"])
0304         for i in (1, 2):
0305             for x in ("pT", "pseudorapidity", "phi"):
0306                 m &= np.isfinite(d[f"t{i}_{x}"])
0307         m &= (d["t1_pT"] > PT_SANITY) & (d["t2_pT"] > PT_SANITY)
0308         m &= (np.abs(d["t1_pseudorapidity"]) < ETA_RANGE[1])
0309         m &= (np.abs(d["t2_pseudorapidity"]) < ETA_RANGE[1])
0310         m &= (d["cmass"] > lo) & (d["cmass"] < hi)
0311         idx = np.flatnonzero(m)
0312 
0313         need = max_candidates - n_keep
0314         if idx.size > need:
0315             idx = rng.choice(idx, size=need, replace=False)
0316             idx.sort()
0317         chunks.append({k: (v[idx] if isinstance(v, np.ndarray) else v) for k, v in d.items()})
0318         n_keep += idx.size
0319         print(f"[{label}] scanned {n_seen} / {t.num_entries} entries, kept {n_keep}", flush=True)
0320         if n_keep >= max_candidates:
0321             break
0322 
0323     if not chunks:
0324         return convert(t.arrays(branches, entry_stop=0, library="np"))
0325     out = {"mother": mother}
0326     for k in chunks[0]:
0327         if isinstance(chunks[0][k], np.ndarray):
0328             out[k] = np.concatenate([c[k] for c in chunks])
0329     return out
0330 
0331 
0332 def _read_csv_species(path):
0333     df = pd.read_csv(path)
0334     df.columns = [c.strip() for c in df.columns]
0335     mother = next((p for p in MOTHER_PREFIXES if f"{p}_mass" in df.columns), None)
0336     if mother is None:
0337         raise RuntimeError(f"{path}: no recognised mother prefix in columns")
0338     out = {"mother": mother}
0339     for x in ("mass", "pT", "pseudorapidity", "phi"):
0340         out[f"c{x}"] = df[f"{mother}_{x}"].to_numpy(float)
0341     for i in (1, 2):
0342         for x in ("pT", "pseudorapidity", "phi"):
0343             out[f"t{i}_{x}"] = df[f"track_{i}_{x}"].to_numpy(float)
0344         if f"track_{i}_PDG_ID" in df.columns:
0345             out[f"t{i}_PDG_ID"] = df[f"track_{i}_PDG_ID"].to_numpy()
0346     return out
0347 
0348 
0349 def _species_selection(d, mass_lo, mass_hi, has_cov, label):
0350     """Eta acceptance + pT sanity + mass window + covariance validity.
0351 
0352     NO daughter pT cut (task 4): only a 1 MeV sanity floor.
0353     Rejection categories are counted and printed.
0354     """
0355     n0 = d["cmass"].size
0356     counts = {}
0357 
0358     def apply(mask, name):
0359         counts[name] = int((~mask).sum())
0360         return mask
0361 
0362     m = np.ones(n0, bool)
0363     fin = np.ones(n0, bool)
0364     for i in (1, 2):
0365         for x in ("pT", "pseudorapidity", "phi"):
0366             fin &= np.isfinite(d[f"t{i}_{x}"])
0367     fin &= np.isfinite(d["cmass"])
0368     m &= apply(fin, "non-finite kinematics")
0369     pts = (d["t1_pT"] > PT_SANITY) & (d["t2_pT"] > PT_SANITY)
0370     m &= apply(pts, f"pT <= {PT_SANITY} GeV sanity")
0371     eta = (np.abs(d["t1_pseudorapidity"]) < ETA_RANGE[1]) & \
0372           (np.abs(d["t2_pseudorapidity"]) < ETA_RANGE[1])
0373     m &= apply(eta, "|eta| acceptance")
0374     win = (d["cmass"] > mass_lo) & (d["cmass"] < mass_hi)
0375     m &= apply(win, "mass window")
0376 
0377     if has_cov:
0378         for i in (1, 2):
0379             cov = d[f"t{i}_Covariance"].astype(float)
0380             sig, layout, Vxx, Vyy = cov_pt_frac_sigma(cov, d[f"t{i}_phi"])
0381             if i == 1:
0382                 print(f"[{label}] covariance layout: {layout}")
0383             rcov = sig / np.maximum(d[f"t{i}_pT"], 1e-12)
0384             ok_fin = np.isfinite(sig) & np.isfinite(Vxx) & np.isfinite(Vyy)
0385             ok_pos = (Vxx > 0) & (Vyy > 0)
0386             ok_rng = (rcov > RCOV_VALID[0]) & (rcov < RCOV_VALID[1])
0387             m &= apply(ok_fin, f"track_{i} cov non-finite")
0388             m &= apply(ok_pos, f"track_{i} cov diag <= 0")
0389             m &= apply(ok_rng, f"track_{i} r_cov outside {RCOV_VALID}")
0390             d[f"t{i}_rcov"] = rcov
0391             d[f"t{i}_sigpt"] = sig
0392 
0393     for name, ncut in counts.items():
0394         if ncut:
0395             print(f"[{label}] rejected {ncut:7d} : {name}")
0396     print(f"[{label}] selected {int(m.sum())} / {n0} candidates "
0397           f"(no daughter pT cut)")
0398     return {k: (v[m] if isinstance(v, np.ndarray) else v) for k, v in d.items()}
0399 
0400 
0401 def _pterr_crosscheck(d, label):
0402     """sqrt(cov projection) vs the stored pTErr branch; abort on bulk mismatch."""
0403     for i in (1, 2):
0404         if f"t{i}_pTErr" not in d or f"t{i}_sigpt" not in d:
0405             continue
0406         pterr = d[f"t{i}_pTErr"].astype(float)
0407         ok = np.isfinite(pterr) & (pterr > 0)
0408         if ok.sum() < 100:
0409             continue
0410         ratio = d[f"t{i}_sigpt"][ok] / pterr[ok]
0411         med = float(np.median(ratio))
0412         frac_bad = float(np.mean(np.abs(ratio - 1.0) > 0.2))
0413         print(f"[{label}] track_{i}: median sigma_pT(cov)/pTErr = {med:.4f}, "
0414               f"frac |ratio-1|>20% = {frac_bad:.3f}")
0415         if abs(med - 1.0) > 0.2:
0416             sys.exit(f"[{label}] FATAL: covariance-projected sigma(pT) disagrees "
0417                      f"with pTErr by >20% on the bulk -- packing convention is "
0418                      f"wrong; everything downstream would be invalid.")
0419 
0420 
0421 def _subsample(d, n_max, rng, label):
0422     n = d["cmass"].size
0423     if n_max and n > n_max:
0424         idx = rng.choice(n, size=n_max, replace=False)
0425         idx.sort()
0426         d = {k: (v[idx] if isinstance(v, np.ndarray) else v) for k, v in d.items()}
0427         print(f"[{label}] subsampled {n} -> {n_max} candidates (--max_candidates)")
0428     return d
0429 
0430 
0431 def load(args):
0432     """Returns (K, L, meta).  meta carries has_cov, the curvature-feature
0433     normalisation, the loss-bin pT edges and the data-driven plot ranges."""
0434     rng = np.random.default_rng(args.subsample_seed)
0435 
0436     def read(path, need_pdg):
0437         if path.endswith(".csv"):
0438             return _read_csv_species(path), False
0439         d = _read_root_species(path, args.tree, need_pdg,
0440                                max_candidates=args.max_candidates,
0441                                rng=rng, label=("Lambda" if need_pdg else "K0s"))
0442         return d, ("t1_Covariance" in d and "t2_Covariance" in d)
0443 
0444     dK, covK = read(args.kshort, need_pdg=False)
0445     dL, covL = read(args.lam, need_pdg=True)
0446     has_cov = covK and covL
0447     if args.reso_model == "pull" and not has_cov:
0448         sys.exit("--reso_model pull requires Covariance branches in BOTH inputs "
0449                  "(ROOT ntuples). CSV input carries none: use --reso_model absolute.")
0450 
0451     dK = _species_selection(dK, 0.40, 0.60, covK, "K0s")
0452     dL = _species_selection(dL, 1.08, 1.15, covL, "Lambda")
0453     if has_cov:
0454         _pterr_crosscheck(dK, "K0s")
0455         _pterr_crosscheck(dL, "Lambda")
0456     dK = _subsample(dK, args.max_candidates, rng, "K0s")
0457     dL = _subsample(dL, args.max_candidates, rng, "Lambda")
0458 
0459     T = lambda x: torch.tensor(np.ascontiguousarray(x), dtype=torch.float64)
0460 
0461     # K0s daughter charges: branch if present, else pi- / pi+ convention
0462     if "t1_charge" in dK:
0463         q1K = T(np.sign(dK["t1_charge"]).astype(float))
0464         q2K = T(np.sign(dK["t2_charge"]).astype(float))
0465     else:
0466         q1K = torch.full((dK["cmass"].size,), -1.0, dtype=torch.float64)
0467         q2K = torch.full((dK["cmass"].size,), +1.0, dtype=torch.float64)
0468 
0469     K = dict(pt1=T(dK["t1_pT"]), eta1=T(dK["t1_pseudorapidity"]), phi1=T(dK["t1_phi"]),
0470              pt2=T(dK["t2_pT"]), eta2=T(dK["t2_pseudorapidity"]), phi2=T(dK["t2_phi"]),
0471              q1=q1K, q2=q2K, m1=PION_MASS, m2=PION_MASS, M=M_K0S,
0472              mass=T(dK["cmass"]), cpt=T(dK["cpT"]),
0473              ceta=T(dK["cpseudorapidity"]), cphi=T(dK["cphi"]))
0474     qpi = torch.tensor(np.sign(dL["t1_PDG_ID"]).astype(float), dtype=torch.float64)
0475     qp  = torch.tensor(np.sign(dL["t2_PDG_ID"]).astype(float), dtype=torch.float64)
0476     L = dict(pt1=T(dL["t1_pT"]), eta1=T(dL["t1_pseudorapidity"]), phi1=T(dL["t1_phi"]),
0477              pt2=T(dL["t2_pT"]), eta2=T(dL["t2_pseudorapidity"]), phi2=T(dL["t2_phi"]),
0478              q1=qpi, q2=qp, m1=PION_MASS, m2=PROTON_MASS, M=M_LAMBDA,
0479              mass=T(dL["cmass"]),
0480              is_lam=torch.tensor(dL["t2_PDG_ID"] > 0, dtype=torch.bool),
0481              cpt=T(dL["cpT"]), ceta=T(dL["cpseudorapidity"]), cphi=T(dL["cphi"]))
0482     if has_cov:
0483         for S, d in ((K, dK), (L, dL)):
0484             S["rcov1"] = T(d["t1_rcov"])
0485             S["rcov2"] = T(d["t2_rcov"])
0486 
0487     # -------- data-driven curvature normalisation, bins and plot ranges
0488     all_pt = np.concatenate([dK["t1_pT"], dK["t2_pT"], dL["t1_pT"], dL["t2_pT"]])
0489     u = 1.0 / all_pt
0490     q16, q50, q84 = np.quantile(u, [0.16, 0.50, 0.84])
0491     u_ref = float(q50)
0492     u_scale = float(max(0.5 * (q84 - q16), 1e-3))    # robust sigma
0493     u_lo, u_hi = np.quantile(u, [0.005, 0.995])
0494     pt_lo, pt_hi = np.quantile(all_pt, [0.005, 0.995])
0495     pt_lo = min(float(pt_lo), float(args.lookup_pt_min))
0496     pt_hi = max(float(pt_hi), float(args.lookup_pt_max))
0497     u_lo = 1.0 / pt_hi
0498     u_hi = 1.0 / pt_lo
0499     pt_edges = np.unique(np.quantile(all_pt, np.linspace(0.0, 1.0, 7)))
0500     if pt_edges.size < 4:                            # degenerate: fall back
0501         pt_edges = np.linspace(pt_lo, pt_hi, 7)
0502     pt_slices = semi_even_pt_grid(pt_lo, pt_hi, args.kappa_slices)
0503     curv_slices = 1.0 / pt_slices
0504 
0505     meta = dict(has_cov=has_cov,
0506                 norm=dict(u_ref=u_ref, u_scale=u_scale, u_clamp=U_CLAMP),
0507                 pt_edges=pt_edges, pt_slices=pt_slices, curv_slices=curv_slices,
0508                 pt_plot=(float(pt_lo), float(pt_hi)),
0509                 curv_plot=(float(u_lo), float(u_hi)))
0510     print(f"[norm] curvature feature: u_ref = {u_ref:.4f} GeV^-1, "
0511           f"u_scale = {u_scale:.4f} GeV^-1, clamp +-{U_CLAMP}")
0512     print(f"[bins] daughter pT loss-bin edges (data quantiles): "
0513           f"{np.array2string(pt_edges, precision=3)}")
0514     return K, L, meta
0515 
0516 
0517 # ---------------------------------------------------------------- scale model
0518 CONVENTION_TAG = "curvature_mult_uncapped_v4"
0519 
0520 
0521 class KappaNet(nn.Module):
0522     """x = (u_n, eta_n, sin phi, cos phi) -> (eps, delta), UNCAPPED.
0523 
0524     u = 1/pT (unsigned curvature); u_n = clamp((u - u_ref)/u_scale, +-U_CLAMP).
0525     The clamp is a numerical guard: outside it kappa is constant in curvature.
0526     Convention:  (1/pT)_corr = (1/pT) * (1 + eps + q*delta);  kappa() returns
0527     the pT MULTIPLIER 1/(1+eps+q*delta) so call sites keep pT_corr = kappa*pT.
0528     q enters only through the output combination -> exact even/odd split in
0529     curvature space.  Near-identity init; the annealed L2 prior in train() is
0530     the only soft pull to identity and vanishes at the last epoch.
0531     """
0532 
0533     def __init__(self, norm, hidden=48, layers=3):
0534         super().__init__()
0535         self.u_ref = float(norm["u_ref"])
0536         self.u_scale = float(norm["u_scale"])
0537         self.u_clamp = float(norm["u_clamp"])
0538         dims = [4] + [hidden] * layers + [2]
0539         seq = []
0540         for i in range(len(dims) - 1):
0541             seq.append(nn.Linear(dims[i], dims[i + 1]))
0542             if i < len(dims) - 2:
0543                 seq.append(nn.SiLU())
0544         self.net = nn.Sequential(*seq)
0545         with torch.no_grad():
0546             self.net[-1].weight *= 0.01
0547             self.net[-1].bias.zero_()          # eps = delta = 0  ->  kappa = 1
0548 
0549     def norm_dict(self):
0550         return dict(u_ref=self.u_ref, u_scale=self.u_scale, u_clamp=self.u_clamp)
0551 
0552     def features(self, pt, eta, phi):
0553         u = 1.0 / pt.clamp(min=1e-6)
0554         u_n = ((u - self.u_ref) / self.u_scale).clamp(-self.u_clamp, self.u_clamp)
0555         return torch.stack([u_n, eta / 1.0, torch.sin(phi), torch.cos(phi)], -1)
0556 
0557     def u_clamp_frac(self, pt):
0558         with torch.no_grad():
0559             u = 1.0 / pt.clamp(min=1e-6)
0560             u_n = (u - self.u_ref) / self.u_scale
0561             return float((u_n.abs() >= self.u_clamp).double().mean())
0562 
0563     def eps_delta(self, pt, eta, phi):
0564         out = self.net(self.features(pt, eta, phi))
0565         return out[..., 0], out[..., 1]
0566 
0567     def kappa(self, pt, eta, phi, q):
0568         """Returns (pT multiplier, eps, delta);  pT_corr = multiplier * pT."""
0569         eps, dlt = self.eps_delta(pt, eta, phi)
0570         kcurv = (1.0 + eps + q * dlt).clamp(min=KAPPA_GUARD)
0571         return 1.0 / kcurv, eps, dlt
0572 
0573 
0574 class IdentityKappa(nn.Module):
0575     """kappa == 1; used to run stage 2 on injection toys."""
0576 
0577     def kappa(self, pt, eta, phi, q):
0578         z = torch.zeros_like(pt)
0579         return torch.ones_like(pt), z, z
0580 
0581 
0582 def save_stage1(model, path):
0583     torch.save({"state_dict": model.state_dict(),
0584                 "norm": model.norm_dict(),
0585                 "convention": CONVENTION_TAG}, path)
0586 
0587 
0588 def load_stage1(path, hidden):
0589     ckpt = torch.load(path, weights_only=False)
0590     if not (isinstance(ckpt, dict) and ckpt.get("convention") == CONVENTION_TAG):
0591         sys.exit(f"--load_stage1 {path}: checkpoint predates the uncapped "
0592                  f"curvature convention '{CONVENTION_TAG}' (or is a bare "
0593                  f"state_dict). It would load silently with WRONG semantics. "
0594                  f"Retrain stage 1.")
0595     model = KappaNet(ckpt["norm"], hidden=hidden).double()
0596     model.load_state_dict(ckpt["state_dict"])
0597     return model
0598 
0599 
0600 # ---------------------------------------------------------------- kinematics
0601 def inv_mass(pt1, eta1, phi1, m1, pt2, eta2, phi2, m2):
0602     px1, py1, pz1 = pt1 * torch.cos(phi1), pt1 * torch.sin(phi1), pt1 * torch.sinh(eta1)
0603     px2, py2, pz2 = pt2 * torch.cos(phi2), pt2 * torch.sin(phi2), pt2 * torch.sinh(eta2)
0604     e1 = torch.sqrt(px1**2 + py1**2 + pz1**2 + m1**2)
0605     e2 = torch.sqrt(px2**2 + py2**2 + pz2**2 + m2**2)
0606     m2v = (e1 + e2)**2 - (px1 + px2)**2 - (py1 + py2)**2 - (pz1 + pz2)**2
0607     return torch.sqrt(m2v.clamp(min=1e-12))
0608 
0609 
0610 def _p4(pt, eta, phi, m):
0611     px, py, pz = pt * torch.cos(phi), pt * torch.sin(phi), pt * torch.sinh(eta)
0612     p2 = px * px + py * py + pz * pz
0613     return px, py, pz, torch.sqrt(p2 + m * m), p2
0614 
0615 
0616 def mass_jacobians(pt1, eta1, phi1, m1, pt2, eta2, phi2, m2):
0617     """(m, J1, J2), J_i = dm/dln(pT_i) at fixed (eta, phi); not leg-symmetric."""
0618     px1, py1, pz1, E1, p1sq = _p4(pt1, eta1, phi1, m1)
0619     px2, py2, pz2, E2, p2sq = _p4(pt2, eta2, phi2, m2)
0620     msq = (E1 + E2)**2 - (px1 + px2)**2 - (py1 + py2)**2 - (pz1 + pz2)**2
0621     m = torch.sqrt(msq.clamp(min=1e-12))
0622     pdot = px1 * px2 + py1 * py2 + pz1 * pz2
0623     J1 = (E2 * p1sq / E1 - pdot) / m
0624     J2 = (E1 * p2sq / E2 - pdot) / m
0625     return m, J1, J2
0626 
0627 
0628 def beta_of(pt, eta, m):
0629     p = pt * torch.cosh(eta)
0630     return p / torch.sqrt(p * p + m * m)
0631 
0632 
0633 # ---------------------------------------------------------------- signal weights
0634 def gauss_lin(x, N, mu, sig, a, b):
0635     return N * np.exp(-0.5 * ((x - mu) / sig)**2) + a + b * x
0636 
0637 
0638 def fit_peak(masses, mu0, sig0, lo, hi, nbins=100, weights=None):
0639     """Gaussian+linear fit with floating peak. Returns (mu, sig, popt)."""
0640     h, edges = np.histogram(masses, bins=nbins, range=(lo, hi), weights=weights)
0641     c = 0.5 * (edges[:-1] + edges[1:])
0642     p0 = [h.max() - np.median(h), mu0, sig0, np.median(h), 0.0]
0643     try:
0644         popt, _ = curve_fit(gauss_lin, c, h, p0=p0,
0645                             sigma=np.sqrt(np.maximum(h, 1)), maxfev=20000)
0646         if not (lo < popt[1] < hi) or not (0.2 * sig0 < abs(popt[2]) < 5 * sig0):
0647             raise RuntimeError
0648     except Exception:
0649         popt = p0  # fall back to seed; weights degrade gracefully
0650     return popt[1], abs(popt[2]), popt
0651 
0652 
0653 def signal_weights(masses, popt):
0654     s = popt[0] * np.exp(-0.5 * ((masses - popt[1]) / popt[2])**2)
0655     b = np.maximum(popt[3] + popt[4] * masses, 0.0)
0656     w = s / np.maximum(s + b, 1e-9)
0657     return np.clip(w, 0.0, 1.0)
0658 
0659 
0660 # ---------------------------------------------------------------- binning
0661 def semi_even_pt_grid(pt_min, pt_max, n):
0662     """Human-readable pT grid: dense below 3 GeV, sparse at the high end."""
0663     anchors = np.array([0.10, 0.25, 0.50, 0.75, 1.00, 1.25, 1.50, 1.75,
0664                         2.00, 2.25, 2.50, 2.75, 3.00, 4.00, 5.00], dtype=float)
0665     lo, hi = float(pt_min), float(pt_max)
0666     vals = anchors[(anchors >= lo) & (anchors <= hi)]
0667     vals = np.unique(np.concatenate(([lo], vals, [hi])))
0668     if vals.size == n:
0669         return vals
0670     q = np.linspace(0.0, 1.0, n)
0671     return np.interp(q, np.linspace(0.0, 1.0, vals.size), vals)
0672 
0673 
0674 def bin_index(pt, eta, phi, pt_edges, n_eta, n_phi):
0675     ipt = torch.bucketize(pt, pt_edges) - 1
0676     ipt = ipt.clamp(0, len(pt_edges) - 2)
0677     ieta = ((eta - ETA_RANGE[0]) / (ETA_RANGE[1] - ETA_RANGE[0]) * n_eta).long().clamp(0, n_eta - 1)
0678     iphi = ((phi + np.pi) / (2 * np.pi) * n_phi).long().clamp(0, n_phi - 1)
0679     return (ipt * n_eta + ieta) * n_phi + iphi, (len(pt_edges) - 1) * n_eta * n_phi
0680 
0681 
0682 def binned_mean_sq(idx, nbins, values, weights, min_w=5.0):
0683     """sum_b w_b * mean_b^2, weighted means via scatter-add (differentiable)."""
0684     sw = torch.zeros(nbins, dtype=values.dtype).index_add_(0, idx, weights)
0685     swv = torch.zeros(nbins, dtype=values.dtype).index_add_(0, idx, weights * values)
0686     mask = sw > min_w
0687     mean = swv[mask] / sw[mask]
0688     return (sw[mask] * mean**2).sum() / sw[mask].sum().clamp(min=1e-9)
0689 
0690 
0691 def corrected_mass(model, S):
0692     k1, e1, d1 = model.kappa(S["pt1"], S["eta1"], S["phi1"], S["q1"])
0693     k2, e2, d2 = model.kappa(S["pt2"], S["eta2"], S["phi2"], S["q2"])
0694     m = inv_mass(k1 * S["pt1"], S["eta1"], S["phi1"], S["m1"],
0695                  k2 * S["pt2"], S["eta2"], S["phi2"], S["m2"])
0696     reg = (e1**2 + d1**2 + e2**2 + d2**2).mean()
0697     return m, (k1, k2), reg
0698 
0699 
0700 # ---------------------------------------------------------------- stage 1
0701 def train(args, K, L, meta):
0702     model = KappaNet(meta["norm"], hidden=args.hidden).double()
0703     opt = torch.optim.Adam(model.parameters(), lr=args.lr, weight_decay=1e-5)
0704 
0705     pt_edges = torch.tensor(meta["pt_edges"], dtype=torch.float64)
0706     N_ETA, N_PHI = 10, 8
0707 
0708     def refresh_weights():
0709         with torch.no_grad():
0710             mK, _, _ = corrected_mass(model, K)
0711             mL, _, _ = corrected_mass(model, L)
0712         muK, sK, pK = fit_peak(mK.numpy(), M_K0S, 0.010, 0.42, 0.58)
0713         muL, sL, pL = fit_peak(mL.numpy(), M_LAMBDA, 0.004, 1.09, 1.145)
0714         K["w"] = torch.tensor(signal_weights(mK.numpy(), pK))
0715         L["w"] = torch.tensor(signal_weights(mL.numpy(), pL))
0716         return (muK, sK), (muL, sL)
0717 
0718     (muK0, _), (muL0, _) = refresh_weights()
0719     print(f"raw peaks:  K0s {muK0*1e3:8.3f} MeV (PDG {M_K0S*1e3:.3f}), "
0720           f"Lambda {muL0*1e3:8.3f} MeV (PDG {M_LAMBDA*1e3:.3f})")
0721 
0722     for ep in range(args.epochs):
0723         opt.zero_grad()
0724         loss = torch.tensor(0.0, dtype=torch.float64)
0725         prior_w = args.lam_prior * (1.0 - ep / max(args.epochs - 1, 1))  # anneal -> 0
0726 
0727         for S in (K, L):
0728             m, _, reg = corrected_mass(model, S)
0729             pull = (m - S["M"]) / (0.010 if S is K else 0.004)
0730             w = S["w"]
0731             for (pt, eta, phi, q) in ((S["pt1"], S["eta1"], S["phi1"], S["q1"]),
0732                                       (S["pt2"], S["eta2"], S["phi2"], S["q2"])):
0733                 idx, nb = bin_index(pt, eta, phi, pt_edges, N_ETA, N_PHI)
0734                 idx = idx + ((q > 0).long() * nb)
0735                 loss = loss + binned_mean_sq(idx, 2 * nb, pull, w)
0736             loss = loss + prior_w * reg
0737 
0738         mL, _, _ = corrected_mass(model, L)
0739         pullL = (mL - M_LAMBDA) / 0.004
0740         idx, nb = bin_index(L["pt1"], L["eta1"], L["phi1"], pt_edges, N_ETA, N_PHI)
0741         sel_l, sel_b = L["is_lam"], ~L["is_lam"]
0742         wl = L["w"]
0743         swl  = torch.zeros(nb, dtype=torch.float64).index_add_(0, idx[sel_l], wl[sel_l])
0744         swvl = torch.zeros(nb, dtype=torch.float64).index_add_(0, idx[sel_l], (wl * pullL)[sel_l])
0745         swb  = torch.zeros(nb, dtype=torch.float64).index_add_(0, idx[sel_b], wl[sel_b])
0746         swvb = torch.zeros(nb, dtype=torch.float64).index_add_(0, idx[sel_b], (wl * pullL)[sel_b])
0747         mk = (swl > 5) & (swb > 5)
0748         split = swvl[mk] / swl[mk] - swvb[mk] / swb[mk]
0749         wsum = (swl[mk] * swb[mk]) / (swl[mk] + swb[mk])
0750         loss = loss + args.lam_split * (wsum * split**2).sum() / wsum.sum().clamp(min=1e-9)
0751 
0752         k1, _, _ = model.kappa(K["pt1"], K["eta1"], K["phi1"], K["q1"])
0753         k2, _, _ = model.kappa(K["pt2"], K["eta2"], K["phi2"], K["q2"])
0754         p1, p2 = k1 * K["pt1"], k2 * K["pt2"]
0755         alpha = (p1 - p2) / (p1 + p2)                      # pi- minus pi+
0756         idxc, nbc = bin_index(K["cpt"], K["ceta"], K["cphi"],
0757                               pt_edges, N_ETA, N_PHI)
0758         loss = loss + args.lam_alpha * binned_mean_sq(idxc, nbc, alpha, K["w"])
0759 
0760         loss.backward()
0761         opt.step()
0762 
0763         if ep % args.refresh == args.refresh - 1:
0764             (muK, _), (muL, _) = refresh_weights()
0765             print(f"ep {ep+1:4d}  loss {loss.item():9.5f}  "
0766                   f"K0s peak {muK*1e3:8.3f}  Lam peak {muL*1e3:8.3f} MeV")
0767 
0768     return model
0769 
0770 
0771 # ================================================================ STAGE 2
0772 class ResoNet(nn.Module):
0773     """(eta, sin phi, cos phi) -> fields, mode-dependent.
0774 
0775     absolute: two heads (a, b), r^2 = (a/beta)^2 + (b pT)^2 -- a MEASUREMENT
0776               of the resolution.
0777     pull:     one head s(eta, phi), r = s * pT^{u_p} * r_cov -- a CALIBRATION
0778               of the covariance-reported resolution (s == 1, u_p == 0 means
0779               the tracker's errors are right).
0780     Deliberately NOT a function of pT (identifiability); species enters only
0781     through beta (absolute) or through r_cov itself (pull).
0782     """
0783 
0784     def __init__(self, mode, hidden=32, layers=3):
0785         super().__init__()
0786         self.mode = mode
0787         n_out = 2 if mode == "absolute" else 1
0788         dims = [3] + [hidden] * layers + [n_out]
0789         seq = []
0790         for i in range(len(dims) - 1):
0791             seq.append(nn.Linear(dims[i], dims[i + 1]))
0792             if i < len(dims) - 2:
0793                 seq.append(nn.SiLU())
0794         self.net = nn.Sequential(*seq)
0795         with torch.no_grad():
0796             self.net[-1].weight *= 0.01
0797             if mode == "absolute":
0798                 # r ~ 1.5% at 1 GeV pion: a ~ b ~ 0.0106; 0.05*softplus(z)=0.0106
0799                 self.net[-1].bias.fill_(-1.443)
0800             else:
0801                 # s = S_SCALE*softplus(z) = 1  ->  softplus(z) = 0.5
0802                 self.net[-1].bias.fill_(math.log(math.expm1(1.0 / S_SCALE)))
0803 
0804     def features(self, eta, phi):
0805         return torch.stack([eta / 1.0, torch.sin(phi), torch.cos(phi)], -1)
0806 
0807     def ab(self, eta, phi):
0808         out = self.net(self.features(eta, phi))
0809         return A_SCALE * F.softplus(out[..., 0]), B_SCALE * F.softplus(out[..., 1])
0810 
0811     def s(self, eta, phi):
0812         out = self.net(self.features(eta, phi))
0813         return S_SCALE * F.softplus(out[..., 0])
0814 
0815 
0816 class ResoGlobals(nn.Module):
0817     """Floor c (1 or per-resonance), per-group (f, k), and the pull exponent u_p."""
0818 
0819     def __init__(self, n_groups, n_c=1):
0820         super().__init__()
0821         c_raw0 = math.log(math.expm1(C_INIT))          # softplus^-1(C_INIT)
0822         self.c_raw = nn.Parameter(torch.full((n_c,), c_raw0, dtype=torch.float64))
0823         self.f_raw = nn.Parameter(torch.full((n_groups,), -2.0, dtype=torch.float64))
0824         self.k_raw = nn.Parameter(torch.zeros(n_groups, dtype=torch.float64))
0825         self.up = nn.Parameter(torch.zeros(1, dtype=torch.float64))   # pull only
0826 
0827     def c(self, i=0):
0828         return F.softplus(self.c_raw[i])
0829 
0830     def f(self, i):
0831         return F_MAX * torch.sigmoid(self.f_raw[i])
0832 
0833     def k(self, i):
0834         return K_MIN + (K_MAX - K_MIN) * torch.sigmoid(self.k_raw[i])
0835 
0836     def u_p(self):
0837         return self.up[0]
0838 
0839 
0840 def r_terms(a, b, pt, beta):
0841     return a / beta, b * pt
0842 
0843 
0844 def sigma_mass_ab(R, a1, b1, a2, b2, c):
0845     t1a, t1b = r_terms(a1, b1, R["pt1"], R["beta1"])
0846     t2a, t2b = r_terms(a2, b2, R["pt2"], R["beta2"])
0847     r1sq = t1a**2 + t1b**2
0848     r2sq = t2a**2 + t2b**2
0849     return torch.sqrt((R["J1"]**2 * r1sq + R["J2"]**2 * r2sq + c**2).clamp(min=1e-12))
0850 
0851 
0852 def pull_r(R, leg, s, up):
0853     return s * R[f"pt{leg}"].clamp(min=1e-6)**up * R[f"rcov{leg}"]
0854 
0855 
0856 def sigma_mass_pull(R, s1, s2, up, c):
0857     r1 = pull_r(R, 1, s1, up)
0858     r2 = pull_r(R, 2, s2, up)
0859     return torch.sqrt((R["J1"]**2 * r1**2 + R["J2"]**2 * r2**2 + c**2).clamp(min=1e-12))
0860 
0861 
0862 def sigma_mass_net(R, net, glob, c):
0863     if net.mode == "absolute":
0864         a1, b1 = net.ab(R["eta1"], R["phi1"])
0865         a2, b2 = net.ab(R["eta2"], R["phi2"])
0866         return sigma_mass_ab(R, a1, b1, a2, b2, c)
0867     s1 = net.s(R["eta1"], R["phi1"])
0868     s2 = net.s(R["eta2"], R["phi2"])
0869     return sigma_mass_pull(R, s1, s2, glob.u_p(), c)
0870 
0871 
0872 def log_mixture_pdf(m, M, sigma, f, k, lo, hi):
0873     """ln of the window-normalised core+tail Gaussian mixture."""
0874     s1, s2 = sigma, k * sigma
0875     lg1 = -0.5 * ((m - M) / s1)**2 - torch.log(s1) - LOG_SQRT_2PI
0876     lg2 = -0.5 * ((m - M) / s2)**2 - torch.log(s2) - LOG_SQRT_2PI
0877     lp = torch.logaddexp(torch.log1p(-f) + lg1, torch.log(f) + lg2)
0878     frac = lambda s: 0.5 * (torch.erf((hi - M) / (s * SQRT2))
0879                             - torch.erf((lo - M) / (s * SQRT2)))
0880     Z = (1.0 - f) * frac(s1) + f * frac(s2)
0881     return lp - torch.log(Z.clamp(min=1e-12))
0882 
0883 
0884 def width_nll_sum(R, M, sigma, w, f, k, lo, hi):
0885     return -(w * log_mixture_pdf(R["m"], M, sigma, f, k, lo, hi)).sum()
0886 
0887 
0888 # ---------------------------------------------------------------- reso inputs
0889 def prepare_reso_inputs(model, S):
0890     """Freeze the scale, precompute everything stage 2 needs (constant with
0891     KappaNet frozen).  Jacobians and beta use the CORRECTED momenta.  r_cov is
0892     carried through unchanged: it is a fractional resolution, invariant under
0893     the kappa scale to first order (and identical in pT and curvature)."""
0894     with torch.no_grad():
0895         k1, _, _ = model.kappa(S["pt1"], S["eta1"], S["phi1"], S["q1"])
0896         k2, _, _ = model.kappa(S["pt2"], S["eta2"], S["phi2"], S["q2"])
0897         pt1, pt2 = k1 * S["pt1"], k2 * S["pt2"]
0898         m, J1, J2 = mass_jacobians(pt1, S["eta1"], S["phi1"], S["m1"],
0899                                    pt2, S["eta2"], S["phi2"], S["m2"])
0900         if "dm" in S:            # injection toys only: explicit floor smearing
0901             m = m + S["dm"]
0902         R = dict(m=m, J1=J1, J2=J2,
0903                  pt1=pt1, eta1=S["eta1"], phi1=S["phi1"],
0904                  pt2=pt2, eta2=S["eta2"], phi2=S["phi2"],
0905                  beta1=beta_of(pt1, S["eta1"], S["m1"]),
0906                  beta2=beta_of(pt2, S["eta2"], S["m2"]),
0907                  cpt=S["cpt"], ceta=S["ceta"], cphi=S["cphi"],
0908                  m1=S["m1"], m2=S["m2"])
0909         for key in ("rcov1", "rcov2"):
0910             if key in S:
0911                 R[key] = S[key]
0912     return R
0913 
0914 
0915 def subset_R(R, sel):
0916     out = {}
0917     for k, v in R.items():
0918         out[k] = v[sel] if torch.is_tensor(v) else v
0919     return out
0920 
0921 
0922 def build_groups(model, K, L, pure_signal=False):
0923     """Three groups: K0s, Lambda, Lambdabar (own tail params; shared ResoNet)."""
0924     RK = prepare_reso_inputs(model, K)
0925     RL = prepare_reso_inputs(model, L)
0926 
0927     if pure_signal:
0928         wK = torch.ones_like(RK["m"])
0929         wL = torch.ones_like(RL["m"])
0930     else:
0931         _, _, pK = fit_peak(RK["m"].numpy(), M_K0S, SIG0["K0s"], *WIN["K0s"])
0932         _, _, pL = fit_peak(RL["m"].numpy(), M_LAMBDA, SIG0["Lambda"], *WIN["Lambda"])
0933         wK = torch.tensor(signal_weights(RK["m"].numpy(), pK))
0934         wL = torch.tensor(signal_weights(RL["m"].numpy(), pL))
0935 
0936     isl = L["is_lam"]
0937     groups = [
0938         dict(name="K0s",       R=RK,                  w=wK,        c_idx=0),
0939         dict(name="Lambda",    R=subset_R(RL, isl),   w=wL[isl],   c_idx=0),
0940         dict(name="Lambdabar", R=subset_R(RL, ~isl),  w=wL[~isl],  c_idx=0),
0941     ]
0942     for g in groups:
0943         g["M"] = MPDG[g["name"]]
0944         g["lo"], g["hi"] = WIN[g["name"]]
0945     return groups
0946 
0947 
0948 def set_c_indices(groups, per_resonance):
0949     if not per_resonance:
0950         for g in groups:
0951             g["c_idx"] = 0
0952         return 1
0953     for g in groups:
0954         g["c_idx"] = 0 if g["name"] == "K0s" else 1
0955     return 2
0956 
0957 
0958 # ---------------------------------------------------------------- stage 2 train
0959 def train_resolution(groups, args, n_c=1, verbose=True):
0960     net = ResoNet(args.reso_model, hidden=args.reso_hidden,
0961                   layers=args.reso_layers).double()
0962     glob = ResoGlobals(len(groups), n_c=n_c).double()
0963     # globals get 5x LR: Adam stalls along the c <-> (s, u_p) soft direction
0964     opt = torch.optim.Adam([
0965         {"params": net.parameters(), "lr": args.reso_lr},
0966         {"params": glob.parameters(), "lr": 5.0 * args.reso_lr},
0967     ], weight_decay=0.0)
0968     wtot = sum(float(g["w"].sum()) for g in groups)
0969 
0970     def total_nll():
0971         nll = torch.tensor(0.0, dtype=torch.float64)
0972         for gi, g in enumerate(groups):
0973             sig = sigma_mass_net(g["R"], net, glob, glob.c(g["c_idx"]))
0974             nll = nll + width_nll_sum(g["R"], g["M"], sig, g["w"],
0975                                       glob.f(gi), glob.k(gi), g["lo"], g["hi"])
0976         return nll
0977 
0978     for ep in range(args.reso_epochs):
0979         opt.zero_grad()
0980         loss = total_nll() / wtot
0981         loss.backward()
0982         opt.step()
0983         if verbose and ep % max(args.reso_epochs // 10, 1) == 0:
0984             with torch.no_grad():
0985                 cs = " ".join(f"c{i}={float(glob.c(i))*1e3:.3f}" for i in range(n_c))
0986                 extra = (f" u_p={float(glob.u_p()):+.4f}"
0987                          if args.reso_model == "pull" else "")
0988                 fs = " ".join(f"{g['name'][:4]}: f={float(glob.f(gi)):.3f} "
0989                               f"k={float(glob.k(gi)):.2f}"
0990                               for gi, g in enumerate(groups))
0991             print(f"  reso ep {ep+1:4d}  -lnL/w {loss.item():10.5f}  "
0992                   f"{cs} MeV{extra}   {fs}")
0993 
0994     # LBFGS polish of the GLOBALS with the field frozen
0995     for p in net.parameters():
0996         p.requires_grad_(False)
0997     with torch.no_grad():
0998         best = float(total_nll())
0999         state0 = {k: v.clone() for k, v in glob.state_dict().items()}
1000     lb = torch.optim.LBFGS(list(glob.parameters()), lr=0.5, max_iter=100,
1001                            tolerance_grad=1e-10, line_search_fn="strong_wolfe")
1002 
1003     def closure():
1004         lb.zero_grad()
1005         v = total_nll()
1006         v.backward()
1007         return v
1008     try:
1009         lb.step(closure)
1010     except Exception as e:
1011         print(f"  [train_resolution] LBFGS polish skipped: {e}")
1012     with torch.no_grad():
1013         v_pol = float(total_nll())
1014     if not np.isfinite(v_pol) or v_pol > best:
1015         glob.load_state_dict(state0)
1016         print("  [train_resolution] LBFGS polish rejected (no improvement)")
1017     else:
1018         print(f"  [train_resolution] LBFGS polish: nll {best:.3f} -> {v_pol:.3f}")
1019     for p in net.parameters():
1020         p.requires_grad_(True)
1021     return net, glob
1022 
1023 
1024 # ---------------------------------------------------------------- reduced fit
1025 def fit_global_reduced(groups, mode, n_c=1, steps=3000, lr=0.02):
1026     """Reduced fit with constant fields, for a Hessian covariance.
1027 
1028     absolute: theta = (ln a, ln b, c..., f..., k...)
1029     pull:     theta = (ln s, u_p,  c..., f..., k...)
1030     Purpose is the injection go/no-go ('recovered within uncertainties');
1031     ResoNet itself carries no error bars.
1032     """
1033     ng = len(groups)
1034     if mode == "absolute":
1035         head0 = [math.log(0.0106), math.log(0.0106)]
1036     else:
1037         head0 = [0.0, 0.0]                       # ln s = 0, u_p = 0
1038     theta0 = torch.tensor(head0 +
1039                           [math.log(math.expm1(C_INIT))] * n_c +
1040                           [-2.0] * ng + [0.0] * ng, dtype=torch.float64)
1041 
1042     def unpack(t):
1043         # numerical bounds only; they never bind at a physical minimum
1044         if mode == "absolute":
1045             h1, h2 = torch.exp(t[0].clamp(-12, 2)), torch.exp(t[1].clamp(-12, 2))
1046         else:
1047             h1, h2 = torch.exp(t[0].clamp(-6, 6)), t[1].clamp(-3, 3)   # s, u_p
1048         c = F.softplus(t[2:2 + n_c])
1049         f = F_MAX * torch.sigmoid(t[2 + n_c:2 + n_c + ng])
1050         k = K_MIN + (K_MAX - K_MIN) * torch.sigmoid(t[2 + n_c + ng:])
1051         return h1, h2, c, f, k
1052 
1053     def nll(t):
1054         h1, h2, c, f, k = unpack(t)
1055         tot = torch.tensor(0.0, dtype=torch.float64)
1056         for gi, g in enumerate(groups):
1057             R = g["R"]
1058             if mode == "absolute":
1059                 sig = sigma_mass_ab(R, h1, h2, h1, h2, c[g["c_idx"]])
1060             else:
1061                 sig = sigma_mass_pull(R, h1, h1, h2, c[g["c_idx"]])
1062             tot = tot + width_nll_sum(R, g["M"], sig, g["w"], f[gi], k[gi],
1063                                       g["lo"], g["hi"])
1064         return tot
1065 
1066     t = theta0.clone().requires_grad_(True)
1067     opt = torch.optim.Adam([t], lr=lr)
1068     best, t_best = float("inf"), theta0.clone()
1069     for _ in range(steps):
1070         opt.zero_grad()
1071         v = nll(t)
1072         v.backward()
1073         opt.step()
1074         fv = float(v.detach())
1075         if np.isfinite(fv) and fv < best:
1076             best, t_best = fv, t.detach().clone()
1077 
1078     lb = torch.optim.LBFGS([t], lr=0.5, max_iter=200,
1079                            tolerance_grad=1e-10, line_search_fn="strong_wolfe")
1080 
1081     def closure():
1082         lb.zero_grad()
1083         v = nll(t)
1084         v.backward()
1085         return v
1086     try:
1087         lb.step(closure)
1088     except Exception as e:
1089         print(f"  [fit_global_reduced] LBFGS polish skipped: {e}")
1090     with torch.no_grad():
1091         v_pol = float(nll(t)) if torch.isfinite(t).all() else float("inf")
1092     if not np.isfinite(v_pol) or v_pol > best:
1093         with torch.no_grad():
1094             t.copy_(t_best)
1095     else:
1096         best = v_pol
1097 
1098     td = t.detach()
1099     H = torch.autograd.functional.hessian(nll, td)
1100     if not torch.isfinite(H).all():
1101         print("  [fit_global_reduced] Hessian non-finite at the polished point; "
1102               "retrying at the Adam-best point")
1103         td = t_best.clone()
1104         H = torch.autograd.functional.hessian(nll, td)
1105     pd, ev = False, None
1106     cov = torch.full_like(H, float("nan"))
1107     if torch.isfinite(H).all():
1108         try:
1109             ev = torch.linalg.eigvalsh(0.5 * (H + H.T))
1110             tol = 1e-8 * float(ev.abs().max())
1111             pd = bool((ev > -tol).all())
1112             cov = torch.linalg.inv(H) if pd else torch.linalg.pinv(H)
1113         except Exception as e:
1114             print(f"  [fit_global_reduced] eigen-decomposition failed: {e}")
1115     else:
1116         print("  [fit_global_reduced] Hessian contains non-finite entries")
1117     if not torch.isfinite(cov).all():
1118         print("  [fit_global_reduced] covariance not usable; errors reported as nan")
1119         cov = torch.full_like(H, float("nan"))
1120     if ev is not None and not pd:
1121         print(f"  [fit_global_reduced] Hessian not positive definite "
1122               f"(min eig {float(ev.min()):.3e}) -- fit is not at a minimum")
1123 
1124     def _err(i):
1125         v = float(cov[i, i])
1126         return math.sqrt(v) if np.isfinite(v) and v > 0 else float("nan")
1127 
1128     def _rho(i, j):
1129         d = math.sqrt(float(cov[i, i]) * float(cov[j, j]))
1130         return float(cov[i, j]) / d if np.isfinite(d) and d > 0 else float("nan")
1131 
1132     h1, h2, c, f, k = unpack(td)
1133     out = dict(pos_def=pd, c=c.detach().numpy(), f=f.detach().numpy(),
1134                k=k.detach().numpy(), nll=float(nll(td)))
1135     if mode == "absolute":
1136         out.update(a=float(h1), b=float(h2),
1137                    sa=float(h1) * _err(0), sb=float(h2) * _err(1),
1138                    rho_ab=_rho(0, 1), rho_ac=_rho(0, 2))
1139     else:
1140         out.update(s=float(h1), u_p=float(h2),
1141                    ss=float(h1) * _err(0), su=_err(1),
1142                    rho_su=_rho(0, 1), rho_sc=_rho(0, 2))
1143     return out
1144 
1145 
1146 # ---------------------------------------------------------------- outputs
1147 def export_lookup(model, meta, path):
1148     """kappa is the pT MULTIPLIER (pT_corr = kappa * pT); the grid is uniform
1149     in 1/pT (task 5) and both columns are exported.  Normalisation constants
1150     are stamped so the map is reproducible at deployment time."""
1151     pts = semi_even_pt_grid(meta["pt_plot"][0], meta["pt_plot"][1], 60)
1152     curvs = 1.0 / pts
1153     etas = np.linspace(*ETA_RANGE, 13)
1154     phis = np.linspace(-np.pi, np.pi, 25)
1155     rows = []
1156     with torch.no_grad():
1157         for q in (-1.0, 1.0):
1158             U, E, Fm = np.meshgrid(curvs, etas, phis, indexing="ij")
1159             P = 1.0 / U
1160             t = lambda a: torch.tensor(a.ravel(), dtype=torch.float64)
1161             kap, eps, dlt = model.kappa(t(P), t(E), t(Fm),
1162                                         torch.full((P.size,), q, dtype=torch.float64))
1163             rows.append(np.column_stack([np.full(P.size, q), P.ravel(), U.ravel(),
1164                                          E.ravel(), Fm.ravel(), kap.numpy(),
1165                                          (1.0 / kap).numpy(),
1166                                          eps.numpy(), dlt.numpy()]))
1167     with open(path, "w") as fh:
1168         fh.write("# convention: (1/pT)_corr = (1/pT) * kappa_curv, "
1169                  "kappa_curv = 1 + eps + q*delta (uncapped)\n")
1170         fh.write("# kappa is the pT multiplier consumed downstream: "
1171                  "pT_corr = kappa * pT = pT / kappa_curv\n")
1172         fh.write(f"# feature norm: u_ref = {model.u_ref:.6f} GeV^-1, "
1173                  f"u_scale = {model.u_scale:.6f} GeV^-1, "
1174                  f"u_clamp = {model.u_clamp:.1f} (kappa constant in 1/pT "
1175                  f"outside the clamp)\n")
1176         fh.write(f"# convention tag: {CONVENTION_TAG}\n")
1177         fh.write("# CAVEAT: trained on vertex-constrained daughter kinematics; "
1178                  "deployed on raw SvtxTrackMap tracks (calibration/deployment "
1179                  "mismatch is documented in the analysis note)\n")
1180         fh.write("q,pT,curv,eta,phi,kappa,kappa_curv,eps,delta\n")
1181         np.savetxt(fh, np.vstack(rows), delimiter=",", fmt="%.6f")
1182 
1183 
1184 PULL_INTERPRETATION = [
1185     "PULL MODEL: s and u_p are a CALIBRATION of the covariance-reported",
1186     "resolution, r = s(eta,phi) * pT^{u_p} * r_cov -- not a measurement of the",
1187     "resolution itself. s==1, u_p==0 means the tracker's errors are correct.",
1188     "s mixes (i) resolution missed by the Kalman hit-error/material model and",
1189     "(ii) covariance mis-estimation; nothing here separates them.",
1190     "The stored covariance is POST-VERTEX-CONSTRAINT: s is defined relative to",
1191     "constrained covariances and must NOT be applied to raw SvtxTrack",
1192     "covariances (unmeasured constraint-shrinkage factor).",
1193     "s at low pT is partially degenerate with the floor c: see rho(s,c).",
1194     "No r grid is exported: r depends on the per-track covariance; consumers",
1195     "rebuild r = s(eta,phi) * pT^{u_p} * r_cov from their own covariance.",
1196 ]
1197 
1198 
1199 def export_reso_lookup(net, glob, groups, path, n_c=1):
1200     """absolute: (eta, phi, a, b) fields.  pull: (eta, phi, s) plus u_p in the
1201     header.  Never an evaluated r grid (species/covariance dependence)."""
1202     etas = np.linspace(*ETA_RANGE, 25)
1203     phis = np.linspace(-np.pi, np.pi, 25)
1204     E, P = np.meshgrid(etas, phis, indexing="ij")
1205     t = lambda x: torch.tensor(x.ravel(), dtype=torch.float64)
1206     hdr = []
1207     with torch.no_grad():
1208         if net.mode == "absolute":
1209             a, b = net.ab(t(E), t(P))
1210             cols = np.column_stack([E.ravel(), P.ravel(), a.numpy(), b.numpy()])
1211             colhdr = "eta,phi,a,b"
1212             hdr.append("model: r^2 = (a(eta,phi)/beta)^2 + (b(eta,phi)*pT)^2 ; "
1213                        "sigma_m^2 = J1^2 r1^2 + J2^2 r2^2 + c^2")
1214             hdr.append("a dimensionless (enters as a/beta); b in 1/GeV; c in GeV")
1215         else:
1216             s = net.s(t(E), t(P))
1217             cols = np.column_stack([E.ravel(), P.ravel(), s.numpy()])
1218             colhdr = "eta,phi,s"
1219             hdr.append("model: r = s(eta,phi) * pT^{u_p} * r_cov ; "
1220                        "sigma_m^2 = J1^2 r1^2 + J2^2 r2^2 + c^2")
1221             hdr.append(f"u_p = {float(glob.u_p()):+.6f}")
1222             hdr.extend(PULL_INTERPRETATION)
1223         for i in range(n_c):
1224             hdr.append(f"c[{i}] = {float(glob.c(i)):.6e} GeV")
1225         for gi, g in enumerate(groups):
1226             hdr.append(f"tail {g['name']}: f = {float(glob.f(gi)):.4f}, "
1227                        f"k = {float(glob.k(gi)):.4f}")
1228     with open(path, "w") as fh:
1229         for h in hdr:
1230             fh.write("# " + h + "\n")
1231         fh.write(colhdr + "\n")
1232         np.savetxt(fh, cols, delimiter=",", fmt="%.6e")
1233 
1234 
1235 def _savefig(fig, outdir, name):
1236     fig.tight_layout()
1237     fig.savefig(os.path.join(outdir, name + ".png"), dpi=130)
1238     fig.savefig(os.path.join(outdir, name + ".pdf"))
1239     plt.close(fig)
1240 
1241 
1242 def export_mass_histograms_root(K, L, mK, mL, outdir):
1243     """Write ROOT TH1D mass spectra before/after stage-1 correction."""
1244     import uproot
1245 
1246     isl = L["is_lam"].numpy()
1247     specs = [
1248         ("kshort",      K["mass"].numpy(),        mK,       (0.45, 0.55)),
1249         ("lambda",      L["mass"].numpy()[isl],   mL[isl],  (1.09, 1.15)),
1250         ("anti_lambda", L["mass"].numpy()[~isl],  mL[~isl], (1.09, 1.15)),
1251     ]
1252     path = os.path.join(outdir, "stage1_mass_histograms.root")
1253     with uproot.recreate(path) as f:
1254         for name, raw, cor, rng in specs:
1255             edges = np.linspace(rng[0], rng[1], 51)
1256             h_raw, _ = np.histogram(raw, bins=edges)
1257             h_cor, _ = np.histogram(cor, bins=edges)
1258             f[f"{name}_mass_raw"] = (h_raw.astype(np.float64), edges)
1259             f[f"{name}_mass_corrected"] = (h_cor.astype(np.float64), edges)
1260     print(f"wrote stage-1 raw/corrected mass histograms to {path}")
1261 
1262 
1263 def plot_mass_1d(K, L, mK, mL, outdir):
1264     """Figure 1: raw vs corrected 1D mass, K0s / Lambda / Lambdabar."""
1265     isl = L["is_lam"].numpy()
1266     fig, ax = plt.subplots(1, 3, figsize=(15, 4.2))
1267     for a, raw, cor, pdg, rng, t in (
1268         (ax[0], K["mass"].numpy(), mK, M_K0S, (0.42, 0.58), "K0s"),
1269         (ax[1], L["mass"].numpy()[isl], mL[isl], M_LAMBDA, (1.09, 1.145), "Lambda"),
1270         (ax[2], L["mass"].numpy()[~isl], mL[~isl], M_LAMBDA, (1.09, 1.145), "Lambdabar")):
1271         a.hist(raw, bins=80, range=rng, histtype="step", label="raw")
1272         a.hist(cor, bins=80, range=rng, histtype="step", label="corrected")
1273         a.axvline(pdg, ls="--", c="k", lw=0.8, label="PDG")
1274         a.set_title(t); a.set_xlabel("m [GeV]"); a.set_ylabel("candidates / bin")
1275         a.legend(fontsize=8)
1276     fig.suptitle("Invariant mass: raw vs corrected")
1277     _savefig(fig, outdir, "fig1_mass_1d")
1278 
1279 
1280 def plot_mass_vs_kin_2d(K, L, mK, mL, outdir, kind, pt_plot):
1281     """Figures 2/3: 2D histogram of mass (y) vs candidate pT or eta (x)."""
1282     isl = L["is_lam"].numpy()
1283     if kind == "pt":
1284         xK, xL = K["cpt"].numpy(), L["cpt"].numpy()
1285         xrange, xlabel = (0.0, 1.3 * pt_plot[1]), r"candidate $p_T$ [GeV]"
1286         figname = "fig2_mass_vs_pt_2d"
1287     else:
1288         xK, xL = K["ceta"].numpy(), L["ceta"].numpy()
1289         xrange, xlabel = (-2.5, 2.5), "candidate eta"
1290         figname = "fig3_mass_vs_eta_2d"
1291 
1292     rows = [
1293         ("K0s",       xK,       K["mass"].numpy(),       mK,       (0.42, 0.58),  M_K0S),
1294         ("Lambda",    xL[isl],  L["mass"].numpy()[isl],  mL[isl],  (1.09, 1.145), M_LAMBDA),
1295         ("Lambdabar", xL[~isl], L["mass"].numpy()[~isl], mL[~isl], (1.09, 1.145), M_LAMBDA),
1296     ]
1297     fig, ax = plt.subplots(3, 2, figsize=(10, 12))
1298     for i, (name, x, mraw, mcor, mrange, pdg) in enumerate(rows):
1299         for j, (m, lab) in enumerate(((mraw, "raw"), (mcor, "corrected"))):
1300             a = ax[i, j]
1301             h = a.hist2d(x, m, bins=[60, 70], range=[xrange, mrange],
1302                         cmap="viridis", cmin=1)
1303             a.axhline(pdg, ls="--", c="w", lw=1.0)
1304             a.set_title(f"{name} ({lab})")
1305             a.set_xlabel(xlabel); a.set_ylabel("m [GeV]")
1306             cb = fig.colorbar(h[3], ax=a)
1307             cb.set_label("candidates / bin")
1308     fig.suptitle(f"Invariant mass vs candidate {kind}: raw vs corrected")
1309     _savefig(fig, outdir, figname)
1310 
1311 
1312 def plot_alpha_vs_eta_1d(K, k1, k2, outdir):
1313     """Figure 4: signal-weighted <alpha> vs candidate eta, raw vs corrected."""
1314     p1r, p2r = K["pt1"].numpy(), K["pt2"].numpy()
1315     p1c, p2c = (k1 * K["pt1"]).numpy(), (k2 * K["pt2"]).numpy()
1316     eta_c = K["ceta"].numpy(); w = K["w"].numpy()
1317     eb = np.linspace(*ETA_RANGE, 13)
1318     fig, ax = plt.subplots(figsize=(6, 4.5))
1319     for lab, a1, a2 in (("raw", p1r, p2r), ("corrected", p1c, p2c)):
1320         al = (a1 - a2) / (a1 + a2)
1321         num, _ = np.histogram(eta_c, eb, weights=w * al)
1322         den, _ = np.histogram(eta_c, eb, weights=w)
1323         ax.plot(0.5 * (eb[:-1] + eb[1:]), num / np.maximum(den, 1e-9), "o-", label=lab)
1324     ax.axhline(0, c="k", lw=0.8)
1325     ax.set_xlabel("K0s candidate eta")
1326     ax.set_ylabel(r"$\langle\alpha\rangle$ (signal-weighted)")
1327     ax.set_title(r"K0s daughter $p_T$ asymmetry vs eta")
1328     ax.legend()
1329     _savefig(fig, outdir, "fig4_alpha_vs_eta_1d")
1330 
1331 
1332 def plot_alpha_vs_mass_2d(K, mK, k1, k2, outdir):
1333     """Figure 5: daughter pT asymmetry alpha (x) vs K0s mass (y)."""
1334     p1r, p2r = K["pt1"].numpy(), K["pt2"].numpy()
1335     p1c, p2c = (k1 * K["pt1"]).numpy(), (k2 * K["pt2"]).numpy()
1336     alpha_raw = (p1r - p2r) / (p1r + p2r)
1337     alpha_cor = (p1c - p2c) / (p1c + p2c)
1338     mraw = K["mass"].numpy()
1339     arange, mrange = (-1, 1), (0.4, 0.6)
1340     fig, ax = plt.subplots(1, 2, figsize=(11, 4.6))
1341     for a, al, m, lab in ((ax[0], alpha_raw, mraw, "raw"),
1342                           (ax[1], alpha_cor, mK, "corrected")):
1343         h = a.hist2d(al, m, bins=[100, 100], range=[arange, mrange],
1344                     cmap="viridis", cmin=1)
1345         a.axhline(M_K0S, ls="--", c="w", lw=1.0)
1346         a.axvline(0.0, ls=":", c="w", lw=0.8)
1347         a.set_title(f"K0s ({lab})")
1348         a.set_xlabel(r"$\alpha=(p_T^{\pi^-}-p_T^{\pi^+})/(p_T^{\pi^-}+p_T^{\pi^+})$")
1349         a.set_ylabel("m [GeV]")
1350         cb = fig.colorbar(h[3], ax=a)
1351         cb.set_label("candidates / bin")
1352     fig.suptitle("K0s daughter pT asymmetry vs invariant mass")
1353     _savefig(fig, outdir, "fig5_alpha_vs_mass_2d")
1354 
1355 
1356 def _slice_title(u):
1357     return f"1/pT = {u:.2f} GeV$^{{-1}}$ (pT = {1.0/u:.2f} GeV)"
1358 
1359 
1360 def _plot_map_grid(maps, curv_slices, outdir, name, suptitle, cmap, vmin, vmax,
1361                    colorbar_label, title_prefix=""):
1362     ee = np.linspace(*ETA_RANGE, 50); pp = np.linspace(-np.pi, np.pi, 50)
1363     n = len(curv_slices); ncols = int(np.ceil(np.sqrt(n)));
1364     nrows = int(np.ceil(n / ncols))
1365     fig, ax = plt.subplots(nrows, ncols, figsize=(4.6 * ncols, 4.1 * nrows))
1366     ax = np.atleast_2d(ax)
1367     for idx, u in enumerate(curv_slices):
1368         r, c = divmod(idx, ncols)
1369         a = ax[r, c]
1370         im = a.pcolormesh(ee, pp, maps[idx].T, shading="auto", cmap=cmap,
1371                           vmin=vmin, vmax=vmax)
1372         a.set_title(title_prefix + _slice_title(u), fontsize=9)
1373         a.set_xlabel("eta"); a.set_ylabel("phi")
1374         cb = fig.colorbar(im, ax=a)
1375         cb.set_label(colorbar_label)
1376     for idx in range(n, nrows * ncols):
1377         r, c = divmod(idx, ncols); ax[r, c].axis("off")
1378     fig.suptitle(suptitle)
1379     _savefig(fig, outdir, name)
1380 
1381 
1382 def plot_kappa_maps_vs_curv(model, outdir, curv_slices):
1383     """Figure 6: charge-even (eps) and charge-odd (delta) curvature-scale maps,
1384     sliced UNIFORMLY IN 1/pT (task 5), each panel labelled with both 1/pT and
1385     the equivalent pT.  These are the exact even/odd fields of the convention
1386     (1/pT)_corr = (1/pT)(1 + eps + q*delta), read directly off the network."""
1387     ee = np.linspace(*ETA_RANGE, 50); pp = np.linspace(-np.pi, np.pi, 50)
1388     E, P = np.meshgrid(ee, pp, indexing="ij")
1389     t = lambda a: torch.tensor(a.ravel(), dtype=torch.float64)
1390     evens, odds = [], []
1391     with torch.no_grad():
1392         for u in curv_slices:
1393             eps, dlt = model.eps_delta(t(np.full(E.size, 1.0 / u)), t(E), t(P))
1394             evens.append(eps.numpy().reshape(E.shape))
1395             odds.append(dlt.numpy().reshape(E.shape))
1396     vmax_e = max(1e-4, max(np.abs(z).max() for z in evens))
1397     vmax_o = max(1e-4, max(np.abs(z).max() for z in odds))
1398 
1399     _plot_map_grid(evens, curv_slices, outdir, "fig6a_kappa_even_maps_vs_pt",
1400                    "Charge-even curvature-scale maps across pT",
1401                    "RdBu_r", -vmax_e, vmax_e,
1402                    r"$\epsilon$  (charge-even fractional $1/p_T$ scale)")
1403     _plot_map_grid(odds, curv_slices, outdir, "fig6b_kappa_odd_maps_vs_pt",
1404                    "Charge-odd curvature-scale maps across pT",
1405                    "RdBu_r", -vmax_o, vmax_o,
1406                    r"$\delta$  (charge-odd fractional $1/p_T$ scale)")
1407 
1408 
1409 def plot_kappa_final_vs_curv(model, outdir, curv_slices):
1410     """Figure 7: deployed pT multiplier kappa_q = 1/(1+eps+q*delta) per charge,
1411     sliced uniformly in 1/pT (task 5), shared color scale."""
1412     ee = np.linspace(*ETA_RANGE, 50); pp = np.linspace(-np.pi, np.pi, 50)
1413     E, P = np.meshgrid(ee, pp, indexing="ij")
1414     t = lambda a: torch.tensor(a.ravel(), dtype=torch.float64)
1415     kp_maps, km_maps = [], []
1416     with torch.no_grad():
1417         for u in curv_slices:
1418             kp, _, _ = model.kappa(t(np.full(E.size, 1.0 / u)), t(E), t(P),
1419                                    torch.ones(E.size, dtype=torch.float64))
1420             km, _, _ = model.kappa(t(np.full(E.size, 1.0 / u)), t(E), t(P),
1421                                    -torch.ones(E.size, dtype=torch.float64))
1422             kp_maps.append(kp.numpy().reshape(E.shape))
1423             km_maps.append(km.numpy().reshape(E.shape))
1424     dev = max(1e-4, max(np.abs(z - 1.0).max() for z in kp_maps + km_maps))
1425 
1426     _plot_map_grid(kp_maps, curv_slices, outdir, "fig7a_kappa_qplus_maps_vs_pt",
1427                    r"Final $p_T$ scale correction for positive tracks",
1428                    "RdBu_r", 1 - dev, 1 + dev,
1429                    r"$\kappa(q{=}{+}1)$  final $p_T$ multiplier",
1430                    title_prefix="q = +1, ")
1431     _plot_map_grid(km_maps, curv_slices, outdir, "fig7b_kappa_qminus_maps_vs_pt",
1432                    r"Final $p_T$ scale correction for negative tracks",
1433                    "RdBu_r", 1 - dev, 1 + dev,
1434                    r"$\kappa(q{=}{-}1)$  final $p_T$ multiplier",
1435                    title_prefix="q = -1, ")
1436 
1437 
1438 # ---------------------------------------------------------------- stage-2 figs
1439 def core_width(x, w, rng=(-6, 6), nbins=120, fit_range=3.0, min_w=200.0):
1440     """Weighted Gaussian-core width of a pull distribution (tail-insensitive).
1441 
1442     Mildly biased high (tail leakage under |pull| < fit_range): a perfectly
1443     closing fit reads ~2-4% above 1 for f ~ 0.02, k ~ 2.  Interpret residual
1444     structure vs pT/eta/phi, not the absolute offset.
1445     """
1446     if np.sum(w) < min_w:
1447         return np.nan, np.nan
1448     h, edges = np.histogram(x, bins=nbins, range=rng, weights=w)
1449     c = 0.5 * (edges[:-1] + edges[1:])
1450     sel = np.abs(c) < fit_range
1451     g = lambda t, N, mu, s, b0: N * np.exp(-0.5 * ((t - mu) / s)**2) + b0
1452     try:
1453         popt, _ = curve_fit(g, c[sel], h[sel],
1454                             p0=[h[sel].max(), 0.0, 1.0, 0.0], maxfev=20000)
1455         s = abs(popt[2])
1456         if not (0.1 < s < 5.0) or not (-2 < popt[1] < 2):
1457             raise RuntimeError
1458     except Exception:
1459         return np.nan, np.nan
1460     return popt[1], abs(popt[2])
1461 
1462 
1463 def weighted_rms(x, w):
1464     mu = np.sum(w * x) / np.sum(w)
1465     return math.sqrt(np.sum(w * (x - mu)**2) / np.sum(w))
1466 
1467 
1468 def _quantile_edges(x, w, n):
1469     q = np.linspace(0, 1, n + 1)
1470     return np.unique(np.quantile(x, q))
1471 
1472 
1473 def plot_pull_closure(groups, net, glob, outdir):
1474     """Figure 8 (primary go/no-go): pull = (m - M_PDG) / sigma_m.
1475 
1476     In pull mode a RAW-COVARIANCE overlay is added: the pull computed with
1477     s = 1, u_p = 0 and no floor -- its core width is the single number saying
1478     how wrong the shipped covariances are (subject to the SV-constraint
1479     caveat).  Deviations of the calibrated core from 1 are a mis-modelled
1480     resolution field, NOT a scale problem (mu is fixed to PDG; fig1).
1481     """
1482     pulls, ws, cs, raws = {}, {}, {}, {}
1483     with torch.no_grad():
1484         for gi, g in enumerate(groups):
1485             sig = sigma_mass_net(g["R"], net, glob, glob.c(g["c_idx"]))
1486             pulls[g["name"]] = ((g["R"]["m"] - g["M"]) / sig).numpy()
1487             ws[g["name"]] = g["w"].numpy()
1488             cs[g["name"]] = (g["R"]["cpt"].numpy(), g["R"]["ceta"].numpy(),
1489                              g["R"]["cphi"].numpy())
1490             if net.mode == "pull":
1491                 one = torch.ones_like(g["R"]["m"])
1492                 sig0 = sigma_mass_pull(g["R"], one, one,
1493                                        torch.tensor(0.0, dtype=torch.float64),
1494                                        torch.tensor(0.0, dtype=torch.float64))
1495                 raws[g["name"]] = ((g["R"]["m"] - g["M"]) / sig0).numpy()
1496 
1497     fig, ax = plt.subplots(2, 3, figsize=(15.5, 9))
1498     raw_cores = {}
1499     for i, g in enumerate(groups):
1500         n = g["name"]
1501         a = ax[0, i]
1502         h, edges, _ = a.hist(pulls[n], bins=120, range=(-6, 6), weights=ws[n],
1503                              histtype="step", color="C0", label="calibrated")
1504         if n in raws:
1505             a.hist(raws[n], bins=120, range=(-6, 6), weights=ws[n],
1506                    histtype="step", color="C2", ls="--",
1507                    label="raw covariance (s=1, u_p=0, no floor)")
1508             raw_cores[n] = core_width(raws[n], ws[n])[1]
1509         mu, s = core_width(pulls[n], ws[n])
1510         if np.isfinite(s):
1511             c = 0.5 * (edges[:-1] + edges[1:])
1512             amp = h[np.argmin(np.abs(c - mu))]
1513             a.plot(c, amp * np.exp(-0.5 * ((c - mu) / s)**2), "r-", lw=1.2,
1514                    label=f"core fit  $\\sigma$={s:.3f}")
1515         rms = weighted_rms(pulls[n], ws[n])
1516         a.axvline(0, c="k", lw=0.7)
1517         ttl = f"{n}: core {s:.3f}, RMS {rms:.3f}"
1518         if n in raw_cores and np.isfinite(raw_cores[n]):
1519             ttl += f", raw-cov core {raw_cores[n]:.3f}"
1520         a.set_title(ttl, fontsize=9)
1521         a.set_xlabel(r"pull $=(m-M_{PDG})/\sigma_m$")
1522         a.set_ylabel("weighted candidates / bin")
1523         a.legend(fontsize=7)
1524 
1525     for j, (lab, ix, rng) in enumerate((("candidate $p_T$ [GeV]", 0, None),
1526                                         ("candidate $\\eta$", 1, ETA_RANGE),
1527                                         ("candidate $\\phi$", 2, (-np.pi, np.pi)))):
1528         a = ax[1, j]
1529         for g in groups:
1530             n = g["name"]
1531             x = cs[n][ix]
1532             edges = _quantile_edges(x, ws[n], 10) if rng is None else np.linspace(*rng, 11)
1533             xs, ss = [], []
1534             for lo, hi in zip(edges[:-1], edges[1:]):
1535                 sel = (x >= lo) & (x < hi)
1536                 if sel.sum() < 50:
1537                     continue
1538                 _, s = core_width(pulls[n][sel], ws[n][sel])
1539                 xs.append(0.5 * (lo + hi)); ss.append(s)
1540             a.plot(xs, ss, "o-", ms=3, label=n)
1541         a.axhline(1.0, c="k", ls="--", lw=0.8)
1542         a.set_ylim(0.6, 1.6)
1543         a.set_xlabel(lab)
1544         a.set_ylabel("core pull width")
1545         a.legend(fontsize=8)
1546     fig.suptitle("Pull closure: core width must be 1.0, globally and in every bin")
1547     _savefig(fig, outdir, "fig8_pull_closure")
1548     out = {g["name"]: (core_width(pulls[g["name"]], ws[g["name"]])[1],
1549                        weighted_rms(pulls[g["name"]], ws[g["name"]]))
1550            for g in groups}
1551     return out, raw_cores
1552 
1553 
1554 def plot_width_overlay(groups, net, glob, outdir):
1555     """Figure 9: fitted Gaussian core width of the corrected mass peak vs the
1556     predicted median sigma_m, per (candidate pT, eta) bin.  fig8 is the
1557     unbiased test (per-bin mixture over sigma_m biases this one slightly)."""
1558     fig, ax = plt.subplots(3, 2, figsize=(11, 12))
1559     with torch.no_grad():
1560         for i, g in enumerate(groups):
1561             n = g["name"]
1562             sig = sigma_mass_net(g["R"], net, glob, glob.c(g["c_idx"])).numpy()
1563             m = g["R"]["m"].numpy(); w = g["w"].numpy()
1564             for j, (xv, lab, rng) in enumerate(
1565                     ((g["R"]["cpt"].numpy(), r"candidate $p_T$ [GeV]", None),
1566                      (g["R"]["ceta"].numpy(), r"candidate $\eta$", ETA_RANGE))):
1567                 edges = _quantile_edges(xv, w, 10) if rng is None else np.linspace(*rng, 11)
1568                 xs, fit, pred = [], [], []
1569                 for lo, hi in zip(edges[:-1], edges[1:]):
1570                     sel = (xv >= lo) & (xv < hi)
1571                     if w[sel].sum() < 200:
1572                         continue
1573                     _, sfit, _ = fit_peak(m[sel], g["M"], SIG0[n], g["lo"], g["hi"],
1574                                           weights=w[sel])
1575                     xs.append(0.5 * (lo + hi))
1576                     fit.append(sfit * 1e3)
1577                     pred.append(np.median(sig[sel]) * 1e3)
1578                 a = ax[i, j]
1579                 a.plot(xs, fit, "o-", ms=3, label="fitted core width")
1580                 a.plot(xs, pred, "s--", ms=3, label=r"median predicted $\sigma_m$")
1581                 a.set_xlabel(lab); a.set_ylabel(r"$\sigma_m$ [MeV]")
1582                 a.set_title(n); a.legend(fontsize=8)
1583     fig.suptitle("Mass-peak core width vs predicted resolution")
1584     _savefig(fig, outdir, "fig9_width_overlay")
1585 
1586 
1587 def plot_r_vs_pt(groups, net, glob, outdir, pt_plot):
1588     """Figure 10: per-track fractional resolution vs pT.
1589 
1590     absolute: the field prediction decomposed into a/beta and b*pT at eta
1591     references (as v2), pion vs proton hypothesis.
1592     pull: per-leg binned medians of raw r_cov and calibrated
1593     r = s*pT^{u_p}*r_cov, split into pion legs and proton legs.
1594     """
1595     if net.mode == "absolute":
1596         pts = np.geomspace(max(pt_plot[0], 5e-2), pt_plot[1], 120)
1597         tp = torch.tensor(pts, dtype=torch.float64)
1598         eta_ref = (-1.0, 0.0, 1.0)
1599         fig, ax = plt.subplots(1, len(eta_ref), figsize=(5.2 * len(eta_ref), 4.4),
1600                                squeeze=False)
1601         with torch.no_grad():
1602             c_mev = float(glob.c(0)) * 1e3
1603             for i, eta in enumerate(eta_ref):
1604                 a_f, b_f = net.ab(torch.tensor([eta], dtype=torch.float64),
1605                                   torch.tensor([0.0], dtype=torch.float64))
1606                 a_f, b_f = float(a_f), float(b_f)
1607                 axx = ax[0, i]
1608                 for mass, name, col in ((PION_MASS, r"$\pi$", "C0"),
1609                                         (PROTON_MASS, "p", "C3")):
1610                     beta = beta_of(tp, torch.full_like(tp, eta), mass).numpy()
1611                     t_ms = a_f / beta
1612                     r = np.sqrt(t_ms**2 + (b_f * pts)**2)
1613                     axx.plot(pts, 1e2 * r, "-", c=col, label=f"{name}: total")
1614                     axx.plot(pts, 1e2 * t_ms, ":", c=col, lw=1.0,
1615                              label=f"{name}: $a/\\beta$")
1616                 axx.plot(pts, 1e2 * b_f * pts, "--", c="k", lw=1.0, label=r"$b\,p_T$")
1617                 axx.set_xscale("log"); axx.set_xlabel(r"$p_T$ [GeV]")
1618                 axx.set_ylabel(r"$\sigma(p_T)/p_T$ [%]")
1619                 axx.set_title(f"$\\eta$ = {eta:+.1f}: a = {a_f:.4f}, "
1620                               f"b = {b_f:.4f} GeV$^{{-1}}$, c = {c_mev:.2f} MeV")
1621                 axx.legend(fontsize=7); axx.grid(alpha=0.25)
1622         fig.suptitle("Per-track fractional momentum resolution (absolute model)")
1623     else:
1624         legs = {"pion": [], "proton": []}
1625         with torch.no_grad():
1626             up = glob.u_p()
1627             for g in groups:
1628                 R, w = g["R"], g["w"]
1629                 for leg in (1, 2):
1630                     kind = "proton" if (R["m2"] == PROTON_MASS and leg == 2) else "pion"
1631                     s = net.s(R[f"eta{leg}"], R[f"phi{leg}"])
1632                     rp = pull_r(R, leg, s, up)
1633                     legs[kind].append((R[f"pt{leg}"].numpy(),
1634                                        R[f"rcov{leg}"].numpy(), rp.numpy(),
1635                                        w.numpy()))
1636         fig, ax = plt.subplots(1, 2, figsize=(11, 4.6))
1637         for axx, kind in zip(ax, ("pion", "proton")):
1638             pt = np.concatenate([x[0] for x in legs[kind]])
1639             rc = np.concatenate([x[1] for x in legs[kind]])
1640             rp = np.concatenate([x[2] for x in legs[kind]])
1641             edges = np.unique(np.quantile(pt, np.linspace(0, 1, 13)))
1642             xs, med_c, med_p = [], [], []
1643             for lo, hi in zip(edges[:-1], edges[1:]):
1644                 sel = (pt >= lo) & (pt < hi)
1645                 if sel.sum() < 100:
1646                     continue
1647                 xs.append(0.5 * (lo + hi))
1648                 med_c.append(1e2 * np.median(rc[sel]))
1649                 med_p.append(1e2 * np.median(rp[sel]))
1650             axx.plot(xs, med_c, "s--", ms=3, c="C2", label=r"median raw $r_{cov}$")
1651             axx.plot(xs, med_p, "o-", ms=3, c="C0",
1652                      label=r"median calibrated $s\,p_T^{u_p}\,r_{cov}$")
1653             axx.set_xlabel(r"track $p_T$ [GeV]")
1654             axx.set_ylabel(r"$\sigma(p_T)/p_T$ [%]")
1655             axx.set_title(f"{kind} legs")
1656             axx.legend(fontsize=8); axx.grid(alpha=0.25)
1657         fig.suptitle("Covariance-reported vs calibrated per-track resolution "
1658                      "(pull model)")
1659     _savefig(fig, outdir, "fig10_r_vs_pt")
1660 
1661 
1662 def plot_ab_maps(net, outdir):
1663     """Figure 11 (absolute mode): (eta, phi) maps of the two fields."""
1664     ee = np.linspace(*ETA_RANGE, 60); pp = np.linspace(-np.pi, np.pi, 60)
1665     E, P = np.meshgrid(ee, pp, indexing="ij")
1666     t = lambda x: torch.tensor(x.ravel(), dtype=torch.float64)
1667     with torch.no_grad():
1668         a, b = net.ab(t(E), t(P))
1669     a = a.numpy().reshape(E.shape); b = b.numpy().reshape(E.shape)
1670     fig, ax = plt.subplots(1, 2, figsize=(12.5, 4.8))
1671     for axx, z, lab, ttl in (
1672         (ax[0], a, "MS-like fractional resolution coefficient "
1673                    r"(dimensionless, enters as $a/\beta$)", r"$a(\eta,\phi)$"),
1674         (ax[1], b, r"curvature term coefficient [1/GeV], enters as $b\,p_T$",
1675                    r"$b(\eta,\phi)$")):
1676         im = axx.pcolormesh(ee, pp, z.T, shading="auto", cmap="viridis")
1677         axx.set_xlabel(r"$\eta$"); axx.set_ylabel(r"$\phi$"); axx.set_title(ttl)
1678         cb = fig.colorbar(im, ax=axx); cb.set_label(lab, fontsize=8)
1679     fig.suptitle("Resolution fields (charge-even by construction)")
1680     _savefig(fig, outdir, "fig11_ab_maps")
1681 
1682 
1683 def plot_r_maps_vs_curv(net, glob, outdir, curv_slices):
1684     """Figure 12 (absolute mode): r(eta, phi) maps across 1/pT, pi vs p."""
1685     ee = np.linspace(*ETA_RANGE, 50); pp = np.linspace(-np.pi, np.pi, 50)
1686     E, P = np.meshgrid(ee, pp, indexing="ij")
1687     t = lambda a: torch.tensor(a.ravel(), dtype=torch.float64)
1688     n = len(curv_slices); ncols = 4
1689     nrows_each = int(np.ceil(n / ncols))
1690 
1691     with torch.no_grad():
1692         a_f, b_f = net.ab(t(E), t(P))
1693     a_f, b_f = a_f.numpy(), b_f.numpy()
1694 
1695     r_pi, r_p = [], []
1696     for u in curv_slices:
1697         pt = 1.0 / u
1698         beta_pi = beta_of(torch.tensor(np.full(E.size, pt)), t(E), PION_MASS).numpy()
1699         beta_p = beta_of(torch.tensor(np.full(E.size, pt)), t(E), PROTON_MASS).numpy()
1700         r_pi.append((1e2 * np.sqrt((a_f / beta_pi)**2 + (b_f * pt)**2)).reshape(E.shape))
1701         r_p.append((1e2 * np.sqrt((a_f / beta_p)**2 + (b_f * pt)**2)).reshape(E.shape))
1702     vmax_pi = max(z.max() for z in r_pi)
1703     vmax_p = max(z.max() for z in r_p)
1704 
1705     fig, ax = plt.subplots(2 * nrows_each, ncols,
1706                            figsize=(4.6 * ncols, 4.1 * 2 * nrows_each))
1707     for block, (maps, vmax, lab) in enumerate((
1708             (r_pi, vmax_pi, r"$r=\sigma(p_T)/p_T$ [%]  (pion hypothesis)"),
1709             (r_p, vmax_p, r"$r=\sigma(p_T)/p_T$ [%]  (proton hypothesis)"))):
1710         for idx, u in enumerate(curv_slices):
1711             r, c = divmod(idx, ncols)
1712             a = ax[block * nrows_each + r, c]
1713             im = a.pcolormesh(ee, pp, maps[idx].T, shading="auto", cmap="viridis",
1714                               vmin=0, vmax=vmax)
1715             a.set_title(("$\\pi$, " if block == 0 else "p, ") + _slice_title(u),
1716                         fontsize=9)
1717             a.set_xlabel("eta"); a.set_ylabel("phi")
1718             cb = fig.colorbar(im, ax=a)
1719             cb.set_label(lab)
1720         for idx in range(n, nrows_each * ncols):
1721             r, c = divmod(idx, ncols); ax[block * nrows_each + r, c].axis("off")
1722     fig.suptitle(r"Per-track fractional momentum resolution $r(\eta,\phi)$ "
1723                 r"across 1/pT, by mass hypothesis")
1724     _savefig(fig, outdir, "fig12_r_maps_vs_curv")
1725 
1726 
1727 def plot_calibration_factor(net, glob, outdir):
1728     """Figure 13 (pull mode): s(eta, phi) map plus 1D profiles.
1729 
1730     Read-off: 'the tracker under/over-estimates sigma(pT) by factor s'."""
1731     ee = np.linspace(*ETA_RANGE, 60); pp = np.linspace(-np.pi, np.pi, 60)
1732     E, P = np.meshgrid(ee, pp, indexing="ij")
1733     t = lambda x: torch.tensor(x.ravel(), dtype=torch.float64)
1734     with torch.no_grad():
1735         s = net.s(t(E), t(P)).numpy().reshape(E.shape)
1736         up = float(glob.u_p())
1737     fig, ax = plt.subplots(1, 3, figsize=(16, 4.6))
1738     im = ax[0].pcolormesh(ee, pp, s.T, shading="auto", cmap="viridis")
1739     ax[0].set_xlabel(r"$\eta$"); ax[0].set_ylabel(r"$\phi$")
1740     ax[0].set_title(rf"$s(\eta,\phi)$   ($u_p$ = {up:+.4f})")
1741     cb = fig.colorbar(im, ax=ax[0])
1742     cb.set_label("covariance calibration factor s  (s = 1: errors correct)")
1743     ax[1].plot(ee, s.mean(axis=1), "o-", ms=3)
1744     ax[1].axhline(1.0, c="k", ls="--", lw=0.8)
1745     ax[1].set_xlabel(r"$\eta$"); ax[1].set_ylabel(r"$\langle s\rangle_\phi$")
1746     ax[1].set_title("phi-averaged profile")
1747     ax[2].plot(pp, s.mean(axis=0), "o-", ms=3)
1748     ax[2].axhline(1.0, c="k", ls="--", lw=0.8)
1749     ax[2].set_xlabel(r"$\phi$"); ax[2].set_ylabel(r"$\langle s\rangle_\eta$")
1750     ax[2].set_title("eta-averaged profile")
1751     fig.suptitle("Covariance calibration field: r = s(eta,phi) * pT^{u_p} * r_cov")
1752     _savefig(fig, outdir, "fig13_calibration_factor")
1753 
1754 
1755 # ---------------------------------------------------------------- diagnostics
1756 def diagnostics(model, K, L, meta, outdir):
1757     with torch.no_grad():
1758         mK, _, _ = corrected_mass(model, K)
1759         mL, _, _ = corrected_mass(model, L)
1760         k1, e1, d1 = model.kappa(K["pt1"], K["eta1"], K["phi1"], K["q1"])
1761         k2, e2, d2 = model.kappa(K["pt2"], K["eta2"], K["phi2"], K["q2"])
1762     mK, mL = mK.numpy(), mL.numpy()
1763     isl = L["is_lam"].numpy()
1764 
1765     lines = []
1766     for name, raw, cor, pdg, lo, hi, s0 in (
1767         ("K0s",    K["mass"].numpy(), mK, M_K0S,    0.42, 0.58, 0.010),
1768         ("Lambda", L["mass"].numpy()[isl],  mL[isl],  M_LAMBDA, 1.09, 1.145, 0.004),
1769         ("Lambdabar", L["mass"].numpy()[~isl], mL[~isl], M_LAMBDA, 1.09, 1.145, 0.004)):
1770         mu_r, s_r, _ = fit_peak(raw, pdg, s0, lo, hi)
1771         mu_c, s_c, _ = fit_peak(cor, pdg, s0, lo, hi)
1772         lines.append(f"{name:10s} raw peak {mu_r*1e3:9.3f} MeV  ->  corrected "
1773                      f"{mu_c*1e3:9.3f} MeV   (PDG {pdg*1e3:.3f}, "
1774                      f"pull {(mu_c-pdg)*1e3:+.3f} MeV)")
1775     e_all = np.concatenate([e1.numpy(), e2.numpy()])
1776     d_all = np.concatenate([d1.numpy(), d2.numpy()])
1777     kc = np.concatenate([(1.0 + e1 + K["q1"] * d1).numpy(),
1778                          (1.0 + e2 + K["q2"] * d2).numpy()])
1779     lines.append(f"{'fields':10s} eps mean {e_all.mean():+.5f} rms {e_all.std():.5f} "
1780                  f"max|.| {np.abs(e_all).max():.5f};  "
1781                  f"delta mean {d_all.mean():+.5f} rms {d_all.std():.5f} "
1782                  f"max|.| {np.abs(d_all).max():.5f}   (uncapped)")
1783     guard_frac = np.mean(kc <= KAPPA_GUARD + 1e-12)
1784     lines.append(f"{'guard':10s} kappa_curv <= {KAPPA_GUARD} fraction = "
1785                  f"{guard_frac:.2e}  (numerical guard, must be 0)")
1786     if guard_frac > 0:
1787         lines.append(f"{'WARNING':10s} numerical guard active on data -- the "
1788                      f"converged model is not trustworthy; investigate.")
1789     all_pt = torch.cat([K["pt1"], K["pt2"], L["pt1"], L["pt2"]])
1790     uc = model.u_clamp_frac(all_pt)
1791     lines.append(f"{'feature':10s} u = 1/pT, u_ref = {model.u_ref:.4f}, "
1792                  f"u_scale = {model.u_scale:.4f} GeV^-1; |u_n| >= "
1793                  f"{model.u_clamp:.1f} clamp fraction = {uc:.2e} "
1794                  f"(kappa constant in 1/pT beyond the clamp)")
1795     txt = "\n".join(lines)
1796     print(txt)
1797     with open(os.path.join(outdir, "summary.txt"), "w") as f:
1798         f.write(txt + "\n")
1799 
1800     export_mass_histograms_root(K, L, mK, mL, outdir)
1801     plot_mass_1d(K, L, mK, mL, outdir)
1802     plot_mass_vs_kin_2d(K, L, mK, mL, outdir, "pt", meta["pt_plot"])
1803     plot_mass_vs_kin_2d(K, L, mK, mL, outdir, "eta", meta["pt_plot"])
1804     plot_alpha_vs_eta_1d(K, k1, k2, outdir)
1805     plot_alpha_vs_mass_2d(K, mK, k1, k2, outdir)
1806     plot_kappa_maps_vs_curv(model, outdir, meta["curv_slices"])
1807     plot_kappa_final_vs_curv(model, outdir, meta["curv_slices"])
1808 
1809 
1810 def resolution_diagnostics(net, glob, groups, outdir, meta, n_c=1, scalars=None):
1811     pw, raw_cores = plot_pull_closure(groups, net, glob, outdir)
1812     plot_width_overlay(groups, net, glob, outdir)
1813     plot_r_vs_pt(groups, net, glob, outdir, meta["pt_plot"])
1814     if net.mode == "absolute":
1815         plot_ab_maps(net, outdir)
1816         plot_r_maps_vs_curv(net, glob, outdir, meta["curv_slices"])
1817     else:
1818         plot_calibration_factor(net, glob, outdir)
1819 
1820     lines = ["", f"--- stage 2: momentum resolution ({net.mode} model) ---"]
1821     if net.mode == "pull":
1822         lines += ["# " + s for s in PULL_INTERPRETATION]
1823     for gi, g in enumerate(groups):
1824         core, rms = pw[g["name"]]
1825         with torch.no_grad():
1826             fg, kg = float(glob.f(gi)), float(glob.k(gi))
1827         exp_rms = math.sqrt(1 - fg + fg * kg**2)
1828         line = (f"{g['name']:10s} pull core width {core:6.4f}  RMS {rms:6.4f} "
1829                 f"(expected RMS from tail {exp_rms:5.3f})   "
1830                 f"f = {fg:6.4f}, k = {kg:5.3f}")
1831         if g["name"] in raw_cores and np.isfinite(raw_cores[g["name"]]):
1832             line += f"   raw-cov pull core {raw_cores[g['name']]:6.4f}"
1833         lines.append(line)
1834     with torch.no_grad():
1835         for i in range(n_c):
1836             lines.append(f"{'floor':10s} c[{i}] = {float(glob.c(i))*1e3:7.4f} MeV")
1837     with torch.no_grad():
1838         if net.mode == "absolute":
1839             aa, bb = [], []
1840             for g in groups:
1841                 for leg in ("1", "2"):
1842                     a, b = net.ab(g["R"]["eta" + leg], g["R"]["phi" + leg])
1843                     aa.append(a.numpy()); bb.append(b.numpy())
1844             aa = np.concatenate(aa); bb = np.concatenate(bb)
1845             lines.append(f"{'fields':10s} track-averaged a = {aa.mean():.5f} "
1846                          f"(spread {aa.std():.5f}),  b = {bb.mean():.5f} "
1847                          f"(spread {bb.std():.5f}) GeV^-1")
1848         else:
1849             up = glob.u_p()
1850             lines.append(f"{'exponent':10s} u_p = {float(up):+.5f} "
1851                          f"(0 = no residual pT shape on top of r_cov)")
1852             for g in groups:
1853                 R, ratio = g["R"], []
1854                 for leg in (1, 2):
1855                     s = net.s(R[f"eta{leg}"], R[f"phi{leg}"])
1856                     ratio.append((pull_r(R, leg, s, up) /
1857                                   R[f"rcov{leg}"]).numpy())
1858                 ratio = np.concatenate(ratio)
1859                 q16, q50, q84 = np.quantile(ratio, [0.16, 0.50, 0.84])
1860                 lines.append(f"{g['name']:10s} r_pred/r_cov median {q50:.4f}  "
1861                              f"[16%, 84%] = [{q16:.4f}, {q84:.4f}]   "
1862                              f"<- factor by which the reported momentum "
1863                              f"uncertainty is wrong")
1864     if scalars is not None:
1865         if net.mode == "absolute":
1866             lines.append(f"{'reduced':10s} constant-field fit: "
1867                          f"a = {scalars['a']:.5f} +- {scalars['sa']:.5f}, "
1868                          f"b = {scalars['b']:.5f} +- {scalars['sb']:.5f};  "
1869                          f"rho(a,b) = {scalars['rho_ab']:+.3f}, "
1870                          f"rho(a,c) = {scalars['rho_ac']:+.3f}"
1871                          f"{'' if scalars['pos_def'] else '   [Hessian NOT pos.def.]'}")
1872         else:
1873             lines.append(f"{'reduced':10s} constant-field fit: "
1874                          f"s = {scalars['s']:.5f} +- {scalars['ss']:.5f}, "
1875                          f"u_p = {scalars['u_p']:+.5f} +- {scalars['su']:.5f};  "
1876                          f"rho(s,u_p) = {scalars['rho_su']:+.3f}, "
1877                          f"rho(s,c) = {scalars['rho_sc']:+.3f}"
1878                          f"{'' if scalars['pos_def'] else '   [Hessian NOT pos.def.]'}")
1879         lines.append(f"{'':10s} NB: on data this reduced fit is misspecified "
1880                      f"(the field really depends on eta, phi); central values "
1881                      f"are an occupancy-weighted compromise and only the "
1882                      f"injection-toy errors are strictly interpretable.")
1883     txt = "\n".join(lines)
1884     print(txt)
1885     with open(os.path.join(outdir, "summary.txt"), "a") as f:
1886         f.write(txt + "\n")
1887 
1888 
1889 # ---------------------------------------------------------------- injection
1890 def _two_body_toy(M, m1, m2, cpt, ceta, cphi, rng):
1891     """Isotropic two-body decay boosted to the lab; truth mass exactly M."""
1892     pstar = math.sqrt((M**2 - (m1 + m2)**2) * (M**2 - (m1 - m2)**2)) / (2 * M)
1893     ct = rng.uniform(-1, 1, cpt.size)
1894     st = np.sqrt(1 - ct**2)
1895     ph = rng.uniform(-np.pi, np.pi, cpt.size)
1896     p1 = np.stack([pstar * st * np.cos(ph), pstar * st * np.sin(ph), pstar * ct], -1)
1897     p2 = -p1
1898     E1s = math.sqrt(pstar**2 + m1**2)
1899     E2s = math.sqrt(pstar**2 + m2**2)
1900 
1901     pc = np.stack([cpt * np.cos(cphi), cpt * np.sin(cphi), cpt * np.sinh(ceta)], -1)
1902     Ec = np.sqrt((pc**2).sum(-1) + M**2)
1903     bv = pc / Ec[:, None]
1904     g = Ec / M
1905     out = []
1906     for ps, Es in ((p1, E1s), (p2, E2s)):
1907         bp = (bv * ps).sum(-1)
1908         p = ps + (g**2 / (g + 1) * bp + g * Es)[:, None] * bv
1909         pt = np.hypot(p[:, 0], p[:, 1])
1910         eta = np.arcsinh(p[:, 2] / np.maximum(pt, 1e-12))
1911         phi = np.arctan2(p[:, 1], p[:, 0])
1912         out.append((pt, eta, phi))
1913     return out
1914 
1915 
1916 def run_injection(args, K, L, meta, outdir):
1917     """Mandatory go/no-go.  pull mode: recover (s0, u0) drawn against r_cov
1918     resampled from data.  absolute mode: recover (a0, b0)."""
1919     os.makedirs(outdir, exist_ok=True)
1920     rng = np.random.default_rng(args.inject_seed)
1921     mode = args.reso_model
1922     n = args.inject_n
1923 
1924     def rcov_pool(S, leg):
1925         return S[f"rcov{leg}"].numpy()
1926 
1927     def make(Sdata, M, m1, m2):
1928         idx = rng.integers(0, Sdata["cpt"].numel(), n)
1929         cpt = Sdata["cpt"].numpy()[idx]
1930         ceta = Sdata["ceta"].numpy()[idx]
1931         cphi = Sdata["cphi"].numpy()[idx]
1932         (pt1, eta1, phi1), (pt2, eta2, phi2) = _two_body_toy(M, m1, m2,
1933                                                              cpt, ceta, cphi, rng)
1934         d = dict(cpt=cpt, ceta=ceta, cphi=cphi, M=M, m1=m1, m2=m2,
1935                  eta1=eta1, phi1=phi1, eta2=eta2, phi2=phi2)
1936         if mode == "pull":
1937             rc1 = rng.choice(rcov_pool(Sdata, 1), n)
1938             rc2 = rng.choice(rcov_pool(Sdata, 2), n)
1939             r1 = args.inject_s0 * pt1**args.inject_u0 * rc1
1940             r2 = args.inject_s0 * pt2**args.inject_u0 * rc2
1941             d["rcov1"], d["rcov2"] = rc1, rc2
1942         else:
1943             def r_ab(pt, eta, m):
1944                 beta = beta_of(torch.tensor(pt), torch.tensor(eta), m).numpy()
1945                 return np.sqrt((args.inject_a0 / beta)**2 + (args.inject_b0 * pt)**2)
1946             r1 = r_ab(pt1, eta1, m1)
1947             r2 = r_ab(pt2, eta2, m2)
1948         d["pt1"] = np.maximum(pt1 * (1 + r1 * rng.standard_normal(n)), 1e-3)
1949         d["pt2"] = np.maximum(pt2 * (1 + r2 * rng.standard_normal(n)), 1e-3)
1950         d["dm"] = args.inject_c0 * rng.standard_normal(n)
1951         return d
1952 
1953     T = lambda x: torch.tensor(x, dtype=torch.float64)
1954 
1955     def to_S(d):
1956         S = dict(pt1=T(d["pt1"]), eta1=T(d["eta1"]), phi1=T(d["phi1"]),
1957                  pt2=T(d["pt2"]), eta2=T(d["eta2"]), phi2=T(d["phi2"]),
1958                  q1=torch.zeros(n, dtype=torch.float64),
1959                  q2=torch.zeros(n, dtype=torch.float64),
1960                  m1=d["m1"], m2=d["m2"], M=d["M"], dm=T(d["dm"]),
1961                  cpt=T(d["cpt"]), ceta=T(d["ceta"]), cphi=T(d["cphi"]))
1962         for key in ("rcov1", "rcov2"):
1963             if key in d:
1964                 S[key] = T(d[key])
1965         return S
1966 
1967     SK = to_S(make(K, M_K0S, PION_MASS, PION_MASS))
1968     SL = to_S(make(L, M_LAMBDA, PION_MASS, PROTON_MASS))
1969     SL["is_lam"] = torch.tensor(rng.random(n) < 0.5)
1970 
1971     idk = IdentityKappa()
1972 
1973     def fiducial(S, lo, hi):
1974         """Eta acceptance + mass window; NO pT cut, matching the data path."""
1975         with torch.no_grad():
1976             m, _, _ = mass_jacobians(S["pt1"], S["eta1"], S["phi1"], S["m1"],
1977                                      S["pt2"], S["eta2"], S["phi2"], S["m2"])
1978             m = m + S["dm"]
1979         sel = ((S["pt1"] > PT_SANITY) & (S["pt2"] > PT_SANITY) &
1980                (S["eta1"].abs() < ETA_RANGE[1]) & (S["eta2"].abs() < ETA_RANGE[1]) &
1981                (m > lo) & (m < hi))
1982         return {k: (v[sel] if torch.is_tensor(v) else v) for k, v in S.items()}
1983 
1984     SK = fiducial(SK, *WIN["K0s"])
1985     SL = fiducial(SL, *WIN["Lambda"])
1986     print(f"[inject] toy after selection: {SK['pt1'].numel()} K0s, "
1987           f"{SL['pt1'].numel()} Lambda(+bar)")
1988 
1989     groups = build_groups(idk, SK, SL, pure_signal=True)
1990     n_c = set_c_indices(groups, args.c_per_resonance)
1991 
1992     net, glob = train_resolution(groups, args, n_c=n_c)
1993     scal = fit_global_reduced(groups, mode, n_c=n_c, steps=args.inject_scalar_steps)
1994 
1995     resolution_diagnostics(net, glob, groups, outdir, meta, n_c=n_c, scalars=scal)
1996 
1997     if mode == "pull":
1998         pa = (scal["s"] - args.inject_s0) / max(scal["ss"], 1e-12)
1999         pb = (scal["u_p"] - args.inject_u0) / max(scal["su"], 1e-12)
2000         rec = [f"injected  s0 = {args.inject_s0:.5f}   u0 = {args.inject_u0:+.5f}   "
2001                f"c0 = {args.inject_c0*1e3:.4f} MeV",
2002                f"recovered s   = {scal['s']:.5f} +- {scal['ss']:.5f}  pull {pa:+.2f}",
2003                f"recovered u_p = {scal['u_p']:+.5f} +- {scal['su']:.5f}  pull {pb:+.2f}",
2004                f"recovered c   = {', '.join(f'{x*1e3:.4f}' for x in scal['c'])} MeV "
2005                f"(injected {args.inject_c0*1e3:.4f}; rho(s,c) = {scal['rho_sc']:+.3f})"]
2006     else:
2007         pa = (scal["a"] - args.inject_a0) / max(scal["sa"], 1e-12)
2008         pb = (scal["b"] - args.inject_b0) / max(scal["sb"], 1e-12)
2009         rec = [f"injected  a0 = {args.inject_a0:.5f}   b0 = {args.inject_b0:.5f} GeV^-1   "
2010                f"c0 = {args.inject_c0*1e3:.4f} MeV",
2011                f"recovered a  = {scal['a']:.5f} +- {scal['sa']:.5f}  pull {pa:+.2f}",
2012                f"recovered b  = {scal['b']:.5f} +- {scal['sb']:.5f}  pull {pb:+.2f}",
2013                f"recovered c  = {', '.join(f'{x*1e3:.4f}' for x in scal['c'])} MeV "
2014                f"(injected {args.inject_c0*1e3:.4f}; rho(a,c) = {scal['rho_ac']:+.3f})"]
2015 
2016     with torch.no_grad():
2017         pw = {}
2018         for g in groups:
2019             sig = sigma_mass_net(g["R"], net, glob, glob.c(g["c_idx"]))
2020             pl = ((g["R"]["m"] - g["M"]) / sig).numpy()
2021             pw[g["name"]] = core_width(pl, g["w"].numpy())[1]
2022 
2023     lines = ["", f"--- injection campaign (go/no-go, {mode} model) ---"] + rec + \
2024             ["pull core widths: " + ", ".join(f"{k} {v:.4f}" for k, v in pw.items())]
2025     ok = (abs(pa) < 3 and abs(pb) < 3 and
2026           all(np.isfinite(v) and abs(v - 1) < 0.05 for v in pw.values()))
2027     lines.append(f"VERDICT: {'PASS' if ok else 'FAIL'}")
2028     txt = "\n".join(lines)
2029     print(txt)
2030     with open(os.path.join(outdir, "summary.txt"), "a") as f:
2031         f.write(txt + "\n")
2032     return ok
2033 
2034 
2035 # ---------------------------------------------------------------- main
2036 def main():
2037     ap = argparse.ArgumentParser()
2038     ap.add_argument("--kshort", default="kshort_kfparticle.root",
2039                     help="K0s KFParticle nTuple (.root) or legacy CSV")
2040     ap.add_argument("--lam", default="lambda_kfparticle.root",
2041                     help="Lambda KFParticle nTuple (.root) or legacy CSV")
2042     ap.add_argument("--tree", default="DecayTree", help="TTree name in the ROOT files")
2043     ap.add_argument("--max_candidates", type=int, default=0,
2044                     help="use at most N candidates per species (0 = all), "
2045                          "random subsample")
2046     ap.add_argument("--subsample_seed", type=int, default=42)
2047     ap.add_argument("--outdir", default="calib_out")
2048     ap.add_argument("--lookup_pt_min", type=float, default=0.10,
2049                     help="lower pT [GeV] covered by kappa lookup and kappa-map diagnostics")
2050     ap.add_argument("--lookup_pt_max", type=float, default=5.0,
2051                     help="upper pT [GeV] covered by kappa lookup and kappa-map diagnostics")
2052     ap.add_argument("--kappa_slices", type=int, default=25,
2053                     help="number of curvature slices in fig6/fig7 kappa diagnostics")
2054     # stage 1
2055     ap.add_argument("--epochs", type=int, default=400)
2056     ap.add_argument("--lr", type=float, default=2e-3)
2057     ap.add_argument("--hidden", type=int, default=48)
2058     ap.add_argument("--refresh", type=int, default=50, help="weight-refresh cadence")
2059     ap.add_argument("--lam_prior", type=float, default=0.05,
2060                     help="L2(eps,delta) weight at epoch 0; annealed linearly to 0")
2061     ap.add_argument("--lam_split", type=float, default=1.0)
2062     ap.add_argument("--lam_alpha", type=float, default=2.0)
2063     ap.add_argument("--load_stage1", default=None,
2064                     help="existing model.pt (must carry the v4 convention tag; "
2065                          "older checkpoints are rejected)")
2066     # stage 2
2067     ap.add_argument("--no_reso", action="store_true", help="stage 1 only", default=False)
2068     ap.add_argument("--reso_model", choices=("absolute", "pull"), default="pull",
2069                     help="absolute: measure (a,b) fields from peak widths; "
2070                          "pull: calibrate the KFParticle covariance, "
2071                          "r = s(eta,phi)*pT^u_p*r_cov (needs Covariance branches)")
2072     ap.add_argument("--reso_epochs", type=int, default=300)
2073     ap.add_argument("--reso_lr", type=float, default=2e-3)
2074     ap.add_argument("--reso_hidden", type=int, default=32)
2075     ap.add_argument("--reso_layers", type=int, default=3)
2076     ap.add_argument("--c_per_resonance", action="store_true",
2077                     help="separate mass-width floor for K0s and Lambda")
2078     ap.add_argument("--reso_scalar_steps", type=int, default=1500,
2079                     help="reduced global-scalar fit steps (for Hessian errors)")
2080     # injection
2081     ap.add_argument("--inject", action="store_true",
2082                     help="append the go/no-go toy campaign after the data pass")
2083     ap.add_argument("--inject_only", action="store_true",
2084                     help="run the toy campaign and skip the data pass")
2085     ap.add_argument("--inject_n", type=int, default=200000)
2086     ap.add_argument("--inject_s0", type=float, default=1.30,
2087                     help="pull mode: injected covariance miscalibration factor")
2088     ap.add_argument("--inject_u0", type=float, default=0.0,
2089                     help="pull mode: injected pT exponent")
2090     ap.add_argument("--inject_a0", type=float, default=0.010,
2091                     help="absolute mode: injected MS-like coefficient")
2092     ap.add_argument("--inject_b0", type=float, default=0.012,
2093                     help="absolute mode: injected curvature coefficient [1/GeV]")
2094     ap.add_argument("--inject_c0", type=float, default=0.0015,
2095                     help="injected mass-width floor [GeV]")
2096     ap.add_argument("--inject_seed", type=int, default=1234)
2097     ap.add_argument("--inject_scalar_steps", type=int, default=1500)
2098     args = ap.parse_args()
2099 
2100     os.makedirs(args.outdir, exist_ok=True)
2101     K, L, meta = load(args)
2102     print(f"loaded {len(K['mass'])} K0s, {len(L['mass'])} Lambda candidates")
2103 
2104     if args.inject or args.inject_only:
2105         ok = run_injection(args, K, L, meta, os.path.join(args.outdir, "injection"))
2106         if not ok:
2107             print("[inject] FAILED -- do not deploy the resolution map. "
2108                   "Debug before running on data.")
2109         if args.inject_only:
2110             return
2111 
2112     # ---- stage 1: momentum scale
2113     if args.load_stage1:
2114         model = load_stage1(args.load_stage1, hidden=args.hidden)
2115         print(f"loaded stage-1 model from {args.load_stage1} "
2116               f"(convention '{CONVENTION_TAG}' verified)")
2117         with torch.no_grad():
2118             mK, _, _ = corrected_mass(model, K)
2119             mL, _, _ = corrected_mass(model, L)
2120         _, _, pK = fit_peak(mK.numpy(), M_K0S, 0.010, 0.42, 0.58)
2121         _, _, pL = fit_peak(mL.numpy(), M_LAMBDA, 0.004, 1.09, 1.145)
2122         K["w"] = torch.tensor(signal_weights(mK.numpy(), pK))
2123         L["w"] = torch.tensor(signal_weights(mL.numpy(), pL))
2124     else:
2125         model = train(args, K, L, meta)
2126         save_stage1(model, os.path.join(args.outdir, "model.pt"))
2127 
2128     export_lookup(model, meta, os.path.join(args.outdir, "kappa_lookup.csv"))
2129     diagnostics(model, K, L, meta, args.outdir)
2130 
2131     if args.no_reso:
2132         print(f"outputs written to {args.outdir}/  (stage 1 only)")
2133         return
2134 
2135     # ---- stage 2, KappaNet frozen
2136     for p in model.parameters():
2137         p.requires_grad_(False)
2138     model.eval()
2139 
2140     groups = build_groups(model, K, L, pure_signal=False)
2141     n_c = set_c_indices(groups, args.c_per_resonance)
2142     print(f"stage 2 ({args.reso_model}): {len(groups)} resonance groups, "
2143           f"{n_c} floor parameter(s)")
2144     net, glob = train_resolution(groups, args, n_c=n_c)
2145     scal = fit_global_reduced(groups, args.reso_model, n_c=n_c,
2146                               steps=args.reso_scalar_steps)
2147 
2148     torch.save({"reso_net": net.state_dict(), "globals": glob.state_dict(),
2149                 "mode": args.reso_model, "n_c": n_c,
2150                 "groups": [g["name"] for g in groups]},
2151                os.path.join(args.outdir, "reso_model.pt"))
2152     export_reso_lookup(net, glob, groups,
2153                        os.path.join(args.outdir, "reso_lookup.csv"), n_c=n_c)
2154     resolution_diagnostics(net, glob, groups, args.outdir, meta,
2155                            n_c=n_c, scalars=scal)
2156 
2157     print(f"outputs written to {args.outdir}/")
2158 
2159 
2160 if __name__ == "__main__":
2161     main()