# brainnet -- SimNIBS's neural networks for brain anatomy: TopoFit cortical # surface reconstruction and TREGA affine template registration, with the # trained weights shipped in-tree. # # WHY IT IS HERE: it is the last link that unblocks SimNIBS's `charm`. # python-pytorch-ignite -> python-brainsynth -> python-brainnet -> simnibs # Until this package existed, `charm --help` died with # ModuleNotFoundError: No module named 'brainnet' # at simnibs/segmentation/brain_surface.py line 13, and `charm`, # `simnibs.mesh_tools.meshing` and `simnibs.optimization.opt_struct` were all # unusable -- i.e. SimNIBS worked only on the pre-built head models we ship, # never on a subject's own MRI. # # Class 1 under docs/PACKAGING-POLICY.md: Fedora ships no package by this source # name. Verified 2026-09-28: # dnf repoquery --repo=fedora --repo=updates 'python*brainnet*' -> empty # dnf repoquery --whatprovides 'python3dist(brainnet)' -> empty # # *** DO NOT PACKAGE THIS FROM PyPI. *** # PyPI's `brainnet` is UNRELATED software: version 0.0.01, MIT, a "library for # neural networks" from github.com/CryptoNyxz. Packaging that by reflex would # ship the wrong program under the right name. This comes from the git tag in # github.com/simnibs/brainnet and nowhere else. SimNIBS 4.6.0's # tools/environment.py pins exactly GitHubCommit("brainnet", "v0.2", # user="simnibs"). %global srcname brainnet Name: python-%{srcname} Version: 0.2 Release: 1%{?dist} Summary: Neural networks for brain anatomy used by SimNIBS charm # Repository licence, GPL-3.0: verbatim boilerplate, placeholders unfilled, no # per-file headers anywhere in the tree, no "or any later version" statement -- # hence -only, matching python-cortech.spec and python-brainsynth.spec for the # sibling projects. GitHub's own licence detection agrees (spdx_id GPL-3.0). # # THE TRAINED WEIGHTS. docs/packaging-model-weights.md says do not infer the # weights' licence from the repository's, because python-nninteractive is a # case in the same field where the code was Apache-2.0 and the checkpoints were # CC-BY-NC-SA-4.0. So this was looked for specifically, on 2026-09-28, and here # is exactly what was and was not found. # # NOT found -- no model card; no README section about the models (the README is # eleven lines and is entirely about compiling the CUDA extension); no LICENSE, # NOTICE or terms file under brainnet/resources/ or resources/models/; no # licence text embedded in the .pt files (they are plain torch zip archives of # data.pkl plus tensor blobs); no per-file header anywhere. # # FOUND -- two independent statements, and no statement anywhere that # contradicts them: # 1. The repository ships one LICENSE, GPL-3.0, at its root, with the weights # inside it and no carve-out. # 2. SimNIBS's own 3RD-PARTY.md -- upstream's licence inventory for everything # it bundles -- records "brainnet ... GNU General Public License (GPL), # Version 3 or later". That document is granular enough to record # field-of-use limits when they exist: its ADMlib entry says "GPL-v2.0, for # non-commercial and academic purposes only". A non-commercial restriction # on these weights had an obvious place to appear and does not appear. # That is Pattern A in docs/packaging-model-weights.md, so the weights ship. # # WHAT IS STILL OPEN, and it is Morgan's call, not this spec's. The weights' # licence is nowhere stated *separately*, and their training data is not # ours: brainnet/config/train_parameters.py names ABIDE, ADHD200, ADNI3, AIBL, # Buckner40, Chinese-HCP, HCP and OASIS3, with ground-truth surfaces from # FreeSurfer 7.4.1 recons. Each of those corpora has its own data use # agreement, and Buckner40 is distributed with FreeSurfer under the FreeSurfer # licence. Those agreements restrict the DATA, and a trained model is not the # data -- but nobody upstream has said so in writing. The weights are split # into python3-%%{srcname}-data precisely so that, if the answer comes back # restricted, only that subpackage moves to mhough/neurofedora-nonfree and the # code package is unaffected. Request drafted in # docs/upstream/brainsynth-brainnet-packaging.md. License: GPL-3.0-only URL: https://github.com/simnibs/%{srcname} Source0: %{url}/archive/v%{version}/%{srcname}-%{version}.tar.gz # Pure Python plus data. No compiled object is built: see the CUDA note below. BuildArch: noarch BuildRequires: python3-devel BuildRequires: pyproject-rpm-macros # %%check imports every module that is not on the documented exclusion list, and # the modules under brainnet.evaluation import these at module scope even # though upstream declares none of them. They are BuildRequired rather than # excluded, because an excluded module is a module nobody is checking. BuildRequires: python3-pandas BuildRequires: python3-matplotlib BuildRequires: python3-surfa %global _description %{expand: BrainNet provides the neural networks SimNIBS uses to build a head model from a subject's own MRI: a TopoFit implementation that deforms a template mesh onto the white and pial cortical surfaces, and TREGA, which estimates the affine transform between subject space and MNI space. Both ship with trained weights, including contrast- and resolution-agnostic variants trained on synthetic images from brainsynth, so they do not assume a 1 mm T1-weighted scan. This is what makes `charm` work on a subject's own scan rather than only on a pre-built head model. Everything here runs on the CPU. brainnet also carries CUDA sources for two mesh operations used during TRAINING (nearest-neighbour search and self-intersection detection); they are shipped as source and are not built. They cannot be built on Fedora at all: upstream compiles them with torch.utils.cpp_extension.CUDAExtension, which needs a CUDA_HOME, and Fedora's python3-torch is a ROCm/HIP build that has none. Nothing in the inference path -- which is all SimNIBS uses -- touches them.} %description %_description %package -n python3-%{srcname} Summary: %{summary} # THE WEIGHTS ARE NOT OPTIONAL. Every prediction path loads one: # brainnet/resources/__init__.py:load_pretrained_state() is called by # helpers/topofit.py and helpers/trega.py, which are exactly what # simnibs/segmentation/brain_surface.py calls. Without the -data subpackage # this package imports perfectly and then fails at the point of use. # # That is not hypothetical. python3-simnibs shipped for months WITHOUT # `Requires: simnibs-data`; the module imported, every smoke test passed, and # every simulation failed because the coil models were absent. See # docs/packaging-model-weights.md, Pattern C. %%check below asserts this exact # Requires line by querying the spec's built packages, not by grepping for it. Requires: python3-%{srcname}-data = %{version}-%{release} # Upstream's declared runtime dependencies. brainsynth and cortech are ours; # the rest are Fedora's. Requires: python3-brainsynth Requires: python3-pytorch-ignite >= 0.4 Requires: python3-nibabel >= 5.1 Requires: python3-numpy >= 1.26 Requires: python3-scipy >= 1.11 Requires: python3-torch >= 2.1 Requires: python3-tqdm >= 4.67 # cortech: THE VERSION MATTERS, and it was the main risk in this package. # # python-cortech is deliberately pinned to the v0.1 tag rather than master, # because cortech master's 2026-06-04 Surface refactor added a manifold # assertion to Surface.__init__ that SimNIBS 4.6.0 predates -- with master, # every TMS simulation on the ernie example subject dies at coil positioning. # brainnet v0.2 was pushed 2026-07-14, i.e. AFTER that refactor, so it could # plausibly have needed the newer API, which would have forced a choice between # brainnet and a working simnibs. # # It does not. brainnet touches cortech in exactly two places, both checked # against the v0.1 source and both exercised by %%check: # brainnet/helpers/topofit.py:8,595 import cortech; cortech.Surface(v, f).save(path) # brainnet/evaluation/surface_evaluation.py:6 from cortech.surface import Surface # cortech v0.1's __init__.py exports Surface, and its Surface.__init__ takes # (vertices, faces, ...) with no manifold assertion, and Surface.save exists at # surface.py:1432. No floor is needed and none is given: a floor of >= 0.1 # would be satisfied by the master snapshot we must NOT use, so it would be # worse than nothing. If cortech is ever bumped here, re-read those two call # sites. Requires: python3-cortech # NOT a dependency: wandb. It is upstream's [project.optional-dependencies] # "train" extra, it is imported at module scope by brainnet/train/train.py, and # it is not packaged for Fedora and is not going to be. That module is on the # exclusion list in %%check with a negative control proving why. # # NOT hard dependencies: pandas, matplotlib and surfa. Upstream declares none of # them, yet brainnet/evaluation/* imports them at module scope -- a genuine # missing-dependency bug, reported upstream. They are Recommends rather than # Requires because nothing on the charm path touches brainnet.evaluation, and a # head-model build should not drag matplotlib in. They ARE BuildRequired, so the # import check covers those modules instead of excusing them. Recommends: python3-pandas Recommends: python3-matplotlib Recommends: python3-surfa %description -n python3-%{srcname} %_description %package -n python3-%{srcname}-data Summary: Trained model weights for brainnet BuildArch: noarch %description -n python3-%{srcname}-data %{expand: The trained TopoFit and TREGA weights that brainnet loads at prediction time: topofit/{synth_1mm,synth_random,t1w_1mm}_state.pt and trega/synth_random_state.pt, with their JSON architecture configs. Split out because they are roughly 60 MB of the package, and because the licence position on trained weights is narrower than on the source: see the extended comment in the spec. python3-brainnet requires this package explicitly -- the weights are not optional, and a brainnet without them would import cleanly and then fail at the point of use.} %prep %autosetup -n %{srcname}-%{version} # Guard the premise. If a future tarball stops shipping the weights -- upstream # moving them to git-annex or a release asset, as FreeSurfer did for SynthStrip # -- this must fail here and loudly, not produce an empty -data subpackage that # satisfies its own Requires while containing nothing. Positive test on purpose: # `test -s X || exit 1`, never `test ! -s X && exit 1`. for f in brainnet/resources/models/topofit/synth_1mm_state.pt \ brainnet/resources/models/topofit/synth_random_state.pt \ brainnet/resources/models/topofit/t1w_1mm_state.pt \ brainnet/resources/models/trega/synth_random_state.pt \ brainnet/resources/models/topofit/synth_1mm_config.json \ brainnet/resources/models/topofit/synth_random_config.json \ brainnet/resources/models/topofit/t1w_1mm_config.json \ brainnet/resources/models/trega/synth_random_config.json \ brainnet/mesh/cuda/extensions.cpp \ brainnet/mesh/cuda/nearest_neighbor.cu \ brainnet/mesh/cuda/self_intersections.cu; do test -s "$f" || { echo "ERROR: missing or empty package data: $f"; exit 1; } done # THE CUDA SOURCES: SHIPPED AS DATA, NEVER COMPILED. Read the code, do not # guess, because the two possibilities need opposite handling and Fedora cannot # ship CUDA at all (docs/CUDA-RPM-FUSION.md). # # They are NOT JIT-compiled at import time -- there is no # torch.utils.cpp_extension.load() call anywhere in the tree. They are NOT # compiled by the wheel build either: the build backend is plain setuptools with # no ext_modules, and brainnet/mesh/cuda/build.py is a SEPARATE, hand-run # setup script that upstream's README tells you to invoke yourself # ("cd into /brainnet/mesh/cuda and execute python build.py build_ext # --inplace"). It exits immediately unless torch.cuda.is_available() and # CUDA_HOME are both set, and it asserts CONDA_PREFIX for CUB headers, so it # could not run in a build root even if something called it. # # Assert that, rather than trusting this comment to stay true. If upstream ever # wires the extension into the build, this fails and the packaging decision gets # revisited instead of a CUDA object silently appearing in a Fedora package. if grep -rn "cpp_extension.load\|cpp_extension\.load_inline" --include=*.py . ; then echo "ERROR: brainnet now JIT-compiles a torch extension at runtime." echo "Re-read the CUDA decision in this spec before shipping." exit 1 fi if grep -qE "ext_modules|cmdclass|CUDAExtension" pyproject.toml ; then echo "ERROR: pyproject.toml now declares a compiled extension." exit 1 fi test ! -e setup.py || { echo "ERROR: a setup.py appeared; re-check the build"; exit 1; } # brainnet/mesh/cuda/: SHIPPED AS DATA, NEVER COMPILED, AND UNBUILDABLE HERE. # Read the code, do not guess, because the two possibilities need opposite # handling and Fedora cannot ship CUDA at all (docs/CUDA-RPM-FUSION.md, # docs/cuda-inventory.md). # # * NOT JIT-compiled at import: there is no torch.utils.cpp_extension.load() # anywhere in the tree -- asserted just above. # * NOT compiled by the wheel: the backend is plain setuptools, pyproject.toml # declares no ext_modules, and there is no setup.py -- asserted just above. # * build.py is a SEPARATE script upstream's README tells the user to run by # hand ("cd into /brainnet/mesh/cuda and execute python build.py build_ext # --inplace"). It uses CUDAExtension and bails when CUDA_HOME is None. # # So a Fedora user cannot build it EITHER, and not merely for want of nvcc: # Fedora's python3-torch 2.9.1 is a ROCm/HIP build (torch.version.cuda is None, # torch.version.hip is 7.1.52802), so there is no CUDA_HOME for # torch.utils.cpp_extension to find even with an NVIDIA card and the toolkit # installed. The acceleration in mesh/cuda/ is structurally unavailable on # Fedora, which is said in the -data description and the changelog so nobody # files a bug about a missing speedup. # # All four files ship at their upstream path, so the README's instructions # refer to something that exists. But build.py is INSTALLED AS AN IMPORTABLE # MODULE -- brainnet/mesh/cuda/ has no __init__.py and setuptools installs it # as a namespace package anyway -- and importing it runs # if not have_cuda: print(...); exit() # at module scope. exit() raises SystemExit, a BaseException, which rpm's # import_all_modules.py does not catch: the interpreter simply ends with status # 0 and %%pyproject_check_import reports SUCCESS having stopped partway through # its ALPHABETICAL sweep. # # That is measured, not theoretical. The first build of this package checked 81 # modules, stopped dead at "brainnet.mesh.cuda.build", never reached # brainnet.mesh.surface, brainnet.modules.*, brainnet.networks.*, # brainnet.resources or brainnet.train.* -- every module that matters here -- # and passed green. One shipped file silently disabled the check on a third of # the package. It is therefore on the exclusion list in %%check, and the # independent sweep there is written specifically to catch a SystemExit rather # than be ended by one. test -s brainnet/mesh/cuda/build.py || { echo "ERROR: build.py is gone; re-check"; exit 1; } grep -qE "^ *exit\(\)" brainnet/mesh/cuda/build.py || { echo "ERROR: brainnet/mesh/cuda/build.py no longer calls exit() at module" echo "scope. Good news, but the exclusion in %%check must then be retired." exit 1 } test ! -e brainnet/mesh/cuda/__init__.py || { echo "NOTE: brainnet/mesh/cuda/__init__.py now exists; re-read the %%check" echo "exclusion for brainnet.mesh.cuda.build." exit 1 } # TWO SHIPPED FILES ARE NOT VALID PYTHON. They are scratch analysis scripts # under brainnet/evaluation/ that upstream committed mid-edit: # surface_evaluation_analysis.py:107 IndentationError -- an empty `for` body # topofit_analyze.py:85 SyntaxError -- a stray `|` at line end # rpm's brp-python-bytecompile fails the build on them, and rightly: a Fedora # package may not ship .py files that cannot be compiled. Nothing in the tree # imports either one (checked: no reference to those module names anywhere), so # they are removed rather than patched -- inventing a fix for a script we cannot # run would be worse than deleting it. Reported upstream; see # docs/upstream/brainsynth-brainnet-packaging.md. # # Each deletion is justified on the spot, not on trust: if upstream repairs a # file, this FAILS so the removal gets retired instead of quietly eating a # working module. for f in brainnet/evaluation/surface_evaluation_analysis.py \ brainnet/evaluation/topofit_analyze.py; do test -f "$f" || { echo "ERROR: $f is gone; re-check this removal"; exit 1; } if %{python3} -m py_compile "$f" 2>/dev/null; then echo "ERROR: $f compiles now. Upstream has fixed it, so this removal" echo "must be dropped -- otherwise packaging is deleting working code." exit 1 fi rm -f "$f" done # Positive control for the pair above: everything that REMAINS must compile, # EXCEPT brainnet/mesh/cuda/build.py, whose import calls exit() -- compileall # only compiles, it does not import, so it is fine here; see the note above. # Without this control, "we deleted the two that failed" would be # indistinguishable from "we deleted two at random and got lucky". %{python3} -m compileall -q brainnet find brainnet -name __pycache__ -type d -prune -exec rm -rf {} + %generate_buildrequires # The "train" extra is deliberately not requested: it is wandb, which is not # packaged and is not going to be. %pyproject_buildrequires %build %pyproject_wheel %install %pyproject_install %pyproject_save_files -l %{srcname} %check # --------------------------------------------------------------------------- # 1. GUARD THE MODULE LIST, before using it. %%pyproject_check_import derives # what it imports from %%pyproject_save_files; if that list ever shrank the # check would keep reporting success while looking at almost nothing. Positive # test (`grep -qxF ... || exit 1`), deliberately not `grep -q ... && exit 1`, # which returns 1 when grep does not match and inverts the test it resembles. # # Every name here is on the path SimNIBS charm actually takes: # simnibs/segmentation/brain_surface.py imports brainnet, brainnet.helpers, # brainnet.datasets, brainnet.Surface and brainnet.DeepSurferTopology, and calls # brainnet.helpers.{trega,topofit}.PredictionStep.from_pretrained. for m in %{srcname} \ %{srcname}.bin.predict \ %{srcname}.datasets \ %{srcname}.helpers \ %{srcname}.helpers.topofit \ %{srcname}.helpers.trega \ %{srcname}.mesh.surface \ %{srcname}.mesh.topology \ %{srcname}.modules.brainnet \ %{srcname}.modules.criterion \ %{srcname}.networks.topofit \ %{srcname}.networks.templatereg \ %{srcname}.resources; do grep -qxF "$m" "%{_pyproject_modules}" || { echo "ERROR: $m is not in the %%pyproject_check_import module list." echo "The import check would pass without ever looking at it." echo "--- list as generated ---" cat "%{_pyproject_modules}" exit 1 } done # --------------------------------------------------------------------------- # 2. Import everything except the 25 modules upstream ships unimportable. # # That is a large exclusion list and it deserves the explanation. brainnet v0.2 # is a research tree: alongside the inference code SimNIBS uses, it carries the # author's training and evaluation scaffolding, and that scaffolding refers to # names the library no longer has. Every exclusion below is one of five kinds, # and %%check step 3 asserts the specific failure for each, so none of them can # be a cover for a real regression: # # (i) a loss or module name deleted from brainnet.modules -- SurfaceRMSELoss, # SymmetricSampledNormLoss, SymmetricSampledMSELoss, # SampledSemiSymmetricL1Loss, MSNELoss, brainnet.modules.body # (11 modules under brainnet.config, plus brainnet.modules.decouple) # (ii) a module upstream never committed -- brainnet.train.utilities and # brainnet.config.topofit.mri (8 modules under brainnet.train) # (iii) a name missing from brainnet/__init__.py -- RESOURCES_DIR # (brainnet.bin.dump_model_checkpoint), the same class of defect as # brainsynth's root_dir # (iv) wandb, upstream's "train" extra, which is not packaged for Fedora # (brainnet.train.train) # (v) hardcoded absolute paths on the author's cluster, and a sibling that # fails for (ii) (brainnet.evaluation.alignment_analyze, # brainnet.evaluation.evaluate, brainnet.evaluation.evaluate_photo) # # Plus brainnet.mesh.cuda.build, which does not merely fail: it calls exit() at # module scope, which ENDS THE SWEEP with status 0. See %%prep. # # NOTHING on the charm path is here. brainnet, brainnet.mesh.*, # brainnet.modules.brainnet, brainnet.networks.*, brainnet.helpers.topofit, # brainnet.helpers.trega, brainnet.datasets and brainnet.resources all import, # and step 1 above proves they are in the list. # # ONE LINE ONLY. rpm reads a macro's arguments to the end of the line, so a # backslash-continued invocation passes the first flag and drops the rest into # the shell as stray text -- which is how an earlier draft excluded one module # out of four and failed the build. %global check_exclude -e %{srcname}.mesh.cuda.build -e %{srcname}.bin.dump_model_checkpoint -e %{srcname}.config.braininflate.losses -e %{srcname}.config.braininflate.main -e %{srcname}.config.brainreg.losses -e %{srcname}.config.brainreg.main -e %{srcname}.config.topofit.adapt.losses -e %{srcname}.config.topofit.adapt.main -e %{srcname}.config.topofit.adapt.main_N10 -e %{srcname}.config.topofit.adapt.main_N20 -e %{srcname}.config.topofit.brainpass.losses -e %{srcname}.config.topofit.brainpass.main -e %{srcname}.evaluation.alignment_analyze -e %{srcname}.evaluation.evaluate -e %{srcname}.evaluation.evaluate_photo -e %{srcname}.modules.decouple -e %{srcname}.train.braininflate_train -e %{srcname}.train.brainnet_train -e %{srcname}.train.brainreg_train -e %{srcname}.train.dump_model_parameters -e %{srcname}.train.features_train -e %{srcname}.train.sphericalreg_train -e %{srcname}.train.train -e %{srcname}.train.train_peirong -e %{srcname}.train.upsampler_train_test %pyproject_check_import %{check_exclude} # The plain module names, derived from the SAME text as the flags above so the # two cannot drift apart. The Python sweep in step 3 reads this. BROKEN=$(echo %{check_exclude} | sed 's/-e //g') test -n "$BROKEN" || { echo "ERROR: the exclusion list came out empty"; exit 1; } # --------------------------------------------------------------------------- # 3. The assertions an import check cannot make. # NOTE FOR ANYONE EDITING THE SCRIPT BELOW: rpm expands macros inside a heredoc # body even with a quoted delimiter -- the quotes stop the SHELL, not rpm. No # %%-prefixed token may appear between here and the EOF, not even in a comment # or a docstring, and doubling the sign does not help: that emits a literal # doubled sign into the generated file. An earlier draft of the brainsynth spec # named a macro in a docstring and rpm spliced the macro's body into the Python # source, which died with "SyntaxError: invalid decimal literal". cat > %{_builddir}/brainnet-check.py <<'EOF' """What this proves, none of which "it imported" would prove. (a) An INDEPENDENT sweep of every module in the RPM module list, minus the documented exclusions, with SystemExit caught. This exists because rpm's own sweep does NOT catch SystemExit: one shipped file that calls exit() at import ended it silently, with status 0, a third of the way through. A negative control below proves this sweep catches that case. (b) The four trained weight files are installed, non-empty, and are real torch checkpoints with trained-looking parameters -- not 60 MB of anything. (c) The CPU path works, on the CPU. What is NOT asserted is torch.cuda.is_available() being False: on Fedora that is not a CUDA test. python3-torch 2.9.1 is a ROCm/HIP build (torch.version.cuda is None, torch.version.hip is 7.1.52802, libraries are libamdhip64/libMIOpen/ librocblas with no NVIDIA library at all) and HIP reports itself through the torch.cuda namespace, so that flag is True on any Fedora box with an AMD GPU -- measured. Accelerators are masked in the environment instead, and every tensor is asserted to be on the CPU device. (d) The cortech v0.1 API brainnet uses really is there, since that version pin is the main compatibility risk in this package. (e) Removing the weights produces a clear, path-naming error rather than an obscure one. The simnibs-data omission was expensive precisely because "the module imports fine" looked like success. (f) The CUDA extension is absent and the CPU path does not care. """ import importlib import json import os import shutil import sys import tempfile import textwrap from pathlib import Path import numpy as np import torch CPU = torch.device("cpu") torch.set_num_threads(1) import brainnet import brainnet.resources import brainnet.helpers.topofit import brainnet.helpers.trega print("brainnet imported from", brainnet.__file__) print( f"torch {torch.__version__} " f"(version.cuda={torch.version.cuda}, " f"version.hip={getattr(torch.version, 'hip', None)})" ) # --- (a) the independent sweep ---------------------------------------------- modules_file = Path(os.environ["PYPROJECT_MODULES"]) excluded = set(os.environ["BROKEN"].split()) all_modules = [ line.strip() for line in modules_file.read_text().splitlines() if line.strip() ] assert len(all_modules) > 100, ( f"only {len(all_modules)} modules in the list; the package has far more, " "so something truncated it" ) assert excluded < set(all_modules), sorted(excluded - set(all_modules)) # Bound the exclusions: if a future version broke most of the tree, excluding # it all would still satisfy the per-module assertions further down. assert len(excluded) * 3 < len(all_modules), (len(excluded), len(all_modules)) def sweep(names): """Import each name. Returns {name: exception}. Catches BaseException on purpose: SystemExit and KeyboardInterrupt are exactly what rpm's sweep misses, and a module that raises one must be reported, not obeyed.""" failed = {} for name in names: try: importlib.import_module(name) except BaseException as exc: # noqa: BLE001 -- see docstring failed[name] = exc return failed # NEGATIVE CONTROL, before the real sweep. A module that calls exit() must be # REPORTED by sweep(), not end it. Without this, "sweep() found no failures" # would be consistent with sweep() having been terminated on its first module, # which is the bug this whole function exists to defend against. with tempfile.TemporaryDirectory() as probe_dir: Path(probe_dir, "brainnet_sweep_probe_exit.py").write_text( 'print("probe: calling exit() at module scope")\nexit()\n' ) Path(probe_dir, "brainnet_sweep_probe_ok.py").write_text("value = 1\n") sys.path.insert(0, probe_dir) try: probe = sweep(["brainnet_sweep_probe_exit", "brainnet_sweep_probe_ok"]) finally: sys.path.remove(probe_dir) sys.modules.pop("brainnet_sweep_probe_exit", None) sys.modules.pop("brainnet_sweep_probe_ok", None) if "brainnet_sweep_probe_exit" not in probe: raise SystemExit( "ERROR: sweep() did not report a module that calls exit(). Either it " "was terminated by it -- the exact defect this guards -- or it is not " "importing anything. The sweep below would prove nothing." ) assert isinstance(probe["brainnet_sweep_probe_exit"], SystemExit), probe assert "brainnet_sweep_probe_ok" not in probe, probe print("negative control OK: sweep() reports a module that calls exit() at import") to_import = [m for m in all_modules if m not in excluded] failures = sweep(to_import) if failures: print("--- modules that failed the independent sweep ---") for name, exc in sorted(failures.items()): print(f" {name}: {type(exc).__name__}: {exc}") raise SystemExit( f"ERROR: {len(failures)} of {len(to_import)} modules failed to import " "outside the documented exclusion list." ) print(f"independent sweep OK: {len(to_import)} modules imported, 0 failures") # And each exclusion must still fail, for the kind of reason it was excluded # for. If upstream fixes one, THIS FAILS -- so the exclusion gets retired # instead of quietly outliving its cause. EXPECTED = { "brainnet.mesh.cuda.build": "", "brainnet.bin.dump_model_checkpoint": "RESOURCES_DIR", "brainnet.config.braininflate.losses": "SurfaceRMSELoss", "brainnet.config.braininflate.main": "SurfaceRMSELoss", "brainnet.config.brainreg.losses": "SymmetricSampledNormLoss", "brainnet.config.brainreg.main": "body", "brainnet.config.topofit.adapt.losses": "SymmetricSampledMSELoss", "brainnet.config.topofit.adapt.main": "body", "brainnet.config.topofit.adapt.main_N10": "body", "brainnet.config.topofit.adapt.main_N20": "body", "brainnet.config.topofit.brainpass.losses": "SampledSemiSymmetricL1Loss", "brainnet.config.topofit.brainpass.main": "body", "brainnet.evaluation.alignment_analyze": "/mnt/", "brainnet.evaluation.evaluate": "sklearn", "brainnet.evaluation.evaluate_photo": "BrainNetTrainer", "brainnet.modules.decouple": "MSNELoss", "brainnet.train.braininflate_train": "brainnet.train.utilities", "brainnet.train.brainnet_train": "brainnet.train.utilities", "brainnet.train.brainreg_train": "brainnet.train.utilities", "brainnet.train.dump_model_parameters": "brainnet.config.topofit.mri", "brainnet.train.features_train": "brainnet.train.utilities", "brainnet.train.sphericalreg_train": "brainnet.train.utilities", "brainnet.train.train": "wandb", "brainnet.train.train_peirong": "brainnet.train.utilities", "brainnet.train.upsampler_train_test": "brainnet.train.utilities", } assert set(EXPECTED) == excluded, ( "the exclusion list in the spec and the table in this script disagree", sorted(set(EXPECTED) ^ excluded), ) still_broken = sweep(sorted(EXPECTED)) fixed = [m for m in EXPECTED if m not in still_broken] if fixed: raise SystemExit( "ERROR: these modules now import, so their exclusions must be REMOVED " f"from the spec or they stop being checked: {fixed}" ) for name, needle in EXPECTED.items(): exc = still_broken[name] if name == "brainnet.mesh.cuda.build": assert isinstance(exc, SystemExit), (name, type(exc).__name__, exc) continue assert needle in str(exc), (name, needle, f"{type(exc).__name__}: {exc}") print(f"all {len(EXPECTED)} exclusions still fail, each for its documented reason") # --- (b) the weights -------------------------------------------------------- models_dir = Path(brainnet.resources.PRETRAINED_MODELS_DIR) # Size floors from the v0.2 tag, set well below the real figures so a truncated # or placeholder file fails while a retrained model of similar shape passes. WEIGHTS = { ("topofit", "synth", "1mm"): 10_000_000, ("topofit", "synth", "random"): 10_000_000, ("topofit", "t1w", "1mm"): 8_000_000, ("trega", "synth", "random"): 500_000, } total = 0 for (model, contrast, resolution), min_size in WEIGHTS.items(): state_path = models_dir / model / f"{contrast}_{resolution}_state.pt" config_path = models_dir / model / f"{contrast}_{resolution}_config.json" assert state_path.is_file(), f"weights not installed: {state_path}" assert config_path.is_file(), f"config not installed: {config_path}" size = state_path.stat().st_size assert size >= min_size, (state_path, size, min_size) total += size # Go through brainnet's own accessor, so the path construction in # resources/__init__.py is under test too, not just the files on disk. state = brainnet.resources.load_pretrained_state( model, contrast, resolution, device=CPU ) config = brainnet.resources.load_pretrained_config(model, contrast, resolution) assert isinstance(state, dict) and state, state_path assert isinstance(config, dict) and config, config_path tensors = [v for v in state.values() if isinstance(v, torch.Tensor)] if not tensors: for value in state.values(): if isinstance(value, dict): tensors += [v for v in value.values() if isinstance(v, torch.Tensor)] assert tensors, f"{state_path}: no tensors in the checkpoint" assert all(t.device.type == "cpu" for t in tensors), state_path n_params = sum(t.numel() for t in tensors) assert n_params > 100_000, (state_path, n_params) assert all(torch.isfinite(t.float()).all() for t in tensors), state_path # A zero-filled or freshly-initialised checkpoint would not pass this. assert max(float(t.float().abs().max()) for t in tensors) > 0.0, state_path print( f"{model}/{contrast}_{resolution}: {size/1e6:.1f} MB, " f"{len(tensors)} tensors, {n_params} parameters" ) assert total > 50_000_000, total print(f"four checkpoints, {total/1e6:.1f} MB total, all loaded onto the CPU") # --- (c) a real CPU forward pass, end to end ------------------------------- # TREGA is the small model and it is what charm runs FIRST: it estimates the # subject-to-MNI affine before TopoFit deforms the template onto the cortex. # PredictionStep.__call__ is used rather than model.forward, so the brainsynth # preprocessing pipeline is exercised too -- this is the one assertion that # proves the two new packages work TOGETHER rather than merely both importing. step = brainnet.helpers.trega.PredictionStep.from_pretrained(device=CPU) assert step.preprocessor is not None, "trega came back with no preprocessor" # A synthetic head-sized volume. Not the right shape for the network's input: # the preprocessor's job is to conform it, so feeding something else is part of # the test. image = torch.zeros((1, 160, 170, 160), dtype=torch.float32, device=CPU) image[0, 40:120, 45:125, 40:120] = 1.0 vox2ras = torch.eye(4, dtype=torch.float32, device=CPU) vox2ras[:3, 3] = torch.tensor([-80.0, -85.0, -80.0]) assert image.device.type == "cpu" and vox2ras.device.type == "cpu" affines = step(None, (image, vox2ras)) assert isinstance(affines, dict), type(affines) assert sorted(affines) == ["brain", "lh", "rh"], sorted(affines) for name, affine in affines.items(): assert affine.shape == (1, 4, 4), (name, affine.shape) assert affine.device.type == "cpu", (name, affine.device) assert torch.isfinite(affine).all(), f"{name} affine has NaN or inf" # An affine that maps a brain onto a template must be invertible. A # singular or zero matrix would satisfy "returned a tensor of the right # shape" and be useless, which is the point of checking the determinant. det = float(torch.linalg.det(affine[0, :3, :3])) assert abs(det) > 1e-3, f"{name} affine is near-singular: det={det}" # And the bottom row must be a proper homogeneous row. assert torch.allclose( affine[0, 3], torch.tensor([0.0, 0.0, 0.0, 1.0]), atol=1e-5 ), affine[0, 3] print(f"trega affine {name}: det={det:.4f}, finite, homogeneous") # postprocess() applies the estimated affine to the DeepSurfer template, which # is brainsynth's data going through brainnet's mesh code. placed = step.postprocess(affines) assert sorted(placed) == ["lh", "rh"], sorted(placed) for hemi, surf in placed.items(): assert surf.vertices.shape == (1, 62, 3), (hemi, surf.vertices.shape) assert surf.vertices.device.type == "cpu", (hemi, surf.vertices.device) assert torch.isfinite(surf.vertices).all(), hemi print("trega CPU pipeline OK: preprocess -> 3 affines -> template placed") # All three TopoFit checkpoints must build a model and load. The parameter # counts are exact and architecture-determined, so a config that no longer # matches its weights, or a weights file swapped for another, fails here rather # than at a user's first head model. TOPOFIT_PARAMS = { ("t1w", "1mm"): 4117925, ("synth", "1mm"): 5148137, ("synth", "random"): 5231113, } for (contrast, resolution), expected_params in TOPOFIT_PARAMS.items(): tf = brainnet.helpers.topofit.PredictionStep.from_pretrained( contrast=contrast, resolution=resolution, device=CPU ) n_params = sum(p.numel() for p in tf.model.parameters()) assert n_params == expected_params, (contrast, resolution, n_params, expected_params) assert all(p.device.type == "cpu" for p in tf.model.parameters()) print(f"topofit {contrast}/{resolution} loaded on the CPU: {n_params} parameters") # --- (d) the cortech API this package's version pin depends on ------------- import cortech import cortech.surface assert hasattr(cortech, "Surface"), dir(cortech) assert cortech.Surface is cortech.surface.Surface assert hasattr(cortech.Surface, "save"), "cortech.Surface.save is gone" # A deliberately NON-manifold triangle soup: two disjoint triangles sharing no # edge. cortech master's post-2026-06-04 Surface refactor asserts manifoldness # in __init__ and would reject this; the v0.1 tag we pin must accept it. That # assertion is exactly what breaks SimNIBS 4.6.0, which is why the pin exists, # and brainnet v0.2 -- pushed after the refactor -- had to be checked against it. soup_v = np.array( [[0.0, 0, 0], [1, 0, 0], [0, 1, 0], [5, 5, 5], [6, 5, 5], [5, 6, 5]] ) soup_f = np.array([[0, 1, 2], [3, 4, 5]]) surface = cortech.Surface(soup_v, soup_f) assert surface.vertices.shape == (6, 3), surface.vertices.shape # And the call brainnet actually makes, round-tripped through a real file. with tempfile.TemporaryDirectory() as out: target = Path(out) / "lh.white" surface.save(target) assert target.is_file() and target.stat().st_size > 0, target print("cortech v0.1 API OK: non-manifold Surface accepted, save() writes a file") # --- (e) the diagnostic when the weights are missing ----------------------- victim = models_dir / "trega" / "synth_random_state.pt" stash = tempfile.mkdtemp(prefix="brainnet-weights-") backup = Path(stash) / victim.name try: shutil.move(str(victim), str(backup)) assert not victim.exists() try: brainnet.resources.load_pretrained_state("trega", "synth", "random", CPU) except FileNotFoundError as exc: assert str(victim) in str(exc), (str(victim), str(exc)) print("missing-weights diagnostic OK:", exc) else: raise SystemExit( "ERROR: loading a checkpoint that is not on disk did not raise " "FileNotFoundError. The data subpackage could go missing and the " "failure would be silent or obscure -- the simnibs-data shape." ) finally: shutil.move(str(backup), str(victim)) shutil.rmtree(stash, ignore_errors=True) assert victim.is_file() and victim.stat().st_size > 500_000, victim # --- (f) no compiled object, and the CPU path does not need one ------------ pkg_dir = Path(brainnet.__file__).parent sos = sorted(str(p) for p in pkg_dir.rglob("*.so")) assert not sos, f"a compiled object was packaged: {sos}" cuda_dir = pkg_dir / "mesh" / "cuda" shipped = sorted(p.name for p in cuda_dir.iterdir() if p.is_file()) assert shipped == [ "build.py", "extensions.cpp", "nearest_neighbor.cu", "self_intersections.cu", ], shipped print("cuda directory ships source only:", shipped) import brainnet.mesh.surface as bsurf # surface.py wraps the extension import in try/except ImportError. Assert the # except branch was really taken -- that the module loaded WITHOUT the # extension, rather than the extension having appeared from somewhere. assert not hasattr(bsurf, "cuda_extensions"), ( "a CUDA extension module got imported; this package must not build one" ) topology = brainnet.DeepSurferTopology() assert topology.n_faces == 120, topology.n_faces assert topology.n_vertices == 62, topology.n_vertices levels = brainnet.DeepSurferTopology.recursive_subdivision(6) assert levels[-1].n_faces == 491520 == 120 * 4**6, levels[-1].n_faces assert levels[-1].n_vertices == 245762, levels[-1].n_vertices assert levels[-1].faces.device.type == "cpu", levels[-1].faces.device print("DeepSurferTopology OK on the CPU: level 6 is 245762 vertices / 491520 faces") # The brainsynth template must be the SAME topology, or the two packages have # drifted apart and charm would deform the wrong mesh. import brainsynth tpl = brainsynth.resources.Template().load_surface("lh", "white") assert tpl["vertices"].shape[0] == levels[-1].n_vertices, tpl["vertices"].shape assert tpl["faces"].shape[0] == levels[-1].n_faces, tpl["faces"].shape print("brainsynth template matches brainnet DeepSurferTopology at level 6") print("ALL BRAINNET CHECKS PASSED") EOF # Mask every accelerator, whichever backend this torch was built against, so # the CPU path is what runs here regardless of the builder. CUDA_VISIBLE_DEVICES # covers an NVIDIA build; HIP_VISIBLE_DEVICES and ROCR_VISIBLE_DEVICES cover # Fedora's actual ROCm build, which reports itself through torch.cuda. OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 \ CUDA_VISIBLE_DEVICES="" HIP_VISIBLE_DEVICES="" ROCR_VISIBLE_DEVICES="" \ PYPROJECT_MODULES="%{_pyproject_modules}" \ BROKEN="$BROKEN" \ PYTHONPATH=%{buildroot}%{python3_sitelib} \ %{python3} %{_builddir}/brainnet-check.py # --------------------------------------------------------------------------- # 4. ASSERT THE DATA DEPENDENCY IS DECLARED, on the artefact rather than by # grepping this file. PACKAGING-POLICY rule 2: a Requires in the preamble # attaches to the main package, which a python spec never builds, so it can ship # on nothing. Ask rpmspec what the BUILT packages require. # # The positive control comes first: if the query returns nothing -- wrong path, # rpmspec missing, a spec that no longer parses -- this fails instead of passing # vacuously, which is the whole failure mode it exists for. SPECFILE="%{_specdir}/%{name}.spec" test -f "$SPECFILE" || { echo "ERROR: cannot find $SPECFILE to verify the data dependency"; exit 1; } REQS=$(rpmspec -q --builtrpms --qf '[%{=NAME} %{REQUIRENAME}\n]' "$SPECFILE") test -n "$REQS" || { echo "ERROR: rpmspec returned no requires at all"; exit 1; } printf '%s\n' "$REQS" | grep -qx "python3-%{srcname} python3-%{srcname}-data" || { echo "ERROR: python3-%{srcname} does NOT require python3-%{srcname}-data." echo "The module would import and every prediction would fail at the point" echo "of use -- exactly the python3-simnibs / simnibs-data defect." printf '%s\n' "$REQS" | grep "^python3-%{srcname} " || true exit 1 } # Negative control: the same grep must NOT match a dependency that is not # declared. Without it, a grep that matched anything would look identical. if printf '%s\n' "$REQS" | grep -qx "python3-%{srcname} python3-%{srcname}-nonexistent"; then echo "ERROR: the requires grep matches a dependency that does not exist," echo "so the assertion above proves nothing." exit 1 fi echo "data dependency asserted on the artefact: python3-%{srcname} requires python3-%{srcname}-data" %files -n python3-%{srcname} -f %{pyproject_files} %license LICENSE %doc README.md # %%pyproject_save_files does not record console scripts, so this would be an # unpackaged file. %{_bindir}/%{srcname} %exclude %{python3_sitelib}/%{srcname}/resources/models %files -n python3-%{srcname}-data %license LICENSE %{python3_sitelib}/%{srcname}/resources/models %changelog * Mon Sep 28 2026 Morgan Hough - 0.2-1 - Initial package, from the v0.2 git tag -- the tag SimNIBS 4.6.0's tools/environment.py pins. NOT from PyPI: PyPI's "brainnet" is unrelated software (0.0.01, MIT, github.com/CryptoNyxz). - Last link of the chain that unblocks SimNIBS charm (pytorch-ignite -> brainsynth -> brainnet). Before this, charm --help died with ModuleNotFoundError at simnibs/segmentation/brain_surface.py:13 and head-model creation from a subject's own MRI was impossible. - cortech v0.1 is compatible, checked rather than assumed. brainnet uses cortech in exactly two places (helpers/topofit.py and evaluation/surface_evaluation.py) and both work against the v0.1 tag we pin; %%check builds a non-manifold Surface, which cortech master would reject and which is why the pin exists. - The ~60 MB of trained weights ship in python3-brainnet-data, with a hard versioned Requires asserted in %%check by querying rpmspec --builtrpms, not by grepping the spec. Licence position: repo GPL-3.0 with no carve-out and SimNIBS's own 3RD-PARTY.md records brainnet as GPL-3-or-later while recording non-commercial limits for other components -- but there is no model card and no statement about the weights specifically, and the training corpora (ADNI, HCP, OASIS3, AIBL, Buckner40, ABIDE, ADHD200) each carry their own data use agreements. Flagged for Morgan; the split exists so a reversal costs one subpackage. - The CUDA sources are shipped as data and never compiled. There is no cpp_extension.load() anywhere, pyproject.toml declares no ext_modules, and brainnet/mesh/cuda/build.py is a hand-run script upstream's README tells the user to invoke. It cannot be run on Fedora regardless: it uses CUDAExtension and bails when CUDA_HOME is None, and Fedora's python3-torch is a ROCm/HIP build (torch.version.cuda is None) so there is no CUDA_HOME to find. %%prep asserts each of those premises so a future upstream change cannot slip a CUDA object into a Fedora package, and %%check asserts no .so was packaged and that Surface loads with the extension absent. - WHY THE EXCLUSION LIST IN %%check IS 25 LONG, AND THE BUG THAT MADE IT NECESSARY TO LOOK. brainnet/mesh/cuda/build.py installs as an importable module (the directory has no __init__.py, so setuptools makes it a namespace package) and calls exit() at module scope. exit() raises SystemExit, which rpm's import_all_modules.py does not catch, so the FIRST build of this package checked 81 modules alphabetically, ended silently at brainnet.mesh.cuda.build with status 0, never looked at brainnet.mesh.surface, brainnet.modules.*, brainnet.networks.*, brainnet.resources or brainnet.train.*, and reported SUCCESS. With that module excluded the sweep reaches all 114 and 24 of them fail -- upstream's training and evaluation scaffolding, referring to loss classes deleted from brainnet.modules, to a brainnet.train.utilities that was never committed, to wandb, and to absolute paths on the author's cluster. None is on the charm path. %%check now runs its OWN sweep that catches BaseException, with a negative control proving it reports a module that calls exit() instead of being ended by it. - The CPU assertions do NOT test torch.cuda.is_available(). Fedora's torch is ROCm and reports itself through the torch.cuda namespace, so that flag is True on any machine with an AMD GPU and says nothing about CUDA; the accelerators are masked in the environment and every tensor is asserted to be on the CPU device instead. See docs/cuda-inventory.md. - Two shipped files are not valid Python and are removed in %%prep: evaluation/surface_evaluation_analysis.py has an empty `for` body (IndentationError at line 107) and evaluation/topofit_analyze.py a stray `|` (SyntaxError at line 85). rpm's bytecompile step fails the build on them and nothing imports either one. Each removal asserts the file still fails to compile first, and the rest of the tree is compiled as a positive control. - - pandas, matplotlib and surfa are imported at module scope by brainnet/evaluation/* and declared by upstream nowhere; Recommends here, BuildRequires so the import check covers those modules. wandb is upstream's "train" extra, is not packaged, and its one module-scope importer is on the documented exclusion list. - f44-x86_64 only. python-pytorch-ignite caps requires-python at <3.15 and f45/rawhide ship 3.15, so brainnet cannot resolve there yet either.