Skip to content

pyhf review from Claude FableΒ #2706

Description

@henryiii

πŸ€– 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. "_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

  1. 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.

  2. 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.

  3. 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.

  4. 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].

  5. 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").

  6. 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.

  7. 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.

  8. 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 {}.

  9. 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

  1. 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.
  2. src/pyhf/infer/test_statistics.py:19 β€” docstring says "is 0 if muhat > 0" β€” should be "muhat > mu".
  3. 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

  1. 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.
  2. 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.
  3. 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.
  4. src/pyhf/modifiers/staterror.py:92-106 β€” per-(sample, bin) Python loop computing relerrs over already-numpy arrays; vectorize with np.where.
  5. 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).
  6. 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

  1. 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.
  2. 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.)
  3. src/pyhf/tensor/manager.py:157-167 β€” bitwise | between boolean comparisons wrapped in bool(...); use or.
  4. Small dead code: constraints.py:151 self.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).
  5. Typos worth fixing while touching pdf.py: _prameter_objects (line 169), finalizd_builder_data (line 151).

Modernizations

  1. 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.
  2. 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.

Metadata

Metadata

Labels

No labels
No labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions