You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Review complete. I fanned out subagents across the five major subsystems, then personally verified every load-bearing claim against the source β discarding several false positives along the way (e.g. "_TensorViewer caches the backend" β no, its pyhf.default_backend use is intentional setup-time index math with a proper tensorlib_changed subscription; and "ToyCalculator re-fits redundantly" β the pre-fits generate toys, the per-toy fits compute the test statistic; both are required). Everything below is verified unless marked otherwise. Nothing was modified.
Bugs
Likely worth fixing
src/pyhf/optimize/mixins.py:87,105 β latent NameError in _internal_postprocess.num_fixed_pars is defined only inside if uncertainties is not None: but used unconditionally in the if correlations is not None: block. Today minuit sets both unc and corr so it never fires, but any result with correlations and no uncertainties crashes. Hoist the assignment above both blocks.
src/pyhf/events.py:51-75 β dead plain-function callbacks are called as None(...). For bound methods, __call__ checks the __self__ weakref and _flush() prunes dead ones. But plain functions are stored as (weakref.ref(callback), None): _flush keeps every arg is None entry forever, and __call__ line 75 invokes func()(*args) without checking, so a garbage-collected function (e.g. a locally-defined subscriber, common in tests) raises TypeError: 'NoneType' object is not callable on the next set_backend.
src/pyhf/parameters/utils.py:46-62 β two defects in reduce_paramsets_requirements. (a) After combined_paramset[k].pop(), the continue for "undefined" leaves an empty set as the value for every attribute the modifier doesn't support; downstream only survives because paramset constructors test truthiness (if sigmas:). (b) The elif ordering compares user lists against the literal string "undefined", so configuring an unsupported attribute (e.g. sigmas on a normsys parameter) with anything other than exactly 9 elements yields "expected 9" instead of the intended "does not use the sigmas attribute" error. Check default_v == "undefined" first and del the key on skip.
src/pyhf/constraints.py:138 (and the poisson twin ~:259) β broken no-constraint fallback.tensorlib.astensor(0.0)[0] indexes a 0-d array and raises IndexError on both backends. Unreached via Model.logpdf (which goes through make_pdf), but anyone calling *_constraint_combined.logpdf directly with batch_size=None and no constraints crashes. Drop the [0].
src/pyhf/readxml.py:218 β KeyError on missing Activate.modtag.attrib["Activate"] hard-indexes while every sibling attribute uses .get(...) with a default; a <StatError> without Activate kills the whole XML import with a raw KeyError. Use .get("Activate", "False").
CLI file-handle leaks β src/pyhf/cli/infer.py:103,199, src/pyhf/cli/rootio.py:97.json.loads(click.open_file(pfile, ...).read()) never closes the handle, while all ~15 neighboring sites correctly use with. Harmless under CPython refcounting, but inconsistent and warns under -X dev/PyPy.
src/pyhf/readxml.py:535-537 β clear_filecache() drops uproot files without closing them, and the module-global __FILECACHE__ holds every opened ROOT file open for the life of the process. Close handles before clearing.
src/pyhf/patchset.py:179 β vestigial sentinel keys._patches_by_key = {"name": {}, "values": {}} but patches are stored flat in the same dict. Lookups work, but a patch literally named "name" or "values" falsely triggers the duplicate-name error, and patchset["name"] returns {} instead of raising InvalidPatchLookup. Initialize to {}.
assert used for input/model validation β modifiers/histosys.py:107, modifiers/normsys.py:75 (user-supplied interpcodes; exceptions.InvalidInterpCode exists for exactly this), and modifiers/staterror.py:122 (guards the mask-equality invariant that the later [-1] indexing relies on). All vanish under python -O, turning bad input into silently wrong results.
Minor
src/pyhf/pdf.py:990-995 β the logpdf exception handler logs tensorlib.tolist(data); if astensor(data) was what raised (ragged input), tolist on the raw input can raise again inside the handler and mask the original error.
src/pyhf/infer/test_statistics.py:19 β docstring says "is 0 if muhat > 0" β should be "muhat > mu".
src/pyhf/optimize/mixins.py:190-192 β mutates pdf.config.par_names in place. Safe for pyhf models (the property rebuilds the list per access) but corrupts any duck-typed config that returns a stored list; copy first.
Performance
src/pyhf/workspace.py:54-79 (_join_items) β repeated secondary_item[key] in keys and double keys.index(...) make joins O(nΒ·m); the comment even admits it. A {key: index} dict makes it linear; note keys is also never updated after appends, which only stays correct because workspace specs forbid duplicate names.
src/pyhf/modifiers/shapesys.py:152-159 / staterror.py:173-180 β default_backend.astensor(self._mask) re-tensorizes the entire (mods Γ samples Γ bins) mask inside the per-systematic loop at model build; hoist it out.
src/pyhf/modifiers/shapesys.py:120-132 β self.__shapesys_info builds a full (mods, samples, 3, bins) tensor that is never read anywhere (grep-verified). Delete it; it's both dead code and a build-time cost.
src/pyhf/modifiers/staterror.py:92-106 β per-(sample, bin) Python loop computing relerrs over already-numpy arrays; vectorize with np.where.
src/pyhf/modifiers/shapefactor.py:174-180 β element-wise triple loop doing single-element numpy assignments to populate the access field; vectorizable per (systematic, batch).
src/pyhf/parameters/paramview.py:~90 β _precompute re-tensorizes index arrays with the default float dtype although they were built as dtype="int", forcing int re-casts in every consumer (constraints, shapesys/staterror reindex). Pass dtype="int".
Simplifications
Modifier duplication (the biggest cleanup opportunity): normfactor_builder, shapefactor_builder, and lumi_builder are byte-identical except for the required_parset they call; shapesys_combined and staterror_combined share ~80 near-identical lines (__init__, _reindex_access_field, _precompute, apply); histosys/shapesys/staterror share the concatenate-and-validate finalize block. A small shared base class would remove several hundred lines. While at it, unify the inconsistent (batch, npars) if batch else (npars,) vs (batch or 1, npars) parfield-shape conventions.
Interpolators: _precompute_alphasets in code1.py and code4.py duplicates _precompute verbatim, and code4's six-row A_inverse matrix literal appears twice β the _slow_code4 copy rebuilt on every scalar call. (Agent-reported with quoted code; spot-checked pattern, not line-by-line.)
src/pyhf/tensor/manager.py:157-167 β bitwise | between boolean comparisons wrapped in bool(...); use or.
Small dead code: constraints.py:151self.par_indices is never used (the gaussian twin doesn't have it); modifiers/shapesys.py:~190 calls get_backend() twice back-to-back; modifiers/normfactor.py:102-111 duplicates self.param_viewer.get(pars) in both branches (only the einsum subscript differs).
Python version window (pyproject.toml:14,35, noxfile.py:7): requires-python = ">=3.9" but 3.9 EOL'd October 2025; the noxfile still lists 3.8, which is below even the declared floor; classifiers stop at 3.13 with no 3.14 (released October 2025). Per SPEC 0, NumPy ecosystem packages would be on >=3.11 by now. Bumping the floor would also unlock dropping some from __future__ import annotations workarounds and older typing fallbacks.
src/pyhf/schema/validator.py:82 β still on the deprecated jsonschema.RefResolver (warning suppressed in pyproject.toml:182, tracked as issue jsonschema v4.18.0 will introduce breaking changesΒ #2139). Migrating to the referencing registry API and bumping the jsonschema>=4.15 floor to >=4.18 closes that out.
The dependency floors, JAX usage (jax.jit/value_and_grad), iminuit v2 API, and importlib.resources patterns all checked out as current β no other modernization debt found.
If you'd like, I can turn the high-confidence, low-risk subset (items 1β9, 13β15, 21β23) into a fix branch with tests.
π€ AI text below π€
Review complete. I fanned out subagents across the five major subsystems, then personally verified every load-bearing claim against the source β discarding several false positives along the way (e.g. "
_TensorViewercaches the backend" β no, itspyhf.default_backenduse is intentional setup-time index math with a propertensorlib_changedsubscription; and "ToyCalculator re-fits redundantly" β the pre-fits generate toys, the per-toy fits compute the test statistic; both are required). Everything below is verified unless marked otherwise. Nothing was modified.Bugs
Likely worth fixing
src/pyhf/optimize/mixins.py:87,105β latentNameErrorin_internal_postprocess.num_fixed_parsis defined only insideif uncertainties is not None:but used unconditionally in theif correlations is not None:block. Today minuit sets bothuncandcorrso it never fires, but any result with correlations and no uncertainties crashes. Hoist the assignment above both blocks.src/pyhf/events.py:51-75β dead plain-function callbacks are called asNone(...). For bound methods,__call__checks the__self__weakref and_flush()prunes dead ones. But plain functions are stored as(weakref.ref(callback), None):_flushkeeps everyarg is Noneentry forever, and__call__line 75 invokesfunc()(*args)without checking, so a garbage-collected function (e.g. a locally-defined subscriber, common in tests) raisesTypeError: 'NoneType' object is not callableon the nextset_backend.src/pyhf/parameters/utils.py:46-62β two defects inreduce_paramsets_requirements. (a) Aftercombined_paramset[k].pop(), thecontinuefor"undefined"leaves an empty set as the value for every attribute the modifier doesn't support; downstream only survives because paramset constructors test truthiness (if sigmas:). (b) Theelifordering compares user lists against the literal string"undefined", so configuring an unsupported attribute (e.g.sigmason a normsys parameter) with anything other than exactly 9 elements yields "expected 9" instead of the intended "does not use the sigmas attribute" error. Checkdefault_v == "undefined"first anddelthe key on skip.src/pyhf/constraints.py:138(and the poisson twin ~:259) β broken no-constraint fallback.tensorlib.astensor(0.0)[0]indexes a 0-d array and raisesIndexErroron both backends. Unreached viaModel.logpdf(which goes throughmake_pdf), but anyone calling*_constraint_combined.logpdfdirectly withbatch_size=Noneand no constraints crashes. Drop the[0].src/pyhf/readxml.py:218βKeyErroron missingActivate.modtag.attrib["Activate"]hard-indexes while every sibling attribute uses.get(...)with a default; a<StatError>withoutActivatekills the whole XML import with a rawKeyError. Use.get("Activate", "False").CLI file-handle leaks β
src/pyhf/cli/infer.py:103,199,src/pyhf/cli/rootio.py:97.json.loads(click.open_file(pfile, ...).read())never closes the handle, while all ~15 neighboring sites correctly usewith. Harmless under CPython refcounting, but inconsistent and warns under-X dev/PyPy.src/pyhf/readxml.py:535-537βclear_filecache()drops uproot files without closing them, and the module-global__FILECACHE__holds every opened ROOT file open for the life of the process. Close handles before clearing.src/pyhf/patchset.py:179β vestigial sentinel keys._patches_by_key = {"name": {}, "values": {}}but patches are stored flat in the same dict. Lookups work, but a patch literally named"name"or"values"falsely triggers the duplicate-name error, andpatchset["name"]returns{}instead of raisingInvalidPatchLookup. Initialize to{}.assertused for input/model validation βmodifiers/histosys.py:107,modifiers/normsys.py:75(user-supplied interpcodes;exceptions.InvalidInterpCodeexists for exactly this), andmodifiers/staterror.py:122(guards the mask-equality invariant that the later[-1]indexing relies on). All vanish underpython -O, turning bad input into silently wrong results.Minor
src/pyhf/pdf.py:990-995β thelogpdfexception handler logstensorlib.tolist(data); ifastensor(data)was what raised (ragged input),toliston the raw input can raise again inside the handler and mask the original error.src/pyhf/infer/test_statistics.py:19β docstring says "is 0 if muhat > 0" β should be "muhat > mu".src/pyhf/optimize/mixins.py:190-192β mutatespdf.config.par_namesin place. Safe for pyhf models (the property rebuilds the list per access) but corrupts any duck-typed config that returns a stored list; copy first.Performance
src/pyhf/workspace.py:54-79(_join_items) β repeatedsecondary_item[key] in keysand doublekeys.index(...)make joins O(nΒ·m); the comment even admits it. A{key: index}dict makes it linear; notekeysis also never updated after appends, which only stays correct because workspace specs forbid duplicate names.src/pyhf/modifiers/shapesys.py:152-159/staterror.py:173-180βdefault_backend.astensor(self._mask)re-tensorizes the entire (mods Γ samples Γ bins) mask inside the per-systematic loop at model build; hoist it out.src/pyhf/modifiers/shapesys.py:120-132βself.__shapesys_infobuilds a full(mods, samples, 3, bins)tensor that is never read anywhere (grep-verified). Delete it; it's both dead code and a build-time cost.src/pyhf/modifiers/staterror.py:92-106β per-(sample, bin) Python loop computingrelerrsover already-numpy arrays; vectorize withnp.where.src/pyhf/modifiers/shapefactor.py:174-180β element-wise triple loop doing single-element numpy assignments to populate the access field; vectorizable per (systematic, batch).src/pyhf/parameters/paramview.py:~90β_precomputere-tensorizes index arrays with the default float dtype although they were built asdtype="int", forcing int re-casts in every consumer (constraints, shapesys/staterror reindex). Passdtype="int".Simplifications
normfactor_builder,shapefactor_builder, andlumi_builderare byte-identical except for therequired_parsetthey call;shapesys_combinedandstaterror_combinedshare ~80 near-identical lines (__init__,_reindex_access_field,_precompute,apply); histosys/shapesys/staterror share the concatenate-and-validatefinalizeblock. A small shared base class would remove several hundred lines. While at it, unify the inconsistent(batch, npars) if batch else (npars,)vs(batch or 1, npars)parfield-shape conventions._precompute_alphasetsin code1.py and code4.py duplicates_precomputeverbatim, and code4's six-rowA_inversematrix literal appears twice β the_slow_code4copy rebuilt on every scalar call. (Agent-reported with quoted code; spot-checked pattern, not line-by-line.)src/pyhf/tensor/manager.py:157-167β bitwise|between boolean comparisons wrapped inbool(...); useor.constraints.py:151self.par_indicesis never used (the gaussian twin doesn't have it);modifiers/shapesys.py:~190callsget_backend()twice back-to-back;modifiers/normfactor.py:102-111duplicatesself.param_viewer.get(pars)in both branches (only the einsum subscript differs).pdf.py:_prameter_objects(line 169),finalizd_builder_data(line 151).Modernizations
pyproject.toml:14,35,noxfile.py:7):requires-python = ">=3.9"but 3.9 EOL'd October 2025; the noxfile still lists 3.8, which is below even the declared floor; classifiers stop at 3.13 with no 3.14 (released October 2025). Per SPEC 0, NumPy ecosystem packages would be on >=3.11 by now. Bumping the floor would also unlock dropping somefrom __future__ import annotationsworkarounds and older typing fallbacks.src/pyhf/schema/validator.py:82β still on the deprecatedjsonschema.RefResolver(warning suppressed in pyproject.toml:182, tracked as issue jsonschema v4.18.0 will introduce breaking changesΒ #2139). Migrating to thereferencingregistry API and bumping thejsonschema>=4.15floor to>=4.18closes that out.The dependency floors, JAX usage (
jax.jit/value_and_grad), iminuit v2 API, andimportlib.resourcespatterns all checked out as current β no other modernization debt found.If you'd like, I can turn the high-confidence, low-risk subset (items 1β9, 13β15, 21β23) into a fix branch with tests.