Skip to content

Differentiable SCETlib prediction as a rabbit parameter model - #715

Draft
lucalavezzo wants to merge 24 commits into
WMass:mainfrom
lucalavezzo:scetlib-ad-param-model
Draft

Differentiable SCETlib prediction as a rabbit parameter model#715
lucalavezzo wants to merge 24 commits into
WMass:mainfrom
lucalavezzo:scetlib-ad-param-model

Conversation

@lucalavezzo

@lucalavezzo lucalavezzo commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

What

A new package wremnants/postprocessing/scetlib_ad/ providing a rabbit ParamModel
in which every theory parameter SCETlib exposes is a continuous fit parameter with
exact derivatives
, instead of a discrete template morph whose joint response with
the others is an outer product.

parameter group count (Z) cache/runcard flag it needs
alphaS 1 has_as for the PDF-consistent version (see below)
nonperturbative λ (Collins–Soper + TMD) 8
theory nuisance parameters 10 a non-off [TNPs] block
resummation kappa_R 1 diff_scales
matching transition points x1..x3 3 diff_scalessign-inverted, see Validation
kappa_F 1 diff_scales and has_muf (inert without it)
PDF eigenvector coefficients n_eig n_eig > 0

Which of these exist is a property of the cache, not of this code: the model
reads gradient_param_names() and registers what it finds.

The prediction comes from the SCETlib autodiff-sigmaul branch, whose
ScetlibCachedXsecTF replays a prepared cache (compressed bin rules for the
resummed piece + a frozen fixed-order grid for the nonsingular) and returns exact
first and second derivatives from clad.

How it works

1  SCETlib cached rule replay  ->  sigma(p; g)       boson level, gen grid
2  fold through the response R ->  sigma_reco(p; b)  gen -> reco
3  ratio to the reference      ->  rnorm(b, proc)    handed to rabbit

There is no surrogate: autodiff differentiates the real prediction.
ScetlibCachedXsecTF is an ordinary TF-differentiable function whose backward pass
is itself a custom_gradient contracting Hessian-vector products, so nested
GradientTapes work and TF drives every C++ call. The model just calls it inside
the graph, exactly as examples/matched_ad/tf_gradients.py does. An earlier
revision injected a local quadratic behind stop_gradient instead; that path was
removed (62fb5881, "one differentiation path, no fallback") — there is now one
path, so there is nothing to keep in sync.

That imposes one requirement which is easy to break by accident: map rabbit's fit
vector into SCETlib's layout with a constant 0/1 matrix multiply, never
tensor_scatter_nd_update.
rabbit's vector holds only the fitted parameters, POIs
first, while SCETlib's holds every registered parameter in registry order, so some
mapping is unavoidable. A scatter's backward pass contains a gather, whose gradient
TF represents as tf.IndexedSlices, and the bridge's second-order py_function
payloads call .numpy() on the incoming cotangent and fail on it — so anything past
first order breaks. The matmul is bit-identical (entries are exactly 0 and 1) and
free at these sizes (at most ~25 × 25).

Other notes:

  • One rabbit job. Second derivatives come from the bridge's own HVP
    contraction, so the composite Hessian is exact and fit + postfit covariance run
    together. Confirmed on the Asimov fits below: one job, edmval ~ 1e-27.
  • --jitCompile off is required (XLA cannot compile a PyFunc) and is
    enforced at construction with an actionable message.
  • GenFold sums cache bins onto the card's gen grid — handling a different
    nesting order, a signed-Y cache folded onto |Y|, and a cache finer than the fit
    binning — and verifies every gen bin is exactly tiled rather than assuming it.
    Note it does not normalise the Y convention out of the values it returns: a
    positive-side-only cache yields half the |Y| cross section. That cancels in the
    ratio the fit uses, and does not cancel if you compare absolutely.

Profile scales as unit nuisances

set_diff_scales(1) makes kappa_R and the transition points differentiable. But
their templates encode multiplicative (κ_R: 0.5 / 1 / 2) or asymmetric
(x2: 0.35 / 0.6 / 0.75) steps, which a symmetric Gaussian prior on the physical
value cannot express. So params.REPARAM maps them to unit nuisances —
exp(θ·ln2) for the scales, a quadratic for the transition point — and TF
differentiates the map, so the chain rule keeps gradient and Hessian exact.

The map itself is verified bit-exact (0.000e+00): θ = −1/0/+1 land on the
physical values, and the analytic Jacobian column times dκ/dθ equals TF's AD
gradient per bin, with dκ/dθ = 0.6931471806 = ln2 and 0.2 respectively. (The map
being right is not the same as the underlying scale response being right — see the
transition points below.)

Methodology note for anyone re-testing this: do not validate these
derivatives with a central difference across the anchor. The value surrogate has a
knot there (c_val forces exactness at the anchor), so a symmetric difference
averages two different slopes and the error is flat in h over four decades —
which looks like a real failure and is not. Compare against the analytic Jacobian.

Validation

Against a validated SCETlib production run and the histmaker, on the analysis
binning (Z, ptll × yll, 210 gen bins, cache_aspair):

test result
cached rules vs a live SCETlib evaluation (small cache) 6e-15 value, 1.5e-15 gradient, 3.4e-14 Hessian
σ_gen vs the production driver, full space 7.4e-06
σ_reco vs the histmaker's corrected reco, shape 0.128% yield-weighted
σ_reco vs indata.norm, absolute (no rescaling) 0.149%, total 0.998847
injection closure (small cache) exact, every other parameter unmoved, EDM 6e-22

Per-variation response against the Corr templates, all 37 labels the reference
carries (validate_variations.py), as max|dev| / mean|dev|:

block agreement verdict
20 TNP variations 2.2e-16 … 7.4e-04 / 2e-18 … 3.3e-05 excellent
8 NP λ variations 4.6e-04 … 4.9e-03 / 9e-06 … 2.1e-04 good
mufdown / mufup 2.9e-03 / 1.4e-02 usable, worth understanding
kappaFO2.-kappaf0.5 4.5e-03 good
kappaFO0.5-kappaf2. 4.0e-02 the κ_R down direction is 10× worse than up
3 transition_points* 1.1e-01 … 2.0e-01 WRONG SIGN — see below

The transition-point directions disagree in sign with their templates. All three
move the prediction the opposite way from the reference:

label                          model rng        ref rng
transition_points0.2_0.35_1.0  [1.0000,1.1593]  [0.9602,1.0000]   model UP,   ref DOWN
transition_points0.2_0.75_1.0  [0.8957,1.0000]  [1.0000,1.0207]   model DOWN, ref UP
transition_points0.3_0.6_0.9   [0.8971,1.0103]  [0.9990,1.0135]   model DOWN, ref UP

The third moves x1/x3 rather than x2, so this is not a mapping slip in the
validation table, and the reparametrisation map is separately verified bit-exact.
The central values match the templates' (x = 0.2, 0.6, 1.0). Leading suspect is a
convention difference between set_diff_scales and the production
transition_points setting. Until it is resolved, resumTransition* must not be
floated for a physics result
— and note that the σ(α_s) in fit B below did float
resumTransition2, so its number and its ρ are provisional.

Also worth flagging: the κ_R down direction agreeing only to 4% matters more than it
looks, because κ_R is the direction that dominates σ(α_s).

Asimov reco fits

-t -1, real card, login-node CPU, one job each:

floating edmval saturated 2ΔNLL / ndof wall σ(α_s)
6 (α_s + 5 λ) 9.7e-28 0.0 / 774 46 min 6.16e-04
8 (+ κ_R, x2 — x2 provisional) 9.7e-28 0.0 / 772 58 min 1.81e-03

Every parameter returns exactly at truth, and the Hessian is finite and sensible.

ρ(α_s, resumScaleMuR) = +0.927. κ_R and α_s both set the strength of the
resummed logs, so they trade off almost freely in the qT shape — floating κ_R as a
continuous nuisance costs a factor 2.9 on σ(α_s). The κ_R treatment is therefore
the dominant choice for the α_s uncertainty, more than any NP λ. (Also
ρ(λ2, λ2_ν) = −0.97, the two low-qT damping knobs being near-degenerate as
expected.) Neither σ(α_s) is quotable: these caches carry no PDF eigenvectors.

Two bugs this validation caught

The AD path initially disagreed with a validated production run by 1–3%. Every
earlier test compared the AD path against itself (exact to 1e-15), so none of them
could see it. Running the same configuration through SCETlib's own production driver
split it into two independent bugs whose product reproduced the discrepancy to 1e-5
in every bin:

  1. Ours — the cache builder configured the calculation without
    configure_ew_parameters / configure_fiducial_volumes /
    configure_calculation, so it silently used SCETlib's default EW inputs rather
    than the runcard's. Flat +1.61%. Fixed here.
  2. UpstreamDrellYan::operator()'s ad::Node_shared was hoisted out of the
    node loop, so every node reused node 0's shared sub-expressions. qT-dependent,
    ±1.4%. Reported and cherry-picked upstream as b919b61 (which also turned up
    a second occurrence).

After both: A/driver = 0.000e+00, B_cacheON/driver = 3.028e-06.

Cache contents are load-bearing — read this before quoting anything

The model registers what the cache has, and a cache built with --no-pdf is missing
directions silently unless the card still carries the templates:

  • has_as = 0α_s is a derivative at fixed PDF. The α_s pair rides on the
    existing alphas slot so one parameter moves the calculation and the PDF;
    without it, do not quote α_s. --no-pdf now says so out loud.
  • has_muf = 0resumScaleMuF has an identically zero derivative. It is
    refused at fit time rather than silently doing nothing.
  • n_eig = 0 → no PDF uncertainty from the model, so the card's pdf* templates
    must be kept.

Measured build cost at 210 bins: rules 9.0 min, FO warm 20.6 min, and 19.4 min per
PDF member
for the fixed-order variations. The α_s + μF cache (4 members) is
~1.9 h; the full 29 eigenvector pairs (62 members) is ~20 h. The member loop is
the only serial axis left — the bin loop inside it is already _parallel_run — so
that is what sharding would have to split.

Not yet done

  • The transition-point sign inversion above. Highest priority; it gates
    floating resumTransition* at all.
  • No PDF eigenvector cache yet (the ~20 h build, plus beamfunc grids for 58
    members). Until then the card must keep pdf*.
  • TNPs are not floated by defaultfit_params excludes resumTNP_*. They
    work when asked for; the default is deliberate, not an oversight.
  • Below qT ≈ 2 GeV the model's nonsingular cutoff (0.1 GeV) differs from the
    reference's (--qtCutoff 1.0). It largely cancels in the ratio the fit uses, and
    is being handled separately.
  • Real-data fits, impacts, and a comparison of the continuous κ_R treatment against
    the discrete resumFOScale templates it would replace.

Changes to the shared Z dilepton histmaker

Most of this PR is a self-contained package, but five files are shared and one of
them changes a default. Flagging it explicitly so it can be objected to.

--responseGenBinning now defaults to theoryCorr. It adds two histograms to a
Z dilepton unfolding run: the reco x gen response on the theory correction's own gen
grid (770 gen bins for the Z — 70 qT bins to 100 GeV x 11 |Y| bins, |Y| truncated
at the reco yll edge 2.5) and the gen total on that same grid. The grid is read from
the correction file at runtime; no bin count is hardcoded.

Who pays for it, and how much:

  • Nothing changes for a default run. The block is inside if args.unfolding:, and
    --unfolding is store_true/False, as is --poiAsNoi. A mz_dilepton run that
    passes neither never reaches the flag.
  • The only runs affected are Z dilepton poi-as-noi unfolding histmakers — which are
    the runs that want a response matrix in the first place.
  • mz_dilepton is Z-only, so no W histmaker is touched (those use
    mw_with_mu_eta_pt.py).
  • Cost: 2.49 MB of an 86.2 MB output (2.9%), measured on
    260826_Z_histmaker_respgrid by summing the two histograms' stored datasets
    (nominal_prefsr_yieldsResponse 2.47 MB, prefsr_response 0.02 MB). They are
    23.5 MB in memory and compress ~9.5x because the reco x gen matrix is very sparse.
  • --responseGenBinning none opts out in one flag.

The two preconditions are no longer hard errors when the flag is on only by default:
--poiAsNoi off means there is no reco x gen histogram to put on a finer grid, so the
response is skipped with a warning rather than failing a run that never asked for it.
Passing --responseGenBinning theoryCorr explicitly still raises, so an explicit
request is never silently downgraded.

--responseGenPtVExtend (the qT > 100 extension, 803 bins) stays PROVISIONAL and
off by default: the corrections do not exist on the wider grid yet, so the MC would be
uncorrected there while the model predicts a corrected cross section.

Notes for review

  • The package is standalone: no code dependency on the scetlib_np package of
    SCETlib NP continuous parameter model #701. The two scetlib_np strings in response.py are datacard conventions
    (the auxiliary group name and the metadata key existing cards carry) and are
    overridable via response_group=. One optional script,
    compare_to_np_model.py, imports scetlib_np lazily — that script exists to
    cross-check the two models, so it is the one place the dependency is intended.
  • Guards for the failure modes that are otherwise silent: cache/card binning
    mismatch, cache anchor vs the card's recorded nonperturbative values, template
    nuisances that would double-count a fitted parameter (regex-based, so ^pdf\d+
    catches the eigenvectors without catching pdfAlphaS), TNPs fitted without
    priors, and any parameter whose Jacobian column is identically zero.
  • The diff is 22 files. 17 are the package itself; the other five are shared and
    are called out in "Changes to the shared Z dilepton histmaker" below.
  • Linting: the three repo-wide commands CI actually runs (isort --profile black --line-length 88, flake8 --max-line-length 88 with the F-code select list, and
    black --check) are clean in the CI container. pylint is not installed locally,
    so the pre-commit hook's final step could not be run — CI's lint job does not run
    pylint either.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c

lucalavezzo and others added 24 commits August 19, 2026 10:52
Adds `wremnants/postprocessing/scetlib_ad/`, a rabbit ParamModel in which every
theory parameter SCETlib exposes is a continuous fit parameter with exact
derivatives, rather than a discrete template morph whose joint response with the
others is an outer product: alpha_s, the 8 nonperturbative lambdas, the 10 theory
nuisance parameters, and PDF eigenvector coefficients. Which of these exist is a
property of the cache, not of the code -- the model reads
`gradient_param_names()` and registers what it finds. Only the profile-scale
parameters (kappaFO, kappaf, muf, transition points) are outside SCETlib's
autodiff and still need template nuisances.

The prediction comes from the SCETlib `autodiff-sigmaul` branch, whose
`ScetlibCachedXsecTF` replays a prepared cache -- compressed bin rules for the
resummed piece plus a frozen fixed-order grid for the nonsingular -- and returns
exact first and second derivatives from clad.

Design notes:

* Derivatives are injected, not traced. SCETlib returns value, Jacobian and
  exact Hessian from C++, so instead of differentiating through the py_function
  boundary the model hands autodiff an exact local quadratic
  `stop_gradient(val) + J.d + 0.5 d^T K d` with `d = p - stop_gradient(p)`.
  Value, first and second derivative are then exact at the evaluation point while
  everything downstream stays pure TF. This also keeps the PyFunc behind
  stop_gradient, so `GradientTape.jacobian` never re-enters C++ once per fit
  parameter. `differentiate=through` selects the alternative so the claim is
  checkable; the two agree on the gradient to 1.3e-16.
* One rabbit job. The exact second-derivative term is always included, so the
  composite Hessian is exact and the fit and postfit covariance run together.
* `--jitCompile off` is required (XLA cannot compile a PyFunc) and enforced at
  construction with an actionable message.
* `GenFold` sums cache bins onto the card's gen grid, handling a different
  nesting order, a signed-Y cache folded onto |Y|, and a cache finer than the
  fit's binning; it verifies every gen bin is exactly tiled rather than assuming.
* Guards for the failure modes that are otherwise silent: cache/card binning
  mismatch, cache anchor vs the card's recorded nonperturbative values, template
  nuisances that would double-count a fitted parameter, TNPs fitted without
  priors, and any parameter whose Jacobian column is identically zero.

Validated on a small cache: injection closure is exact in alpha_s and the
lambdas, the cached rules reproduce a live SCETlib evaluation to 1e-15 (values),
1.5e-15 (gradient) and 3.4e-14 (Hessian), and TNPs fit with priors.

Scripts under `scripts/rabbit/scetlib_ad/` build a cache for a card's binning,
check a cache standalone, build a self-contained closure card, and validate the
resummed piece against a native SCETlib production run. `conf/` carries runcards
reproducing the analysis configuration -- note that the analysis order is defined
by the `[TNPs]` block, so an analysis-faithful cache has 19 parameters, not 9.

Not yet exercised: the reco fold path, and any production-binning cache.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The production runs carry an off-peak mass bin (10-60) alongside the 60-120 one,
so summing the reference's Q axis would have compared our mass window against a
wider one. Select the bin whose edges match the cache's window and fail loudly if
there is none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
rabbit's fit vector is not SCETlib's: it holds only the fitted parameters, POIs
first, while SCETlib's holds every registered parameter in registry order. The
model has to map between them inside the differentiated graph, and it was doing so
with tensor_scatter_nd_update.

That op's backward pass contains a gather, whose gradient TF represents as
tf.IndexedSlices, and the SCETlib bridge's second-order py_function payloads call
.numpy() on the incoming cotangent -- so anything past first order failed with
"'IndexedSlices' object has no attribute 'numpy'". Isolated to the scatter itself:
a nested-tape HVP works on a bare Variable, and fails with a scatter in front even
when the scatter covers the whole vector.

Replacing it with a multiplication by a constant 0/1 selection matrix is
bit-identical (the entries are exactly 0 and 1) and negligible at these sizes
(at most ~25 x 25), and TF's matmul gradient rule always yields a dense cotangent.

This makes differentiate=through usable, which turns the straight-through default
from an assertion into a measured claim: the two now agree to 1.3e-16 on the
gradient, 4e-15 on HVPs and 2.4e-17 on the Hessian, and a full fit driven either
way returns the same alphaS and the same uncertainty on every parameter. Second
order had no cross-check at all before this.

Straight-through stays the default on cost rather than correctness, since rabbit
builds the postfit Hessian as t2.jacobian(grad, self.x) over the whole fit vector
and pfor cannot vectorise a PyFunc; the reasoning is now written down in the
module docstring and the README.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Now that the fit vector is mapped with a dense matmul, letting TF differentiate
the SCETlib call is not only possible but the better default: it is the ordinary
TF idiom, it is what examples/matched_ad/tf_gradients.py does, and on a
6-parameter gen-level card it is faster -- 15.8 s of fit time against 40.9 s --
with identical postfit values and uncertainties and a slightly better EDM.

straightthrough is kept, because the two scale oppositely in the number of FIT
parameters (counting every datacard nuisance, not just ours). rabbit builds the
postfit Hessian as t2.jacobian(grad, self.x) and pfor cannot vectorise a PyFunc,
so `through` costs one C++ HVP sweep per fit parameter, while `straightthrough`
pays one value+Jacobian and one full Hessian per distinct parameter point whatever
the count. With an HVP at ~2.5x a gradient and a materialised Hessian at ~40x, the
crossover is a few tens of parameters: `through` for a gen-level fit,
`straightthrough` if a reco card with hundreds of nuisances makes the postfit
Hessian the bottleneck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Differentiate through the SCETlib bridge and nothing else. The straight-through
surrogate, the `differentiate` option and the mode checker are removed: with the
dense-matmul mapping in place, letting TF differentiate the real prediction works
at every order, is the ordinary TF idiom, and is faster at these parameter counts,
so carrying a second path that agrees with the first to 1e-16 was surface without
a reason.

The one requirement it imposes is now documented where it can be seen rather than
guarded by a flag: map rabbit's fit vector into SCETlib's layout with a constant
0/1 matrix multiply, never tensor_scatter_nd_update, or everything past first order
breaks while first order keeps working.

Fit unchanged: alphaS 0.1195 +/- 0.00045, same uncertainty on every parameter,
EDM 4.7e-21, and 12.8 s of fit time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
configure() called configure_calculation() and set_vary() but not
configure_ew_parameters() or configure_fiducial_volumes(). Neither is part
of configure_calculation; prod/scetlib_run/scetlib-run-qT.py -- the path
every production correction was made with -- calls both.

So the runcard's [Electroweak] block was silently ignored and SCETlib's
defaults used instead. On the analysis card, which sets mZ = 91.1535,
GammaZ = 2.4932 and custom alphaem / sin2_thw / CKM, that is a flat 1.61%
normalization error, essentially independent of qT.

Measured against the production driver on the reference runcard read
verbatim (calculation_piece = sing), Q [60,120], Y [1.0,1.5]:

  before:  operator() / driver = 1.01637 .. 1.01664 across qT 2 -> 10
  after:   operator() / driver = 1.00000000 (bit-identical, max dev 0.0)

examples/matched_ad/prepare_cache.py upstream has the same omission; the
docstring now says why this must not be "simplified" back to match it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Everything SCETlib exposes is now a continuous fit parameter, so the
corresponding card templates can be dropped instead of double-counted.

Backend (xsec_backend.py):
- configure() gains diff_scales (registers scale_kappa_R, scale_x1..x3 and an
  inert scale_kappa_F slot via set_diff_scales(1)) and fo_resolve_muR (resolves
  the fixed-order muR dependence into the frozen grid, so the FO piece follows
  kappa_R in closed form). fo_resolve_muR must be set BEFORE the grid is built
  and costs ~3x on the warm.
- diff_scales REFUSES muf_follows_muB = yes: with muf tied to muB a live
  kappa_R would move muF while the beam convolutions stay frozen at their own.
- cache_param_names() peeks the npz 'names' array without loading the cache, so
  the calculation is configured the way the cache expects rather than the way we
  would prefer. A mismatch is otherwise a hard load failure -- the fingerprint
  hashes the names in order.

Cache builder (prepare_cache_for_card.py):
- --pdf-eig / --as-pair / --no-muf / --no-pdf / --grid-jobs, and
  build_variations() reusing the upstream helpers.
- The alphaS pair rides on the EXISTING alphas slot, so one parameter moves the
  calculation and the PDF together. Without it alphas is a derivative at FIXED
  PDF, which --no-pdf now says out loud: do not quote alphaS from such a cache.

Parameters (params.py) and model (param_model.py):
- Name maps for the scales and the PDF eigenvector coefficients, plus
  resumScale / resumTransition impact groups and pdf_group().
- REPARAM: the profile scales become UNIT nuisances, since their templates
  encode multiplicative (kappa_R: 0.5/1/2) or asymmetric (x2: 0.35/0.6/0.75)
  steps that a symmetric Gaussian on the physical value cannot express. The map
  is exp(theta*ln2) or a quadratic; TF differentiates it so the chain rule keeps
  the gradient and Hessian exact. Verified bit-exact against the analytic
  Jacobian at theta = -1, 0, +1.
- Construction asserts theta = 0 reproduces the anchor to 1e-12, so a mistyped
  map cannot silently shift the start point.
- _check_double_counting refuses to float a direction whose card templates are
  still present (regex, so ^pdf\d+ catches the eigenvectors without catching
  pdfAlphaS), and _check_no_inert_params refuses a parameter with an identically
  zero derivative -- which is what scale_kappa_F is unless the cache was built
  with the muF member pair, and which would otherwise make the covariance
  singular.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
validate_reco.py -- the model's sigma_reco against the histmaker's corrected
reco hist, folded through the response exactly as the fit does. Documents the
four traps this comparison has: R sums helicitySig while N_gen takes UL, the
ptVGen [44,100] bin is an OVERFLOW (it holds qT > 100 while sigma_SC stops
there), the two sides differ by pb-vs-fb so the plots density-normalise, and
hist.project() on a cropped hist silently re-adds the flow. Closes at 0.128%
yield-weighted. --reference card compares against indata.norm's signal column
instead (sliced start:stop, not [:nbins]), --no-match-norm drops the global
scale so the ABSOLUTE normalisation is tested (0.149%), and --y-fold defaults
off the GenFold's own y_convention -- a positive-side-only cache holds HALF the
|Y| cross section, which cancels in the ratio construction and does not cancel
absolutely.

validate_variations.py -- every variation the model produces against the
corresponding template from the Corr file, for all 38 labels. 28 of 38 agree at
1e-6..1e-8 above qT ~ 4; the residual is concentrated at low qT, where the
nonsingular cutoff differs from the reference (ours 0.1 GeV vs --qtCutoff 1.0).

compare_to_np_model.py -- AD model vs the scetlib_np model. Their centrals are
NOT required to agree (different nonsingulars, and the fit only ever uses the
ratio to each model's own central); what must agree is the response to a shared
parameter.

compare_cards.py -- row-sum audit and event-vs-matrix granularity between two
cards, needing neither cache nor SCETlib. Written for the deferred
uncorrected-histmaker route, but the row-sum audit stands alone as a check that
marginalisation, cropping and axis order are right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
…tion status

The docstring still claimed the profile-scale parameters "are outside SCETlib's
autodiff and still need template nuisances". That has been false since
set_diff_scales(1) was wired in: kappa_R and x1..x3 are differentiable, and
kappa_F has a slot that is inert unless the cache carries the muF member pair.

Replaced with the actual status, which is NOT uniform across the scale
directions and should not be summarised as "validated":
- TNPs reproduce their templates to 1e-4..1e-16, the NP lambdas to ~1e-3;
- kappa_R reproduces kappaFO2.-kappaf0.5 to 4.5e-03 but kappaFO0.5-kappaf2.
  only to 4.0e-02, i.e. the down direction is 10x worse than the up direction,
  and that is the direction driving sigma(alpha_s) (rho = +0.93);
- all three transition_points variations move the prediction the OPPOSITE way
  from their templates (model [1.0000,1.1593] vs reference [0.9602,1.0000], and
  likewise for the other two). The one that moves x1/x3 rather than x2 inverts
  too, so it is not a mapping slip in the validation table, and the
  reparametrisation map is separately verified bit-exact. Suspect a convention
  difference between set_diff_scales and the production transition_points
  setting. Documented as: do not float resumTransition* for a physics result
  until resolved.

Also drops a comment referring to "the differentiate=through path", an option
removed in 62fb588.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
max|dev| alone cannot tell a low-qT cutoff artefact from a broken response, and
the two need completely different follow-up. Adds a "worst qT" column (always)
and --profile (the qT profile of max|dev| over |Y|, with a bar chart).

It immediately separates three different failure modes that all looked like one
number before:

- lambdas and TNPs: the residual is ONLY the low-qT feature. lambda21.0 goes
  4.9e-03 at [0,1] -> 2.2e-03 -> 5.3e-04 -> 1.8e-04 and is at 1e-05 by 5 GeV,
  1e-06 by 20. s1. has the same shape at 7.1e-04. Nothing to fix in the
  response; this is the known nonsingular cutoff mismatch.
- kappa_R: the low-qT feature PLUS a broad shoulder. kappaFO0.5-kappaf2. is
  4.0e-02 at [0,1] but stays at 5e-03..9e-03 through 3-7 GeV and ~1e-03 out to
  14 GeV -- 10-100x the lambda residual at the same qT, so something beyond the
  cutoff is wrong.
- muF: the low-qT feature PLUS a FLAT ~2e-04 pedestal at every qT out to 100,
  which is an offset in the response rather than a low-qT artefact.
- transition points: EXACTLY 0.00e+00 below 12 GeV, then monotonically rising to
  1.99e-01 at [33,44]. Not a low-qT problem at all -- it fails precisely where
  the resummed -> fixed-order matching lives, which is consistent with these
  being the matching transition points, and with the sign inversion already
  documented in param_model's docstring.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The main CorrZ carries no alphaS and no PDF variations at all -- they live in
two sidecars produced by the same job, `*_pdfas_CorrZ` and `*_pdfvars_CorrZ`.
Validating those two directions therefore needs more than one reference file,
so --corr now takes a list and the per-file work moved into _one_file().

Their labels do not follow the main file's convention either, so they are
resolved by pattern rather than enumerated:

  * `pdfCT18ZNNLO_as_0116` / `ALPHAS_116` (HERAPDF spells it differently)
    -> alphas = 0.116. NB `_as_0118` is that file's CENTRAL, not a variation,
    which central_label() has to know or every ratio comes out against the
    wrong denominator.
  * `pdf0` is the central of the other file and `pdf(2i+1)`/`pdf(2i+2)` are
    eigenvector i up/down, i.e. c_e = +-1 by construction of
    build_pdf_variations. Reported as skipped, not silently passed, when the
    cache was built with n_eig = 0.

Measured with these: alphaS reproduces its template to 2.0e-03 (up) and
2.4e-03 (down), worst in the lowest qT bin, which is the same low-qT feature
the scale directions show and not an alphaS-specific problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The transition-point response we hand the fitter has the wrong sign, and the
cause is upstream rather than in this model. Making the identical physical
change through the runcard with set_diff_scales off reproduces the production
template to 2e-6; making it by moving the registered scale_x2 parameter with
set_diff_scales(1) gives 0.966985 where the template says 1.159163 at
qT [33,44] -- opposite sign, roughly -7x in slope, and linear from zero.

Mechanism: the transition points move muF by ~20% (muF has its own profile over
the same points) while the per-node beam convolutions stay frozen at the
config's muF -- conv_probe shows they shift 7-16% over that range. kappa_R
escapes it because set_muR_factor holds muF fixed by construction. It is not
fixable from Python: SCETlib's muF machinery interpolates a GLOBAL member while
the induced shift is per node, and DrellYan.hpp:586 shows a per-node dconv was
already considered and rejected. Eliminated by measurement along the way: the
REPARAM map, the label mapping in the validation table, the shared
formulas::f_run, the ported node scalars (node_scalars_probe agrees to 0.00e+00),
the separately inlined node_value, the compressed bin rules, calculation_piece,
the frozen nonsingular, and make_theory_corr.

So resumTransition2 joins 1 and 3 in DEFAULT_FROZEN. That is a KNOWN GAP, not a
fix: it drops the transition-point uncertainty from the fit. It is still
preferable to profiling a nuisance whose response points the wrong way, which
biases the POI rather than merely mis-sizing an error. Floating one anyway stays
possible -- that is how the upstream fix will be tested -- but now prints a
warning, driven by a KNOWN_BAD_RESPONSE table so the reason travels with the
name instead of living only in a comment.

Also drops the blank line isort wants gone in param_model's import block, which
is what the linting CI job has been failing on. NB run isort from a checkout
with submodules populated: in a linked worktree `rabbit/` is empty, isort then
classifies it third-party, and "fixing" the file there produces exactly the
grouping CI rejects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
set_pdf_eig_params was never called on the sub-pieces before the rules were
built, so --pdf-eig > 0 -- the DEFAULT -- built for hours and then died inside
the extension. SCETlib signposts this correctly ("call set_pdf_eig_params on
this piece before ..."), we simply never called it, which is why every cache so
far has n_eig = 0. Now called on both pieces at build time, and again at load
from the cache's own parameter names, since it configures the CALCULATION rather
than anything the file can carry.

Also in prepare_cache_for_card.py:
  * the mandatory step-one prepare() before build_bin_rules, plus a drift check
    that raises if the matched values move
  * --subset for contiguous (y, qT) test caches
  * --members LO:HI to build one shard, with a .shard.json sidecar
  * plan_variations() split out of build_variations() as the single source of
    the canonical member order [eig pairs, alphaS dn/up, muF lo/hi]

build_cache_parallel.py is new: a pure-Python parser and splicer for both blobs,
merging along the BIN axis (validated to 0.000e+00 in value and Jacobian) and
along the member axis within a single build (byte-identical round trip).

Members must NEVER be split across processes. The builder is not reproducible:
four builds of the same configuration gave 357/359/359/371 nodes/bin, with site
counts differing in 9 of 10 bins, because the range splitting depends on the
worker count and each thread keeps a private integrator. A merge across builds
would be silently wrong -- the loaders check only settings, struct sizes and
version. The member stage is node-parallel anyway (145 of 200 cores busy), so
raise --threads or split BINS; forking members is ~90x slower per member.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
…the transition

validate_variations.py gains --partial, comparing only the gen bins the cache
actually tiles and always reporting how many were excluded, so a subset cache
can be validated without pretending it covered the grid. It also plots the
central comparison, with the rapidity-convention factor applied explicitly
rather than divided out silently.

params.py: resumTransition2 leaves DEFAULT_FROZEN now that its derivative works
upstream, and KNOWN_BAD_RESPONSE is emptied. resumTransition1/3 stay frozen as a
physics choice, not a workaround.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Theory corrections stay applied IN the histmaker, with the param model supplying
only the ratio for the variations (settled 2026-08-25). compare_cards.py existed
to compare a corrected against an uncorrected card, which is the configuration
we are not pursuing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The build was 1863 lines of WRemnants with almost nothing WRemnants in it, and
the two modules depended on each other in BOTH directions (the scheduler ran the
builder as a subprocess while the builder imported the scheduler for its merge).
Three pieces now, with the seam where the knowledge lives:

  * the cache FILE -> scetlib-cms/py/scetlib_cache.py (TF-free writer, blob
    readers, bin and member merges). Merging two rule blobs is an operation on
    SCETlib's own serialisation format using knowledge of SCETlib's internals;
    if the layout changes, their CI now finds out instead of us getting wrong
    answers.
  * the BUILDER -> scetlib-cms/examples/matched_ad/prepare_cache.py, driven by
    an explicit bin list.
  * the WRAPPER -> here: gen axes off the rabbit card, the runcard, --subset,
    orchestration, thread budgeting. 849 -> 523 and 1014 -> 319 lines.

`main()` deliberately stays in the wrapper. knot_scan/ and transition_knots/
rebind plan_variations/build_variations and then call main(); had main() moved
upstream those rebinds would be SILENTLY IGNORED -- a build finishing with
default knots while its log claims the override. Steps resolve through
_resolve_steps() (globals first, upstream second) and fork_member_build takes
build_fn/write_fn so a rebound step reaches forked children.

Also drops TensorFlow from the build path, which was the point of the exercise
for wall clock: _import_scetlib() fetched sl_config, sl_variations AND
ScetlibCachedXsecTF from one try:, so configure() -- the first call any build
makes -- pulled TF. _import_cached_xsec() is now separate and evaluation-only.
Measured on a real 2-bin build, one change apart: tensorflow imported yes->no,
modules 5165->848, peak RSS 1404->681 MB, threads at exit 203->90. The
TF_NUM_*_THREADS workaround is no longer needed for a build.

Production path proven unchanged rather than assumed: the call-diff harness
extended 3 -> 7 configurations, all SAME; the writer byte-identical except three
bytes of uninitialised Bin_rule_opts padding, which two runs of the OLD writer
also differ in; backend_check identical but for timings; bin merge 0.000e+00
against both parents in value and Jacobian with the arms provably separated;
member merge 9 arrays byte-identical; and the cross-build guard still refuses,
now with a test showing two independent builds agree on everything the C++
loaders check while their nominal rules differ byte for byte.

Upstream half is MR !10; no C++ touched, so sizeof(ad::GlobalData) is unmoved
and every cache on disk stays loadable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Two related changes, both additive and both off by default.

`mz_dilepton --responseGenBinning theoryCorr` writes two EXTRA histograms,
nominal_prefsr_yieldsResponse and prefsr_response, on the theory correction's own
gen grid, beside the untouched unfolding pair. The unfolding path is proven
unaffected: with -j 1 so fill order cannot differ, 464 histograms bit-identical,
0 differing, 0 axis changes, 0 lost, exactly 2 new. `get_unfolding_dilepton_axes`
gains `edges_override` so a second, finer set of gen axes can be built without
touching the binning unfolding uses.

`ptZgen_binning_corr` gains 110, 130 and 250 above 100. make_theory_corr rebins
its inputs to their COMMON binning, so the single [100, 1300] cell collapsed the
whole region regardless of what the other inputs carried. 250 is where the yield
stops RECONSTRUCTING rather than where MiNNLO stops (~1 TeV): the analysis' own
muon window pushes both muons past 60 GeV by qT ~ 130, and reco efficiency runs
0.019 at [100,110], 0.0011 at [120,130] and exactly 0 above 250. The top edge
stays 1300 so nothing leaves the normalisation. The W binning is untouched.

Why it matters, stated honestly: the correction is NOT ~1 where it stops -- it
runs 1.02 at qT 46-48, 0.93 at 80-90 and back up to 0.94 at 90-100 with a 13.8%
envelope, while the flow bin is exactly 1.0 over all 2106 overflow cells. But
today's fit cannot see it (gen qT > 100 feeds 1.6e-07 of the fit's yield, ~ten MC
events); it bites at reco ptll [44,100], i.e. the day the reco range goes past
44 GeV. This is a correctness move, not a bias fix.

Agreement between the two grids is enforced in code rather than by convention:
check_gen_grid_vs_correction() refuses a straddling edge or an edge past the
correction's support, called from the histmaker and from the cache side, with the
card carrying corr_generator so the cache-side check happens automatically.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
CI's black --check flagged scripts/histmakers/mz_dilepton.py and
wremnants/production/theory_corrections.py, which skipped every analysis
job behind it. Formatting only, no behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
setupRabbit imported the response loader from
wremnants.postprocessing.scetlib_np, which this branch does not carry --
so --storeResponseMatrix (and any import of setupRabbit) was broken on
the branch. Move the module into scetlib_ad, where the card path that
uses it lives.

load_R and has_response are unchanged; the only edit is dropping the
cross-package import of GEN_AXES/RECO_AXES in favour of carrying the
same tuples inline, so scetlib_ad does not depend on the superseded
scetlib_np.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
A Hessian PDF member is not 1 sigma: CT18Z's are 90% CL, and the
analysis may inflate on top. add_pdf_uncertainty applies
pdf_inflation_factor * pdfMap[set]["scale"] to the templates; the model
has to apply the same product or it inflates the PDF uncertainty by
1.645.

params.pdf_set_key / pdf_coeff_scale read the factor out of
theory_utils.pdfMap (never hard coded, and raising on an unknown set
rather than silently returning 1). param_model resolves it from the
runcard's own pdf_set plus the card's own noi, and applies it to the
COEFFICIENT rather than the response: I(c) is exactly quadratic in c, so
theta = +-1 evaluates SCETlib at the 68% CL point in eigenvector space,
which is what a 1 sigma PDF displacement is. Pass pdf_coeff_scale=1 to
switch it off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
The default fit_params excluded the theory nuisance parameters, on the
grounds that floating ten of them should be an explicit choice. That was
the wrong default: the TNPs ARE the resummation theory uncertainty of
this prediction, an analysis-faithful runcard registers all ten, and
leaving them fixed understates the uncertainty -- the more dangerous of
the two failure modes. They are not unconstrained either; each carries an
N(0,1) constraint by construction.

So the default set is now every registered direction except the frozen
shape constants, which makes it the same set as fit_params="all" ("all"
is kept: it is explicit at call sites and appears in the meta_info of
every fit run before this change). With poi_params still "alphaS", the
default configuration is now alpha_s as the POI and everything else
fitted with priors.

priors therefore has to default to True as well -- _setup_priors raises
when a TNP floats with priors off, so the first change alone would make
every default invocation fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Floating the TNPs by default does not construct: b_qqDS scales a channel
that does not contribute to the Z, so its whole response is O(1e-16) and
its Jacobian column is identically zero. A zero column is a zero row and
column of the NLL Hessian, i.e. a singular covariance, and
_check_no_inert_params refuses it -- so every default invocation raised
and would have had to pass fit_params explicitly.

It has to be frozen rather than "fitted with a prior": a prior would
regularise the singularity away while still reporting a parameter the
data cannot constrain at all.

The default configuration is unchanged in intent -- alpha_s the sole POI,
everything with a non-zero response floating with a prior -- and now
comes out at 47 of 53 floating, priors on 46, which matches the validated
explicit-fit_params configuration name for name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
…orr is for

Two unrelated fixes in the same argument block.

The getDatasets call had been left at a local debugging hack --
`extended=False` with the real expression commented out. That silently
changes which datasets every dilepton run loads, for everyone, and has
nothing to do with this PR. Restored to the base's
`extended="msht20an3lo" not in args.pdfs`.

--responseGenBinning's help described what the theoryCorr grid IS but not
what it is FOR. Say plainly that it is the binning the alpha_s analysis
uses, and why it is nevertheless not the default: it requires --poiAsNoi
and at least one --theoryCorr, which a plain unfolding run need not pass.
The default stays "none"; flipping it would break those runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
…tead of raising

The finer response grid is what the alpha_s analysis uses, so make it the
default. Its two preconditions stop being hard errors when it is on only
by default:

  - --theoryCorr already defaults to three non-empty entries
    (parsing.py), so that guard essentially never fires.
  - --poiAsNoi is store_true/False, and it is the real one: outside the
    poi-as-noi path there is no reco x gen histogram to put on a finer
    grid, only the gen total. A default-on flag must not hard-error such
    a run, so warn, write no response histograms, and carry on.

An EXPLICIT --responseGenBinning theoryCorr still raises: a user who
asked for the response deserves an error rather than a silent downgrade.
Implemented with an argparse sentinel default of None, resolved right
after parse_args, and args.responseGenBinning is set to "none" when
skipped so meta_info records what was actually done.

Scope of the new default, measured rather than assumed: the block is
inside `if args.unfolding:` and --unfolding is store_true/False, so a
default mz_dilepton run never reaches the flag. The only runs affected
are Z dilepton poi-as-noi unfolding histmakers -- the runs that want a
response matrix anyway -- and mz_dilepton is Z-only, so no W histmaker is
touched. Cost there, measured on 260826_Z_histmaker_respgrid: the two
histograms are 2.49 MB of an 86.2 MB output (2.9%), 23.5 MB in memory
before compression. --responseGenBinning none opts out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnJ9YKK8c1q1sCDouM6y1c
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant