Artifacts and resolution
The released files, how a name becomes a path, and how an inspiral model is bound to its merger-ringdown arm. Written 2026-09-02, the day the deposit became six files and the digests were checked.
The six released files
Zenodo record 22262339, https://doi.org/10.5281/zenodo.22262339, base URL
https://zenodo.org/records/22262339/files/. The v3 family is the default;
v2 is deposited so that published v2 numbers stay reproducible
(nrhjsurrogate/models.py, the registry, which is the only place the names
and checksums live).
| name | what | bytes | sha256 (public copy) |
|---|---|---|---|
NRHJSur3dq8_AA_v3 |
action-angle inspiral, generation 3, RELEASE_AA |
31952616 | b6d2d300...1ebf483 |
NRHJSur3dq8_AdA_v3 |
adiabatic-angle inspiral, generation 3, RELEASE_ADA |
15734472 | c0ca9922...f549a1a |
NRHJSur3dq8_MR_v3 |
the merger-ringdown arm both v3 inspirals name, RELEASE_MR_V3 |
19606296 | dd1bf008...235059a |
NRHJSur3dq8_AA_v2 |
previous generation, PREVIOUS_AA |
30743544 | a05f6e02...97bd06b4 |
NRHJSur3dq8_AdA_v2 |
previous generation, PREVIOUS_ADA |
15830464 | 20dad0f5...58bc61ce |
NRHJSur3dq8_MR_v2 |
the arm both v2 inspirals name (the constant is still spelled RELEASE_MR) |
19690832 | da0ea1b0...59f67546b |
Read the names from nrhjsurrogate.RELEASE_AA and RELEASE_ADA rather than
typing them. The full digests are in models.MODELS and were reproduced
byte for byte on a second scrub run. The wheel ships code only; the
files come from the deposit or from models.fetch_model(name), which
downloads into the per-user cache and verifies the SHA-256 and size BEFORE
the file is moved into place, so a truncated download can never be found by a
loader (models.py:fetch_model). An unregistered name is refused with the
released names listed, measured: fetch_model("nrhjsur_production_v4_swsph")
raises unknown model ...; released models: NRHJSur3dq8_AA_v3, NRHJSur3dq8_AdA_v3.
A model cannot be evaluated without its merger-ringdown arm. Each
inspiral file records the arm's NAME (mr_name) and the content digest it
was validated against (mr_content_md5); both v3 inspirals record
NRHJSur3dq8_MR_v3 and 912502ce1e193e81a91269693508f31c (read off the
files' root attributes). Loading resolves that name on every load and
refuses to continue without it, rather than returning an inspiral-only
model, because a model degraded by accident is indistinguishable from one
degraded on purpose (nrhjsurrogate/serialize.py:1206, resolve_mr_arm).
The waveform, the remnant and the error estimate all need the arm.
How a name becomes a path
models.resolve_model(name_or_path), models.py:569. First hit wins:
- an explicit path that exists, used verbatim;
$NRHJ_MODEL_DIR, colon separated like$PATH;$LAL_DATA_PATH, the LALSuite convention, so an artifact dropped besideNRSur7dq4andNRHybSur3dq8resolves by name with nothing to learn; below the project variable so a project choice beats a shared install, above the cache so a file placed on purpose beats one the package downloaded, and never written to;- the per-user cache,
$NRHJ_MODEL_CACHE_DIR, elseplatformdirsif it happens to be installed, else$XDG_CACHE_HOME/nrhjsurrogate, else~/.cache/nrhjsurrogate; model_release/beside a source checkout, deliberately last.
models.search_path() prints the list: the directory named by
NRHJ_MODEL_DIR, then the directories on LAL_DATA_PATH, then
~/.cache/nrhjsurrogate/models. A path with directory components is a
specific request and is not searched for elsewhere. A miss raises
FileNotFoundError naming the file, its size and digest, every directory
searched and the two ways to fix it; measured for an unknown name.
Earlier spellings of these variables still work and warn once, naming the variable to use instead.
Verification is opt-in on load and the internal copies fail it, on
purpose. The registry pins the PUBLIC checksums, the scrubbed files in
dist_models/ that the deposit holds. The checkout's model_release/
copies carry the construction provenance (build script, cache paths,
training ids) that the release strips, so they hash differently:
model_release/NRHJSur3dq8_AA_v3.h5 measures 9cac9f97... against the
registry's b6d2d300..., and models.verify_model refuses it with
checksum mismatch. Evaluation is bitwise identical between the two copies,
192 of 192 arrays over four inspirals and six parameter points.
resolve_model(..., verify=True) is how a consumer asks for the check.
The arm binding and its digest
serialize.compose_mr_arm (serialize.py:1352) decides, in order: an
explicit merger_ringdown_path wins; else an embedded arm (pre-de-embedding
files, none of the six); else mr_representation == "none" is a
deliberately arm-less model; else the recorded name is resolved and checked.
The check compares digests, and the digest is computed in an unusual way
that has consequences. mr_content_digest_of_arm (serialize.py:965)
RE-SERIALISES the in-memory arm through the ordinary writer into an
in-memory HDF5 image and hashes the resulting group. The alternative, hashing
the dataclass fields, would have been a second definition of "the same arm",
free to drift from the on-disk one. Two things follow:
- A stored field's KEY is part of the arm's identity even when nothing
reads its value. Fields the evaluator never reads still round trip
through the reader and the writer, so they enter the content digest.
Preparing the public copy therefore changed the digest of the arm:
912502ce...internal became419923f3...public for v3, and136981ad...became9509f809...for v2. Both digests of each arm are registered inmodels.MR_CONTENT_MD5_TO_NAME, so an internal pair resolves, a public pair resolves, and a crossed pair is still refused (8 of 8 crossed pairs refused). The provenance attributesmr_content_md5,mr_name,mr_recipe,mr_sourceare excluded (_MR_PROVENANCE_ATTRS,serialize.py:790), which is why correcting the group'smr_nameon the public copy was digest-inert. - A write-side helper runs on the load path. Measured, 86 ms per load
to recompute the digest from the resolved arm, against 180 ms for the whole
load_h5.
Raise versus warn, all three measured:
| case | behaviour |
|---|---|
the arm resolved BY NAME has a different digest (MR_v2 bytes placed under the MR_v3 name in $NRHJ_MODEL_DIR) |
ValueError: merger-ringdown arm mismatch; nobody chose that file |
an explicit merger_ringdown_path="NRHJSur3dq8_MR_v2" on the v3 inspiral |
loads, with a warning naming both digests: a deliberate swap is a sanctioned workflow, and must not be reported as the released composition |
| the arm is absent from every searched directory | FileNotFoundError naming the file, the digest expected and the directories; proven by the deposit gate in an isolated venv, because inside a checkout the model_release/ fallback supplies the arm and the case cannot be reproduced there |
The representation is checked too: a swsph model composes only with a
swsph arm. And the two families are not interchangeable between classes:
NRHJSurAdA.load on an AA file, and NRHJSurAA.load_h5 on an AdA file,
each raise unsupported format_version naming the tag they found. Those tags
are literal strings stored inside the files and one of them still carries a
retired spelling of the project name; they identify the on-disk format, not
the model.
What the files say about themselves
Root attributes worth reading with h5py before trusting a number:
extrapolation = "N2", the strain extrapolation order the model was built on, which the names do not encode. The public copies carry it; the INTERNALmodel_release/NRHJSur3dq8_AA_v3.h5does not, which is whytests/test_model_resolver.py::test_released_h5_records_its_extrapolation_orderfails on a checkout until that artifact is restamped.known_weakness_odd_m_21onAA_v3: the (2, 1) in-sample mismatch is 0.950x the v2 model's, better on only 66 of 242 folds. The sharedMR_v3arm also reads 0.807 on (3, 2) and 0.906 on (4, 4) against its predecessor with the inspiral held fixed (models.py, theRELEASE_ADAcomment). Both are properties of the released composition.mr_arm,mr_name,mr_content_md5,mr_representation: the binding described above. The internalMR_v3group still names itself with the candidate spellingNRHJSur3dq8_MR_v3_cand_tailed_selfsel; the public copy is corrected to the release name.domain_end_M = 124.5on the arm: the reach after the peak.
Limits, next to the capability
- The mass ratio in the name is 1.51e-5 outside the hull. The stored
log-q training bound is 2.0794264247 against
log(8) = 2.0794415417, so the largest trained mass ratio is 7.999879;in_hull(8, 0, 0)isFalseand every error estimate taken there warns that it is an extrapolation. Owner decision 2026-09-02: left as is and documented, here and in model-error-estimate.md. The parity gate keeps (8, -0.5, 0.6) as a point precisely because it exercises the clamped path (scripts/release_eval_parity.py). $LAL_DATA_PATHcan satisfy a missing artifact from outside your deposit. That is the point of it, and it is also how the self-sufficiency gate once passed on a wheel that could not load AdA; the gate now strips it. When you need to know which file served, askmodels.resolve_model(name).- The checkout's
model_release/README.mdandRELEASE_CHECKSUMS.txtare stale: last regenerated 2026-08-23, they still describe v2 as the release and carry no v3 rows, while the deposit metadata says v3 plusMR_v3. The registry inmodels.pyis the authority. dist_models/must be regenerated after ANY change to an internal artifact byscripts/make_public_artifacts.py; the six checksums inmodels.py, the deposit README and the metadata move together.- Nothing on the deposit is a pickle. The default load route is HDF5 and no released file is a pickle.
AA_v1,AdA_v1,MR_v0_9,MR_v1are registered as SUPERSEDED so the version numbers keep meaning something. Several other names in the registry are construction-side files that ship nowhere.
Where the code is
| what | where |
|---|---|
| registry, resolution, fetching, verification | nrhjsurrogate/models.py |
| arm resolution, digest, composition | nrhjsurrogate/serialize.py:965, :1206, :1352 |
| loaders | nrhjsurrogate/driver/aa_api.py:594 (load), :690 (load_h5); driver/api.py:204 |
| one name for the arm argument | nrhjsurrogate/driver/loader_arguments.py |
| public copies | scripts/make_public_artifacts.py -> dist_models/ |
| deposit gate | scripts/deposit_selfsufficiency_gate.py |
| user documentation | docs/USAGE.md section 1; examples/usage/10_finding_the_artifacts.py |